From 9a6a8b14abd13b46e1b5a5861313274e5a3ccb1c Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 1 Sep 2026 20:00:38 -0700 Subject: [PATCH 01/12] fix(execution): keep terminal reconnect off runs a Sim run tool owns (#7382) * fix(execution): keep terminal reconnect off runs a Sim run tool owns The terminal's reconnect effect treated "execution pointer present and the current execution id matches" as an orphaned run and claimed it. A Chat run tool creates exactly that state before the server has acknowledged the run, and opening the workflow tab in Chat re-runs the effect mid-run, so the reconnect GET raced the execute POST's buffer init, got a 404, and logged "Execution state is no longer available after reconnect" as a Run Error on a run that succeeded. It also tore down the live run's store state and cleared the pointer the tool keeps for reload recovery. The run tool now exposes its ownership (isRunToolActiveForWorkflow) and the reconnect effect skips a workflow whose run it owns, leaving the pointer in place. When the tool gives up an interrupted run it notifies subscribeToRunToolRelease subscribers and the hook re-arms its reconnect, so the terminal re-attaches from the last persisted event the way the manual run path already does on interruption. Runs the tool observed to completion never notify, so a failed completion report still leaves the pointer for bindRunToolToExecution to re-report after a reload. Co-Authored-By: Claude Fable 5.1 * fix(execution): classify Chat stream drops and stop async launches writing a terminal pointer Only useExecutionStream.execute wrapped a transport failure as SSEStreamInterruptedError; executeWorkflowWithFullLogging rethrew the raw TypeError, so a mid-run network drop on the Chat run-tool path took the generic branch: the tool reported "error" to Sim, the confirm route marked the row failed, and the pointer was cleared while the server kept running the workflow. The classifier is now one exported helper (toStreamInterruptedError) used by both execute paths and by the shared executor's post-acknowledgement catch, so the run tool reaches its recoverable branch, reports "background", keeps the pointer, and releases the run to the terminal reconnect. Async launches wrote the terminal execution pointer only so bindRunToolToExecution would find something after a reload, but an async run has no reconnectable stream, so any reconnect against that pointer 404'd into the same synthetic Run Error. The tab-local pending completion report already carries the execution id, so async launches no longer touch the pointer and recovery answers from the pending report first, falling back to the pointer only for a live run this tab was observing. The legacy clearExecutionPointerAfterReport flag is still honoured for pointers older clients left behind. Co-Authored-By: Claude Fable 5.1 * test(copilot): type the run-tool execution mocks with the real options contract Greptile flagged the new mock's `options: any`; the sibling abort test had the same shape. Export WorkflowExecutionOptions from the shared executor and use it in both, with a helper that fails the test if the run tool ever stops passing an abort signal. Co-Authored-By: Claude Fable 5.1 * fix(execution): recognise Firefox's NetworkError form as a stream drop The transport-failure matcher only knew Chrome's "network error" with a space, so Firefox's "NetworkError when attempting to fetch resource." fell through as a plain failure. Now that every live stream shares this classifier, match the browsers' known messages as patterns and cover each form in the executor test. Co-Authored-By: Claude Fable 5.1 * fix(execution): keep the stream-error predicates safe for nullish rejections isClientDisconnectError read error.name unguarded, so a stream that rejected with null or undefined would throw inside the catch and mask the original failure. Both predicates now take unknown and bail on non-object values; the executor test covers a nullish body-reader rejection. Co-Authored-By: Claude Fable 5.1 * fix(execution): never classify the stream layer's own errors as transport drops An ExecutionStreamHttpError or SSEEventHandlerError whose message happened to contain a browser transport phrase ("Failed to fetch workflow state") would have been re-wrapped as a stream interruption, losing the HTTP status and taking the recovery path for a run that never started. The predicate now excludes the stream layer's typed errors before looking at message text. Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Claude Fable 5.1 --- .../hooks/use-workflow-execution.test.tsx | 162 ++++++++++++++++- .../hooks/use-workflow-execution.ts | 19 ++ .../utils/workflow-execution-utils.test.ts | 111 +++++++++++- .../utils/workflow-execution-utils.ts | 19 +- apps/sim/hooks/use-execution-stream.ts | 79 ++++++-- .../tools/client/run-tool-execution.test.ts | 169 +++++++++++++++--- .../tools/client/run-tool-execution.ts | 101 ++++++++--- 7 files changed, 596 insertions(+), 64 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx index 110e77c5ac3..3677c7b57f1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx @@ -19,12 +19,15 @@ const { mockFetch, mockHandleExecutionCancelledConsole, mockHandleExecutionErrorConsole, + mockIsExecutionStreamHttpError, + mockIsRunToolActiveForWorkflow, mockLoadExecutionPointer, mockReconnect, mockRequestJson, mockResolveStartCandidates, mockSelectBestTrigger, mockUploadInternalFileSession, + runToolReleaseListeners, terminalStoreState, workflowBlocks, workflowStoreState, @@ -101,12 +104,15 @@ const { mockFetch: vi.fn(), mockHandleExecutionCancelledConsole: vi.fn(), mockHandleExecutionErrorConsole: vi.fn(), + mockIsExecutionStreamHttpError: vi.fn(() => false), + mockIsRunToolActiveForWorkflow: vi.fn(() => false), mockLoadExecutionPointer: vi.fn(), mockReconnect: vi.fn(), mockRequestJson: vi.fn(), mockResolveStartCandidates: vi.fn(), mockSelectBestTrigger: vi.fn(), mockUploadInternalFileSession: vi.fn(), + runToolReleaseListeners: new Set<(workflowId: string) => void>(), terminalStoreState, workflowBlocks, workflowStoreState, @@ -125,6 +131,16 @@ vi.mock('@/lib/api/client/request', () => ({ requestJson: mockRequestJson, })) +vi.mock('@/lib/copilot/tools/client/run-tool-execution', () => ({ + isRunToolActiveForWorkflow: mockIsRunToolActiveForWorkflow, + subscribeToRunToolRelease: (listener: (workflowId: string) => void) => { + runToolReleaseListeners.add(listener) + return () => { + runToolReleaseListeners.delete(listener) + } + }, +})) + vi.mock('@/lib/api/contracts/workflows', () => ({ cancelWorkflowExecutionContract: {}, workflowLogContract: {}, @@ -214,7 +230,7 @@ vi.mock('@/hooks/use-execution-stream', () => { class SSEStreamInterruptedError extends Error {} return { - isExecutionStreamHttpError: () => false, + isExecutionStreamHttpError: mockIsExecutionStreamHttpError, SSEEventHandlerError, SSEStreamInterruptedError, useExecutionStream: () => ({ @@ -419,6 +435,8 @@ function resetWorkflowExecutionTestState() { mockBeginScopedExecution.mockReset().mockReturnValue({}) mockAdoptScopedExecution.mockReset().mockReturnValue(undefined) mockEndScopedExecution.mockReset().mockReturnValue(true) + mockIsExecutionStreamHttpError.mockReset().mockReturnValue(false) + mockIsRunToolActiveForWorkflow.mockReset().mockReturnValue(false) mockLoadExecutionPointer.mockReset().mockResolvedValue(null) mockReconnect.mockReset().mockResolvedValue(undefined) mockResolveStartCandidates.mockReset().mockReturnValue([]) @@ -430,6 +448,36 @@ function resetWorkflowExecutionTestState() { executionStoreState.getWorkflowExecution.mockReturnValue(idleExecution) executionStoreState.getCurrentExecutionId.mockReturnValue(null) workflowStoreState.edges.length = 0 + runToolReleaseListeners.clear() +} + +/** + * The store and pointer state a Sim run tool leaves behind the moment it starts + * a run, before the server has acknowledged it: this is what the reconnect + * flow reads as an orphaned run. + */ +function primeRunToolOwnedExecution() { + terminalStoreState._hasHydrated = true + executionStoreState.getWorkflowExecution.mockReturnValue({ + ...executionStoreState.getWorkflowExecution(), + status: 'running', + isExecuting: true, + currentExecutionId: 'execution-1', + }) + executionStoreState.getCurrentExecutionId.mockReturnValue('execution-1') + mockLoadExecutionPointer.mockResolvedValue({ + workflowId: 'workflow-1', + executionId: 'execution-1', + lastEventId: 0, + }) +} + +/** The reconnect endpoint's answer while the run's buffer does not exist yet. */ +function rejectReconnectWithMissingRunBuffer() { + mockIsExecutionStreamHttpError.mockReturnValue(true) + mockReconnect.mockRejectedValue( + Object.assign(new Error('Reconnect failed (404)'), { httpStatus: 404 }) + ) } describe('useWorkflowExecution lifecycle ownership', () => { @@ -594,6 +642,118 @@ describe('useWorkflowExecution lifecycle ownership', () => { unmount() }) + it('logs a Run Error when a reconnect for an unowned pointer finds no run buffer', async () => { + primeRunToolOwnedExecution() + rejectReconnectWithMissingRunBuffer() + + const { unmount } = renderWorkflowExecutionHook() + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) + + expect(mockReconnect).toHaveBeenCalledTimes(1) + expect(mockHandleExecutionErrorConsole.mock.calls[0]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + executionId: 'execution-1', + error: 'Execution state is no longer available after reconnect', + }), + ]) + ) + expect(executionStoreState.setCurrentExecutionId).toHaveBeenCalledWith('workflow-1', null) + expect(executionStoreState.setIsExecuting).toHaveBeenCalledWith('workflow-1', false) + expect(mockClearExecutionPointer).toHaveBeenCalledWith('workflow-1') + + unmount() + }) + + it('leaves a run owned by a client run tool to its live stream instead of reconnecting', async () => { + /* + * Same state as above, but a Sim run tool in this tab still owns the run. + * Its live stream is the source of truth, so reconnecting here would race + * the run's own start (the 404 above, logged as a Run Error mid-run), tear + * down the live run's store state, and clear the pointer the tool keeps + * for reload recovery. + */ + primeRunToolOwnedExecution() + rejectReconnectWithMissingRunBuffer() + mockIsRunToolActiveForWorkflow.mockReturnValue(true) + + const { unmount } = renderWorkflowExecutionHook() + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) + + expect(mockIsRunToolActiveForWorkflow).toHaveBeenCalledWith('workflow-1') + expect(mockReconnect).not.toHaveBeenCalled() + expect(mockHandleExecutionErrorConsole).not.toHaveBeenCalled() + expect(mockClearExecutionPointer).not.toHaveBeenCalled() + expect(executionStoreState.setCurrentExecutionId).not.toHaveBeenCalled() + expect(executionStoreState.setIsExecuting).not.toHaveBeenCalled() + expect(executionStoreState.setActiveBlocks).not.toHaveBeenCalled() + + unmount() + }) + + it('reconnects once the client run tool releases a run whose stream dropped', async () => { + primeRunToolOwnedExecution() + mockIsRunToolActiveForWorkflow.mockReturnValue(true) + + const { unmount } = renderWorkflowExecutionHook() + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) + expect(mockReconnect).not.toHaveBeenCalled() + expect(runToolReleaseListeners.size).toBeGreaterThan(0) + + /* + * What the run tool leaves behind when it gives the run up: no current + * execution, not executing, ownership released, and the pointer still + * carrying the last event it persisted. + */ + executionStoreState.getWorkflowExecution.mockReturnValue({ + ...executionStoreState.getWorkflowExecution(), + status: 'idle', + isExecuting: false, + currentExecutionId: null, + }) + executionStoreState.getCurrentExecutionId.mockReturnValue(null) + mockLoadExecutionPointer.mockResolvedValue({ + workflowId: 'workflow-1', + executionId: 'execution-1', + lastEventId: 5, + }) + mockIsRunToolActiveForWorkflow.mockReturnValue(false) + await act(async () => { + for (const listener of runToolReleaseListeners) listener('workflow-2') + await Promise.resolve() + await Promise.resolve() + }) + expect(mockReconnect).not.toHaveBeenCalled() + + await act(async () => { + for (const listener of runToolReleaseListeners) listener('workflow-1') + await Promise.resolve() + await Promise.resolve() + }) + + expect(mockReconnect).toHaveBeenCalledTimes(1) + expect(mockReconnect).toHaveBeenCalledWith( + expect.objectContaining({ + workflowId: 'workflow-1', + executionId: 'execution-1', + fromEventId: 5, + }) + ) + expect(mockClearExecutionPointer).not.toHaveBeenCalled() + + unmount() + expect(runToolReleaseListeners.size).toBe(0) + }) + it('does not let delayed debug completion reset a replacement execution', async () => { const debugPersistenceExecution = {} const replacementPersistenceExecution = {} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts index 6fd7b15084b..4b25eb8ac05 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts @@ -21,6 +21,10 @@ import { workflowLogContract, workflowStateSchema, } from '@/lib/api/contracts/workflows' +import { + isRunToolActiveForWorkflow, + subscribeToRunToolRelease, +} from '@/lib/copilot/tools/client/run-tool-execution' import type { SecretSafeBlockLog } from '@/lib/logs/execution/display-types' import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' import { processStreamingBlockLogs } from '@/lib/tokenization' @@ -2386,6 +2390,14 @@ export function useWorkflowExecution() { [activeWorkflowId, setExecutionResult, tryStartExecution] ) + useEffect(() => { + if (!activeWorkflowId) return + return subscribeToRunToolRelease((workflowId) => { + if (workflowId !== activeWorkflowId) return + setReconnectAttemptNonce((nonce) => nonce + 1) + }) + }, [activeWorkflowId]) + useEffect(() => { if (!activeWorkflowId || !hasHydrated) return if (activeReconnections.has(activeWorkflowId)) return @@ -2404,6 +2416,13 @@ export function useWorkflowExecution() { } const runReconnect = async () => { + if (isRunToolActiveForWorkflow(reconnectWorkflowId)) { + logger.info('Reconnection skipped; a client run tool owns this workflow run', { + workflowId: reconnectWorkflowId, + }) + return + } + let executionId: string | undefined let fromEventId = 0 diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.test.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.test.ts index 10db82c7614..5e4e2dbb241 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.test.ts @@ -13,7 +13,11 @@ import { reconcileFinalBlockLogs, } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils' import type { BlockLog } from '@/executor/types' -import type { ExecutionStreamHttpError } from '@/hooks/use-execution-stream' +import { + ExecutionStreamHttpError, + SSEEventHandlerError, + SSEStreamInterruptedError, +} from '@/hooks/use-execution-stream' import { useExecutionStore } from '@/stores/execution' describe('workflow-execution-utils', () => { @@ -61,6 +65,111 @@ describe('workflow-execution-utils', () => { expect(terminalConsoleMockFns.mockAddConsole).not.toHaveBeenCalled() }) + describe('executeWorkflowWithFullLogging stream interruption', () => { + /** A response whose server acknowledged the run and whose body then fails with `readError`. */ + function stubAcknowledgedStream(readError: unknown) { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { get: (name: string) => (name === 'X-Execution-Id' ? 'exec-server' : null) }, + body: { + getReader: () => ({ + read: vi.fn().mockRejectedValue(readError), + releaseLock: vi.fn(), + }), + }, + }) + ) + } + + function stubExecutionStore() { + const store = { + getCurrentExecutionId: vi.fn(() => 'exec-server'), + setActiveBlocks: vi.fn(), + setBlockRunStatus: vi.fn(), + setCurrentExecutionId: vi.fn(), + setEdgeRunStatus: vi.fn(), + setIsExecuting: vi.fn(), + } + vi.mocked(useExecutionStore.getState).mockReturnValue(store as any) + return store + } + + it.each([ + ['Chrome', 'network error'], + ['Chrome before headers', 'Failed to fetch'], + ['Firefox', 'NetworkError when attempting to fetch resource.'], + ['Safari', 'Load failed'], + ])( + 'classifies a %s transport drop after the server acknowledged the run as an interruption', + async (_browser, message) => { + /* + * The Chat run tool only preserves a run for reconnect when it sees + * SSEStreamInterruptedError; a raw TypeError from the body reader used to + * fall through as a plain failure, reporting an error to Sim and tearing + * the run down while the server kept executing it. + */ + const store = stubExecutionStore() + stubAcknowledgedStream(new TypeError(message)) + + const promise = executeWorkflowWithFullLogging({ + workflowId: 'wf-1', + executionId: 'exec-1', + copilotToolCallId: 'tool-1', + preserveExecutionOnTerminal: true, + }) + + await expect(promise).rejects.toBeInstanceOf(SSEStreamInterruptedError) + await expect(promise).rejects.toMatchObject({ executionId: 'exec-server' }) + expect(store.setCurrentExecutionId).toHaveBeenCalledWith('wf-1', 'exec-server') + expect(store.setCurrentExecutionId).not.toHaveBeenCalledWith('wf-1', null) + expect(store.setIsExecuting).not.toHaveBeenCalled() + } + ) + + it.each([ + ['a nullish rejection', null], + ['a client abort', new DOMException('Aborted', 'AbortError')], + [ + 'an HTTP rejection whose message mentions a transport phrase', + new ExecutionStreamHttpError('Failed to fetch workflow state', 500), + ], + [ + 'a handler failure whose message mentions a transport phrase', + new SSEEventHandlerError( + 'network error while persisting console rows', + 'block:completed', + 3, + 'exec-server', + new Error('persist failed') + ), + ], + [ + 'the run tool stop reason, which aborts with a plain string', + 'user_stop:cancelRunToolExecution', + ], + ['a non-transport failure', new Error('Unexpected token in JSON')], + ])('rethrows %s unclassified', async (_label, readError) => { + stubExecutionStore() + stubAcknowledgedStream(readError) + + const rejection = await executeWorkflowWithFullLogging({ + workflowId: 'wf-1', + executionId: 'exec-1', + preserveExecutionOnTerminal: true, + }).then( + () => { + throw new Error('expected the stream failure to reject') + }, + (error: unknown) => error + ) + + expect(rejection).toBe(readError) + }) + }) + describe('createBlockEventHandlers', () => { it('skips duplicate block start rows during reconnect replay', () => { terminalConsoleMockFns.mockAddConsole({ diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts index 70bb9fbde9f..3c67bd35c6a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' +import { getErrorMessage, toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { isPlainRecord } from '@sim/utils/object' import { normalizeWorkflowEdgeSourceHandle } from '@sim/workflow-types/workflow' @@ -19,6 +19,7 @@ import { processSSEStream, SSEEventHandlerError, SSEStreamInterruptedError, + toStreamInterruptedError, } from '@/hooks/use-execution-stream' import { useExecutionStore } from '@/stores/execution' import type { ConsoleEntry, ConsoleUpdate } from '@/stores/terminal' @@ -946,7 +947,7 @@ export function handleExecutionCancelledConsole( addCancelledConsoleEntry(deps.addConsole, params) } -interface WorkflowExecutionOptions { +export interface WorkflowExecutionOptions { workflowId?: string workflowInput?: any onStream?: (se: StreamingExecution) => Promise @@ -1221,6 +1222,20 @@ export async function executeWorkflowWithFullLogging( 'CopilotExecution' ) } catch (error) { + const interrupted = toStreamInterruptedError( + error, + executionIdRef.current, + 'Execution stream interrupted before a terminal event was received' + ) + if (interrupted) { + logger.warn('Execution stream interrupted; preserving execution for reconnect', { + workflowId: wfId, + executionId: executionIdRef.current, + error: getErrorMessage(error), + }) + preserveExecutionForRecovery = true + throw interrupted + } if (error instanceof SSEEventHandlerError || error instanceof SSEStreamInterruptedError) { preserveExecutionForRecovery = true } diff --git a/apps/sim/hooks/use-execution-stream.ts b/apps/sim/hooks/use-execution-stream.ts index fb156950e88..b4bcc38d3b4 100644 --- a/apps/sim/hooks/use-execution-stream.ts +++ b/apps/sim/hooks/use-execution-stream.ts @@ -1,6 +1,7 @@ import { useCallback } from 'react' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import type { WorkflowStateContractInput } from '@/lib/api/contracts/workflows' import { readSSEEvents } from '@/lib/core/utils/sse' import type { @@ -67,18 +68,58 @@ export class SSEStreamInterruptedError extends Error { * Detects errors caused by the browser killing a fetch (page refresh, navigation, tab close). * These should be treated as clean disconnects, not execution errors. */ -function isClientDisconnectError(error: any): boolean { - return error.name === 'AbortError' +function isClientDisconnectError(error: unknown): boolean { + return isRecordLike(error) && error.name === 'AbortError' } -function isRecoverableStreamError(error: any): boolean { - if (isClientDisconnectError(error)) return false - const msg = (error.message ?? '').toLowerCase() +/** + * Messages browsers put on the TypeError a fetch or body read rejects with when + * the connection drops: Chrome's "network error" and "Failed to fetch", + * Firefox's "NetworkError when attempting to fetch resource.", and Safari's + * "Load failed". + */ +const TRANSPORT_FAILURE_MESSAGE_PATTERNS = [ + /network\s?error/, + /failed to fetch/, + /load failed/, +] as const + +/** + * Errors the stream layer raises itself carry their own meaning (an HTTP + * rejection, a handler failure, an already classified drop), so their message + * text must never be mistaken for a transport failure. + */ +function isStreamLayerError(error: unknown): boolean { return ( - msg.includes('network error') || msg.includes('failed to fetch') || msg.includes('load failed') + error instanceof ExecutionStreamHttpError || + error instanceof SSEEventHandlerError || + error instanceof SSEStreamInterruptedError ) } +function isRecoverableStreamError(error: unknown): boolean { + if (!isRecordLike(error) || isClientDisconnectError(error) || isStreamLayerError(error)) { + return false + } + const msg = typeof error.message === 'string' ? error.message.toLowerCase() : '' + return TRANSPORT_FAILURE_MESSAGE_PATTERNS.some((pattern) => pattern.test(msg)) +} + +/** + * Wraps a transport failure that cut a live execution stream before its + * terminal event, so every consumer of a live stream classifies interruptions + * the same way and recovery code can rely on one error type. Returns null for + * client aborts and for anything that is not a transport failure. + */ +export function toStreamInterruptedError( + error: unknown, + executionId: string | undefined, + message: string +): SSEStreamInterruptedError | null { + if (!isRecoverableStreamError(error)) return null + return new SSEStreamInterruptedError(message, executionId, error) +} + /** * Processes SSE events from a response body and invokes appropriate callbacks. * Exported for use by standalone (non-hook) execution paths like executeWorkflowWithFullLogging. @@ -318,16 +359,17 @@ export function useExecutionStream() { logger.info('Execution stream disconnected (page unload or abort)') return } - if (isRecoverableStreamError(error)) { + const interrupted = toStreamInterruptedError( + error, + serverExecutionId, + 'Execution stream interrupted before a terminal event was received' + ) + if (interrupted) { logger.warn('Execution stream interrupted; preserving execution for reconnect', { executionId: serverExecutionId, error: error.message, }) - throw new SSEStreamInterruptedError( - 'Execution stream interrupted before a terminal event was received', - serverExecutionId, - error - ) + throw interrupted } logger.error('Execution stream error:', error) if (!(error instanceof SSEEventHandlerError)) { @@ -423,16 +465,17 @@ export function useExecutionStream() { logger.info('Run-from-block stream disconnected (page unload or abort)') return } - if (isRecoverableStreamError(error)) { + const interrupted = toStreamInterruptedError( + error, + serverExecutionId, + 'Run-from-block stream interrupted before a terminal event was received' + ) + if (interrupted) { logger.warn('Run-from-block stream interrupted; preserving execution for reconnect', { executionId: serverExecutionId, error: error.message, }) - throw new SSEStreamInterruptedError( - 'Run-from-block stream interrupted before a terminal event was received', - serverExecutionId, - error - ) + throw interrupted } logger.error('Run-from-block execution error:', error) if (!(error instanceof SSEEventHandlerError)) { diff --git a/apps/sim/lib/copilot/tools/client/run-tool-execution.test.ts b/apps/sim/lib/copilot/tools/client/run-tool-execution.test.ts index 12fc1b480a0..4df007c34cd 100644 --- a/apps/sim/lib/copilot/tools/client/run-tool-execution.test.ts +++ b/apps/sim/lib/copilot/tools/client/run-tool-execution.test.ts @@ -3,6 +3,7 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { WorkflowExecutionOptions } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils' const { clearExecutionPointer, @@ -66,6 +67,12 @@ vi.mock('@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-u executeWorkflowWithFullLogging, })) +/** The abort signal the run tool wires into every client-side execution. */ +function requireAbortSignal(options: WorkflowExecutionOptions): AbortSignal { + if (!options.abortSignal) throw new Error('run tool did not pass an abort signal') + return options.abortSignal +} + vi.mock('@/stores/execution/store', () => ({ useExecutionStore: { getState: () => ({ @@ -115,7 +122,9 @@ import { cancelRunToolExecution, executeRunToolOnClient, isRunToolActiveForId, + isRunToolActiveForWorkflow, reportManualRunToolStop, + subscribeToRunToolRelease, } from './run-tool-execution' describe('run tool execution cancellation', () => { @@ -130,16 +139,18 @@ describe('run tool execution cancellation', () => { it('passes an abort signal into executeWorkflowWithFullLogging and aborts it', async () => { let capturedSignal: AbortSignal | undefined - executeWorkflowWithFullLogging.mockImplementationOnce(async (options: any) => { - capturedSignal = options.abortSignal - await new Promise((_, reject) => { - options.abortSignal.addEventListener( - 'abort', - () => reject(new DOMException('Aborted', 'AbortError')), - { once: true } - ) - }) - }) + executeWorkflowWithFullLogging.mockImplementationOnce( + async (options: WorkflowExecutionOptions) => { + capturedSignal = requireAbortSignal(options) + await new Promise((_, reject) => { + capturedSignal?.addEventListener( + 'abort', + () => reject(new DOMException('Aborted', 'AbortError')), + { once: true } + ) + }) + } + ) executeRunToolOnClient('tool-1', 'run_workflow', { workflowId: 'wf-1' }) await Promise.resolve() @@ -150,6 +161,96 @@ describe('run tool execution cancellation', () => { expect(capturedSignal?.aborted).toBe(true) }) + it('owns the workflow for exactly as long as the client run is in flight', async () => { + executeWorkflowWithFullLogging.mockImplementationOnce( + async (options: WorkflowExecutionOptions) => { + await new Promise((_, reject) => { + requireAbortSignal(options).addEventListener( + 'abort', + () => reject(new DOMException('Aborted', 'AbortError')), + { once: true } + ) + }) + } + ) + let ownedWhenPointerSaved: boolean | undefined + saveExecutionPointer.mockImplementationOnce(() => { + ownedWhenPointerSaved = isRunToolActiveForWorkflow('wf-1') + }) + expect(isRunToolActiveForWorkflow('wf-1')).toBe(false) + + executeRunToolOnClient('tool-1', 'run_workflow', { workflowId: 'wf-1' }) + await Promise.resolve() + const ownedWhileInFlight = isRunToolActiveForWorkflow('wf-1') + const otherWorkflowOwnedWhileInFlight = isRunToolActiveForWorkflow('wf-2') + + cancelRunToolExecution('wf-1') + await vi.waitFor(() => expect(clearExecutionPointer).toHaveBeenCalledWith('wf-1')) + + expect(ownedWhenPointerSaved).toBe(true) + expect(ownedWhileInFlight).toBe(true) + expect(otherWorkflowOwnedWhileInFlight).toBe(false) + expect(saveExecutionPointer).toHaveBeenCalledWith( + expect.objectContaining({ workflowId: 'wf-1', lastEventId: 0 }) + ) + expect(isRunToolActiveForWorkflow('wf-1')).toBe(false) + }) + + it.each([ + ['handler', new MockSSEEventHandlerError('Block handler failed on event 7', 'exec-1')], + ['transport', new MockSSEStreamInterruptedError('Execution stream interrupted', 'exec-1')], + ])( + 'releases a run whose stream was cut by a %s failure only after giving up ownership', + async (_kind, interruption) => { + const ownedAtRelease: boolean[] = [] + const listener = vi.fn((workflowId: string) => { + ownedAtRelease.push(isRunToolActiveForWorkflow(workflowId)) + }) + const unsubscribe = subscribeToRunToolRelease(listener) + executeWorkflowWithFullLogging.mockRejectedValueOnce(interruption) + + try { + executeRunToolOnClient('tool-1', 'run_workflow', { workflowId: 'wf-1' }) + await vi.waitFor(() => expect(listener).toHaveBeenCalledWith('wf-1')) + + expect(listener).toHaveBeenCalledTimes(1) + expect(ownedAtRelease).toEqual([false]) + expect(setIsExecuting).toHaveBeenCalledWith('wf-1', false) + expect(setCurrentExecutionId).toHaveBeenCalledWith('wf-1', null) + expect(setIsExecuting.mock.invocationCallOrder.at(-1)).toBeLessThan( + listener.mock.invocationCallOrder[0] + ) + expect(clearExecutionPointer).not.toHaveBeenCalled() + expect(fetch).toHaveBeenCalledWith( + '/api/copilot/confirm', + expect.objectContaining({ + body: expect.stringContaining('"status":"background"'), + }) + ) + expect(vi.mocked(fetch).mock.calls[0][1]?.body).toContain('"executionId":"exec-1"') + } finally { + unsubscribe() + } + } + ) + + it('does not release a run it observed to completion, even when the report fails', async () => { + const listener = vi.fn() + const unsubscribe = subscribeToRunToolRelease(listener) + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500 })) + executeWorkflowWithFullLogging.mockResolvedValueOnce({ success: true }) + + try { + executeRunToolOnClient('tool-1', 'run_workflow', { workflowId: 'wf-1' }) + await vi.waitFor(() => expect(isRunToolActiveForWorkflow('wf-1')).toBe(false)) + + expect(listener).not.toHaveBeenCalled() + expect(clearExecutionPointer).not.toHaveBeenCalled() + } finally { + unsubscribe() + } + }) + it('can report a manual stop using the explicit toolCallId override', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true }) vi.stubGlobal('fetch', fetchMock) @@ -252,12 +353,11 @@ describe('run tool execution cancellation', () => { expect(fetchMock.mock.calls[1][0]).toBe('/api/copilot/confirm') expect(fetchMock.mock.calls[1][1]?.body).toContain('"status":"background"') expect(fetchMock.mock.calls[1][1]?.body).toContain('"executionId":"exec-async"') - expect(saveExecutionPointer).toHaveBeenCalledWith({ - workflowId: 'wf-1', - executionId: 'exec-async', - lastEventId: 0, - }) - expect(clearExecutionPointer).toHaveBeenCalledWith('wf-1') + // An async run has no reconnectable stream, so it must never leave the + // terminal a pointer that a reconnect would 404 against. + expect(saveExecutionPointer).not.toHaveBeenCalled() + expect(clearExecutionPointer).not.toHaveBeenCalled() + expect(window.sessionStorage.getItem('sim:copilot:run-tool-completion:tool-async')).toBeNull() }) it('recovers a queued async launch by re-reporting it without enqueueing again', async () => { @@ -283,11 +383,10 @@ describe('run tool execution cancellation', () => { await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(6)) await vi.waitFor(() => expect(isRunToolActiveForId('tool-recover-async')).toBe(false)) - loadExecutionPointer.mockResolvedValueOnce({ - workflowId: 'wf-1', - executionId: 'exec-recover-async', - lastEventId: 0, - }) + expect(saveExecutionPointer).not.toHaveBeenCalled() + expect( + window.sessionStorage.getItem('sim:copilot:run-tool-completion:tool-recover-async') + ).toContain('"executionId":"exec-recover-async"') await expect(bindRunToolToExecution('tool-recover-async', 'wf-1')).resolves.toBe(true) @@ -298,6 +397,34 @@ describe('run tool execution cancellation', () => { expect( fetchMock.mock.calls.filter(([url]) => url === '/api/workflows/wf-1/execute') ).toHaveLength(1) + expect(clearExecutionPointer).not.toHaveBeenCalled() + expect( + window.sessionStorage.getItem('sim:copilot:run-tool-completion:tool-recover-async') + ).toBeNull() + }) + + it('cleans up the terminal pointer an earlier client left for an async launch', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }) + vi.stubGlobal('fetch', fetchMock) + loadExecutionPointer.mockResolvedValueOnce({ + workflowId: 'wf-1', + executionId: 'exec-legacy-async', + lastEventId: 0, + }) + window.sessionStorage.setItem( + 'sim:copilot:run-tool-completion:tool-legacy-async', + JSON.stringify({ + status: 'background', + executionId: 'exec-legacy-async', + clearExecutionPointerAfterReport: true, + }) + ) + + await expect(bindRunToolToExecution('tool-legacy-async', 'wf-1')).resolves.toBe(true) + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock.mock.calls[0][1]?.body).toContain('"status":"background"') + expect(fetchMock.mock.calls[0][1]?.body).toContain('"executionId":"exec-legacy-async"') expect(clearExecutionPointer).toHaveBeenCalledWith('wf-1') }) diff --git a/apps/sim/lib/copilot/tools/client/run-tool-execution.ts b/apps/sim/lib/copilot/tools/client/run-tool-execution.ts index ec341f1491a..99ca035e6c2 100644 --- a/apps/sim/lib/copilot/tools/client/run-tool-execution.ts +++ b/apps/sim/lib/copilot/tools/client/run-tool-execution.ts @@ -45,11 +45,23 @@ const logger = createLogger('CopilotRunToolExecution') const activeRunToolByWorkflowId = new Map() const activeRunAbortByWorkflowId = new Map() const manuallyStoppedToolCallIds = new Set() +type RunToolReleaseListener = (workflowId: string) => void +const runToolReleaseListeners = new Set() const PENDING_COMPLETION_STORAGE_PREFIX = 'sim:copilot:run-tool-completion:' +/** + * Tab-local record of a completion this tab still owes Sim for a tool call, + * written just before the report is sent and cleared once it lands, so a reload + * mid-report can re-send it instead of re-running the tool. + */ interface PendingCompletionReport { status: AsyncConfirmationStatus executionId?: string + /** + * Written by earlier clients for async launches, which also wrote a terminal + * execution pointer for a run that has no reconnectable stream. Honoured so + * that pointer is cleaned up once the pending report is delivered. + */ clearExecutionPointerAfterReport?: boolean } @@ -165,13 +177,7 @@ async function enqueueAsyncWorkflowRun( const pendingCompletion: PendingCompletionReport = { status: ASYNC_TOOL_CONFIRMATION_STATUS.background, executionId: responseExecutionId, - clearExecutionPointerAfterReport: true, } - await saveExecutionPointer({ - workflowId, - executionId: responseExecutionId, - lastEventId: 0, - }) savePendingCompletionReport(toolCallId, pendingCompletion) try { @@ -183,7 +189,6 @@ async function enqueueAsyncWorkflowRun( pendingCompletion.executionId ) clearPendingCompletionReport(toolCallId) - await clearExecutionPointer(workflowId) } catch (error) { logger.error( '[RunTool] Async workflow was queued but background status could not be reported', @@ -249,6 +254,15 @@ function clearPendingCompletionReport(toolCallId: string): void { } } +/** + * Re-binds a tool call that the server still shows as executing to whatever + * this tab already knows about it, instead of running the tool again. + * + * Two tab-local records can answer: a pending completion report (a report this + * tab owed Sim and never delivered) is re-sent as is, and otherwise a terminal + * execution pointer (a live run this tab was observing) is reported as + * continuing in the background. With neither, the caller runs the tool. + */ export async function bindRunToolToExecution( toolCallId: string, workflowId: string @@ -271,21 +285,15 @@ export async function bindRunToolToExecution( } const pointer = await loadExecutionPointer(workflowId).catch(() => null) - if (!pointer?.executionId) { - logger.info('[RunTool] Recovery skipped: no tab-local execution pointer', { + const pendingCompletion = loadPendingCompletionReport(toolCallId) + if (pendingCompletion) { + const executionId = pendingCompletion.executionId ?? pointer?.executionId + logger.info('[RunTool] Recovery re-sending pending completion report', { workflowId, toolCallId, + executionId, + status: pendingCompletion.status, }) - return false - } - - logger.info('[RunTool] Recovery moved to background for existing execution pointer', { - workflowId, - toolCallId, - executionId: pointer.executionId, - }) - const pendingCompletion = loadPendingCompletionReport(toolCallId) - if (pendingCompletion) { try { await reportCompletion( toolCallId, @@ -294,7 +302,7 @@ export async function bindRunToolToExecution( pendingCompletion.status === MothershipStreamV1ToolOutcome.cancelled ? { reason: 'user_cancelled', cancelledByUser: true } : undefined, - pendingCompletion.executionId ?? pointer.executionId + executionId ) clearPendingCompletionReport(toolCallId) if (pendingCompletion.clearExecutionPointerAfterReport) { @@ -304,13 +312,27 @@ export async function bindRunToolToExecution( logger.warn('[RunTool] Failed to report recovered terminal completion', { workflowId, toolCallId, - executionId: pointer.executionId, + executionId, error: toError(error).message, }) } return true } + if (!pointer?.executionId) { + logger.info('[RunTool] Recovery skipped: no tab-local execution pointer', { + workflowId, + toolCallId, + }) + return false + } + + logger.info('[RunTool] Recovery moved to background for existing execution pointer', { + workflowId, + toolCallId, + executionId: pointer.executionId, + }) + try { await reportCompletion( toolCallId, @@ -375,6 +397,38 @@ export function isRunToolActiveForId(toolCallId: string): boolean { return false } +/** + * Whether a client run tool in this tab currently owns the workflow's run. + * + * While it does, its live execute stream is the source of truth for the run and + * for the completion it reports to Sim, so the terminal's reconnect flow must + * not claim the execution pointer the tool writes before the server has + * acknowledged the run. + */ +export function isRunToolActiveForWorkflow(workflowId: string): boolean { + return activeRunToolByWorkflowId.has(workflowId) +} + +/** + * Subscribes to a client run tool releasing a workflow run whose stream dropped + * before the run finished. The run keeps executing server-side and its + * execution pointer is retained, so a subscriber that can re-attach to the + * execution stream should do so once this fires. It does not fire for runs the + * tool observed to completion, even when reporting that completion failed. + */ +export function subscribeToRunToolRelease(listener: RunToolReleaseListener): () => void { + runToolReleaseListeners.add(listener) + return () => { + runToolReleaseListeners.delete(listener) + } +} + +function notifyRunToolReleased(workflowId: string): void { + for (const listener of runToolReleaseListeners) { + listener(workflowId) + } +} + export function cancelRunToolExecution(workflowId: string): void { const controller = activeRunAbortByWorkflowId.get(workflowId) if (!controller) return @@ -566,6 +620,7 @@ async function doExecuteRunTool( }) let leaveExecutionRecoverable = false + let streamInterrupted = false try { const result = await executeWorkflowWithFullLogging({ @@ -649,6 +704,7 @@ async function doExecuteRunTool( const msg = toError(err).message if (err instanceof SSEEventHandlerError || err instanceof SSEStreamInterruptedError) { leaveExecutionRecoverable = true + streamInterrupted = true logger.warn( '[RunTool] Execution stream interrupted; leaving workflow execution in background', { @@ -719,5 +775,8 @@ async function doExecuteRunTool( setIsExecuting(targetWorkflowId, false) setActiveBlocks(targetWorkflowId, new Set()) } + if (streamInterrupted && activeToolCallId === toolCallId) { + notifyRunToolReleased(targetWorkflowId) + } } } From 938a3158924cf2493e483140dfa4c666813b21aa Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos <157128530+BillLeoutsakosvl346@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:22:12 -0700 Subject: [PATCH 02/12] fix(monday): support OAuth 2.1 (#7384) * fix(monday): support OAuth 2.1 * fix(monday): address OAuth review feedback --------- Co-authored-by: Bill Leoutsakos --- apps/sim/app/api/auth/oauth/utils.test.ts | 25 ++- .../lib/auth/connectors/managed-oauth.test.ts | 8 + apps/sim/lib/auth/connectors/managed-oauth.ts | 4 +- apps/sim/lib/auth/connectors/providers.ts | 73 ++++++-- .../sim/lib/credentials/managed-oauth.test.ts | 132 ++++++++++++++- apps/sim/lib/oauth/monday.test.ts | 158 ++++++++++++++++++ apps/sim/lib/oauth/monday.ts | 124 ++++++++++++++ apps/sim/lib/oauth/oauth.test.ts | 137 ++++++++++++++- apps/sim/lib/oauth/oauth.ts | 23 ++- 9 files changed, 660 insertions(+), 24 deletions(-) create mode 100644 apps/sim/lib/oauth/monday.test.ts create mode 100644 apps/sim/lib/oauth/monday.ts diff --git a/apps/sim/app/api/auth/oauth/utils.test.ts b/apps/sim/app/api/auth/oauth/utils.test.ts index 5bcec970c87..70d2ec4e50c 100644 --- a/apps/sim/app/api/auth/oauth/utils.test.ts +++ b/apps/sim/app/api/auth/oauth/utils.test.ts @@ -142,12 +142,18 @@ describe('OAuth Utils', () => { refreshToken: 'new-refresh-token', }) - mockUpdateChain() + const { mockSet } = mockUpdateChain() const result = await refreshTokenIfNeeded('request-id', mockCredential, 'credential-id') expect(mockRefreshOAuthToken).toHaveBeenCalledWith('google', 'refresh-token') - expect(mockDb.update).toHaveBeenCalled() + expect(mockSet).toHaveBeenCalledWith( + expect.objectContaining({ + accessToken: 'new-token', + refreshToken: 'new-refresh-token', + accessTokenExpiresAt: expect.any(Date), + }) + ) expect(result).toEqual({ accessToken: 'new-token', refreshed: true }) }) @@ -185,6 +191,21 @@ describe('OAuth Utils', () => { expect(mockRefreshOAuthToken).not.toHaveBeenCalled() expect(result).toEqual({ accessToken: 'token', refreshed: false }) }) + + it('keeps a legacy non-expiring Monday credential usable without refreshing it', async () => { + const legacyCredential = { + id: 'legacy-monday-credential-id', + accessToken: 'legacy-monday-access-token', + refreshToken: null, + accessTokenExpiresAt: null, + providerId: 'monday', + } + + const result = await refreshTokenIfNeeded('request-id', legacyCredential, legacyCredential.id) + + expect(mockRefreshOAuthToken).not.toHaveBeenCalled() + expect(result).toEqual({ accessToken: 'legacy-monday-access-token', refreshed: false }) + }) }) describe('refreshAccessTokenIfNeeded', () => { diff --git a/apps/sim/lib/auth/connectors/managed-oauth.test.ts b/apps/sim/lib/auth/connectors/managed-oauth.test.ts index 329278549c5..6987f1498cb 100644 --- a/apps/sim/lib/auth/connectors/managed-oauth.test.ts +++ b/apps/sim/lib/auth/connectors/managed-oauth.test.ts @@ -296,6 +296,14 @@ describe('userinfo-backed managed OAuth connectors', () => { } ) + it('requires PKCE and refresh-token persistence for Monday OAuth 2.1', () => { + expect(policyFor('monday')).toMatchObject({ + pkce: true, + requiresRefreshToken: true, + nonceVerification: 'state_only', + }) + }) + it.each(['linear', 'monday'])( 'treats a partial %s GraphQL response as no identity at all', async (providerId) => { diff --git a/apps/sim/lib/auth/connectors/managed-oauth.ts b/apps/sim/lib/auth/connectors/managed-oauth.ts index 0c6410aa710..d43d9cf56e3 100644 --- a/apps/sim/lib/auth/connectors/managed-oauth.ts +++ b/apps/sim/lib/auth/connectors/managed-oauth.ts @@ -798,8 +798,8 @@ const USER_INFO_MANAGED_OAUTH_CONNECTORS = new Map ManagedOAuthCon () => createUserInfoManagedOAuthConnector({ providerId: 'monday', - /** monday.com access tokens do not expire and no refresh token is issued. */ - requiresRefreshToken: false, + requiresRefreshToken: true, + pkce: true, scopes: { from: 'token_response' }, userInfo: { url: MONDAY_API_URL, diff --git a/apps/sim/lib/auth/connectors/providers.ts b/apps/sim/lib/auth/connectors/providers.ts index 435285d952d..314e71b0631 100644 --- a/apps/sim/lib/auth/connectors/providers.ts +++ b/apps/sim/lib/auth/connectors/providers.ts @@ -10,6 +10,7 @@ import { syntheticConnectorEmail } from '@/lib/auth/connector-email' import { env } from '@/lib/core/config/env' import { inspectConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server' import { + DEFAULT_MAX_ERROR_BODY_BYTES, readResponseJsonWithLimit, readResponseTextWithLimit, } from '@/lib/core/utils/stream-limits' @@ -22,6 +23,11 @@ import { getBoundMicrosoftDataverseEnvironment, resolveMicrosoftDataverseOAuthCallbackScopes, } from '@/lib/oauth/microsoft-dataverse' +import { + exchangeMondayAuthorizationCode, + MONDAY_OAUTH_AUTHORIZATION_URL, + MONDAY_OAUTH_TOKEN_URL, +} from '@/lib/oauth/monday' import { SALESFORCE_LOGIN_HOSTS } from '@/lib/oauth/salesforce' import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' import { MONDAY_API_URL, MONDAY_API_VERSION } from '@/tools/monday/utils' @@ -86,6 +92,17 @@ interface AttioWorkspaceMemberResponse { } } +interface MondayUserInfoResponse { + data?: { + me?: { + id?: string | number + name?: string | null + email?: string | null + } | null + } + errors?: unknown[] +} + /** * Shape of `GET https://api.bitbucket.org/2.0/user` for the authenticated user. * @see https://developer.atlassian.com/cloud/bitbucket/rest/api-group-users/#api-user-get @@ -1729,15 +1746,29 @@ export function buildConnectorProviders(): GenericOAuthConfig[] { providerId: 'monday', clientId: env.MONDAY_CLIENT_ID as string, clientSecret: env.MONDAY_CLIENT_SECRET as string, - authorizationUrl: 'https://auth.monday.com/oauth2/authorize', - tokenUrl: 'https://auth.monday.com/oauth2/token', + authorizationUrl: MONDAY_OAUTH_AUTHORIZATION_URL, + tokenUrl: MONDAY_OAUTH_TOKEN_URL, userInfoUrl: 'https://api.monday.com/v2', scopes: getCanonicalScopesForProvider('monday'), responseType: 'code', - pkce: false, + pkce: true, + authentication: 'post', redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/monday`, + getToken: async ({ code, codeVerifier, redirectURI }) => { + if (!codeVerifier) { + throw new Error('Monday OAuth token exchange requires a PKCE verifier') + } + return exchangeMondayAuthorizationCode({ + clientId: env.MONDAY_CLIENT_ID as string, + clientSecret: env.MONDAY_CLIENT_SECRET as string, + code, + codeVerifier, + redirectUri: redirectURI, + }) + }, getUserInfo: async (tokens) => { try { + const signal = AbortSignal.timeout(15_000) const response = await fetch(MONDAY_API_URL, { method: 'POST', headers: { @@ -1746,10 +1777,15 @@ export function buildConnectorProviders(): GenericOAuthConfig[] { Authorization: tokens.accessToken ?? '', }, body: JSON.stringify({ query: '{ me { id name email } }' }), + signal, }) if (!response.ok) { - await response.text().catch(() => {}) + await readResponseTextWithLimit(response, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'Monday OAuth user info error response', + signal, + }).catch(() => {}) logger.error('Error fetching Monday.com user info:', { status: response.status, statusText: response.statusText, @@ -1757,16 +1793,33 @@ export function buildConnectorProviders(): GenericOAuthConfig[] { return null } - const data = await response.json() + const data = await readResponseJsonWithLimit(response, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'Monday OAuth user info response', + signal, + }) + if (data.errors?.length) { + logger.error('Monday.com user info returned GraphQL errors', { + errorCount: data.errors.length, + }) + return null + } const user = data.data?.me - if (!user) return null + const userId = + typeof user?.id === 'string' || typeof user?.id === 'number' + ? String(user.id) + : undefined + if (!user || !userId) return null + + const email = typeof user.email === 'string' ? user.email : undefined + const name = typeof user.name === 'string' ? user.name : undefined const now = new Date() return { - id: `${user.id.toString()}-${generateId()}`, - name: user.name || 'Monday.com User', - email: user.email || syntheticConnectorEmail('monday', user.id), - emailVerified: !!user.email, + id: `${userId}-${generateId()}`, + name: name || 'Monday.com User', + email: email || syntheticConnectorEmail('monday', userId), + emailVerified: !!email, createdAt: now, updatedAt: now, } diff --git a/apps/sim/lib/credentials/managed-oauth.test.ts b/apps/sim/lib/credentials/managed-oauth.test.ts index 1df5b3543e7..cc62461af31 100644 --- a/apps/sim/lib/credentials/managed-oauth.test.ts +++ b/apps/sim/lib/credentials/managed-oauth.test.ts @@ -2,13 +2,14 @@ * @vitest-environment node */ import { dbChainMockFns, resetDbChainMock } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ getBilling: vi.fn(), isAvailable: vi.fn(), getAdapter: vi.fn(), decryptSecret: vi.fn(), + encryptSecret: vi.fn(), })) vi.mock('@/lib/billing/core/workspace-access', () => ({ @@ -25,15 +26,44 @@ vi.mock('@/lib/credential-groups/provider-registry', () => ({ vi.mock('@/lib/core/security/encryption', () => ({ decryptSecret: mocks.decryptSecret, - encryptSecret: vi.fn(), + encryptSecret: mocks.encryptSecret, })) import { resolveManagedOAuthToken } from '@/lib/credentials/managed-oauth' +function mondayCredentialRow() { + return { + id: 'credential-1', + workspaceId: 'workspace-1', + type: 'managed_oauth', + providerId: 'monday', + authorizationAppId: 'monday:monday-client-1', + managedOauthScopeVersion: 1, + managedOauthStatus: 'active', + grantedScopes: ['boards:read', 'me:read'], + encryptedOauthTokenSet: 'encrypted-token-set', + accessTokenExpiresAt: new Date('2026-09-01T11:00:00.000Z'), + refreshTokenExpiresAt: null, + credentialGroupId: 'group-1', + credentialGroupEnrollmentId: 'enrollment-1', + } +} + +function mondayTokenResolutionParams() { + return { + credentialId: 'credential-1', + workspaceId: 'workspace-1', + expectedProviderId: 'monday', + requiredScopes: ['boards:read', 'me:read'], + } +} + describe('managed OAuth token resolution', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-09-01T12:00:00.000Z')) mocks.getBilling.mockResolvedValue({ plan: 'enterprise' }) mocks.isAvailable.mockResolvedValue(true) mocks.decryptSecret.mockResolvedValue({ @@ -53,6 +83,10 @@ describe('managed OAuth token resolution', () => { }) }) + afterEach(() => { + vi.useRealTimers() + }) + it('uses a non-expiring Slack access token without entering refresh', async () => { dbChainMockFns.limit.mockResolvedValueOnce([ { @@ -80,4 +114,98 @@ describe('managed OAuth token resolution', () => { ).resolves.toEqual({ accessToken: 'xoxp-slack-token', refreshed: false }) expect(dbChainMockFns.transaction).not.toHaveBeenCalled() }) + + it('refreshes an expired Monday credential and persists its rotated token set', async () => { + const row = mondayCredentialRow() + dbChainMockFns.limit.mockResolvedValueOnce([row]).mockResolvedValueOnce([row]) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: row.id }]) + mocks.decryptSecret.mockResolvedValue({ + decrypted: JSON.stringify({ + type: 'managed-oauth-token-set', + version: 1, + tokenType: 'Bearer', + accessToken: 'expired-access-token', + refreshToken: 'old-refresh-token', + }), + }) + mocks.encryptSecret.mockResolvedValue({ encrypted: 'encrypted-rotated-token-set' }) + const refreshToken = vi.fn().mockResolvedValue({ + ok: true, + accessToken: 'new-access-token', + refreshToken: 'rotated-refresh-token', + expiresIn: 3600, + }) + mocks.getAdapter.mockReturnValue({ + getPolicy: vi.fn().mockResolvedValue({ + authorizationAppId: row.authorizationAppId, + scopeVersion: 1, + }), + hasRequiredScopes: vi.fn().mockReturnValue(true), + refreshToken, + isTerminalRefreshError: vi.fn().mockReturnValue(false), + }) + + await expect(resolveManagedOAuthToken(mondayTokenResolutionParams())).resolves.toEqual({ + accessToken: 'new-access-token', + refreshed: true, + }) + + expect(refreshToken).toHaveBeenCalledWith('old-refresh-token') + const [serializedTokenSet] = mocks.encryptSecret.mock.calls[0] as [string] + expect(JSON.parse(serializedTokenSet)).toEqual({ + type: 'managed-oauth-token-set', + version: 1, + tokenType: 'Bearer', + accessToken: 'new-access-token', + refreshToken: 'rotated-refresh-token', + }) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + encryptedOauthTokenSet: 'encrypted-rotated-token-set', + accessTokenExpiresAt: new Date('2026-09-01T13:00:00.000Z'), + lastRefreshedAt: new Date('2026-09-01T12:00:00.000Z'), + }) + ) + }) + + it('marks an expired Monday credential for reauthorization after a terminal refresh error', async () => { + const row = mondayCredentialRow() + dbChainMockFns.limit.mockResolvedValueOnce([row]).mockResolvedValueOnce([row]) + mocks.decryptSecret.mockResolvedValue({ + decrypted: JSON.stringify({ + type: 'managed-oauth-token-set', + version: 1, + tokenType: 'Bearer', + accessToken: 'expired-access-token', + refreshToken: 'old-refresh-token', + }), + }) + const refreshToken = vi.fn().mockResolvedValue({ + ok: false, + errorCode: 'invalid_grant', + message: 'Refresh token rejected', + }) + const isTerminalRefreshError = vi.fn().mockReturnValue(true) + mocks.getAdapter.mockReturnValue({ + getPolicy: vi.fn().mockResolvedValue({ + authorizationAppId: row.authorizationAppId, + scopeVersion: 1, + }), + hasRequiredScopes: vi.fn().mockReturnValue(true), + refreshToken, + isTerminalRefreshError, + }) + + await expect(resolveManagedOAuthToken(mondayTokenResolutionParams())).rejects.toMatchObject({ + code: 'MANAGED_CREDENTIAL_NEEDS_REAUTH', + statusCode: 401, + }) + + expect(refreshToken).toHaveBeenCalledWith('old-refresh-token') + expect(isTerminalRefreshError).toHaveBeenCalledWith('invalid_grant') + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ managedOauthStatus: 'needs_reauth' }) + ) + expect(mocks.encryptSecret).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/lib/oauth/monday.test.ts b/apps/sim/lib/oauth/monday.test.ts new file mode 100644 index 00000000000..bf12e745e4f --- /dev/null +++ b/apps/sim/lib/oauth/monday.test.ts @@ -0,0 +1,158 @@ +/** + * @vitest-environment node + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + exchangeMondayAuthorizationCode, + MONDAY_OAUTH_TOKEN_URL, + resolveMondayAccessTokenExpiresAt, +} from '@/lib/oauth/monday' + +const SCOPES = [ + 'boards:read', + 'boards:write', + 'updates:read', + 'updates:write', + 'webhooks:read', + 'webhooks:write', + 'me:read', +] + +function unsignedJwt(payload: Record): string { + const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url') + const body = Buffer.from(JSON.stringify(payload)).toString('base64url') + return `${header}.${body}.signature` +} + +function tokenResponse(overrides: Record = {}): Response { + return new Response( + JSON.stringify({ + access_token: unsignedJwt({ exp: Math.floor(Date.now() / 1000) + 3600 }), + refresh_token: 'monday-refresh-token', + token_type: 'Bearer', + scope: SCOPES.join(' '), + ...overrides, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) +} + +describe('Monday OAuth 2.1', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('exchanges a PKCE authorization code at the v2 endpoint', async () => { + const fetchMock = vi.fn().mockResolvedValue(tokenResponse()) + vi.stubGlobal('fetch', fetchMock) + + const tokens = await exchangeMondayAuthorizationCode({ + clientId: 'monday-client-id', + clientSecret: 'monday-client-secret', + code: 'authorization-code', + codeVerifier: 'pkce-verifier', + redirectUri: 'https://www.sim.ai/api/auth/oauth2/callback/monday', + }) + + expect(tokens).toMatchObject({ + refreshToken: 'monday-refresh-token', + tokenType: 'Bearer', + scopes: SCOPES, + }) + expect(tokens.accessTokenExpiresAt).toBeInstanceOf(Date) + + const [endpoint, request] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(endpoint).toBe(MONDAY_OAUTH_TOKEN_URL) + expect(request).toMatchObject({ + method: 'POST', + redirect: 'error', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }) + expect(JSON.parse(request.body as string)).toEqual({ + grant_type: 'authorization_code', + client_id: 'monday-client-id', + client_secret: 'monday-client-secret', + code: 'authorization-code', + redirect_uri: 'https://www.sim.ai/api/auth/oauth2/callback/monday', + code_verifier: 'pkce-verifier', + }) + }) + + it('uses the access-token JWT expiration before response and fallback lifetimes', () => { + const now = new Date('2026-09-01T12:00:00.000Z') + const jwtExpirySeconds = Math.floor(now.getTime() / 1000) + 2700 + const expiresAt = resolveMondayAccessTokenExpiresAt( + unsignedJwt({ exp: jwtExpirySeconds }), + 1800, + now + ) + + expect(expiresAt).toEqual(new Date(jwtExpirySeconds * 1000)) + }) + + it('preserves an expired JWT expiration so the credential refreshes immediately', () => { + const now = new Date('2026-09-01T12:00:00.000Z') + const jwtExpirySeconds = Math.floor(now.getTime() / 1000) - 60 + + expect( + resolveMondayAccessTokenExpiresAt(unsignedJwt({ exp: jwtExpirySeconds }), 3600, now) + ).toEqual(new Date(jwtExpirySeconds * 1000)) + }) + + it('falls back to expires_in and then one hour for an opaque access token', () => { + const now = new Date('2026-09-01T12:00:00.000Z') + + expect(resolveMondayAccessTokenExpiresAt('opaque-token', 1200, now)).toEqual( + new Date('2026-09-01T12:20:00.000Z') + ) + expect(resolveMondayAccessTokenExpiresAt('opaque-token', undefined, now)).toEqual( + new Date('2026-09-01T13:00:00.000Z') + ) + }) + + it.each([ + ['missing refresh token', { refresh_token: undefined }], + ['missing access token', { access_token: undefined }], + ['non-bearer token', { token_type: 'mac' }], + ])('rejects an incomplete response: %s', async (_label, overrides) => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(tokenResponse(overrides))) + + await expect( + exchangeMondayAuthorizationCode({ + clientId: 'client-id', + clientSecret: 'client-secret', + code: 'authorization-code', + codeVerifier: 'pkce-verifier', + redirectUri: 'https://www.sim.ai/api/auth/oauth2/callback/monday', + }) + ).rejects.toThrow('Monday OAuth token response was incomplete') + }) + + it('does not expose a provider error response or request secrets', async () => { + const providerSecret = 'provider-secret-that-must-not-escape' + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: providerSecret }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }) + ) + ) + + const error = await exchangeMondayAuthorizationCode({ + clientId: 'client-id', + clientSecret: 'client-secret-that-must-not-escape', + code: 'authorization-code-that-must-not-escape', + codeVerifier: 'pkce-verifier-that-must-not-escape', + redirectUri: 'https://www.sim.ai/api/auth/oauth2/callback/monday', + }).catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(Error) + expect((error as Error).message).toBe('Monday OAuth token exchange failed with HTTP 400') + expect((error as Error).message).not.toContain(providerSecret) + }) +}) diff --git a/apps/sim/lib/oauth/monday.ts b/apps/sim/lib/oauth/monday.ts new file mode 100644 index 00000000000..ace87d76433 --- /dev/null +++ b/apps/sim/lib/oauth/monday.ts @@ -0,0 +1,124 @@ +import type { OAuth2Tokens } from 'better-auth/oauth2' +import { decodeJwt } from 'jose' +import { z } from 'zod' +import { + DEFAULT_MAX_ERROR_BODY_BYTES, + readResponseJsonWithLimit, + readResponseTextWithLimit, +} from '@/lib/core/utils/stream-limits' + +export const MONDAY_OAUTH_AUTHORIZATION_URL = 'https://auth.monday.com/oauth2/authorize' +export const MONDAY_OAUTH_TOKEN_URL = 'https://auth.monday.com/oauth_ms/oauth/token' + +const MONDAY_OAUTH_TOKEN_TIMEOUT_MS = 15_000 +const MONDAY_ACCESS_TOKEN_FALLBACK_LIFETIME_SECONDS = 60 * 60 +const MONDAY_ACCESS_TOKEN_MAX_RESPONSE_LIFETIME_SECONDS = 24 * 60 * 60 + +const mondayOAuthTokenResponseSchema = z.object({ + access_token: z.string().min(1), + refresh_token: z.string().min(1), + token_type: z.string().min(1), + expires_in: z.union([z.number(), z.string()]).optional(), + scope: z.string().optional(), +}) + +interface ExchangeMondayAuthorizationCodeParams { + clientId: string + clientSecret: string + code: string + codeVerifier: string + redirectUri: string +} + +function parsePositiveLifetimeSeconds(value: unknown): number | undefined { + const parsed = typeof value === 'number' || typeof value === 'string' ? Number(value) : Number.NaN + return Number.isFinite(parsed) && + parsed > 0 && + parsed <= MONDAY_ACCESS_TOKEN_MAX_RESPONSE_LIFETIME_SECONDS + ? parsed + : undefined +} + +/** + * Resolves monday.com's access-token expiry for storage and refresh scheduling. + * + * OAuth 2.1 access tokens are JWTs and monday.com documents the `exp` claim as + * authoritative. The response lifetime and one-hour documented default keep + * credentials refreshable if a deployment temporarily receives an opaque token. + */ +export function resolveMondayAccessTokenExpiresAt( + accessToken: string, + expiresIn?: unknown, + now = new Date() +): Date { + try { + const { exp } = decodeJwt(accessToken) + if (typeof exp === 'number' && Number.isFinite(exp)) { + const expiresAt = new Date(exp * 1000) + if (!Number.isNaN(expiresAt.getTime())) return expiresAt + } + } catch {} + + const lifetimeSeconds = + parsePositiveLifetimeSeconds(expiresIn) ?? MONDAY_ACCESS_TOKEN_FALLBACK_LIFETIME_SECONDS + return new Date(now.getTime() + lifetimeSeconds * 1000) +} + +/** Exchanges a monday.com OAuth 2.1 authorization code without exposing token material. */ +export async function exchangeMondayAuthorizationCode({ + clientId, + clientSecret, + code, + codeVerifier, + redirectUri, +}: ExchangeMondayAuthorizationCodeParams): Promise { + const signal = AbortSignal.timeout(MONDAY_OAUTH_TOKEN_TIMEOUT_MS) + const response = await fetch(MONDAY_OAUTH_TOKEN_URL, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + grant_type: 'authorization_code', + client_id: clientId, + client_secret: clientSecret, + code, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + }), + redirect: 'error', + signal, + }) + if (!response.ok) { + await readResponseTextWithLimit(response, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'Monday OAuth token error response', + signal, + }).catch(() => {}) + throw new Error(`Monday OAuth token exchange failed with HTTP ${response.status}`) + } + + const payload = await readResponseJsonWithLimit(response, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'Monday OAuth token response', + signal, + }) + + const parsed = mondayOAuthTokenResponseSchema.safeParse(payload) + if (!parsed.success || parsed.data.token_type.toLowerCase() !== 'bearer') { + throw new Error('Monday OAuth token response was incomplete') + } + + const scopes = parsed.data.scope?.split(/\s+/).filter(Boolean) + return { + accessToken: parsed.data.access_token, + refreshToken: parsed.data.refresh_token, + tokenType: parsed.data.token_type, + accessTokenExpiresAt: resolveMondayAccessTokenExpiresAt( + parsed.data.access_token, + parsed.data.expires_in + ), + ...(scopes ? { scopes } : {}), + } +} diff --git a/apps/sim/lib/oauth/oauth.test.ts b/apps/sim/lib/oauth/oauth.test.ts index 3aadd1da130..d5411450953 100644 --- a/apps/sim/lib/oauth/oauth.test.ts +++ b/apps/sim/lib/oauth/oauth.test.ts @@ -1,5 +1,5 @@ import { createMockFetch, resetEnvMock, setEnv } from '@sim/testing' -import { getOAuth2Tokens } from 'better-auth/oauth2' +import { createAuthorizationURL, getOAuth2Tokens } from 'better-auth/oauth2' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' beforeAll(() => { @@ -50,7 +50,7 @@ beforeAll(() => { SALESFORCE_CLIENT_ID: 'salesforce_client_id', SALESFORCE_CLIENT_SECRET: 'salesforce_client_secret', ZOHO_CLIENT_ID: 'zoho_client_id', - ZOHO_CLIENT_SECRET: 'zoho_client_secret', + ZOHO_CLIENT_SECRET: undefined, SHOPIFY_CLIENT_ID: 'shopify_client_id', SHOPIFY_CLIENT_SECRET: 'shopify_client_secret', ZOOM_CLIENT_ID: 'zoom_client_id', @@ -61,7 +61,7 @@ beforeAll(() => { SPOTIFY_CLIENT_SECRET: 'spotify_client_secret', CALCOM_CLIENT_ID: 'calcom_client_id', MONDAY_CLIENT_ID: 'monday_client_id', - MONDAY_CLIENT_SECRET: undefined, + MONDAY_CLIENT_SECRET: 'monday_client_secret', }) }) @@ -93,6 +93,12 @@ const defaultOAuthResponse = { }, } +function oauthTestJwt(payload: Record): string { + const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url') + const body = Buffer.from(JSON.stringify(payload)).toString('base64url') + return `${header}.${body}.signature` +} + /** * Helper to run a function with a mocked global fetch. */ @@ -146,6 +152,74 @@ describe('Atlassian OAuth connectors', () => { ) }) +function getMondayConnector() { + const connector = buildConnectorProviders().find((candidate) => candidate.providerId === 'monday') + if (!connector) throw new Error('Monday OAuth connector is not configured in this test') + return connector +} + +describe('Monday OAuth connector', () => { + it('generates the OAuth 2.1 authorization request from the connector contract', async () => { + const connector = getMondayConnector() + expect(connector).toMatchObject({ + providerId: 'monday', + authorizationUrl: 'https://auth.monday.com/oauth2/authorize', + tokenUrl: 'https://auth.monday.com/oauth_ms/oauth/token', + scopes: [ + 'boards:read', + 'boards:write', + 'updates:read', + 'updates:write', + 'webhooks:read', + 'webhooks:write', + 'me:read', + ], + responseType: 'code', + pkce: true, + authentication: 'post', + redirectURI: 'http://localhost:3000/api/auth/oauth2/callback/monday', + }) + const authorizationUrl = await createAuthorizationURL({ + id: connector.providerId, + options: { + clientId: connector.clientId, + clientSecret: connector.clientSecret, + redirectURI: connector.redirectURI, + }, + authorizationEndpoint: connector.authorizationUrl!, + state: 'state-1', + codeVerifier: 'a'.repeat(128), + scopes: connector.scopes, + redirectURI: connector.redirectURI!, + responseType: connector.responseType, + }) + + expect(authorizationUrl.searchParams.get('redirect_uri')).toBe( + 'http://localhost:3000/api/auth/oauth2/callback/monday' + ) + expect(authorizationUrl.searchParams.get('scope')).toBe(connector.scopes?.join(' ')) + expect(authorizationUrl.searchParams.get('code_challenge_method')).toBe('S256') + expect(authorizationUrl.searchParams.get('code_challenge')).toBeTruthy() + }) + + it('rejects GraphQL errors returned with HTTP 200 during user-info lookup', async () => { + const getUserInfo = getMondayConnector().getUserInfo + if (!getUserInfo) throw new Error('Monday OAuth connector must define getUserInfo') + + const userInfo = await withMockFetch( + createMockFetch({ + json: { + data: { me: { id: 'user-1', name: 'Person', email: 'person@example.com' } }, + errors: [{ message: 'Permission denied' }], + }, + }), + () => getUserInfo({ accessToken: 'access-token' }) + ) + + expect(userInfo).toBeNull() + }) +}) + describe('Microsoft Dataverse OAuth connector', () => { it('keeps static connector scopes empty and supplies the canonical legacy grant per request', () => { const connector = buildConnectorProviders().find( @@ -645,13 +719,13 @@ describe('OAuth Token Refresh', () => { const mockFetch = createMockFetch(defaultOAuthResponse) const result = await withMockFetch(mockFetch, () => - refreshOAuthToken('monday', 'test_refresh_token') + refreshOAuthToken('zoho-desk', 'test_refresh_token') ) expect(result).toEqual({ ok: false, message: - 'OAuth client monday is partially configured — missing MONDAY_CLIENT_SECRET. Run npx sim-setup add integration monday.', + 'OAuth client zoho-desk is partially configured — missing ZOHO_CLIENT_SECRET. Run npx sim-setup add integration zoho-desk.', }) expect(mockFetch).not.toHaveBeenCalled() }) @@ -827,6 +901,59 @@ describe('OAuth Token Refresh', () => { }) }) + it.concurrent('refreshes Monday with JSON body credentials and rotates its token', async () => { + const expiresAtSeconds = Math.floor(Date.now() / 1000) + 2700 + const mockFetch = createMockFetch({ + json: { + access_token: oauthTestJwt({ exp: expiresAtSeconds }), + refresh_token: 'rotated-monday-refresh-token', + token_type: 'Bearer', + scope: 'boards:read me:read', + }, + }) + + const result = await withMockFetch(mockFetch, () => + refreshOAuthToken('monday', 'old-monday-refresh-token') + ) + + expect(result).toMatchObject({ + ok: true, + refreshToken: 'rotated-monday-refresh-token', + }) + if (result.ok) { + expect(result.expiresIn).toBeGreaterThanOrEqual(2699) + expect(result.expiresIn).toBeLessThanOrEqual(2700) + } + + const [endpoint, request] = mockFetch.mock.calls[0] as [string, RequestInit] + expect(endpoint).toBe('https://auth.monday.com/oauth_ms/oauth/token') + expect(request.headers).toMatchObject({ 'Content-Type': 'application/json' }) + expect(JSON.parse(request.body as string)).toEqual({ + grant_type: 'refresh_token', + refresh_token: 'old-monday-refresh-token', + client_id: 'monday_client_id', + client_secret: 'monday_client_secret', + }) + }) + + it.concurrent('rejects a Monday refresh response that omits token rotation', async () => { + const mockFetch = createMockFetch({ + json: { + access_token: oauthTestJwt({ exp: Math.floor(Date.now() / 1000) + 3600 }), + token_type: 'Bearer', + }, + }) + + const result = await withMockFetch(mockFetch, () => + refreshOAuthToken('monday', 'old-monday-refresh-token') + ) + + expect(result).toEqual({ + ok: false, + message: 'Invalid Monday token refresh response', + }) + }) + it.concurrent('should return Bitbucket rotating refresh tokens', async () => { const mockFetch = createMockFetch({ json: { diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index a8d07519aaa..b77cc07bd95 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -78,6 +78,7 @@ import { } from '@/lib/core/utils/stream-limits' import { getDocusignOAuthUrl } from '@/lib/oauth/docusign' import { parseInstagramLongLivedToken } from '@/lib/oauth/instagram' +import { MONDAY_OAUTH_TOKEN_URL, resolveMondayAccessTokenExpiresAt } from '@/lib/oauth/monday' import { SALESFORCE_ADDITIONAL_PROVIDER_IDS, SALESFORCE_LOGIN_HOSTS, @@ -1891,11 +1892,12 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig { 'MONDAY_CLIENT_SECRET' ) return { - tokenEndpoint: 'https://auth.monday.com/oauth2/token', + tokenEndpoint: MONDAY_OAUTH_TOKEN_URL, clientId, clientSecret, useBasicAuth: false, - supportsRefreshTokenRotation: false, + useJsonBody: true, + supportsRefreshTokenRotation: true, } } case 'zoho-desk': { @@ -2206,14 +2208,29 @@ export async function refreshOAuthToken( newRefreshToken = data.refresh_token logger.info(`Received new refresh token from ${provider}`) } + if (provider === 'monday' && !newRefreshToken) { + logger.warn('Monday token refresh response omitted its rotating refresh token') + return { ok: false, message: 'Invalid Monday token refresh response' } + } const rawExpiresIn = data.expires_in ?? data.expiresIn const parsedExpiresIn = typeof rawExpiresIn === 'number' || typeof rawExpiresIn === 'string' ? Number(rawExpiresIn) : Number.NaN + const responseExpiresIn = + Number.isFinite(parsedExpiresIn) && parsedExpiresIn > 0 ? parsedExpiresIn : undefined const expiresIn = - Number.isFinite(parsedExpiresIn) && parsedExpiresIn > 0 ? parsedExpiresIn : 3600 + provider === 'monday' && accessToken + ? Math.max( + 1, + Math.ceil( + (resolveMondayAccessTokenExpiresAt(accessToken, responseExpiresIn).getTime() - + Date.now()) / + 1000 + ) + ) + : (responseExpiresIn ?? 3600) if (!accessToken) { // Log only the shape, never `data` itself - on a partial success it can From 47c08056019cd85fd359c21ce0e23bb8bc28a80c Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 1 Sep 2026 20:22:46 -0700 Subject: [PATCH 03/12] improvement(docs): streamline API reference navigation (#7383) --- apps/docs/app/openapi.json/route.ts | 11 ++ .../docs-layout/sidebar-components.tsx | 15 +- .../docs/api-reference/getting-started.mdx | 6 +- apps/docs/lib/openapi-download.test.ts | 71 ++++++++ apps/docs/lib/openapi-download.ts | 166 ++++++++++++++++++ 5 files changed, 256 insertions(+), 13 deletions(-) create mode 100644 apps/docs/app/openapi.json/route.ts create mode 100644 apps/docs/lib/openapi-download.test.ts create mode 100644 apps/docs/lib/openapi-download.ts diff --git a/apps/docs/app/openapi.json/route.ts b/apps/docs/app/openapi.json/route.ts new file mode 100644 index 00000000000..b668de90a90 --- /dev/null +++ b/apps/docs/app/openapi.json/route.ts @@ -0,0 +1,11 @@ +import { createOpenApiDownloadDocument } from '@/lib/openapi-download' + +export const revalidate = false + +export function GET() { + return Response.json(createOpenApiDownloadDocument(), { + headers: { + 'Content-Disposition': 'attachment; filename="sim-openapi-v2.json"', + }, + }) +} diff --git a/apps/docs/components/docs-layout/sidebar-components.tsx b/apps/docs/components/docs-layout/sidebar-components.tsx index a899be3a730..2e4112f69f4 100644 --- a/apps/docs/components/docs-layout/sidebar-components.tsx +++ b/apps/docs/components/docs-layout/sidebar-components.tsx @@ -69,23 +69,12 @@ export function SidebarItem({ item }: { item: Item }) { ) } -function isApiReferenceFolder(node: Folder): boolean { - if (node.index?.url.includes('/api-reference/')) return true - for (const child of node.children) { - if (child.type === 'page' && child.url.includes('/api-reference/')) return true - if (child.type === 'folder' && isApiReferenceFolder(child)) return true - } - return false -} - export function SidebarFolder({ item, children }: { item: Folder; children: ReactNode }) { const pathname = usePathname() const { prefetch } = useSidebar() const hasActiveChild = checkHasActiveChild(item, pathname) - const isApiRef = isApiReferenceFolder(item) - const isOnApiRefPage = pathname.startsWith('/api-reference') const hasChildren = item.children.length > 0 - const defaultOpen = hasActiveChild || (isApiRef && isOnApiRefPage) + const defaultOpen = hasActiveChild const [manualOpen, setManualOpen] = useState<{ pathname: string; open: boolean } | null>(null) const open = manualOpen?.pathname === pathname ? manualOpen.open : defaultOpen const toggleOpen = () => setManualOpen({ pathname, open: !open }) @@ -131,6 +120,7 @@ export function SidebarFolder({ item, children }: { item: Folder; children: Reac chipHoverSurfaceClass )} aria-label={open ? 'Collapse' : 'Expand'} + aria-expanded={open} > @@ -139,6 +129,7 @@ export function SidebarFolder({ item, children }: { item: Folder; children: Reac ) : ( ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/hooks/use-skill-auto-mention.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/hooks/use-skill-auto-mention.ts index 6aa5006ba54..2ce6adcf83e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/hooks/use-skill-auto-mention.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/hooks/use-skill-auto-mention.ts @@ -89,7 +89,12 @@ export function useSkillAutoMention({ for (const server of mcpServers) { const key = server.name.toLowerCase() if (!byName.has(key)) { - byName.set(key, { kind: 'mcp', serverId: server.id, label: server.name }) + byName.set(key, { + kind: 'mcp', + serverId: server.id, + label: server.name, + ...(server.managedConnectorId ? { managedConnectorId: server.managedConnectorId } : {}), + }) } } const names = [...byName.values()] diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 8a9a6399493..6114e8057bd 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -3876,7 +3876,12 @@ export function useChat( ...('folderId' in c && c.folderId ? { folderId: c.folderId } : {}), ...(c.kind === 'skill' && 'skillId' in c ? { skillId: c.skillId } : {}), ...(c.kind === 'integration' && 'blockType' in c ? { blockType: c.blockType } : {}), - ...(c.kind === 'mcp' && 'serverId' in c ? { serverId: c.serverId } : {}), + ...(c.kind === 'mcp' && 'serverId' in c + ? { + serverId: c.serverId, + ...(c.managedConnectorId ? { managedConnectorId: c.managedConnectorId } : {}), + } + : {}), ...(c.kind === 'file_selection' ? { fileName: c.fileName, diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts index 9634055b5e5..983cc4eba67 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts @@ -1,3 +1,4 @@ +import type { ManagedMcpConnectorId } from '@/lib/credential-groups/managed-mcp-connectors' import type { ChatContext } from '@/stores/panel' import type { BrowserTextSelection, TerminalTextSelection } from '@/stores/panel/types' @@ -152,6 +153,7 @@ export interface ChatMessageContext { blockType?: string skillId?: string serverId?: string + managedConnectorId?: ManagedMcpConnectorId /** Selected passage for a `file_selection` context. */ text?: string /** Source file name for a `file_selection` context. */ diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.test.ts b/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.test.ts index e8cee8a1a54..daf315dc569 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.test.ts @@ -78,6 +78,7 @@ describe('credential-groups prefetch', () => { name: 'Engineering', description: null, options: [], + mcpServers: [], status: 'active', createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx index 55678e16cbc..29aff5fb184 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx @@ -11,6 +11,7 @@ import { McpIcon } from '@/components/icons' import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation' import { requestJson } from '@/lib/api/client/request' import { getWorkflowStateContract } from '@/lib/api/contracts/workflows' +import { getManagedMcpConnectorIcon } from '@/lib/credential-groups/managed-mcp-connector-icons' import { getIssueBadgeLabel, getIssueBadgeVariant, @@ -35,6 +36,7 @@ import { import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { useMcpOauthPopup } from '@/hooks/mcp/use-mcp-oauth-popup' +import { useCredentialGroups } from '@/hooks/queries/credential-groups' import { type McpServer, type McpTool, @@ -75,6 +77,7 @@ interface ServerListItemProps { isLoadingTools?: boolean isRefreshing?: boolean discoveryError?: string | null + ownerName?: string onViewDetails: () => void onAuthorize: () => void } @@ -87,10 +90,14 @@ function ServerListItem({ isLoadingTools = false, isRefreshing = false, discoveryError = null, + ownerName, onViewDetails, onAuthorize, }: ServerListItemProps) { const transportLabel = formatTransportLabel(server.transport || 'http') + const ServerIcon = server.managedConnectorId + ? getManagedMcpConnectorIcon(server.managedConnectorId) + : McpIcon const toolsLabel = getServerToolsLabel( tools, server.connectionStatus, @@ -113,20 +120,22 @@ function ServerListItem({ const serverName = server.name || 'Unnamed server' // Transport rides on the description rather than beside the name — inside the // row's truncating title a long name would clip it away entirely. - const statusText = isConnecting - ? 'Waiting for authorization...' - : isRefreshing - ? 'Refreshing...' - : isLoadingTools && tools.length === 0 - ? 'Loading...' - : showDiscoveryError - ? discoveryError - : toolsLabel + const statusText = server.managedConnectorId + ? `Managed by ${ownerName ?? 'a Credential Group'}` + : isConnecting + ? 'Waiting for authorization...' + : isRefreshing + ? 'Refreshing...' + : isLoadingTools && tools.length === 0 + ? 'Loading...' + : showDiscoveryError + ? discoveryError + : toolsLabel return ( } - iconFilled + icon={} + iconFilled={!server.managedConnectorId} title={serverName} description={ <> @@ -145,7 +154,10 @@ function ServerListItem({ clickLabel={`Open ${serverName}`} navigable trailing={ - canManage && server.authType === 'oauth' && server.connectionStatus !== 'connected' ? ( + canManage && + !server.managedConnectorId && + server.authType === 'oauth' && + server.connectionStatus !== 'connected' ? ( {isConnecting ? 'Reopen authorization' : 'Authorize'} ) : undefined } @@ -194,6 +206,9 @@ export function MCP() { isLoading: serversLoading, error: serversError, } = useMcpServers(workspaceId) + const credentialGroups = useCredentialGroups( + workspacePermissions.canAdmin ? workspaceId : undefined + ) const { data: mcpToolsData = [], toolsStateByServer } = useMcpToolsQuery(workspaceId) const { data: storedTools = [], refetch: refetchStoredTools } = useStoredMcpTools(workspaceId, { enabled: selectedServerId !== null, @@ -276,6 +291,9 @@ export function MCP() { const filteredServers = (servers || []).filter((server) => server.name?.toLowerCase().includes(searchTerm.toLowerCase()) ) + const credentialGroupNameById = new Map( + credentialGroups.data?.credentialGroups.map((group) => [group.id, group.name] as const) ?? [] + ) const handleViewDetails = (serverId: string) => { setSelectedServerId(serverId) @@ -440,7 +458,7 @@ export function MCP() { back={{ text: 'MCP tools', icon: ArrowLeft, onSelect: handleBackToList }} title={server.name || 'Unnamed server'} actions={ - canEdit + canEdit && !server.managedConnectorId ? [ { text: refreshAction.text, @@ -474,6 +492,14 @@ export function MCP() { )} + {server.managedConnectorId && ( + + {server.credentialGroupId + ? (credentialGroupNameById.get(server.credentialGroupId) ?? 'Credential Group') + : 'Credential Group'} + + )} + {server.connectionStatus !== 'connected' && (

@@ -487,20 +513,23 @@ export function MCP() { )} - {canEdit && server.authType === 'oauth' && server.connectionStatus !== 'connected' && ( - -

- { - await startOauthForServer(server.id) - }} - > - {connectingOauthServers.has(server.id) ? 'Reopen authorization' : 'Authorize'} - -
-
- )} + {canEdit && + !server.managedConnectorId && + server.authType === 'oauth' && + server.connectionStatus !== 'connected' && ( + +
+ { + await startOauthForServer(server.id) + }} + > + {connectingOauthServers.has(server.id) ? 'Reopen authorization' : 'Authorize'} + +
+
+ )} @@ -702,6 +731,11 @@ export function MCP() { key={server.id} canManage={canEdit} server={server} + ownerName={ + server.credentialGroupId + ? credentialGroupNameById.get(server.credentialGroupId) + : undefined + } tools={tools} isConnecting={connectingOauthServers.has(server.id)} isLoadingTools={isLoadingTools} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/mcp-server-modal/mcp-server-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/mcp-server-modal/mcp-server-selector.tsx index ac65ed2eb68..8ebf1754340 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/mcp-server-modal/mcp-server-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/mcp-server-modal/mcp-server-selector.tsx @@ -3,12 +3,14 @@ import { useEffect, useMemo, useState } from 'react' import { Combobox } from '@sim/emcn' import { useParams } from 'next/navigation' +import { McpIcon } from '@/components/icons' +import { getManagedMcpConnectorIcon } from '@/lib/credential-groups/managed-mcp-connector-icons' import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text' import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider' import type { SubBlockConfig } from '@/blocks/types' -import { useMcpServers } from '@/hooks/queries/mcp' +import { useMcpToolServers } from '@/hooks/queries/mcp' interface McpServerSelectorProps { blockId: string @@ -30,7 +32,7 @@ export function McpServerSelector({ const workspaceId = params.workspaceId as string const [inputValue, setInputValue] = useState('') - const { data: servers = [], isLoading, error } = useMcpServers(workspaceId) + const { data: servers = [], isLoading, error } = useMcpToolServers(workspaceId) const enabledServers = servers.filter((s) => s.enabled && !s.deletedAt) const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id) @@ -47,6 +49,9 @@ export function McpServerSelector({ enabledServers.map((server) => ({ label: server.name, value: server.id, + icon: server.managedConnectorId + ? getManagedMcpConnectorIcon(server.managedConnectorId) + : McpIcon, })), [enabledServers] ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx index 6a78d2ccf23..496958e1d22 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx @@ -16,6 +16,8 @@ import { ArrowLeft, ChevronRight, Server, Wrench, X } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { useParams } from 'next/navigation' import { McpIcon, WorkflowIcon } from '@/components/icons' +import { getManagedMcpConnectorIcon } from '@/lib/credential-groups/managed-mcp-connector-icons' +import { MCP_SERVER_ADVANCED_TOOL_TYPE } from '@/lib/mcp/shared' import { getIssueBadgeLabel, getIssueBadgeVariant, @@ -69,7 +71,7 @@ import { useAllowedMcpDomains, useCreateMcpServer, useForceRefreshMcpTools, - useMcpServers, + useMcpToolServers, useStoredMcpTools, } from '@/hooks/queries/mcp' import { useWorkflows } from '@/hooks/queries/workflows' @@ -104,6 +106,17 @@ import { const logger = createLogger('ToolInput') +const ADVANCED_MCP_SERVER_TOOL_SCHEMA: McpToolSchema = { + type: 'object', + properties: { + serverId: { + type: 'string', + description: 'Canonical workspace MCP server ID', + }, + }, + required: ['serverId'], +} + function WorkflowToolDeployBadge({ workflowId, onDeploySuccess, @@ -455,7 +468,7 @@ export const ToolInput = memo(function ToolInput({ return names }, [mcpTools]) - const { data: mcpServers = [], isLoading: mcpServersLoading } = useMcpServers(workspaceId) + const { data: mcpServers = [], isLoading: mcpServersLoading } = useMcpToolServers(workspaceId) const { data: storedMcpTools = [] } = useStoredMcpTools(workspaceId) const forceRefreshMcpTools = useForceRefreshMcpTools().mutate const { navigateToSettings } = useSettingsNavigation() @@ -472,7 +485,10 @@ export const ToolInput = memo(function ToolInput({ ) const hasRefreshedRef = useRef(false) - const hasMcpTools = selectedTools.some((tool) => tool.type === 'mcp') + const hasMcpTools = selectedTools.some( + (tool) => tool.type === 'mcp' || tool.type === MCP_SERVER_ADVANCED_TOOL_TYPE + ) + const supportsAdvancedMcpServer = blockType === 'agent' || blockType === 'mothership' useEffect(() => { if (isPreview) return @@ -1108,46 +1124,75 @@ export const ToolInput = memo(function ToolInput({ mcpServerDrilldown && !permissionConfig.disableMcpTools && !mcpUnsupported && - mcpToolsByServer.size > 0 + mcpServers.some((server) => server.id === mcpServerDrilldown) ) { - const tools = mcpToolsByServer.get(mcpServerDrilldown) - if (tools && tools.length > 0) { - const server = mcpServers.find((s) => s.id === mcpServerDrilldown) - const serverName = tools[0]?.serverName || server?.name || 'Unknown Server' - const toolCount = tools.length - const selectedToolIdsForServer = new Set( - selectedTools - .filter((t) => t.type === 'mcp' && t.params?.serverId === mcpServerDrilldown) - .map((t) => t.toolId) - ) - const allAlreadySelected = tools.every((t) => selectedToolIdsForServer.has(t.id)) - const serverToolItems: ComboboxOption[] = [] + const tools = mcpToolsByServer.get(mcpServerDrilldown) ?? [] + const server = mcpServers.find((candidate) => candidate.id === mcpServerDrilldown) + const ServerIcon = server?.managedConnectorId + ? getManagedMcpConnectorIcon(server.managedConnectorId) + : Server + const serverName = tools[0]?.serverName || server?.name || 'Unknown Server' + const allAlreadySelected = selectedTools.some( + (tool) => + tool.type === MCP_SERVER_ADVANCED_TOOL_TYPE && + tool.params?.serverId === mcpServerDrilldown + ) + const serverToolItems: ComboboxOption[] = [] - // Back navigation + serverToolItems.push({ + label: 'Back', + value: `mcp-server-back`, + iconElement: , + onSelect: () => { + setMcpServerDrilldown(null) + }, + keepOpen: true, + }) + + if (supportsAdvancedMcpServer) { serverToolItems.push({ - label: 'Back', - value: `mcp-server-back`, - iconElement: , + label: 'Use all available tools', + value: `mcp-server-all-${mcpServerDrilldown}`, + iconElement: createToolIcon('var(--brand-agent)', ServerIcon), onSelect: () => { + if (allAlreadySelected) return + const filteredTools = selectedTools.filter( + (tool) => + !( + (tool.type === 'mcp' || tool.type === MCP_SERVER_ADVANCED_TOOL_TYPE) && + tool.params?.serverId === mcpServerDrilldown + ) + ) + const serverBinding: StoredTool = { + type: MCP_SERVER_ADVANCED_TOOL_TYPE, + params: { serverId: mcpServerDrilldown }, + isExpanded: false, + usageControl: 'auto', + } + const nextTools = [ + ...filteredTools.map((tool) => ({ ...tool, isExpanded: false })), + serverBinding, + ] + reindexCanonicalModesOnMutate(selectedTools, filteredTools) + setStoreValue(nextTools) setMcpServerDrilldown(null) + setOpen(false) }, - keepOpen: true, + disabled: isPreview || disabled || allAlreadySelected, }) + } - // "Use all tools" option — adds each tool individually + for (const mcpTool of tools) { + const alreadySelected = + allAlreadySelected || isMcpToolAlreadySelected(selectedTools, mcpTool.id) serverToolItems.push({ - label: `Use all ${toolCount} tools`, - value: `mcp-server-all-${mcpServerDrilldown}`, - iconElement: createToolIcon('#6366F1', Server), + label: mcpTool.name, + value: `mcp-${mcpTool.id}`, + iconElement: createToolIcon(mcpTool.bgColor || '#6366F1', mcpTool.icon || McpIcon), onSelect: () => { - if (allAlreadySelected) return - // Remove existing individual tools from this server to avoid duplicates - const filteredTools = selectedTools.filter( - (t) => !(t.type === 'mcp' && t.params?.serverId === mcpServerDrilldown) - ) - // Add all tools individually - const newTools: StoredTool[] = tools.map((mcpTool) => ({ - type: 'mcp' as const, + if (alreadySelected) return + const newTool: StoredTool = { + type: 'mcp', title: mcpTool.name, toolId: mcpTool.id, params: { @@ -1156,61 +1201,23 @@ export const ToolInput = memo(function ToolInput({ toolName: mcpTool.name, serverName: mcpTool.serverName, }, - isExpanded: false, - usageControl: 'auto' as const, + isExpanded: true, + usageControl: 'auto', schema: { ...mcpTool.inputSchema, description: mcpTool.description, }, - })) - // Diff against `filteredTools` (pre-spread, same refs as `selectedTools`) - the - // spread copy below preserves the same relative order, so this correctly reflects - // each surviving tool's new position. - reindexCanonicalModesOnMutate(selectedTools, filteredTools) - setStoreValue([...filteredTools.map((t) => ({ ...t, isExpanded: false })), ...newTools]) - setMcpServerDrilldown(null) - setOpen(false) + } + handleMcpToolSelect(newTool, true) }, - disabled: isPreview || disabled || allAlreadySelected, - }) - - // Individual tools - for (const mcpTool of tools) { - const alreadySelected = isMcpToolAlreadySelected(selectedTools, mcpTool.id) - serverToolItems.push({ - label: mcpTool.name, - value: `mcp-${mcpTool.id}`, - iconElement: createToolIcon(mcpTool.bgColor || '#6366F1', mcpTool.icon || McpIcon), - onSelect: () => { - if (alreadySelected) return - const newTool: StoredTool = { - type: 'mcp', - title: mcpTool.name, - toolId: mcpTool.id, - params: { - serverId: mcpTool.serverId, - ...(server?.url && { serverUrl: server.url }), - toolName: mcpTool.name, - serverName: mcpTool.serverName, - }, - isExpanded: true, - usageControl: 'auto', - schema: { - ...mcpTool.inputSchema, - description: mcpTool.description, - }, - } - handleMcpToolSelect(newTool, true) - }, - disabled: isPreview || disabled || alreadySelected, - }) - } - - groups.push({ - section: serverName, - items: serverToolItems, + disabled: isPreview || disabled || alreadySelected, }) } + + groups.push({ + section: serverName, + items: serverToolItems, + }) return groups } @@ -1245,6 +1252,29 @@ export const ToolInput = memo(function ToolInput({ ) : undefined, }) + if (supportsAdvancedMcpServer) { + actionItems.push({ + label: 'MCP Server (Advanced)', + value: 'action-mcp-server-advanced', + icon: Server, + onSelect: () => { + setStoreValue([ + ...selectedTools.map((tool) => ({ ...tool, isExpanded: false })), + { + type: MCP_SERVER_ADVANCED_TOOL_TYPE, + params: { serverId: '' }, + isExpanded: true, + usageControl: 'auto', + }, + ]) + setOpen(false) + }, + disabled: isPreview || disabled || mcpUnsupported, + suffixElement: mcpUnsupported ? ( + + ) : undefined, + }) + } } if (actionItems.length > 0) { groups.push({ items: actionItems }) @@ -1280,18 +1310,23 @@ export const ToolInput = memo(function ToolInput({ } // MCP Servers — root folder view - if (!permissionConfig.disableMcpTools && !mcpUnsupported && mcpToolsByServer.size > 0) { + if (!permissionConfig.disableMcpTools && !mcpUnsupported && mcpServers.length > 0) { const serverItems: ComboboxOption[] = [] - for (const [serverId, tools] of mcpToolsByServer) { - const server = mcpServers.find((s) => s.id === serverId) + for (const server of mcpServers) { + if (!server.enabled) continue + const serverId = server.id + const tools = mcpToolsByServer.get(serverId) ?? [] const serverName = tools[0]?.serverName || server?.name || 'Unknown Server' const toolCount = tools.length + const ServerIcon = server.managedConnectorId + ? getManagedMcpConnectorIcon(server.managedConnectorId) + : Server serverItems.push({ label: `${serverName} (${toolCount} tools)`, value: `mcp-server-folder-${serverId}`, - iconElement: createToolIcon('#6366F1', Server), + iconElement: createToolIcon('#6366F1', ServerIcon), suffixElement: , onSelect: () => { setMcpServerDrilldown(serverId) @@ -1396,6 +1431,7 @@ export const ToolInput = memo(function ToolInput({ permissionConfig.disableMcpTools, mcpUnsupported, customUnsupported, + supportsAdvancedMcpServer, availableWorkflows, isToolAlreadySelected, reindexCanonicalModesOnMutate, @@ -1424,16 +1460,18 @@ export const ToolInput = memo(function ToolInput({ selectedTools.map((tool, toolIndex) => { const isCustomTool = tool.type === 'custom-tool' const isMcpTool = tool.type === 'mcp' + const isAdvancedMcpServer = tool.type === MCP_SERVER_ADVANCED_TOOL_TYPE + const isMcpFamily = isMcpTool || isAdvancedMcpServer const isWorkflowTool = tool.type === 'workflow' // Fall back to the unfiltered registry so chips for types hidden // from the picker (permissions, hideFromToolbar) keep their chrome. const toolBlock = - !isCustomTool && !isMcpTool + !isCustomTool && !isMcpFamily ? (toolBlocks.find((block) => block.type === tool.type) ?? getBlock(tool.type)) : null const currentToolId = - !isCustomTool && !isMcpTool + !isCustomTool && !isMcpFamily ? getToolIdForOperation(tool.type, tool.operation, toolBlock ?? undefined) || tool.toolId || '' @@ -1446,7 +1484,7 @@ export const ToolInput = memo(function ToolInput({ ) const subBlocksResult: SubBlocksForToolInput | null = - !isCustomTool && !isMcpTool && currentToolId + !isCustomTool && !isMcpFamily && currentToolId ? getSubBlocksForToolInput( currentToolId, tool.type, @@ -1465,12 +1503,23 @@ export const ToolInput = memo(function ToolInput({ : null const mcpTool = isMcpTool ? mcpTools.find((t) => t.id === tool.toolId) : null + const advancedMcpServer = isAdvancedMcpServer + ? mcpServers.find((server) => server.id === tool.params?.serverId) + : undefined + const McpFamilyIcon = mcpTool?.icon + ? mcpTool.icon + : advancedMcpServer?.managedConnectorId + ? getManagedMcpConnectorIcon(advancedMcpServer.managedConnectorId) + : McpIcon + const mcpTileColor = mcpTool?.bgColor || 'var(--brand-agent)' const mcpToolSchema = isMcpTool ? tool.schema || mcpTool?.inputSchema : null // Canonical name wins; stored title only when nothing resolves // (same policy as the canvas summary — see resolveStoredToolName). - const toolDisplayName = - resolveStoredToolName(tool, { customTools, mcpToolNamesById }) ?? 'Unknown Tool' + const toolDisplayName = isAdvancedMcpServer + ? (mcpServers.find((server) => server.id === tool.params?.serverId)?.name ?? + 'MCP Server (Advanced)') + : (resolveStoredToolName(tool, { customTools, mcpToolNamesById }) ?? 'Unknown Tool') /** * Every field this tool row renders, as `SubBlockConfig`s. A registry tool's @@ -1478,8 +1527,13 @@ export const ToolInput = memo(function ToolInput({ * declared type); an MCP tool's are derived from its JSON Schema. Both then * render through the one canonical sub-block renderer. */ - const displaySubBlocks: BlockSubBlockConfig[] = isMcpTool - ? buildSubBlocksFromJsonSchema(mcpToolSchema ?? undefined, formatParameterLabel) + const displaySubBlocks: BlockSubBlockConfig[] = isMcpFamily + ? buildSubBlocksFromJsonSchema( + isAdvancedMcpServer + ? ADVANCED_MCP_SERVER_TOOL_SCHEMA + : (mcpToolSchema ?? undefined), + formatParameterLabel + ) : (subBlocksResult?.subBlocks ?? []).filter( (sb) => !sb.reactiveCondition || @@ -1487,7 +1541,7 @@ export const ToolInput = memo(function ToolInput({ ) const hasOperations = - !isCustomTool && !isMcpTool && hasMultipleOperations(toolBlock ?? undefined) + !isCustomTool && !isMcpFamily && hasMultipleOperations(toolBlock ?? undefined) const hasToolBody = hasOperations || displaySubBlocks.length > 0 const isSearchExpanded = @@ -1547,8 +1601,8 @@ export const ToolInput = memo(function ToolInput({ style={{ backgroundColor: isCustomTool ? '#3B82F6' - : isMcpTool - ? mcpTool?.bgColor || '#6366F1' + : isMcpFamily + ? mcpTileColor : isWorkflowTool ? '#6366F1' : toolBlock?.bgColor, @@ -1556,13 +1610,10 @@ export const ToolInput = memo(function ToolInput({ > {isCustomTool ? ( - ) : isMcpTool ? ( + ) : isMcpFamily ? ( ) : isWorkflowTool ? ( { if (subBlock?.type !== 'mcp-server-selector' || typeof rawValue !== 'string') { return null diff --git a/apps/sim/blocks/blocks/credential-group.ts b/apps/sim/blocks/blocks/credential-group.ts index 46a9c28f042..18cc264fb12 100644 --- a/apps/sim/blocks/blocks/credential-group.ts +++ b/apps/sim/blocks/blocks/credential-group.ts @@ -64,6 +64,14 @@ interface CredentialGroupBlockOutput { providerSubjectId: string providerTenantId: string | null }> + mcpConnections: Array<{ + credentialId: string + email: string + displayName: string + mcpServerId: string + mcpServerName: string + toolNames: string[] + }> credentialGroups: Array<{ id: string name: string @@ -94,21 +102,32 @@ interface CredentialGroupBlockOutput { } const INVITE_OPERATIONS = ['send_invite', 'get_invite_link'] as const -const GROUP_OPERATIONS = ['list_credentials', ...INVITE_OPERATIONS, 'list_people'] as const -const LIST_OPERATIONS = ['list_credentials', 'list_people', 'list_groups'] as const +const GROUP_OPERATIONS = [ + 'list_credentials', + 'list_mcp_connections', + ...INVITE_OPERATIONS, + 'list_people', +] as const +const LIST_OPERATIONS = [ + 'list_credentials', + 'list_mcp_connections', + 'list_people', + 'list_groups', +] as const export const CredentialGroupBlock: BlockConfig = { type: 'credential_group', name: 'Credential Groups', - description: 'Invite people and use credentials collected by Credential Groups', + description: 'Invite people and use credentials or MCP connections from Credential Groups', longDescription: - 'List usable managed credentials, inspect invited people, send or generate an account-connection invitation, or discover Credential Groups in the current workspace. The block returns credential IDs and account metadata without exposing OAuth tokens.', + 'List usable managed credentials or MCP connections, inspect invited people, send or generate an account-connection invitation, or discover Credential Groups in the current workspace. The block returns credential IDs and account metadata without exposing OAuth tokens.', bestPractices: ` - "List Credentials" returns every active credential. Filter by email to select one enrolled person, by provider to select one account type, or by both for an exact match. - Provider blocks can use the current actor's enrolled credential by default. Using another enrollment requires an explicit workflow access grant. - With a workflow access grant, use "List Credentials" with a ForEach loop to run a provider block once for every connected account. - Continue with nextCursor until hasMore is false when a list operation returns multiple pages. - "List Credentials" returns active, usable credentials only. Reconnect-needed and revoked credentials are excluded. + - "List MCP Connections" returns explicit managed MCP credential IDs and tool names. Pass one credentialId to an advanced MCP server tool. - Use "List People" to inspect invitation and connection progress without exposing credential secrets. - "Send Invite" sends one email. Use a loop when invitations should come from a dynamic list. - "Get Invite Link" issues a fresh seven-day bearer link without sending email. It invalidates the previous link for that email, so treat the output as a secret. @@ -130,6 +149,16 @@ export const CredentialGroupBlock: BlockConfig = { { text: ', from', field: ['providerFilter', 'manualProviderIds'] }, { text: ', up to', field: 'limit', after: 'credentials' }, ], + list_mcp_connections: [ + { + text: 'List MCP connections from', + field: ['credentialGroup', 'manualCredentialGroup'], + core: true, + }, + { text: ', for', field: 'email' }, + { text: ', on server', field: 'mcpServerId' }, + { text: ', up to', field: 'limit', after: 'connections' }, + ], send_invite: [ { text: 'Invite', field: 'email', core: true }, { @@ -167,6 +196,7 @@ export const CredentialGroupBlock: BlockConfig = { type: 'dropdown', options: [ { label: 'List Credentials', id: 'list_credentials' }, + { label: 'List MCP Connections', id: 'list_mcp_connections' }, { label: 'Send Invite', id: 'send_invite' }, { label: 'Get Invite Link', id: 'get_invite_link' }, { label: 'List People', id: 'list_people' }, @@ -226,6 +256,15 @@ export const CredentialGroupBlock: BlockConfig = { placeholder: '["google-email", "slack"] — leave empty for all providers', condition: { field: 'operation', value: 'list_credentials' }, }, + { + id: 'mcpServerId', + title: 'MCP Server ID', + type: 'short-input', + required: false, + mode: 'advanced', + placeholder: 'mcp-... — leave empty for all MCP servers', + condition: { field: 'operation', value: 'list_mcp_connections' }, + }, { id: 'peopleStatuses', title: 'Status', @@ -265,7 +304,7 @@ export const CredentialGroupBlock: BlockConfig = { operation: { type: 'string', description: - "'list_credentials', 'send_invite', 'get_invite_link', 'list_people', or 'list_groups'", + "'list_credentials', 'list_mcp_connections', 'send_invite', 'get_invite_link', 'list_people', or 'list_groups'", }, credentialGroupId: { type: 'string', description: 'Credential Group ID' }, email: { @@ -276,6 +315,10 @@ export const CredentialGroupBlock: BlockConfig = { type: 'json', description: 'Optional OAuth provider IDs to include when listing credentials', }, + mcpServerId: { + type: 'string', + description: 'Optional root MCP server ID to include when listing MCP connections', + }, peopleStatuses: { type: 'json', description: 'Optional invitation statuses to include when listing people', @@ -290,6 +333,12 @@ export const CredentialGroupBlock: BlockConfig = { 'Usable credential references (credentialId, email, displayName, providerId, providerSubjectId, providerTenantId)', condition: { field: 'operation', value: 'list_credentials' }, }, + mcpConnections: { + type: 'json', + description: + 'Usable MCP connection references (credentialId, email, displayName, mcpServerId, mcpServerName, toolNames)', + condition: { field: 'operation', value: 'list_mcp_connections' }, + }, credentialGroups: { type: 'json', description: diff --git a/apps/sim/ee/credential-groups/components/credential-group-access.tsx b/apps/sim/ee/credential-groups/components/credential-group-access.tsx index 5d061f2ac4c..915171b5b86 100644 --- a/apps/sim/ee/credential-groups/components/credential-group-access.tsx +++ b/apps/sim/ee/credential-groups/components/credential-group-access.tsx @@ -5,7 +5,7 @@ import { Chip, toast } from '@sim/emcn' import { Workflow } from '@sim/emcn/icons' import { getErrorMessage } from '@sim/utils/errors' import type { CredentialGroupAccessResponse } from '@/lib/api/contracts/credential-groups' -import { CREDENTIAL_GROUP_WORKFLOW_ACCESS_LIMIT } from '@/lib/credential-groups/workflow-access-limits' +import { CREDENTIAL_GROUP_WORKFLOW_ACCESS_LIMIT } from '@/lib/credential-groups/limits' import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { diff --git a/apps/sim/ee/credential-groups/components/credential-group-detail.tsx b/apps/sim/ee/credential-groups/components/credential-group-detail.tsx index a6a76bf9ab4..0884035e701 100644 --- a/apps/sim/ee/credential-groups/components/credential-group-detail.tsx +++ b/apps/sim/ee/credential-groups/components/credential-group-detail.tsx @@ -5,10 +5,12 @@ import { Chip, ChipConfirmModal, ChipModalTabs, toast } from '@sim/emcn' import { ArrowLeft, Plus, User } from '@sim/emcn/icons' import { getErrorMessage } from '@sim/utils/errors' import { useQueryState } from 'nuqs' +import { McpIcon } from '@/components/icons' import { saveDiscardActions } from '@/components/settings/save-discard-actions' import type { CredentialGroupEnrollment, CredentialGroupEnrollmentConnection, + CredentialGroupEnrollmentMcpConnection, } from '@/lib/api/contracts/credential-groups' import type { CredentialGroupProvider } from '@/lib/credential-groups/providers' import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers' @@ -62,6 +64,7 @@ const CREDENTIAL_GROUP_TABS = [ interface EnrollmentConnectionsProps { connections: CredentialGroupEnrollmentConnection[] + mcpConnections: CredentialGroupEnrollmentMcpConnection[] } interface CredentialProviderIconProps { @@ -73,9 +76,11 @@ function CredentialProviderIcon({ provider }: CredentialProviderIconProps) { return } -function EnrollmentConnections({ connections }: EnrollmentConnectionsProps) { +function EnrollmentConnections({ connections, mcpConnections }: EnrollmentConnectionsProps) { const connected = connections.filter((connection) => connection.status === 'active') - const count = connected.reduce((total, connection) => total + connection.count, 0) + const connectedMcp = mcpConnections.filter((connection) => connection.status === 'active') + const count = + connected.reduce((total, connection) => total + connection.count, 0) + connectedMcp.length const providers = [...new Set(connected.map((connection) => connection.provider))] return ( @@ -83,8 +88,9 @@ function EnrollmentConnections({ connections }: EnrollmentConnectionsProps) { {providers.map((provider) => { return })} + {connectedMcp.length > 0 ? : null} - {count} connected {count === 1 ? 'account' : 'accounts'} + {count} connected {count === 1 ? 'connection' : 'connections'} ) @@ -130,7 +136,13 @@ export function CredentialGroupDetail({ ? (enrollments.find((enrollment) => enrollment.id === deletingEnrollmentId) ?? null) : null const configurationReady = - Boolean(credentialGroup?.options.length) && + Boolean( + credentialGroup && + (credentialGroup.options.length || + credentialGroup.mcpServers.some( + (server) => server.enabled && server.authType === 'oauth' + )) + ) && credentialGroup?.options.every( (option) => option.provider !== 'slack' || @@ -275,7 +287,7 @@ export function CredentialGroupDetail({ ? { value: providerSearch, onChange: setProviderSearch, - placeholder: 'Search account types...', + placeholder: 'Search accounts and MCP servers...', disabled: detail.isPending, } : undefined @@ -332,7 +344,10 @@ export function CredentialGroupDetail({ iconFilled title={enrollment.email} description={ - + } trailing={ (null) const [removingProvider, setRemovingProvider] = useState(null) + const [databricksSetupOpen, setDatabricksSetupOpen] = useState(false) + const [removingMcpConnector, setRemovingMcpConnector] = useState( + null + ) - const isUpdating = updateGroup.isPending + const isUpdating = + updateGroup.isPending || createMcpConnector.isPending || deleteMcpConnector.isPending const updateOptions = async ( options: NonNullable, @@ -142,6 +163,35 @@ export function CredentialGroupDetails({ if (await updateOptions(options, `${service.name} removed`)) setRemovingProvider(null) } + const addMcpConnector = async (connectorId: Exclude) => { + try { + await createMcpConnector.mutateAsync({ + workspaceId, + groupId: credentialGroup.id, + body: { connectorId }, + }) + toast.success(`${MANAGED_MCP_CONNECTORS[connectorId].name} added`) + } catch (error) { + toast.error(getErrorMessage(error, 'Could not add managed MCP connector')) + } + } + + const handleRemoveMcpConnector = async () => { + if (!removingMcpConnector) return + const connector = MANAGED_MCP_CONNECTORS[removingMcpConnector] + try { + await deleteMcpConnector.mutateAsync({ + workspaceId, + groupId: credentialGroup.id, + connectorId: removingMcpConnector, + }) + toast.success(`${connector.name} removed`) + setRemovingMcpConnector(null) + } catch (error) { + toast.error(getErrorMessage(error, 'Could not remove managed MCP connector')) + } + } + /** * A provider whose OAuth client this deployment has not configured can never finish an * enrollment, so it is not offered — but one already on the group stays listed regardless, or @@ -161,6 +211,20 @@ export function CredentialGroupDetails({ if (!providerQuery) return true return getCredentialGroupProviderService(provider).name.toLowerCase().includes(providerQuery) }) + const shownMcpConnectors = MANAGED_MCP_CONNECTOR_IDS.filter((connectorId) => { + if (!providerQuery) return true + const connector = MANAGED_MCP_CONNECTORS[connectorId] + return ( + connector.name.toLowerCase().includes(providerQuery) || + connector.description.toLowerCase().includes(providerQuery) + ) + }) + const databricksServerSummary = credentialGroup.mcpServers.find( + (server) => server.managedConnectorId === 'databricks' + ) + const databricksServer = databricksServerSummary + ? mcpServers.data?.find((server) => server.id === databricksServerSummary.id) + : undefined return ( <> @@ -283,6 +347,68 @@ export function CredentialGroupDetails({ + + {shownMcpConnectors.length === 0 ? ( + + {providerSearch.trim() + ? `No MCP apps found matching "${providerSearch}"` + : 'No managed MCP apps are available.'} + + ) : null} +
+ {shownMcpConnectors.map((connectorId) => { + const connector = MANAGED_MCP_CONNECTORS[connectorId] + const server = credentialGroup.mcpServers.find( + (candidate) => candidate.managedConnectorId === connectorId + ) + const ConnectorIcon = getManagedMcpConnectorIcon(connectorId) + return ( + } + title={server?.name ?? connector.name} + description={connector.description} + badge={server ? Added : undefined} + trailing={ + server ? ( + setDatabricksSetupOpen(true), + disabled: isUpdating || !databricksServer, + }, + ] + : []), + { + label: 'Remove', + destructive: true, + onSelect: () => setRemovingMcpConnector(connectorId), + disabled: isUpdating, + }, + ]} + /> + ) : ( + { + if (connectorId === 'databricks') setDatabricksSetupOpen(true) + else void addMcpConnector(connectorId) + }} + > + {connectorId === 'databricks' ? 'Set up' : 'Add'} + + ) + } + /> + ) + })} +
+
+ + + !open && !isUpdating && setRemovingProvider(null)} @@ -312,6 +446,23 @@ export function CredentialGroupDetails({ disabled: isUpdating, }} /> + + !open && !isUpdating && setRemovingMcpConnector(null)} + srTitle='Remove MCP app' + title={`Remove ${ + removingMcpConnector ? MANAGED_MCP_CONNECTORS[removingMcpConnector].name : 'MCP app' + }`} + defaultAction='confirm' + text='People will no longer be able to connect this app. Existing OAuth grants and saved tool metadata will be revoked.' + dismissLabel='Cancel' + confirm={{ + label: isUpdating ? 'Removing...' : 'Remove', + onClick: handleRemoveMcpConnector, + disabled: isUpdating, + }} + /> ) } diff --git a/apps/sim/ee/credential-groups/components/databricks-mcp-connector-modal.tsx b/apps/sim/ee/credential-groups/components/databricks-mcp-connector-modal.tsx new file mode 100644 index 00000000000..7908646500d --- /dev/null +++ b/apps/sim/ee/credential-groups/components/databricks-mcp-connector-modal.tsx @@ -0,0 +1,165 @@ +'use client' + +import { useState } from 'react' +import { + ChipModal, + ChipModalBody, + ChipModalError, + ChipModalField, + ChipModalFooter, + ChipModalHeader, + toast, +} from '@sim/emcn' +import { getErrorMessage } from '@sim/utils/errors' +import { DatabricksIcon } from '@/components/icons' +import type { McpServer } from '@/lib/api/contracts/mcp' +import { + useCreateCredentialGroupMcpConnector, + useUpdateCredentialGroupMcpConnector, +} from '@/hooks/queries/credential-groups' + +interface DatabricksMcpConnectorModalProps { + credentialGroupId: string + onOpenChange: (open: boolean) => void + open: boolean + server?: McpServer + workspaceId: string +} + +export function DatabricksMcpConnectorModal({ + credentialGroupId, + onOpenChange, + open, + server, + workspaceId, +}: DatabricksMcpConnectorModalProps) { + const createConnector = useCreateCredentialGroupMcpConnector() + const updateConnector = useUpdateCredentialGroupMcpConnector() + const [nameInput, setNameInput] = useState(null) + const [urlInput, setUrlInput] = useState(null) + const [clientIdInput, setClientIdInput] = useState(null) + const [clientSecret, setClientSecret] = useState('') + const name = nameInput ?? server?.name ?? 'Databricks' + const url = urlInput ?? server?.url ?? '' + const clientId = clientIdInput ?? server?.oauthClientId ?? '' + const pending = createConnector.isPending || updateConnector.isPending + const error = createConnector.error ?? updateConnector.error + + const reset = () => { + setNameInput(null) + setUrlInput(null) + setClientIdInput(null) + setClientSecret('') + createConnector.reset() + updateConnector.reset() + } + + const handleOpenChange = (nextOpen: boolean) => { + if (pending && !nextOpen) return + onOpenChange(nextOpen) + if (!nextOpen) reset() + } + + const handleSubmit = async () => { + if (!name.trim() || !url.trim() || !clientId.trim() || pending) return + try { + if (server) { + await updateConnector.mutateAsync({ + workspaceId, + groupId: credentialGroupId, + connectorId: 'databricks', + body: { + name: name.trim(), + url: url.trim(), + oauthClientId: clientId.trim(), + ...(clientSecret.trim() ? { oauthClientSecret: clientSecret.trim() } : {}), + }, + }) + } else { + await createConnector.mutateAsync({ + workspaceId, + groupId: credentialGroupId, + body: { + connectorId: 'databricks', + name: name.trim(), + url: url.trim(), + oauthClientId: clientId.trim(), + ...(clientSecret.trim() ? { oauthClientSecret: clientSecret.trim() } : {}), + }, + }) + } + toast.success(server ? 'Databricks updated' : 'Databricks added') + handleOpenChange(false) + } catch (submitError) { + toast.error(getErrorMessage(submitError, 'Could not save Databricks')) + } + } + + return ( + + handleOpenChange(false)} + closeDisabled={pending} + > + {server ? 'Edit Databricks MCP' : 'Add Databricks MCP'} + + + + + + + {error ? getErrorMessage(error) : null} + + handleOpenChange(false)} + cancelDisabled={pending} + primaryAction={{ + label: pending ? 'Saving...' : 'Save', + onClick: () => void handleSubmit(), + disabled: pending || !name.trim() || !url.trim() || !clientId.trim(), + }} + /> + + ) +} diff --git a/apps/sim/ee/workspace-forking/lib/remap/remap-references.ts b/apps/sim/ee/workspace-forking/lib/remap/remap-references.ts index 916acd6ecdf..6886502536d 100644 --- a/apps/sim/ee/workspace-forking/lib/remap/remap-references.ts +++ b/apps/sim/ee/workspace-forking/lib/remap/remap-references.ts @@ -4,7 +4,7 @@ import { isRecordLike, omit } from '@sim/utils/object' import type { SubBlockType } from '@sim/workflow-types/blocks' import type { z } from 'zod' import type { forkRemapKindSchema } from '@/lib/api/contracts/workspace-fork' -import { createMcpToolId } from '@/lib/mcp/shared' +import { createMcpToolId, MCP_SERVER_ADVANCED_TOOL_TYPE } from '@/lib/mcp/shared' import { coerceObjectArray, type SubBlockRecord, @@ -954,7 +954,7 @@ function remapForkToolInputValue( return } if ( - tool.type === 'mcp' && + (tool.type === 'mcp' || tool.type === MCP_SERVER_ADVANCED_TOOL_TYPE) && isRecordLike(tool.params) && typeof tool.params.serverId === 'string' ) { @@ -981,7 +981,8 @@ function remapForkToolInputValue( keep({ ...tool, params: nextParams, - toolId: toolName ? createMcpToolId(target, toolName) : tool.toolId, + toolId: + tool.type === 'mcp' && toolName ? createMcpToolId(target, toolName) : tool.toolId, }) return } diff --git a/apps/sim/executor/handlers/agent/agent-handler.test.ts b/apps/sim/executor/handlers/agent/agent-handler.test.ts index c3026f5db06..1932e0f5fed 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.test.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.test.ts @@ -126,9 +126,19 @@ vi.mock('@/executor/utils/http', () => ({ /** Connected MCP servers every workspace-server lookup in this suite resolves. */ const MCP_SERVER_ROWS = [ - { id: 'mcp-search-server', connectionStatus: 'connected' }, - { id: 'same-server', connectionStatus: 'connected' }, - { id: 'mcp-legacy-server', connectionStatus: 'connected' }, + { + id: 'mcp-search-server', + connectionStatus: 'connected', + credentialGroupId: null, + enabled: true, + }, + { id: 'same-server', connectionStatus: 'connected', credentialGroupId: null, enabled: true }, + { + id: 'mcp-legacy-server', + connectionStatus: 'connected', + credentialGroupId: null, + enabled: true, + }, ] const mockReadAvailableCustomToolByIdOrTitleAsExecutor = vi.fn() @@ -3701,6 +3711,74 @@ describe('AgentBlockHandler', () => { ) }) + it('expands every live tool from an explicitly selected managed MCP connection', async () => { + const credentialId = 'mcp-cg-123456789012345678901' + mockDiscoverMcpServerToolsAsExecutor.mockResolvedValue([ + { + name: 'search_transcripts', + description: 'Search transcripts', + inputSchema: { + type: 'object', + properties: { query: { type: 'string' } }, + required: ['query'], + }, + serverId: credentialId, + serverName: 'Fireflies', + }, + { + name: 'get_transcript', + description: 'Get one transcript', + inputSchema: { + type: 'object', + properties: { transcriptId: { type: 'string' } }, + required: ['transcriptId'], + }, + serverId: credentialId, + serverName: 'Fireflies', + }, + ]) + + await handler.execute( + { + ...mockContext, + userId: 'permission-check-user', + workspaceId: 'test-workspace-123', + workflowId: 'test-workflow-456', + }, + mockBlock, + { + model: 'gpt-4o', + userPrompt: 'Use Fireflies', + apiKey: 'test-api-key', + tools: [ + { + type: 'mcp-server-advanced', + params: { serverId: credentialId }, + usageControl: 'auto' as const, + }, + ], + } + ) + + expect(mockDiscoverMcpServerToolsAsExecutor).toHaveBeenCalledWith( + expect.objectContaining({ + serverId: credentialId, + workspaceId: 'test-workspace-123', + }) + ) + const providerTools = mockExecuteProviderRequest.mock.calls[0][1].tools + expect(providerTools).toEqual([ + expect.objectContaining({ + id: `${credentialId}-search_transcripts`, + params: {}, + }), + expect.objectContaining({ + id: `${credentialId}-get_transcript`, + params: {}, + }), + ]) + }) + describe('customToolId resolution - DB as source of truth', () => { const staleInlineSchema = { function: { diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index ef359254ea6..00a7dfc196f 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -17,8 +17,13 @@ import { readWorkflowInputFieldsForTool, readWorkflowMetadataForTool, } from '@/lib/internal/workflows/read-tool-enrichment' +import { assertValidMcpServerToolBindings, MCP_SERVER_ADVANCED_TOOL_TYPE } from '@/lib/mcp/shared' import type { McpToolSchema } from '@/lib/mcp/types' -import { createMcpToolId } from '@/lib/mcp/utils' +import { + createMcpToolId, + isManagedMcpConnectionId, + MANAGED_MCP_CONNECTION_PREFIX, +} from '@/lib/mcp/utils' import { type AutoMediaKind, type AutoRoutingResult, @@ -595,7 +600,9 @@ export class AgentBlockHandler implements BlockHandler { private async validateToolPermissions(ctx: ExecutionContext, tools: ToolInput[]): Promise { if (!Array.isArray(tools) || tools.length === 0) return - const hasMcpTools = tools.some((t) => t.type === 'mcp') + const hasMcpTools = tools.some( + (t) => t.type === 'mcp' || t.type === MCP_SERVER_ADVANCED_TOOL_TYPE + ) const hasCustomTools = tools.some((t) => t.type === 'custom-tool') if (hasMcpTools) { @@ -635,21 +642,41 @@ export class AgentBlockHandler implements BlockHandler { } const availableServerIds = new Set() - if (serverIds.length > 0) { + const sharedServerIds: string[] = [] + for (const serverId of serverIds) { + if (serverId.startsWith(MANAGED_MCP_CONNECTION_PREFIX)) { + if (!isManagedMcpConnectionId(serverId)) { + throw new Error('Invalid managed MCP connection ID') + } + availableServerIds.add(serverId) + } else { + sharedServerIds.push(serverId) + } + } + if (sharedServerIds.length > 0) { try { const servers = await db - .select({ id: mcpServers.id, connectionStatus: mcpServers.connectionStatus }) + .select({ + id: mcpServers.id, + connectionStatus: mcpServers.connectionStatus, + credentialGroupId: mcpServers.credentialGroupId, + enabled: mcpServers.enabled, + }) .from(mcpServers) .where( and( eq(mcpServers.workspaceId, ctx.workspaceId), - inArray(mcpServers.id, serverIds), + inArray(mcpServers.id, sharedServerIds), isNull(mcpServers.deletedAt) ) ) for (const server of servers) { - if (server.connectionStatus === 'connected') { + if ( + server.enabled && + !server.credentialGroupId && + server.connectionStatus === 'connected' + ) { availableServerIds.add(server.id) } } @@ -662,7 +689,7 @@ export class AgentBlockHandler implements BlockHandler { getErrorDiagnosticFallback(error) ) ) - for (const serverId of serverIds) { + for (const serverId of sharedServerIds) { availableServerIds.add(serverId) } } @@ -723,8 +750,11 @@ export class AgentBlockHandler implements BlockHandler { const root = ['tools', String(toolIndex)] as const const paths: ResolvedSecretInputPath[] = [[...root, 'type']] if (tool.operation !== undefined) paths.push([...root, 'operation']) + if (tool.type === 'mcp' || tool.type === MCP_SERVER_ADVANCED_TOOL_TYPE) { + paths.push([...root, 'params', 'serverId']) + } if (tool.type === 'mcp') { - paths.push([...root, 'params', 'serverId'], [...root, 'params', 'toolName']) + paths.push([...root, 'params', 'toolName']) } if (tool.type === 'custom-tool' && !tool.customToolId) { paths.push([...root, 'title'], [...root, 'schema', 'function', 'name']) @@ -735,6 +765,7 @@ export class AgentBlockHandler implements BlockHandler { ) const mcpTools: IndexedToolInput[] = [] + const advancedMcpServers: IndexedToolInput[] = [] const otherTools: IndexedToolInput[] = [] const inputProvenance = new Map< ProviderToolConfig, @@ -767,9 +798,12 @@ export class AgentBlockHandler implements BlockHandler { return formattedTool } + assertValidMcpServerToolBindings(filtered.map(({ tool }) => tool)) for (const entry of filtered) { if (entry.tool.type === 'mcp') { mcpTools.push(entry) + } else if (entry.tool.type === MCP_SERVER_ADVANCED_TOOL_TYPE) { + advancedMcpServers.push(entry) } else { otherTools.push(entry) } @@ -820,8 +854,13 @@ export class AgentBlockHandler implements BlockHandler { trackInputProvenance, projectedToolInputs ) + const advancedMcpResults = await this.processAdvancedMcpServers( + ctx, + advancedMcpServers, + trackInputProvenance + ) - const allTools = [...otherResults, ...mcpResults] + const allTools = [...otherResults, ...mcpResults, ...advancedMcpResults] const tools = allTools.filter( (tool): tool is ProviderToolConfig => tool !== null && tool !== undefined ) @@ -880,7 +919,10 @@ export class AgentBlockHandler implements BlockHandler { // An MCP tool has no block, so its only structured keys are the ones its own // `paramsTransform` decodes. A custom tool has neither. const blockInputs = - tool.type && tool.type !== 'mcp' && tool.type !== 'custom-tool' + tool.type && + tool.type !== 'mcp' && + tool.type !== MCP_SERVER_ADVANCED_TOOL_TYPE && + tool.type !== 'custom-tool' ? getBlock(tool.type)?.inputs : undefined return prepareResolvedSecretProjectedInputs(alignedParams, blockInputs, formattedParams, { @@ -1145,6 +1187,37 @@ export class AgentBlockHandler implements BlockHandler { return results } + private async processAdvancedMcpServers( + ctx: ExecutionContext, + entries: IndexedToolInput[], + trackInputProvenance: ( + formattedTool: ProviderToolConfig | null, + entry: IndexedToolInput + ) => ProviderToolConfig | null + ): Promise> { + const results = await Promise.all( + entries.map(async (entry) => { + const serverId = entry.tool.params?.serverId + if (!serverId) throw new Error('MCP Server (Advanced) requires params.serverId') + const tools = await this.discoverMcpToolsForServer(ctx, serverId) + return Promise.all( + tools.map(async (tool) => { + const created = await this.buildMcpTool({ + serverId, + toolName: tool.name, + description: tool.description || `MCP tool ${tool.name} from ${tool.serverName}`, + schema: tool.inputSchema || { type: 'object', properties: {} }, + userProvidedParams: {}, + usageControl: entry.tool.usageControl, + }) + return trackInputProvenance(created, entry) + }) + ) + }) + ) + return results.flat() + } + /** * Create MCP tool from cached schema. No MCP server connection required. */ @@ -1296,9 +1369,6 @@ export class AgentBlockHandler implements BlockHandler { /** Discovers one server's tools through the authorized MCP operation. */ private async discoverMcpToolsForServer(ctx: ExecutionContext, serverId: string): Promise { - if (!ctx.userId) { - throw new Error('userId is required for MCP tool discovery') - } if (!ctx.workspaceId) { throw new Error('workspaceId is required for MCP tool discovery') } @@ -1344,7 +1414,7 @@ export class AgentBlockHandler implements BlockHandler { schema: McpToolSchema userProvidedParams: Record usageControl?: 'auto' | 'force' | 'none' - }) { + }): Promise { const filteredSchema = filterSchemaForLLM(config.schema, config.userProvidedParams) const toolId = createMcpToolId(config.serverId, config.toolName) @@ -1360,7 +1430,11 @@ export class AgentBlockHandler implements BlockHandler { return { id: toolId, description: config.description, - parameters: filteredSchema, + parameters: { + type: filteredSchema.type, + properties: filteredSchema.properties ?? {}, + required: filteredSchema.required ?? [], + }, params: config.userProvidedParams, usageControl: config.usageControl || 'auto', paramsTransform: (params: Record) => decodeToolParams(params, paramShapes), diff --git a/apps/sim/executor/handlers/agent/types.ts b/apps/sim/executor/handlers/agent/types.ts index 4c311b190dd..35f97a6cd29 100644 --- a/apps/sim/executor/handlers/agent/types.ts +++ b/apps/sim/executor/handlers/agent/types.ts @@ -51,6 +51,7 @@ export interface AgentInputs { * - Standard block types (e.g., 'api', 'search', 'function') * - 'custom-tool': User-defined tools with custom code * - 'mcp': Individual MCP tool from a connected server + * - 'mcp-server-advanced': All tools available to the executing subject from one MCP server */ export interface ToolInput { /** Tool type identifier */ diff --git a/apps/sim/executor/handlers/credential-group/credential-group-handler.test.ts b/apps/sim/executor/handlers/credential-group/credential-group-handler.test.ts index f1bdeaa44d5..02b2080019a 100644 --- a/apps/sim/executor/handlers/credential-group/credential-group-handler.test.ts +++ b/apps/sim/executor/handlers/credential-group/credential-group-handler.test.ts @@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({ enforceInviteRateLimit: vi.fn(), listCredentials: vi.fn(), listGroups: vi.fn(), + listMcpConnections: vi.fn(), listPeople: vi.fn(), sendInvite: vi.fn(), })) @@ -29,6 +30,10 @@ vi.mock('@/lib/credential-groups/application/list-groups', () => ({ listCredentialGroupsForWorkflow: { execute: mocks.listGroups }, })) +vi.mock('@/lib/credential-groups/application/list-mcp-connections', () => ({ + listCredentialGroupMcpConnections: { execute: mocks.listMcpConnections }, +})) + vi.mock('@/lib/credential-groups/application/list-people', () => ({ CREDENTIAL_GROUP_PEOPLE_STATUSES: [ 'invited', @@ -201,6 +206,36 @@ describe('CredentialGroupBlockHandler', () => { }) }) + it('lists explicit MCP connection references for an advanced MCP tool', async () => { + mocks.listMcpConnections.mockResolvedValue({ + mcpConnections: [], + count: 0, + hasMore: false, + nextCursor: null, + }) + + const result = await new CredentialGroupBlockHandler().execute(context, block, { + operation: 'list_mcp_connections', + credentialGroupId: ' group-1 ', + email: ' person@example.com ', + mcpServerId: ' mcp-server-1 ', + limit: '25', + cursor: ' mcp-cg-connection-1 ', + }) + + expect(mocks.listMcpConnections).toHaveBeenCalledWith({ + principal, + input: { + credentialGroupId: 'group-1', + email: 'person@example.com', + mcpServerId: 'mcp-server-1', + limit: 25, + cursor: 'mcp-cg-connection-1', + }, + }) + expect(result).toEqual({ mcpConnections: [], count: 0, hasMore: false, nextCursor: null }) + }) + it('lists groups under workspace-scoped delegation', async () => { mocks.listGroups.mockResolvedValue({ credentialGroups: [], diff --git a/apps/sim/executor/handlers/credential-group/credential-group-handler.ts b/apps/sim/executor/handlers/credential-group/credential-group-handler.ts index 0bc0c24169a..44d90ea3bd5 100644 --- a/apps/sim/executor/handlers/credential-group/credential-group-handler.ts +++ b/apps/sim/executor/handlers/credential-group/credential-group-handler.ts @@ -3,6 +3,7 @@ import { CREDENTIAL_GROUP_DELEGATION_AUDIENCE } from '@/lib/credential-groups/ap import { createCredentialGroupInviteLink } from '@/lib/credential-groups/application/create-invite-link' import { listCredentialGroupCredentials } from '@/lib/credential-groups/application/list-credentials' import { listCredentialGroupsForWorkflow } from '@/lib/credential-groups/application/list-groups' +import { listCredentialGroupMcpConnections } from '@/lib/credential-groups/application/list-mcp-connections' import { CREDENTIAL_GROUP_PEOPLE_STATUSES, listCredentialGroupPeople, @@ -21,6 +22,7 @@ const logger = createLogger('CredentialGroupBlockHandler') const CREDENTIAL_GROUP_OPERATION_IDS = [ 'list_credentials', + 'list_mcp_connections', 'send_invite', 'get_invite_link', 'list_people', @@ -132,6 +134,24 @@ export class CredentialGroupBlockHandler implements BlockHandler { }) return result } + case 'list_mcp_connections': { + const result = await listCredentialGroupMcpConnections.execute({ + principal, + input: { + credentialGroupId: credentialGroupId!, + limit: parseLimit(inputs.limit), + cursor: parseOptionalString(inputs.cursor, 'Cursor'), + email: parseOptionalString(inputs.email, 'Email'), + mcpServerId: parseOptionalString(inputs.mcpServerId, 'MCP Server ID'), + }, + }) + logger.info('Listed Credential Group MCP connections', { + credentialGroupId, + count: result.count, + hasMore: result.hasMore, + }) + return result + } case 'send_invite': { await enforceCredentialGroupInvitationExecutionRateLimit(principal.workspaceId) const result = await sendCredentialGroupInvite.execute({ diff --git a/apps/sim/executor/handlers/mothership/mothership-handler.test.ts b/apps/sim/executor/handlers/mothership/mothership-handler.test.ts index d66264203fc..98a90bd6823 100644 --- a/apps/sim/executor/handlers/mothership/mothership-handler.test.ts +++ b/apps/sim/executor/handlers/mothership/mothership-handler.test.ts @@ -33,6 +33,7 @@ const { mockAreModelSafeWorkspaceFileKeys, mockBuildAuthHeaders, mockBuildAPIUrl, + mockDiscoverMcpServerToolsAsExecutor, mockExtractAPIErrorMessage, mockGenerateId, mockReadUserFileContent, @@ -40,6 +41,7 @@ const { mockAreModelSafeWorkspaceFileKeys: vi.fn(), mockBuildAuthHeaders: vi.fn(), mockBuildAPIUrl: vi.fn(), + mockDiscoverMcpServerToolsAsExecutor: vi.fn(), mockExtractAPIErrorMessage: vi.fn(), mockGenerateId: vi.fn(), mockReadUserFileContent: vi.fn(), @@ -51,6 +53,10 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () 'File cannot be sent to a model because its secret provenance is unavailable', })) +vi.mock('@/lib/internal/mcp/discover-tools', () => ({ + discoverMcpServerToolsAsExecutor: mockDiscoverMcpServerToolsAsExecutor, +})) + vi.mock('@/executor/utils/http', () => ({ buildAuthHeaders: mockBuildAuthHeaders, buildAPIUrl: mockBuildAPIUrl, @@ -982,6 +988,48 @@ describe('MothershipBlockHandler', () => { expect(body.contexts).toEqual([{ kind: 'skill', skillId: 'skill-1', label: 'sales-playbook' }]) }) + it('expands an explicitly selected managed MCP connection for the request', async () => { + const credentialId = 'mcp-cg-123456789012345678901' + mockDiscoverMcpServerToolsAsExecutor.mockResolvedValueOnce([ + { + name: 'search_transcripts', + description: 'Search transcripts', + inputSchema: { type: 'object', properties: { query: { type: 'string' } } }, + serverId: credentialId, + serverName: 'Fireflies', + }, + ]) + fetchMock.mockResolvedValue(createJsonResponse({ content: 'done', toolCalls: [] })) + + await handler.execute(context, block, { + prompt: 'Search Fireflies', + tools: [ + { + type: 'mcp-server-advanced', + params: { serverId: credentialId }, + usageControl: 'force', + }, + ], + }) + + expect(mockDiscoverMcpServerToolsAsExecutor).toHaveBeenCalledWith( + expect.objectContaining({ serverId: credentialId, workspaceId: context.workspaceId }) + ) + const [, options] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(JSON.parse(String(options.body)).mcpTools).toEqual([ + { + type: 'mcp', + usageControl: 'force', + schema: { type: 'object', properties: { query: { type: 'string' } } }, + params: { + serverId: credentialId, + toolName: 'search_transcripts', + serverName: 'Fireflies', + }, + }, + ]) + }) + it('does not scan arbitrary Mothership metadata, attachment names, or payloads', async () => { const secret = 'boundary-secret' const registry = new ResolvedSecretTraceRegistry([ diff --git a/apps/sim/executor/handlers/mothership/mothership-handler.ts b/apps/sim/executor/handlers/mothership/mothership-handler.ts index e75a1814c41..bf2b275ed59 100644 --- a/apps/sim/executor/handlers/mothership/mothership-handler.ts +++ b/apps/sim/executor/handlers/mothership/mothership-handler.ts @@ -20,6 +20,8 @@ import { RESOLVED_SECRET_PROVENANCE_FIELD, RESOLVED_SECRET_PROVENANCE_METADATA_V1, } from '@/lib/execution/private-tool-metadata' +import { discoverMcpServerToolsAsExecutor } from '@/lib/internal/mcp/discover-tools' +import { assertValidMcpServerToolBindings, MCP_SERVER_ADVANCED_TOOL_TYPE } from '@/lib/mcp/shared' import { areModelSafeWorkspaceFileKeys, MODEL_UNSAFE_WORKSPACE_FILE_ERROR_MESSAGE, @@ -144,6 +146,64 @@ function selectMothershipMcpTools(tools: unknown): MothershipMcpToolSelection[] return selectIndexedMothershipMcpTools(tools).map(({ selection }) => selection) } +async function expandMothershipMcpTools( + ctx: ExecutionContext, + tools: unknown +): Promise { + if (!Array.isArray(tools)) return [] + assertValidMcpServerToolBindings(tools) + const individual = selectMothershipMcpTools(tools) + const advanced: Array<{ serverId: string; usageControl: 'auto' | 'force' }> = tools.flatMap( + (candidate) => { + if (!isPlainRecord(candidate) || candidate.type !== MCP_SERVER_ADVANCED_TOOL_TYPE) return [] + if (candidate.usageControl === 'none') return [] + if (!isPlainRecord(candidate.params)) { + throw new Error('MCP Server (Advanced) requires params.serverId') + } + const serverId = candidate.params.serverId + if (typeof serverId !== 'string' || !serverId.trim()) { + throw new Error('MCP Server (Advanced) requires params.serverId') + } + const usageControl: 'auto' | 'force' = candidate.usageControl === 'force' ? 'force' : 'auto' + return [{ serverId, usageControl }] + } + ) + if (advanced.length === 0) return individual + if (!ctx.workspaceId || !ctx.workflowId) { + throw new Error('Workspace and workflow context are required for MCP Server (Advanced)') + } + const workspaceId = ctx.workspaceId + const workflowId = ctx.workflowId + + const expanded = await Promise.all( + advanced.map(async ({ serverId, usageControl }) => { + const discovered = await discoverMcpServerToolsAsExecutor({ + workspaceId, + context: { + workflowId, + workspaceId, + executionId: ctx.executionId, + userId: ctx.userId, + executorDelegationOrigin: ctx.executorDelegationOrigin, + }, + serverId, + signal: ctx.abortSignal, + }) + return discovered.map((tool) => ({ + type: 'mcp' as const, + usageControl, + schema: tool.inputSchema, + params: { + serverId, + toolName: tool.name, + serverName: tool.serverName, + }, + })) + }) + ) + return [...individual, ...expanded.flat()] +} + function selectIndexedMothershipSkillContexts( skills: unknown, privateSelectorIndexes: ReadonlySet = new Set() @@ -288,6 +348,12 @@ function selectMothershipMetadataModelInputPaths( modelInputPaths.push([...root, 'params', 'serverName']) } } + if (Array.isArray(tools)) { + tools.forEach((candidate, inputIndex) => { + if (!isPlainRecord(candidate) || candidate.type !== MCP_SERVER_ADVANCED_TOOL_TYPE) return + structuralInputPaths.push(['tools', String(inputIndex), 'params', 'serverId']) + }) + } for (const { inputIndex, hasExplicitLabel } of selectIndexedMothershipSkillContexts(skills)) { const root = ['skills', String(inputIndex)] as const @@ -827,7 +893,7 @@ export class MothershipBlockHandler implements BlockHandler { secretScope: inputs.secretScope, mountedSecrets: inputs.mountedSecrets, }) - const mcpTools = selectMothershipMcpTools(modelInputProjection.value.tools) + const mcpTools = await expandMothershipMcpTools(ctx, modelInputProjection.value.tools) const skillContexts = selectMothershipSkillContexts( modelInputProjection.value.skills, privateSkillSelectors.inputIndexes diff --git a/apps/sim/hooks/mcp/use-mcp-oauth-popup.test.tsx b/apps/sim/hooks/mcp/use-mcp-oauth-popup.test.tsx index d7720a3971c..ae76a1fde93 100644 --- a/apps/sim/hooks/mcp/use-mcp-oauth-popup.test.tsx +++ b/apps/sim/hooks/mcp/use-mcp-oauth-popup.test.tsx @@ -17,6 +17,7 @@ vi.mock('@/hooks/queries/mcp', () => ({ useStartMcpOauth: () => ({ mutateAsync: mockStartOauth }), mcpKeys: { serversList: (workspaceId: string) => ['mcp', 'servers', workspaceId], + managedCatalogList: (workspaceId: string) => ['mcp', 'managed-catalog', workspaceId], serverToolsList: (workspaceId: string, serverId: string) => [ 'mcp', 'server-tools', diff --git a/apps/sim/hooks/mcp/use-mcp-oauth-popup.ts b/apps/sim/hooks/mcp/use-mcp-oauth-popup.ts index d1e76921b48..ba5a82f04fd 100644 --- a/apps/sim/hooks/mcp/use-mcp-oauth-popup.ts +++ b/apps/sim/hooks/mcp/use-mcp-oauth-popup.ts @@ -88,6 +88,7 @@ export function useMcpOauthPopup({ workspaceId }: UseMcpOauthPopupProps) { const invalidateServer = useCallback( (serverId: string) => { queryClient.invalidateQueries({ queryKey: mcpKeys.serversList(workspaceId) }) + queryClient.invalidateQueries({ queryKey: mcpKeys.managedCatalogList(workspaceId) }) queryClient.invalidateQueries({ queryKey: mcpKeys.serverToolsList(workspaceId, serverId) }) queryClient.invalidateQueries({ queryKey: mcpKeys.storedToolsList(workspaceId) }) }, diff --git a/apps/sim/hooks/mcp/use-mcp-tools.ts b/apps/sim/hooks/mcp/use-mcp-tools.ts index a6e816b038b..cc5eb1d49b2 100644 --- a/apps/sim/hooks/mcp/use-mcp-tools.ts +++ b/apps/sim/hooks/mcp/use-mcp-tools.ts @@ -10,6 +10,7 @@ import { useCallback, useMemo } from 'react' import { createLogger } from '@sim/logger' import { useQueryClient } from '@tanstack/react-query' import { McpIcon } from '@/components/icons' +import { getManagedMcpConnectorIcon } from '@/lib/credential-groups/managed-mcp-connector-icons' import { createMcpToolId } from '@/lib/mcp/shared' import type { McpToolSchema } from '@/lib/mcp/types' import { mcpKeys, useMcpToolsQuery } from '@/hooks/queries/mcp' @@ -51,7 +52,7 @@ export function useMcpTools(workspaceId: string): UseMcpToolsResult { type: 'mcp' as const, inputSchema: tool.inputSchema, bgColor: '#6366F1', - icon: McpIcon, + icon: tool.managedConnectorId ? getManagedMcpConnectorIcon(tool.managedConnectorId) : McpIcon, })) }, [mcpToolsData]) diff --git a/apps/sim/hooks/queries/credential-groups.ts b/apps/sim/hooks/queries/credential-groups.ts index 868dc0156b9..c7a82929492 100644 --- a/apps/sim/hooks/queries/credential-groups.ts +++ b/apps/sim/hooks/queries/credential-groups.ts @@ -6,8 +6,10 @@ import type { ContractBodyInput } from '@/lib/api/contracts' import { type CredentialGroupAccessResponse, createCredentialGroupContract, + createCredentialGroupMcpConnectorContract, deleteCredentialGroupContract, deleteCredentialGroupEnrollmentContract, + deleteCredentialGroupMcpConnectorContract, getCredentialGroupAccessContract, getCredentialGroupContract, inviteCredentialGroupEnrollmentsContract, @@ -15,8 +17,10 @@ import { startSlackCredentialGroupConfigurationContract, updateCredentialGroupAccessContract, updateCredentialGroupContract, + updateCredentialGroupMcpConnectorContract, } from '@/lib/api/contracts/credential-groups' import type { ContractJsonResponse } from '@/lib/api/contracts/types' +import { mcpKeys } from '@/hooks/queries/mcp' import { CREDENTIAL_GROUP_ACCESS_STALE_TIME, CREDENTIAL_GROUP_DETAIL_STALE_TIME, @@ -145,6 +149,9 @@ export function useCreateCredentialGroup() { queryClient.invalidateQueries({ queryKey: credentialGroupKeys.list(variables.workspaceId), }), + queryClient.invalidateQueries({ + queryKey: mcpKeys.managedCatalogList(variables.workspaceId), + }), invalidateSelectorQueries(queryClient), ]), }) @@ -165,6 +172,9 @@ export function useDeleteCredentialGroup() { queryClient.invalidateQueries({ queryKey: credentialGroupKeys.list(variables.workspaceId), }), + queryClient.invalidateQueries({ + queryKey: mcpKeys.managedCatalogList(variables.workspaceId), + }), invalidateSelectorQueries(queryClient), ]) }, @@ -198,11 +208,92 @@ export function useUpdateCredentialGroup() { queryClient.invalidateQueries({ queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId), }), + queryClient.invalidateQueries({ + queryKey: mcpKeys.managedCatalogList(variables.workspaceId), + }), invalidateSelectorQueries(queryClient), ]), }) } +function invalidateManagedMcpConnectorQueries( + queryClient: ReturnType, + workspaceId: string, + groupId: string +) { + return Promise.all([ + queryClient.invalidateQueries({ queryKey: credentialGroupKeys.list(workspaceId) }), + queryClient.invalidateQueries({ queryKey: credentialGroupKeys.detail(workspaceId, groupId) }), + queryClient.invalidateQueries({ queryKey: mcpKeys.serversList(workspaceId) }), + queryClient.invalidateQueries({ queryKey: mcpKeys.managedCatalogList(workspaceId) }), + invalidateSelectorQueries(queryClient), + ]) +} + +export function useCreateCredentialGroupMcpConnector() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ + workspaceId, + groupId, + body, + }: { + workspaceId: string + groupId: string + body: ContractBodyInput + }) => + requestJson(createCredentialGroupMcpConnectorContract, { + params: { id: workspaceId, groupId }, + body, + }), + onSettled: (_data, _error, variables) => + invalidateManagedMcpConnectorQueries(queryClient, variables.workspaceId, variables.groupId), + }) +} + +export function useUpdateCredentialGroupMcpConnector() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ + workspaceId, + groupId, + connectorId, + body, + }: { + workspaceId: string + groupId: string + connectorId: 'fireflies' | 'granola' | 'databricks' + body: ContractBodyInput + }) => + requestJson(updateCredentialGroupMcpConnectorContract, { + params: { id: workspaceId, groupId, connectorId }, + body, + }), + onSettled: (_data, _error, variables) => + invalidateManagedMcpConnectorQueries(queryClient, variables.workspaceId, variables.groupId), + }) +} + +export function useDeleteCredentialGroupMcpConnector() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ + workspaceId, + groupId, + connectorId, + }: { + workspaceId: string + groupId: string + connectorId: 'fireflies' | 'granola' | 'databricks' + }) => + requestJson(deleteCredentialGroupMcpConnectorContract, { + params: { id: workspaceId, groupId, connectorId }, + }), + onSettled: (_data, _error, variables) => + invalidateManagedMcpConnectorQueries(queryClient, variables.workspaceId, variables.groupId), + }) +} + export function useStartSlackCredentialGroupConfiguration() { return useMutation({ mutationFn: async ({ @@ -242,6 +333,9 @@ export function useInviteCredentialGroupEnrollments() { queryClient.invalidateQueries({ queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId), }), + queryClient.invalidateQueries({ + queryKey: mcpKeys.managedCatalogList(variables.workspaceId), + }), invalidateSelectorQueries(queryClient), ]), }) @@ -267,6 +361,9 @@ export function useResendCredentialGroupEnrollment() { queryClient.invalidateQueries({ queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId), }), + queryClient.invalidateQueries({ + queryKey: mcpKeys.managedCatalogList(variables.workspaceId), + }), invalidateSelectorQueries(queryClient), ]), }) @@ -292,6 +389,9 @@ export function useDeleteCredentialGroupEnrollment() { queryClient.invalidateQueries({ queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId), }), + queryClient.invalidateQueries({ + queryKey: mcpKeys.managedCatalogList(variables.workspaceId), + }), invalidateSelectorQueries(queryClient), ]), }) diff --git a/apps/sim/hooks/queries/mcp.test.tsx b/apps/sim/hooks/queries/mcp.test.tsx index 638172515eb..a3972024483 100644 --- a/apps/sim/hooks/queries/mcp.test.tsx +++ b/apps/sim/hooks/queries/mcp.test.tsx @@ -18,6 +18,7 @@ vi.mock('@/lib/api/client/request', () => ({ import { discoverMcpToolsContract, getAllowedMcpDomainsContract, + listManagedMcpCatalogContract, listMcpServersContract, listStoredMcpToolsContract, type McpServer, @@ -103,6 +104,7 @@ function mockServers(servers: McpServer[]) { if (contract === discoverMcpToolsContract) { return { success: true, data: { tools: [], totalCount: 0, byServer: {} } } } + if (contract === listManagedMcpCatalogContract) return { servers: [], tools: [] } throw new Error('Unexpected MCP request') }) } @@ -141,7 +143,7 @@ describe('useMcpToolsQuery', () => { const { unmount } = renderHookWithClient(() => useMcpToolsQuery(WORKSPACE_ID)) await flush() - expect(mockRequestJson).toHaveBeenCalledTimes(1) + expect(mockRequestJson).toHaveBeenCalledTimes(2) expect(mockRequestJson).toHaveBeenCalledWith( listMcpServersContract, expect.objectContaining({ query: { workspaceId: WORKSPACE_ID } }) @@ -150,6 +152,65 @@ describe('useMcpToolsQuery', () => { unmount() }) + it('includes managed Credential Group connection snapshots without upstream discovery', async () => { + const managedServer = server('mcp-cg-123456789012345678901', { + name: 'Fireflies — alex@example.com', + authType: 'oauth', + url: undefined, + }) + mockRequestJson.mockImplementation(async (contract) => { + if (contract === listMcpServersContract) { + return { success: true, data: { servers: [] } } + } + if (contract === listManagedMcpCatalogContract) { + return { + servers: [managedServer], + tools: [ + { + name: 'search_transcripts', + description: 'Search transcripts', + inputSchema: { type: 'object', properties: {} }, + serverId: managedServer.id, + serverName: managedServer.name, + }, + ], + } + } + throw new Error('Managed MCP snapshots must not trigger discovery') + }) + + const hook = renderHookWithClient(() => useMcpToolsQuery(WORKSPACE_ID)) + await flush() + + expect(hook.getResult().data).toEqual([ + expect.objectContaining({ + name: 'search_transcripts', + serverId: managedServer.id, + }), + ]) + expect(mockRequestJson).toHaveBeenCalledTimes(2) + + hook.unmount() + }) + + it('surfaces a shared server-list failure when the managed catalog is empty', async () => { + const serverListError = new Error('server list failed') + mockRequestJson.mockImplementation(async (contract) => { + if (contract === listMcpServersContract) throw serverListError + if (contract === listManagedMcpCatalogContract) return { servers: [], tools: [] } + throw new Error('Unexpected MCP request') + }) + + const hook = renderHookWithClient(() => useMcpToolsQuery(WORKSPACE_ID)) + await flush() + + expect(hook.getResult().data).toEqual([]) + expect(hook.getResult().error).toBe(serverListError) + expect(hook.getResult().isLoading).toBe(false) + + hook.unmount() + }) + it('defers detail and form metadata queries while their surfaces are closed', async () => { mockRequestJson.mockImplementation(async (contract) => { if (contract === listStoredMcpToolsContract || contract === getAllowedMcpDomainsContract) { diff --git a/apps/sim/hooks/queries/mcp.ts b/apps/sim/hooks/queries/mcp.ts index e3134e3f8d2..78b22793780 100644 --- a/apps/sim/hooks/queries/mcp.ts +++ b/apps/sim/hooks/queries/mcp.ts @@ -16,8 +16,10 @@ import { deleteMcpServerContract, discoverMcpToolsContract, getAllowedMcpDomainsContract, + listManagedMcpCatalogContract, listMcpServersContract, listStoredMcpToolsContract, + type ManagedMcpCatalog, type McpServer, type McpServerTestBody, type McpServerTestResult, @@ -52,6 +54,9 @@ export const mcpKeys = { all: ['mcp'] as const, servers: () => [...mcpKeys.all, 'servers'] as const, serversList: (workspaceId?: string) => [...mcpKeys.servers(), workspaceId ?? ''] as const, + managedCatalog: () => [...mcpKeys.all, 'managedCatalog'] as const, + managedCatalogList: (workspaceId?: string) => + [...mcpKeys.managedCatalog(), workspaceId ?? ''] as const, serverTools: () => [...mcpKeys.all, 'serverTools'] as const, serverToolsWorkspace: (workspaceId?: string) => [...mcpKeys.serverTools(), workspaceId ?? ''] as const, @@ -114,6 +119,42 @@ export function useMcpServers(workspaceId: string) { }) } +async function fetchManagedMcpCatalog( + workspaceId: string, + signal?: AbortSignal +): Promise { + return requestJson(listManagedMcpCatalogContract, { + query: { workspaceId }, + signal, + }) +} + +export function useManagedMcpCatalog(workspaceId: string) { + return useQuery({ + queryKey: mcpKeys.managedCatalogList(workspaceId), + queryFn: ({ signal }) => fetchManagedMcpCatalog(workspaceId, signal), + enabled: Boolean(workspaceId), + retry: false, + staleTime: MCP_SERVER_LIST_STALE_TIME, + }) +} + +export function useMcpToolServers(workspaceId: string) { + const shared = useMcpServers(workspaceId) + const managed = useManagedMcpCatalog(workspaceId) + return useMemo( + () => ({ + data: [ + ...(shared.data ?? []).filter((server) => !server.credentialGroupId), + ...(managed.data?.servers ?? []), + ], + isLoading: shared.isLoading || managed.isLoading, + error: shared.error ?? managed.error, + }), + [shared.data, shared.error, shared.isLoading, managed.data, managed.error, managed.isLoading] + ) +} + async function fetchMcpTools( workspaceId: string, forceRefresh = false, @@ -142,6 +183,7 @@ function isServerEligibleForDiscovery(server: McpServer, workspaceId: string): b return ( server.enabled && server.workspaceId === workspaceId && + !server.credentialGroupId && (server.authType !== 'oauth' || server.connectionStatus === 'connected') ) } @@ -152,7 +194,12 @@ function isServerEligibleForDiscovery(server: McpServer, workspaceId: string): b */ export function useMcpToolsQuery(workspaceId: string) { const queryClient = useQueryClient() - const { data: servers, isLoading: serversLoading } = useMcpServers(workspaceId) + const { + data: servers, + isLoading: serversLoading, + error: serversError, + } = useMcpServers(workspaceId) + const managedCatalog = useManagedMcpCatalog(workspaceId) // Push is intrinsic to consuming the tools query: every surface that reads tools (settings, // tool picker, dynamic args, tool selector, canvas block) gets real-time `list_changed` // refresh via the shared, reference-counted subscription — so the 5-min stale time is always @@ -196,11 +243,21 @@ export function useMcpToolsQuery(workspaceId: string) { }) return useMemo(() => { - const tools: McpTool[] = [] - let hasData = false + const tools: McpTool[] = [...(managedCatalog.data?.tools ?? [])] + let hasData = Boolean(managedCatalog.data?.tools.length) let anyServerLoading = false - let firstError: Error | null = null - const statusById = new Map(servers?.map((s) => [s.id, s.connectionStatus])) + let firstError: Error | null = + managedCatalog.error instanceof Error + ? managedCatalog.error + : serversError instanceof Error + ? serversError + : null + const statusById = new Map( + [...(servers ?? []), ...(managedCatalog.data?.servers ?? [])].map((server) => [ + server.id, + server.connectionStatus, + ]) + ) const toolsStateByServer = new Map< string, { isLoading: boolean; isFetching: boolean; error: Error | null } @@ -231,13 +288,13 @@ export function useMcpToolsQuery(workspaceId: string) { } return { data: tools, - isLoading: (serversLoading || anyServerLoading) && !hasData, - isFetching: serversLoading || results.some((r) => r.isFetching), + isLoading: (serversLoading || managedCatalog.isLoading || anyServerLoading) && !hasData, + isFetching: serversLoading || managedCatalog.isFetching || results.some((r) => r.isFetching), // Suppress when any healthy server rendered; per-server errors live in `toolsStateByServer`. error: hasData ? null : firstError, toolsStateByServer, } - }, [results, serversLoading, serverIds, servers]) + }, [results, serversLoading, serversError, serverIds, servers, managedCatalog]) } export function useForceRefreshMcpTools() { @@ -273,6 +330,7 @@ export function useForceRefreshMcpTools() { }, onSettled: (_data, _error, workspaceId) => { queryClient.invalidateQueries({ queryKey: mcpKeys.serversList(workspaceId) }) + queryClient.invalidateQueries({ queryKey: mcpKeys.managedCatalogList(workspaceId) }) queryClient.invalidateQueries({ queryKey: mcpKeys.storedToolsList(workspaceId) }) }, }) @@ -576,6 +634,7 @@ export function useMcpToolsEvents(workspaceId: string) { queryClient.invalidateQueries({ queryKey: mcpKeys.serverToolsWorkspace(workspaceId) }) } queryClient.invalidateQueries({ queryKey: mcpKeys.serversList(workspaceId) }) + queryClient.invalidateQueries({ queryKey: mcpKeys.managedCatalogList(workspaceId) }) queryClient.invalidateQueries({ queryKey: mcpKeys.storedToolsList(workspaceId) }) queryClient.invalidateQueries({ queryKey: workflowMcpServerKeys.all }) } diff --git a/apps/sim/hooks/queries/workflow-search-replace.ts b/apps/sim/hooks/queries/workflow-search-replace.ts index 0335a8a8563..4f837b3117d 100644 --- a/apps/sim/hooks/queries/workflow-search-replace.ts +++ b/apps/sim/hooks/queries/workflow-search-replace.ts @@ -6,6 +6,7 @@ import { type DiscoverMcpToolsResponse, discoverMcpToolsContract, type ListMcpServersResponse, + listManagedMcpCatalogContract, listMcpServersContract, } from '@/lib/api/contracts/mcp' import { @@ -400,11 +401,19 @@ export function useWorkflowSearchMcpServerDetails( const serversQuery = useQuery({ queryKey: workflowSearchReplaceKeys.mcpServerListDetails(workspaceId), - queryFn: ({ signal }: { signal: AbortSignal }) => - requestJson(listMcpServersContract, { - query: { workspaceId: workspaceId as string }, - signal, - }), + queryFn: async ({ signal }: { signal: AbortSignal }) => { + const [shared, managed] = await Promise.all([ + requestJson(listMcpServersContract, { + query: { workspaceId: workspaceId as string }, + signal, + }), + requestJson(listManagedMcpCatalogContract, { + query: { workspaceId: workspaceId as string }, + signal, + }), + ]) + return [...shared.data.servers, ...managed.servers] + }, enabled: Boolean(workspaceId && serverMatches.length > 0), staleTime: WORKFLOW_SEARCH_MCP_SERVER_LIST_STALE_TIME, }) @@ -412,7 +421,7 @@ export function useWorkflowSearchMcpServerDetails( return useMemo( () => serverMatches.map((match) => { - const server = serversQuery.data?.data.servers.find((item) => item.id === match.rawValue) + const server = serversQuery.data?.find((item) => item.id === match.rawValue) return { data: serversQuery.data ? { @@ -437,11 +446,19 @@ export function useWorkflowSearchMcpToolDetails( const toolsQuery = useQuery({ queryKey: workflowSearchReplaceKeys.mcpToolListDetails(workspaceId), - queryFn: ({ signal }: { signal: AbortSignal }) => - requestJson(discoverMcpToolsContract, { - query: { workspaceId: workspaceId as string }, - signal, - }), + queryFn: async ({ signal }: { signal: AbortSignal }) => { + const [shared, managed] = await Promise.all([ + requestJson(discoverMcpToolsContract, { + query: { workspaceId: workspaceId as string }, + signal, + }), + requestJson(listManagedMcpCatalogContract, { + query: { workspaceId: workspaceId as string }, + signal, + }), + ]) + return [...shared.data.tools, ...managed.tools] + }, enabled: Boolean(workspaceId && toolMatches.length > 0), staleTime: WORKFLOW_SEARCH_MCP_TOOL_LIST_STALE_TIME, }) @@ -449,7 +466,7 @@ export function useWorkflowSearchMcpToolDetails( return useMemo( () => toolMatches.map((match) => { - const tool = toolsQuery.data?.data.tools.find( + const tool = toolsQuery.data?.find( (item) => createMcpToolId(item.serverId, item.name) === match.rawValue ) return { @@ -706,16 +723,30 @@ export function useWorkflowSearchMcpServerReplacementOptions( queries: [ { queryKey: workflowSearchReplaceKeys.mcpServerReplacementOptions(workspaceId), - queryFn: ({ signal }: { signal: AbortSignal }) => - requestJson(listMcpServersContract, { - query: { workspaceId: workspaceId as string }, - signal, - }), + queryFn: async ({ + signal, + }: { + signal: AbortSignal + }): Promise => { + const [shared, managed] = await Promise.all([ + requestJson(listMcpServersContract, { + query: { workspaceId: workspaceId as string }, + signal, + }), + requestJson(listManagedMcpCatalogContract, { + query: { workspaceId: workspaceId as string }, + signal, + }), + ]) + return [...shared.data.servers, ...managed.servers] + }, enabled: Boolean(workspaceId && serverGroups.length > 0), staleTime: WORKFLOW_SEARCH_MCP_SERVER_REPLACEMENT_STALE_TIME, - select: (response: ListMcpServersResponse): WorkflowSearchReplacementOption[] => + select: ( + servers: ListMcpServersResponse['data']['servers'] + ): WorkflowSearchReplacementOption[] => serverGroups.flatMap((match) => - response.data.servers.map((server) => ({ + servers.map((server) => ({ kind: 'mcp-server', value: server.id, label: server.name, @@ -754,15 +785,29 @@ export function useWorkflowSearchMcpToolReplacementOptions( queries: [ { queryKey: workflowSearchReplaceKeys.mcpToolReplacementOptions(workspaceId), - queryFn: ({ signal }: { signal: AbortSignal }) => - requestJson(discoverMcpToolsContract, { - query: { workspaceId: workspaceId as string }, - signal, - }), + queryFn: async ({ + signal, + }: { + signal: AbortSignal + }): Promise => { + const [shared, managed] = await Promise.all([ + requestJson(discoverMcpToolsContract, { + query: { workspaceId: workspaceId as string }, + signal, + }), + requestJson(listManagedMcpCatalogContract, { + query: { workspaceId: workspaceId as string }, + signal, + }), + ]) + return [...shared.data.tools, ...managed.tools] + }, enabled: Boolean(workspaceId && toolGroups.length > 0), staleTime: WORKFLOW_SEARCH_MCP_TOOL_REPLACEMENT_STALE_TIME, - select: (response: DiscoverMcpToolsResponse): WorkflowSearchReplacementOption[] => - buildWorkflowSearchMcpToolReplacementOptions(toolGroups, response.data.tools), + select: ( + tools: DiscoverMcpToolsResponse['data']['tools'] + ): WorkflowSearchReplacementOption[] => + buildWorkflowSearchMcpToolReplacementOptions(toolGroups, tools), }, ], }) diff --git a/apps/sim/lib/api/contracts/credential-groups.test.ts b/apps/sim/lib/api/contracts/credential-groups.test.ts index 5f7edf584e3..7ca0cd7b232 100644 --- a/apps/sim/lib/api/contracts/credential-groups.test.ts +++ b/apps/sim/lib/api/contracts/credential-groups.test.ts @@ -14,7 +14,7 @@ import { import { CREDENTIAL_GROUP_WORKFLOW_ACCESS_LIMIT, CREDENTIAL_GROUP_WORKFLOW_CATALOG_LIMIT, -} from '@/lib/credential-groups/workflow-access-limits' +} from '@/lib/credential-groups/limits' describe('credential group contracts', () => { it('describes the shared managed OAuth callback as a redirect', () => { @@ -204,9 +204,13 @@ describe('credential group contracts', () => { createdAt: '2026-08-11T12:00:00.000Z', updatedAt: '2026-08-11T12:05:00.000Z', connections: [{ provider: 'gmail', status: 'active', count: 2 }], + mcpConnections: [{ mcpServerId: 'mcp-server-1', name: 'Fireflies', status: 'active' }], }) expect(result.connections).toEqual([{ provider: 'gmail', status: 'active', count: 2 }]) + expect(result.mcpConnections).toEqual([ + { mcpServerId: 'mcp-server-1', name: 'Fireflies', status: 'active' }, + ]) }) it('accepts a bounded unique workflow access selection', () => { diff --git a/apps/sim/lib/api/contracts/credential-groups.ts b/apps/sim/lib/api/contracts/credential-groups.ts index 94fba1f1e92..b96b71c8afd 100644 --- a/apps/sim/lib/api/contracts/credential-groups.ts +++ b/apps/sim/lib/api/contracts/credential-groups.ts @@ -2,14 +2,16 @@ import { z } from 'zod' import { workflowIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { - CREDENTIAL_GROUP_PROVIDER_IDS, - CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS, -} from '@/lib/credential-groups/providers' -import { + CREDENTIAL_GROUP_MCP_SERVER_LIMIT, CREDENTIAL_GROUP_WORKFLOW_ACCESS_LIMIT, CREDENTIAL_GROUP_WORKFLOW_CATALOG_LIMIT, CREDENTIAL_GROUP_WORKFLOW_NAME_MAX_LENGTH, -} from '@/lib/credential-groups/workflow-access-limits' +} from '@/lib/credential-groups/limits' +import { MANAGED_MCP_CONNECTOR_IDS } from '@/lib/credential-groups/managed-mcp-connectors' +import { + CREDENTIAL_GROUP_PROVIDER_IDS, + CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS, +} from '@/lib/credential-groups/providers' export const credentialGroupProviderSchema = z.enum(CREDENTIAL_GROUP_PROVIDER_IDS) export const credentialGroupStatusSchema = z.enum(['active', 'disabled']) @@ -25,6 +27,7 @@ export const credentialGroupOptionConfigurationStatusSchema = z.enum([ 'ready', 'needs_update', ]) +export const managedMcpConnectorIdSchema = z.enum(MANAGED_MCP_CONNECTOR_IDS) const credentialGroupOptionFields = { label: z.string().trim().min(1, 'Option label is required').max(100), @@ -71,12 +74,22 @@ export const credentialGroupOptionUpdateInputSchema = z.discriminatedUnion('prov slackCredentialGroupOptionInputSchema.extend({ id: z.string().min(1).max(128).optional() }), ]) +export const credentialGroupMcpServerSchema = z.object({ + id: z.string().min(1).max(128), + name: z.string().min(1), + description: z.string().nullable(), + authType: z.string().min(1), + enabled: z.boolean(), + managedConnectorId: managedMcpConnectorIdSchema, +}) + export const credentialGroupSchema = z.object({ id: z.string(), workspaceId: z.string(), name: z.string(), description: z.string().nullable(), options: z.array(credentialGroupOptionSchema).max(CREDENTIAL_GROUP_PROVIDER_IDS.length), + mcpServers: z.array(credentialGroupMcpServerSchema).max(CREDENTIAL_GROUP_MCP_SERVER_LIMIT), status: credentialGroupStatusSchema, createdAt: z.string(), updatedAt: z.string(), @@ -85,6 +98,7 @@ export const credentialGroupSchema = z.object({ export type CredentialGroup = z.output export type CredentialGroupOption = z.output export type CredentialGroupOptionInput = z.input +export type CredentialGroupMcpServer = z.output export const credentialGroupEnrollmentSchema = z.object({ id: z.string(), @@ -109,15 +123,27 @@ export const credentialGroupEnrollmentConnectionSchema = z.object({ count: z.number().int().positive(), }) +export const credentialGroupEnrollmentMcpConnectionSchema = z.object({ + mcpServerId: z.string().min(1).max(128), + name: z.string().min(1).max(255), + status: z.enum(['active', 'needs_reauth', 'revoked']), +}) + export const credentialGroupEnrollmentDetailSchema = credentialGroupEnrollmentSchema.extend({ connections: z .array(credentialGroupEnrollmentConnectionSchema) .max(CREDENTIAL_GROUP_PROVIDER_IDS.length * 3), + mcpConnections: z + .array(credentialGroupEnrollmentMcpConnectionSchema) + .max(CREDENTIAL_GROUP_MCP_SERVER_LIMIT), }) export type CredentialGroupEnrollmentConnection = z.output< typeof credentialGroupEnrollmentConnectionSchema > +export type CredentialGroupEnrollmentMcpConnection = z.output< + typeof credentialGroupEnrollmentMcpConnectionSchema +> export type CredentialGroupEnrollmentDetail = z.output export const credentialGroupAccessPolicySchema = z @@ -183,6 +209,10 @@ export const credentialGroupDetailParamsSchema = credentialGroupWorkspaceParamsS groupId: z.string().min(1, 'Credential group ID is required').max(128), }) +export const credentialGroupMcpConnectorParamsSchema = credentialGroupDetailParamsSchema.extend({ + connectorId: managedMcpConnectorIdSchema, +}) + export const credentialGroupEnrollmentParamsSchema = credentialGroupDetailParamsSchema.extend({ enrollmentId: z.string().min(1, 'Enrollment ID is required').max(128), }) @@ -196,6 +226,11 @@ export const startCredentialGroupOAuthParamsSchema = optionId: z.string().min(1, 'Credential option ID is required').max(128), }) +export const startCredentialGroupMcpOAuthParamsSchema = + publicCredentialGroupEnrollmentParamsSchema.extend({ + mcpServerId: z.string().min(1, 'MCP server ID is required').max(128), + }) + export const credentialGroupOAuthCallbackQuerySchema = z .object({ state: z.string().min(1, 'OAuth state is required').max(512), @@ -357,6 +392,40 @@ export const updateCredentialGroupBodySchema = z export type UpdateCredentialGroupBody = z.input +export const createCredentialGroupMcpConnectorBodySchema = z.discriminatedUnion('connectorId', [ + z.object({ connectorId: z.literal('fireflies') }).strict(), + z.object({ connectorId: z.literal('granola') }).strict(), + z + .object({ + connectorId: z.literal('databricks'), + name: z.string().trim().min(1, 'Name is required').max(100), + url: z.string().trim().url('Enter a valid Databricks MCP URL').max(2048), + oauthClientId: z.string().trim().min(1, 'OAuth Client ID is required').max(512), + oauthClientSecret: z.string().trim().min(1).max(2048).optional(), + }) + .strict(), +]) + +export type CreateCredentialGroupMcpConnectorBody = z.input< + typeof createCredentialGroupMcpConnectorBodySchema +> + +export const updateCredentialGroupMcpConnectorBodySchema = z + .object({ + name: z.string().trim().min(1, 'Name is required').max(100).optional(), + url: z.string().trim().url('Enter a valid Databricks MCP URL').max(2048).optional(), + oauthClientId: z.string().trim().min(1, 'OAuth Client ID is required').max(512).optional(), + oauthClientSecret: z.string().trim().min(1).max(2048).nullable().optional(), + }) + .strict() + .refine((body) => Object.keys(body).length > 0, { + message: 'At least one field must be updated', + }) + +export type UpdateCredentialGroupMcpConnectorBody = z.input< + typeof updateCredentialGroupMcpConnectorBodySchema +> + const listCredentialGroupsResponseSchema = z.object({ credentialGroups: z.array(credentialGroupSchema), /** @@ -458,6 +527,36 @@ export const updateCredentialGroupContract = defineRouteContract({ }, }) +export const createCredentialGroupMcpConnectorContract = defineRouteContract({ + method: 'POST', + path: '/api/workspaces/[id]/credential-groups/[groupId]/mcp-connectors', + params: credentialGroupDetailParamsSchema, + body: createCredentialGroupMcpConnectorBodySchema, + response: { + mode: 'json', + status: 201, + schema: z.object({ mcpServer: credentialGroupMcpServerSchema }), + }, +}) + +export const updateCredentialGroupMcpConnectorContract = defineRouteContract({ + method: 'PATCH', + path: '/api/workspaces/[id]/credential-groups/[groupId]/mcp-connectors/[connectorId]', + params: credentialGroupMcpConnectorParamsSchema, + body: updateCredentialGroupMcpConnectorBodySchema, + response: { + mode: 'json', + schema: z.object({ mcpServer: credentialGroupMcpServerSchema }), + }, +}) + +export const deleteCredentialGroupMcpConnectorContract = defineRouteContract({ + method: 'DELETE', + path: '/api/workspaces/[id]/credential-groups/[groupId]/mcp-connectors/[connectorId]', + params: credentialGroupMcpConnectorParamsSchema, + response: { mode: 'json', schema: z.object({ success: z.literal(true) }) }, +}) + export const getCredentialGroupAccessContract = defineRouteContract({ method: 'GET', path: '/api/workspaces/[id]/credential-groups/[groupId]/access', @@ -501,6 +600,13 @@ export const startCredentialGroupOAuthContract = defineRouteContract({ response: { mode: 'empty' }, }) +export const startCredentialGroupMcpOAuthContract = defineRouteContract({ + method: 'GET', + path: '/api/credential-groups/enroll/[token]/mcp/[mcpServerId]', + params: startCredentialGroupMcpOAuthParamsSchema, + response: { mode: 'empty' }, +}) + export const completeCredentialGroupEnrollmentContract = defineRouteContract({ method: 'POST', path: '/api/credential-groups/enroll/[token]/complete', diff --git a/apps/sim/lib/api/contracts/mcp.ts b/apps/sim/lib/api/contracts/mcp.ts index d720802b55c..523cbc01e59 100644 --- a/apps/sim/lib/api/contracts/mcp.ts +++ b/apps/sim/lib/api/contracts/mcp.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { type ContractJsonResponse, defineRouteContract } from '@/lib/api/contracts/types' import { v2TimestampSchema } from '@/lib/api/contracts/v2/shared' +import { MANAGED_MCP_CONNECTOR_IDS } from '@/lib/credential-groups/managed-mcp-connectors' import type { McpToolSchema, McpToolSchemaProperty } from '@/lib/mcp/types' const MAX_MCP_REFRESH_SERVER_IDS = 100 @@ -53,6 +54,7 @@ export const mcpTransportSchema = z.enum(['streamable-http']) const mcpTransportResponseSchema = mcpTransportSchema.catch('streamable-http') export const mcpAuthTypeSchema = z.enum(['none', 'headers', 'oauth']) +export const managedMcpConnectorIdSchema = z.enum(MANAGED_MCP_CONNECTOR_IDS) const consecutiveFailuresSchema = z.preprocess( (value) => (typeof value === 'number' ? value : undefined), @@ -97,6 +99,7 @@ export const mcpToolSchema = z.object({ inputSchema: mcpToolInputSchema, serverId: z.string(), serverName: z.string(), + managedConnectorId: managedMcpConnectorIdSchema.optional(), }) export const storedMcpToolSchema = z.object({ @@ -143,10 +146,22 @@ export const mcpServerSchema = z deletedAt: optionalDateStringFromNullableSchema, oauthClientId: optionalStringFromNullableSchema, hasOauthClientSecret: z.boolean().optional(), + credentialGroupId: optionalStringFromNullableSchema, + managedConnectorId: z.preprocess( + (value) => (value === null ? undefined : value), + managedMcpConnectorIdSchema.optional() + ), }) .passthrough() export type McpServer = z.output +export const managedMcpCatalogSchema = z.object({ + servers: z.array(mcpServerSchema).max(500), + tools: z.array(mcpToolSchema).max(500_000), +}) + +export type ManagedMcpCatalog = z.output + export const mcpWorkspaceQuerySchema = z.object({ workspaceId: z.string().min(1), }) @@ -170,6 +185,7 @@ export const createMcpServerBodySchema = z workspaceId: z.string().optional(), oauthClientId: z.string().nullable().optional(), oauthClientSecret: z.string().nullable().optional(), + managedConnectorId: z.never().optional(), }) .passthrough() @@ -317,6 +333,16 @@ export const listMcpServersContract = defineRouteContract({ ), }, }) + +export const listManagedMcpCatalogContract = defineRouteContract({ + method: 'GET', + path: '/api/mcp/managed-connections', + query: mcpWorkspaceQuerySchema, + response: { + mode: 'json', + schema: managedMcpCatalogSchema, + }, +}) export type ListMcpServersResponse = ContractJsonResponse export const createMcpServerContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/v2/__tests__/workflow-agent-tools.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/workflow-agent-tools.test.ts index dd5c2a1c115..7a00015bb7f 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/workflow-agent-tools.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/workflow-agent-tools.test.ts @@ -11,7 +11,7 @@ import { import { MAX_MCP_TOOL_NAME_BYTES } from '@/lib/mcp/constants' describe('v2AgentToolInputSchema', () => { - it('accepts catalog integration, custom-tool reference, and MCP tool shapes', () => { + it('accepts catalog integration, custom-tool reference, and both MCP tool shapes', () => { const tools = [ { type: 'cloudwatch', @@ -29,6 +29,11 @@ describe('v2AgentToolInputSchema', () => { params: { serverId: 'mcp_123', toolName: 'search_docs', collection: 'incidents' }, usageControl: 'none', }, + { + type: 'mcp-server-advanced', + params: { serverId: 'mcp_456' }, + usageControl: 'auto', + }, ] expect(v2AgentToolInputSchema.parse(tools)).toEqual(tools) @@ -56,6 +61,8 @@ describe('v2AgentToolInputSchema', () => { it.each([ [{ type: 'custom-tool', usageControl: 'auto' }], [{ type: 'mcp', params: { serverId: 'mcp_123' }, usageControl: 'auto' }], + [{ type: 'mcp-server-advanced', params: {}, usageControl: 'auto' }], + [{ type: 'mcp-server-advanced', params: { serverId: 'mcp_123', toolName: 'lookup' } }], [{ type: 'slack', operation: 'send', usageControl: 'sometimes' }], ])('rejects a malformed reserved tool shape', (tools) => { expect(v2AgentToolInputSchema.safeParse(tools).success).toBe(false) @@ -98,6 +105,13 @@ describe('v2AgentToolInputSchema', () => { }, }, ], + [ + 'advanced MCP server id', + { + type: 'mcp-server-advanced', + params: { serverId: 'a'.repeat(MAX_ID_LENGTH + 1) }, + }, + ], [ 'MCP multibyte tool name', { diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index 6a78ce0f8bf..2a2e828361b 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -2659,8 +2659,8 @@ export const v2AgentIntegrationToolSchema = z .min(1, 'Agent integration tool type cannot be empty') .max(255, 'Agent integration tool type must be at most 255 characters') .regex( - /^(?!(?:custom-tool|mcp)$).+$/, - 'Agent integration tool type must be a catalog block id, not `custom-tool` or `mcp`' + /^(?!(?:custom-tool|mcp|mcp-server-advanced)$).+$/, + 'Agent integration tool type must be a catalog block id, not a reserved custom or MCP type' ) .describe( 'Catalog block id, such as `cloudwatch` or `slack`. Use the block id, never an underlying tool id.' @@ -2820,9 +2820,49 @@ export const v2AgentMcpToolSchema = z ], }) +/** Every tool currently available through one workspace MCP server. */ +export const v2AgentMcpServerAdvancedSchema = z + .object({ + type: z.literal('mcp-server-advanced').describe('Server-wide MCP binding discriminator.'), + params: z + .object({ + serverId: z + .string() + .trim() + .min(1, 'Agent MCP serverId cannot be empty') + .max(MAX_ID_LENGTH, `Agent MCP serverId must be at most ${MAX_ID_LENGTH} characters`) + .describe( + 'Workspace MCP server ID or explicit credential-group managed MCP connection ID.' + ), + }) + .strict() + .describe('Server identity for discovering and invoking every available MCP tool.'), + usageControl: v2AgentToolUsageControlSchema.optional(), + }) + .catchall( + z.unknown().describe('Forward-compatible MCP server metadata preserved by the workflow editor.') + ) + .meta({ + id: 'AgentMcpServerAdvanced', + title: 'Agent MCP server (advanced)', + description: 'All tools available to the executing subject from one MCP server.', + examples: [ + { + type: 'mcp-server-advanced', + params: { serverId: 'mcp_01J9X2ABCDEF' }, + usageControl: 'auto', + }, + ], + }) + /** One callable tool attached directly to an Agent block. */ export const v2AgentToolSchema = z - .xor([v2AgentIntegrationToolSchema, v2AgentCustomToolSchema, v2AgentMcpToolSchema]) + .xor([ + v2AgentIntegrationToolSchema, + v2AgentCustomToolSchema, + v2AgentMcpToolSchema, + v2AgentMcpServerAdvancedSchema, + ]) .meta({ id: 'AgentTool', title: 'Agent tool', diff --git a/apps/sim/lib/credential-groups/application/enrollment-operations.ts b/apps/sim/lib/credential-groups/application/enrollment-operations.ts index eb17a758bca..c34ed537056 100644 --- a/apps/sim/lib/credential-groups/application/enrollment-operations.ts +++ b/apps/sim/lib/credential-groups/application/enrollment-operations.ts @@ -34,6 +34,18 @@ export const credentialGroupEnrollmentOperations = { principalKind: 'credential_group_enrollment', }), // permission-group-exempt: the enrollment principal is a one-time credential-connect token, not a workspace member, so no permission group governs it + startMcpOAuth: defineCredentialGroupEnrollmentOperation({ + id: 'credential_groups.enrollment.mcp_oauth.start', + capability: 'none', + principalKind: 'credential_group_enrollment', + }), + // permission-group-exempt: the enrollment principal is a one-time credential-connect token, not a workspace member, so no permission group governs it + completeMcpOAuth: defineCredentialGroupEnrollmentOperation({ + id: 'credential_groups.enrollment.mcp_oauth.complete', + capability: 'none', + principalKind: 'credential_group_enrollment', + }), + // permission-group-exempt: the enrollment principal is a one-time credential-connect token, not a workspace member, so no permission group governs it complete: defineCredentialGroupEnrollmentOperation({ id: 'credential_groups.enrollment.complete', capability: 'none', diff --git a/apps/sim/lib/credential-groups/application/list-mcp-connections.test.ts b/apps/sim/lib/credential-groups/application/list-mcp-connections.test.ts new file mode 100644 index 00000000000..b543cafb4d8 --- /dev/null +++ b/apps/sim/lib/credential-groups/application/list-mcp-connections.test.ts @@ -0,0 +1,203 @@ +/** + * @vitest-environment node + */ +import type { SessionPrincipal, WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getWorkspaceOwnerSubscriptionAccess: vi.fn(), + listMcpConnections: vi.fn(), + loadGroup: vi.fn(), + loadWorkspace: vi.fn(), + resolveCredentialGroupsAvailability: vi.fn(), + resolvePermission: vi.fn(), +})) + +vi.mock('@/lib/billing/core/workspace-access', () => ({ + getWorkspaceOwnerSubscriptionAccess: mocks.getWorkspaceOwnerSubscriptionAccess, +})) + +vi.mock('@/lib/credential-groups/availability', () => ({ + resolveCredentialGroupsAvailability: mocks.resolveCredentialGroupsAvailability, +})) + +vi.mock('@/lib/credential-groups/credentials', () => ({ + loadCredentialGroupCredentialListContext: mocks.loadGroup, +})) + +vi.mock('@/lib/credential-groups/mcp-connections', () => ({ + CredentialGroupMcpConnectionCursorNotFoundError: class extends Error { + constructor() { + super('Credential group MCP connection cursor not found') + this.name = 'CredentialGroupMcpConnectionCursorNotFoundError' + } + }, + listCredentialGroupMcpConnectionReferences: mocks.listMcpConnections, + MAX_CREDENTIAL_GROUP_MCP_CONNECTION_PAGE_SIZE: 100, +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +import { listCredentialGroupMcpConnections } from '@/lib/credential-groups/application/list-mcp-connections' +import { CredentialGroupMcpConnectionCursorNotFoundError } from '@/lib/credential-groups/mcp-connections' + +const groupContext = { + credentialGroupId: 'group-1', + workspaceId: 'workspace-1', + name: 'Credential Group', + status: 'active' as const, + options: [], +} +const workspaceContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const input = { credentialGroupId: 'group-1', limit: 50 } + +function executorPrincipal(credentialGroupId = 'group-1'): WorkflowExecutionDelegatedPrincipal { + return { + kind: 'delegated', + serviceId: 'executor', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:credential-groups', + issuedAt: new Date(Date.now() - 1_000), + expiresAt: new Date(Date.now() + 60_000), + resourceScope: { credentialGroupId }, + delegationContext: { + kind: 'workflow_execution', + workflowId: 'workflow-1', + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment', + deploymentVersionId: 'deployment-version-1', + }, + }, + } +} + +describe('listCredentialGroupMcpConnections', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadGroup.mockResolvedValue(groupContext) + mocks.loadWorkspace.mockResolvedValue(workspaceContext) + mocks.resolvePermission.mockResolvedValue('read') + mocks.getWorkspaceOwnerSubscriptionAccess.mockResolvedValue({ isEnterprise: true }) + mocks.resolveCredentialGroupsAvailability.mockResolvedValue({ available: true }) + mocks.listMcpConnections.mockResolvedValue({ + mcpConnections: [ + { + credentialId: 'mcp-cg-connection-1', + email: 'person@example.com', + displayName: 'Fireflies', + mcpServerId: 'mcp-server-1', + mcpServerName: 'Fireflies', + toolNames: ['list_transcripts'], + }, + ], + nextCursor: null, + }) + }) + + it('rejects unsupported principals before loading the group', async () => { + const principal: SessionPrincipal = { + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', + } + + await expect( + listCredentialGroupMcpConnections.execute({ principal, input }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.loadGroup).not.toHaveBeenCalled() + }) + + it('rejects executor delegation scoped to another group', async () => { + await expect( + listCredentialGroupMcpConnections.execute({ + principal: executorPrincipal('group-2'), + input, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.listMcpConnections).not.toHaveBeenCalled() + }) + + it('lists bounded MCP connection references after authorization and entitlement checks', async () => { + const result = await listCredentialGroupMcpConnections.execute({ + principal: executorPrincipal(), + input: { + ...input, + email: ' Person@Example.COM ', + mcpServerId: ' mcp-server-1 ', + }, + }) + + expect(mocks.listMcpConnections).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + credentialGroupId: 'group-1', + limit: 50, + cursor: undefined, + email: 'person@example.com', + mcpServerId: 'mcp-server-1', + }) + expect(result).toEqual({ + mcpConnections: [ + { + credentialId: 'mcp-cg-connection-1', + email: 'person@example.com', + displayName: 'Fireflies', + mcpServerId: 'mcp-server-1', + mcpServerName: 'Fireflies', + toolNames: ['list_transcripts'], + }, + ], + count: 1, + hasMore: false, + nextCursor: null, + }) + }) + + it('rejects invalid filters before querying MCP connections', async () => { + await expect( + listCredentialGroupMcpConnections.execute({ + principal: executorPrincipal(), + input: { ...input, email: 'not-an-email' }, + }) + ).rejects.toMatchObject({ code: 'validation', message: 'Email must be a valid address' }) + expect(mocks.listMcpConnections).not.toHaveBeenCalled() + }) + + it('fails before listing when the group is disabled', async () => { + mocks.loadGroup.mockResolvedValue({ ...groupContext, status: 'disabled' }) + + await expect( + listCredentialGroupMcpConnections.execute({ principal: executorPrincipal(), input }) + ).rejects.toMatchObject({ code: 'conflict' }) + expect(mocks.listMcpConnections).not.toHaveBeenCalled() + }) + + it('classifies a stale or cross-group cursor as invalid input', async () => { + mocks.listMcpConnections.mockRejectedValueOnce( + new CredentialGroupMcpConnectionCursorNotFoundError() + ) + + await expect( + listCredentialGroupMcpConnections.execute({ + principal: executorPrincipal(), + input: { ...input, cursor: 'mcp-cg-other' }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + }) +}) diff --git a/apps/sim/lib/credential-groups/application/list-mcp-connections.ts b/apps/sim/lib/credential-groups/application/list-mcp-connections.ts new file mode 100644 index 00000000000..4eda63879fe --- /dev/null +++ b/apps/sim/lib/credential-groups/application/list-mcp-connections.ts @@ -0,0 +1,87 @@ +import { isValidEmailSyntax, normalizeEmail } from '@sim/utils/string' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { credentialGroupDelegationPolicy } from '@/lib/credential-groups/application/authorization' +import { + requireCredentialGroupsAvailable, + resolveCredentialGroupContext, +} from '@/lib/credential-groups/application/context' +import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' +import { + CredentialGroupMcpConnectionCursorNotFoundError, + type CredentialGroupMcpConnectionReference, + listCredentialGroupMcpConnectionReferences, + MAX_CREDENTIAL_GROUP_MCP_CONNECTION_PAGE_SIZE, +} from '@/lib/credential-groups/mcp-connections' + +export interface ListCredentialGroupMcpConnectionsInput { + credentialGroupId: string + limit: number + cursor?: string + email?: string + mcpServerId?: string +} + +export interface ListCredentialGroupMcpConnectionsResult { + mcpConnections: CredentialGroupMcpConnectionReference[] + count: number + hasMore: boolean + nextCursor: string | null +} + +export const listCredentialGroupMcpConnections = defineAuthorizedWorkspaceUseCase({ + operation: credentialGroupOperations.listMcpConnections, + resolveContext: ({ input }: { input: ListCredentialGroupMcpConnectionsInput }) => + resolveCredentialGroupContext(input.credentialGroupId), + authorizationOptions: { delegation: credentialGroupDelegationPolicy }, + execute: async ({ input, context }): Promise => { + if ( + !Number.isInteger(input.limit) || + input.limit < 1 || + input.limit > MAX_CREDENTIAL_GROUP_MCP_CONNECTION_PAGE_SIZE + ) { + throw new OrchestrationError( + 'validation', + `Limit must be an integer between 1 and ${MAX_CREDENTIAL_GROUP_MCP_CONNECTION_PAGE_SIZE}` + ) + } + if (context.status !== 'active') { + throw new OrchestrationError('conflict', 'Credential group is disabled') + } + + const email = input.email ? normalizeEmail(input.email) : undefined + if (email && !isValidEmailSyntax(email)) { + throw new OrchestrationError('validation', 'Email must be a valid address') + } + const mcpServerId = input.mcpServerId?.trim() + if (input.mcpServerId !== undefined && !mcpServerId) { + throw new OrchestrationError('validation', 'MCP server ID must not be empty') + } + + await requireCredentialGroupsAvailable(context.workspaceId) + + let page + try { + page = await listCredentialGroupMcpConnectionReferences({ + workspaceId: context.workspaceId, + credentialGroupId: context.credentialGroupId, + limit: input.limit, + cursor: input.cursor, + email, + mcpServerId, + }) + } catch (error) { + if (error instanceof CredentialGroupMcpConnectionCursorNotFoundError) { + throw new OrchestrationError('validation', error.message) + } + throw error + } + + return { + mcpConnections: page.mcpConnections, + count: page.mcpConnections.length, + hasMore: page.nextCursor !== null, + nextCursor: page.nextCursor, + } + }, +}) diff --git a/apps/sim/lib/credential-groups/application/manage-access.test.ts b/apps/sim/lib/credential-groups/application/manage-access.test.ts index b333ef02b0c..f5cceaadf7d 100644 --- a/apps/sim/lib/credential-groups/application/manage-access.test.ts +++ b/apps/sim/lib/credential-groups/application/manage-access.test.ts @@ -10,7 +10,7 @@ import { credentialGroupWorkflowAccessPolicyCodec, decodeCredentialGroupWorkflowAccessPolicy, } from '@/lib/credential-groups/application/workflow-access-policy' -import { CREDENTIAL_GROUP_WORKFLOW_CATALOG_LIMIT } from '@/lib/credential-groups/workflow-access-limits' +import { CREDENTIAL_GROUP_WORKFLOW_CATALOG_LIMIT } from '@/lib/credential-groups/limits' const mocks = vi.hoisted(() => ({ requirePolicy: vi.fn(), diff --git a/apps/sim/lib/credential-groups/application/manage-access.ts b/apps/sim/lib/credential-groups/application/manage-access.ts index dbbbcd63c69..cdb3b5e03f1 100644 --- a/apps/sim/lib/credential-groups/application/manage-access.ts +++ b/apps/sim/lib/credential-groups/application/manage-access.ts @@ -17,7 +17,7 @@ import { import { CREDENTIAL_GROUP_WORKFLOW_CATALOG_LIMIT, CREDENTIAL_GROUP_WORKFLOW_NAME_MAX_LENGTH, -} from '@/lib/credential-groups/workflow-access-limits' +} from '@/lib/credential-groups/limits' import { ResourcePolicyRevisionConflictError, requireResourcePolicy, diff --git a/apps/sim/lib/credential-groups/application/manage-enrollments.ts b/apps/sim/lib/credential-groups/application/manage-enrollments.ts index b4220f4a18c..970a98d74b3 100644 --- a/apps/sim/lib/credential-groups/application/manage-enrollments.ts +++ b/apps/sim/lib/credential-groups/application/manage-enrollments.ts @@ -14,6 +14,7 @@ import { loadCredentialGroupInviterIdentity, resendCredentialGroupEnrollment, } from '@/lib/credential-groups/enrollments' +import { mcpService } from '@/lib/mcp/service' interface CredentialGroupEnrollmentSettingsInput { assertedWorkspaceId: string @@ -122,12 +123,11 @@ export const deleteCredentialGroupEnrollmentSettings = defineAuthorizedWorkspace async execute({ input, context }) { await requireCredentialGroupSettingsAvailable(context.workspaceId) try { - const credentialGroupEnrollment = await deleteCredentialGroupEnrollment( + return await deleteCredentialGroupEnrollment( context.workspaceId, context.credentialGroupId, input.enrollmentId ) - return { credentialGroupEnrollment } } catch (error) { normalizeEnrollmentError(error) } @@ -140,4 +140,10 @@ export const deleteCredentialGroupEnrollmentSettings = defineAuthorizedWorkspace description: `Deleted ${result.credentialGroupEnrollment.email} from the Credential Group`, metadata: { enrollmentId: result.credentialGroupEnrollment.id }, }), + afterSuccess: ({ result }) => + Promise.all( + result.retiredMcpConnectionIds.map((connectionId) => + mcpService.evictServerConnections(connectionId, 'credential_group_enrollment_deleted') + ) + ).then(() => undefined), }) diff --git a/apps/sim/lib/credential-groups/application/manage-groups.ts b/apps/sim/lib/credential-groups/application/manage-groups.ts index 3825bf52b69..2ab02cb8528 100644 --- a/apps/sim/lib/credential-groups/application/manage-groups.ts +++ b/apps/sim/lib/credential-groups/application/manage-groups.ts @@ -17,6 +17,7 @@ import { CredentialGroupEnrollmentError, listCredentialGroupEnrollments, } from '@/lib/credential-groups/enrollments' +import { clearCredentialGroupMcpOAuthAttempts } from '@/lib/credential-groups/mcp-oauth-state' import { listConfiguredCredentialGroupProviders } from '@/lib/credential-groups/provider-availability' import { createCredentialGroup, @@ -29,6 +30,8 @@ import type { CreateCredentialGroupInput, UpdateCredentialGroupInput, } from '@/lib/credential-groups/types' +import { evictMcpServerConnections } from '@/lib/mcp/connection-pool' +import { mcpService } from '@/lib/mcp/service' function throwCredentialGroupConflict(error: unknown): never { if (getPostgresErrorCode(error) === '23505') { @@ -137,15 +140,15 @@ export const updateCredentialGroupSettings = defineAuthorizedWorkspaceUseCase({ async execute({ input, context }) { await requireCredentialGroupSettingsAvailable(context.workspaceId) try { - const credentialGroup = await updateCredentialGroup( + const result = await updateCredentialGroup( context.workspaceId, context.credentialGroupId, validateUpdateCredentialGroupInput(input.update) ) - if (!credentialGroup) { + if (!result) { throw new OrchestrationError('not_found', 'Credential group not found') } - return { credentialGroup } + return result } catch (error) { throwCredentialGroupConflict(error) } @@ -157,6 +160,12 @@ export const updateCredentialGroupSettings = defineAuthorizedWorkspaceUseCase({ resourceName: result.credentialGroup.name, description: 'Updated a Credential Group', }), + afterSuccess: ({ result }) => + Promise.all( + result.retiredMcpConnectionIds.map((connectionId) => + evictMcpServerConnections(connectionId, 'credential_group_mcp_unlinked') + ) + ).then(() => undefined), }) export const deleteCredentialGroupSettings = defineAuthorizedWorkspaceUseCase({ @@ -166,9 +175,13 @@ export const deleteCredentialGroupSettings = defineAuthorizedWorkspaceUseCase({ authorizationOptions: {}, async execute({ context }) { await requireCredentialGroupSettingsAvailable(context.workspaceId) - const deleted = await deleteCredentialGroup(context.workspaceId, context.credentialGroupId) - if (!deleted) throw new OrchestrationError('not_found', 'Credential group not found') - return { success: true as const } + const result = await deleteCredentialGroup(context.workspaceId, context.credentialGroupId) + if (!result.deleted) throw new OrchestrationError('not_found', 'Credential group not found') + return { + success: true as const, + retiredMcpConnectionIds: result.retiredMcpConnectionIds, + retiredMcpServerIds: result.retiredMcpServerIds, + } }, projectAudit: ({ context }) => ({ action: AuditAction.CREDENTIAL_GROUP_UPDATED, @@ -177,4 +190,16 @@ export const deleteCredentialGroupSettings = defineAuthorizedWorkspaceUseCase({ resourceName: context.name, description: 'Deleted a Credential Group', }), + async afterSuccess({ context, result }) { + await mcpService.clearCache(context.workspaceId) + await clearCredentialGroupMcpOAuthAttempts(result.retiredMcpServerIds) + await Promise.all([ + ...result.retiredMcpServerIds.map((serverId) => + evictMcpServerConnections(serverId, 'credential_group_deleted') + ), + ...result.retiredMcpConnectionIds.map((connectionId) => + evictMcpServerConnections(connectionId, 'credential_group_deleted') + ), + ]) + }, }) diff --git a/apps/sim/lib/credential-groups/application/manage-mcp-connectors.ts b/apps/sim/lib/credential-groups/application/manage-mcp-connectors.ts new file mode 100644 index 00000000000..e2974b868be --- /dev/null +++ b/apps/sim/lib/credential-groups/application/manage-mcp-connectors.ts @@ -0,0 +1,165 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + requireCredentialGroupSettingsAvailable, + resolveCredentialGroupSettingsContext, +} from '@/lib/credential-groups/application/context' +import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' +import type { ManagedMcpConnectorId } from '@/lib/credential-groups/managed-mcp-connectors' +import { + type CreateManagedMcpConnectorInput, + createManagedMcpConnector, + deleteManagedMcpConnector, + ManagedMcpConnectorError, + type UpdateManagedMcpConnectorInput, + updateManagedMcpConnector, +} from '@/lib/credential-groups/managed-mcp-service' +import { clearCredentialGroupMcpOAuthAttempts } from '@/lib/credential-groups/mcp-oauth-state' +import { evictMcpServerConnections } from '@/lib/mcp/connection-pool' +import { mcpService } from '@/lib/mcp/service' + +interface ManagedMcpConnectorTargetInput { + assertedWorkspaceId: string + credentialGroupId: string +} + +function projectManagedMcpConnectorError(error: unknown): never { + if (error instanceof ManagedMcpConnectorError) { + throw new OrchestrationError( + error.code === 'bad_gateway' ? 'validation' : error.code, + error.message + ) + } + throw error +} + +async function applyManagedMcpConnectorEffects(params: { + workspaceId: string + serverIds: string[] + connectionIds: string[] + reason: string +}): Promise { + await mcpService.clearCache(params.workspaceId) + await clearCredentialGroupMcpOAuthAttempts(params.serverIds) + await Promise.all([ + ...params.serverIds.map((serverId) => evictMcpServerConnections(serverId, params.reason)), + ...params.connectionIds.map((connectionId) => + evictMcpServerConnections(connectionId, params.reason) + ), + ]) +} + +export interface CreateCredentialGroupMcpConnectorInput extends ManagedMcpConnectorTargetInput { + connector: CreateManagedMcpConnectorInput +} + +export const createCredentialGroupMcpConnector = defineAuthorizedWorkspaceUseCase({ + operation: credentialGroupOperations.createMcpConnector, + resolveContext: ({ input }: { input: CreateCredentialGroupMcpConnectorInput }) => + resolveCredentialGroupSettingsContext(input.credentialGroupId, input.assertedWorkspaceId), + authorizationOptions: {}, + async execute({ principal, input, context }) { + await requireCredentialGroupSettingsAvailable(context.workspaceId) + try { + return await createManagedMcpConnector({ + workspaceId: context.workspaceId, + credentialGroupId: context.credentialGroupId, + userId: principal.userId, + input: input.connector, + }) + } catch (error) { + projectManagedMcpConnectorError(error) + } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.MCP_SERVER_ADDED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: result.mcpServer.id, + resourceName: result.mcpServer.name, + description: `Added managed MCP connector "${result.mcpServer.name}"`, + }), + afterSuccess: ({ context, result }) => + applyManagedMcpConnectorEffects({ + workspaceId: context.workspaceId, + serverIds: [], + connectionIds: [], + reason: 'managed connector added', + }), +}) + +export interface UpdateCredentialGroupMcpConnectorInput extends ManagedMcpConnectorTargetInput { + connectorId: ManagedMcpConnectorId + update: UpdateManagedMcpConnectorInput +} + +export const updateCredentialGroupMcpConnector = defineAuthorizedWorkspaceUseCase({ + operation: credentialGroupOperations.updateMcpConnector, + resolveContext: ({ input }: { input: UpdateCredentialGroupMcpConnectorInput }) => + resolveCredentialGroupSettingsContext(input.credentialGroupId, input.assertedWorkspaceId), + authorizationOptions: {}, + async execute({ input, context }) { + await requireCredentialGroupSettingsAvailable(context.workspaceId) + try { + return await updateManagedMcpConnector({ + workspaceId: context.workspaceId, + credentialGroupId: context.credentialGroupId, + connectorId: input.connectorId, + input: input.update, + }) + } catch (error) { + projectManagedMcpConnectorError(error) + } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.MCP_SERVER_UPDATED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: result.mcpServer.id, + resourceName: result.mcpServer.name, + description: `Updated managed MCP connector "${result.mcpServer.name}"`, + }), + afterSuccess: ({ context, result }) => + applyManagedMcpConnectorEffects({ + workspaceId: context.workspaceId, + serverIds: result.resetMcpServerIds, + connectionIds: result.retiredMcpConnectionIds, + reason: 'managed connector configuration changed', + }), +}) + +export interface DeleteCredentialGroupMcpConnectorInput extends ManagedMcpConnectorTargetInput { + connectorId: ManagedMcpConnectorId +} + +export const deleteCredentialGroupMcpConnector = defineAuthorizedWorkspaceUseCase({ + operation: credentialGroupOperations.deleteMcpConnector, + resolveContext: ({ input }: { input: DeleteCredentialGroupMcpConnectorInput }) => + resolveCredentialGroupSettingsContext(input.credentialGroupId, input.assertedWorkspaceId), + authorizationOptions: {}, + async execute({ input, context }) { + await requireCredentialGroupSettingsAvailable(context.workspaceId) + try { + return await deleteManagedMcpConnector({ + workspaceId: context.workspaceId, + credentialGroupId: context.credentialGroupId, + connectorId: input.connectorId, + }) + } catch (error) { + projectManagedMcpConnectorError(error) + } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.MCP_SERVER_REMOVED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: result.mcpServer.id, + resourceName: result.mcpServer.name, + description: `Removed managed MCP connector "${result.mcpServer.name}"`, + }), + afterSuccess: ({ context, result }) => + applyManagedMcpConnectorEffects({ + workspaceId: context.workspaceId, + serverIds: result.serverIds, + connectionIds: result.retiredMcpConnectionIds, + reason: 'managed connector removed', + }), +}) diff --git a/apps/sim/lib/credential-groups/application/operations.ts b/apps/sim/lib/credential-groups/application/operations.ts index 409920b039e..633a7689349 100644 --- a/apps/sim/lib/credential-groups/application/operations.ts +++ b/apps/sim/lib/credential-groups/application/operations.ts @@ -72,6 +72,30 @@ export const credentialGroupOperations = { principalKinds: ['session'], }), // permission-group-exempt: workspace admin already decides this, and no group key names the credential-groups section + createMcpConnector: defineWorkspaceOperation({ + id: 'credential_groups.mcp_connectors.create', + minimumRole: 'admin', + workspaceApiKey: 'deny', + capability: 'none', + principalKinds: ['session'], + }), + // permission-group-exempt: workspace admin already decides this, and no group key names the credential-groups section + updateMcpConnector: defineWorkspaceOperation({ + id: 'credential_groups.mcp_connectors.update', + minimumRole: 'admin', + workspaceApiKey: 'deny', + capability: 'none', + principalKinds: ['session'], + }), + // permission-group-exempt: workspace admin already decides this, and no group key names the credential-groups section + deleteMcpConnector: defineWorkspaceOperation({ + id: 'credential_groups.mcp_connectors.delete', + minimumRole: 'admin', + workspaceApiKey: 'deny', + capability: 'none', + principalKinds: ['session'], + }), + // permission-group-exempt: workspace admin already decides this, and no group key names the credential-groups section inviteBatch: defineWorkspaceOperation({ id: 'credential_groups.invites.send_batch', minimumRole: 'admin', @@ -104,6 +128,15 @@ export const credentialGroupOperations = { principalKinds: ['delegated'], delegatedServices: ['executor'], }), + // permission-group-exempt: read by the executor to resolve an enrolled person's MCP connection; use is enforced by the Credential Group policy + listMcpConnections: defineWorkspaceOperation({ + id: 'credential_groups.mcp_connections.list', + minimumRole: 'read', + workspaceApiKey: 'deny', + capability: 'none', + principalKinds: ['delegated'], + delegatedServices: ['executor'], + }), // permission-group-exempt: read by the executor to resolve an enrolled person's credential; the group's enrollment rows are the gate, and no group key names them listGroups: defineWorkspaceOperation({ id: 'credential_groups.list', diff --git a/apps/sim/lib/credential-groups/application/public-enrollment.test.ts b/apps/sim/lib/credential-groups/application/public-enrollment.test.ts index f6cc790ef10..3a023ed69ea 100644 --- a/apps/sim/lib/credential-groups/application/public-enrollment.test.ts +++ b/apps/sim/lib/credential-groups/application/public-enrollment.test.ts @@ -10,16 +10,24 @@ const mocks = vi.hoisted(() => ({ completeOAuth: vi.fn(), fireTrigger: vi.fn(), getEnrollment: vi.fn(), + getMcpOAuthContext: vi.fn(), getOAuthContext: vi.fn(), + startMcpOAuth: vi.fn(), startOAuth: vi.fn(), })) vi.mock('@/lib/credential-groups/enrollments', () => ({ completeAuthorizedCredentialGroupEnrollment: mocks.completeEnrollment, + getAuthorizedCredentialGroupMcpOAuthContext: mocks.getMcpOAuthContext, getAuthorizedCredentialGroupOAuthContext: mocks.getOAuthContext, getAuthorizedPublicCredentialGroupEnrollment: mocks.getEnrollment, })) +vi.mock('@/lib/credential-groups/mcp-oauth', () => ({ + completeCredentialGroupMcpOAuth: vi.fn(), + startCredentialGroupMcpOAuth: mocks.startMcpOAuth, +})) + vi.mock('@/lib/credential-groups/oauth', () => ({ completeCredentialGroupOAuth: mocks.completeOAuth, startCredentialGroupOAuth: mocks.startOAuth, @@ -33,6 +41,7 @@ import { completePublicCredentialGroupEnrollment, completePublicCredentialGroupOAuth, readPublicCredentialGroupEnrollment, + startPublicCredentialGroupMcpOAuth, startPublicCredentialGroupOAuth, } from '@/lib/credential-groups/application/public-enrollment' @@ -90,7 +99,13 @@ describe('public Credential Group enrollment application operations', () => { displayName: 'person@example.com', enrollmentStatus: 'in_progress', }) + mocks.getMcpOAuthContext.mockResolvedValue({ + enrollmentId: 'enrollment-1', + credentialGroupId: 'group-1', + server: { id: 'mcp-server-1' }, + }) mocks.startOAuth.mockResolvedValue('https://accounts.example/authorize') + mocks.startMcpOAuth.mockResolvedValue('https://mcp.example/authorize') }) it('rejects a workspace session before resolving invitation data', async () => { @@ -147,6 +162,30 @@ describe('public Credential Group enrollment application operations', () => { expect(result).toEqual({ authorizationUrl: 'https://accounts.example/authorize' }) }) + it('rejects a substituted bearer before creating managed MCP state', async () => { + await expect( + startPublicCredentialGroupMcpOAuth.execute({ + principal, + input: { invitationToken: 'different-token', mcpServerId: 'mcp-server-1' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + expect(mocks.startMcpOAuth).not.toHaveBeenCalled() + }) + + it('starts managed MCP OAuth only for a server linked to the current invitation', async () => { + const result = await startPublicCredentialGroupMcpOAuth.execute({ + principal, + input: { invitationToken, mcpServerId: 'mcp-server-1' }, + }) + + expect(mocks.getMcpOAuthContext).toHaveBeenCalledWith(identity, 'mcp-server-1') + expect(mocks.startMcpOAuth).toHaveBeenCalledWith( + expect.objectContaining({ server: { id: 'mcp-server-1' } }), + invitationToken + ) + expect(result).toEqual({ authorizationUrl: 'https://mcp.example/authorize' }) + }) + it('fires form submitted only for the first completion transition', async () => { mocks.completeEnrollment.mockResolvedValue({ completed: true, transitioned: true }) diff --git a/apps/sim/lib/credential-groups/application/public-enrollment.ts b/apps/sim/lib/credential-groups/application/public-enrollment.ts index 5b3023aad8f..a8d50fa48e7 100644 --- a/apps/sim/lib/credential-groups/application/public-enrollment.ts +++ b/apps/sim/lib/credential-groups/application/public-enrollment.ts @@ -6,10 +6,16 @@ import { OrchestrationError } from '@/lib/core/orchestration/types' import { credentialGroupEnrollmentOperations } from '@/lib/credential-groups/application/enrollment-operations' import { completeAuthorizedCredentialGroupEnrollment, + getAuthorizedCredentialGroupMcpOAuthContext, getAuthorizedCredentialGroupOAuthContext, getAuthorizedPublicCredentialGroupEnrollment, type PublicCredentialGroupEnrollmentIdentity, } from '@/lib/credential-groups/enrollments' +import { + completeCredentialGroupMcpOAuth, + startCredentialGroupMcpOAuth, +} from '@/lib/credential-groups/mcp-oauth' +import type { CredentialGroupMcpOAuthAttempt } from '@/lib/credential-groups/mcp-oauth-state' import { completeCredentialGroupOAuth, startCredentialGroupOAuth, @@ -214,3 +220,60 @@ export const completePublicCredentialGroupOAuth = defineAuthorizedCredentialGrou return { connectedOptionId: context.oauth.option.id } }, }) + +interface PublicCredentialGroupMcpOAuthInput { + invitationToken: string + mcpServerId: string +} + +interface PublicCredentialGroupMcpOAuthContext extends PublicCredentialGroupEnrollmentIdentity { + oauth: NonNullable>> +} + +async function resolvePublicMcpOAuthContext( + principal: CredentialGroupEnrollmentPrincipal, + mcpServerId: string +): Promise { + const identity = identityFromPrincipal(principal) + const oauth = await getAuthorizedCredentialGroupMcpOAuthContext(identity, mcpServerId) + if (!oauth) throw new OrchestrationError('not_found', 'Invitation is invalid or expired') + return { ...identity, oauth } +} + +export const startPublicCredentialGroupMcpOAuth = defineAuthorizedCredentialGroupEnrollmentUseCase({ + operation: credentialGroupEnrollmentOperations.startMcpOAuth, + resolveContext: ({ + principal, + input, + }: { + principal: CredentialGroupEnrollmentPrincipal + input: PublicCredentialGroupMcpOAuthInput + }) => resolvePublicMcpOAuthContext(principal, input.mcpServerId), + async execute({ principal, input, context }) { + requireInvitationToken(principal, input.invitationToken) + return { + authorizationUrl: await startCredentialGroupMcpOAuth(context.oauth, input.invitationToken), + } + }, +}) + +interface CompletePublicCredentialGroupMcpOAuthInput { + attempt: CredentialGroupMcpOAuthAttempt + code: string +} + +export const completePublicCredentialGroupMcpOAuth = + defineAuthorizedCredentialGroupEnrollmentUseCase({ + operation: credentialGroupEnrollmentOperations.completeMcpOAuth, + resolveContext: ({ + principal, + input, + }: { + principal: CredentialGroupEnrollmentPrincipal + input: CompletePublicCredentialGroupMcpOAuthInput + }) => resolvePublicMcpOAuthContext(principal, input.attempt.mcpServerId), + async execute({ principal, input, context }) { + requireInvitationToken(principal, input.attempt.invitationToken) + return completeCredentialGroupMcpOAuth(context.oauth, input.attempt.codeVerifier, input.code) + }, + }) diff --git a/apps/sim/lib/credential-groups/application/workflow-access-policy.test.ts b/apps/sim/lib/credential-groups/application/workflow-access-policy.test.ts index abd5feb5c50..151608c4f6c 100644 --- a/apps/sim/lib/credential-groups/application/workflow-access-policy.test.ts +++ b/apps/sim/lib/credential-groups/application/workflow-access-policy.test.ts @@ -10,7 +10,7 @@ import { evaluateCredentialGroupWorkflowAccess, requireDefaultCredentialGroupWorkflowAccessPolicy, } from '@/lib/credential-groups/application/workflow-access-policy' -import { CREDENTIAL_GROUP_WORKFLOW_ACCESS_LIMIT } from '@/lib/credential-groups/workflow-access-limits' +import { CREDENTIAL_GROUP_WORKFLOW_ACCESS_LIMIT } from '@/lib/credential-groups/limits' import type { ResourcePolicyBindingFor } from '@/lib/resource-policies/registry' const GROUP_ID = 'group-1' diff --git a/apps/sim/lib/credential-groups/application/workflow-access-policy.ts b/apps/sim/lib/credential-groups/application/workflow-access-policy.ts index f0e3248ceac..29faac88e46 100644 --- a/apps/sim/lib/credential-groups/application/workflow-access-policy.ts +++ b/apps/sim/lib/credential-groups/application/workflow-access-policy.ts @@ -1,6 +1,6 @@ import type { WorkflowExecutionAuthority } from '@sim/auth/principal' import { z } from 'zod' -import { CREDENTIAL_GROUP_WORKFLOW_ACCESS_LIMIT } from '@/lib/credential-groups/workflow-access-limits' +import { CREDENTIAL_GROUP_WORKFLOW_ACCESS_LIMIT } from '@/lib/credential-groups/limits' import { CREDENTIAL_GROUP_ACTOR_OWNS_CREDENTIAL_CONDITION_KEY } from '@/lib/resource-policies/conditions' import { WORKFLOW_MODE_RESOURCE_POLICY_CONDITION_KEY } from '@/lib/resource-policies/conditions/workflow-mode' import { diff --git a/apps/sim/lib/credential-groups/enrollments.test.ts b/apps/sim/lib/credential-groups/enrollments.test.ts index 48657895ca4..6424f0e1c17 100644 --- a/apps/sim/lib/credential-groups/enrollments.test.ts +++ b/apps/sim/lib/credential-groups/enrollments.test.ts @@ -331,12 +331,14 @@ describe('deleteCredentialGroupEnrollment', () => { }) it('deletes the enrollment and lets its foreign-key cascade remove managed credentials', async () => { - dbChainMockFns.limit.mockResolvedValueOnce([{ email: ENROLLMENT.email }]) + queueTableRows(schemaMock.credentialGroupEnrollment, [{ email: ENROLLMENT.email }]) + queueTableRows(schemaMock.credential, [{ id: 'mcp-cg-connection-1' }]) dbChainMockFns.returning.mockResolvedValueOnce([ENROLLMENT]) const result = await deleteCredentialGroupEnrollment('workspace-1', 'group-1', ENROLLMENT.id) - expect(result.id).toBe(ENROLLMENT.id) + expect(result.credentialGroupEnrollment.id).toBe(ENROLLMENT.id) + expect(result.retiredMcpConnectionIds).toEqual(['mcp-cg-connection-1']) expect(dbChainMockFns.update).not.toHaveBeenCalled() expect(dbChainMockFns.delete).toHaveBeenCalledOnce() expect(dbChainMockFns.delete).toHaveBeenCalledWith(schemaMock.credentialGroupEnrollment) diff --git a/apps/sim/lib/credential-groups/enrollments.ts b/apps/sim/lib/credential-groups/enrollments.ts index f4579c77026..a22ac3fd42a 100644 --- a/apps/sim/lib/credential-groups/enrollments.ts +++ b/apps/sim/lib/credential-groups/enrollments.ts @@ -4,6 +4,7 @@ import { credential, credentialGroup, credentialGroupEnrollment, + mcpServers, user, workspace, } from '@sim/db/schema' @@ -11,12 +12,14 @@ import { sha256Hex } from '@sim/security/hash' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { normalizeEmail, truncate } from '@sim/utils/string' -import { and, count, desc, eq, inArray, lt, or, sql } from 'drizzle-orm' +import { and, asc, count, desc, eq, inArray, isNull, lt, or, sql } from 'drizzle-orm' import { renderCredentialGroupInvitationEmail } from '@/components/emails/credential-groups/render' import { getCredentialGroupInvitationSubject } from '@/components/emails/subjects' import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access' import { getBaseUrl } from '@/lib/core/utils/urls' import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability' +import type { ManagedMcpConnectorId } from '@/lib/credential-groups/managed-mcp-connectors' +import { getManagedMcpConnector } from '@/lib/credential-groups/managed-mcp-connectors' import { getCredentialGroupProviderAdapter } from '@/lib/credential-groups/provider-registry' import type { CredentialGroupProvider } from '@/lib/credential-groups/providers' import { @@ -27,6 +30,7 @@ import { import type { CredentialGroupEnrollmentConnection, CredentialGroupEnrollmentDetail, + CredentialGroupEnrollmentMcpConnection, CredentialGroupEnrollmentRecord, InviteCredentialGroupEnrollmentsInput, } from '@/lib/credential-groups/types' @@ -88,6 +92,17 @@ export interface PublicCredentialGroupEnrollment { }> } > + mcpServers: Array<{ + id: string + name: string + description: string | null + managedConnectorId: ManagedMcpConnectorId + connection: { + id: string + status: 'connected' | 'needs_reauth' | 'revoked' + grantedAt: string + } | null + }> status: CredentialGroupEnrollmentRecord['status'] } @@ -104,6 +119,19 @@ export interface CredentialGroupOAuthContext { options: CredentialGroupOptionConfig[] } +export interface CredentialGroupMcpOAuthContext { + enrollmentId: string + credentialGroupId: string + workspaceId: string + email: string + enrollmentStatus: EnrollmentRow['status'] + server: { + id: string + name: string + url: string + } +} + export interface PublicCredentialGroupEnrollmentIdentity { enrollmentId: string credentialGroupId: string @@ -330,7 +358,25 @@ async function getInvitationContext( throw new CredentialGroupEnrollmentError('Credential group is disabled', 409) } if (!row.options.some((option) => option.status === 'active')) { - throw new CredentialGroupEnrollmentError('Add an account type before inviting people', 409) + const [linkedMcpServer] = await db + .select({ id: mcpServers.id }) + .from(mcpServers) + .where( + and( + eq(mcpServers.workspaceId, workspaceId), + eq(mcpServers.credentialGroupId, groupId), + eq(mcpServers.authType, 'oauth'), + eq(mcpServers.enabled, true), + isNull(mcpServers.deletedAt) + ) + ) + .limit(1) + if (!linkedMcpServer) { + throw new CredentialGroupEnrollmentError( + 'Add an account type or OAuth MCP server before inviting people', + 409 + ) + } } return row } @@ -526,6 +572,19 @@ export async function listCredentialGroupEnrollments( const activeOptionIds = group.options .filter((option) => option.status === 'active') .map((option) => option.id) + const activeMcpServers = await db + .select({ id: mcpServers.id, name: mcpServers.name }) + .from(mcpServers) + .where( + and( + eq(mcpServers.workspaceId, workspaceId), + eq(mcpServers.credentialGroupId, groupId), + eq(mcpServers.authType, 'oauth'), + eq(mcpServers.enabled, true), + isNull(mcpServers.deletedAt) + ) + ) + const activeMcpServerById = new Map(activeMcpServers.map((server) => [server.id, server])) const cursorPosition = cursor ? decodeCredentialGroupEnrollmentCursor(cursor) : undefined @@ -585,6 +644,31 @@ export async function listCredentialGroupEnrollments( if (connectionRows.length > connectionSummaryLimit) { throw new Error('Managed credential connection summaries exceed the supported provider states') } + const mcpConnectionSummaryLimit = enrollmentIds.length * activeMcpServers.length + const mcpConnectionRows = + enrollmentIds.length === 0 || activeMcpServers.length === 0 + ? [] + : await db + .select({ + enrollmentId: credential.credentialGroupEnrollmentId, + mcpServerId: credential.mcpServerId, + status: credential.managedOauthStatus, + }) + .from(credential) + .where( + and( + eq(credential.type, 'managed_mcp'), + inArray(credential.credentialGroupEnrollmentId, enrollmentIds), + inArray( + credential.mcpServerId, + activeMcpServers.map((server) => server.id) + ) + ) + ) + .limit(mcpConnectionSummaryLimit + 1) + if (mcpConnectionRows.length > mcpConnectionSummaryLimit) { + throw new Error('Managed MCP connection summaries exceed the linked server limit') + } const connectionsByEnrollment = new Map() for (const connection of connectionRows) { if (!connection.enrollmentId) { @@ -599,6 +683,22 @@ export async function listCredentialGroupEnrollments( if (current) current.push(summary) else connectionsByEnrollment.set(connection.enrollmentId, [summary]) } + const mcpConnectionsByEnrollment = new Map() + for (const connection of mcpConnectionRows) { + if (!connection.enrollmentId || !connection.mcpServerId) { + throw new Error('Managed MCP credential source is missing') + } + const server = activeMcpServerById.get(connection.mcpServerId) + if (!server) throw new Error('Managed MCP credential references an unlinked server') + const summary: CredentialGroupEnrollmentMcpConnection = { + mcpServerId: server.id, + name: server.name, + status: toCredentialGroupConnectionStatus(connection.status), + } + const current = mcpConnectionsByEnrollment.get(connection.enrollmentId) + if (current) current.push(summary) + else mcpConnectionsByEnrollment.set(connection.enrollmentId, [summary]) + } const nextCursorEnrollment = hasNextPage ? pageRows.at(-1)?.enrollment : undefined if (hasNextPage && !nextCursorEnrollment) { throw new Error('Credential group enrollment page is missing its cursor boundary') @@ -607,6 +707,7 @@ export async function listCredentialGroupEnrollments( enrollments: pageRows.map(({ enrollment }) => ({ ...toCredentialGroupEnrollment(enrollment), connections: connectionsByEnrollment.get(enrollment.id) ?? [], + mcpConnections: mcpConnectionsByEnrollment.get(enrollment.id) ?? [], })), nextCursor: nextCursorEnrollment ? encodeCredentialGroupEnrollmentCursor(nextCursorEnrollment) @@ -727,7 +828,10 @@ export async function deleteCredentialGroupEnrollment( workspaceId: string, groupId: string, enrollmentId: string -): Promise { +): Promise<{ + credentialGroupEnrollment: CredentialGroupEnrollmentRecord + retiredMcpConnectionIds: string[] +}> { const [existing] = await db .select({ email: credentialGroupEnrollment.email }) .from(credentialGroupEnrollment) @@ -745,6 +849,15 @@ export async function deleteCredentialGroupEnrollment( return db.transaction(async (tx) => { await lockCredentialGroupInvitationTarget(tx, groupId, existing.email) await lockCredentialGroupEnrollmentLifecycle(tx, enrollmentId) + const managedMcpConnections = await tx + .select({ id: credential.id }) + .from(credential) + .where( + and( + eq(credential.type, 'managed_mcp'), + eq(credential.credentialGroupEnrollmentId, enrollmentId) + ) + ) const [deleted] = await tx .delete(credentialGroupEnrollment) .where( @@ -755,7 +868,10 @@ export async function deleteCredentialGroupEnrollment( ) .returning() if (!deleted) throw new CredentialGroupEnrollmentError('Enrollment not found', 404) - return toCredentialGroupEnrollment(deleted) + return { + credentialGroupEnrollment: toCredentialGroupEnrollment(deleted), + retiredMcpConnectionIds: managedMcpConnections.map((row) => row.id), + } }) } @@ -780,24 +896,64 @@ export async function getAuthorizedPublicCredentialGroupEnrollment( async function buildPublicCredentialGroupEnrollment( row: NonNullable>> ): Promise { - const connectionRows = await db - .select({ - optionId: credential.credentialGroupOptionId, - status: credential.managedOauthStatus, - scopeVersion: credential.managedOauthScopeVersion, - authorizationAppId: credential.authorizationAppId, - grantedScopes: credential.grantedScopes, - displayName: credential.displayName, - metadata: credential.providerMetadata, - grantedAt: credential.grantedAt, - }) - .from(credential) - .where( - and( - eq(credential.type, 'managed_oauth'), - eq(credential.credentialGroupEnrollmentId, row.enrollment.id) + const [connectionRows, linkedMcpServers, mcpConnectionRows] = await Promise.all([ + db + .select({ + optionId: credential.credentialGroupOptionId, + status: credential.managedOauthStatus, + scopeVersion: credential.managedOauthScopeVersion, + authorizationAppId: credential.authorizationAppId, + grantedScopes: credential.grantedScopes, + displayName: credential.displayName, + metadata: credential.providerMetadata, + grantedAt: credential.grantedAt, + }) + .from(credential) + .where( + and( + eq(credential.type, 'managed_oauth'), + eq(credential.credentialGroupEnrollmentId, row.enrollment.id) + ) + ), + db + .select({ + id: mcpServers.id, + name: mcpServers.name, + description: mcpServers.description, + managedConnectorId: mcpServers.managedConnectorId, + }) + .from(mcpServers) + .where( + and( + eq(mcpServers.workspaceId, row.workspaceId), + eq(mcpServers.credentialGroupId, row.groupId), + eq(mcpServers.authType, 'oauth'), + eq(mcpServers.enabled, true), + isNull(mcpServers.deletedAt) + ) ) - ) + .orderBy(asc(mcpServers.name), asc(mcpServers.id)), + db + .select({ + id: credential.id, + mcpServerId: credential.mcpServerId, + status: credential.managedOauthStatus, + grantedAt: credential.grantedAt, + }) + .from(credential) + .where( + and( + eq(credential.type, 'managed_mcp'), + eq(credential.credentialGroupEnrollmentId, row.enrollment.id) + ) + ), + ]) + const mcpConnectionByServerId = new Map( + mcpConnectionRows.map((connection) => { + if (!connection.mcpServerId) throw new Error('Managed MCP credential has no server') + return [connection.mcpServerId, connection] as const + }) + ) return { inviterName: row.inviterName, @@ -850,6 +1006,28 @@ async function buildPublicCredentialGroupEnrollment( } }) ), + mcpServers: linkedMcpServers.map((server) => { + if (!server.managedConnectorId) { + throw new Error(`Credential Group MCP server ${server.id} has no managed connector ID`) + } + const managedConnectorId = getManagedMcpConnector(server.managedConnectorId).id + const connection = mcpConnectionByServerId.get(server.id) + if (!connection?.grantedAt) return { ...server, managedConnectorId, connection: null } + return { + ...server, + managedConnectorId, + connection: { + id: connection.id, + status: + connection.status === 'active' + ? ('connected' as const) + : connection.status === 'revoked' + ? ('revoked' as const) + : ('needs_reauth' as const), + grantedAt: connection.grantedAt.toISOString(), + }, + } + }), status: row.enrollment.status, } } @@ -962,6 +1140,46 @@ export async function getAuthorizedCredentialGroupOAuthContext( return credentialGroupOAuthContextFromRow(row, option) } +export async function getAuthorizedCredentialGroupMcpOAuthContext( + identity: PublicCredentialGroupEnrollmentIdentity, + mcpServerId: string +): Promise { + const row = await resolveAuthorizedPublicEnrollmentRow(identity) + if (!row) return null + const [server] = await db + .select({ + id: mcpServers.id, + name: mcpServers.name, + url: mcpServers.url, + managedConnectorId: mcpServers.managedConnectorId, + }) + .from(mcpServers) + .where( + and( + eq(mcpServers.id, mcpServerId), + eq(mcpServers.workspaceId, row.workspaceId), + eq(mcpServers.credentialGroupId, row.groupId), + eq(mcpServers.authType, 'oauth'), + eq(mcpServers.enabled, true), + isNull(mcpServers.deletedAt) + ) + ) + .limit(1) + if (!server?.url) return null + if (!server.managedConnectorId) { + throw new Error(`Credential Group MCP server ${server.id} has no managed connector ID`) + } + getManagedMcpConnector(server.managedConnectorId) + return { + enrollmentId: row.enrollment.id, + credentialGroupId: row.groupId, + workspaceId: row.workspaceId, + email: row.enrollment.email, + enrollmentStatus: row.enrollment.status, + server: { id: server.id, name: server.name, url: server.url }, + } +} + function credentialGroupOAuthContextFromRow( row: NonNullable>>, option: CredentialGroupOptionConfig diff --git a/apps/sim/lib/credential-groups/workflow-access-limits.ts b/apps/sim/lib/credential-groups/limits.ts similarity index 77% rename from apps/sim/lib/credential-groups/workflow-access-limits.ts rename to apps/sim/lib/credential-groups/limits.ts index 50c7c24949e..93e3a889f59 100644 --- a/apps/sim/lib/credential-groups/workflow-access-limits.ts +++ b/apps/sim/lib/credential-groups/limits.ts @@ -1,3 +1,4 @@ +export const CREDENTIAL_GROUP_MCP_SERVER_LIMIT = 50 export const CREDENTIAL_GROUP_WORKFLOW_ACCESS_LIMIT = 50 export const CREDENTIAL_GROUP_WORKFLOW_CATALOG_LIMIT = 500 export const CREDENTIAL_GROUP_WORKFLOW_NAME_MAX_LENGTH = 255 diff --git a/apps/sim/lib/credential-groups/managed-mcp-connector-icons.ts b/apps/sim/lib/credential-groups/managed-mcp-connector-icons.ts new file mode 100644 index 00000000000..f42806a8769 --- /dev/null +++ b/apps/sim/lib/credential-groups/managed-mcp-connector-icons.ts @@ -0,0 +1,12 @@ +import { DatabricksIcon, FirefliesIcon, GranolaIcon } from '@/components/icons' +import type { ManagedMcpConnectorId } from '@/lib/credential-groups/managed-mcp-connectors' + +export const MANAGED_MCP_CONNECTOR_ICONS = { + fireflies: FirefliesIcon, + granola: GranolaIcon, + databricks: DatabricksIcon, +} as const satisfies Record + +export function getManagedMcpConnectorIcon(connectorId: ManagedMcpConnectorId) { + return MANAGED_MCP_CONNECTOR_ICONS[connectorId] +} diff --git a/apps/sim/lib/credential-groups/managed-mcp-connectors.test.ts b/apps/sim/lib/credential-groups/managed-mcp-connectors.test.ts new file mode 100644 index 00000000000..13a4e0ba42b --- /dev/null +++ b/apps/sim/lib/credential-groups/managed-mcp-connectors.test.ts @@ -0,0 +1,42 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + getManagedMcpConnector, + requireManagedMcpConnectorUrl, +} from '@/lib/credential-groups/managed-mcp-connectors' + +describe('managed MCP connectors', () => { + it('uses immutable URLs for fixed connectors', () => { + expect(requireManagedMcpConnectorUrl('fireflies')).toBe('https://api.fireflies.ai/mcp') + expect(requireManagedMcpConnectorUrl('granola')).toBe('https://mcp.granola.ai/mcp') + expect(() => requireManagedMcpConnectorUrl('fireflies', 'https://example.com/mcp')).toThrow( + 'Fireflies uses the fixed MCP URL' + ) + }) + + it.each([ + 'https://workspace.cloud.databricks.com/api/2.0/mcp/functions/catalog/schema', + 'https://workspace.azuredatabricks.net/api/2.0/mcp/vector-search/catalog/schema/index', + 'https://workspace.cloud.databricks.us/api/2.0/mcp/functions/catalog/schema', + 'https://example.databricksapps.com/mcp', + ])('accepts an official Databricks MCP URL: %s', (url) => { + expect(requireManagedMcpConnectorUrl('databricks', url)).toBe(url) + }) + + it.each([ + 'http://workspace.cloud.databricks.com/api/2.0/mcp/functions/catalog/schema', + 'https://workspace.cloud.databricks.com/not-mcp', + 'https://databricks.example.com/api/2.0/mcp/functions/catalog/schema', + 'https://workspace.cloud.databricks.com/api/2.0/mcp/functions/catalog/schema?token=secret', + ])('rejects a noncanonical Databricks MCP URL: %s', (url) => { + expect(() => requireManagedMcpConnectorUrl('databricks', url)).toThrow() + }) + + it('fails on connector IDs that are not in the registry', () => { + expect(() => getManagedMcpConnector('custom')).toThrow( + 'Unsupported managed MCP connector: custom' + ) + }) +}) diff --git a/apps/sim/lib/credential-groups/managed-mcp-connectors.ts b/apps/sim/lib/credential-groups/managed-mcp-connectors.ts new file mode 100644 index 00000000000..de4f379f169 --- /dev/null +++ b/apps/sim/lib/credential-groups/managed-mcp-connectors.ts @@ -0,0 +1,108 @@ +export const MANAGED_MCP_CONNECTOR_IDS = ['fireflies', 'granola', 'databricks'] as const + +export type ManagedMcpConnectorId = (typeof MANAGED_MCP_CONNECTOR_IDS)[number] + +interface FixedManagedMcpConnector { + id: Exclude + name: string + description: string + url: string + oauthClientRegistration: 'dynamic' +} + +interface DatabricksManagedMcpConnector { + id: 'databricks' + name: string + description: string + oauthClientRegistration: 'preregistered' +} + +export type ManagedMcpConnector = FixedManagedMcpConnector | DatabricksManagedMcpConnector + +export const MANAGED_MCP_CONNECTORS = { + fireflies: { + id: 'fireflies', + name: 'Fireflies', + description: 'Let each person connect their own Fireflies account', + url: 'https://api.fireflies.ai/mcp', + oauthClientRegistration: 'dynamic', + }, + granola: { + id: 'granola', + name: 'Granola', + description: 'Let each person connect their own Granola account', + url: 'https://mcp.granola.ai/mcp', + oauthClientRegistration: 'dynamic', + }, + databricks: { + id: 'databricks', + name: 'Databricks', + description: 'Let each person connect their own Databricks account', + oauthClientRegistration: 'preregistered', + }, +} as const satisfies Record + +const DATABRICKS_WORKSPACE_HOST_SUFFIXES = [ + '.cloud.databricks.com', + '.cloud.databricks.us', + '.cloud.databricks.mil', + '.azuredatabricks.net', + '.gcp.databricks.com', + '.databricks.com', +] as const + +const DATABRICKS_APP_HOST_SUFFIXES = [ + '.databricksapps.com', + '.databricksapps.us', + '.databricksapps.mil', +] as const + +export function isManagedMcpConnectorId(value: string): value is ManagedMcpConnectorId { + return MANAGED_MCP_CONNECTOR_IDS.some((connectorId) => connectorId === value) +} + +export function getManagedMcpConnector(connectorId: string): ManagedMcpConnector { + if (!isManagedMcpConnectorId(connectorId)) { + throw new Error(`Unsupported managed MCP connector: ${connectorId}`) + } + return MANAGED_MCP_CONNECTORS[connectorId] +} + +function hostnameHasSuffix(hostname: string, suffixes: readonly string[]): boolean { + return suffixes.some((suffix) => hostname.endsWith(suffix)) +} + +export function requireManagedMcpConnectorUrl( + connectorId: ManagedMcpConnectorId, + rawUrl?: string +): string { + const connector = getManagedMcpConnector(connectorId) + if ('url' in connector) { + if (rawUrl !== undefined && rawUrl !== connector.url) { + throw new Error(`${connector.name} uses the fixed MCP URL ${connector.url}`) + } + return connector.url + } + + if (!rawUrl?.trim()) throw new Error('Databricks MCP URL is required') + let url: URL + try { + url = new URL(rawUrl.trim()) + } catch { + throw new Error('Databricks MCP URL is invalid') + } + if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) { + throw new Error('Databricks MCP URL must be a credential-free HTTPS URL') + } + + const hostname = url.hostname.toLowerCase() + const isWorkspaceHost = hostnameHasSuffix(hostname, DATABRICKS_WORKSPACE_HOST_SUFFIXES) + const isAppHost = hostnameHasSuffix(hostname, DATABRICKS_APP_HOST_SUFFIXES) + const isManagedServicePath = + url.pathname.startsWith('/api/2.0/mcp/') || url.pathname.startsWith('/ai-gateway/mcp-services/') + const isAppPath = url.pathname === '/mcp' || url.pathname === '/mcp/' + if ((!isWorkspaceHost || !isManagedServicePath) && (!isAppHost || !isAppPath)) { + throw new Error('Databricks MCP URL must point to an official Databricks MCP endpoint') + } + return url.toString().replace(/\/$/, '') +} diff --git a/apps/sim/lib/credential-groups/managed-mcp-service.ts b/apps/sim/lib/credential-groups/managed-mcp-service.ts new file mode 100644 index 00000000000..7d31e594d72 --- /dev/null +++ b/apps/sim/lib/credential-groups/managed-mcp-service.ts @@ -0,0 +1,577 @@ +import { db } from '@sim/db' +import { + credential, + credentialGroup, + credentialGroupEnrollment, + mcpServerOauth, + mcpServers, +} from '@sim/db/schema' +import { getPostgresErrorCode } from '@sim/utils/errors' +import { and, eq, inArray, isNull, ne } from 'drizzle-orm' +import { encryptSecret } from '@/lib/core/security/encryption' +import { + getManagedMcpConnector, + type ManagedMcpConnectorId, + requireManagedMcpConnectorUrl, +} from '@/lib/credential-groups/managed-mcp-connectors' +import type { DbOrTx } from '@/lib/db/types' +import { + McpDnsResolutionError, + McpDomainNotAllowedError, + McpSsrfError, + validateMcpDomain, + validateMcpServerSsrf, +} from '@/lib/mcp/domain-check' +import { generateMcpServerId } from '@/lib/mcp/utils' + +export class ManagedMcpConnectorError extends Error { + constructor( + message: string, + readonly code: 'validation' | 'not_found' | 'conflict' | 'forbidden' | 'bad_gateway' + ) { + super(message) + this.name = 'ManagedMcpConnectorError' + } +} + +export interface ManagedMcpConnectorSummary { + id: string + name: string + description: string | null + authType: string + enabled: boolean + managedConnectorId: ManagedMcpConnectorId +} + +export type CreateManagedMcpConnectorInput = + | { connectorId: 'fireflies' | 'granola' } + | { + connectorId: 'databricks' + name: string + url: string + oauthClientId: string + oauthClientSecret?: string + } + +export interface UpdateManagedMcpConnectorInput { + name?: string + url?: string + oauthClientId?: string + oauthClientSecret?: string | null +} + +export interface ManagedMcpConnectorMutationResult { + mcpServer: ManagedMcpConnectorSummary + retiredMcpConnectionIds: string[] + resetMcpServerIds: string[] +} + +function toSummary(row: typeof mcpServers.$inferSelect): ManagedMcpConnectorSummary { + if (!row.managedConnectorId) { + throw new Error(`Credential Group MCP server ${row.id} has no managed connector ID`) + } + const connector = getManagedMcpConnector(row.managedConnectorId) + return { + id: row.id, + name: row.name, + description: row.description, + authType: row.authType, + enabled: row.enabled, + managedConnectorId: connector.id, + } +} + +async function validateServerUrl(url: string): Promise { + try { + validateMcpDomain(url) + await validateMcpServerSsrf(url) + } catch (error) { + if (error instanceof McpDomainNotAllowedError || error instanceof McpSsrfError) { + throw new ManagedMcpConnectorError(error.message, 'forbidden') + } + if (error instanceof McpDnsResolutionError) { + throw new ManagedMcpConnectorError(error.message, 'bad_gateway') + } + throw error + } +} + +function resolveManagedMcpConnectorUrl( + connectorId: ManagedMcpConnectorId, + rawUrl?: string +): string { + try { + return requireManagedMcpConnectorUrl(connectorId, rawUrl) + } catch (error) { + if (error instanceof Error) { + throw new ManagedMcpConnectorError(error.message, 'validation') + } + throw error + } +} + +async function retireManagedMcpCredentials( + credentialGroupId: string, + mcpServerIds: string[], + executor: DbOrTx +): Promise { + if (mcpServerIds.length === 0) return [] + const enrollmentIds = executor + .select({ id: credentialGroupEnrollment.id }) + .from(credentialGroupEnrollment) + .where(eq(credentialGroupEnrollment.credentialGroupId, credentialGroupId)) + const retired = await executor + .update(credential) + .set({ + managedOauthStatus: 'revoked', + encryptedOauthTokenSet: null, + accessTokenExpiresAt: null, + mcpTools: null, + mcpToolsRefreshedAt: null, + revokedAt: new Date(), + updatedAt: new Date(), + }) + .where( + and( + eq(credential.type, 'managed_mcp'), + inArray(credential.credentialGroupEnrollmentId, enrollmentIds), + inArray(credential.mcpServerId, mcpServerIds) + ) + ) + .returning({ id: credential.id }) + return retired.map((row) => row.id) +} + +export async function retireManagedMcpServersForGroup( + workspaceId: string, + credentialGroupId: string, + executor: DbOrTx +): Promise<{ serverIds: string[]; connectionIds: string[] }> { + const servers = await executor + .select({ id: mcpServers.id }) + .from(mcpServers) + .where( + and( + eq(mcpServers.workspaceId, workspaceId), + eq(mcpServers.credentialGroupId, credentialGroupId), + isNull(mcpServers.deletedAt) + ) + ) + .for('update') + const serverIds = servers.map((server) => server.id) + if (serverIds.length === 0) return { serverIds: [], connectionIds: [] } + const connectionIds = await retireManagedMcpCredentials(credentialGroupId, serverIds, executor) + const now = new Date() + await executor + .update(mcpServers) + .set({ enabled: false, deletedAt: now, updatedAt: now }) + .where(inArray(mcpServers.id, serverIds)) + await executor.delete(mcpServerOauth).where(inArray(mcpServerOauth.mcpServerId, serverIds)) + return { serverIds, connectionIds } +} + +export async function createManagedMcpConnector(params: { + workspaceId: string + credentialGroupId: string + userId: string + input: CreateManagedMcpConnectorInput +}): Promise { + const connector = getManagedMcpConnector(params.input.connectorId) + const url = resolveManagedMcpConnectorUrl( + connector.id, + params.input.connectorId === 'databricks' ? params.input.url : undefined + ) + await validateServerUrl(url) + const serverId = generateMcpServerId(params.workspaceId, url) + const oauthClientId = + params.input.connectorId === 'databricks' ? params.input.oauthClientId.trim() : null + const oauthClientSecret = + params.input.connectorId === 'databricks' && params.input.oauthClientSecret + ? (await encryptSecret(params.input.oauthClientSecret)).encrypted + : null + const name = params.input.connectorId === 'databricks' ? params.input.name.trim() : connector.name + if (!name) + throw new ManagedMcpConnectorError('Managed MCP connector name is required', 'validation') + if (params.input.connectorId === 'databricks' && !oauthClientId) { + throw new ManagedMcpConnectorError('Databricks OAuth Client ID is required', 'validation') + } + + try { + const mcpServer = await db.transaction(async (tx) => { + const [group] = await tx + .select({ id: credentialGroup.id }) + .from(credentialGroup) + .where( + and( + eq(credentialGroup.id, params.credentialGroupId), + eq(credentialGroup.workspaceId, params.workspaceId) + ) + ) + .limit(1) + .for('update') + if (!group) throw new ManagedMcpConnectorError('Credential group not found', 'not_found') + + const [existingProvider] = await tx + .select({ id: mcpServers.id }) + .from(mcpServers) + .where( + and( + eq(mcpServers.workspaceId, params.workspaceId), + eq(mcpServers.credentialGroupId, params.credentialGroupId), + eq(mcpServers.managedConnectorId, connector.id), + isNull(mcpServers.deletedAt) + ) + ) + .limit(1) + if (existingProvider) { + throw new ManagedMcpConnectorError( + `${connector.name} is already configured for this Credential Group`, + 'conflict' + ) + } + + const [liveServerWithUrl] = await tx + .select({ id: mcpServers.id }) + .from(mcpServers) + .where( + and( + eq(mcpServers.workspaceId, params.workspaceId), + eq(mcpServers.url, url), + isNull(mcpServers.deletedAt) + ) + ) + .limit(1) + .for('update') + if (liveServerWithUrl) { + throw new ManagedMcpConnectorError( + 'An MCP server with this URL already exists. Remove it from MCP settings first.', + 'conflict' + ) + } + + const [existingUrl] = await tx + .select() + .from(mcpServers) + .where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, params.workspaceId))) + .limit(1) + .for('update') + const now = new Date() + if (existingUrl) { + const [revived] = await tx + .update(mcpServers) + .set({ + credentialGroupId: params.credentialGroupId, + managedConnectorId: connector.id, + createdBy: params.userId, + name, + description: connector.description, + transport: 'streamable-http', + url, + authType: 'oauth', + oauthClientId, + oauthClientSecret, + headers: {}, + enabled: true, + connectionStatus: 'disconnected', + lastConnected: null, + lastError: null, + deletedAt: null, + updatedAt: now, + }) + .where(eq(mcpServers.id, serverId)) + .returning() + if (!revived) throw new Error('Managed MCP server revival returned no row') + return revived + } + + const [created] = await tx + .insert(mcpServers) + .values({ + id: serverId, + workspaceId: params.workspaceId, + credentialGroupId: params.credentialGroupId, + managedConnectorId: connector.id, + createdBy: params.userId, + name, + description: connector.description, + transport: 'streamable-http', + url, + authType: 'oauth', + oauthClientId, + oauthClientSecret, + headers: {}, + enabled: true, + connectionStatus: 'disconnected', + lastConnected: null, + createdAt: now, + updatedAt: now, + }) + .returning() + if (!created) throw new Error('Managed MCP server insert returned no row') + return created + }) + return { + mcpServer: toSummary(mcpServer), + retiredMcpConnectionIds: [], + resetMcpServerIds: [], + } + } catch (error) { + if (getPostgresErrorCode(error) === '23505') { + throw new ManagedMcpConnectorError( + `${connector.name} is already configured for this Credential Group`, + 'conflict' + ) + } + throw error + } +} + +export async function updateManagedMcpConnector(params: { + workspaceId: string + credentialGroupId: string + connectorId: ManagedMcpConnectorId + input: UpdateManagedMcpConnectorInput +}): Promise { + if (params.connectorId !== 'databricks') { + throw new ManagedMcpConnectorError( + 'Only Databricks connector settings can be changed', + 'validation' + ) + } + const current = await db + .select() + .from(mcpServers) + .where( + and( + eq(mcpServers.workspaceId, params.workspaceId), + eq(mcpServers.credentialGroupId, params.credentialGroupId), + eq(mcpServers.managedConnectorId, params.connectorId), + isNull(mcpServers.deletedAt) + ) + ) + .limit(1) + .then((rows) => rows[0]) + if (!current) throw new ManagedMcpConnectorError('Managed MCP connector not found', 'not_found') + const url = resolveManagedMcpConnectorUrl( + 'databricks', + params.input.url ?? current.url ?? undefined + ) + if (url !== current.url) await validateServerUrl(url) + const encryptedSecret = + params.input.oauthClientSecret === undefined + ? undefined + : params.input.oauthClientSecret === null + ? null + : (await encryptSecret(params.input.oauthClientSecret)).encrypted + + const result = await db.transaction(async (tx) => { + const [group] = await tx + .select({ id: credentialGroup.id }) + .from(credentialGroup) + .where( + and( + eq(credentialGroup.id, params.credentialGroupId), + eq(credentialGroup.workspaceId, params.workspaceId) + ) + ) + .limit(1) + .for('update') + if (!group) throw new ManagedMcpConnectorError('Credential group not found', 'not_found') + + const [locked] = await tx + .select() + .from(mcpServers) + .where( + and( + eq(mcpServers.id, current.id), + eq(mcpServers.workspaceId, params.workspaceId), + eq(mcpServers.credentialGroupId, params.credentialGroupId), + eq(mcpServers.managedConnectorId, 'databricks'), + isNull(mcpServers.deletedAt) + ) + ) + .limit(1) + .for('update') + if (!locked) throw new ManagedMcpConnectorError('Managed MCP connector not found', 'not_found') + const urlChanged = url !== locked.url + const targetServerId = generateMcpServerId(params.workspaceId, url) + if (urlChanged && targetServerId === locked.id) { + throw new Error(`MCP server ID collision for ${locked.id}`) + } + if (urlChanged) { + const [liveServerWithUrl] = await tx + .select({ id: mcpServers.id }) + .from(mcpServers) + .where( + and( + eq(mcpServers.workspaceId, params.workspaceId), + eq(mcpServers.url, url), + ne(mcpServers.id, locked.id), + isNull(mcpServers.deletedAt) + ) + ) + .limit(1) + .for('update') + if (liveServerWithUrl) { + throw new ManagedMcpConnectorError( + 'An MCP server with this URL already exists. Remove it from MCP settings first.', + 'conflict' + ) + } + } + const nextName = params.input.name?.trim() ?? locked.name + const nextOauthClientId = params.input.oauthClientId?.trim() ?? locked.oauthClientId + if (!nextName) { + throw new ManagedMcpConnectorError('Databricks name is required', 'validation') + } + if (!nextOauthClientId) { + throw new ManagedMcpConnectorError('Databricks OAuth Client ID is required', 'validation') + } + const nextOauthClientSecret = + encryptedSecret === undefined ? locked.oauthClientSecret : encryptedSecret + const changedCredentials = + urlChanged || nextOauthClientId !== locked.oauthClientId || encryptedSecret !== undefined + const retiredMcpConnectionIds = changedCredentials + ? await retireManagedMcpCredentials(params.credentialGroupId, [locked.id], tx) + : [] + if (changedCredentials) { + await tx.delete(mcpServerOauth).where(eq(mcpServerOauth.mcpServerId, locked.id)) + } + const now = new Date() + if (!urlChanged) { + const [updated] = await tx + .update(mcpServers) + .set({ + name: nextName, + oauthClientId: nextOauthClientId, + ...(encryptedSecret !== undefined ? { oauthClientSecret: encryptedSecret } : {}), + ...(changedCredentials + ? { connectionStatus: 'disconnected', lastConnected: null, lastError: null } + : {}), + updatedAt: now, + }) + .where(eq(mcpServers.id, locked.id)) + .returning() + if (!updated) throw new Error('Managed MCP server update returned no row') + return { + mcpServer: toSummary(updated), + retiredMcpConnectionIds, + resetMcpServerIds: changedCredentials ? [locked.id] : [], + } + } + + const [target] = await tx + .select() + .from(mcpServers) + .where(and(eq(mcpServers.id, targetServerId), eq(mcpServers.workspaceId, params.workspaceId))) + .limit(1) + .for('update') + if (target?.deletedAt === null) { + throw new ManagedMcpConnectorError( + 'An MCP server with this URL already exists. Remove it from MCP settings first.', + 'conflict' + ) + } + + await tx + .update(mcpServers) + .set({ enabled: false, deletedAt: now, updatedAt: now }) + .where(eq(mcpServers.id, locked.id)) + if (target) { + await tx.delete(mcpServerOauth).where(eq(mcpServerOauth.mcpServerId, target.id)) + } + const rowValues = { + credentialGroupId: params.credentialGroupId, + managedConnectorId: 'databricks' as const, + createdBy: locked.createdBy, + name: nextName, + description: getManagedMcpConnector('databricks').description, + transport: 'streamable-http', + url, + authType: 'oauth', + oauthClientId: nextOauthClientId, + oauthClientSecret: nextOauthClientSecret, + headers: {}, + enabled: true, + connectionStatus: 'disconnected', + lastConnected: null, + lastError: null, + deletedAt: null, + updatedAt: now, + } + const [replacement] = target + ? await tx.update(mcpServers).set(rowValues).where(eq(mcpServers.id, target.id)).returning() + : await tx + .insert(mcpServers) + .values({ + id: targetServerId, + workspaceId: params.workspaceId, + ...rowValues, + createdAt: now, + }) + .returning() + if (!replacement) throw new Error('Managed MCP server replacement returned no row') + return { + mcpServer: toSummary(replacement), + retiredMcpConnectionIds, + resetMcpServerIds: [locked.id, replacement.id], + } + }) + return result +} + +export async function deleteManagedMcpConnector(params: { + workspaceId: string + credentialGroupId: string + connectorId: ManagedMcpConnectorId +}): Promise<{ + mcpServer: ManagedMcpConnectorSummary + serverIds: string[] + retiredMcpConnectionIds: string[] +}> { + return db.transaction(async (tx) => { + const [group] = await tx + .select({ id: credentialGroup.id }) + .from(credentialGroup) + .where( + and( + eq(credentialGroup.id, params.credentialGroupId), + eq(credentialGroup.workspaceId, params.workspaceId) + ) + ) + .limit(1) + .for('update') + if (!group) throw new ManagedMcpConnectorError('Credential group not found', 'not_found') + + const [server] = await tx + .select() + .from(mcpServers) + .where( + and( + eq(mcpServers.workspaceId, params.workspaceId), + eq(mcpServers.credentialGroupId, params.credentialGroupId), + eq(mcpServers.managedConnectorId, params.connectorId), + isNull(mcpServers.deletedAt) + ) + ) + .limit(1) + .for('update') + if (!server) throw new ManagedMcpConnectorError('Managed MCP connector not found', 'not_found') + const retiredMcpConnectionIds = await retireManagedMcpCredentials( + params.credentialGroupId, + [server.id], + tx + ) + const now = new Date() + await tx + .update(mcpServers) + .set({ enabled: false, deletedAt: now, updatedAt: now }) + .where(eq(mcpServers.id, server.id)) + await tx.delete(mcpServerOauth).where(eq(mcpServerOauth.mcpServerId, server.id)) + return { + mcpServer: toSummary(server), + serverIds: [server.id], + retiredMcpConnectionIds, + } + }) +} diff --git a/apps/sim/lib/credential-groups/mcp-connections.test.ts b/apps/sim/lib/credential-groups/mcp-connections.test.ts new file mode 100644 index 00000000000..87869655ef4 --- /dev/null +++ b/apps/sim/lib/credential-groups/mcp-connections.test.ts @@ -0,0 +1,99 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, hasMockCondition, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { listCredentialGroupMcpConnectionReferences } from '@/lib/credential-groups/mcp-connections' + +describe('listCredentialGroupMcpConnectionReferences', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('returns MCP credential IDs and tool names without secret material', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'mcp-cg-connection-1', + email: 'person@example.com', + displayName: 'Fireflies', + mcpServerId: 'mcp-server-1', + mcpServerName: 'Fireflies', + managedConnectorId: 'fireflies', + hasToolSnapshot: true, + toolNames: ['list_transcripts', 'get_transcript'], + createdAt: new Date('2026-09-01T12:00:00.000Z'), + }, + ]) + + const result = await listCredentialGroupMcpConnectionReferences({ + workspaceId: 'workspace-1', + credentialGroupId: 'group-1', + limit: 50, + }) + + expect(result).toEqual({ + mcpConnections: [ + { + credentialId: 'mcp-cg-connection-1', + email: 'person@example.com', + displayName: 'Fireflies', + mcpServerId: 'mcp-server-1', + mcpServerName: 'Fireflies', + toolNames: ['list_transcripts', 'get_transcript'], + }, + ], + nextCursor: null, + }) + }) + + it('applies email and root MCP server filters inside the credential group scope', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([]) + + await listCredentialGroupMcpConnectionReferences({ + workspaceId: 'workspace-1', + credentialGroupId: 'group-1', + email: 'person@example.com', + mcpServerId: 'mcp-server-1', + limit: 50, + }) + + const where = dbChainMockFns.where.mock.calls.at(-1)?.[0] + expect( + hasMockCondition( + where, + (condition) => condition.type === 'eq' && condition.right === 'person@example.com' + ) + ).toBe(true) + expect( + hasMockCondition( + where, + (condition) => condition.type === 'eq' && condition.right === 'mcp-server-1' + ) + ).toBe(true) + }) + + it('fails fast when an active connection has no tool snapshot', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'mcp-cg-connection-1', + email: 'person@example.com', + displayName: 'Fireflies', + mcpServerId: 'mcp-server-1', + mcpServerName: 'Fireflies', + managedConnectorId: 'fireflies', + hasToolSnapshot: false, + toolNames: [], + createdAt: new Date('2026-09-01T12:00:00.000Z'), + }, + ]) + + await expect( + listCredentialGroupMcpConnectionReferences({ + workspaceId: 'workspace-1', + credentialGroupId: 'group-1', + limit: 50, + }) + ).rejects.toThrow('Managed MCP connection mcp-cg-connection-1 has no tool snapshot') + }) +}) diff --git a/apps/sim/lib/credential-groups/mcp-connections.ts b/apps/sim/lib/credential-groups/mcp-connections.ts new file mode 100644 index 00000000000..b3aeb1c3044 --- /dev/null +++ b/apps/sim/lib/credential-groups/mcp-connections.ts @@ -0,0 +1,158 @@ +import { db } from '@sim/db' +import { credential, credentialGroup, credentialGroupEnrollment, mcpServers } from '@sim/db/schema' +import { and, asc, eq, gt, inArray, isNull, or, sql } from 'drizzle-orm' +import { getManagedMcpConnector } from '@/lib/credential-groups/managed-mcp-connectors' + +export const MAX_CREDENTIAL_GROUP_MCP_CONNECTION_PAGE_SIZE = 100 + +export interface CredentialGroupMcpConnectionReference { + credentialId: string + email: string + displayName: string + mcpServerId: string + mcpServerName: string + toolNames: string[] +} + +export class CredentialGroupMcpConnectionCursorNotFoundError extends Error { + constructor() { + super('Credential group MCP connection cursor not found') + this.name = 'CredentialGroupMcpConnectionCursorNotFoundError' + } +} + +interface ListCredentialGroupMcpConnectionReferencesInput { + workspaceId: string + credentialGroupId: string + limit: number + cursor?: string + email?: string + mcpServerId?: string +} + +function decodeToolNames(value: unknown): string[] { + const decoded = credential.mcpTools.mapFromDriverValue(value) + if (!Array.isArray(decoded) || !decoded.every((name) => typeof name === 'string')) { + throw new Error('Managed MCP tool name projection is invalid') + } + return decoded +} + +/** Lists one bounded page of active managed MCP connections without selecting token material. */ +export async function listCredentialGroupMcpConnectionReferences({ + workspaceId, + credentialGroupId, + limit, + cursor, + email, + mcpServerId, +}: ListCredentialGroupMcpConnectionReferencesInput): Promise<{ + mcpConnections: CredentialGroupMcpConnectionReference[] + nextCursor: string | null +}> { + const scope = () => + and( + eq(credential.workspaceId, workspaceId), + eq(credential.type, 'managed_mcp'), + eq(credential.managedOauthStatus, 'active'), + eq(credentialGroup.id, credentialGroupId), + eq(credentialGroup.workspaceId, workspaceId), + eq(credentialGroup.status, 'active'), + inArray(credentialGroupEnrollment.status, ['in_progress', 'completed']), + eq(mcpServers.workspaceId, workspaceId), + eq(mcpServers.authType, 'oauth'), + eq(mcpServers.enabled, true), + isNull(mcpServers.deletedAt), + sql`${mcpServers.credentialGroupId} = ${credentialGroup.id}`, + email ? eq(credentialGroupEnrollment.email, email) : undefined, + mcpServerId ? eq(mcpServers.id, mcpServerId) : undefined + ) + + let cursorPosition: { id: string; createdAt: Date } | undefined + if (cursor) { + const [cursorRow] = await db + .select({ id: credential.id, createdAt: credential.createdAt }) + .from(credential) + .innerJoin( + credentialGroupEnrollment, + eq(credentialGroupEnrollment.id, credential.credentialGroupEnrollmentId) + ) + .innerJoin( + credentialGroup, + eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId) + ) + .innerJoin(mcpServers, eq(mcpServers.id, credential.mcpServerId)) + .where(and(eq(credential.id, cursor), scope())) + .limit(1) + if (!cursorRow) throw new CredentialGroupMcpConnectionCursorNotFoundError() + cursorPosition = cursorRow + } + + const rows = await db + .select({ + id: credential.id, + email: credentialGroupEnrollment.email, + displayName: credential.displayName, + mcpServerId: mcpServers.id, + mcpServerName: mcpServers.name, + managedConnectorId: mcpServers.managedConnectorId, + hasToolSnapshot: sql`${credential.mcpTools} IS NOT NULL`, + toolNames: + sql`COALESCE(jsonb_path_query_array(${credential.mcpTools}, '$[*].name'), '[]'::jsonb)`.mapWith( + decodeToolNames + ), + createdAt: credential.createdAt, + }) + .from(credential) + .innerJoin( + credentialGroupEnrollment, + eq(credentialGroupEnrollment.id, credential.credentialGroupEnrollmentId) + ) + .innerJoin(credentialGroup, eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId)) + .innerJoin(mcpServers, eq(mcpServers.id, credential.mcpServerId)) + .where( + and( + scope(), + cursorPosition + ? or( + gt(credential.createdAt, cursorPosition.createdAt), + and( + eq(credential.createdAt, cursorPosition.createdAt), + gt(credential.id, cursorPosition.id) + ) + ) + : undefined + ) + ) + .orderBy(asc(credential.createdAt), asc(credential.id)) + .limit(limit + 1) + + const hasMore = rows.length > limit + const pageRows = hasMore ? rows.slice(0, limit) : rows + const nextCursor = hasMore ? pageRows.at(-1)?.id : null + if (hasMore && !nextCursor) throw new Error('MCP connection page cursor could not be derived') + + return { + mcpConnections: pageRows.map((row) => { + if (!row.hasToolSnapshot) { + throw new Error(`Managed MCP connection ${row.id} has no tool snapshot`) + } + if (!row.managedConnectorId) { + throw new Error(`Managed MCP server ${row.mcpServerId} has no connector ID`) + } + getManagedMcpConnector(row.managedConnectorId) + if (row.toolNames.some((name) => typeof name !== 'string' || !name.trim())) { + throw new Error(`Managed MCP connection ${row.id} has invalid tool metadata`) + } + return { + credentialId: row.id, + email: row.email, + displayName: row.displayName, + mcpServerId: row.mcpServerId, + mcpServerName: row.mcpServerName, + toolNames: row.toolNames, + } + }), + nextCursor: nextCursor ?? null, + } +} diff --git a/apps/sim/lib/credential-groups/mcp-oauth-state.test.ts b/apps/sim/lib/credential-groups/mcp-oauth-state.test.ts new file mode 100644 index 00000000000..e7e88b6e871 --- /dev/null +++ b/apps/sim/lib/credential-groups/mcp-oauth-state.test.ts @@ -0,0 +1,109 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockRedis, values } = vi.hoisted(() => { + const values = new Map() + return { + values, + mockRedis: { + set: vi.fn(async (key: string, value: string) => { + if (values.has(key)) return null + values.set(key, value) + return 'OK' + }), + eval: vi.fn(async (_script: string, _keyCount: number, key: string) => { + const value = values.get(key) ?? null + values.delete(key) + return value + }), + sadd: vi.fn(async () => 1), + srem: vi.fn(async () => 1), + pexpire: vi.fn(async () => 1), + }, + } +}) + +vi.mock('@/lib/core/config/redis', () => ({ + getRedisClient: vi.fn(() => mockRedis), +})) + +vi.mock('@/lib/core/security/encryption', () => ({ + encryptSecret: vi.fn(async (value: string) => ({ + encrypted: `encrypted:${Buffer.from(value).toString('base64')}`, + })), + decryptSecret: vi.fn(async (value: string) => ({ + decrypted: Buffer.from(value.replace(/^encrypted:/, ''), 'base64').toString(), + })), +})) + +import { getRedisClient } from '@/lib/core/config/redis' +import { + consumeCredentialGroupMcpOAuthAttempt, + createCredentialGroupMcpOAuthAttempt, + isCredentialGroupMcpOAuthState, +} from '@/lib/credential-groups/mcp-oauth-state' + +describe('Credential Group MCP OAuth state', () => { + beforeEach(() => { + vi.clearAllMocks() + values.clear() + vi.mocked(getRedisClient).mockReturnValue(mockRedis as never) + }) + + it('encrypts bearer material and consumes an attempt exactly once', async () => { + const state = 'mcp_cg_state-1' + await createCredentialGroupMcpOAuthAttempt({ + state, + enrollmentId: 'enrollment-1', + credentialGroupId: 'group-1', + mcpServerId: 'mcp-server-1', + codeVerifier: 'code-verifier', + invitationToken: 'invitation-token', + }) + + const stored = [...values.values()][0] + expect(isCredentialGroupMcpOAuthState(state)).toBe(true) + expect(stored).not.toContain('code-verifier') + expect(stored).not.toContain('invitation-token') + await expect(consumeCredentialGroupMcpOAuthAttempt(state)).resolves.toMatchObject({ + state, + enrollmentId: 'enrollment-1', + credentialGroupId: 'group-1', + mcpServerId: 'mcp-server-1', + codeVerifier: 'code-verifier', + invitationToken: 'invitation-token', + }) + await expect(consumeCredentialGroupMcpOAuthAttempt(state)).resolves.toBeNull() + }) + + it('fails closed when Redis is unavailable', async () => { + vi.mocked(getRedisClient).mockReturnValue(null) + + await expect( + createCredentialGroupMcpOAuthAttempt({ + state: 'mcp_cg_state-1', + enrollmentId: 'enrollment-1', + credentialGroupId: 'group-1', + mcpServerId: 'mcp-server-1', + codeVerifier: 'code-verifier', + invitationToken: 'invitation-token', + }) + ).rejects.toThrow('Credential Group MCP OAuth requires Redis') + }) + + it('rejects state outside the managed MCP namespace', async () => { + await expect( + createCredentialGroupMcpOAuthAttempt({ + state: 'ordinary-state', + enrollmentId: 'enrollment-1', + credentialGroupId: 'group-1', + mcpServerId: 'mcp-server-1', + codeVerifier: 'code-verifier', + invitationToken: 'invitation-token', + }) + ).rejects.toThrow('invalid prefix') + expect(mockRedis.set).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credential-groups/mcp-oauth-state.ts b/apps/sim/lib/credential-groups/mcp-oauth-state.ts new file mode 100644 index 00000000000..3db80dc64a0 --- /dev/null +++ b/apps/sim/lib/credential-groups/mcp-oauth-state.ts @@ -0,0 +1,150 @@ +import { sha256Hex } from '@sim/security/hash' +import { getRedisClient } from '@/lib/core/config/redis' +import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' + +const MCP_OAUTH_ATTEMPT_TTL_MS = 10 * 60 * 1000 +const MCP_OAUTH_ATTEMPT_VERSION = 1 as const +const MCP_OAUTH_STATE_PREFIX = 'mcp_cg_' + +const CONSUME_SCRIPT = ` +local value = redis.call('GET', KEYS[1]) +if not value then + return nil +end +redis.call('DEL', KEYS[1]) +return value +` + +const CLEAR_SERVER_ATTEMPTS_SCRIPT = ` +local keys = redis.call('SMEMBERS', KEYS[1]) +for _, key in ipairs(keys) do + redis.call('DEL', key) +end +redis.call('DEL', KEYS[1]) +return #keys +` + +interface StoredCredentialGroupMcpOAuthAttempt { + version: typeof MCP_OAUTH_ATTEMPT_VERSION + enrollmentId: string + credentialGroupId: string + mcpServerId: string + encryptedCodeVerifier: string + encryptedInvitationToken: string + createdAt: number +} + +export interface CredentialGroupMcpOAuthAttempt { + state: string + enrollmentId: string + credentialGroupId: string + mcpServerId: string + codeVerifier: string + invitationToken: string + createdAt: number +} + +function requireRedis() { + const redis = getRedisClient() + if (!redis) throw new Error('Credential Group MCP OAuth requires Redis') + return redis +} + +function attemptKey(state: string): string { + return `credential-group:mcp-oauth-attempt:${sha256Hex(state)}` +} + +function serverAttemptsKey(mcpServerId: string): string { + return `credential-group:mcp-oauth-attempts:${mcpServerId}` +} + +function isStoredAttempt(value: unknown): value is StoredCredentialGroupMcpOAuthAttempt { + if (!value || typeof value !== 'object') return false + const candidate = value as Record + return ( + candidate.version === MCP_OAUTH_ATTEMPT_VERSION && + typeof candidate.enrollmentId === 'string' && + typeof candidate.credentialGroupId === 'string' && + typeof candidate.mcpServerId === 'string' && + typeof candidate.encryptedCodeVerifier === 'string' && + typeof candidate.encryptedInvitationToken === 'string' && + typeof candidate.createdAt === 'number' + ) +} + +export function isCredentialGroupMcpOAuthState(state: string): boolean { + return state.startsWith(MCP_OAUTH_STATE_PREFIX) +} + +export async function createCredentialGroupMcpOAuthAttempt(params: { + state: string + enrollmentId: string + credentialGroupId: string + mcpServerId: string + codeVerifier: string + invitationToken: string +}): Promise { + if (!isCredentialGroupMcpOAuthState(params.state)) { + throw new Error('Managed MCP OAuth state has an invalid prefix') + } + const redis = requireRedis() + const [codeVerifier, invitationToken] = await Promise.all([ + encryptSecret(params.codeVerifier), + encryptSecret(params.invitationToken), + ]) + const attempt: StoredCredentialGroupMcpOAuthAttempt = { + version: MCP_OAUTH_ATTEMPT_VERSION, + enrollmentId: params.enrollmentId, + credentialGroupId: params.credentialGroupId, + mcpServerId: params.mcpServerId, + encryptedCodeVerifier: codeVerifier.encrypted, + encryptedInvitationToken: invitationToken.encrypted, + createdAt: Date.now(), + } + const stored = await redis.set( + attemptKey(params.state), + JSON.stringify(attempt), + 'PX', + MCP_OAUTH_ATTEMPT_TTL_MS, + 'NX' + ) + if (stored !== 'OK') throw new Error('Credential Group MCP OAuth state collision') + await redis.sadd(serverAttemptsKey(params.mcpServerId), attemptKey(params.state)) + await redis.pexpire(serverAttemptsKey(params.mcpServerId), MCP_OAUTH_ATTEMPT_TTL_MS) +} + +export async function consumeCredentialGroupMcpOAuthAttempt( + state: string +): Promise { + if (!isCredentialGroupMcpOAuthState(state)) return null + const raw = await requireRedis().eval(CONSUME_SCRIPT, 1, attemptKey(state)) + if (raw === null) return null + if (typeof raw !== 'string') throw new Error('Credential Group MCP OAuth state is malformed') + const parsed: unknown = JSON.parse(raw) + if (!isStoredAttempt(parsed)) throw new Error('Credential Group MCP OAuth state is malformed') + await requireRedis().srem(serverAttemptsKey(parsed.mcpServerId), attemptKey(state)) + if (Date.now() - parsed.createdAt > MCP_OAUTH_ATTEMPT_TTL_MS) return null + const [codeVerifier, invitationToken] = await Promise.all([ + decryptSecret(parsed.encryptedCodeVerifier), + decryptSecret(parsed.encryptedInvitationToken), + ]) + return { + state, + enrollmentId: parsed.enrollmentId, + credentialGroupId: parsed.credentialGroupId, + mcpServerId: parsed.mcpServerId, + codeVerifier: codeVerifier.decrypted, + invitationToken: invitationToken.decrypted, + createdAt: parsed.createdAt, + } +} + +export async function clearCredentialGroupMcpOAuthAttempts(mcpServerIds: string[]): Promise { + if (mcpServerIds.length === 0) return + const redis = requireRedis() + await Promise.all( + mcpServerIds.map((mcpServerId) => + redis.eval(CLEAR_SERVER_ATTEMPTS_SCRIPT, 1, serverAttemptsKey(mcpServerId)) + ) + ) +} diff --git a/apps/sim/lib/credential-groups/mcp-oauth.ts b/apps/sim/lib/credential-groups/mcp-oauth.ts new file mode 100644 index 00000000000..0e9df666c25 --- /dev/null +++ b/apps/sim/lib/credential-groups/mcp-oauth.ts @@ -0,0 +1,108 @@ +import type { OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js' +import type { CredentialGroupMcpOAuthContext } from '@/lib/credential-groups/enrollments' +import { createCredentialGroupMcpOAuthAttempt } from '@/lib/credential-groups/mcp-oauth-state' +import { encryptManagedMcpTokens, persistManagedMcpCredential } from '@/lib/credentials/managed-mcp' +import { + assertSafeOauthServerUrl, + getOrCreateOauthRow, + loadPreregisteredClient, + McpOauthRedirectRequired, + mcpAuthGuarded, + withMcpOauthRefreshLock, +} from '@/lib/mcp/oauth' +import { ManagedMcpOauthProvider } from '@/lib/mcp/oauth/managed-provider' +import { mcpService } from '@/lib/mcp/service' + +export async function startCredentialGroupMcpOAuth( + context: CredentialGroupMcpOAuthContext, + invitationToken: string +): Promise { + assertSafeOauthServerUrl(context.server.url) + return withMcpOauthRefreshLock(context.server.id, async () => { + const clientRow = await getOrCreateOauthRow({ + mcpServerId: context.server.id, + workspaceId: context.workspaceId, + }) + const preregistered = await loadPreregisteredClient(context.server.id) + const provider = new ManagedMcpOauthProvider({ + clientRow, + preregistered, + async onSaveTokens() { + throw new Error('Managed MCP OAuth start cannot persist grant tokens') + }, + }) + + try { + const result = await mcpAuthGuarded(provider, { serverUrl: context.server.url }) + if (result === 'AUTHORIZED') { + throw new Error('Managed MCP OAuth unexpectedly authorized without an enrollment grant') + } + throw new Error('Managed MCP OAuth did not produce an authorization redirect') + } catch (error) { + if (!(error instanceof McpOauthRedirectRequired)) throw error + const attempt = provider.requireAuthorizationAttempt() + await createCredentialGroupMcpOAuthAttempt({ + ...attempt, + enrollmentId: context.enrollmentId, + credentialGroupId: context.credentialGroupId, + mcpServerId: context.server.id, + invitationToken, + }) + return error.authorizationUrl + } + }) +} + +export async function completeCredentialGroupMcpOAuth( + context: CredentialGroupMcpOAuthContext, + codeVerifier: string, + authorizationCode: string +): Promise<{ connectionId: string; mcpServerId: string }> { + assertSafeOauthServerUrl(context.server.url) + const clientRow = await getOrCreateOauthRow({ + mcpServerId: context.server.id, + workspaceId: context.workspaceId, + }) + const preregistered = await loadPreregisteredClient(context.server.id) + let grantedTokens: OAuthTokens | undefined + const provider = new ManagedMcpOauthProvider({ + clientRow, + preregistered, + codeVerifier, + async onSaveTokens(tokens) { + if (!tokens) { + grantedTokens = undefined + return + } + await encryptManagedMcpTokens(tokens) + grantedTokens = tokens + }, + }) + const result = await mcpAuthGuarded(provider, { + serverUrl: context.server.url, + authorizationCode, + }) + if (result !== 'AUTHORIZED' || !grantedTokens) { + throw new Error('Managed MCP OAuth token exchange did not return usable tokens') + } + const tools = await mcpService.discoverManagedMcpTools( + context.server.id, + context.workspaceId, + provider, + undefined, + { requireComplete: true } + ) + const connectionId = await persistManagedMcpCredential({ + enrollmentId: context.enrollmentId, + workspaceId: context.workspaceId, + mcpServerId: context.server.id, + mcpServerName: context.server.name, + tokens: grantedTokens, + tools: tools.map((tool) => ({ + name: tool.name, + ...(tool.description ? { description: tool.description } : {}), + inputSchema: tool.inputSchema, + })), + }) + return { connectionId, mcpServerId: context.server.id } +} diff --git a/apps/sim/lib/credential-groups/service.test.ts b/apps/sim/lib/credential-groups/service.test.ts index 32badce9ee4..18a69bf89f1 100644 --- a/apps/sim/lib/credential-groups/service.test.ts +++ b/apps/sim/lib/credential-groups/service.test.ts @@ -79,7 +79,7 @@ describe('Credential Group service', () => { }, ], }) - ).resolves.toMatchObject({ id: 'group-1' }) + ).resolves.toMatchObject({ credentialGroup: { id: 'group-1' } }) expect(mockGetPolicy).toHaveBeenCalledWith( expect.objectContaining({ slackBotCredentialId: 'bot-1' }), @@ -175,7 +175,11 @@ describe('Credential Group service', () => { .mockResolvedValueOnce([{ id: 'policy-1' }]) .mockResolvedValueOnce([{ id: 'group-1' }]) - await expect(deleteCredentialGroup('workspace-1', 'group-1')).resolves.toBe(true) + await expect(deleteCredentialGroup('workspace-1', 'group-1')).resolves.toEqual({ + deleted: true, + retiredMcpConnectionIds: [], + retiredMcpServerIds: [], + }) expect(dbChainMockFns.for).toHaveBeenCalledWith('update') expect(dbChainMockFns.delete).toHaveBeenCalledTimes(2) diff --git a/apps/sim/lib/credential-groups/service.ts b/apps/sim/lib/credential-groups/service.ts index 6bff6ee700d..c754b11a6d4 100644 --- a/apps/sim/lib/credential-groups/service.ts +++ b/apps/sim/lib/credential-groups/service.ts @@ -4,13 +4,16 @@ import { credential, credentialGroup, credentialGroupEnrollment, + mcpServers, } from '@sim/db/schema' import { generateId } from '@sim/utils/id' -import { and, desc, eq, inArray } from 'drizzle-orm' +import { and, asc, desc, eq, inArray, isNull } from 'drizzle-orm' import { credentialGroupWorkflowAccessPolicyCodec, requireDefaultCredentialGroupWorkflowAccessPolicy, } from '@/lib/credential-groups/application/workflow-access-policy' +import { getManagedMcpConnector } from '@/lib/credential-groups/managed-mcp-connectors' +import { retireManagedMcpServersForGroup } from '@/lib/credential-groups/managed-mcp-service' import { credentialGroupScopePolicyVersion } from '@/lib/credential-groups/provider-adapter' import { decryptCredentialGroupProviderConfiguration } from '@/lib/credential-groups/provider-configuration' import { getCredentialGroupProviderAdapter } from '@/lib/credential-groups/provider-registry' @@ -18,6 +21,7 @@ import { isCredentialGroupProvider } from '@/lib/credential-groups/providers' import { SLACK_MANAGED_USER_SCOPES } from '@/lib/credential-groups/slack-managed-user-scopes' import type { CreateCredentialGroupInput, + CredentialGroupMcpServer, CredentialGroupOptionInput, CredentialGroupRecord, UpdateCredentialGroupInput, @@ -28,6 +32,44 @@ import { requireResourcePolicy, } from '@/lib/resource-policies/repository' +interface CredentialGroupMutationResult { + credentialGroup: CredentialGroupRecord + retiredMcpConnectionIds: string[] +} + +interface DeleteCredentialGroupResult { + deleted: boolean + retiredMcpConnectionIds: string[] + retiredMcpServerIds: string[] +} + +async function listLinkedMcpServers( + credentialGroupId: string, + executor: DbOrTx = db +): Promise { + const rows = await executor + .select({ + id: mcpServers.id, + name: mcpServers.name, + description: mcpServers.description, + authType: mcpServers.authType, + enabled: mcpServers.enabled, + managedConnectorId: mcpServers.managedConnectorId, + }) + .from(mcpServers) + .where(and(eq(mcpServers.credentialGroupId, credentialGroupId), isNull(mcpServers.deletedAt))) + .orderBy(asc(mcpServers.name), asc(mcpServers.id)) + return rows.map((row) => { + if (!row.managedConnectorId) { + throw new Error(`Credential Group MCP server ${row.id} has no managed connector ID`) + } + return { + ...row, + managedConnectorId: getManagedMcpConnector(row.managedConnectorId).id, + } + }) +} + function scopesEqual(left: string[], right: string[]): boolean { const normalizedLeft = [...new Set(left)].sort() const normalizedRight = [...new Set(right)].sort() @@ -97,7 +139,8 @@ async function updateOptions( } async function toCredentialGroup( - row: typeof credentialGroup.$inferSelect + row: typeof credentialGroup.$inferSelect, + linkedMcpServers: CredentialGroupMcpServer[] ): Promise { const providerConfiguration = await decryptCredentialGroupProviderConfiguration( row.encryptedProviderConfiguration @@ -140,6 +183,7 @@ async function toCredentialGroup( : ('ready' as const), } }), + mcpServers: linkedMcpServers, status: row.status, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), @@ -147,12 +191,42 @@ async function toCredentialGroup( } export async function listCredentialGroups(workspaceId: string): Promise { - const rows = await db - .select() - .from(credentialGroup) - .where(eq(credentialGroup.workspaceId, workspaceId)) - .orderBy(desc(credentialGroup.createdAt)) - return Promise.all(rows.map(toCredentialGroup)) + const [rows, serverRows] = await Promise.all([ + db + .select() + .from(credentialGroup) + .where(eq(credentialGroup.workspaceId, workspaceId)) + .orderBy(desc(credentialGroup.createdAt)), + db + .select({ + id: mcpServers.id, + name: mcpServers.name, + description: mcpServers.description, + authType: mcpServers.authType, + enabled: mcpServers.enabled, + credentialGroupId: mcpServers.credentialGroupId, + managedConnectorId: mcpServers.managedConnectorId, + }) + .from(mcpServers) + .where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt))) + .orderBy(asc(mcpServers.name), asc(mcpServers.id)), + ]) + const serversByGroupId = new Map() + for (const server of serverRows) { + if (!server.credentialGroupId) continue + const summary = { + id: server.id, + name: server.name, + description: server.description, + authType: server.authType, + enabled: server.enabled, + managedConnectorId: getManagedMcpConnector(server.managedConnectorId ?? '').id, + } + const current = serversByGroupId.get(server.credentialGroupId) + if (current) current.push(summary) + else serversByGroupId.set(server.credentialGroupId, [summary]) + } + return Promise.all(rows.map((row) => toCredentialGroup(row, serversByGroupId.get(row.id) ?? []))) } export async function getCredentialGroup( @@ -164,7 +238,7 @@ export async function getCredentialGroup( .from(credentialGroup) .where(and(eq(credentialGroup.id, groupId), eq(credentialGroup.workspaceId, workspaceId))) .limit(1) - return row ? toCredentialGroup(row) : null + return row ? toCredentialGroup(row, await listLinkedMcpServers(row.id)) : null } export async function createCredentialGroup( @@ -206,14 +280,14 @@ export async function createCredentialGroup( document: policy.document, credentialGroupId: created.id, }) - return toCredentialGroup(created) + return toCredentialGroup(created, await listLinkedMcpServers(created.id, tx)) }) } export async function deleteCredentialGroup( workspaceId: string, groupId: string -): Promise { +): Promise { return db.transaction(async (tx) => { const [existing] = await tx .select({ id: credentialGroup.id }) @@ -221,7 +295,11 @@ export async function deleteCredentialGroup( .where(and(eq(credentialGroup.id, groupId), eq(credentialGroup.workspaceId, workspaceId))) .limit(1) .for('update') - if (!existing) return false + if (!existing) { + return { deleted: false, retiredMcpConnectionIds: [], retiredMcpServerIds: [] } + } + + const retiredMcp = await retireManagedMcpServersForGroup(workspaceId, groupId, tx) await deleteResourcePolicyForResource( { workspaceId, resourceType: 'credential_group', resourceId: groupId }, @@ -232,7 +310,11 @@ export async function deleteCredentialGroup( .where(and(eq(credentialGroup.id, groupId), eq(credentialGroup.workspaceId, workspaceId))) .returning({ id: credentialGroup.id }) if (deleted.length !== 1) throw new Error('Locked Credential Group delete returned no row') - return true + return { + deleted: true, + retiredMcpConnectionIds: retiredMcp.connectionIds, + retiredMcpServerIds: retiredMcp.serverIds, + } }) } @@ -240,7 +322,7 @@ export async function updateCredentialGroup( workspaceId: string, groupId: string, body: UpdateCredentialGroupInput -): Promise { +): Promise { return db.transaction(async (tx) => { const [existing] = await tx .select() @@ -302,6 +384,9 @@ export async function updateCredentialGroup( ) ) } - return toCredentialGroup(updated) + return { + credentialGroup: await toCredentialGroup(updated, await listLinkedMcpServers(updated.id, tx)), + retiredMcpConnectionIds: [], + } }) } diff --git a/apps/sim/lib/credential-groups/types.ts b/apps/sim/lib/credential-groups/types.ts index a9c39dc5ebe..8842f88c077 100644 --- a/apps/sim/lib/credential-groups/types.ts +++ b/apps/sim/lib/credential-groups/types.ts @@ -1,3 +1,4 @@ +import type { ManagedMcpConnectorId } from '@/lib/credential-groups/managed-mcp-connectors' import type { CredentialGroupProvider } from '@/lib/credential-groups/providers' interface CredentialGroupOptionInputBase { @@ -29,6 +30,15 @@ export interface UpdateCredentialGroupInput { status?: 'active' | 'disabled' } +export interface CredentialGroupMcpServer { + id: string + name: string + description: string | null + authType: string + enabled: boolean + managedConnectorId: ManagedMcpConnectorId +} + interface CredentialGroupOptionBase { id: string label: string @@ -53,6 +63,7 @@ export interface CredentialGroupRecord { name: string description: string | null options: CredentialGroupOption[] + mcpServers: CredentialGroupMcpServer[] status: 'active' | 'disabled' createdAt: string updatedAt: string @@ -86,8 +97,15 @@ export interface CredentialGroupEnrollmentConnection { count: number } +export interface CredentialGroupEnrollmentMcpConnection { + mcpServerId: string + name: string + status: 'active' | 'needs_reauth' | 'revoked' +} + export interface CredentialGroupEnrollmentDetail extends CredentialGroupEnrollmentRecord { connections: CredentialGroupEnrollmentConnection[] + mcpConnections: CredentialGroupEnrollmentMcpConnection[] } export interface InviteCredentialGroupEnrollmentsInput { diff --git a/apps/sim/lib/credentials/access.ts b/apps/sim/lib/credentials/access.ts index 6f7bc25515e..9f1da73fa52 100644 --- a/apps/sim/lib/credentials/access.ts +++ b/apps/sim/lib/credentials/access.ts @@ -12,12 +12,23 @@ type ActiveCredentialMember = typeof credentialMember.$inferSelect type CredentialRecord = typeof credential.$inferSelect export type CredentialType = (typeof credentialTypeEnum.enumValues)[number] -export type OrdinaryCredentialType = Exclude +export type ManagedCredentialType = Extract + +export const MANAGED_CREDENTIAL_TYPES: readonly ManagedCredentialType[] = [ + 'managed_oauth', + 'managed_mcp', +] + +export function isManagedCredentialType(type: CredentialType): type is ManagedCredentialType { + return MANAGED_CREDENTIAL_TYPES.some((managed) => managed === type) +} + +export type OrdinaryCredentialType = Exclude /** Narrows credentials exposed through ordinary user-managed credential surfaces. */ export function requireOrdinaryCredentialType(type: CredentialType): OrdinaryCredentialType { - if (type === 'managed_oauth') { - throw new Error('Managed OAuth credential reached an ordinary credential surface') + if (isManagedCredentialType(type)) { + throw new Error('Managed credential reached an ordinary credential surface') } return type } diff --git a/apps/sim/lib/credentials/application/authorization.ts b/apps/sim/lib/credentials/application/authorization.ts index ef384c6b346..acee7eea975 100644 --- a/apps/sim/lib/credentials/application/authorization.ts +++ b/apps/sim/lib/credentials/application/authorization.ts @@ -1,10 +1,12 @@ import { type Principal, resolvePrincipalExecutionActorUserId } from '@sim/auth/principal' import type { WorkspaceDelegationPolicy } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { ManagedMcpCredentialApplicationContext } from '@/lib/credentials/managed-mcp' import type { ManagedOAuthCredentialApplicationContext } from '@/lib/credentials/managed-oauth' export const CREDENTIAL_DELEGATION_AUDIENCE = 'sim:credentials' export const MANAGED_OAUTH_DELEGATION_AUDIENCE = 'sim:managed-oauth-credentials' +export const MANAGED_MCP_DELEGATION_AUDIENCE = 'sim:managed-mcp-credentials' export const credentialDelegationPolicy = { audience: CREDENTIAL_DELEGATION_AUDIENCE, @@ -23,6 +25,14 @@ export const managedOAuthCredentialDelegationPolicy = { ) => principal.resourceScope?.credentialId === context.credentialId, } satisfies WorkspaceDelegationPolicy +export const managedMcpCredentialDelegationPolicy = { + audience: MANAGED_MCP_DELEGATION_AUDIENCE, + isWithinScope: ( + principal: Extract, + context: ManagedMcpCredentialApplicationContext + ) => principal.resourceScope?.credentialId === context.credentialId, +} satisfies WorkspaceDelegationPolicy + /** * Resolves the user whose credential grants an operation evaluates. * diff --git a/apps/sim/lib/credentials/application/credential-crud.test.ts b/apps/sim/lib/credentials/application/credential-crud.test.ts index c90980159b9..0ba3a46a136 100644 --- a/apps/sim/lib/credentials/application/credential-crud.test.ts +++ b/apps/sim/lib/credentials/application/credential-crud.test.ts @@ -37,6 +37,7 @@ vi.mock('@/lib/credentials/queries', () => ({ vi.mock('@/lib/credentials/access', () => ({ getCredentialActorContext: mocks.getActor, canUseCredential: () => true, + requireOrdinaryCredentialType: (type: string) => type, })) vi.mock('@/lib/credentials/orchestration', () => ({ updateCredentialRecord: mocks.updateRecord, diff --git a/apps/sim/lib/credentials/application/credential-crud.ts b/apps/sim/lib/credentials/application/credential-crud.ts index e0ba3090979..13938cb9abd 100644 --- a/apps/sim/lib/credentials/application/credential-crud.ts +++ b/apps/sim/lib/credentials/application/credential-crud.ts @@ -3,7 +3,11 @@ import { requirePrincipalSubjectUserId } from '@sim/auth/principal' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { getBlockVisibility } from '@/lib/core/config/block-visibility' import { OrchestrationError } from '@/lib/core/orchestration/types' -import { canUseCredential, getCredentialActorContext } from '@/lib/credentials/access' +import { + canUseCredential, + getCredentialActorContext, + requireOrdinaryCredentialType, +} from '@/lib/credentials/access' import { defineAuthorizedCredentialUseCase, requireCredentialAccess, @@ -241,7 +245,7 @@ export const createWorkspaceCredential = defineAuthorizedWorkspaceUseCase({ requirePrincipalSubjectUserId(principal), 'credential_connected', { - credential_type: result.credential.type, + credential_type: requireOrdinaryCredentialType(result.credential.type), provider_id: result.credential.providerId ?? result.credential.type, workspace_id: context.workspaceId, }, diff --git a/apps/sim/lib/credentials/application/credential-members.ts b/apps/sim/lib/credentials/application/credential-members.ts index 58a2262ce60..48736ffceae 100644 --- a/apps/sim/lib/credentials/application/credential-members.ts +++ b/apps/sim/lib/credentials/application/credential-members.ts @@ -1,6 +1,7 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { requirePrincipalSubjectUserId, type SessionPrincipal } from '@sim/auth/principal' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { requireOrdinaryCredentialType } from '@/lib/credentials/access' import { defineAuthorizedCredentialUseCase } from '@/lib/credentials/application/authorized-credential-use-case' import { defineAuthorizedCredentialUserUseCase } from '@/lib/credentials/application/authorized-user-use-case' import { resolveCredentialApplicationContext } from '@/lib/credentials/application/credential-context' @@ -86,7 +87,7 @@ export const upsertCredentialMemberUseCase = defineAuthorizedCredentialUseCase({ afterSuccess: ({ principal, context, result }) => { if (!result.created) return captureServerEvent(requirePrincipalSubjectUserId(principal), 'credential_shared', { - credential_type: context.credential.type, + credential_type: requireOrdinaryCredentialType(context.credential.type), role: result.role, workspace_id: context.workspaceId, }) @@ -119,7 +120,7 @@ export const removeCredentialMemberUseCase = defineAuthorizedCredentialUseCase({ }), afterSuccess: ({ principal, context }) => { captureServerEvent(requirePrincipalSubjectUserId(principal), 'credential_unshared', { - credential_type: context.credential.type, + credential_type: requireOrdinaryCredentialType(context.credential.type), workspace_id: context.workspaceId, }) }, diff --git a/apps/sim/lib/credentials/application/discover-managed-mcp-tools.test.ts b/apps/sim/lib/credentials/application/discover-managed-mcp-tools.test.ts new file mode 100644 index 00000000000..0e6f6f77a7a --- /dev/null +++ b/apps/sim/lib/credentials/application/discover-managed-mcp-tools.test.ts @@ -0,0 +1,143 @@ +/** + * @vitest-environment node + */ +import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + discoverTools: vi.fn(), + loadAuthProvider: vi.fn(), + loadContext: vi.fn(), + loadRuntime: vi.fn(), + requireCredentialAccess: vi.fn(), + resolvePermission: vi.fn(), + saveToolSnapshot: vi.fn(), +})) + +vi.mock('@/lib/credentials/managed-mcp', () => ({ + loadManagedMcpCredentialApplicationContext: mocks.loadContext, + loadManagedMcpRuntimeCredential: mocks.loadRuntime, + saveManagedMcpToolSnapshot: mocks.saveToolSnapshot, +})) + +vi.mock('@/lib/credential-groups/application/authorization', () => ({ + requireCredentialGroupCredentialAccess: mocks.requireCredentialAccess, +})) + +vi.mock('@/lib/mcp/application/managed-auth-provider', () => ({ + loadManagedMcpAuthProvider: mocks.loadAuthProvider, +})) + +vi.mock('@/lib/mcp/oauth', () => ({ + withMcpOauthRefreshLock: vi.fn((_credentialId: string, operation: () => Promise) => + operation() + ), +})) + +vi.mock('@/lib/mcp/service', () => ({ + mcpService: { discoverManagedMcpTools: mocks.discoverTools }, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +import { discoverManagedMcpToolsUseCase } from '@/lib/credentials/application/discover-managed-mcp-tools' + +const context = { + credentialId: 'mcp-cg-123456789012345678901', + credentialGroupId: 'group-1', + credentialGroupEnrollmentId: 'selected-enrollment', + mcpServerId: 'mcp-fireflies', + mcpServerName: 'Fireflies', + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, +} + +const principal: WorkflowExecutionDelegatedPrincipal = { + kind: 'delegated', + serviceId: 'executor', + subjectUserId: 'execution-user', + workspaceId: context.workspaceId, + delegationId: 'delegation-1', + audience: 'sim:managed-mcp-credentials', + issuedAt: new Date(Date.now() - 1_000), + expiresAt: new Date(Date.now() + 60_000), + resourceScope: { credentialId: context.credentialId }, + delegationContext: { + kind: 'workflow_execution', + workflowId: 'workflow-1', + principal: { kind: 'session', userId: 'execution-user', sessionId: 'session-1' }, + currentWorkflow: { workflowId: 'workflow-1', mode: 'draft' }, + }, +} + +describe('discoverManagedMcpToolsUseCase', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadContext.mockResolvedValue(context) + mocks.loadRuntime.mockResolvedValue({ + credentialId: context.credentialId, + mcpServerId: context.mcpServerId, + mcpServerName: context.mcpServerName, + workspaceId: context.workspaceId, + tokenVersion: 'encrypted-token-version-1', + tokens: { access_token: 'access-token' }, + tools: [], + }) + mocks.loadAuthProvider.mockResolvedValue({}) + mocks.requireCredentialAccess.mockResolvedValue(undefined) + mocks.resolvePermission.mockResolvedValue('read') + mocks.discoverTools.mockResolvedValue([ + { + name: 'search_transcripts', + description: 'Search transcripts', + inputSchema: { type: 'object', properties: {} }, + serverId: context.mcpServerId, + serverName: context.mcpServerName, + }, + ]) + }) + + it('discovers through the explicit credential and projects that ID as the tool server', async () => { + const signal = new AbortController().signal + const result = await discoverManagedMcpToolsUseCase.execute({ + principal, + input: { + workspaceId: context.workspaceId, + credentialId: context.credentialId, + signal, + }, + }) + + expect(mocks.requireCredentialAccess).toHaveBeenCalledWith(principal, context, { + resourceType: 'credential_group', + action: 'credential_groups.credentials.use', + }) + expect(mocks.loadRuntime).toHaveBeenCalledWith(context.credentialId, context.workspaceId) + expect(mocks.discoverTools).toHaveBeenCalledWith( + context.mcpServerId, + context.workspaceId, + {}, + signal, + { requireComplete: true } + ) + expect(result.tools).toEqual([ + expect.objectContaining({ + name: 'search_transcripts', + serverId: context.credentialId, + serverName: context.mcpServerName, + }), + ]) + expect(mocks.saveToolSnapshot).toHaveBeenCalledWith(context.credentialId, [ + { + name: 'search_transcripts', + description: 'Search transcripts', + inputSchema: { type: 'object', properties: {} }, + }, + ]) + }) +}) diff --git a/apps/sim/lib/credentials/application/discover-managed-mcp-tools.ts b/apps/sim/lib/credentials/application/discover-managed-mcp-tools.ts new file mode 100644 index 00000000000..0e8ba546f31 --- /dev/null +++ b/apps/sim/lib/credentials/application/discover-managed-mcp-tools.ts @@ -0,0 +1,73 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { requireCredentialGroupCredentialAccess } from '@/lib/credential-groups/application/authorization' +import { managedMcpCredentialDelegationPolicy } from '@/lib/credentials/application/authorization' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { + loadManagedMcpCredentialApplicationContext, + loadManagedMcpRuntimeCredential, + saveManagedMcpToolSnapshot, +} from '@/lib/credentials/managed-mcp' +import { loadManagedMcpAuthProvider } from '@/lib/mcp/application/managed-auth-provider' +import { withMcpOauthRefreshLock } from '@/lib/mcp/oauth' +import { mcpService } from '@/lib/mcp/service' + +export interface DiscoverManagedMcpToolsInput { + workspaceId: string + credentialId: string + signal?: AbortSignal +} + +export const discoverManagedMcpToolsUseCase = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.useManagedMcp, + resolveContext: async ({ input }: { input: DiscoverManagedMcpToolsInput }) => { + const context = await loadManagedMcpCredentialApplicationContext(input.credentialId) + if (!context || context.workspaceId !== input.workspaceId) { + throw new OrchestrationError('not_found', 'Managed MCP connection not found') + } + return context + }, + authorizationOptions: { delegation: managedMcpCredentialDelegationPolicy }, + async authorizeResource({ principal, context, resourcePolicy }) { + await requireCredentialGroupCredentialAccess(principal, context, resourcePolicy) + }, + async execute({ input, context }) { + input.signal?.throwIfAborted() + const runtime = await loadManagedMcpRuntimeCredential(context.credentialId, context.workspaceId) + const tools = await withMcpOauthRefreshLock(runtime.credentialId, async () => + mcpService.discoverManagedMcpTools( + runtime.mcpServerId, + runtime.workspaceId, + await loadManagedMcpAuthProvider(runtime.credentialId, runtime.workspaceId), + input.signal, + { requireComplete: true } + ) + ) + await saveManagedMcpToolSnapshot( + runtime.credentialId, + tools.map((tool) => ({ + name: tool.name, + ...(tool.description ? { description: tool.description } : {}), + inputSchema: tool.inputSchema, + })) + ) + return { + tools: tools.map((tool) => ({ + ...tool, + serverId: runtime.credentialId, + serverName: runtime.mcpServerName, + })), + } + }, + projectAudit: ({ context }) => ({ + action: AuditAction.CREDENTIAL_ACCESSED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: context.credentialId, + description: `Discovered tools from managed MCP credential ${context.credentialId}`, + metadata: { + credentialType: 'managed_mcp', + mcpServerId: context.mcpServerId, + }, + }), +}) diff --git a/apps/sim/lib/credentials/application/operations.ts b/apps/sim/lib/credentials/application/operations.ts index 4bda143e590..c851eaac563 100644 --- a/apps/sim/lib/credentials/application/operations.ts +++ b/apps/sim/lib/credentials/application/operations.ts @@ -183,6 +183,18 @@ export const credentialOperations = { action: CREDENTIAL_GROUP_CREDENTIAL_USE_ACTION, }, }), + useManagedMcp: defineWorkspaceOperation({ + id: 'credentials.managed_mcp.use', + minimumRole: 'read', + workspaceApiKey: 'deny', + capability: 'integrations.manage', + principalKinds: ['delegated'], + delegatedServices: ['executor'], + resourcePolicy: { + resourceType: 'credential_group', + action: CREDENTIAL_GROUP_CREDENTIAL_USE_ACTION, + }, + }), } as const /** diff --git a/apps/sim/lib/credentials/application/presentation.ts b/apps/sim/lib/credentials/application/presentation.ts index 3770f2ab7f7..a7a9305580d 100644 --- a/apps/sim/lib/credentials/application/presentation.ts +++ b/apps/sim/lib/credentials/application/presentation.ts @@ -36,6 +36,7 @@ export function toWorkspaceCredential( access?: CredentialActorContext ): WorkspaceCredential { const type = requireOrdinaryCredentialType(row.type) + if (!row.createdBy) throw new Error(`Credential ${row.id} has no creator`) const role = access?.isAdmin ? 'admin' : (access?.member?.role ?? ('role' in row ? row.role : undefined)) diff --git a/apps/sim/lib/credentials/application/service-account.test.ts b/apps/sim/lib/credentials/application/service-account.test.ts index dbdf310231e..20828646e5a 100644 --- a/apps/sim/lib/credentials/application/service-account.test.ts +++ b/apps/sim/lib/credentials/application/service-account.test.ts @@ -39,6 +39,12 @@ vi.mock('@/lib/credentials/queries', () => ({ })) vi.mock('@/lib/credentials/access', () => ({ getCredentialActorContext: mocks.getActor, + requireOrdinaryCredentialType: (type: string) => { + if (type === 'managed_oauth' || type === 'managed_mcp') { + throw new Error('Managed credential reached an ordinary credential surface') + } + return type + }, })) vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture })) diff --git a/apps/sim/lib/credentials/application/service-account.ts b/apps/sim/lib/credentials/application/service-account.ts index 6112b39a99d..6061034e068 100644 --- a/apps/sim/lib/credentials/application/service-account.ts +++ b/apps/sim/lib/credentials/application/service-account.ts @@ -4,7 +4,7 @@ import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import { OrchestrationError } from '@/lib/core/orchestration/types' import { HttpError } from '@/lib/core/utils/http-error' -import { getCredentialActorContext } from '@/lib/credentials/access' +import { getCredentialActorContext, requireOrdinaryCredentialType } from '@/lib/credentials/access' import { defineAuthorizedCredentialUseCase, requireManageableCredentialType, @@ -183,7 +183,7 @@ export const deleteCredentialUseCase = defineAuthorizedCredentialUseCase({ requirePrincipalSubjectUserId(principal), 'credential_deleted', { - credential_type: result.credential.type, + credential_type: requireOrdinaryCredentialType(result.credential.type), provider_id: result.credential.providerId ?? result.credential.envKey ?? result.credential.id, workspace_id: context.workspaceId, diff --git a/apps/sim/lib/credentials/managed-mcp.ts b/apps/sim/lib/credentials/managed-mcp.ts new file mode 100644 index 00000000000..df57d76d215 --- /dev/null +++ b/apps/sim/lib/credentials/managed-mcp.ts @@ -0,0 +1,402 @@ +import type { OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js' +import { db } from '@sim/db' +import { + credential, + credentialGroup, + credentialGroupEnrollment, + type ManagedMcpToolSnapshot, + mcpServers, +} from '@sim/db/schema' +import { getErrorMessage } from '@sim/utils/errors' +import { and, eq, isNull, ne } from 'drizzle-orm' +import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access' +import type { WorkspaceAuthorizationContext } from '@/lib/core/application' +import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' +import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability' +import { lockCredentialGroupEnrollmentLifecycle } from '@/lib/credential-groups/enrollments' +import { getManagedMcpConnector } from '@/lib/credential-groups/managed-mcp-connectors' +import { generateManagedMcpConnectionId } from '@/lib/mcp/utils' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +const MANAGED_MCP_TOKEN_SET_TYPE = 'managed-mcp-oauth-token-set' as const +const MANAGED_MCP_TOKEN_SET_VERSION = 1 as const + +interface ManagedMcpTokenEnvelope { + type: typeof MANAGED_MCP_TOKEN_SET_TYPE + version: typeof MANAGED_MCP_TOKEN_SET_VERSION + tokens: OAuthTokens +} + +export interface ManagedMcpCredentialApplicationContext extends WorkspaceAuthorizationContext { + credentialId: string + credentialGroupId: string + credentialGroupEnrollmentId: string + mcpServerId: string + mcpServerName: string +} + +export interface ManagedMcpRuntimeCredential { + credentialId: string + mcpServerId: string + mcpServerName: string + workspaceId: string + tokenVersion: string + tokens: OAuthTokens + tools: ManagedMcpToolSnapshot[] +} + +export class ManagedMcpCredentialError extends Error { + constructor( + message: string, + readonly statusCode: 401 | 403 | 404 | 500 + ) { + super(message) + this.name = 'ManagedMcpCredentialError' + } +} + +function isOAuthTokens(value: unknown): value is OAuthTokens { + if (!value || typeof value !== 'object') return false + const candidate = value as Record + return ( + typeof candidate.access_token === 'string' && + candidate.access_token.length > 0 && + (candidate.refresh_token === undefined || typeof candidate.refresh_token === 'string') && + (candidate.token_type === undefined || typeof candidate.token_type === 'string') && + (candidate.expires_in === undefined || typeof candidate.expires_in === 'number') + ) +} + +export async function encryptManagedMcpTokens(tokens: OAuthTokens): Promise { + if (!isOAuthTokens(tokens)) throw new ManagedMcpCredentialError('Invalid MCP OAuth tokens', 500) + const envelope: ManagedMcpTokenEnvelope = { + type: MANAGED_MCP_TOKEN_SET_TYPE, + version: MANAGED_MCP_TOKEN_SET_VERSION, + tokens, + } + return (await encryptSecret(JSON.stringify(envelope))).encrypted +} + +export async function decryptManagedMcpTokens(encrypted: string): Promise { + try { + const { decrypted } = await decryptSecret(encrypted) + const parsed: unknown = JSON.parse(decrypted) + if (!parsed || typeof parsed !== 'object') throw new Error('Invalid token envelope') + const envelope = parsed as Record + if ( + envelope.type !== MANAGED_MCP_TOKEN_SET_TYPE || + envelope.version !== MANAGED_MCP_TOKEN_SET_VERSION || + !isOAuthTokens(envelope.tokens) + ) { + throw new Error('Invalid token envelope') + } + return envelope.tokens + } catch (error) { + throw new ManagedMcpCredentialError( + `Managed MCP credential token data is invalid: ${getErrorMessage(error)}`, + 500 + ) + } +} + +export async function loadManagedMcpCredentialApplicationContext( + credentialId: string +): Promise { + const [row] = await db + .select({ + credentialId: credential.id, + workspaceId: credential.workspaceId, + credentialGroupId: credentialGroup.id, + credentialGroupEnrollmentId: credentialGroupEnrollment.id, + mcpServerId: mcpServers.id, + mcpServerName: mcpServers.name, + managedConnectorId: mcpServers.managedConnectorId, + }) + .from(credential) + .innerJoin( + credentialGroupEnrollment, + eq(credentialGroupEnrollment.id, credential.credentialGroupEnrollmentId) + ) + .innerJoin(credentialGroup, eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId)) + .innerJoin(mcpServers, eq(mcpServers.id, credential.mcpServerId)) + .where(and(eq(credential.id, credentialId), eq(credential.type, 'managed_mcp'))) + .limit(1) + if (!row) return null + if (!row.managedConnectorId) { + throw new Error(`Managed MCP server ${row.mcpServerId} has no connector ID`) + } + getManagedMcpConnector(row.managedConnectorId) + const workspaceContext = await loadActiveWorkspaceApplicationContext(row.workspaceId) + return workspaceContext ? { ...workspaceContext, ...row } : null +} + +export async function loadManagedMcpRuntimeCredential( + credentialId: string, + workspaceId: string +): Promise { + const ownerBilling = await getWorkspaceOwnerSubscriptionAccess(workspaceId) + if (!(await isCredentialGroupsAvailable({ workspaceId, ownerBilling }))) { + throw new ManagedMcpCredentialError( + 'Managed MCP credentials are not available for this workspace', + 403 + ) + } + + const [row] = await db + .select({ + credentialId: credential.id, + workspaceId: credential.workspaceId, + status: credential.managedOauthStatus, + encryptedTokens: credential.encryptedOauthTokenSet, + tools: credential.mcpTools, + enrollmentStatus: credentialGroupEnrollment.status, + groupStatus: credentialGroup.status, + credentialGroupId: credentialGroup.id, + linkedCredentialGroupId: mcpServers.credentialGroupId, + mcpServerId: mcpServers.id, + mcpServerName: mcpServers.name, + managedConnectorId: mcpServers.managedConnectorId, + }) + .from(credential) + .innerJoin( + credentialGroupEnrollment, + eq(credentialGroupEnrollment.id, credential.credentialGroupEnrollmentId) + ) + .innerJoin(credentialGroup, eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId)) + .innerJoin(mcpServers, eq(mcpServers.id, credential.mcpServerId)) + .where( + and( + eq(credential.id, credentialId), + eq(credential.workspaceId, workspaceId), + eq(credential.type, 'managed_mcp'), + eq(mcpServers.workspaceId, workspaceId), + eq(mcpServers.authType, 'oauth'), + eq(mcpServers.enabled, true), + isNull(mcpServers.deletedAt) + ) + ) + .limit(1) + if (!row) throw new ManagedMcpCredentialError('Managed MCP credential not found', 404) + if (!row.managedConnectorId) { + throw new ManagedMcpCredentialError('Managed MCP connector metadata is missing', 500) + } + getManagedMcpConnector(row.managedConnectorId) + if ( + row.status !== 'active' || + row.groupStatus !== 'active' || + !['in_progress', 'completed'].includes(row.enrollmentStatus) || + row.linkedCredentialGroupId !== row.credentialGroupId + ) { + throw new ManagedMcpCredentialError('Managed MCP credential needs authorization', 401) + } + if (!row.encryptedTokens) { + throw new ManagedMcpCredentialError('Managed MCP credential token data is missing', 500) + } + if (!row.tools) throw new ManagedMcpCredentialError('Managed MCP tool metadata is missing', 500) + return { + credentialId: row.credentialId, + workspaceId: row.workspaceId, + mcpServerId: row.mcpServerId, + mcpServerName: row.mcpServerName, + tokenVersion: row.encryptedTokens, + tokens: await decryptManagedMcpTokens(row.encryptedTokens), + tools: row.tools, + } +} + +export async function persistManagedMcpCredential(params: { + enrollmentId: string + workspaceId: string + mcpServerId: string + mcpServerName: string + tokens: OAuthTokens + tools: Array<{ name: string; description?: string; inputSchema: Record }> +}): Promise { + const encryptedOauthTokenSet = await encryptManagedMcpTokens(params.tokens) + const now = new Date() + const accessTokenExpiresAt = + typeof params.tokens.expires_in === 'number' + ? new Date(now.getTime() + params.tokens.expires_in * 1000) + : null + return db.transaction(async (tx) => { + await lockCredentialGroupEnrollmentLifecycle(tx, params.enrollmentId) + const [source] = await tx + .select({ + enrollmentStatus: credentialGroupEnrollment.status, + credentialGroupId: credentialGroupEnrollment.credentialGroupId, + groupStatus: credentialGroup.status, + linkedCredentialGroupId: mcpServers.credentialGroupId, + managedConnectorId: mcpServers.managedConnectorId, + }) + .from(credentialGroupEnrollment) + .innerJoin( + credentialGroup, + eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId) + ) + .innerJoin(mcpServers, eq(mcpServers.id, params.mcpServerId)) + .where( + and( + eq(credentialGroupEnrollment.id, params.enrollmentId), + eq(credentialGroup.workspaceId, params.workspaceId), + eq(mcpServers.workspaceId, params.workspaceId), + eq(mcpServers.authType, 'oauth'), + eq(mcpServers.enabled, true), + isNull(mcpServers.deletedAt) + ) + ) + .limit(1) + .for('update') + if ( + !source || + !source.managedConnectorId || + source.groupStatus !== 'active' || + !['invited', 'in_progress', 'completed'].includes(source.enrollmentStatus) || + source.linkedCredentialGroupId !== source.credentialGroupId + ) { + throw new ManagedMcpCredentialError('Managed MCP connection is no longer available', 404) + } + getManagedMcpConnector(source.managedConnectorId) + + const [existing] = await tx + .select({ id: credential.id }) + .from(credential) + .where( + and( + eq(credential.type, 'managed_mcp'), + eq(credential.credentialGroupEnrollmentId, params.enrollmentId), + eq(credential.mcpServerId, params.mcpServerId) + ) + ) + .limit(1) + .for('update') + const values = { + displayName: params.mcpServerName, + managedOauthStatus: 'active' as const, + encryptedOauthTokenSet, + accessTokenExpiresAt, + mcpTools: params.tools, + mcpToolsRefreshedAt: now, + grantedAt: now, + revokedAt: null, + updatedAt: now, + } + let connectionId: string + if (existing) { + const [updated] = await tx + .update(credential) + .set(values) + .where(and(eq(credential.id, existing.id), eq(credential.type, 'managed_mcp'))) + .returning({ id: credential.id }) + if (!updated) throw new Error('Managed MCP credential update returned no row') + connectionId = updated.id + } else { + const id = generateManagedMcpConnectionId() + const insert: typeof credential.$inferInsert = { + id, + workspaceId: params.workspaceId, + type: 'managed_mcp', + createdBy: null, + credentialGroupEnrollmentId: params.enrollmentId, + mcpServerId: params.mcpServerId, + ...values, + createdAt: now, + } + const [created] = await tx.insert(credential).values(insert).returning({ id: credential.id }) + if (!created) throw new Error('Managed MCP credential insert returned no row') + connectionId = created.id + } + + const [updatedEnrollment] = await tx + .update(credentialGroupEnrollment) + .set({ + status: source.enrollmentStatus === 'completed' ? 'completed' : 'in_progress', + ...(source.enrollmentStatus === 'completed' ? {} : { completedAt: null }), + updatedAt: now, + }) + .where( + and( + eq(credentialGroupEnrollment.id, params.enrollmentId), + ne(credentialGroupEnrollment.status, 'revoked') + ) + ) + .returning({ id: credentialGroupEnrollment.id }) + if (!updatedEnrollment) { + throw new ManagedMcpCredentialError('Managed MCP enrollment is no longer available', 404) + } + return connectionId + }) +} + +export async function saveManagedMcpRuntimeTokens( + credentialId: string, + tokens: OAuthTokens | null, + expectedTokenVersion: string +): Promise { + const now = new Date() + const encryptedOauthTokenSet = tokens ? await encryptManagedMcpTokens(tokens) : null + return db.transaction(async (tx) => { + const [source] = await tx + .select({ enrollmentId: credential.credentialGroupEnrollmentId }) + .from(credential) + .where(and(eq(credential.id, credentialId), eq(credential.type, 'managed_mcp'))) + .limit(1) + if (!source?.enrollmentId) { + throw new ManagedMcpCredentialError('Managed MCP credential is no longer active', 401) + } + await lockCredentialGroupEnrollmentLifecycle(tx, source.enrollmentId) + const updated = await tx + .update(credential) + .set( + tokens + ? { + encryptedOauthTokenSet, + managedOauthStatus: 'active', + accessTokenExpiresAt: + typeof tokens.expires_in === 'number' + ? new Date(now.getTime() + tokens.expires_in * 1000) + : null, + updatedAt: now, + } + : { + encryptedOauthTokenSet: null, + managedOauthStatus: 'needs_reauth', + accessTokenExpiresAt: null, + updatedAt: now, + } + ) + .where( + and( + eq(credential.id, credentialId), + eq(credential.type, 'managed_mcp'), + eq(credential.managedOauthStatus, 'active'), + eq(credential.encryptedOauthTokenSet, expectedTokenVersion) + ) + ) + .returning({ id: credential.id }) + if (updated.length !== 1) { + throw new ManagedMcpCredentialError('Managed MCP credential grant changed', 401) + } + return encryptedOauthTokenSet + }) +} + +/** Replaces the editor snapshot only after a complete live tools/list succeeds. */ +export async function saveManagedMcpToolSnapshot( + credentialId: string, + tools: ManagedMcpToolSnapshot[] +): Promise { + const updated = await db + .update(credential) + .set({ mcpTools: tools, mcpToolsRefreshedAt: new Date(), updatedAt: new Date() }) + .where( + and( + eq(credential.id, credentialId), + eq(credential.type, 'managed_mcp'), + eq(credential.managedOauthStatus, 'active') + ) + ) + .returning({ id: credential.id }) + if (updated.length !== 1) { + throw new ManagedMcpCredentialError('Managed MCP credential grant changed', 401) + } +} diff --git a/apps/sim/lib/credentials/members.test.ts b/apps/sim/lib/credentials/members.test.ts index fae51df0525..074b49de7f2 100644 --- a/apps/sim/lib/credentials/members.test.ts +++ b/apps/sim/lib/credentials/members.test.ts @@ -10,11 +10,14 @@ describe('listCredentialMembershipsForUser', () => { resetDbChainMock() }) - it('excludes managed OAuth credentials from ordinary memberships', async () => { + it('excludes managed credentials from ordinary memberships', async () => { dbChainMockFns.where.mockResolvedValue([]) await listCredentialMembershipsForUser('user-1') - expect(drizzleOrmMock.ne).toHaveBeenCalledWith(schemaMock.credential.type, 'managed_oauth') + expect(drizzleOrmMock.notInArray).toHaveBeenCalledWith(schemaMock.credential.type, [ + 'managed_oauth', + 'managed_mcp', + ]) }) }) diff --git a/apps/sim/lib/credentials/members.ts b/apps/sim/lib/credentials/members.ts index 7c3f68047d8..4c0de62ec25 100644 --- a/apps/sim/lib/credentials/members.ts +++ b/apps/sim/lib/credentials/members.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { credential, credentialMember, user } from '@sim/db/schema' import { generateId } from '@sim/utils/id' -import { and, eq, ne } from 'drizzle-orm' +import { and, eq, notInArray } from 'drizzle-orm' import { OrchestrationError } from '@/lib/core/orchestration/types' import { isSharedCredentialType, requireOrdinaryCredentialType } from '@/lib/credentials/access' import type { CredentialRow } from '@/lib/credentials/queries' @@ -218,7 +218,12 @@ export async function listCredentialMembershipsForUser(userId: string) { }) .from(credentialMember) .innerJoin(credential, eq(credentialMember.credentialId, credential.id)) - .where(and(eq(credentialMember.userId, userId), ne(credential.type, 'managed_oauth'))) + .where( + and( + eq(credentialMember.userId, userId), + notInArray(credential.type, ['managed_oauth', 'managed_mcp']) + ) + ) return rows.map((row) => ({ ...row, type: requireOrdinaryCredentialType(row.type) })) } diff --git a/apps/sim/lib/credentials/orchestration/credential-create.ts b/apps/sim/lib/credentials/orchestration/credential-create.ts index 8c3af20c6bb..3eb5903815c 100644 --- a/apps/sim/lib/credentials/orchestration/credential-create.ts +++ b/apps/sim/lib/credentials/orchestration/credential-create.ts @@ -10,7 +10,7 @@ import { normalizeCredentialEnvKey } from '@/lib/api/contracts/credentials' import { acquireOrganizationUserMutationLocks } from '@/lib/billing/organizations/membership' import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' import { decryptSecret } from '@/lib/core/security/encryption' -import { getCredentialActorContext } from '@/lib/credentials/access' +import { getCredentialActorContext, requireOrdinaryCredentialType } from '@/lib/credentials/access' import { AtlassianValidationError } from '@/lib/credentials/atlassian-service-account' import { getCredentialCreationWorkspaceContext } from '@/lib/credentials/environment' import type { CredentialOrchestrationErrorCode } from '@/lib/credentials/orchestration' @@ -608,7 +608,7 @@ export async function performCreateCredential( params.userId, 'credential_connected', { - credential_type: result.credential.type, + credential_type: requireOrdinaryCredentialType(result.credential.type), provider_id: result.credential.providerId ?? result.credential.type, workspace_id: result.credential.workspaceId, }, diff --git a/apps/sim/lib/credentials/queries.test.ts b/apps/sim/lib/credentials/queries.test.ts index efe7459628e..de3cc280dae 100644 --- a/apps/sim/lib/credentials/queries.test.ts +++ b/apps/sim/lib/credentials/queries.test.ts @@ -16,7 +16,7 @@ describe('listVisibleWorkspaceCredentials', () => { resetDbChainMock() }) - it('always excludes managed OAuth credentials from selector-backed listings', async () => { + it('always excludes managed credentials from selector-backed listings', async () => { dbChainMockFns.orderBy.mockResolvedValueOnce([]) await listVisibleWorkspaceCredentials({ @@ -25,7 +25,10 @@ describe('listVisibleWorkspaceCredentials', () => { workspaceAccess: { canAdmin: true }, }) - expect(drizzleOrmMock.ne).toHaveBeenCalledWith(schemaMock.credential.type, 'managed_oauth') + expect(drizzleOrmMock.notInArray).toHaveBeenCalledWith(schemaMock.credential.type, [ + 'managed_oauth', + 'managed_mcp', + ]) }) it('does not expose Credential Group configuration on a custom Slack bot', async () => { @@ -153,11 +156,14 @@ describe('ordinary credential lookups', () => { credentialId: 'credential-1', }), ], - ])('excludes managed OAuth from the %s path', async (_name, lookup) => { + ])('excludes managed credentials from the %s path', async (_name, lookup) => { dbChainMockFns.limit.mockResolvedValue([]) await lookup() - expect(drizzleOrmMock.ne).toHaveBeenCalledWith(schemaMock.credential.type, 'managed_oauth') + expect(drizzleOrmMock.notInArray).toHaveBeenCalledWith(schemaMock.credential.type, [ + 'managed_oauth', + 'managed_mcp', + ]) }) }) diff --git a/apps/sim/lib/credentials/queries.ts b/apps/sim/lib/credentials/queries.ts index 1fe8d5f02e4..c89a3975a38 100644 --- a/apps/sim/lib/credentials/queries.ts +++ b/apps/sim/lib/credentials/queries.ts @@ -1,6 +1,6 @@ import { db } from '@sim/db' import { credential, credentialMember } from '@sim/db/schema' -import { and, eq, inArray, isNotNull, ne, or, sql } from 'drizzle-orm' +import { and, eq, inArray, isNotNull, notInArray, or, sql } from 'drizzle-orm' import type { V2CredentialSortBy } from '@/lib/api/contracts/v2/credentials' import { type CursorKey, @@ -135,7 +135,8 @@ export async function listVisibleWorkspaceCredentials(params: { const whereClauses = [ eq(credential.workspaceId, workspaceId), - ne(credential.type, 'managed_oauth'), + notInArray(credential.type, ['managed_oauth', 'managed_mcp']), + isNotNull(credential.createdBy), ] if (types?.length) whereClauses.push(inArray(credential.type, types)) if (providerId) whereClauses.push(eq(credential.providerId, providerId)) @@ -198,19 +199,23 @@ export async function listVisibleWorkspaceCredentials(params: { const rows = await (limit === undefined ? query : query.limit(limit + 1)) - const mapped = rows.map(({ memberRole, encryptedServiceAccountKey, ...rest }) => ({ - ...rest, - hasServiceAccountKey: Boolean(encryptedServiceAccountKey), - /** - * An `env_personal` credential's own env owner administers it regardless of - * workspace role — otherwise the owner of a personal secret can't manage it. - */ - role: - (rest.type === 'env_personal' && rest.envOwnerUserId === userId) || - (isWorkspaceAdmin && isSharedCredentialType(rest.type)) - ? ('admin' as const) - : (memberRole ?? ('member' as const)), - })) + const mapped = rows.map(({ memberRole, encryptedServiceAccountKey, ...rest }) => { + if (!rest.createdBy) throw new Error(`Credential ${rest.id} has no creator`) + return { + ...rest, + createdBy: rest.createdBy, + hasServiceAccountKey: Boolean(encryptedServiceAccountKey), + /** + * An `env_personal` credential's own env owner administers it regardless of + * workspace role — otherwise the owner of a personal secret can't manage it. + */ + role: + (rest.type === 'env_personal' && rest.envOwnerUserId === userId) || + (isWorkspaceAdmin && isSharedCredentialType(rest.type)) + ? ('admin' as const) + : (memberRole ?? ('member' as const)), + } + }) return keysetPage(keys, mapped, limit) } @@ -271,12 +276,16 @@ export async function listWorkspacePrincipalCredentials(params: { const rows = await query.limit(limit + 1) - const mapped = rows.map((row) => ({ - ...row, - envKey: null, - envOwnerUserId: null, - role: 'member' as const, - })) + const mapped = rows.map((row) => { + if (!row.createdBy) throw new Error(`Credential ${row.id} has no creator`) + return { + ...row, + createdBy: row.createdBy, + envKey: null, + envOwnerUserId: null, + role: 'member' as const, + } + }) return keysetPage(keys, mapped, limit) } @@ -296,7 +305,7 @@ export async function getWorkspaceCredential(params: { and( eq(credential.id, params.credentialId), eq(credential.workspaceId, params.workspaceId), - ne(credential.type, 'managed_oauth') + notInArray(credential.type, ['managed_oauth', 'managed_mcp']) ) ) .limit(1) @@ -321,7 +330,7 @@ export async function findWorkspaceCredentialLookup(params: { and( eq(credential.id, params.credentialId), eq(credential.workspaceId, params.workspaceId), - ne(credential.type, 'managed_oauth') + notInArray(credential.type, ['managed_oauth', 'managed_mcp']) ) ) .limit(1) @@ -334,7 +343,7 @@ export async function findWorkspaceCredentialLookup(params: { and( eq(credential.accountId, params.credentialId), eq(credential.workspaceId, params.workspaceId), - ne(credential.type, 'managed_oauth') + notInArray(credential.type, ['managed_oauth', 'managed_mcp']) ) ) .limit(1) @@ -348,7 +357,12 @@ export async function getCredentialById(credentialId: string): Promise { expect(mocks.createPrincipal).toHaveBeenCalledWith({ context: CONTEXT, audience: 'sim:mcp-servers', + resourceScope: { mcpServerId: 'mcp-server' }, }) expect(mocks.executeUseCase).toHaveBeenCalledWith({ principal: PRINCIPAL, diff --git a/apps/sim/lib/internal/mcp/execute-tool.ts b/apps/sim/lib/internal/mcp/execute-tool.ts index 3e900d81af2..0f1b459f98f 100644 --- a/apps/sim/lib/internal/mcp/execute-tool.ts +++ b/apps/sim/lib/internal/mcp/execute-tool.ts @@ -9,6 +9,8 @@ import { getRemainingExecutionMs, } from '@/lib/core/execution-limits' import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' +import { MANAGED_MCP_DELEGATION_AUDIENCE } from '@/lib/credentials/application/authorization' +import { ManagedMcpCredentialError } from '@/lib/credentials/managed-mcp' import { createExecutorPrincipalFromExecutionContext } from '@/lib/internal/principals/executor' import { classifyInternalToolIdentityFault, @@ -17,10 +19,11 @@ import { } from '@/lib/internal/tool-operations/identity-faults' import type { InternalToolOperationHandler } from '@/lib/internal/tool-operations/types' import { MCP_SERVER_DELEGATION_AUDIENCE } from '@/lib/mcp/application/authorization' +import { executeManagedMcpToolUseCase } from '@/lib/mcp/application/execute-managed-tool' import { executeMcpToolUseCase, McpToolsNotAllowedError } from '@/lib/mcp/application/execute-tool' import { McpOauthRedirectRequired } from '@/lib/mcp/oauth' import { McpOauthAuthorizationRequiredError } from '@/lib/mcp/types' -import { categorizeError, parseMcpToolId } from '@/lib/mcp/utils' +import { categorizeError, parseMcpToolTarget } from '@/lib/mcp/utils' import { ResolvedSecretTraceProvenanceAccumulator, type ResolvedSecretTraceRegistry, @@ -89,16 +92,17 @@ async function createResponse( export const executeMcpTool: InternalToolOperationHandler = async (request) => { request.signal?.throwIfAborted() - let serverId: string - let toolName: string + let target: ReturnType try { - ;({ serverId, toolName } = parseMcpToolId(request.toolId)) + target = parseMcpToolTarget(request.toolId) } catch (error) { return Response.json( { success: false, error: getErrorMessage(error, 'Invalid MCP tool ID') }, { status: 400 } ) } + const toolName = target.toolName + const targetId = target.kind === 'shared_server' ? target.serverId : target.credentialId if (!request.context.workspaceId) { return Response.json( @@ -126,7 +130,13 @@ export const executeMcpTool: InternalToolOperationHandler = async (request) => { try { const principal = await createExecutorPrincipalFromExecutionContext({ context: request.context, - audience: MCP_SERVER_DELEGATION_AUDIENCE, + audience: + target.kind === 'shared_server' + ? MCP_SERVER_DELEGATION_AUDIENCE + : MANAGED_MCP_DELEGATION_AUDIENCE, + ...(target.kind === 'managed_connection' + ? { resourceScope: { credentialId: target.credentialId } } + : { resourceScope: { mcpServerId: target.serverId } }), }) request.signal?.throwIfAborted() const subject = resolvePrincipalSubject(principal) @@ -144,21 +154,35 @@ export const executeMcpTool: InternalToolOperationHandler = async (request) => { policyTimeoutMs, getRemainingExecutionMs(request.signal) ) - const result = await executeMcpToolUseCase.execute({ - principal, - input: { - workspaceId: request.context.workspaceId, - serverId, - toolName, - arguments: args, - callChain: request.context.callChain, - timeoutMs, - signal: request.signal, - onResolvedSecretTraceProvenance: provenance - ? (value) => provenance?.record(value) - : undefined, - }, - }) + const result = + target.kind === 'shared_server' + ? await executeMcpToolUseCase.execute({ + principal, + input: { + workspaceId: request.context.workspaceId, + serverId: target.serverId, + toolName, + arguments: args, + callChain: request.context.callChain, + timeoutMs, + signal: request.signal, + onResolvedSecretTraceProvenance: provenance + ? (value) => provenance?.record(value) + : undefined, + }, + }) + : await executeManagedMcpToolUseCase.execute({ + principal, + input: { + workspaceId: request.context.workspaceId, + credentialId: target.credentialId, + toolName, + arguments: args, + callChain: request.context.callChain, + timeoutMs, + signal: request.signal, + }, + }) request.signal?.throwIfAborted() const body = result.success ? { success: true, data: { success: true, output: result.output } } @@ -188,13 +212,27 @@ export const executeMcpTool: InternalToolOperationHandler = async (request) => { request.toolId ) } + if (error instanceof ManagedMcpCredentialError && error.statusCode === 401) { + return createResponse( + { + success: false, + error: 'OAuth re-authorization required', + code: 'reauth_required', + serverId: targetId, + }, + 401, + provenance, + request.context.resolvedSecretTraceRegistry, + request.toolId + ) + } if ( error instanceof McpOauthAuthorizationRequiredError || error instanceof McpOauthRedirectRequired || error instanceof UnauthorizedError ) { const oauthServerId = - error instanceof McpOauthAuthorizationRequiredError ? error.serverId : serverId + error instanceof McpOauthAuthorizationRequiredError ? error.serverId : targetId return createResponse( { success: false, @@ -209,6 +247,19 @@ export const executeMcpTool: InternalToolOperationHandler = async (request) => { ) } + if (error instanceof ManagedMcpCredentialError) { + return createResponse( + { + success: false, + error: error.statusCode === 404 ? 'Resource not found' : 'Managed MCP connection failed', + }, + error.statusCode, + provenance, + request.context.resolvedSecretTraceRegistry, + request.toolId + ) + } + const orchestrationError = asOrchestrationError(error) if (orchestrationError) { const message = @@ -230,7 +281,7 @@ export const executeMcpTool: InternalToolOperationHandler = async (request) => { logger.error('MCP tool execution failed', { error: getErrorMessage(error), requestId: request.requestId, - serverId, + serverId: targetId, toolName, }) return createResponse( diff --git a/apps/sim/lib/mcp/application/authorization.ts b/apps/sim/lib/mcp/application/authorization.ts index 38a67e73f09..fba4915e18a 100644 --- a/apps/sim/lib/mcp/application/authorization.ts +++ b/apps/sim/lib/mcp/application/authorization.ts @@ -1,6 +1,7 @@ import { type Principal, resolvePrincipalExecutionActorUserId } from '@sim/auth/principal' import type { WorkspaceDelegationPolicy } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { McpServerContext } from '@/lib/mcp/application/context' export const MCP_SERVER_DELEGATION_AUDIENCE = 'sim:mcp-servers' @@ -13,6 +14,14 @@ export const mcpServerDelegationPolicy = { allowPersonalApiKeys: boolean }> +export const mcpServerExecutionDelegationPolicy = { + audience: MCP_SERVER_DELEGATION_AUDIENCE, + isWithinScope: ( + principal: Extract, + context: McpServerContext + ) => principal.resourceScope?.mcpServerId === context.server.id, +} satisfies WorkspaceDelegationPolicy + /** * The user whose MCP server credentials an operation presents. * diff --git a/apps/sim/lib/mcp/application/execute-managed-tool.test.ts b/apps/sim/lib/mcp/application/execute-managed-tool.test.ts new file mode 100644 index 00000000000..7d1f44a1ae6 --- /dev/null +++ b/apps/sim/lib/mcp/application/execute-managed-tool.test.ts @@ -0,0 +1,216 @@ +/** + * @vitest-environment node + */ +import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + discoverTools: vi.fn(), + executeTool: vi.fn(), + loadAuthProvider: vi.fn(), + loadContext: vi.fn(), + loadRuntime: vi.fn(), + requireCredentialAccess: vi.fn(), + resolvePermission: vi.fn(), + saveToolSnapshot: vi.fn(), +})) + +vi.mock('@/lib/credentials/managed-mcp', () => ({ + loadManagedMcpCredentialApplicationContext: mocks.loadContext, + loadManagedMcpRuntimeCredential: mocks.loadRuntime, + saveManagedMcpToolSnapshot: mocks.saveToolSnapshot, +})) + +vi.mock('@/lib/credential-groups/application/authorization', () => ({ + requireCredentialGroupCredentialAccess: mocks.requireCredentialAccess, +})) + +vi.mock('@/lib/mcp/service', () => ({ + mcpService: { + discoverManagedMcpTools: mocks.discoverTools, + executeManagedMcpTool: mocks.executeTool, + }, +})) + +vi.mock('@/lib/mcp/oauth', () => ({ + withMcpOauthRefreshLock: vi.fn((_credentialId: string, operation: () => Promise) => + operation() + ), +})) + +vi.mock('@/lib/mcp/application/managed-auth-provider', () => ({ + loadManagedMcpAuthProvider: mocks.loadAuthProvider, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +import { executeManagedMcpToolUseCase } from '@/lib/mcp/application/execute-managed-tool' + +const context = { + credentialId: 'mcp-cg-123456789012345678901', + credentialGroupId: 'group-1', + credentialGroupEnrollmentId: 'enrollment-1', + mcpServerId: 'mcp-server-1', + mcpServerName: 'Fireflies', + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, +} + +const principal: WorkflowExecutionDelegatedPrincipal = { + kind: 'delegated', + serviceId: 'executor', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:managed-mcp-credentials', + issuedAt: new Date(Date.now() - 1_000), + expiresAt: new Date(Date.now() + 60_000), + resourceScope: { credentialId: context.credentialId }, + delegationContext: { + kind: 'workflow_execution', + workflowId: 'workflow-1', + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment', + deploymentVersionId: 'version-1', + }, + }, +} + +describe('executeManagedMcpToolUseCase', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadContext.mockResolvedValue(context) + mocks.loadRuntime.mockResolvedValue({ + credentialId: context.credentialId, + mcpServerId: context.mcpServerId, + mcpServerName: context.mcpServerName, + workspaceId: context.workspaceId, + tokenVersion: 'encrypted-token-version-1', + tokens: { access_token: 'access-token' }, + tools: [], + }) + mocks.requireCredentialAccess.mockResolvedValue(undefined) + mocks.resolvePermission.mockResolvedValue('read') + mocks.loadAuthProvider.mockResolvedValue({}) + mocks.discoverTools.mockResolvedValue([]) + mocks.executeTool.mockResolvedValue({ content: [{ type: 'text', text: 'done' }] }) + }) + + it('does not load token material when Credential Group policy denies execution', async () => { + mocks.requireCredentialAccess.mockRejectedValueOnce({ + code: 'forbidden', + message: 'Credential Group credential access denied', + }) + + await expect( + executeManagedMcpToolUseCase.execute({ + principal, + input: { + workspaceId: 'workspace-1', + credentialId: context.credentialId, + toolName: 'search_transcripts', + arguments: {}, + }, + }) + ).rejects.toMatchObject({ + code: 'forbidden', + message: 'Credential Group credential access denied', + }) + + expect(mocks.requireCredentialAccess).toHaveBeenCalledWith(principal, context, { + resourceType: 'credential_group', + action: 'credential_groups.credentials.use', + }) + expect(mocks.loadRuntime).not.toHaveBeenCalled() + expect(mocks.executeTool).not.toHaveBeenCalled() + }) + + it('fails fast when the live tool schema is invalid', async () => { + mocks.discoverTools.mockResolvedValueOnce([{ name: 'search_transcripts', inputSchema: null }]) + + await expect( + executeManagedMcpToolUseCase.execute({ + principal, + input: { + workspaceId: 'workspace-1', + credentialId: context.credentialId, + toolName: 'search_transcripts', + arguments: {}, + }, + }) + ).rejects.toMatchObject({ + code: 'validation', + message: 'Managed MCP tool schema is invalid', + }) + + expect(mocks.executeTool).not.toHaveBeenCalled() + }) + + it('discovers and executes with the explicitly selected managed connection', async () => { + const signal = new AbortController().signal + mocks.discoverTools.mockResolvedValueOnce([ + { + name: 'search_transcripts', + description: 'Search Fireflies transcripts', + inputSchema: { + type: 'object', + required: ['query'], + properties: { query: { type: 'string' } }, + }, + }, + ]) + + const result = await executeManagedMcpToolUseCase.execute({ + principal, + input: { + workspaceId: context.workspaceId, + credentialId: context.credentialId, + toolName: 'search_transcripts', + arguments: { query: 'onboarding' }, + signal, + }, + }) + + expect(result).toEqual({ + success: true, + output: { content: [{ type: 'text', text: 'done' }] }, + }) + expect(mocks.loadRuntime).toHaveBeenCalledWith(context.credentialId, context.workspaceId) + expect(mocks.discoverTools).toHaveBeenCalledWith( + context.mcpServerId, + context.workspaceId, + {}, + signal, + { requireComplete: true } + ) + expect(mocks.saveToolSnapshot).toHaveBeenCalledWith(context.credentialId, [ + { + name: 'search_transcripts', + description: 'Search Fireflies transcripts', + inputSchema: { + type: 'object', + required: ['query'], + properties: { query: { type: 'string' } }, + }, + }, + ]) + expect(mocks.executeTool).toHaveBeenCalledWith( + expect.objectContaining({ + connectionId: context.credentialId, + serverId: context.mcpServerId, + workspaceId: context.workspaceId, + toolCall: { + name: 'search_transcripts', + arguments: { query: 'onboarding' }, + }, + }) + ) + }) +}) diff --git a/apps/sim/lib/mcp/application/execute-managed-tool.ts b/apps/sim/lib/mcp/application/execute-managed-tool.ts new file mode 100644 index 00000000000..8e2c43a61de --- /dev/null +++ b/apps/sim/lib/mcp/application/execute-managed-tool.ts @@ -0,0 +1,117 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { requireCredentialGroupCredentialAccess } from '@/lib/credential-groups/application/authorization' +import { managedMcpCredentialDelegationPolicy } from '@/lib/credentials/application/authorization' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { + loadManagedMcpCredentialApplicationContext, + loadManagedMcpRuntimeCredential, + saveManagedMcpToolSnapshot, +} from '@/lib/credentials/managed-mcp' +import { SIM_VIA_HEADER, serializeCallChain } from '@/lib/execution/call-chain' +import { + coerceToolArguments, + type ExecuteMcpToolResult, + transformToolResult, + validateToolArguments, +} from '@/lib/mcp/application/execute-tool' +import { loadManagedMcpAuthProvider } from '@/lib/mcp/application/managed-auth-provider' +import { withMcpOauthRefreshLock } from '@/lib/mcp/oauth' +import { mcpService } from '@/lib/mcp/service' +import type { McpTool, McpToolCall, McpToolSchema } from '@/lib/mcp/types' + +export interface ExecuteManagedMcpToolInput { + workspaceId: string + credentialId: string + toolName: string + arguments?: Record + callChain?: string[] + timeoutMs?: number + signal?: AbortSignal +} + +function requireToolSchema(value: unknown): McpToolSchema { + if (!value || typeof value !== 'object' || !('type' in value) || value.type !== 'object') { + throw new OrchestrationError('validation', 'Managed MCP tool schema is invalid') + } + return value as McpToolSchema +} + +export const executeManagedMcpToolUseCase = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.useManagedMcp, + resolveContext: async ({ input }: { input: ExecuteManagedMcpToolInput }) => { + const context = await loadManagedMcpCredentialApplicationContext(input.credentialId) + if (!context) throw new OrchestrationError('not_found', 'Managed MCP connection not found') + if (context.workspaceId !== input.workspaceId) { + throw new OrchestrationError('not_found', 'Managed MCP connection not found') + } + return context + }, + authorizationOptions: { delegation: managedMcpCredentialDelegationPolicy }, + async authorizeResource({ principal, context, resourcePolicy }) { + await requireCredentialGroupCredentialAccess(principal, context, resourcePolicy) + }, + async execute({ input, context }): Promise { + input.signal?.throwIfAborted() + const runtime = await loadManagedMcpRuntimeCredential(context.credentialId, context.workspaceId) + const tools = await withMcpOauthRefreshLock(runtime.credentialId, async () => + mcpService.discoverManagedMcpTools( + runtime.mcpServerId, + runtime.workspaceId, + await loadManagedMcpAuthProvider(runtime.credentialId, runtime.workspaceId), + input.signal, + { requireComplete: true } + ) + ) + await saveManagedMcpToolSnapshot( + runtime.credentialId, + tools.map((tool) => ({ + name: tool.name, + ...(tool.description ? { description: tool.description } : {}), + inputSchema: tool.inputSchema, + })) + ) + const discovered = tools.find((tool) => tool.name === input.toolName) + if (!discovered) { + throw new OrchestrationError('not_found', 'Tool not found on the managed MCP connection') + } + const tool: McpTool = { + name: discovered.name, + ...(discovered.description ? { description: discovered.description } : {}), + inputSchema: requireToolSchema(discovered.inputSchema), + serverId: runtime.credentialId, + serverName: runtime.mcpServerName, + } + const args = coerceToolArguments(tool, { ...input.arguments }) + validateToolArguments(tool, args) + const toolCall: McpToolCall = { name: input.toolName, arguments: args } + const extraHeaders = + input.callChain && input.callChain.length > 0 + ? { [SIM_VIA_HEADER]: serializeCallChain(input.callChain) } + : undefined + const providerResult = await mcpService.executeManagedMcpTool({ + connectionId: runtime.credentialId, + serverId: runtime.mcpServerId, + workspaceId: runtime.workspaceId, + toolCall, + extraHeaders, + signal: input.signal, + timeoutMs: input.timeoutMs, + loadAuthProvider: () => loadManagedMcpAuthProvider(context.credentialId, context.workspaceId), + }) + input.signal?.throwIfAborted() + return transformToolResult(providerResult) + }, + projectAudit: ({ input, context }) => ({ + action: AuditAction.CREDENTIAL_ACCESSED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: context.credentialId, + description: `Executed managed MCP tool ${input.toolName}`, + metadata: { + credentialType: 'managed_mcp', + mcpServerId: context.mcpServerId, + toolName: input.toolName, + }, + }), +}) diff --git a/apps/sim/lib/mcp/application/execute-tool.test.ts b/apps/sim/lib/mcp/application/execute-tool.test.ts index 8406c18a812..fefb84eb118 100644 --- a/apps/sim/lib/mcp/application/execute-tool.test.ts +++ b/apps/sim/lib/mcp/application/execute-tool.test.ts @@ -60,6 +60,7 @@ const PRINCIPAL: WorkflowExecutionDelegatedPrincipal = { issuedAt: new Date('2026-08-27T00:00:00.000Z'), expiresAt: new Date('2099-08-27T00:05:00.000Z'), delegationContext: { kind: 'workflow_execution', workflowId: 'workflow-1' }, + resourceScope: { mcpServerId: SERVER.id }, } const ACTORLESS_PRINCIPAL: WorkflowExecutionDelegatedPrincipal = { kind: 'delegated', @@ -84,6 +85,7 @@ const ACTORLESS_PRINCIPAL: WorkflowExecutionDelegatedPrincipal = { workflowId: 'workflow-1', }, }, + resourceScope: { mcpServerId: SERVER.id }, } const COMPATIBILITY_ACTOR_PRINCIPAL: WorkflowExecutionDelegatedPrincipal = { ...ACTORLESS_PRINCIPAL, @@ -178,6 +180,28 @@ describe('executeMcpToolUseCase', () => { expect(mocks.executeTool).not.toHaveBeenCalled() }) + it('does not infer a managed credential from the execution actor', async () => { + mocks.getServer.mockResolvedValueOnce({ ...SERVER, credentialGroupId: 'group-1' }) + + await expect( + executeMcpToolUseCase.execute({ + principal: PRINCIPAL, + input: { + workspaceId: WORKSPACE.workspaceId, + serverId: SERVER.id, + toolName: 'lookup', + }, + }) + ).rejects.toMatchObject({ + code: 'conflict', + message: 'Credential Group MCP servers require an explicit managed connection ID', + }) + + expect(mocks.assertPermissionsAllowed).not.toHaveBeenCalled() + expect(mocks.discoverServerTools).not.toHaveBeenCalled() + expect(mocks.executeTool).not.toHaveBeenCalled() + }) + it('keeps an unattended run connecting as its execution actor', async () => { // Pre-in-process behavior: the executor minted an internal token from // ExecutionContext.userId and MCP ran as that user. Preserved deliberately — diff --git a/apps/sim/lib/mcp/application/execute-tool.ts b/apps/sim/lib/mcp/application/execute-tool.ts index 60dba14a253..8f7388619be 100644 --- a/apps/sim/lib/mcp/application/execute-tool.ts +++ b/apps/sim/lib/mcp/application/execute-tool.ts @@ -4,7 +4,7 @@ import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { SIM_VIA_HEADER, serializeCallChain } from '@/lib/execution/call-chain' import { - mcpServerDelegationPolicy, + mcpServerExecutionDelegationPolicy, requireMcpCredentialUserId, } from '@/lib/mcp/application/authorization' import { resolveMcpServerContext } from '@/lib/mcp/application/context' @@ -42,7 +42,7 @@ function hasType(value: unknown): value is SchemaProperty { return typeof value === 'object' && value !== null && 'type' in value } -function coerceToolArguments( +export function coerceToolArguments( tool: McpTool, input: Record ): Record { @@ -88,7 +88,7 @@ function coerceToolArguments( return result } -function validateToolArguments(tool: McpTool, args: Record): void { +export function validateToolArguments(tool: McpTool, args: Record): void { const schema = tool.inputSchema if (!schema) return @@ -115,7 +115,7 @@ function validateToolArguments(tool: McpTool, args: Record): vo } } -function transformToolResult(result: McpToolResult): ExecuteMcpToolResult { +export function transformToolResult(result: McpToolResult): ExecuteMcpToolResult { if (!result.isError) return { success: true, output: result } const firstContent = Array.isArray(result.content) ? result.content[0] : undefined const errorText = @@ -131,9 +131,15 @@ export const executeMcpToolUseCase = defineAuthorizedWorkspaceUseCase({ operation: mcpServerOperations.executeTool, resolveContext: ({ input }: { input: ExecuteMcpToolInput }) => resolveMcpServerContext(input.workspaceId, input.serverId), - authorizationOptions: { delegation: mcpServerDelegationPolicy }, + authorizationOptions: { delegation: mcpServerExecutionDelegationPolicy }, async execute({ principal, input, context }): Promise { input.signal?.throwIfAborted() + if (context.server.credentialGroupId) { + throw new OrchestrationError( + 'conflict', + 'Credential Group MCP servers require an explicit managed connection ID' + ) + } const userId = requireMcpCredentialUserId(principal) await assertPermissionsAllowed({ userId, diff --git a/apps/sim/lib/mcp/application/managed-auth-provider.ts b/apps/sim/lib/mcp/application/managed-auth-provider.ts new file mode 100644 index 00000000000..039062babfe --- /dev/null +++ b/apps/sim/lib/mcp/application/managed-auth-provider.ts @@ -0,0 +1,30 @@ +import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js' +import { + loadManagedMcpRuntimeCredential, + saveManagedMcpRuntimeTokens, +} from '@/lib/credentials/managed-mcp' +import { getOrCreateOauthRow, loadPreregisteredClient } from '@/lib/mcp/oauth' +import { ManagedMcpOauthProvider } from '@/lib/mcp/oauth/managed-provider' + +/** Creates an OAuth provider whose refresh writes stay bound to the same personal grant. */ +export async function loadManagedMcpAuthProvider( + credentialId: string, + workspaceId: string +): Promise { + const current = await loadManagedMcpRuntimeCredential(credentialId, workspaceId) + const clientRow = await getOrCreateOauthRow({ + mcpServerId: current.mcpServerId, + workspaceId: current.workspaceId, + }) + const preregistered = await loadPreregisteredClient(current.mcpServerId) + let tokenVersion: string | null = current.tokenVersion + return new ManagedMcpOauthProvider({ + clientRow, + preregistered, + tokens: current.tokens, + async onSaveTokens(tokens) { + if (!tokenVersion) throw new Error('Managed MCP credential grant is no longer active') + tokenVersion = await saveManagedMcpRuntimeTokens(current.credentialId, tokens, tokenVersion) + }, + }) +} diff --git a/apps/sim/lib/mcp/application/managed-connections.ts b/apps/sim/lib/mcp/application/managed-connections.ts new file mode 100644 index 00000000000..226d765fe15 --- /dev/null +++ b/apps/sim/lib/mcp/application/managed-connections.ts @@ -0,0 +1,156 @@ +import { db } from '@sim/db' +import { credential, credentialGroup, credentialGroupEnrollment, mcpServers } from '@sim/db/schema' +import { and, asc, eq, inArray, isNull, sql } from 'drizzle-orm' +import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability' +import { getManagedMcpConnector } from '@/lib/credential-groups/managed-mcp-connectors' +import { resolveMcpWorkspaceContext } from '@/lib/mcp/application/context' +import { mcpServerOperations } from '@/lib/mcp/application/operations' +import type { McpToolSchema } from '@/lib/mcp/types' + +const MAX_MANAGED_MCP_CONNECTIONS = 500 +const MAX_MANAGED_MCP_CATALOG_BYTES = 5 * 1024 * 1024 + +function requireMcpToolSchema(inputSchema: unknown): McpToolSchema { + if ( + !inputSchema || + typeof inputSchema !== 'object' || + !('type' in inputSchema) || + inputSchema.type !== 'object' + ) { + throw new Error('Managed MCP tool snapshot must have an object input schema') + } + return inputSchema as McpToolSchema +} + +export const listManagedMcpConnectionsUseCase = defineAuthorizedWorkspaceUseCase({ + operation: mcpServerOperations.listManagedConnections, + resolveContext: ({ input }: { input: { workspaceId: string } }) => + resolveMcpWorkspaceContext(input.workspaceId), + authorizationOptions: {}, + async execute({ context }) { + const ownerBilling = await getWorkspaceOwnerSubscriptionAccess(context.workspaceId) + if (!(await isCredentialGroupsAvailable({ workspaceId: context.workspaceId, ownerBilling }))) { + return { servers: [], tools: [] } + } + const managedCatalogScope = () => + and( + eq(credential.workspaceId, context.workspaceId), + eq(credential.type, 'managed_mcp'), + eq(credential.managedOauthStatus, 'active'), + eq(credentialGroup.status, 'active'), + inArray(credentialGroupEnrollment.status, ['in_progress', 'completed']), + eq(mcpServers.workspaceId, context.workspaceId), + eq(mcpServers.authType, 'oauth'), + eq(mcpServers.enabled, true), + isNull(mcpServers.deletedAt), + sql`${mcpServers.credentialGroupId} = ${credentialGroup.id}` + ) + const metadataRows = await db + .select({ + id: credential.id, + serverId: mcpServers.id, + serverName: mcpServers.name, + serverDescription: mcpServers.description, + managedConnectorId: mcpServers.managedConnectorId, + email: credentialGroupEnrollment.email, + toolSnapshotBytes: + sql`COALESCE(octet_length(${credential.mcpTools}::text), 0)`.mapWith(Number), + createdAt: credential.createdAt, + updatedAt: credential.updatedAt, + }) + .from(credential) + .innerJoin( + credentialGroupEnrollment, + eq(credentialGroupEnrollment.id, credential.credentialGroupEnrollmentId) + ) + .innerJoin( + credentialGroup, + eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId) + ) + .innerJoin(mcpServers, eq(mcpServers.id, credential.mcpServerId)) + .where(managedCatalogScope()) + .orderBy(asc(mcpServers.name), asc(credentialGroupEnrollment.email), asc(credential.id)) + .limit(MAX_MANAGED_MCP_CONNECTIONS + 1) + + if (metadataRows.length > MAX_MANAGED_MCP_CONNECTIONS) { + throw new Error( + `Managed MCP catalog exceeds the ${MAX_MANAGED_MCP_CONNECTIONS}-connection limit` + ) + } + const catalogBytes = metadataRows.reduce((total, row) => total + row.toolSnapshotBytes, 0) + if (catalogBytes > MAX_MANAGED_MCP_CATALOG_BYTES) { + throw new Error( + `Managed MCP catalog exceeds the ${MAX_MANAGED_MCP_CATALOG_BYTES}-byte metadata limit` + ) + } + + const toolRows = + metadataRows.length === 0 + ? [] + : await db + .select({ id: credential.id, tools: credential.mcpTools }) + .from(credential) + .innerJoin( + credentialGroupEnrollment, + eq(credentialGroupEnrollment.id, credential.credentialGroupEnrollmentId) + ) + .innerJoin( + credentialGroup, + eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId) + ) + .innerJoin(mcpServers, eq(mcpServers.id, credential.mcpServerId)) + .where( + and( + managedCatalogScope(), + inArray( + credential.id, + metadataRows.map((row) => row.id) + ) + ) + ) + const toolsByConnectionId = new Map(toolRows.map((row) => [row.id, row.tools])) + const rows = metadataRows.map((row) => { + const tools = toolsByConnectionId.get(row.id) + if (!tools) { + throw new Error(`Managed MCP connection ${row.id} changed while loading its tool snapshot`) + } + if (!row.managedConnectorId) { + throw new Error(`Managed MCP server ${row.serverId} has no connector ID`) + } + return { + ...row, + managedConnectorId: getManagedMcpConnector(row.managedConnectorId).id, + tools, + } + }) + + return { + servers: rows.map((row) => ({ + id: row.id, + workspaceId: context.workspaceId, + name: `${row.serverName} — ${row.email}`, + ...(row.serverDescription ? { description: row.serverDescription } : {}), + transport: 'streamable-http' as const, + authType: 'oauth' as const, + managedConnectorId: row.managedConnectorId, + enabled: true, + connectionStatus: 'connected' as const, + toolCount: row.tools.length, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + })), + tools: rows.flatMap((row) => { + return row.tools.map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: requireMcpToolSchema(tool.inputSchema), + serverId: row.id, + serverName: `${row.serverName} — ${row.email}`, + managedConnectorId: row.managedConnectorId, + })) + }), + } + }, +}) diff --git a/apps/sim/lib/mcp/application/operations.test.ts b/apps/sim/lib/mcp/application/operations.test.ts index f145601905f..4238e5c4b83 100644 --- a/apps/sim/lib/mcp/application/operations.test.ts +++ b/apps/sim/lib/mcp/application/operations.test.ts @@ -150,6 +150,7 @@ const EXPECTED_CAPABILITIES: Record = delete: 'mcp_tools.use', discoverTools: 'mcp_tools.use', executeTool: 'mcp_tools.use', + listManagedConnections: 'mcp_tools.use', listWorkflowDeployments: 'deploy.mcp', readWorkflowDeploymentServer: 'deploy.mcp', listWorkflowDeploymentTools: 'deploy.mcp', diff --git a/apps/sim/lib/mcp/application/operations.ts b/apps/sim/lib/mcp/application/operations.ts index e5cba22f155..e9346e82078 100644 --- a/apps/sim/lib/mcp/application/operations.ts +++ b/apps/sim/lib/mcp/application/operations.ts @@ -39,6 +39,13 @@ export const mcpServerOperations = { capability: 'mcp_tools.use', ...ALL_PRINCIPAL_POLICY, }), + listManagedConnections: defineWorkspaceOperation({ + id: 'mcp_servers.managed_connections.list', + minimumRole: 'read', + workspaceApiKey: 'deny', + capability: 'mcp_tools.use', + principalKinds: ['session'], + }), discoverTools: defineWorkspaceOperation({ id: 'mcp_servers.tools.discover', minimumRole: 'read', diff --git a/apps/sim/lib/mcp/application/use-cases.test.ts b/apps/sim/lib/mcp/application/use-cases.test.ts index d75f79e7ada..b96b83c51ae 100644 --- a/apps/sim/lib/mcp/application/use-cases.test.ts +++ b/apps/sim/lib/mcp/application/use-cases.test.ts @@ -291,6 +291,22 @@ describe('MCP server application use cases', () => { expect(mocks.discoverServerTools).not.toHaveBeenCalled() }) + it('requires an explicit managed connection ID for a Credential Group server', async () => { + mocks.getServer.mockResolvedValueOnce({ ...server, credentialGroupId: 'group-1' }) + + await expect( + discoverMcpServerToolsUseCase.execute({ + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + input: { workspaceId: workspace.workspaceId, serverId: server.id }, + }) + ).rejects.toMatchObject({ + code: 'conflict', + message: 'Credential Group MCP servers require an explicit managed connection ID', + }) + + expect(mocks.discoverServerTools).not.toHaveBeenCalled() + }) + it('discovers one server tools for the acting subject, honouring refresh', async () => { const tools = [ { diff --git a/apps/sim/lib/mcp/application/use-cases.ts b/apps/sim/lib/mcp/application/use-cases.ts index 60bdb4840a7..5eec48638a5 100644 --- a/apps/sim/lib/mcp/application/use-cases.ts +++ b/apps/sim/lib/mcp/application/use-cases.ts @@ -118,6 +118,8 @@ export interface DiscoverMcpServerToolsInput { workspaceId: string serverId: string refresh?: boolean + signal?: AbortSignal + requireComplete?: boolean } /** @@ -139,6 +141,7 @@ export const discoverMcpServerToolsUseCase = defineAuthorizedWorkspaceUseCase({ resolveMcpServerContext(input.workspaceId, input.serverId), authorizationOptions, async execute({ principal, input, context }) { + input.signal?.throwIfAborted() /** * `enabled: false` is a documented registration value, but discovery loads * its configuration through a query that filters on `enabled`, so a @@ -152,18 +155,31 @@ export const discoverMcpServerToolsUseCase = defineAuthorizedWorkspaceUseCase({ 'The MCP server is disabled; enable it before listing its tools' ) } + if (context.server.credentialGroupId) { + throw new OrchestrationError( + 'conflict', + 'Credential Group MCP servers require an explicit managed connection ID' + ) + } - const tools = await mcpService.discoverServerTools( - requireMcpCredentialUserId(principal), - context.server.id, - context.workspaceId, - /** - * A public `refresh` skips the positive cache but keeps the failure - * cooldown; only an explicit user action on their own server may bypass - * both. See {@link McpDiscoveryRefresh}. - */ - input.refresh ? 'skip-cache' : 'cache-aside' - ) + const userId = requireMcpCredentialUserId(principal) + const refresh = input.refresh ? 'skip-cache' : 'cache-aside' + const tools = + input.signal || input.requireComplete + ? await mcpService.discoverServerTools( + userId, + context.server.id, + context.workspaceId, + refresh, + undefined, + { signal: input.signal, requireComplete: input.requireComplete } + ) + : await mcpService.discoverServerTools( + userId, + context.server.id, + context.workspaceId, + refresh + ) return { tools } }, }) @@ -337,6 +353,12 @@ async function updateMcpServer(args: { input: UpdateMcpServerInput context: McpServerContext }): Promise { + if (args.context.server.managedConnectorId) { + throw new OrchestrationError( + 'conflict', + 'This MCP server is managed from its Credential Group settings' + ) + } const attribution = resolvePrincipalAttribution(args.principal, { workspaceBillingOwnerUserId: args.context.billedAccountUserId, }) @@ -423,6 +445,12 @@ export const deleteMcpServerUseCase = defineAuthorizedWorkspaceUseCase({ resolveMcpServerContext(input.workspaceId, input.serverId), authorizationOptions, async execute({ principal, input, context }) { + if (context.server.managedConnectorId) { + throw new OrchestrationError( + 'conflict', + 'This MCP server is managed from its Credential Group settings' + ) + } const attribution = resolvePrincipalAttribution(principal, { workspaceBillingOwnerUserId: context.billedAccountUserId, }) diff --git a/apps/sim/lib/mcp/client.test.ts b/apps/sim/lib/mcp/client.test.ts index 8a87eac5a5f..27d3dbdca4b 100644 --- a/apps/sim/lib/mcp/client.test.ts +++ b/apps/sim/lib/mcp/client.test.ts @@ -248,6 +248,21 @@ describe('McpClient notification handler', () => { expect(tools.map((t) => t.name)).toEqual(['a']) }) + it('fails instead of returning partial tools when complete discovery is required', async () => { + mockSdkListTools + .mockResolvedValueOnce({ tools: [{ name: 'a' }], nextCursor: 'c1' }) + .mockRejectedValueOnce(new Error('page 2 blew up')) + const client = new McpClient({ + config: createConfig(), + securityPolicy: { requireConsent: false, auditLevel: 'basic' }, + }) + + await client.connect() + await expect(client.listTools(undefined, { requireComplete: true })).rejects.toThrow( + 'page 2 blew up' + ) + }) + it('keeps an empty partial (does not throw) when page one succeeds but a later page fails', async () => { // Page one is valid but empty with a cursor; page two fails. Page one succeeded, so // discovery must not fail the server — it returns [] rather than throwing. diff --git a/apps/sim/lib/mcp/client.ts b/apps/sim/lib/mcp/client.ts index c9b2607feac..6f0b2cd4828 100644 --- a/apps/sim/lib/mcp/client.ts +++ b/apps/sim/lib/mcp/client.ts @@ -275,7 +275,10 @@ export class McpClient { return { ...this.connectionStatus } } - async listTools(signal?: AbortSignal): Promise { + async listTools( + signal?: AbortSignal, + options: { requireComplete?: boolean } = {} + ): Promise { if (!this.isConnected) { throw new McpConnectionError('Not connected to server', this.config.name) } @@ -381,6 +384,12 @@ export class McpClient { toolsCollected: tools.length, pagesFetched, }) + if (options.requireComplete) { + throw new McpConnectionError( + `Tool discovery was truncated by the ${truncated} limit`, + this.config.name + ) + } } return tools @@ -397,6 +406,8 @@ export class McpClient { sessionIdPresent: Boolean(this.transport.sessionId), error: getMcpSafeErrorDiagnostics(error), }) + if (options.requireComplete) throw error + // At least one page succeeded → keep its (possibly empty) partial result rather than // failing discovery and marking the server unhealthy; only a page-one failure throws. if (pagesFetched > 0) return tools diff --git a/apps/sim/lib/mcp/connection-pool.ts b/apps/sim/lib/mcp/connection-pool.ts index 8eda72cbd3e..1a12fc5b27e 100644 --- a/apps/sim/lib/mcp/connection-pool.ts +++ b/apps/sim/lib/mcp/connection-pool.ts @@ -320,3 +320,8 @@ if (!('_mcpConnectionPool' in _g)) { } export const mcpConnectionPool: McpConnectionPool | null = _g._mcpConnectionPool ?? null + +/** Evicts every warm connection for a server without importing the full MCP service. */ +export async function evictMcpServerConnections(serverId: string, reason: string): Promise { + await mcpConnectionPool?.evictServer(serverId, reason) +} diff --git a/apps/sim/lib/mcp/oauth/managed-provider.ts b/apps/sim/lib/mcp/oauth/managed-provider.ts new file mode 100644 index 00000000000..1ca319716de --- /dev/null +++ b/apps/sim/lib/mcp/oauth/managed-provider.ts @@ -0,0 +1,128 @@ +import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js' +import type { + OAuthClientInformationMixed, + OAuthClientMetadata, + OAuthTokens, +} from '@modelcontextprotocol/sdk/shared/auth.js' +import { generateId } from '@sim/utils/id' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { McpOauthRedirectRequired, type PreregisteredClient } from '@/lib/mcp/oauth/provider' +import { clearClient, type McpOauthRow, saveClientInformation } from '@/lib/mcp/oauth/storage' + +interface ManagedMcpOauthProviderInit { + clientRow: McpOauthRow + preregistered?: PreregisteredClient + tokens?: OAuthTokens + codeVerifier?: string + onSaveTokens: (tokens: OAuthTokens | null) => Promise +} + +/** Shares server client registration while keeping grant tokens scoped to one enrollment. */ +export class ManagedMcpOauthProvider implements OAuthClientProvider { + private readonly clientRow: McpOauthRow + private readonly preregistered?: PreregisteredClient + private readonly onSaveTokens: (tokens: OAuthTokens | null) => Promise + private currentTokens?: OAuthTokens + private currentState?: string + private currentCodeVerifier?: string + + constructor({ + clientRow, + preregistered, + tokens, + codeVerifier, + onSaveTokens, + }: ManagedMcpOauthProviderInit) { + this.clientRow = clientRow + this.preregistered = preregistered + this.currentTokens = tokens + this.currentCodeVerifier = codeVerifier + this.onSaveTokens = onSaveTokens + } + + get redirectUrl(): string { + return `${getBaseUrl().replace(/\/$/, '')}/api/mcp/oauth/callback` + } + + get clientMetadata(): OAuthClientMetadata { + return { + client_name: 'Sim', + redirect_uris: [this.redirectUrl], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: this.preregistered?.clientSecret ? 'client_secret_post' : 'none', + } + } + + async state(): Promise { + this.currentState = `mcp_cg_${generateId()}` + return this.currentState + } + + clientInformation(): OAuthClientInformationMixed | undefined { + if (this.clientRow.clientInformation) return this.clientRow.clientInformation + if (!this.preregistered) return undefined + return { + client_id: this.preregistered.clientId, + client_secret: this.preregistered.clientSecret, + redirect_uris: [this.redirectUrl], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: this.preregistered.clientSecret ? 'client_secret_post' : 'none', + } + } + + async saveClientInformation(info: OAuthClientInformationMixed): Promise { + if (this.preregistered) return + await saveClientInformation(this.clientRow.id, info) + this.clientRow.clientInformation = info + } + + tokens(): OAuthTokens | undefined { + return this.currentTokens + } + + async saveTokens(tokens: OAuthTokens): Promise { + await this.onSaveTokens(tokens) + this.currentTokens = tokens + } + + async redirectToAuthorization(authorizationUrl: URL): Promise { + throw new McpOauthRedirectRequired(authorizationUrl.toString()) + } + + async saveCodeVerifier(codeVerifier: string): Promise { + this.currentCodeVerifier = codeVerifier + } + + async codeVerifier(): Promise { + if (!this.currentCodeVerifier) { + throw new Error('No PKCE code verifier saved for this managed MCP OAuth session') + } + return this.currentCodeVerifier + } + + async invalidateCredentials( + scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery' + ): Promise { + if (scope === 'all' || scope === 'client') { + await clearClient(this.clientRow.id) + this.clientRow.clientInformation = null + } + if (scope === 'all' || scope === 'tokens') { + await this.onSaveTokens(null) + this.currentTokens = undefined + } + if (scope === 'all' || scope === 'verifier') { + this.currentState = undefined + this.currentCodeVerifier = undefined + } + } + + requireAuthorizationAttempt(): { state: string; codeVerifier: string } { + if (!this.currentState || !this.currentCodeVerifier) { + throw new Error('Managed MCP OAuth provider did not produce state and PKCE verifier') + } + return { state: this.currentState, codeVerifier: this.currentCodeVerifier } + } +} diff --git a/apps/sim/lib/mcp/oauth/storage.ts b/apps/sim/lib/mcp/oauth/storage.ts index 63a567faaf8..c2e54c2e951 100644 --- a/apps/sim/lib/mcp/oauth/storage.ts +++ b/apps/sim/lib/mcp/oauth/storage.ts @@ -71,7 +71,7 @@ async function safeDecrypt( export async function getOrCreateOauthRow(params: { mcpServerId: string - userId: string + userId?: string | null workspaceId: string }): Promise { const existing = await loadOauthRow(params) @@ -82,7 +82,7 @@ export async function getOrCreateOauthRow(params: { await db.insert(mcpServerOauth).values({ id, mcpServerId: params.mcpServerId, - userId: params.userId, + userId: params.userId ?? null, workspaceId: params.workspaceId, }) } catch (error) { @@ -94,7 +94,7 @@ export async function getOrCreateOauthRow(params: { return { id, mcpServerId: params.mcpServerId, - userId: params.userId, + userId: params.userId ?? null, workspaceId: params.workspaceId, clientInformation: null, tokens: null, diff --git a/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts b/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts index 489d9cf4d05..0e07aa8f652 100644 --- a/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts +++ b/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts @@ -8,6 +8,7 @@ import { dbChainMockFns, encryptionMock, posthogServerMock, + queueTableRows, resetDbChainMock, schemaMock, } from '@sim/testing' @@ -33,6 +34,7 @@ vi.mock('@sim/db', () => ({ mcpServers: schemaMock.mcpServers, })) vi.mock('@sim/db/schema', () => ({ + credential: schemaMock.credential, mcpServerOauth: schemaMock.mcpServerOauth, })) vi.mock('@sim/utils/id', () => ({ generateId: vi.fn() })) @@ -635,9 +637,19 @@ describe('MCP server lifecycle orchestration', () => { }) it('evicts the deleted server from the connection pool (row is already gone from clearCache)', async () => { - dbChainMockFns.returning.mockResolvedValueOnce([ + queueTableRows(schemaMock.mcpServers, [ { id: 'server-1', workspaceId: 'workspace-1', name: 'Example', transport: 'streamable-http' }, ]) + dbChainMockFns.returning + .mockResolvedValueOnce([{ id: 'mcp-cg-connection-1' }]) + .mockResolvedValueOnce([ + { + id: 'server-1', + workspaceId: 'workspace-1', + name: 'Example', + transport: 'streamable-http', + }, + ]) const result = await performDeleteMcpServer({ workspaceId: 'workspace-1', @@ -648,5 +660,9 @@ describe('MCP server lifecycle orchestration', () => { expect(result.success).toBe(true) expect(mockRevokeOauthTokens).toHaveBeenCalledWith('server-1', 'workspace-1') expect(mockEvictServerConnections).toHaveBeenCalledWith('server-1', expect.any(String)) + expect(mockEvictServerConnections).toHaveBeenCalledWith( + 'mcp-cg-connection-1', + 'managed connection retired' + ) }) }) diff --git a/apps/sim/lib/mcp/orchestration/server-lifecycle.ts b/apps/sim/lib/mcp/orchestration/server-lifecycle.ts index f0a7cc64f34..aa893f3fde4 100644 --- a/apps/sim/lib/mcp/orchestration/server-lifecycle.ts +++ b/apps/sim/lib/mcp/orchestration/server-lifecycle.ts @@ -1,6 +1,6 @@ import { AuditAction, AuditResourceType, auditUpdatedFields, recordAudit } from '@sim/audit' import { db, mcpServers } from '@sim/db' -import { mcpServerOauth } from '@sim/db/schema' +import { credential, mcpServerOauth } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, isNull } from 'drizzle-orm' @@ -99,6 +99,7 @@ export interface PerformMcpServerResult { revived?: boolean authType?: McpAuthType configurationChanged?: boolean + retiredManagedConnectionIds?: string[] /** * Fields the update's SET clause wrote, minus `updatedAt`, for audit. Only * the writer knows these: a param is not a write, and callers cannot see the @@ -169,6 +170,7 @@ export async function createMcpServer( authType: mcpServers.authType, oauthClientId: mcpServers.oauthClientId, oauthClientSecret: mcpServers.oauthClientSecret, + managedConnectorId: mcpServers.managedConnectorId, }) .from(mcpServers) .where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, params.workspaceId))) @@ -176,6 +178,14 @@ export async function createMcpServer( const urlChanged = existingServer ? existingServer.url !== params.url : true + if (existingServer?.managedConnectorId) { + return { + success: false, + error: 'This MCP server is managed by a Credential Group', + errorCode: 'conflict', + } + } + if ( existingServer && existingServer.deletedAt === null && @@ -506,16 +516,40 @@ export async function deleteMcpServer( ): Promise { try { await revokeMcpOauthTokens(params.serverId, params.workspaceId) - const [server] = await db - .delete(mcpServers) - .where( - and(eq(mcpServers.id, params.serverId), eq(mcpServers.workspaceId, params.workspaceId)) - ) - .returning() + const deleted = await db.transaction(async (tx) => { + const [target] = await tx + .select() + .from(mcpServers) + .where( + and(eq(mcpServers.id, params.serverId), eq(mcpServers.workspaceId, params.workspaceId)) + ) + .limit(1) + .for('update') + if (!target) return null - if (!server) return { success: false, error: 'Server not found', errorCode: 'not_found' } + const retired = await tx + .delete(credential) + .where( + and( + eq(credential.workspaceId, params.workspaceId), + eq(credential.type, 'managed_mcp'), + eq(credential.mcpServerId, params.serverId) + ) + ) + .returning({ id: credential.id }) + const [server] = await tx + .delete(mcpServers) + .where( + and(eq(mcpServers.id, params.serverId), eq(mcpServers.workspaceId, params.workspaceId)) + ) + .returning() + if (!server) throw new Error('MCP server disappeared during deletion') + return { server, retiredManagedConnectionIds: retired.map((row) => row.id) } + }) + + if (!deleted) return { success: false, error: 'Server not found', errorCode: 'not_found' } - return { success: true, server } + return { success: true, ...deleted } } catch (error) { logger.error('Failed to delete MCP server', { error }) throw error @@ -700,6 +734,11 @@ export async function applyMcpServerMutationEffects(params: { action === 'delete' ? 'server deleted' : 'config changed' ) } + await Promise.all( + (result.retiredManagedConnectionIds ?? []).map((connectionId) => + mcpService.evictServerConnections(connectionId, 'managed connection retired') + ) + ) if (action === 'create' && result.updated === false && result.server) { const { PlatformEvents } = await import('@/lib/core/telemetry') diff --git a/apps/sim/lib/mcp/service-pool.test.ts b/apps/sim/lib/mcp/service-pool.test.ts index 3f1566b7ab9..144890f0103 100644 --- a/apps/sim/lib/mcp/service-pool.test.ts +++ b/apps/sim/lib/mcp/service-pool.test.ts @@ -112,7 +112,7 @@ vi.mock('@/lib/mcp/oauth', () => ({ getOrCreateOauthRow: vi.fn(), loadPreregisteredClient: vi.fn(), SimMcpOauthProvider: vi.fn(), - withMcpOauthRefreshLock: vi.fn(), + withMcpOauthRefreshLock: vi.fn((_id: string, fn: () => Promise) => fn()), })) vi.mock('@/lib/mcp/resolve-config', () => ({ resolveMcpConfigEnvVars: (...args: unknown[]) => mockResolveEnvVars(...args), diff --git a/apps/sim/lib/mcp/service.test.ts b/apps/sim/lib/mcp/service.test.ts index 8b297d5272d..f85f29feec4 100644 --- a/apps/sim/lib/mcp/service.test.ts +++ b/apps/sim/lib/mcp/service.test.ts @@ -111,7 +111,7 @@ vi.mock('@/lib/mcp/oauth', () => ({ getOrCreateOauthRow: vi.fn(), loadPreregisteredClient: vi.fn(), SimMcpOauthProvider: vi.fn(), - withMcpOauthRefreshLock: vi.fn(), + withMcpOauthRefreshLock: vi.fn((_id: string, fn: () => Promise) => fn()), })) vi.mock('@/lib/mcp/resolve-config', () => ({ diff --git a/apps/sim/lib/mcp/service.ts b/apps/sim/lib/mcp/service.ts index bb5914156c8..9aeb3620556 100644 --- a/apps/sim/lib/mcp/service.ts +++ b/apps/sim/lib/mcp/service.ts @@ -1,4 +1,7 @@ -import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js' +import { + type OAuthClientProvider, + UnauthorizedError, +} from '@modelcontextprotocol/sdk/client/auth.js' import { StreamableHTTPError } from '@modelcontextprotocol/sdk/client/streamableHttp.js' import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js' import { db } from '@sim/db' @@ -12,7 +15,7 @@ import { and, eq, isNull, lte, or, sql } from 'drizzle-orm' import { generateRequestId } from '@/lib/core/utils/request' import { McpClient } from '@/lib/mcp/client' import { mcpConnectionManager } from '@/lib/mcp/connection-manager' -import { mcpConnectionPool } from '@/lib/mcp/connection-pool' +import { evictMcpServerConnections, mcpConnectionPool } from '@/lib/mcp/connection-pool' import { MAX_MCP_LAST_ERROR_LENGTH } from '@/lib/mcp/constants' import { isMcpDomainAllowed, @@ -43,6 +46,7 @@ import { type McpTransport, } from '@/lib/mcp/types' import { MCP_CLIENT_CONSTANTS, MCP_CONSTANTS } from '@/lib/mcp/utils' +import { createEnvVarPattern } from '@/executor/utils/reference-validation' import { isResolvedSecretTraceProvenanceV1, type ResolvedSecretTraceProvenanceV1, @@ -64,6 +68,7 @@ type ResolvedSecretTraceProvenanceCallback = (provenance: ResolvedSecretTracePro interface McpRequestOptions { signal?: AbortSignal + requireComplete?: boolean } interface McpToolExecutionOptions extends McpRequestOptions { @@ -454,6 +459,90 @@ class McpService { }) } + private async createManagedOauthClient( + config: McpServerConfig, + authProvider: OAuthClientProvider, + signal?: AbortSignal + ): Promise { + if (config.authType !== 'oauth' || !config.url) { + throw new Error('Managed MCP connection requires an OAuth HTTP server') + } + if ( + [config.url, ...Object.values(config.headers ?? {})].some((value) => + createEnvVarPattern().test(value) + ) + ) { + throw new Error('Credential Group MCP servers cannot use personal environment references') + } + validateMcpDomain(config.url) + const resolvedIP = await validateMcpServerSsrf(config.url) + const client = new McpClient({ + config, + securityPolicy: { + requireConsent: true, + auditLevel: 'basic', + maxToolExecutionsPerHour: 1000, + allowedOrigins: [new URL(config.url).origin], + }, + authProvider, + resolvedIP: resolvedIP ?? undefined, + }) + await client.connect({ signal }) + return client + } + + async discoverManagedMcpTools( + serverId: string, + workspaceId: string, + authProvider: OAuthClientProvider, + signal?: AbortSignal, + options: { requireComplete?: boolean } = {} + ): Promise { + const config = await this.getServerConfig(serverId, workspaceId) + if (!config) throw new Error('Managed MCP server is unavailable') + return this.withServerClient( + { key: '', serverId, allowPool: false }, + () => this.createManagedOauthClient(config, authProvider, signal), + (client) => + options.requireComplete + ? client.listTools(signal, { requireComplete: true }) + : client.listTools(signal) + ) + } + + async executeManagedMcpTool(params: { + connectionId: string + serverId: string + workspaceId: string + toolCall: McpToolCall + loadAuthProvider: () => Promise + extraHeaders?: Record + signal?: AbortSignal + timeoutMs?: number + }): Promise { + const config = await this.getServerConfig(params.serverId, params.workspaceId) + if (!config) throw new Error('Managed MCP server is unavailable') + const effectiveConfig = params.extraHeaders + ? { ...config, headers: { ...config.headers, ...params.extraHeaders } } + : config + return withMcpOauthRefreshLock(params.connectionId, () => + this.withServerClient( + { key: '', serverId: params.serverId, allowPool: false }, + async () => + this.createManagedOauthClient( + effectiveConfig, + await params.loadAuthProvider(), + params.signal + ), + (client) => + client.callTool(params.toolCall, { + signal: params.signal, + timeoutMs: params.timeoutMs, + }) + ) + ) + } + /** Auth-scoped pool key: a server's resolved credentials depend on the (user, workspace) env. */ private poolKey( serverId: string, @@ -512,7 +601,8 @@ class McpService { userId: string, workspaceId: string, onResolvedSecretTraceProvenance?: ResolvedSecretTraceProvenanceCallback, - signal?: AbortSignal + signal?: AbortSignal, + requireComplete = false ): Promise { for (let attempt = 0; ; attempt++) { signal?.throwIfAborted() @@ -538,7 +628,9 @@ class McpService { workspaceId, onResolvedSecretTraceProvenance ) - return client.listTools(signal) + return requireComplete + ? client.listTools(signal, { requireComplete: true }) + : client.listTools(signal) } ) } catch (error) { @@ -1041,18 +1133,19 @@ class McpService { onResolvedSecretTraceProvenance?: ResolvedSecretTraceProvenanceCallback, options: McpRequestOptions = {} ): Promise { - if (onResolvedSecretTraceProvenance || options.signal) { + if (onResolvedSecretTraceProvenance || options.signal || options.requireComplete) { return this.discoverServerToolsImpl( userId, serverId, workspaceId, refresh, createInvocationProvenanceReporter(onResolvedSecretTraceProvenance), - options.signal + options.signal, + options.requireComplete ) } - const inflightKey = `${workspaceId}:${serverId}:${userId}:${refresh}` + const inflightKey = `${workspaceId}:${serverId}:${userId}:${refresh}:partial-ok` const existing = this.inflightServerDiscovery.get(inflightKey) if (existing) return existing @@ -1062,7 +1155,8 @@ class McpService { workspaceId, refresh, undefined, - undefined + undefined, + false ).finally(() => { this.inflightServerDiscovery.delete(inflightKey) }) @@ -1076,14 +1170,15 @@ class McpService { workspaceId: string, refresh: McpDiscoveryRefresh, onResolvedSecretTraceProvenance?: ResolvedSecretTraceProvenanceCallback, - signal?: AbortSignal + signal?: AbortSignal, + requireComplete = false ): Promise { signal?.throwIfAborted() const requestId = generateRequestId() const discoveryStartedAt = new Date() const maxRetries = 2 - if (refresh === 'cache-aside') { + if (refresh === 'cache-aside' && !requireComplete) { try { const cached = await this.cacheAdapter.get(serverCacheKey(workspaceId, serverId)) if (cached) { @@ -1119,7 +1214,8 @@ class McpService { userId, workspaceId, onResolvedSecretTraceProvenance, - signal + signal, + requireComplete ) logger.info(`[${requestId}] Discovered ${tools.length} tools from server ${config.name}`) await Promise.allSettled([ @@ -1265,7 +1361,7 @@ class McpService { /** Evict a single server's warm pooled connections (all users) — call on config change/delete. */ async evictServerConnections(serverId: string, reason: string): Promise { - await mcpConnectionPool?.evictServer(serverId, reason) + await evictMcpServerConnections(serverId, reason) } } diff --git a/apps/sim/lib/mcp/shared.test.ts b/apps/sim/lib/mcp/shared.test.ts new file mode 100644 index 00000000000..91ccaecb54f --- /dev/null +++ b/apps/sim/lib/mcp/shared.test.ts @@ -0,0 +1,54 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { assertValidMcpServerToolBindings } from '@/lib/mcp/shared' + +describe('assertValidMcpServerToolBindings', () => { + it('accepts distinct server-wide bindings and unrelated individual tools', () => { + expect(() => + assertValidMcpServerToolBindings([ + { type: 'mcp-server-advanced', params: { serverId: 'mcp-a' } }, + { type: 'mcp-server-advanced', params: { serverId: 'mcp-b' } }, + { type: 'mcp', params: { serverId: 'mcp-c', toolName: 'lookup' } }, + ]) + ).not.toThrow() + }) + + it('rejects duplicate server-wide bindings', () => { + expect(() => + assertValidMcpServerToolBindings([ + { type: 'mcp-server-advanced', params: { serverId: 'mcp-a' } }, + { type: 'mcp-server-advanced', params: { serverId: 'mcp-a' } }, + ]) + ).toThrow('Duplicate MCP Server (Advanced) binding for mcp-a') + }) + + it('rejects mixing a server-wide binding with individual tools from that server', () => { + expect(() => + assertValidMcpServerToolBindings([ + { type: 'mcp', params: { serverId: 'mcp-a', toolName: 'lookup' } }, + { type: 'mcp-server-advanced', params: { serverId: 'mcp-a' } }, + ]) + ).toThrow('cannot be attached as both an advanced server and individual tools') + }) + + it('ignores disabled bindings when checking conflicts', () => { + expect(() => + assertValidMcpServerToolBindings([ + { type: 'mcp', params: { serverId: 'mcp-a', toolName: 'lookup' } }, + { + type: 'mcp-server-advanced', + params: { serverId: 'mcp-a' }, + usageControl: 'none', + }, + ]) + ).not.toThrow() + }) + + it('fails fast on a malformed active server-wide binding', () => { + expect(() => + assertValidMcpServerToolBindings([{ type: 'mcp-server-advanced', params: { serverId: '' } }]) + ).toThrow('requires params.serverId') + }) +}) diff --git a/apps/sim/lib/mcp/shared.ts b/apps/sim/lib/mcp/shared.ts index eaecff1f0e9..7e0430cc793 100644 --- a/apps/sim/lib/mcp/shared.ts +++ b/apps/sim/lib/mcp/shared.ts @@ -5,6 +5,67 @@ import { isMcpTool, MCP } from '@/executor/constants' +export const MCP_SERVER_ADVANCED_TOOL_TYPE = 'mcp-server-advanced' as const + +export interface McpServerAdvancedToolBinding { + type: typeof MCP_SERVER_ADVANCED_TOOL_TYPE + params: { + serverId: string + } + usageControl?: 'auto' | 'force' | 'none' +} + +export function isMcpServerAdvancedToolBinding( + value: unknown +): value is McpServerAdvancedToolBinding { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + const binding = value as { type?: unknown; params?: unknown } + if (binding.type !== MCP_SERVER_ADVANCED_TOOL_TYPE) return false + if (!binding.params || typeof binding.params !== 'object' || Array.isArray(binding.params)) { + return false + } + const serverId = (binding.params as { serverId?: unknown }).serverId + return typeof serverId === 'string' && serverId.trim().length > 0 +} + +/** Rejects ambiguous server-wide bindings while leaving legacy MCP entries untouched. */ +export function assertValidMcpServerToolBindings(value: unknown): void { + if (!Array.isArray(value)) return + const advancedServerIds = new Set() + const individualServerIds = new Set() + for (const candidate of value) { + if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) continue + const tool = candidate as { + type?: unknown + usageControl?: unknown + params?: { serverId?: unknown } + } + if (tool.usageControl === 'none') continue + if (tool.type === 'mcp') { + if (typeof tool.params?.serverId === 'string' && tool.params.serverId) { + individualServerIds.add(tool.params.serverId) + } + continue + } + if (tool.type !== MCP_SERVER_ADVANCED_TOOL_TYPE) continue + const serverId = tool.params?.serverId + if (typeof serverId !== 'string' || !serverId.trim()) { + throw new Error('MCP Server (Advanced) requires params.serverId') + } + if (advancedServerIds.has(serverId)) { + throw new Error(`Duplicate MCP Server (Advanced) binding for ${serverId}`) + } + advancedServerIds.add(serverId) + } + for (const serverId of advancedServerIds) { + if (individualServerIds.has(serverId)) { + throw new Error( + `MCP server ${serverId} cannot be attached as both an advanced server and individual tools` + ) + } + } +} + /** * Sanitizes a string by removing invisible Unicode characters that cause HTTP header errors. * Handles characters like U+2028 (Line Separator) that can be introduced via copy-paste. diff --git a/apps/sim/lib/mcp/types.ts b/apps/sim/lib/mcp/types.ts index c6d4e584666..e4a557329d9 100644 --- a/apps/sim/lib/mcp/types.ts +++ b/apps/sim/lib/mcp/types.ts @@ -1,4 +1,5 @@ import type { Tool } from '@modelcontextprotocol/sdk/types.js' +import type { ManagedMcpConnectorId } from '@/lib/credential-groups/managed-mcp-connectors' import type { ResolvedSecretTraceProvenanceV1 } from '@/executor/utils/resolved-secret-trace-registry' export type McpTransport = 'streamable-http' @@ -91,6 +92,7 @@ export interface McpTool extends Pick { inputSchema: McpToolSchema serverId: string serverName: string + managedConnectorId?: ManagedMcpConnectorId } export interface McpToolCall { diff --git a/apps/sim/lib/mcp/utils.test.ts b/apps/sim/lib/mcp/utils.test.ts index 3fa3d61806c..8a459a48c3e 100644 --- a/apps/sim/lib/mcp/utils.test.ts +++ b/apps/sim/lib/mcp/utils.test.ts @@ -9,10 +9,13 @@ import { import { categorizeError, createMcpToolId, + generateManagedMcpConnectionId, generateMcpServerId, + isManagedMcpConnectionId, MCP_CLIENT_CONSTANTS, MCP_CONSTANTS, parseMcpToolId, + parseMcpToolTarget, validateRequiredFields, validateStringParam, } from './utils' @@ -431,3 +434,40 @@ describe('parseMcpToolId', () => { expect(result.toolName).toBe('tool-with-many-parts') }) }) + +describe('parseMcpToolTarget', () => { + it('preserves a managed connection ID even when its random segment contains hyphens', () => { + const credentialId = 'mcp-cg-abcd-efghijklmnopqrst' + const result = parseMcpToolTarget(`${credentialId}-fireflies-search-transcripts`) + + expect(result).toEqual({ + kind: 'managed_connection', + credentialId, + toolName: 'fireflies-search-transcripts', + }) + }) + + it('keeps existing shared MCP tool IDs unchanged', () => { + expect(parseMcpToolTarget('mcp-12345678-search-transcripts')).toEqual({ + kind: 'shared_server', + serverId: 'mcp-12345678', + toolName: 'search-transcripts', + }) + }) + + it('rejects a managed connection ID without a tool name', () => { + const credentialId = generateManagedMcpConnectionId() + expect(() => parseMcpToolTarget(credentialId)).toThrow('Invalid managed MCP tool ID format') + }) +}) + +describe('isManagedMcpConnectionId', () => { + it('accepts only a complete managed connection ID', () => { + const credentialId = generateManagedMcpConnectionId() + + expect(isManagedMcpConnectionId(credentialId)).toBe(true) + expect(isManagedMcpConnectionId(`${credentialId}-tool`)).toBe(false) + expect(isManagedMcpConnectionId('mcp-cg-short')).toBe(false) + expect(isManagedMcpConnectionId('mcp-shared')).toBe(false) + }) +}) diff --git a/apps/sim/lib/mcp/utils.ts b/apps/sim/lib/mcp/utils.ts index 5f29e46acf8..b5b417dc421 100644 --- a/apps/sim/lib/mcp/utils.ts +++ b/apps/sim/lib/mcp/utils.ts @@ -1,4 +1,5 @@ import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js' +import { generateShortId } from '@sim/utils/id' import { NextResponse } from 'next/server' import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits' import { @@ -24,6 +25,22 @@ export const MCP_CONSTANTS = { */ export const MCP_TOOL_CORE_PARAMS = new Set(['serverId', 'serverUrl', 'toolName', 'serverName']) +export const MANAGED_MCP_CONNECTION_PREFIX = 'mcp-cg-' +const MANAGED_MCP_RANDOM_ID_LENGTH = 21 +const MANAGED_MCP_CONNECTION_ID_LENGTH = + MANAGED_MCP_CONNECTION_PREFIX.length + MANAGED_MCP_RANDOM_ID_LENGTH + +export function generateManagedMcpConnectionId(): string { + return `${MANAGED_MCP_CONNECTION_PREFIX}${generateShortId(MANAGED_MCP_RANDOM_ID_LENGTH)}` +} + +export function isManagedMcpConnectionId(value: string): boolean { + return ( + value.startsWith(MANAGED_MCP_CONNECTION_PREFIX) && + value.length === MANAGED_MCP_CONNECTION_ID_LENGTH + ) +} + /** * Sanitizes a string by removing invisible Unicode characters that cause HTTP header errors. * Handles characters like U+2028 (Line Separator) that can be introduced via copy-paste. @@ -220,6 +237,29 @@ export function parseMcpToolId(toolId: string): { serverId: string; toolName: st return { serverId, toolName } } +export type ParsedMcpToolTarget = + | { kind: 'shared_server'; serverId: string; toolName: string } + | { kind: 'managed_connection'; credentialId: string; toolName: string } + +export function parseMcpToolTarget(toolId: string): ParsedMcpToolTarget { + if (toolId.startsWith(MANAGED_MCP_CONNECTION_PREFIX)) { + if ( + toolId.length <= MANAGED_MCP_CONNECTION_ID_LENGTH || + toolId[MANAGED_MCP_CONNECTION_ID_LENGTH] !== '-' + ) { + throw new Error( + `Invalid managed MCP tool ID format: ${toolId}. Expected: mcp-cg-connectionId-toolName` + ) + } + const credentialId = toolId.slice(0, MANAGED_MCP_CONNECTION_ID_LENGTH) + const toolName = toolId.slice(MANAGED_MCP_CONNECTION_ID_LENGTH + 1) + if (!toolName) throw new Error(`Invalid managed MCP tool ID format: ${toolId}`) + return { kind: 'managed_connection', credentialId, toolName } + } + const { serverId, toolName } = parseMcpToolId(toolId) + return { kind: 'shared_server', serverId, toolName } +} + /** * Generate a deterministic MCP server ID based on workspace and URL. * diff --git a/apps/sim/lib/workflows/editing/builders.ts b/apps/sim/lib/workflows/editing/builders.ts index 2218ea5915d..3b45b30325c 100644 --- a/apps/sim/lib/workflows/editing/builders.ts +++ b/apps/sim/lib/workflows/editing/builders.ts @@ -7,6 +7,7 @@ import { normalizeBlockRetryWaitMs, } from '@sim/workflow-types/workflow' import { isIntegrationDeploymentAvailableForVisibility } from '@/lib/integrations/availability.server' +import { MCP_SERVER_ADVANCED_TOOL_TYPE } from '@/lib/mcp/shared' import { capabilityDeniedBy } from '@/lib/permission-groups/capability-assertions' import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' import { createModelAccessGate } from '@/lib/permission-groups/model-access' @@ -801,13 +802,16 @@ export function filterDisallowedTools( }) continue } - if (tool.type === 'mcp' && capabilityDeniedBy('mcp_tools.use', permissionConfig)) { + if ( + (tool.type === 'mcp' || tool.type === MCP_SERVER_ADVANCED_TOOL_TYPE) && + capabilityDeniedBy('mcp_tools.use', permissionConfig) + ) { logSkippedItem(skippedItems, { type: 'tool_not_allowed', operationType: 'add', blockId, reason: `MCP tool "${tool.title || 'unknown'}" is not allowed by permission group - tool not added`, - details: { toolType: 'mcp', serverId: tool.params?.serverId }, + details: { toolType: tool.type, serverId: tool.params?.serverId }, }) continue } diff --git a/apps/sim/lib/workflows/editing/validation.test.ts b/apps/sim/lib/workflows/editing/validation.test.ts index f3255a20200..00d596acc31 100644 --- a/apps/sim/lib/workflows/editing/validation.test.ts +++ b/apps/sim/lib/workflows/editing/validation.test.ts @@ -1493,6 +1493,33 @@ describe('collectUnresolvedAgentToolReferences', () => { expect(mockValidateSelectorIds).toHaveBeenCalledWith('mcp-server-selector', 'srv_missing', CTX) }) + it('defers an advanced MCP server reference until workflow execution', async () => { + const state = { + blocks: { + a1: { + type: 'agent', + subBlocks: { + tools: { + value: [ + { + type: 'mcp-server-advanced', + params: { + serverId: '', + }, + }, + ], + }, + }, + }, + }, + } + + const refs = await collectUnresolvedAgentToolReferences(state, CTX) + + expect(refs).toHaveLength(0) + expect(mockValidateSelectorIds).not.toHaveBeenCalled() + }) + it('flags a skill whose skillId does not resolve', async () => { mockGetSkillById.mockResolvedValue(null) const state = { diff --git a/apps/sim/lib/workflows/editing/validation.ts b/apps/sim/lib/workflows/editing/validation.ts index 5a676c57e0c..aada8e73d19 100644 --- a/apps/sim/lib/workflows/editing/validation.ts +++ b/apps/sim/lib/workflows/editing/validation.ts @@ -3,11 +3,13 @@ import { toError } from '@sim/utils/errors' import { omit } from '@sim/utils/object' import { isHosted as isHostedDeployment } from '@/lib/core/config/env-flags' import { isIntegrationDeploymentAvailableForVisibility } from '@/lib/integrations/availability.server' +import { MCP_SERVER_ADVANCED_TOOL_TYPE } from '@/lib/mcp/shared' import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' import { resolveAccessControlBlockType } from '@/lib/permission-groups/integration-allowlist' import { getCustomToolById } from '@/lib/workflows/custom-tools/operations' import { validateSelectorIds } from '@/lib/workflows/editing/selector-validator' +import { containsReference } from '@/lib/workflows/sanitization/references' import { getSkillById } from '@/lib/workflows/skills/operations' import { buildCanonicalIndex, @@ -282,6 +284,14 @@ function validateAgentToolEntry(item: any, index: number): string | null { return null } + if (type === MCP_SERVER_ADVANCED_TOOL_TYPE) { + const serverId = item.params?.serverId + if (typeof serverId !== 'string' || !serverId.trim()) { + return `${where} (${MCP_SERVER_ADVANCED_TOOL_TYPE}) must include params.serverId` + } + return null + } + // Integration/block-based tool: the type must be a real registry block that // actually exposes callable tools. A known block with an empty tools.access // (control-flow blocks like condition/loop/parallel/router, or the agent block @@ -1303,9 +1313,13 @@ export async function collectUnresolvedAgentToolReferences( error: toError(error).message, }) } - } else if (tool.type === 'mcp' && workspaceId) { + } else if ( + (tool.type === 'mcp' || tool.type === MCP_SERVER_ADVANCED_TOOL_TYPE) && + workspaceId + ) { const serverId = tool.params?.serverId if (typeof serverId !== 'string' || serverId.trim() === '') continue + if (containsReference(serverId)) continue try { const result = await validateSelectorIds('mcp-server-selector', serverId, context) if (result.invalid.length > 0) { diff --git a/apps/sim/lib/workflows/subblocks/display.ts b/apps/sim/lib/workflows/subblocks/display.ts index 4c2b25d4a70..a4239f60da3 100644 --- a/apps/sim/lib/workflows/subblocks/display.ts +++ b/apps/sim/lib/workflows/subblocks/display.ts @@ -8,6 +8,7 @@ */ import { isRecordLike } from '@sim/utils/object' import { truncate } from '@sim/utils/string' +import { MCP_SERVER_ADVANCED_TOOL_TYPE } from '@/lib/mcp/shared' import type { FilterRule, SortRule } from '@/lib/table/types' import { DELETED_WORKFLOW_LABEL } from '@/lib/workflows/workflow-labels' import { getBlock } from '@/blocks' @@ -507,6 +508,10 @@ export function resolveStoredToolName( return storedTitle } + if (t.type === MCP_SERVER_ADVANCED_TOOL_TYPE) { + return storedTitle || 'MCP Server (Advanced)' + } + if (typeof t.type === 'string' && t.type) { const blockConfig = getBlockConfig(t.type) if (blockConfig?.name) return blockConfig.name diff --git a/apps/sim/stores/panel/types.ts b/apps/sim/stores/panel/types.ts index 42c4a8cfd54..ebab08f7bc0 100644 --- a/apps/sim/stores/panel/types.ts +++ b/apps/sim/stores/panel/types.ts @@ -1,3 +1,5 @@ +import type { ManagedMcpConnectorId } from '@/lib/credential-groups/managed-mcp-connectors' + /** * Available panel tabs */ @@ -89,4 +91,9 @@ export type ChatContext = | { kind: 'slash_command'; command: string; label: string } | { kind: 'integration'; blockType: string; label: string } | { kind: 'skill'; skillId: string; label: string } - | { kind: 'mcp'; serverId: string; label: string } + | { + kind: 'mcp' + serverId: string + label: string + managedConnectorId?: ManagedMcpConnectorId + } diff --git a/packages/auth/src/principal.ts b/packages/auth/src/principal.ts index e47e9dd9262..7065e3b9993 100644 --- a/packages/auth/src/principal.ts +++ b/packages/auth/src/principal.ts @@ -64,6 +64,7 @@ interface DelegatedPrincipalBase { executionId?: string credentialId?: string credentialGroupId?: string + mcpServerId?: string } } @@ -231,6 +232,7 @@ function parseResourceScope(value: unknown): DelegatedPrincipal['resourceScope'] 'executionId', 'credentialId', 'credentialGroupId', + 'mcpServerId', ] as const requireExactKeys(scope, [], keys) const parsed: NonNullable = {} diff --git a/packages/db/migrations/0318_credential_group_managed_mcp.sql b/packages/db/migrations/0318_credential_group_managed_mcp.sql new file mode 100644 index 00000000000..0d2332ec0a7 --- /dev/null +++ b/packages/db/migrations/0318_credential_group_managed_mcp.sql @@ -0,0 +1,114 @@ +-- Adds per-enrollment managed MCP grants without changing existing credential rows. +-- Pure expand: every new column is nullable, and no managed_mcp row can predate this migration. +-- Every pre-COMMIT statement is replay-safe because a concurrent index failure leaves this file +-- unjournaled while preserving the committed schema changes. +ALTER TYPE "public"."credential_type" ADD VALUE IF NOT EXISTS 'managed_mcp' BEFORE 'env_workspace';--> statement-breakpoint +ALTER TABLE "credential" ADD COLUMN IF NOT EXISTS "mcp_server_id" text;--> statement-breakpoint +ALTER TABLE "credential" ADD COLUMN IF NOT EXISTS "mcp_tools" jsonb;--> statement-breakpoint +ALTER TABLE "credential" ADD COLUMN IF NOT EXISTS "mcp_tools_refreshed_at" timestamp;--> statement-breakpoint +ALTER TABLE "mcp_servers" ADD COLUMN IF NOT EXISTS "credential_group_id" text;--> statement-breakpoint +ALTER TABLE "mcp_servers" ADD COLUMN IF NOT EXISTS "managed_connector_id" text;--> statement-breakpoint + +-- PostgreSQL has no ADD CONSTRAINT IF NOT EXISTS, so replay guards are scoped to each table. +-- NOT VALID keeps foreign-key installation to a metadata change before validation. +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM "pg_constraint" + WHERE "conname" = 'credential_mcp_server_id_mcp_servers_id_fk' + AND "conrelid" = '"credential"'::regclass + ) THEN + ALTER TABLE "credential" ADD CONSTRAINT "credential_mcp_server_id_mcp_servers_id_fk" FOREIGN KEY ("mcp_server_id") REFERENCES "public"."mcp_servers"("id") ON DELETE cascade ON UPDATE no action NOT VALID; + END IF; +END $$;--> statement-breakpoint +ALTER TABLE "credential" VALIDATE CONSTRAINT "credential_mcp_server_id_mcp_servers_id_fk";--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM "pg_constraint" + WHERE "conname" = 'mcp_servers_credential_group_id_credential_group_id_fk' + AND "conrelid" = '"mcp_servers"'::regclass + ) THEN + ALTER TABLE "mcp_servers" ADD CONSTRAINT "mcp_servers_credential_group_id_credential_group_id_fk" FOREIGN KEY ("credential_group_id") REFERENCES "public"."credential_group"("id") ON DELETE set null ON UPDATE no action NOT VALID; + END IF; +END $$;--> statement-breakpoint +ALTER TABLE "mcp_servers" VALIDATE CONSTRAINT "mcp_servers_credential_group_id_credential_group_id_fk";--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM "pg_constraint" + WHERE "conname" = 'mcp_servers_credential_group_managed_connector_check' + AND "conrelid" = '"mcp_servers"'::regclass + ) THEN + ALTER TABLE "mcp_servers" ADD CONSTRAINT "mcp_servers_credential_group_managed_connector_check" CHECK ("credential_group_id" IS NULL OR "managed_connector_id" IS NOT NULL) NOT VALID; + END IF; +END $$;--> statement-breakpoint +ALTER TABLE "mcp_servers" VALIDATE CONSTRAINT "mcp_servers_credential_group_managed_connector_check";--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM "pg_constraint" + WHERE "conname" = 'mcp_servers_managed_connector_oauth_check' + AND "conrelid" = '"mcp_servers"'::regclass + ) THEN + ALTER TABLE "mcp_servers" ADD CONSTRAINT "mcp_servers_managed_connector_oauth_check" CHECK ("managed_connector_id" IS NULL OR "auth_type" = 'oauth') NOT VALID; + END IF; +END $$;--> statement-breakpoint +ALTER TABLE "mcp_servers" VALIDATE CONSTRAINT "mcp_servers_managed_connector_oauth_check";--> statement-breakpoint + +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM "pg_constraint" + WHERE "conname" = 'credential_managed_mcp_source_check' + AND "conrelid" = '"credential"'::regclass + ) THEN + ALTER TABLE "credential" ADD CONSTRAINT "credential_managed_mcp_source_check" CHECK ((type::text <> 'managed_mcp') OR ( + id LIKE 'mcp-cg-%' + AND account_id IS NULL + AND provider_id IS NULL + AND authorization_app_id IS NULL + AND credential_group_enrollment_id IS NOT NULL + AND credential_group_option_id IS NULL + AND mcp_server_id IS NOT NULL + AND managed_oauth_status IS NOT NULL + AND (managed_oauth_status <> 'active' OR ( + encrypted_oauth_token_set IS NOT NULL + AND mcp_tools IS NOT NULL + )) + AND granted_at IS NOT NULL + AND managed_oauth_scope_version IS NULL + AND provider_subject_id IS NULL + AND provider_tenant_id IS NULL + AND granted_scopes IS NULL + AND provider_metadata IS NULL + AND created_by IS NULL + AND env_key IS NULL + AND env_owner_user_id IS NULL + AND encrypted_service_account_key IS NULL + AND unredacted = false + )) NOT VALID; + END IF; +END $$;--> statement-breakpoint +ALTER TABLE "credential" VALIDATE CONSTRAINT "credential_managed_mcp_source_check";--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM "pg_constraint" + WHERE "conname" = 'credential_creator_source_check' + AND "conrelid" = '"credential"'::regclass + ) THEN + ALTER TABLE "credential" ADD CONSTRAINT "credential_creator_source_check" CHECK ((type::text = 'managed_mcp') OR created_by IS NOT NULL) NOT VALID; + END IF; +END $$;--> statement-breakpoint +ALTER TABLE "credential" VALIDATE CONSTRAINT "credential_creator_source_check";--> statement-breakpoint +ALTER TABLE "credential" ALTER COLUMN "created_by" DROP NOT NULL;--> statement-breakpoint + +-- The commit makes the new enum label visible and moves index builds outside the migration +-- runner's transaction, as required by PostgreSQL for the partial and concurrent indexes. +COMMIT;--> statement-breakpoint +SET lock_timeout = 0;--> statement-breakpoint +-- A failed concurrent build leaves an invalid index behind, so each replay removes it first. +DROP INDEX CONCURRENTLY IF EXISTS "credential_mcp_server_idx";--> statement-breakpoint +CREATE INDEX CONCURRENTLY IF NOT EXISTS "credential_mcp_server_idx" ON "credential" USING btree ("mcp_server_id");--> statement-breakpoint +DROP INDEX CONCURRENTLY IF EXISTS "credential_managed_mcp_enrollment_server_unique";--> statement-breakpoint +CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "credential_managed_mcp_enrollment_server_unique" ON "credential" USING btree ("credential_group_enrollment_id","mcp_server_id") WHERE "credential"."type" = 'managed_mcp';--> statement-breakpoint +DROP INDEX CONCURRENTLY IF EXISTS "mcp_servers_credential_group_idx";--> statement-breakpoint +CREATE INDEX CONCURRENTLY IF NOT EXISTS "mcp_servers_credential_group_idx" ON "mcp_servers" USING btree ("credential_group_id");--> statement-breakpoint +DROP INDEX CONCURRENTLY IF EXISTS "mcp_servers_credential_group_managed_connector_unique";--> statement-breakpoint +CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "mcp_servers_credential_group_managed_connector_unique" ON "mcp_servers" USING btree ("credential_group_id","managed_connector_id") WHERE "credential_group_id" IS NOT NULL AND "managed_connector_id" IS NOT NULL AND "deleted_at" IS NULL;--> statement-breakpoint +SET lock_timeout = '5s'; diff --git a/packages/db/migrations/meta/0318_snapshot.json b/packages/db/migrations/meta/0318_snapshot.json new file mode 100644 index 00000000000..67e56469a50 --- /dev/null +++ b/packages/db/migrations/meta/0318_snapshot.json @@ -0,0 +1,20986 @@ +{ + "id": "cc128588-30bb-4ad9-b799-ff047f1a0f89", + "prevId": "abfcce0a-145e-4180-ad24-9e80f206903c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_unreconciled_terminal_idx": { + "name": "async_jobs_schedule_unreconciled_terminal_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" IN ('completed', 'failed', 'cancelled') AND COALESCE(\"async_jobs\".\"metadata\" ->> 'scheduleReconciled', 'false') <> 'true'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_work_status": { + "name": "background_work_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "background_work_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_work_status_value", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_work_status_workspace_status_idx": { + "name": "background_work_status_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_workflow_status_idx": { + "name": "background_work_status_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_child_ws_idx": { + "name": "background_work_status_meta_child_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'childWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_other_ws_idx": { + "name": "background_work_status_meta_other_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'otherWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "background_work_status_workspace_id_workspace_id_fk": { + "name": "background_work_status_workspace_id_workspace_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "background_work_status_workflow_id_workflow_id_fk": { + "name": "background_work_status_workflow_id_workflow_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "include_thinking": { + "name": "include_thinking", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_tool_calls": { + "name": "include_tool_calls", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permission_decision": { + "name": "permission_decision", + "type": "copilot_tool_permission_decision", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "permission_decided_at": { + "name": "permission_decided_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "auto_allowed_tools": { + "name": "auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_deleted_partial_idx": { + "name": "copilot_chats_user_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_chats\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_user_created_at_idx": { + "name": "copilot_messages_user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"role\" = 'user' AND \"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unredacted": { + "name": "unredacted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_app_id": { + "name": "authorization_app_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_enrollment_id": { + "name": "credential_group_enrollment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_option_id": { + "name": "credential_group_option_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_oauth_scope_version": { + "name": "managed_oauth_scope_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "provider_subject_id": { + "name": "provider_subject_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_oauth_status": { + "name": "managed_oauth_status", + "type": "managed_oauth_credential_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "granted_scopes": { + "name": "granted_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "provider_metadata": { + "name": "provider_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "encrypted_oauth_token_set": { + "name": "encrypted_oauth_token_set", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_tools": { + "name": "mcp_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "mcp_tools_refreshed_at": { + "name": "mcp_tools_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "granted_at": { + "name": "granted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "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 + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_idx": { + "name": "credential_group_enrollment_idx", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_mcp_server_idx": { + "name": "credential_mcp_server_idx", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_option_unique": { + "name": "credential_group_option_unique", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credential_group_option_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential\".\"type\" = 'managed_oauth'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_managed_mcp_enrollment_server_unique": { + "name": "credential_managed_mcp_enrollment_server_unique", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential\".\"type\" = 'managed_mcp'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_credential_group_enrollment_id_credential_group_enrollment_id_fk": { + "name": "credential_credential_group_enrollment_id_credential_group_enrollment_id_fk", + "tableFrom": "credential", + "tableTo": "credential_group_enrollment", + "columnsFrom": ["credential_group_enrollment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_mcp_server_id_mcp_servers_id_fk": { + "name": "credential_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "credential", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_managed_oauth_source_check": { + "name": "credential_managed_oauth_source_check", + "value": "(type::text <> 'managed_oauth') OR (\n account_id IS NULL\n AND provider_id IS NOT NULL\n AND authorization_app_id IS NOT NULL\n AND provider_subject_id IS NOT NULL\n AND managed_oauth_status IS NOT NULL\n AND granted_scopes IS NOT NULL\n AND cardinality(granted_scopes) > 0\n AND encrypted_oauth_token_set IS NOT NULL\n AND granted_at IS NOT NULL\n )" + }, + "credential_managed_oauth_group_binding_check": { + "name": "credential_managed_oauth_group_binding_check", + "value": "(type::text <> 'managed_oauth') OR (\n credential_group_enrollment_id IS NOT NULL\n AND credential_group_option_id IS NOT NULL\n AND managed_oauth_scope_version IS NOT NULL\n AND managed_oauth_scope_version > 0\n )" + }, + "credential_managed_mcp_source_check": { + "name": "credential_managed_mcp_source_check", + "value": "(type::text <> 'managed_mcp') OR (\n id LIKE 'mcp-cg-%'\n AND account_id IS NULL\n AND provider_id IS NULL\n AND authorization_app_id IS NULL\n AND credential_group_enrollment_id IS NOT NULL\n AND credential_group_option_id IS NULL\n AND mcp_server_id IS NOT NULL\n AND managed_oauth_status IS NOT NULL\n AND (managed_oauth_status <> 'active' OR (\n encrypted_oauth_token_set IS NOT NULL\n AND mcp_tools IS NOT NULL\n ))\n AND granted_at IS NOT NULL\n AND managed_oauth_scope_version IS NULL\n AND provider_subject_id IS NULL\n AND provider_tenant_id IS NULL\n AND granted_scopes IS NULL\n AND provider_metadata IS NULL\n AND created_by IS NULL\n AND env_key IS NULL\n AND env_owner_user_id IS NULL\n AND encrypted_service_account_key IS NULL\n AND unredacted = false\n )" + }, + "credential_creator_source_check": { + "name": "credential_creator_source_check", + "value": "(type::text = 'managed_mcp') OR created_by IS NOT NULL" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_group": { + "name": "credential_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_id": { + "name": "public_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 + }, + "options": { + "name": "options", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "encrypted_provider_configuration": { + "name": "encrypted_provider_configuration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "credential_group_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "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": { + "credential_group_public_id_unique": { + "name": "credential_group_public_id_unique", + "columns": [ + { + "expression": "public_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_workspace_status_idx": { + "name": "credential_group_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_workspace_name_unique": { + "name": "credential_group_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"name\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_group_workspace_id_workspace_id_fk": { + "name": "credential_group_workspace_id_workspace_id_fk", + "tableFrom": "credential_group", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_created_by_user_id_fk": { + "name": "credential_group_created_by_user_id_fk", + "tableFrom": "credential_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_group_enrollment": { + "name": "credential_group_enrollment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_group_id": { + "name": "credential_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "credential_group_enrollment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'invited'" + }, + "invitation_token_hash": { + "name": "invitation_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invitation_expires_at": { + "name": "invitation_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "invited_at": { + "name": "invited_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_delivery_error": { + "name": "last_delivery_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_group_enrollment_group_email_unique": { + "name": "credential_group_enrollment_group_email_unique", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_invitation_token_hash_unique": { + "name": "credential_group_enrollment_invitation_token_hash_unique", + "columns": [ + { + "expression": "invitation_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_group_status_idx": { + "name": "credential_group_enrollment_group_status_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_group_invited_at_id_idx": { + "name": "credential_group_enrollment_group_invited_at_id_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invited_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_group_enrollment_credential_group_id_credential_group_id_fk": { + "name": "credential_group_enrollment_credential_group_id_credential_group_id_fk", + "tableFrom": "credential_group_enrollment", + "tableTo": "credential_group", + "columnsFrom": ["credential_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_enrollment_created_by_user_id_fk": { + "name": "credential_group_enrollment_created_by_user_id_fk", + "tableFrom": "credential_group_enrollment", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_group_enrollment_normalized_email_check": { + "name": "credential_group_enrollment_normalized_email_check", + "value": "\"credential_group_enrollment\".\"email\" = lower(btrim(\"credential_group_enrollment\".\"email\")) AND length(\"credential_group_enrollment\".\"email\") BETWEEN 3 AND 320" + }, + "credential_group_enrollment_invitation_token_hash_length_check": { + "name": "credential_group_enrollment_invitation_token_hash_length_check", + "value": "length(\"credential_group_enrollment\".\"invitation_token_hash\") = 64" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_block": { + "name": "custom_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "trace_child_runs": { + "name": "trace_child_runs", + "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": { + "custom_block_organization_id_idx": { + "name": "custom_block_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_workflow_id_idx": { + "name": "custom_block_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_organization_type_unique": { + "name": "custom_block_organization_type_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_block_organization_id_organization_id_fk": { + "name": "custom_block_organization_id_organization_id_fk", + "tableFrom": "custom_block", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_workflow_id_workflow_id_fk": { + "name": "custom_block_workflow_id_workflow_id_fk", + "tableFrom": "custom_block", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_created_by_user_id_fk": { + "name": "custom_block_created_by_user_id_fk", + "tableFrom": "custom_block", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_attempts": { + "name": "processing_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_queued_at": { + "name": "processing_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_queue_token": { + "name": "processing_queue_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_deferred_until": { + "name": "processing_deferred_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_active_kb_token_count_idx": { + "name": "doc_active_kb_token_count_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "token_count", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"user_excluded\" = false AND \"document\".\"archived_at\" IS NULL AND \"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_secret_provenance": { + "name": "document_secret_provenance", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "document_secret_provenance_document_id_document_id_fk": { + "name": "document_secret_provenance_document_id_document_id_fk", + "tableFrom": "document_secret_provenance", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "document_secret_provenance_status_check": { + "name": "document_secret_provenance_status_check", + "value": "\"document_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "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 + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "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.embedding_secret_provenance": { + "name": "embedding_secret_provenance", + "schema": "", + "columns": { + "embedding_id": { + "name": "embedding_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "embedding_secret_provenance_embedding_id_embedding_id_fk": { + "name": "embedding_secret_provenance_embedding_id_embedding_id_fk", + "tableFrom": "embedding_secret_provenance", + "tableTo": "embedding", + "columnsFrom": ["embedding_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_secret_provenance_status_check": { + "name": "embedding_secret_provenance_status_check", + "value": "\"embedding_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_references_workflow_id_idx": { + "name": "execution_large_value_references_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": ["key", "execution_id", "source"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_workflow_id_idx": { + "name": "execution_large_values_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.folder": { + "name": "folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "folder_resource_type", + "typeSchema": "public", + "primaryKey": false, + "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 + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "folder_user_idx": { + "name": "folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_idx": { + "name": "folder_workspace_resource_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_parent_sort_idx": { + "name": "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": {} + }, + "folder_deleted_at_idx": { + "name": "folder_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_deleted_partial_idx": { + "name": "folder_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"folder\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_name_active_unique": { + "name": "folder_workspace_resource_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"folder\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "folder_user_id_user_id_fk": { + "name": "folder_user_id_user_id_fk", + "tableFrom": "folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_workspace_id_workspace_id_fk": { + "name": "folder_workspace_id_workspace_id_fk", + "tableFrom": "folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_parent_id_folder_id_fk": { + "name": "folder_parent_id_folder_id_fk", + "tableFrom": "folder", + "tableTo": "folder", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "invitation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "membership_intent": { + "name": "membership_intent", + "type": "invitation_membership_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_status_idx": { + "name": "invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_pending_email_org_unique": { + "name": "invitation_pending_email_org_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"invitation\".\"status\" = 'pending' AND \"invitation\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitation_token_unique": { + "name": "invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_workspace_grant": { + "name": "invitation_workspace_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_workspace_grant_unique": { + "name": "invitation_workspace_grant_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_workspace_grant_workspace_id_idx": { + "name": "invitation_workspace_grant_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_grant_invitation_id_invitation_id_fk": { + "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "invitation", + "columnsFrom": ["invitation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_workspace_grant_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_ended_at_id_idx": { + "name": "job_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workflow_schedule", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_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_folder_id_idx": { + "name": "kb_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_folder_id_folder_id_fk": { + "name": "knowledge_base_folder_id_folder_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sync_lock_token": { + "name": "sync_lock_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_lock_lease_at": { + "name": "sync_lock_lease_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()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_skipped": { + "name": "docs_skipped", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_started_at_idx": { + "name": "kcsl_connector_started_at_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcsl_started_at_partial_idx": { + "name": "kcsl_started_at_partial_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector_sync_log\".\"status\" = 'started'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_group_id": { + "name": "credential_group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_connector_id": { + "name": "managed_connector_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 + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_credential_group_idx": { + "name": "mcp_servers_credential_group_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_credential_group_managed_connector_unique": { + "name": "mcp_servers_credential_group_managed_connector_unique", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "managed_connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"mcp_servers\".\"credential_group_id\" IS NOT NULL AND \"mcp_servers\".\"managed_connector_id\" IS NOT NULL AND \"mcp_servers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_credential_group_id_credential_group_id_fk": { + "name": "mcp_servers_credential_group_id_credential_group_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "credential_group", + "columnsFrom": ["credential_group_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "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": { + "mcp_servers_credential_group_managed_connector_check": { + "name": "mcp_servers_credential_group_managed_connector_check", + "value": "\"mcp_servers\".\"credential_group_id\" IS NULL OR \"mcp_servers\".\"managed_connector_id\" IS NOT NULL" + }, + "mcp_servers_managed_connector_oauth_check": { + "name": "mcp_servers_managed_connector_oauth_check", + "value": "\"mcp_servers\".\"managed_connector_id\" IS NULL OR \"mcp_servers\".\"auth_type\" = 'oauth'" + } + }, + "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 + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory_secret_provenance": { + "name": "memory_secret_provenance", + "schema": "", + "columns": { + "memory_id": { + "name": "memory_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "memory_secret_provenance_memory_id_memory_id_fk": { + "name": "memory_secret_provenance_memory_id_memory_id_fk", + "tableFrom": "memory_secret_provenance", + "tableTo": "memory", + "columnsFrom": ["memory_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "memory_secret_provenance_status_check": { + "name": "memory_secret_provenance_status_check", + "value": "\"memory_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "session_policy_settings": { + "name": "session_policy_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "security_policy_version": { + "name": "security_policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_byok_keys": { + "name": "organization_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_byok_organization_provider_idx": { + "name": "organization_byok_organization_provider_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_byok_keys_organization_id_organization_id_fk": { + "name": "organization_byok_keys_organization_id_organization_id_fk", + "tableFrom": "organization_byok_keys", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_byok_keys_created_by_user_id_fk": { + "name": "organization_byok_keys_created_by_user_id_fk", + "tableFrom": "organization_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_member_usage_limit": { + "name": "organization_member_usage_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_member_usage_limit_org_user_unique": { + "name": "org_member_usage_limit_org_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_member_usage_limit_organization_id_idx": { + "name": "org_member_usage_limit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_member_usage_limit_organization_id_organization_id_fk": { + "name": "organization_member_usage_limit_organization_id_organization_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["set_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_type_created_idx": { + "name": "outbox_event_type_created_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "automatic_resume_retry_count": { + "name": "automatic_resume_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_name_unique": { + "name": "permission_group_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_workspace": { + "name": "permission_group_workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_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.pinned_item": { + "name": "pinned_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pinned_item_user_workspace_idx": { + "name": "pinned_item_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": {} + }, + "pinned_item_resource_idx": { + "name": "pinned_item_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": {} + }, + "pinned_item_user_resource_unique": { + "name": "pinned_item_user_resource_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pinned_item_user_id_user_id_fk": { + "name": "pinned_item_user_id_user_id_fk", + "tableFrom": "pinned_item", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pinned_item_workspace_id_workspace_id_fk": { + "name": "pinned_item_workspace_id_workspace_id_fk", + "tableFrom": "pinned_item", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.public_share": { + "name": "public_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "public_share_token_unique": { + "name": "public_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_unique": { + "name": "public_share_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resource_policy": { + "name": "resource_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "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": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "document": { + "name": "document", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_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": { + "resource_policy_resource_unique": { + "name": "resource_policy_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resource_policy_workspace_id_idx": { + "name": "resource_policy_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resource_policy_workspace_id_workspace_id_fk": { + "name": "resource_policy_workspace_id_workspace_id_fk", + "tableFrom": "resource_policy", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "resource_policy_created_by_user_id_fk": { + "name": "resource_policy_created_by_user_id_fk", + "tableFrom": "resource_policy", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "resource_policy_updated_by_user_id_fk": { + "name": "resource_policy_updated_by_user_id_fk", + "tableFrom": "resource_policy", + "tableTo": "user", + "columnsFrom": ["updated_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandbox_image": { + "name": "sandbox_image", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec": { + "name": "spec", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "sandbox_image_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "image_ref": { + "name": "image_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_image_id": { + "name": "provider_image_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_id": { + "name": "build_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "materialization_generation": { + "name": "materialization_generation", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_detail": { + "name": "error_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_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": { + "sandbox_image_provider_spec_unique": { + "name": "sandbox_image_provider_spec_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_status_idx": { + "name": "sandbox_image_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_last_used_idx": { + "name": "sandbox_image_last_used_idx", + "columns": [ + { + "expression": "last_used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.secret_usage": { + "name": "secret_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_name": { + "name": "secret_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_scope": { + "name": "secret_scope", + "type": "secret_usage_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "secret_owner_user_id": { + "name": "secret_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "source": { + "name": "source", + "type": "secret_usage_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "usage_date": { + "name": "usage_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "use_count": { + "name": "use_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_execution_id": { + "name": "last_execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_trigger": { + "name": "last_trigger", + "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": { + "secret_usage_bucket_unique": { + "name": "secret_usage_bucket_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "usage_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "secret_usage_secret_recent_idx": { + "name": "secret_usage_secret_recent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_used_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "secret_usage_workspace_id_workspace_id_fk": { + "name": "secret_usage_workspace_id_workspace_id_fk", + "tableFrom": "secret_usage", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auto_focus_on_click": { + "name": "auto_focus_on_click", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_member": { + "name": "skill_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": { + "skill_member_user_id_idx": { + "name": "skill_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skill_member_unique": { + "name": "skill_member_unique", + "columns": [ + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_member_skill_id_skill_id_fk": { + "name": "skill_member_skill_id_skill_id_fk", + "tableFrom": "skill_member", + "tableTo": "skill", + "columnsFrom": ["skill_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_user_id_user_id_fk": { + "name": "skill_member_user_id_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_invited_by_user_id_fk": { + "name": "skill_member_invited_by_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_domain": { + "name": "sso_domain", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sso_domain_organization_id_idx": { + "name": "sso_domain_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_domain_idx": { + "name": "sso_domain_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_org_domain_unique": { + "name": "sso_domain_org_domain_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_verified_unique": { + "name": "sso_domain_verified_unique", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'verified'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_domain_organization_id_organization_id_fk": { + "name": "sso_domain_organization_id_organization_id_fk", + "tableFrom": "sso_domain", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_domain_created_by_user_id_fk": { + "name": "sso_domain_created_by_user_id_fk", + "tableFrom": "sso_domain", + "tableTo": "user", + "columnsFrom": ["created_by"], + "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 + }, + "domain_verified": { + "name": "domain_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "jit_provisioning_enabled": { + "name": "jit_provisioning_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "sso_provider_provider_id_unique": { + "name": "sso_provider_provider_id_unique", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "last_closed_period_start": { + "name": "last_closed_period_start", + "type": "timestamp", + "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": {} + }, + "subscription_cycle_close_lagging_idx": { + "name": "subscription_cycle_close_lagging_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"subscription\".\"status\" in ('active', 'past_due') and \"subscription\".\"period_start\" is not null and (\"subscription\".\"last_closed_period_start\" is null or \"subscription\".\"last_closed_period_start\" < \"subscription\".\"period_start\")", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_jobs": { + "name": "table_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "capability_governed_user_id": { + "name": "capability_governed_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enrichment_details": { + "name": "enrichment_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_capability_governed_user_id_user_id_fk": { + "name": "table_row_executions_capability_governed_user_id_user_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user", + "columnsFrom": ["capability_governed_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capability_governed_user_id": { + "name": "capability_governed_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_governed_active_idx": { + "name": "table_run_dispatches_governed_active_idx", + "columns": [ + { + "expression": "capability_governed_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_run_dispatches\".\"status\" IN ('pending', 'dispatching')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "table_run_dispatches_capability_governed_user_id_user_id_fk": { + "name": "table_run_dispatches_capability_governed_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["capability_governed_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_views": { + "name": "table_views", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "is_default": { + "name": "is_default", + "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": { + "table_views_table_created_idx": { + "name": "table_views_table_created_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_views_workspace_created_idx": { + "name": "table_views_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_views_table_default_unique": { + "name": "table_views_table_default_unique", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_views_table_id_user_table_definitions_id_fk": { + "name": "table_views_table_id_user_table_definitions_id_fk", + "tableFrom": "table_views", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_workspace_id_workspace_id_fk": { + "name": "table_views_workspace_id_workspace_id_fk", + "tableFrom": "table_views", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_created_by_user_id_fk": { + "name": "table_views_created_by_user_id_fk", + "tableFrom": "table_views", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.upload_session": { + "name": "upload_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "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 + }, + "knowledge_base_id": { + "name": "knowledge_base_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 + }, + "purpose": { + "name": "purpose", + "type": "upload_session_purpose", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "upload_session_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "storage_context": { + "name": "storage_context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "final_key": { + "name": "final_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_provider": { + "name": "storage_provider", + "type": "upload_session_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider_upload_id": { + "name": "provider_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_object_version": { + "name": "provider_object_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "part_size": { + "name": "part_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "part_count": { + "name": "part_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "upload_session_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'uploading'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "processing_lease_id": { + "name": "processing_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_lease_expires_at": { + "name": "processing_lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_file_id": { + "name": "completed_file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "upload_session_token_hash_unique": { + "name": "upload_session_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upload_session_final_key_unique": { + "name": "upload_session_final_key_unique", + "columns": [ + { + "expression": "final_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upload_session_status_expires_at_idx": { + "name": "upload_session_status_expires_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_period_cost_idx": { + "name": "usage_log_billing_period_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_created_at_cost_idx": { + "name": "usage_log_billing_entity_created_at_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "folder_id": { + "name": "folder_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 + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_version": { + "name": "rows_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "schema_locked": { + "name": "schema_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "insert_locked": { + "name": "insert_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "update_locked": { + "name": "update_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "delete_locked": { + "name": "delete_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_folder_id_idx": { + "name": "user_table_def_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_folder_id_folder_id_fk": { + "name": "user_table_definitions_folder_id_folder_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_row_secret_provenance": { + "name": "user_table_row_secret_provenance", + "schema": "", + "columns": { + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_table_row_secret_provenance_row_id_user_table_rows_id_fk": { + "name": "user_table_row_secret_provenance_row_id_user_table_rows_id_fk", + "tableFrom": "user_table_row_secret_provenance", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_table_row_secret_provenance_status_check": { + "name": "user_table_row_secret_provenance_status_check", + "value": "\"user_table_row_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_created_id_idx": { + "name": "user_table_rows_table_created_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_status": { + "name": "registration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_generation": { + "name": "registration_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config_fingerprint": { + "name": "config_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prepared_at": { + "name": "prepared_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_key": { + "name": "routing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_routing_key_active_idx": { + "name": "webhook_routing_key_active_idx", + "columns": [ + { + "expression": "routing_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NULL AND \"webhook\".\"routing_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_active_registration_unique": { + "name": "webhook_active_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'active' AND \"webhook\".\"block_id\" IS NOT NULL AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_candidate_registration_unique": { + "name": "webhook_candidate_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'candidate' AND \"webhook\".\"block_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_registration_status_generation_idx": { + "name": "webhook_registration_status_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_generation", + "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" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_registration_status_check": { + "name": "webhook_registration_status_check", + "value": "\"webhook\".\"registration_status\" IS NULL OR \"webhook\".\"registration_status\" IN ('active', 'candidate', 'retired', 'orphaned')" + }, + "webhook_registration_generation_check": { + "name": "webhook_registration_generation_check", + "value": "\"webhook\".\"registration_generation\" IS NULL OR \"webhook\".\"registration_generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.webhook_path_claim": { + "name": "webhook_path_claim", + "schema": "", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "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": { + "webhook_path_claim_workflow_idx": { + "name": "webhook_path_claim_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_path_claim_workflow_id_workflow_id_fk": { + "name": "webhook_path_claim_workflow_id_workflow_id_fk", + "tableFrom": "webhook_path_claim", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_path_claim_generation_check": { + "name": "webhook_path_claim_generation_check", + "value": "\"webhook_path_claim\".\"generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fork_sync_excluded": { + "name": "fork_sync_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_active_workspace_sort_idx": { + "name": "workflow_active_workspace_sort_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_folder_id_fk": { + "name": "workflow_folder_id_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "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 + }, + "error_enabled": { + "name": "error_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "retry": { + "name": "retry", + "type": "jsonb", + "primaryKey": false, + "notNull": 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_operation": { + "name": "workflow_deployment_operation", + "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": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_active_version_id": { + "name": "previous_active_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preparing'" + }, + "component_readiness": { + "name": "component_readiness", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_deployment_operation_workflow_generation_unique": { + "name": "workflow_deployment_operation_workflow_generation_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_idempotency_unique": { + "name": "workflow_deployment_operation_workflow_idempotency_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_in_flight_unique": { + "name": "workflow_deployment_operation_workflow_in_flight_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_status_idx": { + "name": "workflow_deployment_operation_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_deployment_version_idx": { + "name": "workflow_deployment_operation_deployment_version_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_version_generation_idx": { + "name": "workflow_deployment_operation_workflow_version_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_operation_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_operation_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["previous_active_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_deployment_operation_action_check": { + "name": "workflow_deployment_operation_action_check", + "value": "\"workflow_deployment_operation\".\"action\" IN ('deploy', 'activate')" + }, + "workflow_deployment_operation_status_check": { + "name": "workflow_deployment_operation_status_check", + "value": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating', 'active', 'failed', 'superseded')" + }, + "workflow_deployment_operation_generation_check": { + "name": "workflow_deployment_operation_generation_check", + "value": "\"workflow_deployment_operation\".\"generation\" > 0" + }, + "workflow_deployment_operation_protocol_version_check": { + "name": "workflow_deployment_operation_protocol_version_check", + "value": "\"workflow_deployment_operation\".\"protocol_version\" > 0" + } + }, + "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 + }, + "execution_deadline_at": { + "name": "execution_deadline_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_id_desc_idx": { + "name": "workflow_execution_logs_workspace_started_at_id_desc_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_deadline_idx": { + "name": "workflow_execution_logs_running_deadline_idx", + "columns": [ + { + "expression": "execution_deadline_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'running' AND \"workflow_execution_logs\".\"execution_deadline_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_redacting_started_at_idx": { + "name": "workflow_execution_logs_redacting_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'redacting'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_redacting_deadline_idx": { + "name": "workflow_execution_logs_redacting_deadline_idx", + "columns": [ + { + "expression": "execution_deadline_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'redacting' AND \"workflow_execution_logs\".\"execution_deadline_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_completed_ended_at_idx": { + "name": "workflow_execution_logs_completed_ended_at_idx", + "columns": [ + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'completed' AND \"workflow_execution_logs\".\"level\" = 'info' AND \"workflow_execution_logs\".\"ended_at\" IS NOT NULL", + "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_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_workspace_deleted_partial_idx": { + "name": "workflow_mcp_server_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "parameter_description_overrides": { + "name": "parameter_description_overrides", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_operation_id": { + "name": "deployment_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_scope": { + "name": "secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "mounted_secrets": { + "name": "mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "excluded_dates": { + "name": "excluded_dates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk": { + "name": "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_operation", + "columnsFrom": ["deployment_operation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_secret_scope": { + "name": "inbox_secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "inbox_mounted_secrets": { + "name": "inbox_mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "organization_assigned_at": { + "name": "organization_assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "forked_from_workspace_id": { + "name": "forked_from_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_forked_from_workspace_id_idx": { + "name": "workspace_forked_from_workspace_id_idx", + "columns": [ + { + "expression": "forked_from_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_inbox_provider_id_idx": { + "name": "workspace_inbox_provider_id_idx", + "columns": [ + { + "expression": "inbox_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace\".\"inbox_provider_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_forked_from_workspace_id_workspace_id_fk": { + "name": "workspace_forked_from_workspace_id_workspace_id_fk", + "tableFrom": "workspace", + "tableTo": "workspace", + "columnsFrom": ["forked_from_workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_storage_used_bytes_non_negative": { + "name": "workspace_storage_used_bytes_non_negative", + "value": "\"workspace\".\"storage_used_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_workspace_provider_idx": { + "name": "workspace_byok_workspace_provider_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_collab_state": { + "name": "workspace_file_collab_state", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "doc_state": { + "name": "doc_state", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_collab_state_file_id_workspace_files_id_fk": { + "name": "workspace_file_collab_state_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_collab_state", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_backfill": { + "name": "workspace_file_search_backfill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "after_workspace_id": { + "name": "after_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "after_file_id": { + "name": "after_file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_dispatch_queue": { + "name": "workspace_file_search_dispatch_queue", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enqueued_at": { + "name": "enqueued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_dispatched_at": { + "name": "last_dispatched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_search_dispatch_queue_schedule_idx": { + "name": "workspace_file_search_dispatch_queue_schedule_idx", + "columns": [ + { + "expression": "last_dispatched_at", + "isExpression": false, + "asc": true, + "nulls": "first" + }, + { + "expression": "enqueued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_queue_workspace_fk": { + "name": "workspace_file_search_queue_workspace_fk", + "tableFrom": "workspace_file_search_dispatch_queue", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_index": { + "name": "workspace_file_search_index", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_content_updated_at": { + "name": "source_content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "workspace_file_search_index_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "partial": { + "name": "partial", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "line_count": { + "name": "line_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "indexed_bytes": { + "name": "indexed_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "dispatched_at": { + "name": "dispatched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_search_index_workspace_status_idx": { + "name": "workspace_file_search_index_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_index_pending_dispatch_idx": { + "name": "workspace_file_search_index_pending_dispatch_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_index\".\"status\" = 'pending' AND \"workspace_file_search_index\".\"dispatched_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_index_active_dispatch_idx": { + "name": "workspace_file_search_index_active_dispatch_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dispatched_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_index\".\"status\" = 'pending' AND \"workspace_file_search_index\".\"dispatched_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_index_file_fk": { + "name": "workspace_file_search_index_file_fk", + "tableFrom": "workspace_file_search_index", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_search_index_workspace_fk": { + "name": "workspace_file_search_index_workspace_fk", + "tableFrom": "workspace_file_search_index", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_file_search_index_pk": { + "name": "workspace_file_search_index_pk", + "columns": ["file_id", "source_content_updated_at"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_segment": { + "name": "workspace_file_search_segment", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_content_updated_at": { + "name": "source_content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "line_number": { + "name": "line_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "segment_number": { + "name": "segment_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "segment_start": { + "name": "segment_start", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "line_length": { + "name": "line_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "workspace_file_search_segment_workspace_revision_idx": { + "name": "workspace_file_search_segment_workspace_revision_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_segment_workspace_content_trgm_idx": { + "name": "workspace_file_search_segment_workspace_content_trgm_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "content", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_segment_file_fk": { + "name": "workspace_file_search_segment_file_fk", + "tableFrom": "workspace_file_search_segment", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_search_segment_workspace_fk": { + "name": "workspace_file_search_segment_workspace_fk", + "tableFrom": "workspace_file_search_segment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_file_search_segment_pk": { + "name": "workspace_file_search_segment_pk", + "columns": ["file_id", "source_content_updated_at", "line_number", "segment_number"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_secret_provenance": { + "name": "workspace_file_secret_provenance", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_secret_provenance_file_id_workspace_files_id_fk": { + "name": "workspace_file_secret_provenance_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_secret_provenance", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_file_secret_provenance_status_check": { + "name": "workspace_file_secret_provenance_status_check", + "value": "\"workspace_file_secret_provenance\".\"status\" IN ('exact', 'unknown', 'unrecorded')" + } + }, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_folder_id_folder_id_fk": { + "name": "workspace_files_folder_id_folder_id_fk", + "tableFrom": "workspace_files", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_block_map": { + "name": "workspace_fork_block_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_workflow_id": { + "name": "parent_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_block_id": { + "name": "parent_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_workflow_id": { + "name": "child_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_block_id": { + "name": "child_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_block_map_child_ws_parent_unique": { + "name": "workspace_fork_block_map_child_ws_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_unique": { + "name": "workspace_fork_block_map_child_ws_child_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_parent_wf_idx": { + "name": "workspace_fork_block_map_child_ws_parent_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_wf_idx": { + "name": "workspace_fork_block_map_child_ws_child_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_block_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_block_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_block_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_dependent_value": { + "name": "workspace_fork_dependent_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_block_key": { + "name": "sub_block_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_dependent_value_child_ws_wf_idx": { + "name": "workspace_fork_dependent_value_child_ws_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_dependent_value_field_unique": { + "name": "workspace_fork_dependent_value_field_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sub_block_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_dependent_value", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_promote_run": { + "name": "workspace_fork_promote_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workspace_id": { + "name": "target_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "workspace_fork_promote_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_promote_run_child_ws_target_unique": { + "name": "workspace_fork_promote_run_child_ws_target_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_promote_run_target_ws_idx": { + "name": "workspace_fork_promote_run_target_ws_idx", + "columns": [ + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_promote_run_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_promote_run_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_promote_run_created_by_user_id_fk": { + "name": "workspace_fork_promote_run_created_by_user_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_resource_map": { + "name": "workspace_fork_resource_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "workspace_fork_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_resource_id": { + "name": "parent_resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_resource_id": { + "name": "child_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_resource_map_child_ws_idx": { + "name": "workspace_fork_resource_map_child_ws_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_ws_type_idx": { + "name": "workspace_fork_resource_map_child_ws_type_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_type_parent_unique": { + "name": "workspace_fork_resource_map_child_type_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_resource_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_resource_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_resource_map_created_by_user_id_fk": { + "name": "workspace_fork_resource_map_created_by_user_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_sandbox": { + "name": "workspace_sandbox", + "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 + }, + "language": { + "name": "language", + "type": "sandbox_language", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "dependencies": { + "name": "dependencies", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "cli_tools": { + "name": "cli_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "system_packages": { + "name": "system_packages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "spec_hash": { + "name": "spec_hash", + "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_sandbox_workspace_name_unique": { + "name": "workspace_sandbox_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": {} + }, + "workspace_sandbox_workspace_idx": { + "name": "workspace_sandbox_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_spec_hash_idx": { + "name": "workspace_sandbox_spec_hash_idx", + "columns": [ + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_sandbox_workspace_id_workspace_id_fk": { + "name": "workspace_sandbox_workspace_id_workspace_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_sandbox_created_by_user_id_fk": { + "name": "workspace_sandbox_created_by_user_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.background_work_kind": { + "name": "background_work_kind", + "schema": "public", + "values": ["deployment_side_effects", "fork_content_copy", "fork_sync", "fork_rollback"] + }, + "public.background_work_status_value": { + "name": "background_work_status_value", + "schema": "public", + "values": ["pending", "processing", "completed", "completed_with_warnings", "failed"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.copilot_tool_permission_decision": { + "name": "copilot_tool_permission_decision", + "schema": "public", + "values": ["allow", "allow_chat", "always_allow", "skip"] + }, + "public.credential_group_enrollment_status": { + "name": "credential_group_enrollment_status", + "schema": "public", + "values": ["invited", "delivery_failed", "in_progress", "completed", "revoked"] + }, + "public.credential_group_status": { + "name": "credential_group_status", + "schema": "public", + "values": ["active", "disabled"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": [ + "oauth", + "managed_oauth", + "managed_mcp", + "env_workspace", + "env_personal", + "service_account" + ] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.folder_resource_type": { + "name": "folder_resource_type", + "schema": "public", + "values": ["workflow", "file", "knowledge_base", "table"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.managed_oauth_credential_status": { + "name": "managed_oauth_credential_status", + "schema": "public", + "values": ["active", "needs_reauth", "revoked"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.sandbox_image_status": { + "name": "sandbox_image_status", + "schema": "public", + "values": ["pending", "building", "ready", "failed"] + }, + "public.sandbox_language": { + "name": "sandbox_language", + "schema": "public", + "values": ["javascript", "python"] + }, + "public.secret_usage_scope": { + "name": "secret_usage_scope", + "schema": "public", + "values": ["workspace", "personal"] + }, + "public.secret_usage_source": { + "name": "secret_usage_source", + "schema": "public", + "values": ["workflow", "copilot", "mcp"] + }, + "public.upload_session_method": { + "name": "upload_session_method", + "schema": "public", + "values": ["put", "multipart"] + }, + "public.upload_session_provider": { + "name": "upload_session_provider", + "schema": "public", + "values": ["local", "s3", "blob", "gcs"] + }, + "public.upload_session_purpose": { + "name": "upload_session_purpose", + "schema": "public", + "values": [ + "workspace_file", + "table_import", + "knowledge_document", + "profile_picture", + "workspace_logo", + "mothership_attachment", + "execution_attachment" + ] + }, + "public.upload_session_status": { + "name": "upload_session_status", + "schema": "public", + "values": [ + "uploading", + "completing", + "finalizing", + "completed", + "aborting", + "aborted", + "failed", + "expired" + ] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool", "model_unbilled"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment", + "voice-output", + "api-tool" + ] + }, + "public.workspace_file_search_index_status": { + "name": "workspace_file_search_index_status", + "schema": "public", + "values": ["pending", "ready", "skipped", "failed"] + }, + "public.workspace_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": ["push", "pull"] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "mcp_server", + "workflow_mcp_server", + "custom_block", + "custom_tool", + "skill" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index ac3f8cd70f3..ccaaab0e94e 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -2220,6 +2220,13 @@ "when": 1788304042423, "tag": "0317_giant_kitty_pryde", "breakpoints": true + }, + { + "idx": 318, + "version": "7", + "when": 1788313105264, + "tag": "0318_credential_group_managed_mcp", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 33e9ab77a15..446ed0bbe26 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -3634,6 +3634,11 @@ export const mcpServers = pgTable( workspaceId: text('workspace_id') .notNull() .references(() => workspace.id, { onDelete: 'cascade' }), + credentialGroupId: text('credential_group_id').references( + (): AnyPgColumn => credentialGroup.id, + { onDelete: 'set null' } + ), + managedConnectorId: text('managed_connector_id'), // Track who created the server, but workspace owns it createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }), @@ -3679,6 +3684,22 @@ export const mcpServers = pgTable( table.workspaceId, table.enabled ), + credentialGroupIdx: index('mcp_servers_credential_group_idx').on(table.credentialGroupId), + credentialGroupManagedConnectorUnique: uniqueIndex( + 'mcp_servers_credential_group_managed_connector_unique' + ) + .on(table.credentialGroupId, table.managedConnectorId) + .where( + sql`${table.credentialGroupId} IS NOT NULL AND ${table.managedConnectorId} IS NOT NULL AND ${table.deletedAt} IS NULL` + ), + credentialGroupManagedConnectorCheck: check( + 'mcp_servers_credential_group_managed_connector_check', + sql`${table.credentialGroupId} IS NULL OR ${table.managedConnectorId} IS NOT NULL` + ), + managedConnectorOauthCheck: check( + 'mcp_servers_managed_connector_oauth_check', + sql`${table.managedConnectorId} IS NULL OR ${table.authType} = 'oauth'` + ), // Soft delete pattern - workspace + not deleted (partial: only deleted rows) workspaceDeletedIdx: index('mcp_servers_workspace_deleted_partial_idx') @@ -4136,6 +4157,7 @@ export const usageLog = pgTable( export const credentialTypeEnum = pgEnum('credential_type', [ 'oauth', 'managed_oauth', + 'managed_mcp', 'env_workspace', 'env_personal', 'service_account', @@ -4155,6 +4177,12 @@ export interface ManagedOAuthProviderMetadata { tenantDisplayName?: string } +export interface ManagedMcpToolSnapshot { + name: string + description?: string + inputSchema: Record +} + export const credential = pgTable( 'credential', { @@ -4183,6 +4211,9 @@ export const credential = pgTable( { onDelete: 'cascade' } ), credentialGroupOptionId: text('credential_group_option_id'), + mcpServerId: text('mcp_server_id').references(() => mcpServers.id, { + onDelete: 'cascade', + }), managedOauthScopeVersion: integer('managed_oauth_scope_version'), providerSubjectId: text('provider_subject_id'), providerTenantId: text('provider_tenant_id'), @@ -4190,14 +4221,14 @@ export const credential = pgTable( grantedScopes: text('granted_scopes').array(), providerMetadata: jsonb('provider_metadata').$type(), encryptedOauthTokenSet: text('encrypted_oauth_token_set'), + mcpTools: jsonb('mcp_tools').$type(), + mcpToolsRefreshedAt: timestamp('mcp_tools_refreshed_at'), grantedAt: timestamp('granted_at'), revokedAt: timestamp('revoked_at'), accessTokenExpiresAt: timestamp('access_token_expires_at'), refreshTokenExpiresAt: timestamp('refresh_token_expires_at'), lastRefreshedAt: timestamp('last_refreshed_at'), - createdBy: text('created_by') - .notNull() - .references(() => user.id, { onDelete: 'cascade' }), + createdBy: text('created_by').references(() => user.id, { onDelete: 'cascade' }), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), }, @@ -4210,9 +4241,13 @@ export const credential = pgTable( credentialGroupEnrollmentIdx: index('credential_group_enrollment_idx').on( table.credentialGroupEnrollmentId ), + mcpServerIdx: index('credential_mcp_server_idx').on(table.mcpServerId), credentialGroupOptionUnique: uniqueIndex('credential_group_option_unique') .on(table.credentialGroupEnrollmentId, table.credentialGroupOptionId) .where(sql`${table.type} = 'managed_oauth'`), + managedMcpEnrollmentServerUnique: uniqueIndex('credential_managed_mcp_enrollment_server_unique') + .on(table.credentialGroupEnrollmentId, table.mcpServerId) + .where(sql`${table.type} = 'managed_mcp'`), workspaceAccountUnique: uniqueIndex('credential_workspace_account_unique') .on(table.workspaceId, table.accountId) .where(sql`account_id IS NOT NULL`), @@ -4249,6 +4284,38 @@ export const credential = pgTable( AND managed_oauth_scope_version > 0 )` ), + managedMcpSourceConstraint: check( + 'credential_managed_mcp_source_check', + sql`(type::text <> 'managed_mcp') OR ( + id LIKE 'mcp-cg-%' + AND account_id IS NULL + AND provider_id IS NULL + AND authorization_app_id IS NULL + AND credential_group_enrollment_id IS NOT NULL + AND credential_group_option_id IS NULL + AND mcp_server_id IS NOT NULL + AND managed_oauth_status IS NOT NULL + AND (managed_oauth_status <> 'active' OR ( + encrypted_oauth_token_set IS NOT NULL + AND mcp_tools IS NOT NULL + )) + AND granted_at IS NOT NULL + AND managed_oauth_scope_version IS NULL + AND provider_subject_id IS NULL + AND provider_tenant_id IS NULL + AND granted_scopes IS NULL + AND provider_metadata IS NULL + AND created_by IS NULL + AND env_key IS NULL + AND env_owner_user_id IS NULL + AND encrypted_service_account_key IS NULL + AND unredacted = false + )` + ), + creatorSourceConstraint: check( + 'credential_creator_source_check', + sql`(type::text = 'managed_mcp') OR created_by IS NOT NULL` + ), workspaceEnvSourceConstraint: check( 'credential_workspace_env_source_check', sql`(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)` diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index e83c016c873..065ceb82401 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -389,6 +389,7 @@ type ApplyWorkflowOperationsBodyRef2 = | ApplyWorkflowOperationsBodyRef3 | ApplyWorkflowOperationsBodyRef4 | ApplyWorkflowOperationsBodyRef5 + | ApplyWorkflowOperationsBodyRef6 type ApplyWorkflowOperationsBodyRef3 = { type: string @@ -426,6 +427,14 @@ type ApplyWorkflowOperationsBodyRef5 = { usageControl?: 'auto' | 'force' | 'none' } +type ApplyWorkflowOperationsBodyRef6 = { + type: 'mcp-server-advanced' + params: { + serverId: string + } + usageControl?: 'auto' | 'force' | 'none' +} + export type ApplyWorkflowOperationsBody = { operations: Array atomic?: boolean diff --git a/packages/testing/src/mocks/schema.mock.ts b/packages/testing/src/mocks/schema.mock.ts index c02f0dadb22..8cb8847ccb3 100644 --- a/packages/testing/src/mocks/schema.mock.ts +++ b/packages/testing/src/mocks/schema.mock.ts @@ -1097,6 +1097,7 @@ export const schemaMock = { mcpServers: { id: 'mcpServers.id', workspaceId: 'mcpServers.workspaceId', + credentialGroupId: 'mcpServers.credentialGroupId', createdBy: 'mcpServers.createdBy', name: 'mcpServers.name', description: 'mcpServers.description', @@ -1216,6 +1217,7 @@ export const schemaMock = { enumValues: [ 'oauth', 'managed_oauth', + 'managed_mcp', 'env_workspace', 'env_personal', 'service_account', @@ -1236,12 +1238,18 @@ export const schemaMock = { envOwnerUserId: 'credential.envOwnerUserId', encryptedServiceAccountKey: 'credential.encryptedServiceAccountKey', authorizationAppId: 'credential.authorizationAppId', + credentialGroupEnrollmentId: 'credential.credentialGroupEnrollmentId', + credentialGroupOptionId: 'credential.credentialGroupOptionId', + mcpServerId: 'credential.mcpServerId', + managedOauthScopeVersion: 'credential.managedOauthScopeVersion', providerSubjectId: 'credential.providerSubjectId', providerTenantId: 'credential.providerTenantId', managedOauthStatus: 'credential.managedOauthStatus', grantedScopes: 'credential.grantedScopes', providerMetadata: 'credential.providerMetadata', encryptedOauthTokenSet: 'credential.encryptedOauthTokenSet', + mcpTools: 'credential.mcpTools', + mcpToolsRefreshedAt: 'credential.mcpToolsRefreshedAt', grantedAt: 'credential.grantedAt', revokedAt: 'credential.revokedAt', accessTokenExpiresAt: 'credential.accessTokenExpiresAt', diff --git a/scripts/openapi/documents.test.ts b/scripts/openapi/documents.test.ts index a3e14d01498..d2c31bd694b 100644 --- a/scripts/openapi/documents.test.ts +++ b/scripts/openapi/documents.test.ts @@ -307,7 +307,7 @@ describe('generated OpenAPI documents', () => { ) }) - it('publishes Agent tools as named integration, custom, and MCP schemas', () => { + it('publishes Agent tools as integration, custom, MCP tool, and advanced MCP schemas', () => { const workflowsSpec = generatedDocument(workflowsOpenApiDocument) const schemas = (workflowsSpec.components as JsonObject).schemas as JsonObject const agentToolInput = schemas.AgentToolInput as JsonObject @@ -332,6 +332,7 @@ describe('generated OpenAPI documents', () => { { $ref: '#/components/schemas/AgentIntegrationTool' }, { $ref: '#/components/schemas/AgentCustomTool' }, { $ref: '#/components/schemas/AgentMcpTool' }, + { $ref: '#/components/schemas/AgentMcpServerAdvanced' }, ]) expect(agentToolInput).toEqual( expect.objectContaining({ type: 'array', maxItems: MAX_AGENT_TOOLS_PER_BLOCK }) From 3a51850f10fbb97cbf2569788ae3ed36132b09da Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 2 Sep 2026 00:52:54 -0700 Subject: [PATCH 06/12] fix(workflow): preserve canvas and deploy modal behavior (#7392) --- .../output-select/output-select.test.tsx | 26 ++++++++++++++++--- .../output-select/output-select.tsx | 6 +++++ .../deploy-modal/components/chat/chat.tsx | 1 + .../[workspaceId]/w/[workflowId]/workflow.tsx | 6 +++-- .../components/chip-modal/chip-modal.test.tsx | 10 +++++++ .../src/components/chip-modal/chip-modal.tsx | 2 +- .../components/combobox/combobox.dom.test.tsx | 8 ++++++ .../emcn/src/components/combobox/combobox.tsx | 3 +++ 8 files changed, 56 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.test.tsx index 296da0f8df2..144d5afe4df 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.test.tsx @@ -64,12 +64,14 @@ vi.mock('@sim/emcn', () => ({ groups, multiSelectValues, onMultiSelectChange, + disablePortal, }: { groups: Array<{ section?: string; items: Array<{ label: string; value: string }> }> multiSelectValues?: string[] onMultiSelectChange?: (values: string[]) => void + disablePortal?: boolean }) => ( -
+
{groups.flatMap((group) => group.items.map((option) => (