From b8ec114382a9427634536130f17cc8df1a32c818 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sun, 2 Aug 2026 10:21:27 -0700 Subject: [PATCH 01/11] improvement(chat): secrets mounting / exposure improvements and controls (#6191) * fix(copilot): secrets injection into sandbox * improvement(chat): secrets mounting / exposure improvements and controls * fix(secrets): simplify copilot mounting flow * test(secrets): preserve standard tool permissions * fix(copilot): bind workflow tool completions * fix(secrets): preserve own environment keys * fix(copilot): release failed workflow claims * fix(copilot): trust compacted workflow completion --- .../content/docs/en/platform/credentials.mdx | 18 +- .../sim/app/api/copilot/confirm/route.test.ts | 828 +- apps/sim/app/api/copilot/confirm/route.ts | 290 +- .../api/copilot/tool-permission/route.test.ts | 139 + apps/sim/app/api/credentials/route.test.ts | 60 +- apps/sim/app/api/credentials/route.ts | 5 +- .../app/api/function/execute/route.test.ts | 48 + apps/sim/app/api/function/execute/route.ts | 12 +- .../app/api/mothership/execute/route.test.ts | 60 +- apps/sim/app/api/mothership/execute/route.ts | 28 +- apps/sim/app/api/schedules/[id]/route.ts | 11 +- apps/sim/app/api/schedules/route.ts | 4 + .../[id]/execute/route.async.test.ts | 303 +- .../app/api/workflows/[id]/execute/route.ts | 113 +- .../workspaces/[id]/environment/route.test.ts | 48 +- .../api/workspaces/[id]/environment/route.ts | 41 +- .../api/workspaces/[id]/inbox/route.test.ts | 87 + .../app/api/workspaces/[id]/inbox/route.ts | 70 +- .../task-context-menu/task-context-menu.tsx | 12 +- .../task-details-modal/task-details-modal.tsx | 4 +- .../task-modal/secret-access-section.tsx | 67 + .../components/task-modal/task-modal.tsx | 15 +- .../hooks/use-scheduled-tasks.ts | 6 + .../scheduled-tasks/scheduled-tasks.tsx | 12 +- .../utils/schedule-events.test.ts | 10 +- .../scheduled-tasks/utils/schedule-events.ts | 3 + .../inbox-settings-tab/inbox-settings-tab.tsx | 71 + .../utils/workflow-execution-utils.ts | 2 + apps/sim/background/schedule-execution.ts | 2 + apps/sim/blocks/blocks/mothership.ts | 28 + apps/sim/blocks/types.ts | 2 + .../executor/execution/block-executor.test.ts | 66 + apps/sim/executor/execution/block-executor.ts | 19 +- .../mothership/mothership-handler.test.ts | 4 + .../handlers/mothership/mothership-handler.ts | 7 + .../utils/code-secret-references.test.ts | 37 + .../executor/utils/code-secret-references.ts | 38 + .../resolved-secret-content-projection.ts | 427 + .../resolved-secret-trace-registry.test.ts | 28 + .../utils/resolved-secret-trace-registry.ts | 31 +- apps/sim/hooks/queries/credentials.ts | 6 +- apps/sim/hooks/queries/inbox.ts | 32 + .../sim/hooks/queries/secret-mount-options.ts | 19 + .../utils/fetch-workspace-credentials.ts | 2 + apps/sim/lib/api/contracts/copilot.ts | 1 + apps/sim/lib/api/contracts/hotspots.ts | 10 +- apps/sim/lib/api/contracts/inbox.ts | 10 + apps/sim/lib/api/contracts/index.ts | 1 + .../sim/lib/api/contracts/mothership-chats.ts | 6 + apps/sim/lib/api/contracts/primitives.ts | 16 + apps/sim/lib/api/contracts/schedules.ts | 10 + .../api/contracts/secret-mount-policy.test.ts | 32 + .../lib/api/contracts/secret-mount-policy.ts | 21 + apps/sim/lib/api/contracts/workflows.ts | 1 + .../lib/copilot/async-runs/lifecycle.test.ts | 12 + apps/sim/lib/copilot/async-runs/lifecycle.ts | 25 +- .../lib/copilot/async-runs/repository.test.ts | 193 +- apps/sim/lib/copilot/async-runs/repository.ts | 238 +- apps/sim/lib/copilot/chat/post.test.ts | 14 +- apps/sim/lib/copilot/chat/post.ts | 8 +- .../lib/copilot/environment-context.test.ts | 49 + apps/sim/lib/copilot/environment-context.ts | 35 + .../lib/copilot/generated/tool-catalog-v1.ts | 40 +- .../lib/copilot/generated/tool-schemas-v1.ts | 50 +- .../copilot/persistence/tool-confirm/index.ts | 9 +- .../tool-confirm/tool-confirm.test.ts | 25 +- .../persistence/tool-permission/index.ts | 3 +- .../copilot/request/context/result.test.ts | 5 +- .../sim/lib/copilot/request/go/stream.test.ts | 5 +- apps/sim/lib/copilot/request/go/stream.ts | 2 +- .../copilot/request/handlers/handlers.test.ts | 250 +- apps/sim/lib/copilot/request/handlers/tool.ts | 115 +- .../lib/copilot/request/lifecycle/run.test.ts | 66 +- apps/sim/lib/copilot/request/lifecycle/run.ts | 38 +- .../tools/client-completion-seal.server.ts | 136 + .../lib/copilot/request/tools/client.test.ts | 764 + apps/sim/lib/copilot/request/tools/client.ts | 323 + .../sim/lib/copilot/request/tools/executor.ts | 36 +- apps/sim/lib/copilot/request/tools/files.ts | 9 +- .../copilot/request/tools/permission.test.ts | 46 +- .../tools/resolved-secret-result.test.ts | 219 + .../request/tools/resolved-secret-result.ts | 139 + .../lib/copilot/request/tools/resources.ts | 35 +- .../lib/copilot/request/tools/tables.test.ts | 55 +- apps/sim/lib/copilot/request/tools/tables.ts | 23 +- .../lib/copilot/secret-mount-policy.test.ts | 54 + apps/sim/lib/copilot/secret-mount-policy.ts | 75 + .../copilot/tool-executor/executor.test.ts | 84 +- .../sim/lib/copilot/tool-executor/executor.ts | 20 +- apps/sim/lib/copilot/tool-executor/types.ts | 5 +- .../lib/copilot/tools/client/completion.ts | 5 +- .../tools/client/run-tool-execution.test.ts | 70 +- .../tools/client/run-tool-execution.ts | 135 +- .../sim/lib/copilot/tools/handlers/context.ts | 13 +- .../tools/handlers/function-execute.test.ts | 259 +- .../tools/handlers/function-execute.ts | 157 +- .../tools/handlers/workflow/mutations.test.ts | 56 + .../tools/handlers/workflow/mutations.ts | 8 +- .../secret-mount-materializer.server.test.ts | 456 + .../tools/secret-mount-materializer.server.ts | 312 + .../blocks/get-blocks-metadata-tool.test.ts | 16 + .../server/blocks/get-blocks-metadata-tool.ts | 36 +- .../workflow/edit-workflow/validation.test.ts | 23 + .../workflow/edit-workflow/validation.ts | 11 + apps/sim/lib/copilot/tools/workflow-tools.ts | 67 + apps/sim/lib/copilot/vfs/serializers.test.ts | 38 + apps/sim/lib/copilot/vfs/serializers.ts | 6 +- apps/sim/lib/core/async-jobs/types.ts | 2 + apps/sim/lib/core/utils/records.test.ts | 8 + apps/sim/lib/core/utils/records.ts | 19 +- apps/sim/lib/credentials/environment.test.ts | 83 + apps/sim/lib/credentials/environment.ts | 61 + .../credentials/secret-mount-options.test.ts | 36 + .../lib/credentials/secret-mount-options.ts | 22 + apps/sim/lib/environment/utils.test.ts | 81 +- apps/sim/lib/environment/utils.ts | 21 +- apps/sim/lib/logs/execution/logger.test.ts | 36 + apps/sim/lib/logs/execution/logger.ts | 1 + .../logs/execution/logging-session.test.ts | 41 +- .../sim/lib/logs/execution/logging-session.ts | 22 +- .../logs/execution/trace-secret-projection.ts | 335 +- .../lib/logs/execution/trace-store.test.ts | 65 +- apps/sim/lib/logs/execution/trace-store.ts | 11 +- .../sim/lib/mothership/inbox/executor.test.ts | 181 + apps/sim/lib/mothership/inbox/executor.ts | 32 +- .../executor/execution-state.test.ts | 181 + .../lib/workflows/executor/execution-state.ts | 143 +- .../sanitization/json-sanitizer.test.ts | 39 +- .../workflows/sanitization/json-sanitizer.ts | 12 +- .../workflows/schedules/orchestration.test.ts | 49 + .../lib/workflows/schedules/orchestration.ts | 42 +- apps/sim/lib/workflows/subblocks/options.ts | 21 + apps/sim/serializer/index.ts | 10 + apps/sim/serializer/private-inputs.test.ts | 80 + apps/sim/serializer/types.ts | 2 + apps/sim/tools/index.test.ts | 203 +- apps/sim/tools/index.ts | 54 +- packages/db/migrations/0280_great_riptide.sql | 5 + .../db/migrations/meta/0280_snapshot.json | 18398 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 4 + .../testing/src/mocks/logging-session.mock.ts | 11 +- 142 files changed, 27449 insertions(+), 965 deletions(-) create mode 100644 apps/sim/app/api/copilot/tool-permission/route.test.ts create mode 100644 apps/sim/app/api/workspaces/[id]/inbox/route.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/secret-access-section.tsx create mode 100644 apps/sim/executor/utils/code-secret-references.test.ts create mode 100644 apps/sim/executor/utils/code-secret-references.ts create mode 100644 apps/sim/executor/utils/resolved-secret-content-projection.ts create mode 100644 apps/sim/hooks/queries/secret-mount-options.ts create mode 100644 apps/sim/lib/api/contracts/secret-mount-policy.test.ts create mode 100644 apps/sim/lib/api/contracts/secret-mount-policy.ts create mode 100644 apps/sim/lib/copilot/environment-context.test.ts create mode 100644 apps/sim/lib/copilot/environment-context.ts create mode 100644 apps/sim/lib/copilot/request/tools/client-completion-seal.server.ts create mode 100644 apps/sim/lib/copilot/request/tools/client.test.ts create mode 100644 apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts create mode 100644 apps/sim/lib/copilot/request/tools/resolved-secret-result.ts create mode 100644 apps/sim/lib/copilot/secret-mount-policy.test.ts create mode 100644 apps/sim/lib/copilot/secret-mount-policy.ts create mode 100644 apps/sim/lib/copilot/tools/secret-mount-materializer.server.test.ts create mode 100644 apps/sim/lib/copilot/tools/secret-mount-materializer.server.ts create mode 100644 apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.test.ts create mode 100644 apps/sim/lib/credentials/secret-mount-options.test.ts create mode 100644 apps/sim/lib/credentials/secret-mount-options.ts create mode 100644 apps/sim/lib/mothership/inbox/executor.test.ts create mode 100644 apps/sim/serializer/private-inputs.test.ts create mode 100644 packages/db/migrations/0280_great_riptide.sql create mode 100644 packages/db/migrations/meta/0280_snapshot.json diff --git a/apps/docs/content/docs/en/platform/credentials.mdx b/apps/docs/content/docs/en/platform/credentials.mdx index 824fa951318..3502b5f6243 100644 --- a/apps/docs/content/docs/en/platform/credentials.mdx +++ b/apps/docs/content/docs/en/platform/credentials.mdx @@ -75,6 +75,20 @@ This is an observability projection only. Secret resolution and workflow behavio Masking is activated only when Sim successfully resolves a value from **Settings → Secrets** through `{{KEY}}`. A hardcoded literal, direct `environmentVariables['KEY']` read, or shell `$KEY` read does not activate it by itself. Once activated, every exact occurrence of that value in the run's log-facing content is masked. Encoded, hashed, or otherwise transformed versions are not matched. Do not deliberately return or print secrets. +### Copilot code execution + +Copilot's Function and code-execution tools receive a saved secret only when their code explicitly contains a valid `{{KEY}}` reference. Direct `environmentVariables.KEY` access, shell `$KEY`, dynamic names, literals, and configured-but-unused secrets do not mount a value. Code execution requires workspace write access, and the caller must also be allowed to view the raw value: your own Personal secrets, any secret for which you are a Credential Admin, and Workspace secrets when you are a workspace admin. Credential Members can continue using shared secrets through normal workflow and tool resolution, but cannot mount their plaintext into arbitrary Copilot code. + +Headless surfaces use their saved **Secret access** setting: + +- **Sim Chat block** — under **Show additional fields** +- **Scheduled Tasks** — in the task modal +- **Inbox** — under **Settings → Inbox → Secrets** + +Choose **All secrets** or **Selected secrets**. Existing configurations default to **All secrets** for compatibility. **All secrets** still means only secrets explicitly referenced with `{{KEY}}` that the execution actor may view; it never injects the full environment. Inbox messages from allowed external senders do not receive raw-secret access. + +Code receives the real authorized value at runtime. Before any Copilot-visible tool result is returned, exact occurrences of activated secret values are replaced with `{{KEY}}`; local side effects and runtime results are not rewritten. Encoded, hashed, URL-encoded, otherwise transformed, or network-exfiltrated values cannot be inferred and masked reliably, so code should not deliberately return, transform, print, or transmit secrets to unintended destinations. + ## Secret Details Click **Details** on any secret row to open its detail view. @@ -122,8 +136,8 @@ When a workflow runs, secrets resolve in this order: ({ getAsyncToolCall: vi.fn(), getRunSegment: vi.fn(), - upsertAsyncToolCall: vi.fn(), completeAsyncToolCall: vi.fn(), + detachAsyncToolCall: vi.fn(), publishToolConfirmation: vi.fn(), + encryptSecret: vi.fn(), + getTrustedWorkflowToolExecution: vi.fn(), })) vi.mock('@/lib/copilot/request/http', () => copilotHttpMock) @@ -24,14 +28,24 @@ vi.mock('@/lib/copilot/request/http', () => copilotHttpMock) vi.mock('@/lib/copilot/async-runs/repository', () => ({ getAsyncToolCall, getRunSegment, - upsertAsyncToolCall, completeAsyncToolCall, + detachAsyncToolCall, + getClaimedWorkflowExecutionId: (claimedBy?: string | null) => + claimedBy?.startsWith('workflow:') ? claimedBy.slice('workflow:'.length) : undefined, })) vi.mock('@/lib/copilot/persistence/tool-confirm', () => ({ publishToolConfirmation, })) +vi.mock('@/lib/core/security/encryption', () => ({ + encryptSecret, +})) + +vi.mock('@/lib/workflows/executor/execution-state', () => ({ + getTrustedWorkflowToolExecution, +})) + import { POST } from './route' describe('Copilot Confirm API Route', () => { @@ -41,6 +55,8 @@ describe('Copilot Confirm API Route', () => { checkpointId: 'checkpoint-1', toolName: 'client_tool', args: { foo: 'bar' }, + status: 'running', + claimedBy: 'workflow:execution-1', } beforeEach(() => { @@ -50,9 +66,15 @@ describe('Copilot Confirm API Route', () => { isAuthenticated: true, }) getAsyncToolCall.mockResolvedValue(existingRow) - getRunSegment.mockResolvedValue({ id: 'run-1', userId: 'user-1' }) - upsertAsyncToolCall.mockResolvedValue(existingRow) + getRunSegment.mockResolvedValue({ + id: 'run-1', + userId: 'user-1', + workflowId: 'workflow-from-run', + }) completeAsyncToolCall.mockResolvedValue(existingRow) + detachAsyncToolCall.mockResolvedValue(existingRow) + encryptSecret.mockResolvedValue({ encrypted: 'sealed-client-result', iv: 'iv' }) + getTrustedWorkflowToolExecution.mockResolvedValue({ status: 'completed' }) }) function createMockPostRequest(body: Record): NextRequest { @@ -122,15 +144,15 @@ describe('Copilot Confirm API Route', () => { expect(completeAsyncToolCall).toHaveBeenCalledWith({ toolCallId: 'tool-call-123', status: 'completed', - result: { ok: true }, + result: { __sealedClientToolCompletionV1: 'sealed-client-result' }, error: null, }) - expect(upsertAsyncToolCall).not.toHaveBeenCalled() + expect(detachAsyncToolCall).not.toHaveBeenCalled() expect(publishToolConfirmation).toHaveBeenCalledWith( expect.objectContaining({ toolCallId: 'tool-call-123', status: 'success', - data: { ok: true }, + data: { __sealedClientToolCompletionV1: 'sealed-client-result' }, }) ) }) @@ -149,19 +171,58 @@ describe('Copilot Confirm API Route', () => { expect(completeAsyncToolCall).toHaveBeenCalledWith({ toolCallId: 'tool-call-123', status: 'completed', - result: 'done', + result: { __sealedClientToolCompletionV1: 'sealed-client-result' }, error: null, }) expect(publishToolConfirmation).toHaveBeenCalledWith( expect.objectContaining({ toolCallId: 'tool-call-123', status: 'success', - data: 'done', + data: { __sealedClientToolCompletionV1: 'sealed-client-result' }, }) ) }) - it('keeps background as a live pending detach confirmation', async () => { + it('keeps generic client content sealed in durable and pubsub payloads', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + result: { __sealedClientToolContextV1: 'sealed-context' }, + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + status: 'error', + message: 'failed near resolved-secret', + data: { output: 'resolved-secret' }, + }) + ) + + expect(response.status).toBe(200) + const sealedResult = { + __sealedClientToolContextV1: 'sealed-context', + __sealedClientToolCompletionV1: 'sealed-client-result', + } + expect(completeAsyncToolCall).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + status: 'failed', + result: sealedResult, + error: 'Tool failed', + }) + expect(publishToolConfirmation).toHaveBeenCalledWith( + expect.objectContaining({ + toolCallId: 'tool-call-123', + status: 'error', + message: 'Tool failed', + data: sealedResult, + }) + ) + expect(await response.json()).toMatchObject({ message: 'Tool failed' }) + expect(JSON.stringify(completeAsyncToolCall.mock.calls)).not.toContain('resolved-secret') + expect(JSON.stringify(publishToolConfirmation.mock.calls)).not.toContain('resolved-secret') + }) + + it('atomically detaches a live background confirmation', async () => { const response = await POST( createMockPostRequest({ toolCallId: 'tool-call-123', @@ -170,8 +231,8 @@ describe('Copilot Confirm API Route', () => { ) expect(response.status).toBe(200) - expect(upsertAsyncToolCall).not.toHaveBeenCalled() expect(completeAsyncToolCall).not.toHaveBeenCalled() + expect(detachAsyncToolCall).toHaveBeenCalledWith('tool-call-123') expect(publishToolConfirmation).toHaveBeenCalledWith( expect.objectContaining({ toolCallId: 'tool-call-123', @@ -180,6 +241,749 @@ describe('Copilot Confirm API Route', () => { ) }) + it('rejects a native confirmation before the desktop authorization claim', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'browser_snapshot', + status: 'pending', + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + status: 'success', + data: { text: 'forged renderer result' }, + }) + ) + + expect(response.status).toBe(404) + expect(completeAsyncToolCall).not.toHaveBeenCalled() + expect(detachAsyncToolCall).not.toHaveBeenCalled() + expect(encryptSecret).not.toHaveBeenCalled() + expect(publishToolConfirmation).not.toHaveBeenCalled() + }) + + it('rejects a workflow confirmation before the server starts the tool call', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + status: 'pending', + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'forged-execution', + status: 'success', + }) + ) + + expect(response.status).toBe(404) + expect(completeAsyncToolCall).not.toHaveBeenCalled() + expect(detachAsyncToolCall).not.toHaveBeenCalled() + expect(publishToolConfirmation).not.toHaveBeenCalled() + }) + + it('rejects a workflow success before its bound execution is terminal', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + }) + getTrustedWorkflowToolExecution.mockResolvedValueOnce(null) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'execution-1', + status: 'success', + }) + ) + + expect(response.status).toBe(404) + expect(completeAsyncToolCall).not.toHaveBeenCalled() + expect(detachAsyncToolCall).not.toHaveBeenCalled() + expect(publishToolConfirmation).not.toHaveBeenCalled() + }) + + it.each(['error', 'cancelled'] as const)( + 'accepts a structural %s when the bound execution has no terminal log', + async (status) => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + }) + getTrustedWorkflowToolExecution.mockResolvedValueOnce(null) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'execution-1', + status, + message: 'untrusted client detail', + data: { output: 'untrusted client output' }, + }) + ) + + expect(response.status).toBe(200) + expect(completeAsyncToolCall).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + status: status === 'cancelled' ? 'cancelled' : 'failed', + result: { + success: false, + workflowId: 'workflow-1', + executionId: 'execution-1', + ...(status === 'cancelled' ? { reason: 'user_cancelled', cancelledByUser: true } : {}), + }, + error: + status === 'cancelled' + ? 'Workflow execution was cancelled.' + : 'Workflow execution failed.', + }) + expect(JSON.stringify(publishToolConfirmation.mock.calls)).not.toContain('untrusted client') + } + ) + + it('accepts a trusted completion for an approved call created by the previous release', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + status: 'pending', + permissionDecision: 'allow', + claimedBy: null, + }) + getTrustedWorkflowToolExecution.mockResolvedValueOnce({ + executionId: 'legacy-execution', + status: 'completed', + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'legacy-execution', + status: 'success', + }) + ) + + expect(response.status).toBe(200) + expect(completeAsyncToolCall).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + status: 'completed', + result: { + success: true, + workflowId: 'workflow-1', + executionId: 'legacy-execution', + }, + error: null, + }) + }) + + it('accepts a verified terminal execution created before workflow claims existed', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + claimedBy: null, + }) + getTrustedWorkflowToolExecution.mockResolvedValueOnce({ + executionId: 'legacy-execution', + status: 'completed', + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'legacy-execution', + status: 'success', + }) + ) + + expect(response.status).toBe(200) + expect(completeAsyncToolCall).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + status: 'completed', + result: { + success: true, + workflowId: 'workflow-1', + executionId: 'legacy-execution', + }, + error: null, + }) + }) + + it('rejects a workflow confirmation claimed by another executor', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + claimedBy: 'sim-stream', + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + status: 'error', + }) + ) + + expect(response.status).toBe(404) + expect(getTrustedWorkflowToolExecution).not.toHaveBeenCalled() + expect(completeAsyncToolCall).not.toHaveBeenCalled() + }) + + it('rejects a workflow confirmation for a different claimed execution', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'different-execution', + status: 'success', + }) + ) + + expect(response.status).toBe(404) + expect(getTrustedWorkflowToolExecution).not.toHaveBeenCalled() + expect(completeAsyncToolCall).not.toHaveBeenCalled() + expect(publishToolConfirmation).not.toHaveBeenCalled() + }) + + it('preserves a canonical preflight failure before an execution is bound', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + status: 'running', + claimedBy: null, + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + status: 'error', + message: 'untrusted client detail', + }) + ) + + expect(response.status).toBe(200) + expect(getTrustedWorkflowToolExecution).not.toHaveBeenCalled() + expect(completeAsyncToolCall).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + status: 'failed', + result: { success: false, workflowId: 'workflow-1' }, + error: 'Workflow execution failed.', + }) + expect(JSON.stringify(publishToolConfirmation.mock.calls)).not.toContain( + 'untrusted client detail' + ) + }) + + it('downgrades an unverifiable success from a stale client to a structural failure', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + status: 'running', + claimedBy: null, + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + status: 'success', + message: 'untrusted success detail', + data: { output: 'untrusted output' }, + }) + ) + + expect(response.status).toBe(200) + expect(completeAsyncToolCall).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + status: 'failed', + result: { success: false, workflowId: 'workflow-1' }, + error: 'Workflow execution failed.', + }) + expect(JSON.stringify(publishToolConfirmation.mock.calls)).not.toContain('untrusted') + }) + + it('preserves an approved cancellation before an execution is bound', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + status: 'running', + claimedBy: null, + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + status: 'cancelled', + message: 'untrusted cancellation detail', + }) + ) + + expect(response.status).toBe(200) + expect(getTrustedWorkflowToolExecution).not.toHaveBeenCalled() + expect(completeAsyncToolCall).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + status: 'cancelled', + result: { + success: false, + workflowId: 'workflow-1', + reason: 'user_cancelled', + cancelledByUser: true, + }, + error: 'Workflow execution was cancelled.', + }) + expect(JSON.stringify(publishToolConfirmation.mock.calls)).not.toContain( + 'untrusted cancellation detail' + ) + }) + + it('does not publish when another terminal confirmation already won', async () => { + completeAsyncToolCall.mockResolvedValueOnce(null) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + status: 'success', + }) + ) + + expect(response.status).toBe(500) + expect(publishToolConfirmation).not.toHaveBeenCalled() + }) + + it('does not publish a background replay after the call was finalized', async () => { + detachAsyncToolCall.mockResolvedValueOnce(null) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + status: 'background', + }) + ) + + expect(response.status).toBe(500) + expect(publishToolConfirmation).not.toHaveBeenCalled() + }) + + it('acknowledges an idempotent terminal workflow retry without publishing again', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + status: 'completed', + claimedBy: null, + result: { + success: true, + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'execution-1', + status: 'success', + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ status: 'success' }) + expect(completeAsyncToolCall).not.toHaveBeenCalled() + expect(publishToolConfirmation).not.toHaveBeenCalled() + }) + + it('acknowledges an idempotent background workflow retry from durable state', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + status: 'delivered', + claimedBy: 'workflow:execution-1', + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'execution-1', + status: 'background', + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ status: 'background' }) + expect(detachAsyncToolCall).not.toHaveBeenCalled() + expect(publishToolConfirmation).not.toHaveBeenCalled() + }) + + it('treats a workflow success as a notification and persists only canonical structure', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'execution-1', + status: 'success', + message: 'Completed with resolved-secret', + data: { + success: true, + output: { token: 'prefix-resolved-secret-suffix' }, + logs: ['resolved-secret'], + }, + }) + ) + + expect(response.status).toBe(200) + expect(completeAsyncToolCall).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + status: 'completed', + result: { + success: true, + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + error: null, + }) + expect(publishToolConfirmation).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + executionId: 'execution-1', + status: 'success', + message: 'Workflow execution completed.', + timestamp: expect.any(String), + data: { + success: true, + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + }) + expect(await response.json()).toEqual({ + success: true, + message: 'Workflow execution completed.', + toolCallId: 'tool-call-123', + status: 'success', + }) + expect(JSON.stringify(completeAsyncToolCall.mock.calls)).not.toContain('resolved-secret') + expect(JSON.stringify(publishToolConfirmation.mock.calls)).not.toContain('resolved-secret') + }) + + it('persists workflow failure structure without accepting client errors', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_block', + args: { workflowId: 'workflow-1' }, + }) + getTrustedWorkflowToolExecution.mockResolvedValueOnce({ status: 'failed' }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'execution-1', + status: 'error', + message: 'Function failed with resolved-secret', + data: { success: false, error: 'resolved-secret is invalid' }, + }) + ) + + expect(response.status).toBe(200) + expect(completeAsyncToolCall).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + status: 'failed', + result: { + success: false, + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + error: 'Workflow execution failed.', + }) + const published = publishToolConfirmation.mock.calls[0][0] + expect(published.data).toEqual(completeAsyncToolCall.mock.calls[0][0].result) + expect(published.message).toBe(completeAsyncToolCall.mock.calls[0][0].error) + expect(JSON.stringify(published)).not.toContain('resolved-secret') + }) + + it('uses structural workflow data without accepting execution content', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + }) + getTrustedWorkflowToolExecution.mockResolvedValueOnce({ status: 'failed' }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'execution-1', + status: 'error', + message: 'Failed with unresolved-secret-value', + data: { + success: false, + output: 'unresolved-secret-value', + reason: 'provider_failure', + }, + }) + ) + + expect(response.status).toBe(200) + expect(completeAsyncToolCall).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + status: 'failed', + result: { + success: false, + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + error: 'Workflow execution failed.', + }) + expect(JSON.stringify(publishToolConfirmation.mock.calls[0][0])).not.toContain( + 'unresolved-secret-value' + ) + }) + + it('binds output identity to the stored workflow target', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_from_block', + args: { workflowId: 'stored-workflow' }, + claimedBy: 'workflow:submitted-execution', + }) + getRunSegment.mockResolvedValue({ + id: 'run-1', + userId: 'user-1', + workflowId: 'run-workflow', + }) + + await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'submitted-execution', + status: 'success', + data: { workflowId: 'submitted-workflow', output: 'raw-output' }, + }) + ) + + expect(completeAsyncToolCall).toHaveBeenCalledWith( + expect.objectContaining({ + result: { + success: true, + workflowId: 'stored-workflow', + executionId: 'submitted-execution', + }, + }) + ) + }) + + it('keeps workflow completion structural even for ordinary client content', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: {}, + }) + getRunSegment.mockResolvedValue({ + id: 'run-1', + userId: 'user-1', + workflowId: 'workflow-from-run', + }) + + await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'execution-1', + status: 'success', + message: 'Workflow returned a normal value', + data: { output: { value: 'normal-value' }, logs: [] }, + }) + ) + + expect(completeAsyncToolCall).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + status: 'completed', + result: { + success: true, + workflowId: 'workflow-from-run', + executionId: 'execution-1', + }, + error: null, + }) + expect(publishToolConfirmation.mock.calls[0][0].message).toBe('Workflow execution completed.') + expect(JSON.stringify(publishToolConfirmation.mock.calls)).not.toContain('normal-value') + }) + + it('keeps workflow background confirmations structural without loading provenance', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow_until_block', + args: { workflowId: 'workflow-1' }, + }) + + await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'execution-1', + status: 'background', + message: 'Raw background detail', + data: { lastEventId: 7 }, + }) + ) + + expect(publishToolConfirmation).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + executionId: 'execution-1', + status: 'background', + message: 'Workflow execution is continuing in the background.', + timestamp: expect.any(String), + data: { workflowId: 'workflow-1', executionId: 'execution-1' }, + }) + expect(detachAsyncToolCall).toHaveBeenCalledWith('tool-call-123', { + preserveClaim: true, + }) + expect(getTrustedWorkflowToolExecution).not.toHaveBeenCalled() + }) + + it('detaches a background confirmation while its execution request is still binding', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow_until_block', + args: { workflowId: 'workflow-1' }, + claimedBy: null, + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'unbound-execution', + status: 'background', + }) + ) + + expect(response.status).toBe(200) + expect(detachAsyncToolCall).toHaveBeenCalledWith('tool-call-123', { + preserveClaim: true, + }) + expect(publishToolConfirmation).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + executionId: 'unbound-execution', + status: 'background', + message: 'Workflow execution is continuing in the background.', + timestamp: expect.any(String), + data: { workflowId: 'workflow-1', executionId: 'unbound-execution' }, + }) + }) + + it('accepts a legacy unbound background confirmation without an execution ID', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow_until_block', + args: { workflowId: 'workflow-1' }, + claimedBy: null, + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + status: 'background', + }) + ) + + expect(response.status).toBe(200) + expect(detachAsyncToolCall).toHaveBeenCalledWith('tool-call-123') + expect(publishToolConfirmation).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + status: 'background', + message: 'Workflow execution is continuing in the background.', + timestamp: expect.any(String), + data: { workflowId: 'workflow-1' }, + }) + }) + + it('derives workflow outcome from a content-unavailable trusted terminal execution', async () => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + }) + getTrustedWorkflowToolExecution.mockResolvedValueOnce({ + executionId: 'execution-1', + workflowId: 'workflow-1', + status: 'failed', + contentAvailable: false, + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'execution-1', + status: 'success', + }) + ) + + expect(response.status).toBe(200) + expect(completeAsyncToolCall).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + status: 'failed', + result: { + success: false, + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + error: 'Workflow execution failed.', + }) + expect(publishToolConfirmation).toHaveBeenCalledWith( + expect.objectContaining({ status: 'error', message: 'Workflow execution failed.' }) + ) + expect(await response.json()).toMatchObject({ status: 'error' }) + }) + + it.each(['error', 'cancelled'] as const)( + 'uses a completed server execution instead of the submitted %s status', + async (submittedStatus) => { + getAsyncToolCall.mockResolvedValue({ + ...existingRow, + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + }) + getTrustedWorkflowToolExecution.mockResolvedValueOnce({ + executionId: 'execution-1', + status: 'completed', + }) + + const response = await POST( + createMockPostRequest({ + toolCallId: 'tool-call-123', + executionId: 'execution-1', + status: submittedStatus, + }) + ) + + expect(response.status).toBe(200) + expect(completeAsyncToolCall).toHaveBeenCalledWith({ + toolCallId: 'tool-call-123', + status: 'completed', + result: { + success: true, + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + error: null, + }) + expect(await response.json()).toMatchObject({ status: 'success' }) + } + ) + it('rejects unsupported accepted and rejected confirmation statuses', async () => { const acceptedResponse = await POST( createMockPostRequest({ diff --git a/apps/sim/app/api/copilot/confirm/route.ts b/apps/sim/app/api/copilot/confirm/route.ts index 1dd5bd98a12..07ca1052dc2 100644 --- a/apps/sim/app/api/copilot/confirm/route.ts +++ b/apps/sim/app/api/copilot/confirm/route.ts @@ -1,4 +1,6 @@ +import { isBrowserToolName } from '@sim/browser-protocol' import { createLogger } from '@sim/logger' +import { isTerminalToolName } from '@sim/terminal-protocol' import { getErrorMessage, toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { copilotConfirmContract } from '@/lib/api/contracts/copilot' @@ -8,12 +10,16 @@ import { ASYNC_TOOL_STATUS, type AsyncCompletionData, type AsyncConfirmationStatus, + isDeliveredAsyncStatus, + isTerminalAsyncStatus, + isWorkflowToolExecutionClaimable, } from '@/lib/copilot/async-runs/lifecycle' import { completeAsyncToolCall, + detachAsyncToolCall, getAsyncToolCall, + getClaimedWorkflowExecutionId, getRunSegment, - upsertAsyncToolCall, } from '@/lib/copilot/async-runs/repository' import { CopilotConfirmOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' @@ -27,69 +33,83 @@ import { createUnauthorizedResponse, } from '@/lib/copilot/request/http' import { withIncomingGoSpan } from '@/lib/copilot/request/otel' +import { + retainSealedClientToolContext, + sealClientToolCompletion, +} from '@/lib/copilot/request/tools/client-completion-seal.server' +import { + createStructuralWorkflowToolCompletionData, + getWorkflowToolCompletionExecutionId, + getWorkflowToolCompletionMessage, + getWorkflowToolConfirmationStatus, + isWorkflowToolName, + resolveWorkflowToolTargetId, +} from '@/lib/copilot/tools/workflow-tools' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getTrustedWorkflowToolExecution } from '@/lib/workflows/executor/execution-state' const logger = createLogger('CopilotConfirmAPI') -/** - * Persist terminal durable tool status, then publish a wakeup event. - * - * `background` remains a live detach signal in the current browser workflow - * runtime, so it should not rewrite the durable async row. - */ +function getClientToolCompletionMessage(status: AsyncConfirmationStatus): string { + if (status === ASYNC_TOOL_CONFIRMATION_STATUS.success) return 'Tool completed' + if (status === ASYNC_TOOL_CONFIRMATION_STATUS.background) return 'Tool is running in background' + if (status === ASYNC_TOOL_CONFIRMATION_STATUS.cancelled) return 'Tool cancelled' + return 'Tool failed' +} + +function createConfirmationResponse( + toolCallId: string, + status: AsyncConfirmationStatus, + message: string +): NextResponse { + return NextResponse.json({ success: true, message, toolCallId, status }) +} + +/** Atomically finalize or detach a client tool before publishing its wakeup event. */ async function updateToolCallStatus( existing: NonNullable>>, status: AsyncConfirmationStatus, message?: string, - data?: AsyncCompletionData + data?: AsyncCompletionData, + executionId?: string ): Promise { const toolCallId = existing.toolCallId - if (status === ASYNC_TOOL_CONFIRMATION_STATUS.background) { - publishToolConfirmation({ - toolCallId, - status, - message: message || undefined, - timestamp: new Date().toISOString(), - data, - }) - return true - } - const durableStatus = - status === 'success' - ? ASYNC_TOOL_STATUS.completed - : status === 'cancelled' - ? ASYNC_TOOL_STATUS.cancelled - : status === 'error' - ? ASYNC_TOOL_STATUS.failed - : ASYNC_TOOL_STATUS.pending try { - if ( - durableStatus === ASYNC_TOOL_STATUS.completed || - durableStatus === ASYNC_TOOL_STATUS.failed || - durableStatus === ASYNC_TOOL_STATUS.cancelled - ) { - await completeAsyncToolCall({ + if (status === ASYNC_TOOL_CONFIRMATION_STATUS.background) { + const detached = executionId + ? await detachAsyncToolCall(toolCallId, { preserveClaim: true }) + : await detachAsyncToolCall(toolCallId) + if (!detached) return false + publishToolConfirmation({ toolCallId, - status: durableStatus, - result: data ?? null, - error: status === 'success' ? null : message || status, - }) - } else if (existing.runId) { - await upsertAsyncToolCall({ - runId: existing.runId, - checkpointId: existing.checkpointId ?? null, - toolCallId, - toolName: existing.toolName || 'client_tool', - args: (existing.args as Record | null) ?? {}, - status: durableStatus, + status, + message: message || undefined, + timestamp: new Date().toISOString(), + data, + ...(executionId ? { executionId } : {}), }) + return true } + const durableStatus = + status === ASYNC_TOOL_CONFIRMATION_STATUS.success + ? ASYNC_TOOL_STATUS.completed + : status === ASYNC_TOOL_CONFIRMATION_STATUS.cancelled + ? ASYNC_TOOL_STATUS.cancelled + : ASYNC_TOOL_STATUS.failed + const completed = await completeAsyncToolCall({ + toolCallId, + status: durableStatus, + result: data ?? null, + error: status === 'success' ? null : message || status, + }) + if (!completed) return false publishToolConfirmation({ toolCallId, status, message: message || undefined, timestamp: new Date().toISOString(), data, + ...(executionId ? { executionId } : {}), }) return true } catch (error) { @@ -140,7 +160,13 @@ export const POST = withRouteHandler((req: NextRequest) => { } ) if (!parsed.success) return parsed.response - const { toolCallId, status, message, data } = parsed.data.body + const { + toolCallId, + executionId: submittedExecutionId, + status, + message, + data, + } = parsed.data.body span.setAttributes({ [TraceAttr.ToolCallId]: toolCallId, [TraceAttr.ToolConfirmationStatus]: status, @@ -178,15 +204,172 @@ export const POST = withRouteHandler((req: NextRequest) => { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } - const updated = await updateToolCallStatus(existing, status, message, data) + const isWorkflowTool = isWorkflowToolName(existing.toolName || '') + const workflowId = isWorkflowTool + ? resolveWorkflowToolTargetId(existing.args, run.workflowId) + : undefined + + if (isWorkflowTool && isTerminalAsyncStatus(existing.status)) { + const executionId = getWorkflowToolCompletionExecutionId(existing.result) + if ( + executionId && + submittedExecutionId !== undefined && + submittedExecutionId !== executionId + ) { + span.setAttribute( + TraceAttr.CopilotConfirmOutcome, + CopilotConfirmOutcome.ToolCallNotFound + ) + return createNotFoundResponse('Completed workflow execution not found') + } + + const terminalStatus = getWorkflowToolConfirmationStatus(existing.status) + span.setAttributes({ + [TraceAttr.ToolConfirmationStatus]: terminalStatus, + [TraceAttr.CopilotConfirmOutcome]: CopilotConfirmOutcome.Delivered, + }) + return createConfirmationResponse( + toolCallId, + terminalStatus, + getWorkflowToolCompletionMessage(terminalStatus) + ) + } + + if (isWorkflowTool && isDeliveredAsyncStatus(existing.status)) { + const claimedExecutionId = getClaimedWorkflowExecutionId(existing.claimedBy) + if ( + claimedExecutionId && + submittedExecutionId !== undefined && + submittedExecutionId !== claimedExecutionId + ) { + span.setAttribute( + TraceAttr.CopilotConfirmOutcome, + CopilotConfirmOutcome.ToolCallNotFound + ) + return createNotFoundResponse('Bound workflow tool call not found') + } + + span.setAttributes({ + [TraceAttr.ToolConfirmationStatus]: ASYNC_TOOL_CONFIRMATION_STATUS.background, + [TraceAttr.CopilotConfirmOutcome]: CopilotConfirmOutcome.Delivered, + }) + return createConfirmationResponse( + toolCallId, + ASYNC_TOOL_CONFIRMATION_STATUS.background, + getWorkflowToolCompletionMessage(ASYNC_TOOL_CONFIRMATION_STATUS.background) + ) + } + + const isUnboundTerminalWorkflowOutcome = + status === ASYNC_TOOL_CONFIRMATION_STATUS.error || + status === ASYNC_TOOL_CONFIRMATION_STATUS.cancelled + const isMutableClientToolCall = isWorkflowTool + ? isWorkflowToolExecutionClaimable(existing.status, existing.permissionDecision) + : existing.status === ASYNC_TOOL_STATUS.running + if ( + (isBrowserToolName(existing.toolName) || + isTerminalToolName(existing.toolName) || + isWorkflowTool) && + !isMutableClientToolCall + ) { + span.setAttribute(TraceAttr.CopilotConfirmOutcome, CopilotConfirmOutcome.ToolCallNotFound) + return createNotFoundResponse('Running client tool call not found') + } + + let effectiveStatus = status + let executionId = submittedExecutionId + + if (isWorkflowTool) { + const claimedExecutionId = getClaimedWorkflowExecutionId(existing.claimedBy) + const hasForeignClaim = + existing.claimedBy !== null && existing.claimedBy !== undefined && !claimedExecutionId + + if ( + hasForeignClaim || + (claimedExecutionId && + submittedExecutionId !== undefined && + submittedExecutionId !== claimedExecutionId) + ) { + span.setAttribute( + TraceAttr.CopilotConfirmOutcome, + CopilotConfirmOutcome.ToolCallNotFound + ) + return createNotFoundResponse('Bound workflow tool call not found') + } + + const candidateExecutionId = claimedExecutionId ?? submittedExecutionId + const trustedExecution = + status !== ASYNC_TOOL_CONFIRMATION_STATUS.background && + candidateExecutionId && + workflowId + ? await getTrustedWorkflowToolExecution(candidateExecutionId, workflowId, toolCallId) + : null + + if (claimedExecutionId) { + executionId = claimedExecutionId + if (status !== ASYNC_TOOL_CONFIRMATION_STATUS.background) { + if (trustedExecution) { + effectiveStatus = getWorkflowToolConfirmationStatus(trustedExecution.status) + } else if (!isUnboundTerminalWorkflowOutcome) { + span.setAttribute( + TraceAttr.CopilotConfirmOutcome, + CopilotConfirmOutcome.ToolCallNotFound + ) + return createNotFoundResponse('Completed workflow execution not found') + } + } + } else if (status === ASYNC_TOOL_CONFIRMATION_STATUS.background) { + executionId = submittedExecutionId + } else if (trustedExecution) { + executionId = trustedExecution.executionId + effectiveStatus = getWorkflowToolConfirmationStatus(trustedExecution.status) + } else if (!isUnboundTerminalWorkflowOutcome) { + effectiveStatus = ASYNC_TOOL_CONFIRMATION_STATUS.error + executionId = undefined + } else { + executionId = undefined + } + } + + span.setAttribute(TraceAttr.ToolConfirmationStatus, effectiveStatus) + const projected = isWorkflowTool + ? { + message: getWorkflowToolCompletionMessage(effectiveStatus), + data: createStructuralWorkflowToolCompletionData( + effectiveStatus, + workflowId, + executionId + ), + } + : { + message: getClientToolCompletionMessage(status), + data: { + ...retainSealedClientToolContext(existing.result), + ...(await sealClientToolCompletion({ + toolCallId, + runId: existing.runId, + userId: authenticatedUserId, + ...(message !== undefined ? { message } : {}), + ...(data !== undefined ? { data } : {}), + })), + }, + } + + const updated = await updateToolCallStatus( + existing, + effectiveStatus, + projected.message, + projected.data, + isWorkflowTool ? executionId : undefined + ) if (!updated) { logger.error(`[${tracker.requestId}] Failed to update tool call status`, { userId: authenticatedUserId, toolCallId, - status, - internalStatus: status, - message, + status: effectiveStatus, + internalStatus: effectiveStatus, + message: projected.message, }) span.setAttribute(TraceAttr.CopilotConfirmOutcome, CopilotConfirmOutcome.UpdateFailed) // DB write failed — 500, not 400. 400 is a client-shape error. @@ -194,12 +377,11 @@ export const POST = withRouteHandler((req: NextRequest) => { } span.setAttribute(TraceAttr.CopilotConfirmOutcome, CopilotConfirmOutcome.Delivered) - return NextResponse.json({ - success: true, - message: message || `Tool call ${toolCallId} has been ${status.toLowerCase()}`, + return createConfirmationResponse( toolCallId, - status, - }) + effectiveStatus, + projected.message || `Tool call ${toolCallId} has been ${effectiveStatus.toLowerCase()}` + ) } catch (error) { const duration = tracker.getDuration() diff --git a/apps/sim/app/api/copilot/tool-permission/route.test.ts b/apps/sim/app/api/copilot/tool-permission/route.test.ts new file mode 100644 index 00000000000..bdc42bd79c1 --- /dev/null +++ b/apps/sim/app/api/copilot/tool-permission/route.test.ts @@ -0,0 +1,139 @@ +/** + * @vitest-environment node + */ + +import { copilotHttpMock, copilotHttpMockFns } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + getAsyncToolCall, + getRunSegment, + recordToolPermissionDecision, + publishToolPermissionDecision, + addAutoAllowedTool, + addChatAutoAllowedTool, +} = vi.hoisted(() => ({ + getAsyncToolCall: vi.fn(), + getRunSegment: vi.fn(), + recordToolPermissionDecision: vi.fn(), + publishToolPermissionDecision: vi.fn(), + addAutoAllowedTool: vi.fn(), + addChatAutoAllowedTool: vi.fn(), +})) + +vi.mock('@/lib/copilot/request/http', () => copilotHttpMock) + +vi.mock('@/lib/copilot/async-runs/repository', () => ({ + getAsyncToolCall, + getRunSegment, + recordToolPermissionDecision, +})) + +vi.mock('@/lib/copilot/persistence/tool-permission', () => ({ + publishToolPermissionDecision, + TOOL_PERMISSION_DECISION: { + allow: 'allow', + allow_chat: 'allow_chat', + always_allow: 'always_allow', + skip: 'skip', + }, +})) + +vi.mock('@/lib/copilot/persistence/tool-permission/auto-allow', () => ({ + addAutoAllowedTool, + addChatAutoAllowedTool, +})) + +vi.mock('@/lib/core/config/env-flags', () => ({ + isCopilotToolPermissionsEnabled: true, +})) + +import { POST } from './route' + +describe('Copilot tool permission API', () => { + beforeEach(() => { + vi.clearAllMocks() + copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({ + userId: 'user-1', + isAuthenticated: true, + }) + getAsyncToolCall.mockResolvedValue({ + toolCallId: 'tool-1', + runId: 'run-1', + toolName: 'run_workflow', + status: 'pending', + permissionDecision: null, + }) + getRunSegment.mockResolvedValue({ + id: 'run-1', + userId: 'user-1', + chatId: 'chat-1', + }) + recordToolPermissionDecision.mockResolvedValue({ + toolCallId: 'tool-1', + runId: 'run-1', + toolName: 'run_workflow', + status: 'pending', + permissionDecision: 'allow', + permissionDecidedAt: new Date('2026-08-01T00:00:00.000Z'), + }) + addAutoAllowedTool.mockResolvedValue(undefined) + addChatAutoAllowedTool.mockResolvedValue(undefined) + }) + + function createRequest(decision: 'allow' | 'allow_chat' | 'always_allow' | 'skip') { + return new NextRequest('http://localhost:3000/api/copilot/tool-permission', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ decisions: [{ toolCallId: 'tool-1', decision }] }), + }) + } + + it.each(['allow', 'allow_chat', 'always_allow', 'skip'] as const)( + 'records the generic %s decision without changing execution state', + async (decision) => { + recordToolPermissionDecision.mockResolvedValueOnce({ + toolCallId: 'tool-1', + runId: 'run-1', + toolName: 'run_workflow', + status: 'pending', + permissionDecision: decision, + permissionDecidedAt: new Date('2026-08-01T00:00:00.000Z'), + }) + + const response = await POST(createRequest(decision)) + + expect(response.status).toBe(200) + expect(recordToolPermissionDecision).toHaveBeenCalledWith('tool-1', decision) + expect(publishToolPermissionDecision).toHaveBeenCalledWith( + expect.objectContaining({ toolCallId: 'tool-1', decision }) + ) + } + ) + + it('uses the same decision path for non-workflow tools', async () => { + const toolName = 'function_execute' + const decision = 'allow' + getAsyncToolCall.mockResolvedValueOnce({ + toolCallId: 'tool-1', + runId: 'run-1', + toolName, + status: 'pending', + permissionDecision: null, + }) + recordToolPermissionDecision.mockResolvedValueOnce({ + toolCallId: 'tool-1', + runId: 'run-1', + toolName, + status: 'pending', + permissionDecision: decision, + permissionDecidedAt: new Date('2026-08-01T00:00:00.000Z'), + }) + + const response = await POST(createRequest(decision)) + + expect(response.status).toBe(200) + expect(recordToolPermissionDecision).toHaveBeenCalledWith('tool-1', decision) + }) +}) diff --git a/apps/sim/app/api/credentials/route.test.ts b/apps/sim/app/api/credentials/route.test.ts index e9a4a57e8e4..6127ba4b162 100644 --- a/apps/sim/app/api/credentials/route.test.ts +++ b/apps/sim/app/api/credentials/route.test.ts @@ -3,12 +3,14 @@ * * @vitest-environment node */ +import { credential } from '@sim/db/schema' import { auditMock, authMockFns, createMockRequest, dbChainMockFns, posthogServerMock, + queueTableRows, resetDbChainMock, } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -52,10 +54,66 @@ vi.mock('@/lib/credentials/service-account-secret', () => ({ ServiceAccountSecretError: class ServiceAccountSecretError extends Error {}, })) -import { POST } from '@/app/api/credentials/route' +import { GET, POST } from '@/app/api/credentials/route' const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' +describe('GET /api/credentials', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'user-1', name: 'Test User', email: 'test@example.com' }, + }) + mockCheckWorkspaceAccess.mockResolvedValue({ + hasAccess: true, + canWrite: true, + canAdmin: false, + }) + }) + + it('reports an owned personal secret as raw-view admin without a membership row', async () => { + queueTableRows(credential, [ + { + id: 'credential-1', + workspaceId: WORKSPACE_ID, + type: 'env_personal', + displayName: 'MY_API_KEY', + description: null, + providerId: null, + accountId: null, + envKey: 'MY_API_KEY', + envOwnerUserId: 'user-1', + createdBy: 'user-1', + createdAt: new Date('2026-08-01T00:00:00.000Z'), + updatedAt: new Date('2026-08-01T00:00:00.000Z'), + memberRole: null, + }, + ]) + + const response = await GET( + createMockRequest( + 'GET', + undefined, + {}, + `http://localhost:3000/api/credentials?workspaceId=${WORKSPACE_ID}` + ) + ) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.credentials).toEqual([ + expect.objectContaining({ + id: 'credential-1', + type: 'env_personal', + envKey: 'MY_API_KEY', + envOwnerUserId: 'user-1', + role: 'admin', + }), + ]) + }) +}) + describe('POST /api/credentials', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/app/api/credentials/route.ts b/apps/sim/app/api/credentials/route.ts index 4efe507fa9a..74b3b6337f1 100644 --- a/apps/sim/app/api/credentials/route.ts +++ b/apps/sim/app/api/credentials/route.ts @@ -270,7 +270,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const credentials = rows.map(({ memberRole, ...rest }) => ({ ...rest, role: - isWorkspaceAdmin && isSharedCredentialType(rest.type) ? 'admin' : (memberRole ?? 'member'), + (rest.type === 'env_personal' && rest.envOwnerUserId === session.user.id) || + (isWorkspaceAdmin && isSharedCredentialType(rest.type)) + ? 'admin' + : (memberRole ?? 'member'), })) return NextResponse.json({ credentials }) diff --git a/apps/sim/app/api/function/execute/route.test.ts b/apps/sim/app/api/function/execute/route.test.ts index f78dd26b928..6f2647f9e83 100644 --- a/apps/sim/app/api/function/execute/route.test.ts +++ b/apps/sim/app/api/function/execute/route.test.ts @@ -816,6 +816,34 @@ describe('Function Execute API Route', () => { expect(data.__resolvedSecretNames).toEqual(['API_KEY']) }) + it('does not report a reference when validation rejects before code resolution', async () => { + const response = await POST( + createMockRequest( + 'POST', + { + code: 'return {{API_KEY}}', + envVars: { API_KEY: 'secret-value' }, + outputs: { + files: Array.from({ length: 21 }, (_, index) => ({ + path: `files/output-${index}.json`, + sandboxPath: `/home/user/output-${index}.json`, + })), + }, + }, + { + 'x-sim-request-private-tool-metadata': 'resolved-secret-names-v1', + } + ) + ) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data.error).toContain('Too many sandbox output files requested') + expect(data.__resolvedSecretNames).toEqual([]) + expect(mockExecuteInIsolatedVM).not.toHaveBeenCalled() + expect(mockExecuteInSandbox).not.toHaveBeenCalled() + }) + it('reports only successful references sourced from scoped environment variables', async () => { const envResponse = await POST( createMockRequest( @@ -907,6 +935,26 @@ describe('Function Execute API Route', () => { expect((await response.json()).__resolvedSecretNames).toEqual(['ALLOWED']) }) + it('resolves a selected __proto__ secret as an own environment key', async () => { + const response = await POST( + createMockRequest( + 'POST', + { + code: 'return "{{__proto__}}"', + envVars: Object.fromEntries([['__proto__', 'secret-value']]), + secretScope: 'selected', + mountedSecrets: ['__proto__'], + }, + { + 'x-sim-request-private-tool-metadata': 'resolved-secret-names-v1', + } + ) + ) + + expect(response.status).toBe(200) + expect((await response.json()).__resolvedSecretNames).toEqual(['__proto__']) + }) + it.concurrent('should resolve tag variables with syntax', async () => { const req = createMockRequest('POST', { code: 'return ', diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts index 4f72d777a38..e6e35431b7c 100644 --- a/apps/sim/app/api/function/execute/route.ts +++ b/apps/sim/app/api/function/execute/route.ts @@ -17,6 +17,7 @@ import { writeWorkspaceFileByPath, } from '@/lib/copilot/vfs/resource-writer' import { isRemoteSandboxEnabled } from '@/lib/core/config/env-flags' +import { setRecordValue } from '@/lib/core/utils/records' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { executeInIsolatedVM, type IsolatedVMBrokerHandler } from '@/lib/execution/isolated-vm' @@ -55,6 +56,7 @@ import { getWorkflowById } from '@/lib/workflows/utils' import { escapeRegExp, normalizeName, REFERENCE } from '@/executor/constants' import { type OutputSchema, resolveBlockReference } from '@/executor/utils/block-reference' import { formatLiteralForCode } from '@/executor/utils/code-formatting' +import { createCodeEnvVarPattern } from '@/executor/utils/code-secret-references' import { createEnvVarPattern, createReferencePattern, @@ -579,7 +581,7 @@ function scopeEnvironmentVariables( const scoped: Record = {} const missing: string[] = [] for (const name of allowed) { - if (name in envVars) scoped[name] = envVars[name] + if (Object.hasOwn(envVars, name)) setRecordValue(scoped, name, envVars[name]) else missing.push(name) } if (missing.length > 0) { @@ -607,19 +609,19 @@ function resolveEnvironmentVariables( const resolverVars: Record = {} Object.entries(params).forEach(([key, value]) => { if (value !== undefined && value !== null) { - resolverVars[key] = String(value) + setRecordValue(resolverVars, key, String(value)) } }) Object.entries(envVars).forEach(([key, value]) => { if (value !== undefined && value !== null) { - resolverVars[key] = value + setRecordValue(resolverVars, key, value) } }) while ((match = regex.exec(code)) !== null) { const varName = match[1].trim() - if (!(varName in resolverVars)) { + if (!Object.hasOwn(resolverVars, varName)) { continue } @@ -1595,7 +1597,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { if (lang === CodeLanguage.Shell) { // For shell, env vars are injected as OS env vars via shellEnvs. // Replace {{VAR}} placeholders with $VAR so the shell can access them natively. - resolvedCode = code.replace(/\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}/g, (_match, name) => { + resolvedCode = code.replace(createCodeEnvVarPattern(lang), (_match, name) => { if (Object.hasOwn(envVars, name)) { routeContext?.resolvedSecretNames.add(name) } diff --git a/apps/sim/app/api/mothership/execute/route.test.ts b/apps/sim/app/api/mothership/execute/route.test.ts index 350e85bd8b1..b052d2cfa79 100644 --- a/apps/sim/app/api/mothership/execute/route.test.ts +++ b/apps/sim/app/api/mothership/execute/route.test.ts @@ -190,7 +190,9 @@ describe('mothership private trace provenance transport', () => { } function activateSecret(options: CopilotLifecycleOptions): void { - options.resolvedSecretTraceRegistry?.recordResolved('API_KEY', 'secret-value') + const registry = + options.environmentContext?.resolvedSecretTraceRegistry ?? options.resolvedSecretTraceRegistry + registry?.recordResolved('API_KEY', 'secret-value') } it('does not expose private provenance unless the internal caller requests it', async () => { @@ -218,15 +220,48 @@ describe('mothership private trace provenance transport', () => { expect(mockGetPersonalAndWorkspaceEnv).not.toHaveBeenCalled() expect(mockRunHeadlessCopilotLifecycle).toHaveBeenCalledWith( expect.any(Object), - expect.objectContaining({ resolvedSecretTraceRegistry: undefined }) + expect.objectContaining({ environmentContext: undefined }) ) }) + it('keeps headless secret policy server-only', async () => { + mockRunHeadlessCopilotLifecycle.mockImplementation( + async (payload: Record, options: CopilotLifecycleOptions) => { + expect(payload).not.toHaveProperty('secretScope') + expect(payload).not.toHaveProperty('mountedSecrets') + expect(options).toMatchObject({ + secretActorUserId: 'user-1', + secretMountPolicy: { + secretScope: 'selected', + mountedSecrets: ['API_KEY'], + }, + }) + return successResult() + } + ) + + const response = await POST( + createMockRequest( + 'POST', + { + ...requestBody, + secretScope: 'selected', + mountedSecrets: ['API_KEY'], + }, + { Authorization: 'Bearer internal', 'x-sim-billing-attribution': 'billing' }, + 'http://localhost:3000/api/mothership/execute' + ) + ) + + expect(response.status).toBe(200) + }) + it('keeps execution functional and fails trace provenance closed when catalog setup fails', async () => { mockGetPersonalAndWorkspaceEnv.mockRejectedValueOnce(new Error('catalog unavailable')) mockRunHeadlessCopilotLifecycle.mockImplementation( async (_payload: Record, options: CopilotLifecycleOptions) => { expect(options.resolvedSecretTraceRegistry?.isComplete()).toBe(false) + expect(options.environmentContext).toBeUndefined() return successResult() } ) @@ -258,9 +293,10 @@ describe('mothership private trace provenance transport', () => { it('fails provenance closed without changing a runtime value that rotated after catalog load', async () => { mockRunHeadlessCopilotLifecycle.mockImplementation( async (_payload: Record, options: CopilotLifecycleOptions) => { - expect( - options.resolvedSecretTraceRegistry?.recordResolved('API_KEY', 'rotated-secret-value') - ).toBe(false) + const registry = + options.environmentContext?.resolvedSecretTraceRegistry ?? + options.resolvedSecretTraceRegistry + expect(registry?.recordResolved('API_KEY', 'rotated-secret-value')).toBe(false) return { ...successResult(), content: 'rotated-secret-value' } } ) @@ -292,6 +328,9 @@ describe('mothership private trace provenance transport', () => { it('returns encrypted provenance on a marker-gated successful request', async () => { mockRunHeadlessCopilotLifecycle.mockImplementation( async (_payload: Record, options: CopilotLifecycleOptions) => { + expect(options.environmentContext).not.toHaveProperty('decryptedEnvVars') + expect(options.environmentContext?.resolvedSecretTraceRegistry).toBeDefined() + expect(options.resolvedSecretTraceRegistry).toBeUndefined() activateSecret(options) return successResult() } @@ -322,6 +361,7 @@ describe('mothership private trace provenance transport', () => { scope: { userId: 'user-1', workspaceId: 'workspace-1' }, }) expect(JSON.stringify(body.__resolvedSecretTraceProvenance)).not.toContain('secret-value') + expect(mockGetPersonalAndWorkspaceEnv).toHaveBeenCalledTimes(1) }) it('imports MCP schema-discovery provenance before starting the lifecycle', async () => { @@ -344,7 +384,10 @@ describe('mothership private trace provenance transport', () => { ) mockRunHeadlessCopilotLifecycle.mockImplementation( async (payload: Record, options: CopilotLifecycleOptions) => { - expect(options.resolvedSecretTraceRegistry?.exportProvenance()).toEqual(provenance) + const registry = + options.environmentContext?.resolvedSecretTraceRegistry ?? + options.resolvedSecretTraceRegistry + expect(registry?.exportProvenance()).toEqual(provenance) expect(JSON.stringify(payload)).not.toContain('encrypted-secret') expect(JSON.stringify(payload)).not.toContain('__resolvedSecretTraceProvenance') return successResult() @@ -388,7 +431,10 @@ describe('mothership private trace provenance transport', () => { ) mockRunHeadlessCopilotLifecycle.mockImplementation( async (_payload: Record, options: CopilotLifecycleOptions) => { - expect(options.resolvedSecretTraceRegistry?.isComplete()).toBe(false) + const registry = + options.environmentContext?.resolvedSecretTraceRegistry ?? + options.resolvedSecretTraceRegistry + expect(registry?.isComplete()).toBe(false) return successResult() } ) diff --git a/apps/sim/app/api/mothership/execute/route.ts b/apps/sim/app/api/mothership/execute/route.ts index 1febf5e513e..c3e9d23f77a 100644 --- a/apps/sim/app/api/mothership/execute/route.ts +++ b/apps/sim/app/api/mothership/execute/route.ts @@ -10,6 +10,10 @@ import { buildIntegrationToolSchemas } from '@/lib/copilot/chat/payload' import { processContextsServer } from '@/lib/copilot/chat/process-contents' import { generateWorkspaceContext } from '@/lib/copilot/chat/workspace-context' import { computeWorkspaceEntitlements } from '@/lib/copilot/entitlements' +import { + type CopilotEnvironmentContext, + createCopilotEnvironmentContext, +} from '@/lib/copilot/environment-context' import { MothershipStreamV1EventType, MothershipStreamV1TextChannel, @@ -18,6 +22,7 @@ import { buildSelectedMcpToolSchemas, buildTaggedMcpToolSchemas } from '@/lib/co import { runHeadlessCopilotLifecycle } from '@/lib/copilot/request/lifecycle/headless' import { requestExplicitStreamAbort } from '@/lib/copilot/request/session/explicit-abort' import type { StreamEvent } from '@/lib/copilot/request/types' +import { normalizeSecretMountPolicy } from '@/lib/copilot/secret-mount-policy' import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils' @@ -33,7 +38,6 @@ import { } from '@/lib/workspaces/permissions/utils' import { createIncompleteResolvedSecretTraceRegistry, - createResolvedSecretTraceRegistry, ResolvedSecretTraceProvenanceAccumulator, type ResolvedSecretTraceRegistry, } from '@/executor/utils/resolved-secret-trace-registry' @@ -135,6 +139,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { let messageId: string | undefined let requestId: string | undefined let resolvedSecretTraceRegistry: ResolvedSecretTraceRegistry | undefined + let environmentContext: CopilotEnvironmentContext | undefined const includePrivateProvenance = requestsPrivateToolMetadata( req.headers, RESOLVED_SECRET_PROVENANCE_METADATA_V1 @@ -162,7 +167,10 @@ export const POST = withRouteHandler(async (req: NextRequest) => { workflowId, executionId, userMetadata, + secretScope, + mountedSecrets, } = validation.data.body + const secretMountPolicy = normalizeSecretMountPolicy({ secretScope, mountedSecrets }) /** * Bind actor attribution to the authenticated identity. The executor mints @@ -192,14 +200,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const environment = await getPersonalAndWorkspaceEnv(userId, workspaceId, { workspaceAccess, }) - resolvedSecretTraceRegistry = await createResolvedSecretTraceRegistry({ - personalEncrypted: environment.personalEncrypted, - workspaceEncrypted: environment.workspaceEncrypted, - personalDecrypted: environment.personalDecrypted, - workspaceDecrypted: environment.workspaceDecrypted, - decryptionFailures: environment.decryptionFailures, - scope, - }) + environmentContext = await createCopilotEnvironmentContext(userId, workspaceId, environment) + resolvedSecretTraceRegistry = environmentContext.resolvedSecretTraceRegistry } catch (error) { logger.warn('Failed to build Mothership trace secret catalog', { error: getErrorMessage(error), @@ -376,7 +378,13 @@ export const POST = withRouteHandler(async (req: NextRequest) => { interactive: false, abortSignal: lifecycleAbortController.signal, billingAttribution, - resolvedSecretTraceRegistry, + ...(userPermission ? { userPermission } : {}), + secretActorUserId: userId, + secretMountPolicy, + environmentContext, + ...(!environmentContext && resolvedSecretTraceRegistry + ? { resolvedSecretTraceRegistry } + : {}), onEvent, }) diff --git a/apps/sim/app/api/schedules/[id]/route.ts b/apps/sim/app/api/schedules/[id]/route.ts index 56949815250..6c3f6248c4c 100644 --- a/apps/sim/app/api/schedules/[id]/route.ts +++ b/apps/sim/app/api/schedules/[id]/route.ts @@ -205,12 +205,21 @@ export const PUT = withRouteHandler( time: validatedBody.time, endsAt: validatedBody.endsAt, contexts: validatedBody.contexts, + secretScope: validatedBody.secretScope, + mountedSecrets: validatedBody.mountedSecrets, request, }) if (!updateResult.success) { return NextResponse.json( { error: updateResult.error || 'Failed to update schedule' }, - { status: updateResult.errorCode === 'validation' ? 400 : 500 } + { + status: + updateResult.errorCode === 'forbidden' + ? 403 + : updateResult.errorCode === 'validation' + ? 400 + : 500, + } ) } diff --git a/apps/sim/app/api/schedules/route.ts b/apps/sim/app/api/schedules/route.ts index ca25b2fe946..f2741fc4908 100644 --- a/apps/sim/app/api/schedules/route.ts +++ b/apps/sim/app/api/schedules/route.ts @@ -226,6 +226,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => { endsAt, startDate, contexts, + secretScope, + mountedSecrets, } = parsed.data.body const permission = await verifyWorkspaceMembership(session.user.id, workspaceId) @@ -248,6 +250,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => { endsAt, startDate, contexts, + secretScope, + mountedSecrets, request: req, }) if (!result.success || !result.schedule) { diff --git a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts index c55a9c1030d..6da3b5368fc 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts @@ -9,6 +9,7 @@ import { executionPreprocessingMockFns, hybridAuthMockFns, loggingSessionMock, + loggingSessionMockFns, queueTableRows, requestUtilsMockFns, resetDbChainMock, @@ -28,14 +29,21 @@ import { AsyncJobEnqueueError } from '@/lib/core/async-jobs/types' const { mockAssertBillingAttributionSnapshot, mockClaimExecutionId, + mockClaimWorkflowToolExecution, mockEnqueue, mockExecuteWorkflowCore, mockGenerateId, mockGetWorkspaceBillingSettings, + mockGetAsyncToolCall, + mockGetRunSegment, + mockCreateExecutionEventWriter, + mockFlushExecutionStreamReplayBuffer, mockHandlePostExecutionPauseState, mockHasDurableExecutionOwner, + mockInitializeExecutionStreamMeta, mockReleaseExecutionIdClaim, mockReleaseExecutionSlot, + mockReleaseWorkflowToolExecutionClaim, mockRequireBillingAttributionHeader, mockValidatePublicApiAllowed, } = vi.hoisted(() => ({ @@ -46,14 +54,21 @@ const { return value }), mockClaimExecutionId: vi.fn(), + mockClaimWorkflowToolExecution: vi.fn(), mockEnqueue: vi.fn().mockResolvedValue('job-123'), mockExecuteWorkflowCore: vi.fn(), mockGenerateId: vi.fn(() => 'execution-123'), mockGetWorkspaceBillingSettings: vi.fn(), + mockGetAsyncToolCall: vi.fn(), + mockGetRunSegment: vi.fn(), + mockCreateExecutionEventWriter: vi.fn(), + mockFlushExecutionStreamReplayBuffer: vi.fn(), mockHandlePostExecutionPauseState: vi.fn(), mockHasDurableExecutionOwner: vi.fn(), + mockInitializeExecutionStreamMeta: vi.fn(), mockReleaseExecutionIdClaim: vi.fn(), mockReleaseExecutionSlot: vi.fn(), + mockReleaseWorkflowToolExecutionClaim: vi.fn(), mockRequireBillingAttributionHeader: vi.fn(), mockValidatePublicApiAllowed: vi.fn(), })) @@ -102,6 +117,20 @@ vi.mock('@/lib/workflows/executor/execution-id-claim', () => ({ releaseExecutionIdClaim: mockReleaseExecutionIdClaim, })) +vi.mock('@/lib/copilot/async-runs/repository', () => ({ + claimWorkflowToolExecution: mockClaimWorkflowToolExecution, + getAsyncToolCall: mockGetAsyncToolCall, + getRunSegment: mockGetRunSegment, + releaseWorkflowToolExecutionClaim: mockReleaseWorkflowToolExecutionClaim, +})) + +vi.mock('@/lib/execution/event-buffer', () => ({ + createExecutionEventWriter: mockCreateExecutionEventWriter, + flushExecutionStreamReplayBuffer: mockFlushExecutionStreamReplayBuffer, + initializeExecutionStreamMeta: mockInitializeExecutionStreamMeta, + LIVE_ONLY_EXECUTION_EVENT_TYPES: new Set(), +})) + vi.mock('@/lib/execution/payloads/store', () => ({ storeLargeValue: vi.fn(async (_value, _json, size: number) => ({ __simLargeValueRef: true, @@ -174,6 +203,24 @@ function createSessionReplayRequest(executionId: string): NextRequest { ) } +function createBoundCopilotExecutionRequest(overrides: Record = {}): NextRequest { + return createMockRequest( + 'POST', + { + input: { hello: 'world' }, + stream: true, + isClientSession: true, + triggerType: 'copilot', + copilotToolCallId: 'copilot-tool-1', + ...overrides, + }, + { + 'Content-Type': 'application/json', + Cookie: 'session=value', + } + ) +} + interface ExecutionCallerCase { caseName: string authResult: Record @@ -289,7 +336,23 @@ describe('workflow execute async route', () => { key: `workflow-execution-id:${executionId}`, token: `token-${executionId}`, })) + mockClaimWorkflowToolExecution.mockResolvedValue({ + toolCallId: 'copilot-tool-1', + claimedBy: 'workflow:execution-123', + }) mockHasDurableExecutionOwner.mockResolvedValue(false) + mockGetAsyncToolCall.mockReset().mockResolvedValue({ + toolCallId: 'copilot-tool-1', + runId: 'copilot-run-1', + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + status: 'running', + }) + mockGetRunSegment.mockReset().mockResolvedValue({ + id: 'copilot-run-1', + userId: 'session-user-1', + workflowId: 'workflow-1', + }) requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('req-12345678') workflowsUtilsMockFns.mockWorkflowHasResponseBlock.mockReturnValue(false) @@ -328,7 +391,7 @@ describe('workflow execute async route', () => { }) workflowsPersistenceUtilsMockFns.mockLoadDeployedWorkflowState.mockResolvedValue(null) workflowsPersistenceUtilsMockFns.mockLoadWorkflowFromNormalizedTables.mockResolvedValue(null) - mockExecuteWorkflowCore.mockResolvedValue({ + mockExecuteWorkflowCore.mockReset().mockResolvedValue({ success: true, status: 'completed', output: { ok: true }, @@ -339,6 +402,244 @@ describe('workflow execute async route', () => { }, }) mockHandlePostExecutionPauseState.mockResolvedValue(undefined) + mockInitializeExecutionStreamMeta.mockReset().mockResolvedValue(true) + mockFlushExecutionStreamReplayBuffer.mockReset().mockResolvedValue(true) + mockCreateExecutionEventWriter.mockReset().mockReturnValue({ + write: vi.fn(async (event: unknown) => ({ event, eventId: '1' })), + writeTerminal: vi.fn(async (event: unknown) => ({ event, eventId: '2' })), + close: vi.fn().mockResolvedValue(undefined), + }) + loggingSessionMockFns.mockWaitForPostExecution.mockReset().mockResolvedValue(undefined) + }) + + it('binds a Copilot workflow tool only to its server log and waits before terminal SSE', async () => { + let releasePostExecution: (() => void) | undefined + loggingSessionMockFns.mockWaitForPostExecution.mockImplementationOnce( + () => + new Promise((resolve) => { + releasePostExecution = resolve + }) + ) + + const response = await POST(createBoundCopilotExecutionRequest(), { + params: Promise.resolve({ id: 'workflow-1' }), + }) + const bodyPromise = response.text() + + await vi.waitFor(() => { + expect(loggingSessionMockFns.mockWaitForPostExecution).toHaveBeenCalledTimes(1) + }) + let streamCompleted = false + void bodyPromise.then(() => { + streamCompleted = true + }) + await Promise.resolve() + + expect(response.status).toBe(200) + expect(streamCompleted).toBe(false) + expect(mockClaimWorkflowToolExecution).toHaveBeenCalledWith('copilot-tool-1', 'execution-123') + expect(mockReleaseWorkflowToolExecutionClaim).not.toHaveBeenCalled() + expect(loggingSessionMockFns.mockSetTrustedExecutionCorrelation).toHaveBeenCalledWith({ + executionId: 'execution-123', + requestId: 'req-12345678', + source: 'workflow', + workflowId: 'workflow-1', + triggerType: 'copilot', + copilotToolCallId: 'copilot-tool-1', + }) + const executionArgs = mockExecuteWorkflowCore.mock.calls[0][0] + expect(executionArgs).not.toHaveProperty('copilotToolCallId') + expect(executionArgs.snapshot.metadata).not.toHaveProperty('copilotToolCallId') + + releasePostExecution?.() + const body = await bodyPromise + expect(body).toContain('execution:completed') + }) + + it('rejects a competing Copilot workflow execution before logging starts', async () => { + mockClaimWorkflowToolExecution.mockResolvedValueOnce(null) + + const response = await POST(createBoundCopilotExecutionRequest(), { + params: Promise.resolve({ id: 'workflow-1' }), + }) + + expect(response.status).toBe(409) + expect(await response.json()).toEqual({ + error: 'Copilot workflow tool is already bound to another execution', + }) + expect(loggingSessionMockFns.mockSetTrustedExecutionCorrelation).not.toHaveBeenCalled() + expect(mockPreprocessExecution).not.toHaveBeenCalled() + expect(mockExecuteWorkflowCore).not.toHaveBeenCalled() + expect(mockReleaseWorkflowToolExecutionClaim).not.toHaveBeenCalled() + expect(mockReleaseExecutionIdClaim).toHaveBeenCalled() + }) + + it('releases a bound Copilot workflow claim when preprocessing rejects the run', async () => { + mockPreprocessExecution.mockResolvedValueOnce({ + success: false, + error: { message: 'Not admitted', statusCode: 402 }, + }) + + const response = await POST(createBoundCopilotExecutionRequest(), { + params: Promise.resolve({ id: 'workflow-1' }), + }) + + expect(response.status).toBe(402) + expect(mockReleaseWorkflowToolExecutionClaim).toHaveBeenCalledWith( + 'copilot-tool-1', + 'execution-123' + ) + expect(mockReleaseExecutionIdClaim).toHaveBeenCalled() + }) + + it('retains a bound Copilot workflow claim when preprocessing created a durable error log', async () => { + mockPreprocessExecution.mockResolvedValueOnce({ + success: false, + error: { message: 'Not admitted', statusCode: 402 }, + }) + mockHasDurableExecutionOwner.mockResolvedValueOnce(true) + + const response = await POST(createBoundCopilotExecutionRequest(), { + params: Promise.resolve({ id: 'workflow-1' }), + }) + + expect(response.status).toBe(402) + expect(mockReleaseWorkflowToolExecutionClaim).not.toHaveBeenCalled() + expect(mockReleaseExecutionIdClaim).not.toHaveBeenCalled() + }) + + it('binds a workflow execution after its page-hide confirmation detached the waiter', async () => { + mockGetAsyncToolCall.mockResolvedValueOnce({ + toolCallId: 'copilot-tool-1', + runId: 'copilot-run-1', + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + status: 'delivered', + claimedBy: null, + }) + + const response = await POST(createBoundCopilotExecutionRequest(), { + params: Promise.resolve({ id: 'workflow-1' }), + }) + + expect(response.status).toBe(200) + await response.text() + expect(mockClaimWorkflowToolExecution).toHaveBeenCalledWith('copilot-tool-1', 'execution-123') + }) + + it('binds an approved pending workflow call created by the previous release', async () => { + mockGetAsyncToolCall.mockResolvedValueOnce({ + toolCallId: 'copilot-tool-1', + runId: 'copilot-run-1', + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + status: 'pending', + permissionDecision: 'allow', + claimedBy: null, + }) + + const response = await POST(createBoundCopilotExecutionRequest(), { + params: Promise.resolve({ id: 'workflow-1' }), + }) + + expect(response.status).toBe(200) + await response.text() + expect(mockClaimWorkflowToolExecution).toHaveBeenCalledWith('copilot-tool-1', 'execution-123') + }) + + it.each([ + [ + 'pending tool row', + { + toolCallId: 'copilot-tool-1', + runId: 'copilot-run-1', + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + status: 'pending', + }, + { id: 'copilot-run-1', userId: 'session-user-1', workflowId: 'workflow-1' }, + ], + [ + 'terminal tool row', + { + toolCallId: 'copilot-tool-1', + runId: 'copilot-run-1', + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + status: 'completed', + }, + { id: 'copilot-run-1', userId: 'session-user-1', workflowId: 'workflow-1' }, + ], + [ + 'different workflow target', + { + toolCallId: 'copilot-tool-1', + runId: 'copilot-run-1', + toolName: 'run_workflow', + args: { workflowId: 'workflow-2' }, + status: 'running', + }, + { id: 'copilot-run-1', userId: 'session-user-1', workflowId: 'workflow-1' }, + ], + [ + 'different execution actor', + { + toolCallId: 'copilot-tool-1', + runId: 'copilot-run-1', + toolName: 'run_workflow', + args: { workflowId: 'workflow-1' }, + status: 'running', + }, + { id: 'copilot-run-1', userId: 'other-user', workflowId: 'workflow-1' }, + ], + ])('rejects a Copilot binding owned by a %s', async (_caseName, toolCall, run) => { + mockGetAsyncToolCall.mockResolvedValueOnce(toolCall) + mockGetRunSegment.mockResolvedValueOnce(run) + + const response = await POST(createBoundCopilotExecutionRequest(), { + params: Promise.resolve({ id: 'workflow-1' }), + }) + + expect(response.status).toBe(403) + expect(mockExecuteWorkflowCore).not.toHaveBeenCalled() + expect(loggingSessionMockFns.mockSetTrustedExecutionCorrelation).not.toHaveBeenCalled() + }) + + it('rejects Copilot workflow bindings outside the interactive SSE surface', async () => { + const response = await POST(createBoundCopilotExecutionRequest({ stream: false }), { + params: Promise.resolve({ id: 'workflow-1' }), + }) + + expect(response.status).toBe(400) + expect(mockGetAsyncToolCall).not.toHaveBeenCalled() + expect(mockExecuteWorkflowCore).not.toHaveBeenCalled() + }) + + it.each([ + [ + 'cancelled', + { + success: false, + status: 'cancelled', + output: {}, + logs: [], + metadata: { duration: 1 }, + }, + ], + ['error', new Error('execution failed')], + ])('waits for bound post-execution work on %s terminal paths', async (_caseName, outcome) => { + if (outcome instanceof Error) { + mockExecuteWorkflowCore.mockRejectedValueOnce(outcome) + } else { + mockExecuteWorkflowCore.mockResolvedValueOnce(outcome) + } + + const response = await POST(createBoundCopilotExecutionRequest(), { + params: Promise.resolve({ id: 'workflow-1' }), + }) + await response.text() + + expect(loggingSessionMockFns.mockWaitForPostExecution).toHaveBeenCalledTimes(1) }) it('reuses raw workflow input by execution ID without returning it to the client', async () => { diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index d9712918d9e..73854085f0c 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -19,6 +19,14 @@ import { type BillingAttributionSnapshot, requireBillingAttributionHeader, } from '@/lib/billing/core/billing-attribution' +import { isWorkflowToolExecutionClaimable } from '@/lib/copilot/async-runs/lifecycle' +import { + claimWorkflowToolExecution, + getAsyncToolCall, + getRunSegment, + releaseWorkflowToolExecutionClaim, +} from '@/lib/copilot/async-runs/repository' +import { isWorkflowToolName, resolveWorkflowToolTargetId } from '@/lib/copilot/tools/workflow-tools' import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate' import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs' import { isAsyncJobEnqueueError } from '@/lib/core/async-jobs/types' @@ -145,6 +153,27 @@ const WORKFLOW_EXECUTION_JOB_ID_PREFIX = 'workflow-execution:' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' +async function isValidCopilotWorkflowToolBinding(params: { + toolCallId: string + userId: string + workflowId: string +}): Promise { + const toolCall = await getAsyncToolCall(params.toolCallId) + if ( + !toolCall || + !isWorkflowToolName(toolCall.toolName) || + !isWorkflowToolExecutionClaimable(toolCall.status, toolCall.permissionDecision) + ) { + return false + } + + const run = await getRunSegment(toolCall.runId) + return ( + run?.userId === params.userId && + resolveWorkflowToolTargetId(toolCall.args, run.workflowId) === params.workflowId + ) +} + function createExecutionJsonResponse( body: Record, init: ResponseInit | undefined, @@ -593,6 +622,8 @@ async function handleExecutePost( let executionId = '' let executionIdClaim: ExecutionIdClaim | null = null let executionIdClaimCommitted = false + let workflowToolClaimAcquired = false + let copilotToolCallId: string | undefined try { const auth = await checkHybridAuth(req, { requireWorkflowId: false }) @@ -730,13 +761,19 @@ async function handleExecutePost( workflowStateOverride, deploymentVersionId: admittedDeploymentVersionId, executionId: rawBodyExecutionId, + copilotToolCallId: parsedCopilotToolCallId, triggerBlockId: parsedTriggerBlockId, startBlockId, stopAfterBlockId, runFromBlock: rawRunFromBlock, parentWorkspaceId, } = validation.data + copilotToolCallId = parsedCopilotToolCallId const triggerBlockId = parsedTriggerBlockId ?? startBlockId + const streamHeader = req.headers.get('X-Stream-Response') === 'true' + const enableSSE = streamHeader || streamParam === true + const executionModeHeader = req.headers.get('X-Execution-Mode') + const isAsyncMode = executionModeHeader === 'async' if (admittedDeploymentVersionId && !isMcpBridgeRequest) { return NextResponse.json( { error: 'deploymentVersionId is reserved for internal MCP execution' }, @@ -786,6 +823,20 @@ async function handleExecutePost( ) } + if ( + copilotToolCallId && + (auth.authType !== AuthType.SESSION || + !isClientSession || + triggerType !== 'copilot' || + !enableSSE || + isAsyncMode) + ) { + return NextResponse.json( + { error: 'Copilot tool execution binding is invalid for this request' }, + { status: 400 } + ) + } + if (auth.authType === 'api_key') { if (isClientSession) { return NextResponse.json( @@ -900,6 +951,7 @@ async function handleExecutePost( triggerBlockId: _triggerBlockId, stopAfterBlockId: _stopAfterBlockId, runFromBlock: _runFromBlock, + copilotToolCallId: _copilotToolCallId, workflowId: _workflowId, // Also exclude workflowId used for internal JWT auth parentWorkspaceId: _parentWorkspaceId, ...rest @@ -916,10 +968,6 @@ async function handleExecutePost( const shouldUseDraftState = isPublicApiAccess ? false : (useDraftState ?? auth.authType === AuthType.SESSION) - const streamHeader = req.headers.get('X-Stream-Response') === 'true' - const enableSSE = streamHeader || streamParam === true - const executionModeHeader = req.headers.get('X-Execution-Mode') - const isAsyncMode = executionModeHeader === 'async' const requiresWriteExecutionAccess = Boolean( useDraftState || workflowStateOverride || rawRunFromBlock ) @@ -1027,6 +1075,20 @@ async function handleExecutePost( ) } + if ( + copilotToolCallId && + !(await isValidCopilotWorkflowToolBinding({ + toolCallId: copilotToolCallId, + userId, + workflowId, + })) + ) { + return NextResponse.json( + { error: 'Copilot workflow tool binding was not found' }, + { status: 403 } + ) + } + if (inputFromExecutionId) { const { getExecutionInputForWorkflow } = await import( '@/lib/workflows/executor/execution-state' @@ -1094,12 +1156,33 @@ async function handleExecutePost( ) } + if (copilotToolCallId) { + const boundToolCall = await claimWorkflowToolExecution(copilotToolCallId, executionId) + if (!boundToolCall) { + return NextResponse.json( + { error: 'Copilot workflow tool is already bound to another execution' }, + { status: 409 } + ) + } + workflowToolClaimAcquired = true + } + const loggingSession = new LoggingSession( workflowId, executionId, loggingTriggerType, requestId ) + if (copilotToolCallId) { + loggingSession.setTrustedExecutionCorrelation({ + executionId, + requestId, + source: 'workflow', + workflowId, + triggerType, + copilotToolCallId, + }) + } /** The pre-fetched record avoids a redundant initial workflow lookup. */ const preprocessResult = await preprocessExecution({ @@ -1654,6 +1737,13 @@ async function handleExecutePost( const stream = new ReadableStream({ async start(controller) { let finalMetaStatus: 'complete' | 'error' | 'cancelled' | null = null + let postExecutionAwaited = false + + const awaitBoundCopilotPostExecution = async () => { + if (!copilotToolCallId || postExecutionAwaited) return + await loggingSession.waitForPostExecution() + postExecutionAwaited = true + } registerManualExecutionAborter(executionId, timeoutController.abort) isManualAbortRegistered = true @@ -2029,6 +2119,8 @@ async function handleExecutePost( runFromBlock: resolvedRunFromBlock, }) + await awaitBoundCopilotPostExecution() + await handlePostExecutionPauseState({ result, workflowId, executionId, loggingSession }) /** @@ -2167,6 +2259,7 @@ async function handleExecutePost( ) } } catch (error: unknown) { + await awaitBoundCopilotPostExecution() const isTimeout = isTimeoutError(error) || timeoutController.isTimedOut() const errorMessage = isTimeout ? getTimeoutErrorMessage(error, timeoutController.timeoutMs) @@ -2299,6 +2392,18 @@ async function handleExecutePost( } } + if (copilotToolCallId && workflowToolClaimAcquired && !executionIdClaimCommitted) { + try { + await releaseWorkflowToolExecutionClaim(copilotToolCallId, executionId) + } catch (error) { + reqLogger.warn('Failed to release pre-start Copilot workflow tool claim', { + error: toError(error).message, + executionId, + copilotToolCallId, + }) + } + } + if (executionIdClaim && !executionIdClaimCommitted) { try { await releaseExecutionIdClaim(executionIdClaim) diff --git a/apps/sim/app/api/workspaces/[id]/environment/route.test.ts b/apps/sim/app/api/workspaces/[id]/environment/route.test.ts index 759abd0b1e5..7f0f121d319 100644 --- a/apps/sim/app/api/workspaces/[id]/environment/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/environment/route.test.ts @@ -4,12 +4,17 @@ import { authMockFns, createMockRequest, environmentUtilsMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetWorkspaceById, mockGetUserEntityPermissions, mockGetWorkspaceEnvKeyAdminAccess } = - vi.hoisted(() => ({ - mockGetWorkspaceById: vi.fn(), - mockGetUserEntityPermissions: vi.fn(), - mockGetWorkspaceEnvKeyAdminAccess: vi.fn(), - })) +const { + mockGetPersonalEnvKeyRawAccess, + mockGetWorkspaceById, + mockGetUserEntityPermissions, + mockGetWorkspaceEnvKeyAdminAccess, +} = vi.hoisted(() => ({ + mockGetPersonalEnvKeyRawAccess: vi.fn(), + mockGetWorkspaceById: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), + mockGetWorkspaceEnvKeyAdminAccess: vi.fn(), +})) vi.mock('@/lib/workspaces/permissions/utils', () => ({ getWorkspaceById: mockGetWorkspaceById, @@ -19,6 +24,7 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ const mockGetPersonalAndWorkspaceEnv = environmentUtilsMockFns.mockGetPersonalAndWorkspaceEnv vi.mock('@/lib/credentials/environment', () => ({ + getPersonalEnvKeyRawAccess: mockGetPersonalEnvKeyRawAccess, getWorkspaceEnvKeyAdminAccess: mockGetWorkspaceEnvKeyAdminAccess, createWorkspaceEnvCredentials: vi.fn(), deleteWorkspaceEnvCredentials: vi.fn(), @@ -47,9 +53,14 @@ describe('GET /api/workspaces/[id]/environment', () => { mockGetWorkspaceById.mockResolvedValue({ id: WORKSPACE_ID }) mockGetPersonalAndWorkspaceEnv.mockResolvedValue({ workspaceDecrypted: { OPENAI_API_KEY: 'sk-secret', DATABASE_URL: 'postgres://secret' }, - personalDecrypted: { PERSONAL: { value: 'p' } }, + personalDecrypted: { PERSONAL: 'personal-secret', SHARED_PERSONAL: 'shared-secret' }, + personalOwners: { PERSONAL: 'u-1', SHARED_PERSONAL: 'owner-2' }, conflicts: [], }) + mockGetPersonalEnvKeyRawAccess.mockResolvedValue({ + ownedKeys: new Set(['PERSONAL']), + adminKeys: new Set(), + }) }) it('returns 401 when the caller has no workspace permission', async () => { @@ -116,7 +127,7 @@ describe('GET /api/workspaces/[id]/environment', () => { expect(body.data.workspace.DATABASE_URL).toBe('') }) - it('always returns personal values untouched', async () => { + it('reveals own personal values and masks shared personal values without an admin grant', async () => { mockGetUserEntityPermissions.mockResolvedValue('read') mockGetWorkspaceEnvKeyAdminAccess.mockResolvedValue({ adminKeys: new Set(), @@ -125,6 +136,25 @@ describe('GET /api/workspaces/[id]/environment', () => { const { body } = await callGet() - expect(body.data.personal).toEqual({ PERSONAL: { value: 'p' } }) + expect(body.data.personal).toEqual({ PERSONAL: 'personal-secret', SHARED_PERSONAL: '' }) + }) + + it('reveals shared personal values to an active credential admin', async () => { + mockGetUserEntityPermissions.mockResolvedValue('write') + mockGetWorkspaceEnvKeyAdminAccess.mockResolvedValue({ + adminKeys: new Set(), + knownKeys: new Set(['OPENAI_API_KEY', 'DATABASE_URL']), + }) + mockGetPersonalEnvKeyRawAccess.mockResolvedValue({ + ownedKeys: new Set(['PERSONAL']), + adminKeys: new Set(['SHARED_PERSONAL']), + }) + + const { body } = await callGet() + + expect(body.data.personal).toEqual({ + PERSONAL: 'personal-secret', + SHARED_PERSONAL: 'shared-secret', + }) }) }) diff --git a/apps/sim/app/api/workspaces/[id]/environment/route.ts b/apps/sim/app/api/workspaces/[id]/environment/route.ts index f7aad4aba15..98194c979b0 100644 --- a/apps/sim/app/api/workspaces/[id]/environment/route.ts +++ b/apps/sim/app/api/workspaces/[id]/environment/route.ts @@ -18,6 +18,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createWorkspaceEnvCredentials, deleteWorkspaceEnvCredentials, + getPersonalEnvKeyRawAccess, getWorkspaceEnvKeyAdminAccess, } from '@/lib/credentials/environment' import { @@ -74,6 +75,32 @@ async function maskWorkspaceEnvForViewer({ return masked } +async function maskPersonalEnvForViewer({ + personalDecrypted, + personalOwners, + workspaceId, + userId, +}: { + personalDecrypted: Record + personalOwners: Record + workspaceId: string + userId: string +}): Promise> { + const personalKeys = Object.keys(personalDecrypted) + const { ownedKeys, adminKeys } = await getPersonalEnvKeyRawAccess({ + workspaceId, + personalOwners, + userId, + }) + + return Object.fromEntries( + personalKeys.map((key) => [ + key, + ownedKeys.has(key) || adminKeys.has(key) ? personalDecrypted[key] : '', + ]) + ) +} + export const GET = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() @@ -98,10 +125,8 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { workspaceDecrypted, personalDecrypted, conflicts } = await getPersonalAndWorkspaceEnv( - userId, - workspaceId - ) + const { workspaceDecrypted, personalDecrypted, personalOwners, conflicts } = + await getPersonalAndWorkspaceEnv(userId, workspaceId) const workspace = await maskWorkspaceEnvForViewer({ workspaceDecrypted, @@ -109,12 +134,18 @@ export const GET = withRouteHandler( userId, permission, }) + const personal = await maskPersonalEnvForViewer({ + personalDecrypted, + personalOwners, + workspaceId, + userId, + }) return NextResponse.json( { data: { workspace, - personal: personalDecrypted, + personal, conflicts, }, }, diff --git a/apps/sim/app/api/workspaces/[id]/inbox/route.test.ts b/apps/sim/app/api/workspaces/[id]/inbox/route.test.ts new file mode 100644 index 00000000000..a3eebf6272c --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/inbox/route.test.ts @@ -0,0 +1,87 @@ +/** + * @vitest-environment node + */ +import { + authMockFns, + createMockRequest, + dbChainMock, + dbChainMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetUserEntityPermissions, mockHasWorkspaceInboxAccess } = vi.hoisted(() => ({ + mockGetUserEntityPermissions: vi.fn(), + mockHasWorkspaceInboxAccess: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) + +vi.mock('@/lib/billing/core/subscription', () => ({ + hasWorkspaceInboxAccess: mockHasWorkspaceInboxAccess, +})) + +vi.mock('@/lib/mothership/inbox/lifecycle', () => ({ + disableInbox: vi.fn(), + enableInbox: vi.fn(), + updateInboxAddress: vi.fn(), +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mockGetUserEntityPermissions, +})) + +import { PATCH } from '@/app/api/workspaces/[id]/inbox/route' + +const context = { params: Promise.resolve({ id: 'workspace-1' }) } + +describe('Inbox config secret policy', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'admin-1' } }) + mockGetUserEntityPermissions.mockResolvedValue('admin') + mockHasWorkspaceInboxAccess.mockResolvedValue(true) + }) + + it('updates policy without requiring an inbox lifecycle mutation', async () => { + queueTableRows(schemaMock.workspace, [ + { + inboxEnabled: true, + inboxAddress: 'tasks@example.com', + inboxProviderId: 'provider-1', + inboxSecretScope: 'all', + inboxMountedSecrets: [], + }, + ]) + + const response = await PATCH( + createMockRequest( + 'PATCH', + { secretScope: 'selected', mountedSecrets: [' B ', 'A', 'B'] }, + undefined, + 'http://localhost:3000/api/workspaces/workspace-1/inbox' + ), + context + ) + + const body = await response.json() + expect({ status: response.status, body }).toMatchObject({ + status: 200, + body: { + enabled: true, + address: 'tasks@example.com', + secretScope: 'selected', + mountedSecrets: ['B', 'A'], + }, + }) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + inboxSecretScope: 'selected', + inboxMountedSecrets: ['B', 'A'], + }) + ) + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/inbox/route.ts b/apps/sim/app/api/workspaces/[id]/inbox/route.ts index bbaa2594986..0bcc27b959d 100644 --- a/apps/sim/app/api/workspaces/[id]/inbox/route.ts +++ b/apps/sim/app/api/workspaces/[id]/inbox/route.ts @@ -7,6 +7,7 @@ import { updateInboxConfigContract } from '@/lib/api/contracts/inbox' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { hasWorkspaceInboxAccess } from '@/lib/billing/core/subscription' +import { normalizeSecretMountPolicy } from '@/lib/copilot/secret-mount-policy' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { disableInbox, enableInbox, updateInboxAddress } from '@/lib/mothership/inbox/lifecycle' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -31,6 +32,8 @@ export const GET = withRouteHandler( .select({ inboxEnabled: workspace.inboxEnabled, inboxAddress: workspace.inboxAddress, + inboxSecretScope: workspace.inboxSecretScope, + inboxMountedSecrets: workspace.inboxMountedSecrets, }) .from(workspace) .where(eq(workspace.id, workspaceId)) @@ -68,6 +71,10 @@ export const GET = withRouteHandler( return NextResponse.json({ enabled: ws.inboxEnabled, address: ws.inboxAddress, + ...normalizeSecretMountPolicy({ + secretScope: ws.inboxSecretScope, + mountedSecrets: ws.inboxMountedSecrets, + }), entitled, taskStats: stats, }) @@ -92,9 +99,57 @@ export const PATCH = withRouteHandler( const body = parsed.data.body try { + const [current] = await db + .select({ + inboxEnabled: workspace.inboxEnabled, + inboxAddress: workspace.inboxAddress, + inboxProviderId: workspace.inboxProviderId, + inboxSecretScope: workspace.inboxSecretScope, + inboxMountedSecrets: workspace.inboxMountedSecrets, + }) + .from(workspace) + .where(eq(workspace.id, workspaceId)) + .limit(1) + if (!current) { + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + } + + const hasPolicyUpdate = body.secretScope !== undefined || body.mountedSecrets !== undefined + const secretMountPolicy = normalizeSecretMountPolicy({ + secretScope: body.secretScope ?? current.inboxSecretScope, + mountedSecrets: body.mountedSecrets ?? current.inboxMountedSecrets, + }) + const persistPolicy = async () => { + if (!hasPolicyUpdate) return + await db + .update(workspace) + .set({ + inboxSecretScope: secretMountPolicy.secretScope, + inboxMountedSecrets: secretMountPolicy.mountedSecrets, + updatedAt: new Date(), + }) + .where(eq(workspace.id, workspaceId)) + } + if (body.enabled === false) { await disableInbox(workspaceId) - return NextResponse.json({ enabled: false, address: null }) + await persistPolicy() + return NextResponse.json({ + enabled: false, + address: null, + providerId: null, + ...secretMountPolicy, + }) + } + + if (body.enabled === undefined && body.username === undefined && hasPolicyUpdate) { + await persistPolicy() + return NextResponse.json({ + enabled: current.inboxEnabled, + address: current.inboxAddress, + providerId: current.inboxProviderId, + ...secretMountPolicy, + }) } if (!(await hasWorkspaceInboxAccess(workspaceId))) { @@ -102,21 +157,18 @@ export const PATCH = withRouteHandler( } if (body.enabled === true) { - const [current] = await db - .select({ inboxEnabled: workspace.inboxEnabled }) - .from(workspace) - .where(eq(workspace.id, workspaceId)) - .limit(1) - if (current?.inboxEnabled) { + if (current.inboxEnabled) { return NextResponse.json({ error: 'Inbox is already enabled' }, { status: 409 }) } const config = await enableInbox(workspaceId, { username: body.username }) - return NextResponse.json(config) + await persistPolicy() + return NextResponse.json({ ...config, ...secretMountPolicy }) } if (body.username) { const config = await updateInboxAddress(workspaceId, body.username) - return NextResponse.json(config) + await persistPolicy() + return NextResponse.json({ ...config, ...secretMountPolicy }) } return NextResponse.json({ error: 'No valid update provided' }, { status: 400 }) diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-context-menu/task-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-context-menu/task-context-menu.tsx index f7bf16ba921..b9afc65ea72 100644 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-context-menu/task-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-context-menu/task-context-menu.tsx @@ -16,6 +16,7 @@ interface TaskContextMenuProps { onClose: () => void /** The right-clicked task; its status decides which actions render. */ task: ScheduledTask | null + canEdit: boolean onEdit: () => void /** Opens a new-task modal pre-filled from this task. */ onDuplicate: () => void @@ -37,6 +38,7 @@ export function TaskContextMenu({ position, onClose, task, + canEdit, onEdit, onDuplicate, onPause, @@ -72,10 +74,12 @@ export function TaskContextMenu({ > {isUpcoming ? ( <> - - - Edit - + {canEdit && ( + + + Edit + + )} {canPauseResume && (task?.disabled ? ( diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-details-modal/task-details-modal.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-details-modal/task-details-modal.tsx index 2666b89186e..b52cb043e54 100644 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-details-modal/task-details-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-details-modal/task-details-modal.tsx @@ -39,8 +39,8 @@ interface TaskDetailsModalProps { } /** - * Read-only record modal for tasks that are running or already finished — - * pending tasks open the edit `TaskModal` instead. Three plaintext fields: + * Read-only record modal for tasks that are running, finished, or owned by + * another execution actor. Three plaintext fields: * Status and the run time as copy fields, the prompt as a view-only chip editor. */ export function TaskDetailsModal({ task, onClose }: TaskDetailsModalProps) { diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/secret-access-section.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/secret-access-section.tsx new file mode 100644 index 00000000000..a4b14c8adb7 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/secret-access-section.tsx @@ -0,0 +1,67 @@ +'use client' + +import { ChipModalField, ChipModalSeparator, ChipSelect } from '@sim/emcn' +import type { SecretMountPolicy } from '@/lib/copilot/secret-mount-policy' +import { useRawMountableSecretOptions } from '@/hooks/queries/secret-mount-options' + +const SECRET_SCOPE_OPTIONS = [ + { value: 'all', label: 'All secrets' }, + { value: 'selected', label: 'Selected secrets' }, +] + +interface SecretAccessSectionProps extends SecretMountPolicy { + workspaceId: string + onChange: (policy: SecretMountPolicy) => void +} + +export function SecretAccessSection({ + workspaceId, + secretScope, + mountedSecrets, + onChange, +}: SecretAccessSectionProps) { + const { options, isPending } = useRawMountableSecretOptions(workspaceId) + + return ( +
+ +
+ + + onChange({ + secretScope: value === 'selected' ? 'selected' : 'all', + mountedSecrets, + }) + } + options={SECRET_SCOPE_OPTIONS} + /> + + + {secretScope === 'selected' && ( + + + onChange({ secretScope: 'selected', mountedSecrets: values }) + } + disabled={isPending} + /> + + )} +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/task-modal.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/task-modal.tsx index de88be597bf..fad48a1bd31 100644 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/task-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/task-modal.tsx @@ -13,12 +13,17 @@ import { import { Calendar } from '@sim/emcn/icons' import { format } from 'date-fns' import { useParams } from 'next/navigation' +import { + DEFAULT_SECRET_MOUNT_POLICY, + type SecretMountPolicy, +} from '@/lib/copilot/secret-mount-policy' import { wallClockNow, zonedWallClockToUtc } from '@/lib/core/utils/timezone' import { PromptEditor, usePromptEditor, } from '@/app/workspace/[workspaceId]/home/components/user-input/components' import { RecurrenceSection } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/recurrence-section' +import { SecretAccessSection } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/secret-access-section' import type { CalendarSlot } from '@/app/workspace/[workspaceId]/scheduled-tasks/hooks/use-calendar' import { DEFAULT_RECURRENCE, @@ -69,7 +74,7 @@ function defaultLaunch( } /** The data a task create or edit captures. */ -export interface TaskDraft { +export interface TaskDraft extends SecretMountPolicy { prompt: string /** Resources the prompt `@`-mentions / skills it `/`-invokes, when any. */ contexts?: ChatContext[] @@ -80,7 +85,7 @@ export interface TaskDraft { } /** Pre-filled fields shared by the edit and duplicate flows. */ -export interface TaskPrefill { +export interface TaskPrefill extends SecretMountPolicy { prompt: string /** Stored `@`-mention contexts, re-registered so they carry over. */ contexts?: ChatContext[] @@ -223,6 +228,10 @@ function TaskModalContent({ const [recurrence, setRecurrence] = useState( () => source?.recurrence ?? DEFAULT_RECURRENCE ) + const [secretPolicy, setSecretPolicy] = useState(() => ({ + secretScope: source?.secretScope ?? DEFAULT_SECRET_MOUNT_POLICY.secretScope, + mountedSecrets: source?.mountedSecrets ?? DEFAULT_SECRET_MOUNT_POLICY.mountedSecrets, + })) const launchEditedRef = useRef(false) /** * Synchronous mirror of `submitting` that gates {@link handleSubmit}. The @@ -286,6 +295,7 @@ function TaskModalContent({ launchTime, timezone, recurrence, + ...secretPolicy, }) ) .then(() => true) @@ -331,6 +341,7 @@ function TaskModalContent({ /> + maxRuns: fields.maxRuns ?? null, endsAt: fields.endsAt ?? null, contexts: draft.contexts ?? [], + secretScope: draft.secretScope, + mountedSecrets: draft.mountedSecrets, } } @@ -212,6 +216,8 @@ export function useScheduledTasks({ launchTime, timezone: schedule.timezone, recurrence, + secretScope: schedule.secretScope, + mountedSecrets: schedule.mountedSecrets, } }, [schedules] diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/scheduled-tasks.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/scheduled-tasks.tsx index 8f3410fe014..4d8f1c86cd1 100644 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/scheduled-tasks.tsx +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/scheduled-tasks.tsx @@ -3,6 +3,7 @@ import { useCallback, useMemo, useState } from 'react' import { Calendar, Plus } from '@sim/emcn/icons' import { useParams } from 'next/navigation' +import { useSession } from '@/lib/auth/auth-client' import type { ResourceAction } from '@/app/workspace/[workspaceId]/components' import { Resource } from '@/app/workspace/[workspaceId]/components' import { ScheduleCalendar } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar' @@ -23,6 +24,7 @@ import { useTimezone } from '@/hooks/queries/general-settings' export function ScheduledTasks() { const { workspaceId } = useParams<{ workspaceId: string }>() + const { data: session } = useSession() const timezone = useTimezone() const calendar = useCalendar(timezone) @@ -32,9 +34,12 @@ export function ScheduledTasks() { ) const tasks = useScheduledTasks({ workspaceId, rangeStart: range.start, rangeEnd: range.end }) - /** Pending tasks open the editable TaskModal; running/finished open the record. */ - const editTask = tasks.selectedTask?.status === 'pending' ? tasks.selectedTask : null - const recordTask = tasks.selectedTask?.status !== 'pending' ? tasks.selectedTask : null + /** Only the execution actor may edit task contents; every other view is read-only. */ + const selectedTaskIsEditable = + tasks.selectedTask?.status === 'pending' && + tasks.selectedTask.sourceUserId === session?.user?.id + const editTask = selectedTaskIsEditable ? tasks.selectedTask : null + const recordTask = tasks.selectedTask && !selectedTaskIsEditable ? tasks.selectedTask : null const editSeed = editTask ? tasks.editSeedFor(editTask) : null const { @@ -183,6 +188,7 @@ export function ScheduledTasks() { position={taskContextMenuPosition} onClose={closeTaskContextMenu} task={contextTask} + canEdit={contextTask?.sourceUserId === session?.user?.id} onEdit={openContextTask} onDuplicate={handleDuplicate} onPause={handlePauseContextTask} diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events.test.ts b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events.test.ts index 313af910c11..e2c08c48b30 100644 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events.test.ts @@ -15,6 +15,7 @@ function makeTask(overrides: Partial): ScheduledTask { return { id: 't1', scheduleId: 's1', + sourceUserId: 'user-1', prompt: 'Summarize yesterday', runAt: new Date('2026-06-10T14:30:00.000Z'), timezone: 'UTC', @@ -93,13 +94,18 @@ describe('taskToCalendarEvent', () => { describe('scheduleToTasks', () => { it('renders an active one-time task as a single pending occurrence at its next run', () => { const tasks = scheduleToTasks( - makeRow({ nextRunAt: '2026-06-11T09:00:00.000Z' }), + makeRow({ nextRunAt: '2026-06-11T09:00:00.000Z', sourceUserId: 'creator-1' }), RANGE_START, RANGE_END, NOW ) expect(tasks).toHaveLength(1) - expect(tasks[0]).toMatchObject({ scheduleId: 's1', status: 'pending', recurring: false }) + expect(tasks[0]).toMatchObject({ + scheduleId: 's1', + sourceUserId: 'creator-1', + status: 'pending', + recurring: false, + }) expect(tasks[0].runAt.toISOString()).toBe('2026-06-11T09:00:00.000Z') }) diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events.ts b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events.ts index 141a5d7c32c..0361e6325b3 100644 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events.ts +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events.ts @@ -20,6 +20,8 @@ export interface ScheduledTask { id: string /** The persisted schedule id, used to edit or delete the task. */ scheduleId: string + /** The user whose authority executes the task and who may edit its contents. */ + sourceUserId: string | null /** The instruction Sim runs. Doubles as the calendar title. */ prompt: string /** Resources the prompt `@`-mentions / skills it `/`-invokes, when any. */ @@ -100,6 +102,7 @@ export function scheduleToTasks( const contexts = (row.contexts ?? undefined) as unknown as ChatContext[] | undefined const base = { scheduleId: row.id, + sourceUserId: row.sourceUserId, prompt: row.prompt ?? '', contexts, timezone: row.timezone, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-settings-tab/inbox-settings-tab.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-settings-tab/inbox-settings-tab.tsx index d34e1036307..b9491208fe9 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-settings-tab/inbox-settings-tab.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-settings-tab/inbox-settings-tab.tsx @@ -11,6 +11,8 @@ import { ChipModalField, ChipModalFooter, ChipModalHeader, + ChipSelect, + Label, Tooltip, useCopyToClipboard, } from '@sim/emcn' @@ -24,7 +26,16 @@ import { useInboxSenders, useRemoveInboxSender, useUpdateInboxAddress, + useUpdateInboxSecretPolicy, } from '@/hooks/queries/inbox' +import { useRawMountableSecretOptions } from '@/hooks/queries/secret-mount-options' + +const SECRET_SCOPE_OPTIONS = [ + { value: 'all', label: 'All secrets' }, + { value: 'selected', label: 'Selected secrets' }, +] + +const DROPDOWN_TRIGGER_CLASS = 'w-[240px] flex-shrink-0' export function InboxSettingsTab() { const params = useParams() @@ -33,6 +44,7 @@ export function InboxSettingsTab() { const { data: config } = useInboxConfig(workspaceId) const { data: sendersData, isLoading: sendersLoading } = useInboxSenders(workspaceId) const updateAddress = useUpdateInboxAddress() + const updateSecretPolicy = useUpdateInboxSecretPolicy() const addSender = useAddInboxSender() const removeSender = useRemoveInboxSender() @@ -47,6 +59,11 @@ export function InboxSettingsTab() { const [removeSenderError, setRemoveSenderError] = useState(null) const { copied: copiedAddress, copy } = useCopyToClipboard() + const { options: secretOptions, isPending: secretOptionsPending } = + useRawMountableSecretOptions(workspaceId) + + const secretScope = config?.secretScope ?? 'all' + const mountedSecrets = config?.mountedSecrets ?? [] const handleCopyAddress = useCallback(() => { if (config?.address) void copy(config.address) @@ -228,6 +245,60 @@ export function InboxSettingsTab() { + + +
+
+ +
+ + updateSecretPolicy.mutate({ + workspaceId, + secretScope: value === 'selected' ? 'selected' : 'all', + mountedSecrets, + }) + } + options={SECRET_SCOPE_OPTIONS} + disabled={updateSecretPolicy.isPending} + /> +
+
+ + {secretScope === 'selected' && ( +
+ +
+ + updateSecretPolicy.mutate({ + workspaceId, + secretScope: 'selected', + mountedSecrets: values, + }) + } + disabled={secretOptionsPending || updateSecretPolicy.isPending} + /> +
+
+ )} +
+
0 ? { contexts: jobRecord.contexts } : {}), diff --git a/apps/sim/blocks/blocks/mothership.ts b/apps/sim/blocks/blocks/mothership.ts index 74e81cb07d8..7bf9e17fe7f 100644 --- a/apps/sim/blocks/blocks/mothership.ts +++ b/apps/sim/blocks/blocks/mothership.ts @@ -1,4 +1,5 @@ import { Blimp } from '@sim/emcn' +import { fetchWorkspaceRawSecretNameOptions } from '@/lib/workflows/subblocks/options' import type { BlockConfig } from '@/blocks/types' import type { ToolResponse } from '@/tools/types' @@ -72,6 +73,31 @@ export const MothershipBlock: BlockConfig = { type: 'skill-input', defaultValue: [], }, + { + id: 'secretScope', + title: 'Secret access', + type: 'dropdown', + mode: 'advanced', + hideFromCopilot: true, + options: [ + { label: 'All secrets', id: 'all' }, + { label: 'Selected secrets', id: 'selected' }, + ], + value: () => 'all', + }, + { + id: 'mountedSecrets', + title: 'Secrets', + type: 'dropdown', + mode: 'advanced', + hideFromCopilot: true, + multiSelect: true, + searchable: true, + preserveLabelCase: true, + options: [], + condition: { field: 'secretScope', value: 'selected' }, + fetchOptions: () => fetchWorkspaceRawSecretNameOptions(), + }, ], tools: { access: [], @@ -91,6 +117,8 @@ export const MothershipBlock: BlockConfig = { }, tools: { type: 'json', description: 'MCP tools available to Sim for this request' }, skills: { type: 'json', description: 'Skills activated for this request' }, + secretScope: { type: 'string', description: 'Secret access mode: all or selected' }, + mountedSecrets: { type: 'json', description: 'Secret names available to Sim code execution' }, }, outputs: { content: { type: 'string', description: 'Generated response content' }, diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index 716625d7102..10b2917c3c1 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -337,6 +337,8 @@ export interface SubBlockConfig { connectionDroppable?: boolean hidden?: boolean hideFromPreview?: boolean // Hide this subblock from the workflow block preview + /** Excludes server-only lifecycle configuration from Copilot workflow state and schemas. */ + hideFromCopilot?: boolean hideDividerBefore?: boolean // Visually group this field with the preceding visible subblock showWhenEnvSet?: string // Show this subblock only when a named NEXT_PUBLIC_ env var is truthy; comma-separated means any of them hideWhenHosted?: boolean // Hide this subblock when running on hosted sim diff --git a/apps/sim/executor/execution/block-executor.test.ts b/apps/sim/executor/execution/block-executor.test.ts index e267183e144..d37e71b8887 100644 --- a/apps/sim/executor/execution/block-executor.test.ts +++ b/apps/sim/executor/execution/block-executor.test.ts @@ -440,6 +440,72 @@ describe('BlockExecutor', () => { expect(output).not.toEqual({ content: '' }) }) + it('keeps Sim Chat secret policy in runtime inputs and out of trace inputs', async () => { + const block = createBlock() + block.id = 'mothership-block-1' + block.metadata = { id: BlockType.MOTHERSHIP, name: 'Sim Chat' } + block.config = { + tool: BlockType.MOTHERSHIP, + params: { + prompt: 'Run the task', + secretScope: 'selected', + mountedSecrets: ['OPENAI_API_KEY'], + }, + } + block.privateInputIds = ['secretScope', 'mountedSecrets'] + const workflow: SerializedWorkflow = { + version: '1', + blocks: [block], + connections: [], + loops: {}, + parallels: {}, + } + const state = new ExecutionState() + const resolver = new VariableResolver(workflow, {}, state) + const handler: BlockHandler = { + canHandle: () => true, + execute: async (_ctx, _block, inputs) => { + expect(inputs).toMatchObject({ + prompt: 'Run the task', + secretScope: 'selected', + mountedSecrets: ['OPENAI_API_KEY'], + }) + return { content: 'done' } + }, + } + const executor = new BlockExecutor( + [handler], + resolver, + { + workspaceId: 'workspace-1', + executionId: 'execution-1', + userId: 'user-1', + metadata: { + requestId: 'request-1', + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + userId: 'user-1', + triggerType: 'manual', + useDraftState: false, + startTime: new Date().toISOString(), + }, + }, + state + ) + const ctx = createContext(state) + + await executor.execute(ctx, createNode(block), block) + + expect(ctx.blockLogs[0]?.input).toEqual({ prompt: 'Run the task' }) + const { traceSpans } = buildTraceSpans({ + success: true, + output: { content: 'done' }, + logs: ctx.blockLogs, + }) + expect(traceSpans[0]?.input).toEqual({ prompt: 'Run the task' }) + }) + it('projects a resolved secret out of Function syntax-error TraceSpans only', async () => { const secret = 'function-secret-literal-7f3a91' const block = createBlock() diff --git a/apps/sim/executor/execution/block-executor.ts b/apps/sim/executor/execution/block-executor.ts index 105eb517863..b4f90fcc1c7 100644 --- a/apps/sim/executor/execution/block-executor.ts +++ b/apps/sim/executor/execution/block-executor.ts @@ -160,7 +160,7 @@ export class BlockExecutor { } if (blockLog) { - blockLog.input = this.sanitizeInputsForLog(inputsForLog, block.metadata?.id) + blockLog.input = this.sanitizeInputsForLog(inputsForLog, block) } } catch (error) { cleanupSelfReference?.() @@ -300,7 +300,7 @@ export class BlockExecutor { ctx, node, block, - this.sanitizeInputsForLog(inputsForLog, block.metadata?.id), + this.sanitizeInputsForLog(inputsForLog, block), displayOutput, duration, blockLog.startedAt, @@ -413,7 +413,7 @@ export class BlockExecutor { blockLog.durationMs = duration blockLog.success = true blockLog.error = undefined - blockLog.input = this.sanitizeInputsForLog(input, block.metadata?.id) + blockLog.input = this.sanitizeInputsForLog(input, block) blockLog.output = filterOutputForLog(block.metadata?.id || '', softOutput, { block }) } @@ -428,7 +428,7 @@ export class BlockExecutor { ctx, node, block, - this.sanitizeInputsForLog(input, block.metadata?.id), + this.sanitizeInputsForLog(input, block), filterOutputForLog(block.metadata?.id || '', softOutput, { block }), duration, blockLog.startedAt, @@ -480,7 +480,7 @@ export class BlockExecutor { blockLog.durationMs = duration blockLog.success = false blockLog.error = errorMessage - blockLog.input = this.sanitizeInputsForLog(input, block.metadata?.id) + blockLog.input = this.sanitizeInputsForLog(input, block) blockLog.output = filterOutputForLog(block.metadata?.id || '', errorOutput, { block }) if (ChildWorkflowError.isChildWorkflowError(error) && error.childTraceSpans.length > 0) { @@ -507,7 +507,7 @@ export class BlockExecutor { ctx, node, block, - this.sanitizeInputsForLog(input, block.metadata?.id), + this.sanitizeInputsForLog(input, block), displayOutput, duration, blockLog.startedAt, @@ -631,8 +631,10 @@ export class BlockExecutor { */ private sanitizeInputsForLog( inputs: Record, - blockType?: string + block?: SerializedBlock ): Record { + const blockType = block?.metadata?.id + const privateInputIds = new Set(block?.privateInputIds ?? []) // Custom (deploy-as-block) blocks run via an internal `workflow_executor`; the // baked `workflowId`/`inputMapping` wrapper is plumbing. Log the mapped input // field values (the inputMapping contents) instead. @@ -658,7 +660,8 @@ export class BlockExecutor { SYSTEM_SUBBLOCK_IDS.includes(key) || key === 'triggerMode' || key === FUNCTION_BLOCK_CONTEXT_VARS_KEY || - key === FUNCTION_BLOCK_DISPLAY_CODE_KEY + key === FUNCTION_BLOCK_DISPLAY_CODE_KEY || + privateInputIds.has(key) ) { continue } diff --git a/apps/sim/executor/handlers/mothership/mothership-handler.test.ts b/apps/sim/executor/handlers/mothership/mothership-handler.test.ts index c70776aba80..63d4750ba5f 100644 --- a/apps/sim/executor/handlers/mothership/mothership-handler.test.ts +++ b/apps/sim/executor/handlers/mothership/mothership-handler.test.ts @@ -377,6 +377,8 @@ describe('MothershipBlockHandler', () => { chatId: 'chat-uuid', messageId: 'message-uuid', requestId: 'request-uuid', + secretScope: 'all', + mountedSecrets: [], workflowId: 'workflow-1', executionId: 'execution-1', }) @@ -443,6 +445,8 @@ describe('MothershipBlockHandler', () => { chatId: 'existing-chat-id', messageId: 'message-uuid', requestId: 'request-uuid', + secretScope: 'all', + mountedSecrets: [], workflowId: 'workflow-1', executionId: 'execution-1', }) diff --git a/apps/sim/executor/handlers/mothership/mothership-handler.ts b/apps/sim/executor/handlers/mothership/mothership-handler.ts index b12dea62f66..cbc288f6e44 100644 --- a/apps/sim/executor/handlers/mothership/mothership-handler.ts +++ b/apps/sim/executor/handlers/mothership/mothership-handler.ts @@ -5,6 +5,7 @@ import { BILLING_ATTRIBUTION_HEADER, serializeBillingAttributionHeader, } from '@/lib/billing/core/billing-attribution' +import { normalizeSecretMountPolicy } from '@/lib/copilot/secret-mount-policy' import { env } from '@/lib/core/config/env' import { isExecutionCancelled, isRedisCancellationEnabled } from '@/lib/execution/cancellation' import { readUserFileContent } from '@/lib/execution/payloads/materialization.server' @@ -397,6 +398,10 @@ export class MothershipBlockHandler implements BlockHandler { const chatId = providedConversationId || generateId() const messageId = generateId() const requestId = generateId() + const secretMountPolicy = normalizeSecretMountPolicy({ + secretScope: inputs.secretScope, + mountedSecrets: inputs.mountedSecrets, + }) const fileAttachments = await buildMothershipFileAttachments(inputs.files, ctx, requestId) const mcpTools = Array.isArray(inputs.tools) ? inputs.tools.filter( @@ -442,6 +447,8 @@ export class MothershipBlockHandler implements BlockHandler { chatId, messageId, requestId, + secretScope: secretMountPolicy.secretScope, + mountedSecrets: secretMountPolicy.mountedSecrets, ...(fileAttachments && { fileAttachments }), ...(mcpTools.length > 0 ? { mcpTools } : {}), ...(skillContexts.length > 0 ? { contexts: skillContexts } : {}), diff --git a/apps/sim/executor/utils/code-secret-references.test.ts b/apps/sim/executor/utils/code-secret-references.test.ts new file mode 100644 index 00000000000..4ebf81a0ebe --- /dev/null +++ b/apps/sim/executor/utils/code-secret-references.test.ts @@ -0,0 +1,37 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { extractCodeSecretNames } from '@/executor/utils/code-secret-references' + +describe('Copilot code secret declarations', () => { + it.each(['javascript', 'python'])( + 'matches trimmed and embedded references for %s', + (language) => { + expect( + extractCodeSecretNames( + 'const first = "prefix-{{ API_KEY }}"\nreturn "{{TOKEN}}/{{API_KEY}}"', + language + ) + ).toEqual(['API_KEY', 'TOKEN']) + } + ) + + it('matches only runtime-valid shell identifiers without trimming', () => { + expect( + extractCodeSecretNames( + 'echo {{API_KEY}} {{ API_KEY }} {{9INVALID}} {{WITH-DASH}} {{_TOKEN}}', + 'shell' + ) + ).toEqual(['API_KEY', '_TOKEN']) + }) + + it('ignores direct environment access, shell variables, literals, and malformed references', () => { + expect( + extractCodeSecretNames( + 'return environmentVariables.API_KEY + "$TOKEN" + "literal" + "{{}}" + "{{MISSING"', + 'javascript' + ) + ).toEqual([]) + }) +}) diff --git a/apps/sim/executor/utils/code-secret-references.ts b/apps/sim/executor/utils/code-secret-references.ts new file mode 100644 index 00000000000..3e3bfc19edc --- /dev/null +++ b/apps/sim/executor/utils/code-secret-references.ts @@ -0,0 +1,38 @@ +import { CodeLanguage, DEFAULT_CODE_LANGUAGE, isValidCodeLanguage } from '@/lib/execution/languages' +import { createEnvVarPattern } from '@/executor/utils/reference-validation' + +function resolveCodeLanguage(language: unknown): CodeLanguage { + return typeof language === 'string' && isValidCodeLanguage(language) + ? language + : DEFAULT_CODE_LANGUAGE +} + +export function createCodeEnvVarPattern(language?: unknown): RegExp { + return resolveCodeLanguage(language) === CodeLanguage.Shell + ? /\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}/g + : createEnvVarPattern() +} + +/** + * Extracts only environment references the Function runtime can resolve for the selected language. + * The returned order follows the code, with duplicate names removed after their first occurrence. + */ +export function extractCodeSecretNames(code: unknown, language?: unknown): string[] { + if (typeof code !== 'string') return [] + + const resolvedLanguage = resolveCodeLanguage(language) + const pattern = createCodeEnvVarPattern(resolvedLanguage) + const names: string[] = [] + const seen = new Set() + let match: RegExpExecArray | null + + while ((match = pattern.exec(code)) !== null) { + const name = resolvedLanguage === CodeLanguage.Shell ? match[1] : match[1].trim() + if (name.length > 0 && !seen.has(name)) { + seen.add(name) + names.push(name) + } + } + + return names +} diff --git a/apps/sim/executor/utils/resolved-secret-content-projection.ts b/apps/sim/executor/utils/resolved-secret-content-projection.ts new file mode 100644 index 00000000000..bb04d6e98f2 --- /dev/null +++ b/apps/sim/executor/utils/resolved-secret-content-projection.ts @@ -0,0 +1,427 @@ +import { isPlainRecord } from '@sim/utils/object' +import { LARGE_ARRAY_MANIFEST_MARKER } from '@/lib/execution/payloads/large-array-manifest-metadata' +import { LARGE_VALUE_REF_MARKER } from '@/lib/execution/payloads/large-value-ref' +import { MAX_INLINE_MATERIALIZATION_BYTES } from '@/lib/execution/payloads/materialization.server' +import type { ResolvedSecretTraceMatch } from '@/executor/utils/resolved-secret-trace-registry' + +const MAX_CONTENT_NODES = 100_000 +const MAX_CONTENT_DEPTH = 100 +const MAX_MATCHER_NODES = 250_000 +const MAX_SECRET_LITERAL_LENGTH = 64 * 1024 +const MAX_MATCH_EVENTS = 1_000_000 + +interface SecretReplacement { + plaintext: string + replacement: string +} + +interface SecretTrieNode { + children: Map + failure?: SecretTrieNode + outputLink?: SecretTrieNode + replacement?: SecretReplacement +} + +export interface ResolvedSecretMatcher { + root: SecretTrieNode + maxPatternLength: number +} + +interface ProjectionState { + nodes: number + ancestors: WeakSet + outputBytes: number + maxBytes: number +} + +export interface ResolvedSecretContentProjectionOptions { + /** Values already materialized and verified by a boundary-specific projector. */ + isOpaqueSafeObject?: (value: object) => boolean +} + +export type ResolvedSecretContentProjection = { safe: true; value: unknown } | { safe: false } + +class ResolvedSecretContentProjectionError extends Error { + constructor(message: string) { + super(message) + this.name = 'ResolvedSecretContentProjectionError' + } +} + +function compareStrings(left: string, right: string): number { + if (left < right) return -1 + if (left > right) return 1 + return 0 +} + +function createMatcherFromReplacements( + replacements: readonly SecretReplacement[] +): ResolvedSecretMatcher { + const root: SecretTrieNode = { children: new Map() } + root.failure = root + let nodeCount = 1 + let maxPatternLength = 0 + + for (const replacement of replacements) { + if (replacement.plaintext.length > MAX_SECRET_LITERAL_LENGTH) { + throw new ResolvedSecretContentProjectionError( + 'Secret literal exceeds the matcher size limit' + ) + } + maxPatternLength = Math.max(maxPatternLength, replacement.plaintext.length) + let node = root + for (let index = 0; index < replacement.plaintext.length; index += 1) { + const character = replacement.plaintext[index] + let child = node.children.get(character) + if (!child) { + child = { children: new Map() } + node.children.set(character, child) + nodeCount += 1 + if (nodeCount > MAX_MATCHER_NODES) { + throw new ResolvedSecretContentProjectionError('Secret matcher node limit exceeded') + } + } + node = child + } + node.replacement = replacement + } + + const queue: SecretTrieNode[] = [] + for (const child of root.children.values()) { + child.failure = root + queue.push(child) + } + for (let cursor = 0; cursor < queue.length; cursor += 1) { + const node = queue[cursor] + for (const [character, child] of node.children) { + let fallback = node.failure ?? root + while (fallback !== root && !fallback.children.has(character)) { + fallback = fallback.failure ?? root + } + const transition = fallback.children.get(character) + child.failure = transition && transition !== child ? transition : root + child.outputLink = child.failure.replacement ? child.failure : child.failure.outputLink + queue.push(child) + } + } + + return { root, maxPatternLength } +} + +function advanceMatcher( + matcher: ResolvedSecretMatcher, + node: SecretTrieNode, + character: string +): SecretTrieNode { + let current = node + while (current !== matcher.root && !current.children.has(character)) { + current = current.failure ?? matcher.root + } + return current.children.get(character) ?? matcher.root +} + +export function containsResolvedSecret(value: string, matcher: ResolvedSecretMatcher): boolean { + let node = matcher.root + for (let index = 0; index < value.length; index += 1) { + node = advanceMatcher(matcher, node, value[index]) + if (node.replacement || node.outputLink) return true + } + return false +} + +export function sanitizeResolvedSecretString( + value: string, + matcher: ResolvedSecretMatcher, + maxBytes = MAX_INLINE_MATERIALIZATION_BYTES +): string { + if (maxBytes < 0) { + throw new ResolvedSecretContentProjectionError( + 'Sanitized secret-bearing string exceeds the size limit' + ) + } + if (Buffer.byteLength(value, 'utf8') > maxBytes) { + throw new ResolvedSecretContentProjectionError('Secret-bearing string exceeds the size limit') + } + if (matcher.maxPatternLength === 0 || value.length === 0) return value + + let emitCursor = 0 + let literalStart = 0 + let outputBytes = 0 + let matchEvents = 0 + const chunks: string[] = [] + const windowSize = matcher.maxPatternLength + const slotStarts = new Int32Array(windowSize) + const slotEnds = new Int32Array(windowSize) + slotStarts.fill(-1) + const slotReplacements = new Array(windowSize) + + const append = (chunk: string): void => { + if (!chunk) return + outputBytes += Buffer.byteLength(chunk, 'utf8') + if (outputBytes > maxBytes) { + throw new ResolvedSecretContentProjectionError( + 'Sanitized secret-bearing string exceeds the size limit' + ) + } + const lastIndex = chunks.length - 1 + if (lastIndex >= 0 && chunks[lastIndex].length + chunk.length <= 64 * 1024) { + chunks[lastIndex] += chunk + } else { + chunks.push(chunk) + } + } + + const finalizeThrough = (limit: number): void => { + while (emitCursor <= limit && emitCursor < value.length) { + const slot = emitCursor % windowSize + if (slotStarts[slot] === emitCursor && slotReplacements[slot] !== undefined) { + append(value.slice(literalStart, emitCursor)) + append(slotReplacements[slot] ?? '') + emitCursor = slotEnds[slot] + literalStart = emitCursor + } else { + emitCursor += 1 + } + } + } + + let node = matcher.root + for (let index = 0; index < value.length; index += 1) { + node = advanceMatcher(matcher, node, value[index]) + let outputNode: SecretTrieNode | undefined = node.replacement ? node : node.outputLink + while (outputNode?.replacement) { + matchEvents += 1 + if (matchEvents > MAX_MATCH_EVENTS) { + throw new ResolvedSecretContentProjectionError('Secret matcher event limit exceeded') + } + const start = index - outputNode.replacement.plaintext.length + 1 + if (start >= emitCursor) { + const slot = start % windowSize + const end = index + 1 + if (slotStarts[slot] !== start || end > slotEnds[slot]) { + slotStarts[slot] = start + slotEnds[slot] = end + slotReplacements[slot] = outputNode.replacement.replacement + } + } + outputNode = outputNode.outputLink + } + finalizeThrough(index - matcher.maxPatternLength + 1) + } + + finalizeThrough(value.length - 1) + append(value.slice(literalStart)) + const sanitized = chunks.join('') + if (containsResolvedSecret(sanitized, matcher)) { + throw new ResolvedSecretContentProjectionError( + 'Sanitized content still contains an active secret' + ) + } + return sanitized +} + +export function createResolvedSecretMatcher( + matches: readonly ResolvedSecretTraceMatch[] +): ResolvedSecretMatcher | undefined { + const replacementByPlaintext = new Map() + + for (const match of matches) { + if (!match.plaintext) continue + const current = replacementByPlaintext.get(match.plaintext) + if (current === undefined || compareStrings(match.replacement, current) < 0) { + replacementByPlaintext.set(match.plaintext, match.replacement) + } + } + + const provisional = [...replacementByPlaintext.keys()] + .map((plaintext) => ({ + plaintext, + replacement: replacementByPlaintext.get(plaintext) ?? '', + })) + .sort( + (left, right) => + right.plaintext.length - left.plaintext.length || + compareStrings(left.replacement, right.replacement) || + compareStrings(left.plaintext, right.plaintext) + ) + + if (provisional.length === 0) return undefined + + const detector = createMatcherFromReplacements( + provisional.map(({ plaintext }) => ({ plaintext, replacement: '' })) + ) + return createMatcherFromReplacements( + provisional.map(({ plaintext, replacement }) => ({ + plaintext, + replacement: containsResolvedSecret(replacement, detector) ? '' : replacement, + })) + ) +} + +function visitNode(state: ProjectionState, depth: number): void { + state.nodes += 1 + if (state.nodes > MAX_CONTENT_NODES) { + throw new ResolvedSecretContentProjectionError('Secret-bearing content exceeds node limit') + } + if (depth > MAX_CONTENT_DEPTH) { + throw new ResolvedSecretContentProjectionError('Secret-bearing content exceeds depth limit') + } +} + +function* enumerableDataEntries(value: object): Generator<[string, unknown]> { + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== 'string') { + throw new ResolvedSecretContentProjectionError('Content cannot contain symbol properties') + } + const descriptor = Object.getOwnPropertyDescriptor(value, key) + if (!descriptor?.enumerable || !('value' in descriptor)) { + throw new ResolvedSecretContentProjectionError('Content accessors are not supported') + } + yield [key, descriptor.value] + } +} + +function* arrayDataEntries(value: readonly unknown[]): Generator<[number, unknown]> { + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length') + if ( + !lengthDescriptor || + !('value' in lengthDescriptor) || + typeof lengthDescriptor.value !== 'number' || + !Number.isSafeInteger(lengthDescriptor.value) || + lengthDescriptor.value < 0 + ) { + throw new ResolvedSecretContentProjectionError('Content array length is invalid') + } + + for (const key of Reflect.ownKeys(value)) { + if (key === 'length') continue + if (typeof key !== 'string') { + throw new ResolvedSecretContentProjectionError('Content arrays cannot contain symbols') + } + const index = Number(key) + if (!Number.isInteger(index) || index < 0 || index >= value.length || String(index) !== key) { + throw new ResolvedSecretContentProjectionError('Content array has custom properties') + } + const descriptor = Object.getOwnPropertyDescriptor(value, key) + if (!descriptor?.enumerable || !('value' in descriptor)) { + throw new ResolvedSecretContentProjectionError('Content array accessors are unsupported') + } + yield [index, descriptor.value] + } +} + +function sanitizeContent( + value: unknown, + matcher: ResolvedSecretMatcher, + state: ProjectionState, + options: ResolvedSecretContentProjectionOptions, + depth = 0 +): unknown { + visitNode(state, depth) + if (typeof value === 'string') { + const sanitized = sanitizeResolvedSecretString( + value, + matcher, + state.maxBytes - state.outputBytes + ) + state.outputBytes += Buffer.byteLength(sanitized, 'utf8') + return sanitized + } + if (value === null || typeof value === 'number' || typeof value === 'boolean') { + const rendered = String(value) + if (!containsResolvedSecret(rendered, matcher)) return value + const sanitized = sanitizeResolvedSecretString( + rendered, + matcher, + state.maxBytes - state.outputBytes + ) + state.outputBytes += Buffer.byteLength(sanitized, 'utf8') + return sanitized + } + if (value === undefined) return value + if (typeof value !== 'object') { + throw new ResolvedSecretContentProjectionError('Unsupported secret-bearing content value') + } + if (options.isOpaqueSafeObject?.(value)) return value + if (!Array.isArray(value) && !isPlainRecord(value)) { + throw new ResolvedSecretContentProjectionError('Unsupported secret-bearing content value') + } + if ( + !Array.isArray(value) && + (Object.hasOwn(value, LARGE_VALUE_REF_MARKER) || + Object.hasOwn(value, LARGE_ARRAY_MANIFEST_MARKER)) + ) { + throw new ResolvedSecretContentProjectionError( + 'Offloaded secret-bearing content cannot cross this boundary' + ) + } + if (state.ancestors.has(value)) { + throw new ResolvedSecretContentProjectionError('Cyclic secret-bearing content is unsupported') + } + + state.ancestors.add(value) + try { + if (Array.isArray(value)) { + if (value.length > MAX_CONTENT_NODES - state.nodes) { + throw new ResolvedSecretContentProjectionError('Content array exceeds traversal limit') + } + const sanitized = new Array(value.length) + for (const [index, item] of arrayDataEntries(value)) { + sanitized[index] = sanitizeContent(item, matcher, state, options, depth + 1) + } + return sanitized + } + + const sanitized = Object.create(Object.getPrototypeOf(value)) as Record + const sanitizedKeys = new Set() + for (const [key, item] of enumerableDataEntries(value)) { + const sanitizedKey = sanitizeResolvedSecretString( + key, + matcher, + state.maxBytes - state.outputBytes + ) + state.outputBytes += Buffer.byteLength(sanitizedKey, 'utf8') + if (sanitizedKeys.has(sanitizedKey)) { + throw new ResolvedSecretContentProjectionError( + 'Secret replacement caused an object-key collision' + ) + } + sanitizedKeys.add(sanitizedKey) + Object.defineProperty(sanitized, sanitizedKey, { + value: sanitizeContent(item, matcher, state, options, depth + 1), + enumerable: true, + configurable: true, + writable: true, + }) + } + return sanitized + } finally { + state.ancestors.delete(value) + } +} + +export function projectResolvedSecretContent( + value: unknown, + matcher: ResolvedSecretMatcher, + maxBytes = MAX_INLINE_MATERIALIZATION_BYTES, + options: ResolvedSecretContentProjectionOptions = {} +): ResolvedSecretContentProjection { + try { + return { + safe: true, + value: sanitizeContent( + value, + matcher, + { + nodes: 0, + ancestors: new WeakSet(), + outputBytes: 0, + maxBytes, + }, + options + ), + } + } catch { + return { safe: false } + } +} diff --git a/apps/sim/executor/utils/resolved-secret-trace-registry.test.ts b/apps/sim/executor/utils/resolved-secret-trace-registry.test.ts index 6b435afff6d..1498c98f45a 100644 --- a/apps/sim/executor/utils/resolved-secret-trace-registry.test.ts +++ b/apps/sim/executor/utils/resolved-secret-trace-registry.test.ts @@ -164,6 +164,34 @@ describe('ResolvedSecretTraceRegistry', () => { ]) }) + it('fails closed while one or more secret activations are pending', () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'API_KEY', plaintext: 'secret-value', encryptedValue: 'encrypted-value' }, + ]) + const completeFirst = registry.beginPendingActivation() + const completeSecond = registry.beginPendingActivation() + + expect(registry.isComplete()).toBe(false) + expect(registry.exportProvenance()).toEqual({ + version: 1, + complete: false, + entries: [], + }) + + registry.recordResolved('API_KEY', 'secret-value') + completeFirst() + expect(registry.isComplete()).toBe(false) + + completeSecond() + completeSecond() + expect(registry.isComplete()).toBe(true) + expect(registry.exportProvenance()).toEqual({ + version: 1, + complete: true, + entries: [{ name: 'API_KEY', encryptedValue: 'encrypted-value' }], + }) + }) + it('uses the workspace catalog entry when personal and workspace names conflict', async () => { const registry = await createResolvedSecretTraceRegistry({ personalEncrypted: { SHARED: 'personal-encrypted' }, diff --git a/apps/sim/executor/utils/resolved-secret-trace-registry.ts b/apps/sim/executor/utils/resolved-secret-trace-registry.ts index 632a1a56872..f96694e76ec 100644 --- a/apps/sim/executor/utils/resolved-secret-trace-registry.ts +++ b/apps/sim/executor/utils/resolved-secret-trace-registry.ts @@ -415,6 +415,7 @@ export class ResolvedSecretTraceRegistry { private readonly activeEntries = new Map() private activeProvenanceEntryBytes = 0 private complete = true + private pendingActivations = 0 private readonly scope?: ResolvedSecretTraceScopeV1 private readonly completeProvenanceEnvelopeBytes: number @@ -540,22 +541,40 @@ export class ResolvedSecretTraceRegistry { } isComplete(): boolean { - return this.complete + return this.complete && this.pendingActivations === 0 + } + + isPermanentlyIncomplete(): boolean { + return !this.complete } markIncomplete(): void { this.complete = false } + /** + * Makes projections fail closed while an exact runtime substitution is being established. + * The returned completion callback is idempotent so every exit path can safely release it. + */ + beginPendingActivation(): () => void { + this.pendingActivations += 1 + let completed = false + + return () => { + if (completed) return + completed = true + this.pendingActivations = Math.max(0, this.pendingActivations - 1) + } + } + /** Serializes only encrypted active values; plaintext never enters execution state. */ exportProvenance(): ResolvedSecretTraceProvenanceV1 { - const entries = this.complete - ? this.buildProvenanceEntries([...this.activeEntries.values()]) - : [] + const complete = this.isComplete() + const entries = complete ? this.buildProvenanceEntries([...this.activeEntries.values()]) : [] return { version: 1, - complete: this.complete, + complete, entries, ...(this.scope ? { scope: cloneProvenanceScope(this.scope) } : {}), } @@ -569,7 +588,7 @@ export class ResolvedSecretTraceRegistry { value: unknown, options: ExportResolvedSecretTraceProvenanceForValueOptions = {} ): ResolvedSecretTraceProvenanceV1 { - if (!this.complete) return { version: 1, complete: false, entries: [] } + if (!this.isComplete()) return { version: 1, complete: false, entries: [] } const candidatesByPlaintext = new Map() const sortedActiveEntries = [...this.activeEntries.values()].sort( diff --git a/apps/sim/hooks/queries/credentials.ts b/apps/sim/hooks/queries/credentials.ts index 25daaf06179..a284acaeb12 100644 --- a/apps/sim/hooks/queries/credentials.ts +++ b/apps/sim/hooks/queries/credentials.ts @@ -21,7 +21,10 @@ import { } from '@/lib/api/contracts' import { environmentKeys } from '@/hooks/queries/environment' import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys' -import { fetchWorkspaceCredentialList } from '@/hooks/queries/utils/fetch-workspace-credentials' +import { + fetchWorkspaceCredentialList, + WORKSPACE_CREDENTIAL_LIST_STALE_TIME, +} from '@/hooks/queries/utils/fetch-workspace-credentials' /** * Key prefix for OAuth credential queries. @@ -29,7 +32,6 @@ import { fetchWorkspaceCredentialList } from '@/hooks/queries/utils/fetch-worksp */ const OAUTH_CREDENTIALS_KEY = ['oauthCredentials'] as const -export const WORKSPACE_CREDENTIAL_LIST_STALE_TIME = 60 * 1000 export const WORKSPACE_CREDENTIAL_DETAIL_STALE_TIME = 60 * 1000 export const WORKSPACE_CREDENTIAL_MEMBER_LIST_STALE_TIME = 30 * 1000 diff --git a/apps/sim/hooks/queries/inbox.ts b/apps/sim/hooks/queries/inbox.ts index 9577ddeee6a..6b2a7d4f1f8 100644 --- a/apps/sim/hooks/queries/inbox.ts +++ b/apps/sim/hooks/queries/inbox.ts @@ -12,6 +12,7 @@ import { listInboxSendersContract, listInboxTasksContract, removeInboxSenderContract, + type SecretMountPolicyInput, updateInboxConfigContract, } from '@/lib/api/contracts' @@ -140,6 +141,37 @@ export function useUpdateInboxAddress() { }) } +export function useUpdateInboxSecretPolicy() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + workspaceId, + ...policy + }: { workspaceId: string } & Required) => { + return requestJson(updateInboxConfigContract, { + params: { id: workspaceId }, + body: policy, + }) + }, + onMutate: async ({ workspaceId, ...policy }) => { + const queryKey = inboxKeys.config(workspaceId) + await queryClient.cancelQueries({ queryKey }) + const previous = queryClient.getQueryData(queryKey) + if (previous) queryClient.setQueryData(queryKey, { ...previous, ...policy }) + return { previous } + }, + onError: (_error, variables, context) => { + if (context?.previous) { + queryClient.setQueryData(inboxKeys.config(variables.workspaceId), context.previous) + } + }, + onSettled: (_data, _error, variables) => { + return queryClient.invalidateQueries({ queryKey: inboxKeys.config(variables.workspaceId) }) + }, + }) +} + export function useAddInboxSender() { const queryClient = useQueryClient() diff --git a/apps/sim/hooks/queries/secret-mount-options.ts b/apps/sim/hooks/queries/secret-mount-options.ts new file mode 100644 index 00000000000..6e678c470d7 --- /dev/null +++ b/apps/sim/hooks/queries/secret-mount-options.ts @@ -0,0 +1,19 @@ +'use client' + +import { useMemo } from 'react' +import { selectRawMountableSecretNames } from '@/lib/credentials/secret-mount-options' +import { useWorkspaceCredentials } from '@/hooks/queries/credentials' + +export function useRawMountableSecretOptions(workspaceId?: string) { + const query = useWorkspaceCredentials({ workspaceId }) + const options = useMemo( + () => + selectRawMountableSecretNames(query.data ?? []).map((name) => ({ + value: name, + label: name, + })), + [query.data] + ) + + return { options, isPending: query.isPending } +} diff --git a/apps/sim/hooks/queries/utils/fetch-workspace-credentials.ts b/apps/sim/hooks/queries/utils/fetch-workspace-credentials.ts index 9fd8efd7b6f..bf1dccfe9d3 100644 --- a/apps/sim/hooks/queries/utils/fetch-workspace-credentials.ts +++ b/apps/sim/hooks/queries/utils/fetch-workspace-credentials.ts @@ -1,6 +1,8 @@ import { requestJson } from '@/lib/api/client/request' import { listWorkspaceCredentialsContract, type WorkspaceCredential } from '@/lib/api/contracts' +export const WORKSPACE_CREDENTIAL_LIST_STALE_TIME = 60 * 1000 + /** * Fetches the workspace credential list. * diff --git a/apps/sim/lib/api/contracts/copilot.ts b/apps/sim/lib/api/contracts/copilot.ts index e7136c78dc7..8da3f338c30 100644 --- a/apps/sim/lib/api/contracts/copilot.ts +++ b/apps/sim/lib/api/contracts/copilot.ts @@ -47,6 +47,7 @@ export const copilotCredentialsQuerySchema = z.object({}) export const copilotConfirmBodySchema = z.object({ toolCallId: z.string().min(1, 'Tool call ID is required'), + executionId: z.string().min(1, 'Execution ID is required').max(255).optional(), status: z.enum( Object.values(ASYNC_TOOL_CONFIRMATION_STATUS) as [ AsyncConfirmationStatus, diff --git a/apps/sim/lib/api/contracts/hotspots.ts b/apps/sim/lib/api/contracts/hotspots.ts index eaa75f8f430..75acb8f86de 100644 --- a/apps/sim/lib/api/contracts/hotspots.ts +++ b/apps/sim/lib/api/contracts/hotspots.ts @@ -1,5 +1,9 @@ import { z } from 'zod' -import { customPatternSchema, unknownRecordSchema } from '@/lib/api/contracts/primitives' +import { + customPatternSchema, + stringRecordSchema, + unknownRecordSchema, +} from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { DEFAULT_CODE_LANGUAGE } from '@/lib/execution/languages' export const guardrailsValidateContract = defineRouteContract({ @@ -175,9 +179,9 @@ export const functionExecuteContract = defineRouteContract({ }) .strict() .optional(), - envVars: z.record(z.string(), z.string()).optional().default({}), + envVars: stringRecordSchema.optional().default({}), blockData: unknownRecordSchema.optional().default({}), - blockNameMapping: z.record(z.string(), z.string()).optional().default({}), + blockNameMapping: stringRecordSchema.optional().default({}), blockOutputSchemas: z.record(z.string(), unknownRecordSchema).optional().default({}), workflowVariables: unknownRecordSchema.optional().default({}), contextVariables: unknownRecordSchema.optional().default({}), diff --git a/apps/sim/lib/api/contracts/inbox.ts b/apps/sim/lib/api/contracts/inbox.ts index 34152f3346b..a3e8387c866 100644 --- a/apps/sim/lib/api/contracts/inbox.ts +++ b/apps/sim/lib/api/contracts/inbox.ts @@ -1,4 +1,8 @@ import { z } from 'zod' +import { + mountedSecretNamesSchema, + secretMountScopeSchema, +} from '@/lib/api/contracts/secret-mount-policy' import { defineRouteContract } from '@/lib/api/contracts/types' export const inboxWorkspaceParamsSchema = z.object({ @@ -17,6 +21,8 @@ export const inboxTaskStatusSchema = z.enum([ export const inboxConfigSchema = z.object({ enabled: z.boolean(), address: z.string().nullable(), + secretScope: secretMountScopeSchema, + mountedSecrets: mountedSecretNamesSchema, entitled: z.boolean(), taskStats: z.object({ total: z.number(), @@ -32,12 +38,16 @@ export type InboxTaskStatus = z.output export const updateInboxConfigBodySchema = z.object({ enabled: z.boolean().optional(), username: z.string().min(1).max(64).optional(), + secretScope: secretMountScopeSchema.optional(), + mountedSecrets: mountedSecretNamesSchema.optional(), }) export const updateInboxConfigResponseSchema = z.object({ enabled: z.boolean(), address: z.string().nullable(), providerId: z.string().nullable().optional(), + secretScope: secretMountScopeSchema, + mountedSecrets: mountedSecretNamesSchema, }) export const inboxSenderSchema = z.object({ diff --git a/apps/sim/lib/api/contracts/index.ts b/apps/sim/lib/api/contracts/index.ts index 2001b85b8c2..10ad693347c 100644 --- a/apps/sim/lib/api/contracts/index.ts +++ b/apps/sim/lib/api/contracts/index.ts @@ -22,6 +22,7 @@ export * from './permission-groups' export * from './pinned-items' export * from './primitives' export * from './sandboxes' +export * from './secret-mount-policy' export * from './selectors' export * from './skills' export * from './storage-transfer' diff --git a/apps/sim/lib/api/contracts/mothership-chats.ts b/apps/sim/lib/api/contracts/mothership-chats.ts index 70a297a486d..275f3c80bf5 100644 --- a/apps/sim/lib/api/contracts/mothership-chats.ts +++ b/apps/sim/lib/api/contracts/mothership-chats.ts @@ -1,5 +1,9 @@ import { z } from 'zod' import { scheduleContextSchema } from '@/lib/api/contracts/schedules' +import { + mountedSecretNamesSchema, + secretMountScopeSchema, +} from '@/lib/api/contracts/secret-mount-policy' import { defineRouteContract } from '@/lib/api/contracts/types' const dateStringSchema = z.string().refine((value) => !Number.isNaN(Date.parse(value)), { @@ -119,6 +123,8 @@ export const mothershipExecuteBodySchema = z.object({ mcpTools: z.array(mothershipExecuteMcpToolSchema).optional(), workflowId: z.string().optional(), executionId: z.string().optional(), + secretScope: secretMountScopeSchema.optional(), + mountedSecrets: mountedSecretNamesSchema.optional(), userMetadata: z .object({ name: z.string().optional(), diff --git a/apps/sim/lib/api/contracts/primitives.ts b/apps/sim/lib/api/contracts/primitives.ts index a0bfff57299..f899a27174f 100644 --- a/apps/sim/lib/api/contracts/primitives.ts +++ b/apps/sim/lib/api/contracts/primitives.ts @@ -1,9 +1,25 @@ +import { isPlainRecord } from '@sim/utils/object' import { z } from 'zod' +import { setRecordValue } from '@/lib/core/utils/records' import { PII_LANGUAGE_CODES, stripNerEntities } from '@/lib/guardrails/pii-entities' import { validateRegexPattern } from '@/lib/guardrails/validate_regex' export const unknownRecordSchema = z.record(z.string(), z.unknown()) +export const stringRecordSchema = z + .custom>( + (value) => + isPlainRecord(value) && Object.values(value).every((entry) => typeof entry === 'string'), + { error: 'Expected a record of string values' } + ) + .transform((value) => { + const record: Record = {} + for (const [key, entry] of Object.entries(value)) { + setRecordValue(record, key, entry) + } + return record + }) + export function flattenFieldErrors( error: z.ZodError ): Partial> { diff --git a/apps/sim/lib/api/contracts/schedules.ts b/apps/sim/lib/api/contracts/schedules.ts index d4eaf3d5e11..3a707e59232 100644 --- a/apps/sim/lib/api/contracts/schedules.ts +++ b/apps/sim/lib/api/contracts/schedules.ts @@ -1,4 +1,8 @@ import { z } from 'zod' +import { + mountedSecretNamesSchema, + secretMountScopeSchema, +} from '@/lib/api/contracts/secret-mount-policy' import { defineRouteContract } from '@/lib/api/contracts/types' export const scheduleStatusSchema = z.enum(['active', 'disabled', 'completed']) @@ -74,6 +78,8 @@ export const workflowScheduleRowSchema = z.object({ sourceTaskName: z.string().nullable(), sourceUserId: z.string().nullable(), sourceWorkspaceId: z.string().nullable(), + secretScope: secretMountScopeSchema, + mountedSecrets: mountedSecretNamesSchema, jobHistory: z.array(z.object({ timestamp: z.string(), summary: z.string() })).nullable(), contexts: z.array(scheduleContextSchema).nullable(), excludedDates: z.array(z.string()).nullable(), @@ -113,6 +119,8 @@ export const createScheduleBodySchema = z endsAt: z.string().optional(), startDate: z.string().optional(), contexts: z.array(scheduleContextSchema).optional(), + secretScope: secretMountScopeSchema.optional(), + mountedSecrets: mountedSecretNamesSchema.optional(), }) .superRefine((body, ctx) => { if (!body.cronExpression && !body.time) { @@ -150,6 +158,8 @@ export const updateScheduleBodySchema = z.object({ maxRuns: z.number().int().positive().nullable().optional(), endsAt: z.string().nullable().optional(), contexts: z.array(scheduleContextSchema).optional(), + secretScope: secretMountScopeSchema.optional(), + mountedSecrets: mountedSecretNamesSchema.optional(), }) export type UpdateScheduleBody = z.input diff --git a/apps/sim/lib/api/contracts/secret-mount-policy.test.ts b/apps/sim/lib/api/contracts/secret-mount-policy.test.ts new file mode 100644 index 00000000000..f1898448ebc --- /dev/null +++ b/apps/sim/lib/api/contracts/secret-mount-policy.test.ts @@ -0,0 +1,32 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { mountedSecretNamesSchema } from '@/lib/api/contracts/secret-mount-policy' +import { + MAX_SECRET_MOUNT_NAME_LENGTH, + MAX_SECRET_MOUNT_NAMES, +} from '@/lib/copilot/secret-mount-policy' + +describe('mountedSecretNamesSchema', () => { + it('accepts the bounded names-only policy shape', () => { + expect(mountedSecretNamesSchema.parse([' API_KEY ', 'name-with-dashes'])).toEqual([ + 'API_KEY', + 'name-with-dashes', + ]) + }) + + it('rejects too many names', () => { + expect(() => + mountedSecretNamesSchema.parse( + Array.from({ length: MAX_SECRET_MOUNT_NAMES + 1 }, (_, index) => `SECRET_${index}`) + ) + ).toThrow() + }) + + it('rejects an overlong name without narrowing the runtime name grammar', () => { + expect(() => + mountedSecretNamesSchema.parse(['S'.repeat(MAX_SECRET_MOUNT_NAME_LENGTH + 1)]) + ).toThrow() + }) +}) diff --git a/apps/sim/lib/api/contracts/secret-mount-policy.ts b/apps/sim/lib/api/contracts/secret-mount-policy.ts new file mode 100644 index 00000000000..d3306ef7ea9 --- /dev/null +++ b/apps/sim/lib/api/contracts/secret-mount-policy.ts @@ -0,0 +1,21 @@ +import { z } from 'zod' +import { + MAX_SECRET_MOUNT_NAME_LENGTH, + MAX_SECRET_MOUNT_NAMES, +} from '@/lib/copilot/secret-mount-policy' + +export const secretMountScopeSchema = z.enum(['all', 'selected']) + +export const mountedSecretNameSchema = z.string().trim().min(1).max(MAX_SECRET_MOUNT_NAME_LENGTH) + +export const mountedSecretNamesSchema = z.array(mountedSecretNameSchema).max(MAX_SECRET_MOUNT_NAMES) + +export const secretMountPolicySchema = z.object({ + secretScope: secretMountScopeSchema, + mountedSecrets: mountedSecretNamesSchema, +}) + +export const secretMountPolicyInputSchema = secretMountPolicySchema.partial() + +export type SecretMountPolicyInput = z.input +export type SecretMountPolicyOutput = z.output diff --git a/apps/sim/lib/api/contracts/workflows.ts b/apps/sim/lib/api/contracts/workflows.ts index c254daf74ff..89a926caf38 100644 --- a/apps/sim/lib/api/contracts/workflows.ts +++ b/apps/sim/lib/api/contracts/workflows.ts @@ -369,6 +369,7 @@ export const executeWorkflowBodySchema = z.object({ /** Internal MCP bridge pin for calls admitted before a deployment cutover. */ deploymentVersionId: z.string().min(1).optional(), executionId: z.unknown().optional(), + copilotToolCallId: z.string().min(1).max(255).optional(), triggerBlockId: z.string().optional(), startBlockId: z.string().optional(), stopAfterBlockId: z.string().optional(), diff --git a/apps/sim/lib/copilot/async-runs/lifecycle.test.ts b/apps/sim/lib/copilot/async-runs/lifecycle.test.ts index 8cd4fd872e7..ecf31930c50 100644 --- a/apps/sim/lib/copilot/async-runs/lifecycle.test.ts +++ b/apps/sim/lib/copilot/async-runs/lifecycle.test.ts @@ -10,6 +10,7 @@ import { isAsyncTerminalConfirmationStatus, isDeliveredAsyncStatus, isTerminalAsyncStatus, + isWorkflowToolExecutionClaimable, } from './lifecycle' describe('async tool lifecycle helpers', () => { @@ -26,6 +27,17 @@ describe('async tool lifecycle helpers', () => { expect(isDeliveredAsyncStatus(ASYNC_TOOL_STATUS.delivered)).toBe(true) }) + it('claims only dispatched or explicitly approved workflow calls', () => { + expect(isWorkflowToolExecutionClaimable(ASYNC_TOOL_STATUS.running, null)).toBe(true) + expect(isWorkflowToolExecutionClaimable(ASYNC_TOOL_STATUS.delivered, null)).toBe(true) + expect(isWorkflowToolExecutionClaimable(ASYNC_TOOL_STATUS.pending, 'allow')).toBe(true) + expect(isWorkflowToolExecutionClaimable(ASYNC_TOOL_STATUS.pending, 'allow_chat')).toBe(true) + expect(isWorkflowToolExecutionClaimable(ASYNC_TOOL_STATUS.pending, 'always_allow')).toBe(true) + expect(isWorkflowToolExecutionClaimable(ASYNC_TOOL_STATUS.pending, 'skip')).toBe(false) + expect(isWorkflowToolExecutionClaimable(ASYNC_TOOL_STATUS.pending, null)).toBe(false) + expect(isWorkflowToolExecutionClaimable(ASYNC_TOOL_STATUS.completed, 'allow')).toBe(false) + }) + it('distinguishes background from terminal completion statuses', () => { expect(isAsyncEphemeralConfirmationStatus(ASYNC_TOOL_CONFIRMATION_STATUS.background)).toBe(true) expect(isAsyncEphemeralConfirmationStatus(ASYNC_TOOL_CONFIRMATION_STATUS.success)).toBe(false) diff --git a/apps/sim/lib/copilot/async-runs/lifecycle.ts b/apps/sim/lib/copilot/async-runs/lifecycle.ts index e54b2f1900a..d86ae06442a 100644 --- a/apps/sim/lib/copilot/async-runs/lifecycle.ts +++ b/apps/sim/lib/copilot/async-runs/lifecycle.ts @@ -1,4 +1,4 @@ -import type { CopilotAsyncToolStatus } from '@sim/db/schema' +import type { CopilotAsyncToolStatus, CopilotToolPermissionDecision } from '@sim/db/schema' import { MothershipStreamV1AsyncToolRecordStatus, MothershipStreamV1ToolOutcome, @@ -6,6 +6,12 @@ import { export const ASYNC_TOOL_STATUS = MothershipStreamV1AsyncToolRecordStatus +export const EXECUTABLE_TOOL_PERMISSION_DECISIONS = [ + 'allow', + 'allow_chat', + 'always_allow', +] as const satisfies readonly CopilotToolPermissionDecision[] + export type AsyncLifecycleStatus = | typeof ASYNC_TOOL_STATUS.pending | typeof ASYNC_TOOL_STATUS.running @@ -81,6 +87,23 @@ export interface AsyncCompletionSignal { data?: AsyncCompletionData } +export function isExecutableToolPermissionDecision( + decision: CopilotToolPermissionDecision | null | undefined +): boolean { + return decision !== null && decision !== undefined && decision !== 'skip' +} + +export function isWorkflowToolExecutionClaimable( + status: CopilotAsyncToolStatus, + permissionDecision: CopilotToolPermissionDecision | null | undefined +): boolean { + return ( + status === ASYNC_TOOL_STATUS.running || + status === ASYNC_TOOL_STATUS.delivered || + (status === ASYNC_TOOL_STATUS.pending && isExecutableToolPermissionDecision(permissionDecision)) + ) +} + export function isTerminalAsyncStatus( status: CopilotAsyncToolStatus | AsyncLifecycleStatus | string | null | undefined ): status is AsyncTerminalStatus { diff --git a/apps/sim/lib/copilot/async-runs/repository.test.ts b/apps/sim/lib/copilot/async-runs/repository.test.ts index 50e36eaecd1..fcd9c01a4e7 100644 --- a/apps/sim/lib/copilot/async-runs/repository.test.ts +++ b/apps/sim/lib/copilot/async-runs/repository.test.ts @@ -7,8 +7,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { claimCompletedAsyncToolCall, claimPendingAsyncToolCall, + claimWorkflowToolExecution, completeAsyncToolCall, - markAsyncToolDelivered, + detachAsyncToolCall, + getClaimedWorkflowExecutionId, + recordToolPermissionDecision, + releaseWorkflowToolExecutionClaim, + replaceTerminalAsyncToolCallResult, + upsertAsyncToolCall, } from './repository' describe('async tool repository single-row semantics', () => { @@ -17,27 +23,48 @@ describe('async tool repository single-row semantics', () => { resetDbChainMock() }) - it('does not overwrite a delivered row on late completion', async () => { - const deliveredRow = { + it('atomically completes a live row', async () => { + const completedRow = { toolCallId: 'tool-1', - status: 'delivered', + status: 'completed', result: { ok: true }, error: null, } - dbChainMockFns.limit.mockResolvedValueOnce([deliveredRow]) + dbChainMockFns.returning.mockResolvedValueOnce([completedRow]) const result = await completeAsyncToolCall({ toolCallId: 'tool-1', status: 'completed', - result: { ok: false }, + result: { ok: true }, error: null, }) - expect(result).toEqual(deliveredRow) - expect(dbChainMockFns.returning).not.toHaveBeenCalled() + expect(result).toEqual(completedRow) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'completed', + result: { ok: true }, + completedAt: expect.any(Date), + }) + ) + expect(dbChainMockFns.where).toHaveBeenCalled() }) - it('marks a row delivered and clears the claim fields', async () => { + it('returns null when another terminal transition already won', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([]) + + const result = await completeAsyncToolCall({ + toolCallId: 'tool-1', + status: 'failed', + result: null, + error: 'late error', + }) + + expect(result).toBeNull() + expect(dbChainMockFns.limit).not.toHaveBeenCalled() + }) + + it('atomically detaches a live background call and clears the claim fields', async () => { dbChainMockFns.returning.mockResolvedValueOnce([ { toolCallId: 'tool-1', @@ -45,7 +72,7 @@ describe('async tool repository single-row semantics', () => { }, ]) - await markAsyncToolDelivered('tool-1') + await detachAsyncToolCall('tool-1') expect(dbChainMockFns.set).toHaveBeenCalledWith( expect.objectContaining({ @@ -54,6 +81,7 @@ describe('async tool repository single-row semantics', () => { claimedAt: null, }) ) + expect(dbChainMockFns.where).toHaveBeenCalled() }) it('claims only completed rows for delivery handoff', async () => { @@ -103,4 +131,149 @@ describe('async tool repository single-row semantics', () => { }) ) }) + + it('atomically binds an eligible workflow tool to one execution', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([ + { + toolCallId: 'workflow-tool', + status: 'running', + claimedBy: 'workflow:execution-1', + }, + ]) + + const result = await claimWorkflowToolExecution('workflow-tool', 'execution-1') + + expect(result).toMatchObject({ + toolCallId: 'workflow-tool', + claimedBy: 'workflow:execution-1', + }) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ + status: expect.anything(), + claimedBy: 'workflow:execution-1', + claimedAt: expect.any(Date), + updatedAt: expect.any(Date), + }) + expect(getClaimedWorkflowExecutionId(result?.claimedBy)).toBe('execution-1') + }) + + it('returns null when a workflow tool execution claim loses the race', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([]) + + await expect(claimWorkflowToolExecution('workflow-tool', 'execution-2')).resolves.toBeNull() + }) + + it('releases a matching pre-start workflow claim without changing its lifecycle status', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([ + { + toolCallId: 'workflow-tool', + status: 'delivered', + claimedBy: null, + }, + ]) + + const result = await releaseWorkflowToolExecutionClaim('workflow-tool', 'execution-1') + + expect(result).toMatchObject({ + toolCallId: 'workflow-tool', + status: 'delivered', + claimedBy: null, + }) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ + claimedBy: null, + claimedAt: null, + updatedAt: expect.any(Date), + }) + }) + + it('detaches a bound workflow waiter without releasing its execution claim', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([ + { + toolCallId: 'workflow-tool', + status: 'delivered', + claimedBy: 'workflow:execution-1', + }, + ]) + + await detachAsyncToolCall('workflow-tool', { preserveClaim: true }) + + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'delivered', + claimedBy: undefined, + claimedAt: undefined, + }) + ) + }) + + it('records an approved workflow decision without changing execution state', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([ + { + toolCallId: 'workflow-tool', + status: 'pending', + permissionDecision: 'allow', + }, + ]) + + await recordToolPermissionDecision('workflow-tool', 'allow') + + expect(dbChainMockFns.set).toHaveBeenCalledWith({ + permissionDecision: 'allow', + permissionDecidedAt: expect.any(Date), + updatedAt: expect.any(Date), + }) + }) + + it('replaces only terminal payload fields after trusted projection', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([ + { + toolCallId: 'workflow-tool', + status: 'completed', + result: { output: '{{SECRET}}' }, + }, + ]) + + const result = await replaceTerminalAsyncToolCallResult({ + toolCallId: 'workflow-tool', + status: 'completed', + result: { output: '{{SECRET}}' }, + error: null, + }) + + expect(result).toMatchObject({ + toolCallId: 'workflow-tool', + status: 'completed', + }) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ + status: 'completed', + result: { output: '{{SECRET}}' }, + error: null, + updatedAt: expect.any(Date), + }) + expect(dbChainMockFns.where).toHaveBeenCalled() + }) + + it.each(['pending', 'running'] as const)( + 'keeps the first finalized call identity immutable after it reaches %s', + async (status) => { + const existingRow = { + runId: 'run-1', + toolCallId: 'tool-1', + toolName: 'function_execute', + args: { language: 'javascript', code: 'return {{FIRST_SECRET}}' }, + status, + } + dbChainMockFns.limit.mockResolvedValueOnce([existingRow]) + + const result = await upsertAsyncToolCall({ + runId: 'run-1', + toolCallId: 'tool-1', + toolName: 'function_execute', + args: { language: 'javascript', code: 'return {{SECOND_SECRET}}' }, + status: 'pending', + }) + + expect(result).toEqual(existingRow) + expect(dbChainMockFns.values).not.toHaveBeenCalled() + } + ) }) diff --git a/apps/sim/lib/copilot/async-runs/repository.ts b/apps/sim/lib/copilot/async-runs/repository.ts index 257bfdaec2d..3497be4e987 100644 --- a/apps/sim/lib/copilot/async-runs/repository.ts +++ b/apps/sim/lib/copilot/async-runs/repository.ts @@ -11,18 +11,19 @@ import { import { createLogger } from '@sim/logger' import { filterUndefined } from '@sim/utils/object' import { sanitizeValueForJsonb } from '@sim/utils/string' -import { and, desc, eq, inArray, isNull } from 'drizzle-orm' +import { and, desc, eq, inArray, isNull, or, sql } from 'drizzle-orm' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' import { markSpanForError } from '@/lib/copilot/request/otel' import { ASYNC_TOOL_STATUS, type AsyncCompletionData, - isDeliveredAsyncStatus, - isTerminalAsyncStatus, + type AsyncTerminalStatus, + EXECUTABLE_TOOL_PERMISSION_DECISIONS, } from './lifecycle' const logger = createLogger('CopilotAsyncRunsRepo') +const WORKFLOW_EXECUTION_CLAIM_PREFIX = 'workflow:' // Resolve the tracer lazily per-call to avoid capturing the NoOp tracer // before NodeSDK installs the global TracerProvider (Next.js 16/Turbopack // can evaluate modules before instrumentation-node.ts finishes). @@ -193,6 +194,7 @@ export async function getRunSegment(runId: string) { id: copilotRuns.id, userId: copilotRuns.userId, status: copilotRuns.status, + workflowId: copilotRuns.workflowId, // Needed to scope an "allow for this chat" decision to its chat. chatId: copilotRuns.chatId, }) @@ -243,6 +245,7 @@ export async function upsertAsyncToolCall(input: { toolName: string args?: Record status?: CopilotAsyncToolStatus + sealedContext?: AsyncCompletionData }) { return withDbSpan( TraceSpan.CopilotAsyncRunsUpsertAsyncToolCall, @@ -256,21 +259,10 @@ export async function upsertAsyncToolCall(input: { }, async () => { const existing = await getAsyncToolCall(input.toolCallId) + if (existing) return existing + const incomingStatus = input.status ?? 'pending' - if ( - existing && - (isTerminalAsyncStatus(existing.status) || isDeliveredAsyncStatus(existing.status)) && - !isTerminalAsyncStatus(incomingStatus) && - !isDeliveredAsyncStatus(incomingStatus) - ) { - logger.info('Ignoring async tool upsert that would downgrade terminal state', { - toolCallId: input.toolCallId, - existingStatus: existing.status, - incomingStatus, - }) - return existing - } - const effectiveRunId = input.runId ?? existing?.runId ?? null + const effectiveRunId = input.runId ?? null if (!effectiveRunId) { logger.warn('upsertAsyncToolCall missing runId and no existing row', { toolCallId: input.toolCallId, @@ -282,6 +274,7 @@ export async function upsertAsyncToolCall(input: { const now = new Date() const args = sanitizeValueForJsonb(input.args ?? {}) + const sealedContext = sanitizeValueForJsonb(input.sealedContext) const [row] = await db .insert(copilotAsyncToolCalls) .values({ @@ -291,22 +284,13 @@ export async function upsertAsyncToolCall(input: { toolName: input.toolName, args, status: incomingStatus, + ...(sealedContext !== undefined ? { result: sealedContext } : {}), updatedAt: now, }) - .onConflictDoUpdate({ - target: copilotAsyncToolCalls.toolCallId, - set: { - runId: effectiveRunId, - checkpointId: input.checkpointId ?? null, - toolName: input.toolName, - args, - status: incomingStatus, - updatedAt: now, - }, - }) + .onConflictDoNothing() .returning() - return row + return row ?? getAsyncToolCall(input.toolCallId) } ) } @@ -337,7 +321,8 @@ async function markAsyncToolStatus( result?: AsyncCompletionData | null error?: string | null completedAt?: Date | null - } = {} + } = {}, + expectedStatuses?: CopilotAsyncToolStatus[] ) { return withDbSpan( TraceSpan.CopilotAsyncRunsMarkAsyncToolStatus, @@ -370,7 +355,14 @@ async function markAsyncToolStatus( completedAt: updates.completedAt, updatedAt: new Date(), }) - .where(eq(copilotAsyncToolCalls.toolCallId, toolCallId)) + .where( + expectedStatuses + ? and( + eq(copilotAsyncToolCalls.toolCallId, toolCallId), + inArray(copilotAsyncToolCalls.status, expectedStatuses) + ) + : eq(copilotAsyncToolCalls.toolCallId, toolCallId) + ) .returning() return row ?? null @@ -382,6 +374,90 @@ export async function markAsyncToolRunning(toolCallId: string, claimedBy: string return markAsyncToolStatus(toolCallId, 'running', { claimedBy }) } +export function getClaimedWorkflowExecutionId(claimedBy: string | null | undefined) { + if (!claimedBy?.startsWith(WORKFLOW_EXECUTION_CLAIM_PREFIX)) return undefined + const executionId = claimedBy.slice(WORKFLOW_EXECUTION_CLAIM_PREFIX.length) + return executionId.length > 0 ? executionId : undefined +} + +export async function claimWorkflowToolExecution(toolCallId: string, executionId: string) { + const claimedBy = `${WORKFLOW_EXECUTION_CLAIM_PREFIX}${executionId}` + return withDbSpan( + TraceSpan.CopilotAsyncRunsMarkAsyncToolStatus, + 'UPDATE', + 'copilot_async_tool_calls', + { + [TraceAttr.ToolCallId]: toolCallId, + [TraceAttr.CopilotAsyncToolClaimedBy]: claimedBy, + }, + async () => { + const now = new Date() + const [row] = await db + .update(copilotAsyncToolCalls) + .set({ + status: sql`CASE WHEN ${copilotAsyncToolCalls.status} = ${ASYNC_TOOL_STATUS.pending} THEN ${ASYNC_TOOL_STATUS.running} ELSE ${copilotAsyncToolCalls.status} END`, + claimedBy, + claimedAt: now, + updatedAt: now, + }) + .where( + and( + eq(copilotAsyncToolCalls.toolCallId, toolCallId), + isNull(copilotAsyncToolCalls.claimedBy), + or( + inArray(copilotAsyncToolCalls.status, [ + ASYNC_TOOL_STATUS.running, + ASYNC_TOOL_STATUS.delivered, + ]), + and( + eq(copilotAsyncToolCalls.status, ASYNC_TOOL_STATUS.pending), + inArray(copilotAsyncToolCalls.permissionDecision, [ + ...EXECUTABLE_TOOL_PERMISSION_DECISIONS, + ]) + ) + ) + ) + ) + .returning() + return row ?? null + } + ) +} + +export async function releaseWorkflowToolExecutionClaim(toolCallId: string, executionId: string) { + const claimedBy = `${WORKFLOW_EXECUTION_CLAIM_PREFIX}${executionId}` + return withDbSpan( + TraceSpan.CopilotAsyncRunsReleaseClaim, + 'UPDATE', + 'copilot_async_tool_calls', + { + [TraceAttr.ToolCallId]: toolCallId, + [TraceAttr.CopilotAsyncToolClaimedBy]: claimedBy, + }, + async () => { + const [row] = await db + .update(copilotAsyncToolCalls) + .set({ + claimedBy: null, + claimedAt: null, + updatedAt: new Date(), + }) + .where( + and( + eq(copilotAsyncToolCalls.toolCallId, toolCallId), + eq(copilotAsyncToolCalls.claimedBy, claimedBy), + inArray(copilotAsyncToolCalls.status, [ + ASYNC_TOOL_STATUS.running, + ASYNC_TOOL_STATUS.delivered, + ]) + ) + ) + .returning() + return row ?? null + } + ) +} + /** * Atomically claims a pending client tool exactly once. Native browser actions * use this before crossing the Electron boundary so a replayed renderer event @@ -425,27 +501,79 @@ export async function completeAsyncToolCall(input: { result?: AsyncCompletionData | null error?: string | null }) { - const existing = await getAsyncToolCall(input.toolCallId) - - if (!existing) { - logger.warn('completeAsyncToolCall called before pending row existed', { - toolCallId: input.toolCallId, - status: input.status, - }) - return null - } + return markAsyncToolStatus( + input.toolCallId, + input.status, + { + claimedBy: null, + claimedAt: null, + result: input.result ?? null, + error: input.error ?? null, + completedAt: new Date(), + }, + [ASYNC_TOOL_STATUS.pending, ASYNC_TOOL_STATUS.running] + ) +} - if (isTerminalAsyncStatus(existing.status) || isDeliveredAsyncStatus(existing.status)) { - return existing - } +/** + * Atomically detaches a live client tool after the browser reports that it is + * continuing in the background. Whichever terminal or detach transition wins + * is the only result eligible for publication. + */ +export async function detachAsyncToolCall( + toolCallId: string, + options?: { preserveClaim?: boolean } +) { + return markAsyncToolStatus( + toolCallId, + ASYNC_TOOL_STATUS.delivered, + options?.preserveClaim ? {} : { claimedBy: null, claimedAt: null }, + [ASYNC_TOOL_STATUS.pending, ASYNC_TOOL_STATUS.running] + ) +} - return markAsyncToolStatus(input.toolCallId, input.status, { - claimedBy: null, - claimedAt: null, - result: input.result ?? null, - error: input.error ?? null, - completedAt: new Date(), - }) +/** + * Replaces an already-terminal async tool call from a trusted producer. + * + * Client workflow confirmations are persisted structurally first. The live + * Copilot waiter uses this guarded update only after it has restored and + * projected the server-owned workflow result. + */ +export async function replaceTerminalAsyncToolCallResult(input: { + toolCallId: string + status: AsyncTerminalStatus + result: AsyncCompletionData | null + error: string | null +}) { + return withDbSpan( + TraceSpan.CopilotAsyncRunsMarkAsyncToolStatus, + 'UPDATE', + 'copilot_async_tool_calls', + { + [TraceAttr.ToolCallId]: input.toolCallId, + [TraceAttr.CopilotAsyncToolStatus]: input.status, + [TraceAttr.CopilotAsyncToolHasError]: !!input.error, + }, + async () => { + const [row] = await db + .update(copilotAsyncToolCalls) + .set({ + status: input.status, + result: sanitizeValueForJsonb(input.result), + error: input.error, + updatedAt: new Date(), + }) + .where( + and( + eq(copilotAsyncToolCalls.toolCallId, input.toolCallId), + eq(copilotAsyncToolCalls.status, input.status) + ) + ) + .returning() + + return row ?? null + } + ) } /** @@ -480,7 +608,8 @@ export async function recordToolPermissionDecision( .where( and( eq(copilotAsyncToolCalls.toolCallId, toolCallId), - isNull(copilotAsyncToolCalls.permissionDecision) + isNull(copilotAsyncToolCalls.permissionDecision), + eq(copilotAsyncToolCalls.status, ASYNC_TOOL_STATUS.pending) ) ) .returning() @@ -489,13 +618,6 @@ export async function recordToolPermissionDecision( ) } -export async function markAsyncToolDelivered(toolCallId: string) { - return markAsyncToolStatus(toolCallId, ASYNC_TOOL_STATUS.delivered, { - claimedBy: null, - claimedAt: null, - }) -} - async function listAsyncToolCallsForRun(runId: string) { return withDbSpan( TraceSpan.CopilotAsyncRunsListForRun, diff --git a/apps/sim/lib/copilot/chat/post.test.ts b/apps/sim/lib/copilot/chat/post.test.ts index 1de5ea17dcb..cfd555b6db8 100644 --- a/apps/sim/lib/copilot/chat/post.test.ts +++ b/apps/sim/lib/copilot/chat/post.test.ts @@ -14,11 +14,12 @@ import { } from '@sim/testing' import { NextRequest } from 'next/server' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' const resolveWorkflowIdForUser = workflowsUtilsMockFns.mockResolveWorkflowIdForUser const getUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermissions -const getEffectiveDecryptedEnv = environmentUtilsMockFns.mockGetEffectiveDecryptedEnv +const getEffectiveEnvironmentSnapshot = environmentUtilsMockFns.mockGetEffectiveEnvironmentSnapshot const { generateWorkspaceSnapshot, @@ -134,7 +135,14 @@ describe('handleUnifiedChatPost', () => { }) getUserEntityPermissions.mockResolvedValue('write') resolveBillingAttribution.mockResolvedValue(billingAttribution) - getEffectiveDecryptedEnv.mockResolvedValue({ API_KEY: 'secret' }) + getEffectiveEnvironmentSnapshot.mockResolvedValue({ + personalEncrypted: { API_KEY: 'encrypted-secret' }, + workspaceEncrypted: {}, + personalDecrypted: { API_KEY: 'secret' }, + workspaceDecrypted: {}, + conflicts: [], + decryptionFailures: [], + }) generateWorkspaceSnapshot.mockResolvedValue({ markdown: 'workspace context', snapshot: { workflows: [{ id: 'wf-1', name: 'Alpha', path: 'workflows/Alpha' }] }, @@ -197,6 +205,7 @@ describe('handleUnifiedChatPost', () => { workspaceId: 'ws-1', billingAttribution, requestMode: 'agent', + resolvedSecretTraceRegistry: expect.any(ResolvedSecretTraceRegistry), }), }), }) @@ -238,6 +247,7 @@ describe('handleUnifiedChatPost', () => { workspaceId: 'ws-1', billingAttribution, requestMode: 'agent', + resolvedSecretTraceRegistry: expect.any(ResolvedSecretTraceRegistry), }), }), }) diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts index 4b802c1f27f..27855a7d911 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/copilot/chat/post.ts @@ -32,6 +32,7 @@ import { generateWorkspaceSnapshot } from '@/lib/copilot/chat/workspace-context' import { chatPubSub } from '@/lib/copilot/chat-status' import { COPILOT_REQUEST_MODES } from '@/lib/copilot/constants' import { computeWorkspaceEntitlements } from '@/lib/copilot/entitlements' +import { prepareCopilotEnvironmentContext } from '@/lib/copilot/environment-context' import { CopilotChatFinalizeOutcome, CopilotChatPersistOutcome, @@ -52,7 +53,6 @@ import type { ExecutionContext, OrchestratorResult } from '@/lib/copilot/request import { persistChatResources } from '@/lib/copilot/resources/persistence' import { isEphemeralResource } from '@/lib/copilot/resources/types' import { prepareExecutionContext } from '@/lib/copilot/tools/handlers/context' -import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' import { captureServerEvent } from '@/lib/posthog/server' import { resolveWorkflowIdForUser } from '@/lib/workflows/utils' import { @@ -561,8 +561,8 @@ async function buildInitialExecutionContext(params: { } } - const [decryptedEnvVars, billingAttribution] = await Promise.all([ - getEffectiveDecryptedEnv(userId, workspaceId), + const [environmentContext, billingAttribution] = await Promise.all([ + prepareCopilotEnvironmentContext(userId, workspaceId), workspaceId ? resolveBillingAttribution({ actorUserId: userId, workspaceId }) : Promise.resolve(undefined), @@ -572,7 +572,7 @@ async function buildInitialExecutionContext(params: { workflowId: workflowId ?? '', workspaceId, chatId, - decryptedEnvVars, + ...environmentContext, billingAttribution, messageId, userTimezone, diff --git a/apps/sim/lib/copilot/environment-context.test.ts b/apps/sim/lib/copilot/environment-context.test.ts new file mode 100644 index 00000000000..e4cee310987 --- /dev/null +++ b/apps/sim/lib/copilot/environment-context.test.ts @@ -0,0 +1,49 @@ +/** + * @vitest-environment node + */ +import { environmentUtilsMockFns, resetEnvironmentUtilsMock } from '@sim/testing' +import { afterEach, describe, expect, it } from 'vitest' +import { prepareCopilotEnvironmentContext } from '@/lib/copilot/environment-context' + +describe('prepareCopilotEnvironmentContext', () => { + afterEach(() => { + resetEnvironmentUtilsMock() + }) + + it('keeps decrypted values only in the inert provenance registry', async () => { + environmentUtilsMockFns.mockGetEffectiveEnvironmentSnapshot.mockResolvedValue({ + personalEncrypted: { + SHARED_SECRET: 'personal-encrypted', + PERSONAL_ONLY: 'personal-only-encrypted', + }, + workspaceEncrypted: { + SHARED_SECRET: 'workspace-encrypted', + WORKSPACE_ONLY: 'workspace-only-encrypted', + }, + personalDecrypted: { + SHARED_SECRET: 'personal-value', + PERSONAL_ONLY: 'personal-only-value', + }, + workspaceDecrypted: { + SHARED_SECRET: 'workspace-value', + WORKSPACE_ONLY: 'workspace-only-value', + }, + conflicts: ['SHARED_SECRET'], + decryptionFailures: [], + }) + + const context = await prepareCopilotEnvironmentContext('user-1', 'workspace-1') + + expect(context).not.toHaveProperty('decryptedEnvVars') + expect(context.resolvedSecretTraceRegistry.isComplete()).toBe(true) + expect( + context.resolvedSecretTraceRegistry.recordResolved('SHARED_SECRET', 'workspace-value') + ).toBe(true) + expect(context.resolvedSecretTraceRegistry.getActiveMatches()).toEqual([ + { plaintext: 'workspace-value', replacement: '{{SHARED_SECRET}}' }, + ]) + expect( + environmentUtilsMockFns.mockGetEffectiveEnvironmentSnapshot + ).toHaveBeenCalledExactlyOnceWith('user-1', 'workspace-1') + }) +}) diff --git a/apps/sim/lib/copilot/environment-context.ts b/apps/sim/lib/copilot/environment-context.ts new file mode 100644 index 00000000000..d939d60a69a --- /dev/null +++ b/apps/sim/lib/copilot/environment-context.ts @@ -0,0 +1,35 @@ +import type { ExecutionContext } from '@/lib/copilot/request/types' +import { + type EnvironmentResolutionSnapshot, + getEffectiveEnvironmentSnapshot, +} from '@/lib/environment/utils' +import { createResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' + +export type CopilotEnvironmentContext = Pick + +export async function createCopilotEnvironmentContext( + userId: string, + workspaceId: string | undefined, + environment: EnvironmentResolutionSnapshot +): Promise { + const resolvedSecretTraceRegistry = await createResolvedSecretTraceRegistry({ + personalEncrypted: environment.personalEncrypted, + workspaceEncrypted: environment.workspaceEncrypted, + personalDecrypted: environment.personalDecrypted, + workspaceDecrypted: environment.workspaceDecrypted, + decryptionFailures: environment.decryptionFailures, + scope: { userId, workspaceId }, + }) + + return { + resolvedSecretTraceRegistry, + } +} + +export async function prepareCopilotEnvironmentContext( + userId: string, + workspaceId?: string +): Promise { + const environment = await getEffectiveEnvironmentSnapshot(userId, workspaceId) + return createCopilotEnvironmentContext(userId, workspaceId, environment) +} diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index cda8d3097ea..292b1492232 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -1768,7 +1768,7 @@ export const FunctionExecute: ToolCatalogEntry = { code: { type: 'string', description: - 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with access to pre-installed CLI tools and workspace env vars as $VAR_NAME.', + 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with pre-installed CLI tools. Request each needed secret with an explicit {{VAR_NAME}} reference.', }, inputs: { type: 'object', @@ -3489,21 +3489,27 @@ export const QueryUserTable: ToolCatalogEntry = { type: 'object', description: 'Arguments for the operation', properties: { - filter: { type: 'object', description: 'MongoDB-style filter for query_rows' }, - limit: { - type: 'number', - description: 'Maximum rows to return (optional, default 100, max 1000 per call)', + cursor: { + type: 'string', + description: + 'Opaque pagination cursor for query_rows (optional). Omit for the first page; to fetch the next page, pass back the nextCursor from the previous result\'s "more available" message verbatim. Cannot be combined with a fresh order — the cursor already encodes the paging position.', + }, + filter: { + type: 'object', + description: + 'Predicate filter object for query_rows. A predicate is a tree: {"all":[...]} (AND) or {"any":[...]} (OR); members are leaves {field, op, value} or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (use * as the wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array value. Examples: {"all":[{"field":"status","op":"eq","value":"active"}]}; {"any":[{"field":"status","op":"eq","value":"active"},{"field":"status","op":"eq","value":"pending"}]}; {"all":[{"field":"name","op":"ilike","value":"*jo*"}]}.', }, - offset: { + limit: { type: 'number', - description: 'Number of rows to skip (optional for query_rows, default 0)', + description: + 'Maximum rows per page for query_rows (optional). Omit to fetch the ENTIRE matching result in one response — the call fails if the result exceeds the 5MB budget (narrow with a filter or set a limit). With a limit, a page may end early at the byte budget with more remaining; a non-null nextCursor in the result means more rows exist (continue with cursor).', }, - rowId: { type: 'string', description: 'Row ID (required for get_row)' }, - sort: { - type: 'object', + order: { + type: 'array', description: - "Sort specification as { field: 'asc' | 'desc' } (optional for query_rows)", + 'Sort spec for query_rows (optional). Ordered list of {field, direction} where direction is asc or desc, e.g. [{"field":"wins","direction":"desc"},{"field":"name","direction":"asc"}].', }, + rowId: { type: 'string', description: 'Row ID (required for get_row)' }, tableId: { type: 'string', description: 'Table ID (required for all operations)' }, }, }, @@ -3766,7 +3772,7 @@ export const RunCode: ToolCatalogEntry = { code: { type: 'string', description: - 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with access to pre-installed CLI tools and workspace env vars as $VAR_NAME.', + 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with pre-installed CLI tools. Request each needed secret with an explicit {{VAR_NAME}} reference.', }, inputs: { type: 'object', @@ -4742,17 +4748,17 @@ export const UserTable: ToolCatalogEntry = { description: 'New column type (optional for update_column). Types: string, number, boolean, date, json, select. Converting a column to select also requires options; the conversion fails if any existing cell value doesn\'t match one of them. Converting to a multiple: true select also accepts a comma-separated cell ("Open, Urgent"), which is the form a multi column converts to text as — so multiselect → text → multiselect round-trips.', }, - order: { - type: 'array', - description: - 'Sort spec for query_rows (optional). Ordered list of {field, direction} where direction is asc or desc, e.g. [{"field":"wins","direction":"desc"},{"field":"name","direction":"asc"}].', - }, options: { type: 'array', description: 'Choices for a select (enum) column, as a list of display names, e.g. ["Open", "Closed"]. Required when creating or converting to a select column. On update_column this REPLACES the option list and is matched against the current one BY NAME: a name still present keeps its cells, a name no longer present is removed and cleared from every cell that held it. Send the full list including the options you are keeping — omitting one deletes it. There is no in-place rename, so re-sending an option under a new name clears the cells that held the old one. Max 100.', items: { type: 'string' }, }, + order: { + type: 'array', + description: + 'Sort spec for query_rows (optional). Ordered list of {field, direction} where direction is asc or desc, e.g. [{"field":"wins","direction":"desc"},{"field":"name","direction":"asc"}].', + }, outputColumnNames: { type: 'object', description: diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 825443e2447..421ddcadb25 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -1464,7 +1464,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { code: { type: 'string', description: - 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with access to pre-installed CLI tools and workspace env vars as $VAR_NAME.', + 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with pre-installed CLI tools. Request each needed secret with an explicit {{VAR_NAME}} reference.', }, inputs: { type: 'object', @@ -3148,27 +3148,30 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'object', description: 'Arguments for the operation', properties: { + cursor: { + type: 'string', + description: + 'Opaque pagination cursor for query_rows (optional). Omit for the first page; to fetch the next page, pass back the nextCursor from the previous result\'s "more available" message verbatim. Cannot be combined with a fresh order — the cursor already encodes the paging position.', + }, filter: { type: 'object', - description: 'MongoDB-style filter for query_rows', + description: + 'Predicate filter object for query_rows. A predicate is a tree: {"all":[...]} (AND) or {"any":[...]} (OR); members are leaves {field, op, value} or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (use * as the wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array value. Examples: {"all":[{"field":"status","op":"eq","value":"active"}]}; {"any":[{"field":"status","op":"eq","value":"active"},{"field":"status","op":"eq","value":"pending"}]}; {"all":[{"field":"name","op":"ilike","value":"*jo*"}]}.', }, limit: { type: 'number', - description: 'Maximum rows to return (optional, default 100, max 1000 per call)', + description: + 'Maximum rows per page for query_rows (optional). Omit to fetch the ENTIRE matching result in one response — the call fails if the result exceeds the 5MB budget (narrow with a filter or set a limit). With a limit, a page may end early at the byte budget with more remaining; a non-null nextCursor in the result means more rows exist (continue with cursor).', }, - offset: { - type: 'number', - description: 'Number of rows to skip (optional for query_rows, default 0)', + order: { + type: 'array', + description: + 'Sort spec for query_rows (optional). Ordered list of {field, direction} where direction is asc or desc, e.g. [{"field":"wins","direction":"desc"},{"field":"name","direction":"asc"}].', }, rowId: { type: 'string', description: 'Row ID (required for get_row)', }, - sort: { - type: 'object', - description: - "Sort specification as { field: 'asc' | 'desc' } (optional for query_rows)", - }, tableId: { type: 'string', description: 'Table ID (required for all operations)', @@ -3435,7 +3438,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { code: { type: 'string', description: - 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with access to pre-installed CLI tools and workspace env vars as $VAR_NAME.', + 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with pre-installed CLI tools. Request each needed secret with an explicit {{VAR_NAME}} reference.', }, inputs: { type: 'object', @@ -4247,6 +4250,11 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: 'Array of column names to delete at once (for delete_column). Preferred over columnName when deleting multiple columns.', }, + cursor: { + type: 'string', + description: + 'Opaque pagination cursor for query_rows (optional). Omit for the first page; to fetch the next page, pass back the nextCursor from the previous result\'s "more available" message verbatim. Cannot be combined with a fresh order — the cursor already encodes the paging position.', + }, data: { type: 'object', description: 'Row data as key-value pairs (required for insert_row, update_row)', @@ -4289,7 +4297,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { filter: { type: 'object', description: - 'MongoDB-style filter for query_rows, update_rows_by_filter, delete_rows_by_filter', + 'Predicate filter object for query_rows, update_rows_by_filter, delete_rows_by_filter. A predicate is a tree: {"all":[...]} (AND) or {"any":[...]} (OR); members are leaves {field, op, value} or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (use * as the wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array value. Examples: {"all":[{"field":"status","op":"eq","value":"active"}]}; {"all":[{"field":"wins","op":"gte","value":18},{"field":"status","op":"eq","value":"pending"}]}; {"any":[{"field":"status","op":"eq","value":"active"},{"field":"status","op":"eq","value":"pending"}]}; {"all":[{"field":"name","op":"ilike","value":"*jo*"}]}; {"all":[{"field":"slack_user_id","op":"in","value":["U1","U2"]}]}.', }, groupId: { type: 'string', @@ -4326,7 +4334,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { limit: { type: 'number', description: - 'Maximum rows to return or affect (optional, default 100). Omit on update_rows_by_filter / delete_rows_by_filter to act on every match.', + 'Maximum rows per page for query_rows (optional). Omit to fetch the ENTIRE matching result in one response — the call fails if the result exceeds the 5MB budget (narrow with a filter or set a limit). With a limit, a page may end early at the byte budget with more remaining; a non-null nextCursor in the result means more rows exist (continue with cursor). On update_rows_by_filter / delete_rows_by_filter, caps affected rows; omit to act on every match.', }, mapping: { type: 'object', @@ -4388,10 +4396,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: 'New column type (optional for update_column). Types: string, number, boolean, date, json, select. Converting a column to select also requires options; the conversion fails if any existing cell value doesn\'t match one of them. Converting to a multiple: true select also accepts a comma-separated cell ("Open, Urgent"), which is the form a multi column converts to text as — so multiselect → text → multiselect round-trips.', }, - offset: { - type: 'number', - description: 'Number of rows to skip (optional for query_rows, default 0)', - }, options: { type: 'array', description: @@ -4400,6 +4404,11 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'string', }, }, + order: { + type: 'array', + description: + 'Sort spec for query_rows (optional). Ordered list of {field, direction} where direction is asc or desc, e.g. [{"field":"wins","direction":"desc"},{"field":"name","direction":"asc"}].', + }, outputColumnNames: { type: 'object', description: @@ -4492,11 +4501,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { "Cancellation scope for cancel_table_runs. 'all' cancels in-flight runs across the whole table; 'row' cancels only the row identified by rowId.", enum: ['all', 'row'], }, - sort: { - type: 'object', - description: - "Sort specification as { field: 'asc' | 'desc' } (optional for query_rows)", - }, tableId: { type: 'string', description: diff --git a/apps/sim/lib/copilot/persistence/tool-confirm/index.ts b/apps/sim/lib/copilot/persistence/tool-confirm/index.ts index 09f5fd2ee93..dd1a41006b2 100644 --- a/apps/sim/lib/copilot/persistence/tool-confirm/index.ts +++ b/apps/sim/lib/copilot/persistence/tool-confirm/index.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { + ASYNC_TOOL_CONFIRMATION_STATUS, ASYNC_TOOL_STATUS, type AsyncCompletionEnvelope, type AsyncConfirmationState, @@ -46,10 +47,10 @@ export async function getToolConfirmation( }) if (!row) return null if (row.status === ASYNC_TOOL_STATUS.delivered) { - logger.warn('Delivered async tool rows are outside request confirmation flow', { - toolCallId, - }) - return null + return { + status: ASYNC_TOOL_CONFIRMATION_STATUS.background, + timestamp: row.updatedAt?.toISOString?.(), + } } return { status: diff --git a/apps/sim/lib/copilot/persistence/tool-confirm/tool-confirm.test.ts b/apps/sim/lib/copilot/persistence/tool-confirm/tool-confirm.test.ts index efe38c759ab..7b72bce8255 100644 --- a/apps/sim/lib/copilot/persistence/tool-confirm/tool-confirm.test.ts +++ b/apps/sim/lib/copilot/persistence/tool-confirm/tool-confirm.test.ts @@ -82,7 +82,7 @@ describe('copilot orchestrator persistence', () => { }) }) - it('ignores delivered rows in request confirmation flow', async () => { + it('reconstructs background from a delivered durable row', async () => { row = { status: 'delivered', result: { ok: true }, @@ -90,7 +90,10 @@ describe('copilot orchestrator persistence', () => { updatedAt: new Date('2026-01-01T00:00:00.000Z'), } - await expect(getToolConfirmation('tool-1')).resolves.toBeNull() + await expect(getToolConfirmation('tool-1')).resolves.toEqual({ + status: 'background', + timestamp: '2026-01-01T00:00:00.000Z', + }) }) it('ignores background when waiting for a foreground terminal status', async () => { @@ -163,4 +166,22 @@ describe('copilot orchestrator persistence', () => { timestamp: '2026-01-01T00:00:01.000Z', }) }) + + it('resolves background when detach completes before the waiter subscribes', async () => { + row = { + status: 'delivered', + error: null, + result: null, + updatedAt: new Date('2026-01-01T00:00:01.000Z'), + } + + await expect( + waitForToolConfirmation('tool-1', 5_000, undefined, { + acceptStatus: (status) => status === 'background', + }) + ).resolves.toEqual({ + status: 'background', + timestamp: '2026-01-01T00:00:01.000Z', + }) + }) }) diff --git a/apps/sim/lib/copilot/persistence/tool-permission/index.ts b/apps/sim/lib/copilot/persistence/tool-permission/index.ts index 718b6e9e3e6..083d11004d2 100644 --- a/apps/sim/lib/copilot/persistence/tool-permission/index.ts +++ b/apps/sim/lib/copilot/persistence/tool-permission/index.ts @@ -1,6 +1,7 @@ import type { CopilotToolPermissionDecision } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { isExecutableToolPermissionDecision } from '@/lib/copilot/async-runs/lifecycle' import { getAsyncToolCall } from '@/lib/copilot/async-runs/repository' import { createPubSubChannel, type PubSubChannel } from '@/lib/events/pubsub' @@ -26,7 +27,7 @@ export interface ToolPermissionEnvelope { /** Every allow variant runs the tool; they differ only in what gets remembered. */ export function decisionAllowsExecution(decision: ToolPermissionDecision): boolean { - return decision !== TOOL_PERMISSION_DECISION.skip + return isExecutableToolPermissionDecision(decision) } /** True for the decisions that suppress future prompts for the same tool. */ diff --git a/apps/sim/lib/copilot/request/context/result.test.ts b/apps/sim/lib/copilot/request/context/result.test.ts index ebc2ce9f1be..1947b635512 100644 --- a/apps/sim/lib/copilot/request/context/result.test.ts +++ b/apps/sim/lib/copilot/request/context/result.test.ts @@ -32,7 +32,10 @@ function makeContext(): StreamingContext { wasAborted: false, errors: [], trace: new TraceCollector(), - toolPermissions: { enabled: false, autoAllowed: new Set() }, + toolPermissions: { + enabled: false, + autoAllowed: new Set(), + }, } } diff --git a/apps/sim/lib/copilot/request/go/stream.test.ts b/apps/sim/lib/copilot/request/go/stream.test.ts index 33979504936..efa9d8ef7d8 100644 --- a/apps/sim/lib/copilot/request/go/stream.test.ts +++ b/apps/sim/lib/copilot/request/go/stream.test.ts @@ -109,7 +109,10 @@ function createStreamingContext(): StreamingContext { errors: [], activeFileIntents: new Map(), trace: new TraceCollector(), - toolPermissions: { enabled: false, autoAllowed: new Set() }, + toolPermissions: { + enabled: false, + autoAllowed: new Set(), + }, } } diff --git a/apps/sim/lib/copilot/request/go/stream.ts b/apps/sim/lib/copilot/request/go/stream.ts index 5ebe3be2c4b..471904c16fe 100644 --- a/apps/sim/lib/copilot/request/go/stream.ts +++ b/apps/sim/lib/copilot/request/go/stream.ts @@ -377,7 +377,7 @@ export async function runStreamLoop( state: filePreviewAdapterState, }) - await prePersistClientExecutableToolCall(streamEvent, context, options) + await prePersistClientExecutableToolCall(streamEvent, context, options, execContext) try { await options.onEvent?.(streamEvent) diff --git a/apps/sim/lib/copilot/request/handlers/handlers.test.ts b/apps/sim/lib/copilot/request/handlers/handlers.test.ts index fb595791464..c3f92fe3c06 100644 --- a/apps/sim/lib/copilot/request/handlers/handlers.test.ts +++ b/apps/sim/lib/copilot/request/handlers/handlers.test.ts @@ -15,16 +15,21 @@ const { isSimExecuted, executeTool, ensureHandlersRegistered, toolRequiresApprov }) ) -const { upsertAsyncToolCall, markAsyncToolRunning, completeAsyncToolCall, markAsyncToolDelivered } = +const { upsertAsyncToolCall, markAsyncToolRunning, completeAsyncToolCall } = vi.hoisted(() => ({ + upsertAsyncToolCall: vi.fn(), + markAsyncToolRunning: vi.fn(), + completeAsyncToolCall: vi.fn(), +})) + +const { waitForClientToolCompletion, waitForToolCompletion, waitForWorkflowToolCompletion } = vi.hoisted(() => ({ - upsertAsyncToolCall: vi.fn(), - markAsyncToolRunning: vi.fn(), - completeAsyncToolCall: vi.fn(), - markAsyncToolDelivered: vi.fn(), + waitForClientToolCompletion: vi.fn(), + waitForToolCompletion: vi.fn(), + waitForWorkflowToolCompletion: vi.fn(), })) -const { waitForToolCompletion } = vi.hoisted(() => ({ - waitForToolCompletion: vi.fn(), +const { sealClientToolContext } = vi.hoisted(() => ({ + sealClientToolContext: vi.fn(), })) vi.mock('@/lib/copilot/tool-executor', () => ({ @@ -50,12 +55,17 @@ vi.mock('@/lib/copilot/async-runs/repository', () => ({ releaseCompletedAsyncToolClaim: vi.fn(), upsertAsyncToolCall, markAsyncToolRunning, - markAsyncToolDelivered, completeAsyncToolCall, })) vi.mock('@/lib/copilot/request/tools/client', () => ({ + waitForClientToolCompletion, waitForToolCompletion, + waitForWorkflowToolCompletion, +})) + +vi.mock('@/lib/copilot/request/tools/client-completion-seal.server', () => ({ + sealClientToolContext, })) import { @@ -70,13 +80,14 @@ import { MothershipStreamV1ToolOutcome, MothershipStreamV1ToolPhase, } from '@/lib/copilot/generated/mothership-stream-v1' -import { Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1' +import { FunctionExecute, Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1' import { prePersistClientExecutableToolCall, sseHandlers, subAgentHandlers, } from '@/lib/copilot/request/handlers' import type { ExecutionContext, StreamEvent, StreamingContext } from '@/lib/copilot/request/types' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' describe('sse-handlers tool lifecycle', () => { let context: StreamingContext @@ -88,8 +99,12 @@ describe('sse-handlers tool lifecycle', () => { upsertAsyncToolCall.mockResolvedValue(null) markAsyncToolRunning.mockResolvedValue(null) completeAsyncToolCall.mockResolvedValue(null) - markAsyncToolDelivered.mockResolvedValue(null) waitForToolCompletion.mockResolvedValue(null) + waitForClientToolCompletion.mockResolvedValue(null) + waitForWorkflowToolCompletion.mockResolvedValue(null) + sealClientToolContext.mockResolvedValue({ + __sealedClientToolContextV1: 'sealed-context', + }) context = { chatId: undefined, messageId: 'msg-1', @@ -109,11 +124,15 @@ describe('sse-handlers tool lifecycle', () => { streamComplete: false, wasAborted: false, errors: [], - toolPermissions: { enabled: false, autoAllowed: new Set() }, + toolPermissions: { + enabled: false, + autoAllowed: new Set(), + }, } execContext = { userId: 'user-1', workflowId: 'workflow-1', + resolvedSecretTraceRegistry: new ResolvedSecretTraceRegistry([]), } }) @@ -133,7 +152,9 @@ describe('sse-handlers tool lifecycle', () => { phase: MothershipStreamV1ToolPhase.call, }, } satisfies StreamEvent, - context + context, + {}, + execContext ) expect(upsertAsyncToolCall).toHaveBeenCalledWith({ @@ -141,14 +162,24 @@ describe('sse-handlers tool lifecycle', () => { toolCallId: 'browser-tool-1', toolName: 'browser_list_tabs', args: {}, + sealedContext: { __sealedClientToolContextV1: 'sealed-context' }, status: MothershipStreamV1AsyncToolRecordStatus.pending, }) + expect(sealClientToolContext).toHaveBeenCalledWith({ + toolCallId: 'browser-tool-1', + runId: 'run-1', + userId: 'user-1', + registry: execContext.resolvedSecretTraceRegistry, + }) }) it('persists a gated sim tool and stamps the frame so a reload can still answer it', async () => { toolRequiresApproval.mockReturnValue(true) context.runId = 'run-1' - context.toolPermissions = { enabled: true, autoAllowed: new Set() } + context.toolPermissions = { + enabled: true, + autoAllowed: new Set(), + } const event = { type: MothershipStreamV1EventType.tool, @@ -182,7 +213,10 @@ describe('sse-handlers tool lifecycle', () => { // answer into a disabled endpoint. toolRequiresApproval.mockReturnValue(false) context.runId = 'run-1' - context.toolPermissions = { enabled: false, autoAllowed: new Set() } + context.toolPermissions = { + enabled: false, + autoAllowed: new Set(), + } const event = { type: MothershipStreamV1EventType.tool, @@ -206,7 +240,10 @@ describe('sse-handlers tool lifecycle', () => { it('clears a Go-stamped approval frame on an internal tool', async () => { toolRequiresApproval.mockReturnValue(true) context.runId = 'run-1' - context.toolPermissions = { enabled: true, autoAllowed: new Set() } + context.toolPermissions = { + enabled: true, + autoAllowed: new Set(), + } const event = { type: MothershipStreamV1EventType.tool, @@ -232,7 +269,10 @@ describe('sse-handlers tool lifecycle', () => { it('leaves an already always-allowed tool ungated', async () => { toolRequiresApproval.mockReturnValue(true) context.runId = 'run-1' - context.toolPermissions = { enabled: true, autoAllowed: new Set(['deploy_api']) } + context.toolPermissions = { + enabled: true, + autoAllowed: new Set(['deploy_api']), + } const event = { type: MothershipStreamV1EventType.tool, @@ -431,8 +471,82 @@ describe('sse-handlers tool lifecycle', () => { expect(updated?.result?.output).toBe('done') }) - it('marks background client workflow tools delivered after synthetic result emission', async () => { - waitForToolCompletion.mockResolvedValueOnce({ + it('projects resolved Function secrets before every Copilot-visible result sink', async () => { + const registry = new ResolvedSecretTraceRegistry([ + { + name: 'SECRET', + plaintext: 'secret-value', + encryptedValue: 'encrypted-secret-value', + }, + ]) + registry.recordResolved('SECRET', 'secret-value') + execContext.resolvedSecretTraceRegistry = registry + execContext.chatId = 'chat-1' + executeTool.mockResolvedValueOnce({ + success: true, + output: { + result: 'secret-value', + stdout: 'prefix secret-value', + }, + resources: [{ type: 'file', id: 'file-1', title: 'secret-value.txt' }], + }) + const onEvent = vi.fn() + + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'tool-function', + toolName: FunctionExecute.id, + arguments: { code: 'return {{SECRET}}' }, + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent, + context, + execContext, + { onEvent, interactive: false, timeout: 1000 } + ) + + await sleep(0) + + const safeOutput = { + result: '{{SECRET}}', + stdout: 'prefix {{SECRET}}', + } + expect(completeAsyncToolCall).toHaveBeenCalledWith( + expect.objectContaining({ + toolCallId: 'tool-function', + result: safeOutput, + }) + ) + expect(onEvent).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + toolCallId: 'tool-function', + output: safeOutput, + }), + }) + ) + expect(context.toolCalls.get('tool-function')?.result?.output).toEqual(safeOutput) + expect(onEvent).toHaveBeenCalledWith({ + type: MothershipStreamV1EventType.resource, + payload: { + op: MothershipStreamV1ResourceOp.upsert, + resource: { + type: 'file', + id: 'file-1', + title: '{{SECRET}}.txt', + }, + }, + }) + expect(JSON.stringify(completeAsyncToolCall.mock.calls)).not.toContain('secret-value') + expect(JSON.stringify(onEvent.mock.calls)).not.toContain('secret-value') + }) + + it('emits a structural result for a detached background workflow tool', async () => { + waitForWorkflowToolCompletion.mockResolvedValueOnce({ status: 'background', data: { detached: true }, }) @@ -458,7 +572,13 @@ describe('sse-handlers tool lifecycle', () => { await sleep(0) await Promise.allSettled(context.pendingToolPromises.values()) - expect(markAsyncToolDelivered).toHaveBeenCalledWith('tool-background') + expect(waitForWorkflowToolCompletion).toHaveBeenCalledWith({ + toolCallId: 'tool-background', + workflowId: 'workflow-1', + timeoutMs: 1000, + abortSignal: undefined, + registry: execContext.resolvedSecretTraceRegistry, + }) expect(onEvent).toHaveBeenCalledWith( expect.objectContaining({ type: MothershipStreamV1EventType.tool, @@ -477,10 +597,12 @@ describe('sse-handlers tool lifecycle', () => { }) it('waits for the desktop client when a static VFS read is explicitly user-local', async () => { - waitForToolCompletion.mockResolvedValueOnce({ + waitForClientToolCompletion.mockResolvedValueOnce({ status: 'success', - data: { content: 'hello', totalLines: 1 }, + message: 'Read {{SECRET}}', + data: { content: '{{SECRET}}', totalLines: 1 }, }) + const onEvent = vi.fn() await sseHandlers.tool( { @@ -496,12 +618,32 @@ describe('sse-handlers tool lifecycle', () => { } satisfies StreamEvent, context, execContext, - { onEvent: vi.fn(), interactive: true, timeout: 1000 } + { onEvent, interactive: true, timeout: 1000 } ) await Promise.allSettled(context.pendingToolPromises.values()) - expect(waitForToolCompletion).toHaveBeenCalledWith('tool-user-local-read', 1000, undefined) + expect(waitForClientToolCompletion).toHaveBeenCalledWith({ + toolCallId: 'tool-user-local-read', + runId: context.runId, + userId: 'user-1', + timeoutMs: 1000, + abortSignal: undefined, + registry: execContext.resolvedSecretTraceRegistry, + }) + expect(onEvent).toHaveBeenCalledWith( + expect.objectContaining({ + type: MothershipStreamV1EventType.tool, + payload: expect.objectContaining({ + phase: MothershipStreamV1ToolPhase.result, + output: { content: '{{SECRET}}', totalLines: 1 }, + }), + }) + ) + expect(JSON.stringify(context.toolCalls.get('tool-user-local-read'))).not.toContain( + 'resolved-secret' + ) + expect(JSON.stringify(onEvent.mock.calls)).not.toContain('resolved-secret') expect(executeTool).not.toHaveBeenCalled() }) @@ -645,6 +787,53 @@ describe('sse-handlers tool lifecycle', () => { expect(context.toolCalls.has('glob-generating')).toBe(false) }) + it('executes finalized main-tool arguments instead of a generating snapshot', async () => { + executeTool.mockResolvedValueOnce({ success: true, output: { ok: true } }) + + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'function-finalized-args', + toolName: FunctionExecute.id, + arguments: { language: 'javascript', code: 'return {{STALE_SECRET}}' }, + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + status: 'generating', + }, + } satisfies StreamEvent, + context, + execContext, + { interactive: false, timeout: 1000 } + ) + + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'function-finalized-args', + toolName: FunctionExecute.id, + arguments: { language: 'javascript', code: 'return 1' }, + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent, + context, + execContext, + { interactive: false, timeout: 1000 } + ) + + await sleep(0) + + expect(executeTool).toHaveBeenCalledWith( + FunctionExecute.id, + { language: 'javascript', code: 'return 1' }, + expect.any(Object) + ) + }) + it('updates stored params when a subagent generating event is followed by the final tool call', async () => { executeTool.mockResolvedValueOnce({ success: true, output: { ok: true } }) context.toolCalls.set('parent-1', { @@ -665,6 +854,7 @@ describe('sse-handlers tool lifecycle', () => { mode: MothershipStreamV1ToolMode.async, phase: MothershipStreamV1ToolPhase.call, status: 'generating', + arguments: { name: 'Stale Workflow' }, }, } satisfies StreamEvent, context, @@ -1035,10 +1225,22 @@ describe('sse-handlers tool lifecycle', () => { const firstPromise = context.pendingToolPromises.get('tool-inflight') expect(firstPromise).toBeDefined() - await sseHandlers.tool(event as StreamEvent, context, execContext, { interactive: false }) + await sseHandlers.tool( + { + ...event, + payload: { + ...event.payload, + arguments: { workflowId: 'workflow-2' }, + }, + } as StreamEvent, + context, + execContext, + { interactive: false } + ) expect(executeTool).toHaveBeenCalledTimes(1) expect(context.pendingToolPromises.get('tool-inflight')).toBe(firstPromise) + expect(context.toolCalls.get('tool-inflight')?.params).toEqual({ workflowId: 'workflow-1' }) resolveTool?.({ success: true, output: { ok: true } }) await sleep(0) diff --git a/apps/sim/lib/copilot/request/handlers/tool.ts b/apps/sim/lib/copilot/request/handlers/tool.ts index 880edae7433..6634a625308 100644 --- a/apps/sim/lib/copilot/request/handlers/tool.ts +++ b/apps/sim/lib/copilot/request/handlers/tool.ts @@ -2,11 +2,8 @@ import { isBrowserToolName } from '@sim/browser-protocol' import { createLogger } from '@sim/logger' import { isTerminalToolName } from '@sim/terminal-protocol' import { getErrorMessage, toError } from '@sim/utils/errors' -import { - ASYNC_TOOL_CONFIRMATION_STATUS, - type AsyncCompletionSignal, -} from '@/lib/copilot/async-runs/lifecycle' -import { markAsyncToolDelivered, upsertAsyncToolCall } from '@/lib/copilot/async-runs/repository' +import type { AsyncCompletionSignal } from '@/lib/copilot/async-runs/lifecycle' +import { upsertAsyncToolCall } from '@/lib/copilot/async-runs/repository' import { STREAM_TIMEOUT_MS } from '@/lib/copilot/constants' import { MothershipStreamV1AsyncToolRecordStatus, @@ -26,7 +23,12 @@ import { } from '@/lib/copilot/request/session' import { markToolResultSeen, wasToolResultSeen } from '@/lib/copilot/request/sse-utils' import { setTerminalToolCallState } from '@/lib/copilot/request/tool-call-state' -import { executeToolAndReport, waitForToolCompletion } from '@/lib/copilot/request/tools/executor' +import { + waitForClientToolCompletion, + waitForWorkflowToolCompletion, +} from '@/lib/copilot/request/tools/client' +import { sealClientToolContext } from '@/lib/copilot/request/tools/client-completion-seal.server' +import { executeToolAndReport } from '@/lib/copilot/request/tools/executor' import { runGatedToolExecution, TOOL_AWAITING_APPROVAL_STATUS, @@ -44,7 +46,7 @@ import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools' import { isUserLocalVfsToolCall } from '@/lib/copilot/tools/local-filesystem' import { extractStreamingStringArgument } from '@/lib/copilot/tools/streaming-args' import { getToolDisplayTitle } from '@/lib/copilot/tools/tool-display' -import { isWorkflowToolName } from '@/lib/copilot/tools/workflow-tools' +import { isWorkflowToolName, resolveWorkflowToolTargetId } from '@/lib/copilot/tools/workflow-tools' import { getBlockByToolName } from '@/blocks/registry' import type { ToolScope } from './types' import { @@ -169,7 +171,8 @@ function rebindResolvedIntegrationCall( export async function prePersistClientExecutableToolCall( event: StreamEvent, context: StreamingContext, - options?: OrchestratorOptions + options?: OrchestratorOptions, + execContext?: ExecutionContext ): Promise { if (event.type !== 'tool') return if (!isToolCallStreamEvent(event)) return @@ -221,11 +224,30 @@ export async function prePersistClientExecutableToolCall( if (!context.runId) return + let sealedContext: Awaited> | undefined + if (execContext?.resolvedSecretTraceRegistry) { + try { + sealedContext = await sealClientToolContext({ + toolCallId: data.toolCallId, + runId: context.runId, + userId: execContext.userId, + registry: execContext.resolvedSecretTraceRegistry, + }) + } catch (error) { + execContext.resolvedSecretTraceRegistry.markIncomplete() + logger.warn('Failed to seal client tool provenance', { + toolCallId: data.toolCallId, + error: getErrorMessage(error), + }) + } + } + await upsertAsyncToolCall({ runId: context.runId, toolCallId: data.toolCallId, toolName: data.toolName, args: data.arguments, + sealedContext, // Browser and terminal actions cross a second, native authorization // boundary. Leave those rows pending until Electron atomically claims // them — the authorize endpoint only hands over a pending call, so a row @@ -399,11 +421,20 @@ async function handleCallPhase( if (isPartial && shouldDelayVfsPlaceholder(toolName, args)) return + if ( + existing && + (context.pendingToolPromises.has(toolCallId) || + existing.status === 'awaiting_approval' || + existing.status === 'executing') + ) { + applyToolDisplay(existing) + return + } + if (isSubagent) { if (wasToolResultSeen(toolCallId) || existing?.endTime) { if (!rebindResolvedIntegrationCall(existing, toolName, args)) { - if (existing && !existing.name && toolName) existing.name = toolName - if (existing && !existing.params && args) existing.params = args + if (existing) updateToolCallFromFrame(existing, toolName, args, !isPartial) } applyToolDisplay(existing) return @@ -414,8 +445,7 @@ async function handleCallPhase( (existing && existing.status !== 'pending' && existing.status !== 'executing') ) { if (!rebindResolvedIntegrationCall(existing, toolName, args)) { - if (!existing.name && toolName) existing.name = toolName - if (!existing.params && args) existing.params = args + updateToolCallFromFrame(existing, toolName, args, !isPartial) } applyToolDisplay(existing) return @@ -430,10 +460,11 @@ async function handleCallPhase( args, parentToolCallId!, ui, - spanIdentity + spanIdentity, + !isPartial ) } else { - registerMainToolCall(context, toolCallId, toolName, args, existing, ui) + registerMainToolCall(context, toolCallId, toolName, args, existing, ui, !isPartial) } if (isPartial) return @@ -507,6 +538,16 @@ function removeToolCallContentBlock(context: StreamingContext, toolCallId: strin } } +function updateToolCallFromFrame( + toolCall: ToolCallState, + toolName: string, + args: Record | undefined, + finalized: boolean +): void { + if (!toolCall.name && toolName) toolCall.name = toolName + if (finalized || args !== undefined) toolCall.params = args +} + function registerSubagentToolCall( context: StreamingContext, toolCallId: string, @@ -514,7 +555,8 @@ function registerSubagentToolCall( args: Record | undefined, parentToolCallId: string, ui: { title?: string; phaseLabel?: string; hidden?: boolean }, - spanIdentity: { spanId?: string; parentSpanId?: string } + spanIdentity: { spanId?: string; parentSpanId?: string }, + finalized: boolean ): void { if (!context.subAgentToolCalls[parentToolCallId]) { context.subAgentToolCalls[parentToolCallId] = [] @@ -523,8 +565,7 @@ function registerSubagentToolCall( let toolCall = context.toolCalls.get(toolCallId) if (toolCall) { if (!rebindResolvedIntegrationCall(toolCall, toolName, args)) { - if (!toolCall.name && toolName) toolCall.name = toolName - if (args && !toolCall.params) toolCall.params = args + updateToolCallFromFrame(toolCall, toolName, args, finalized) } applyToolDisplay(toolCall) if (hideFromUi) removeToolCallContentBlock(context, toolCallId) @@ -554,8 +595,7 @@ function registerSubagentToolCall( const existingSubagentToolCall = subagentToolCalls.find((tc) => tc.id === toolCallId) if (existingSubagentToolCall) { if (!rebindResolvedIntegrationCall(existingSubagentToolCall, toolName, args)) { - if (!existingSubagentToolCall.name && toolName) existingSubagentToolCall.name = toolName - if (args && !existingSubagentToolCall.params) existingSubagentToolCall.params = args + updateToolCallFromFrame(existingSubagentToolCall, toolName, args, finalized) } applyToolDisplay(existingSubagentToolCall) } else { @@ -569,12 +609,13 @@ function registerMainToolCall( toolName: string, args: Record | undefined, existing: ToolCallState | undefined, - ui: { title?: string; phaseLabel?: string; hidden?: boolean } + ui: { title?: string; phaseLabel?: string; hidden?: boolean }, + finalized: boolean ): void { const hideFromUi = isToolHiddenInUi(toolName) || ui.hidden === true if (existing) { - if (!rebindResolvedIntegrationCall(existing, toolName, args) && args && !existing.params) { - existing.params = args + if (!rebindResolvedIntegrationCall(existing, toolName, args)) { + updateToolCallFromFrame(existing, toolName, args, finalized) } applyToolDisplay(existing) if (hideFromUi) { @@ -699,25 +740,27 @@ async function dispatchToolExecution( ...(context.runId ? { [TraceAttr.RunId]: context.runId } : {}), }, async (span) => { - const completion = await waitForToolCompletion( - toolCallId, - options.timeout || STREAM_TIMEOUT_MS, - options.abortSignal - ) + const completion = isWorkflowToolName(toolName) + ? await waitForWorkflowToolCompletion({ + toolCallId, + workflowId: resolveWorkflowToolTargetId(args, execContext.workflowId), + timeoutMs: options.timeout || STREAM_TIMEOUT_MS, + abortSignal: options.abortSignal, + registry: execContext.resolvedSecretTraceRegistry, + }) + : await waitForClientToolCompletion({ + toolCallId, + runId: context.runId, + userId: execContext.userId, + timeoutMs: options.timeout || STREAM_TIMEOUT_MS, + abortSignal: options.abortSignal, + registry: execContext.resolvedSecretTraceRegistry, + }) span.setAttribute(TraceAttr.ToolCompletionReceived, completion !== undefined) if (completion) { span.setAttribute(TraceAttr.ToolOutcome, completion.status) } handleClientCompletion(toolCall, toolCallId, completion) - if (completion?.status === ASYNC_TOOL_CONFIRMATION_STATUS.background) { - await markAsyncToolDelivered(toolCallId).catch((err) => { - logger.warn(`Failed to mark background ${scopeLabel}tool delivered`, { - toolCallId, - toolName, - error: toError(err).message, - }) - }) - } await emitSyntheticToolResult(toolCallId, toolCall.name, completion, options) return ( completion ?? { diff --git a/apps/sim/lib/copilot/request/lifecycle/run.test.ts b/apps/sim/lib/copilot/request/lifecycle/run.test.ts index 46cfd2f19c2..de4782347ba 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.test.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.test.ts @@ -2,18 +2,11 @@ * @vitest-environment node */ -import { - environmentUtilsMockFns, - resetEnvFlagsMock, - resetEnvironmentUtilsMock, - setEnvFlags, -} from '@sim/testing' +import { resetEnvFlagsMock, resetEnvironmentUtilsMock, setEnvFlags } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { ExecutionContext, StreamingContext } from '@/lib/copilot/request/types' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' -const mockGetEffectiveDecryptedEnv = environmentUtilsMockFns.mockGetEffectiveDecryptedEnv - afterAll(resetEnvironmentUtilsMock) const { @@ -21,6 +14,7 @@ const { mockForceFailHungToolCall, mockGetMothershipBaseURL, mockGetMothershipSourceEnvHeaders, + mockPrepareCopilotEnvironmentContext, mockPrepareExecutionContext, mockRunStreamLoop, mockPendingToolWaitBudgetMs, @@ -32,6 +26,7 @@ const { mockForceFailHungToolCall: vi.fn(), mockGetMothershipBaseURL: vi.fn(), mockGetMothershipSourceEnvHeaders: vi.fn(), + mockPrepareCopilotEnvironmentContext: vi.fn(), mockPrepareExecutionContext: vi.fn(), mockRunStreamLoop: vi.fn(), mockPendingToolWaitBudgetMs: vi.fn(() => 60_000), @@ -108,6 +103,10 @@ vi.mock('@/lib/copilot/persistence/tool-permission/auto-allow', () => ({ addChatAutoAllowedTool: vi.fn(), })) +vi.mock('@/lib/copilot/environment-context', () => ({ + prepareCopilotEnvironmentContext: mockPrepareCopilotEnvironmentContext, +})) + vi.mock('@/lib/copilot/tools/handlers/context', () => ({ prepareExecutionContext: mockPrepareExecutionContext, })) @@ -147,6 +146,7 @@ describe('runCopilotLifecycle', () => { mockGetAutoAllowedTools.mockResolvedValue(new Set()) mockGetMothershipBaseURL.mockResolvedValue('http://mothership.test') mockGetMothershipSourceEnvHeaders.mockReturnValue({}) + mockPrepareCopilotEnvironmentContext.mockResolvedValue({}) }) it('threads trace provenance through server execution context only', async () => { @@ -155,7 +155,6 @@ describe('runCopilotLifecycle', () => { userId: 'user-1', workflowId: '', workspaceId: 'ws-1', - decryptedEnvVars: {}, } let capturedExecutionContext: ExecutionContext | undefined let capturedRequestBody = '' @@ -203,7 +202,6 @@ describe('runCopilotLifecycle', () => { workflowId: '', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, }, } ) @@ -257,7 +255,6 @@ describe('runCopilotLifecycle', () => { workflowId: 'wf-1', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, }, } ) @@ -277,7 +274,6 @@ describe('runCopilotLifecycle', () => { workflowId: '', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, } mockRunStreamLoop.mockImplementationOnce( @@ -349,7 +345,6 @@ describe('runCopilotLifecycle', () => { workflowId: '', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, } mockRunStreamLoop.mockImplementationOnce( @@ -402,7 +397,6 @@ describe('runCopilotLifecycle', () => { workflowId: '', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, } mockRunStreamLoop.mockImplementationOnce( @@ -444,7 +438,6 @@ describe('runCopilotLifecycle', () => { workflowId: '', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, } mockRunStreamLoop.mockImplementationOnce( @@ -480,9 +473,8 @@ describe('runCopilotLifecycle', () => { ) }) - it('propagates payload userPermission into the generated execution context', async () => { + it('does not trust payload userPermission when building the execution context', async () => { let capturedExecContext: ExecutionContext | undefined - mockGetEffectiveDecryptedEnv.mockResolvedValueOnce({}) mockRunStreamLoop.mockImplementationOnce( async ( _fetchUrl: string, @@ -508,9 +500,35 @@ describe('runCopilotLifecycle', () => { userId: 'user-1', workspaceId: 'ws-1', chatId: 'chat-1', - userPermission: 'write', }) ) + expect(capturedExecContext).not.toHaveProperty('userPermission') + }) + + it('uses only the trusted lifecycle userPermission option', async () => { + let capturedExecContext: ExecutionContext | undefined + mockRunStreamLoop.mockImplementationOnce( + async ( + _fetchUrl: string, + _fetchOptions: RequestInit, + _context: StreamingContext, + execContext: ExecutionContext + ): Promise => { + capturedExecContext = execContext + } + ) + + await runCopilotLifecycle( + { message: 'hello', messageId: 'stream-1', userPermission: 'admin' }, + { + userId: 'user-1', + workspaceId: 'ws-1', + chatId: 'chat-1', + userPermission: 'read', + } + ) + + expect(capturedExecContext?.userPermission).toBe('read') }) it('uses one server billing identity and immutable attribution on initial and resume legs', async () => { @@ -529,7 +547,6 @@ describe('runCopilotLifecycle', () => { setEnvFlags({ isHosted: true }) setEnvFlags({ isCopilotBillingAttributionV1Enabled: true }) mockEnv.COPILOT_API_KEY = 'sim-agent-key' - mockGetEffectiveDecryptedEnv.mockResolvedValueOnce({}) mockRunStreamLoop.mockImplementationOnce( async ( _fetchUrl: string, @@ -591,7 +608,6 @@ describe('runCopilotLifecycle', () => { it('runs legacy-v0 during Sim-first deployment without guessed billing aliases', async () => { setEnvFlags({ isHosted: true }) mockEnv.COPILOT_API_KEY = 'sim-agent-key' - mockGetEffectiveDecryptedEnv.mockResolvedValueOnce({}) await runCopilotLifecycle( { message: 'hello', messageId: 'message-1' }, @@ -626,7 +642,6 @@ describe('runCopilotLifecycle', () => { it('runs modern hosted work without legacy compatibility storage', async () => { setEnvFlags({ isHosted: true }) setEnvFlags({ isCopilotBillingAttributionV1Enabled: true }) - mockGetEffectiveDecryptedEnv.mockResolvedValueOnce({}) await runCopilotLifecycle( { message: 'hello', messageId: 'message-1' }, @@ -654,7 +669,6 @@ describe('runCopilotLifecycle', () => { it('does not emit trusted billing headers for a non-hosted lifecycle', async () => { mockEnv.COPILOT_API_KEY = 'user-or-self-hosted-key' - mockGetEffectiveDecryptedEnv.mockResolvedValueOnce({}) await runCopilotLifecycle( { message: 'hello', messageId: 'message-1', billingRequestId: 'caller-controlled' }, @@ -685,7 +699,6 @@ describe('runCopilotLifecycle', () => { it('normalizes the initial request body with workspaceId from lifecycle options', async () => { let requestBody: Record | undefined - mockGetEffectiveDecryptedEnv.mockResolvedValueOnce({}) mockRunStreamLoop.mockImplementationOnce( async (_fetchUrl: string, fetchOptions: RequestInit): Promise => { requestBody = JSON.parse(String(fetchOptions.body)) @@ -716,7 +729,6 @@ describe('runCopilotLifecycle', () => { workflowId: 'workflow-1', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, } mockRunStreamLoop.mockImplementationOnce( @@ -775,7 +787,6 @@ describe('runCopilotLifecycle', () => { workflowId: '', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, } // 1) Initial stream pauses on an async tool checkpoint with a resolved @@ -857,7 +868,6 @@ describe('runCopilotLifecycle', () => { workflowId: '', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, } // Initial leg pauses on a resolved async tool checkpoint → enters resume. @@ -927,7 +937,6 @@ describe('runCopilotLifecycle', () => { workflowId: '', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, } mockRunStreamLoop.mockImplementationOnce( @@ -990,7 +999,6 @@ describe('runCopilotLifecycle', () => { workflowId: '', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, } mockRunStreamLoop.mockImplementationOnce( @@ -1052,7 +1060,6 @@ describe('runCopilotLifecycle', () => { workflowId: '', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, } mockRunStreamLoop.mockImplementationOnce( @@ -1092,7 +1099,6 @@ describe('runCopilotLifecycle', () => { workflowId: '', workspaceId: 'ws-1', chatId: 'chat-1', - decryptedEnvVars: {}, } // Mirror the real helper: settle the tool call into a terminal error diff --git a/apps/sim/lib/copilot/request/lifecycle/run.ts b/apps/sim/lib/copilot/request/lifecycle/run.ts index 6eb5888b630..aa22adc8829 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.ts @@ -1,5 +1,6 @@ import type { Context } from '@opentelemetry/api' import { createLogger } from '@sim/logger' +import type { PermissionType } from '@sim/platform-authz/workspace' import { toError } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' import { generateId } from '@sim/utils/id' @@ -12,6 +13,10 @@ import { import { isWorkspaceOnEnterprisePlan } from '@/lib/billing/core/subscription' import { createRunSegment, updateRunStatus } from '@/lib/copilot/async-runs/repository' import { SIM_AGENT_VERSION, TOOL_WATCHDOG_RESUME_GRACE_MS } from '@/lib/copilot/constants' +import { + type CopilotEnvironmentContext, + prepareCopilotEnvironmentContext, +} from '@/lib/copilot/environment-context' import { COPILOT_BILLING_PROTOCOL, COPILOT_BILLING_PROTOCOL_HEADER, @@ -53,6 +58,7 @@ import type { StreamEvent, StreamingContext, } from '@/lib/copilot/request/types' +import type { SecretMountPolicy } from '@/lib/copilot/secret-mount-policy' import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url' import { prepareExecutionContext } from '@/lib/copilot/tools/handlers/context' import { env } from '@/lib/core/config/env' @@ -61,7 +67,6 @@ import { isCopilotToolPermissionsEnabled, isHosted, } from '@/lib/core/config/env-flags' -import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' const logger = createLogger('CopilotLifecycle') @@ -97,6 +102,10 @@ export interface CopilotLifecycleOptions extends OrchestratorOptions { executionContext?: ExecutionContext billingAttribution?: BillingAttributionSnapshot resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry + environmentContext?: CopilotEnvironmentContext + userPermission?: PermissionType + secretMountPolicy?: SecretMountPolicy + secretActorUserId?: string | null } /** @@ -163,9 +172,14 @@ export async function runCopilotLifecycle( abortSignal: options.abortSignal, billingAttribution: options.billingAttribution ?? options.executionContext.billingAttribution, + ...(options.userPermission ? { userPermission: options.userPermission } : {}), ...(options.resolvedSecretTraceRegistry ? { resolvedSecretTraceRegistry: options.resolvedSecretTraceRegistry } : {}), + ...(options.secretMountPolicy ? { secretMountPolicy: options.secretMountPolicy } : {}), + ...(options.secretActorUserId !== undefined + ? { secretActorUserId: options.secretActorUserId } + : {}), }, } : {}), @@ -183,6 +197,10 @@ export async function runCopilotLifecycle( abortSignal: lifecycleOptions.abortSignal, billingAttribution: lifecycleOptions.billingAttribution, resolvedSecretTraceRegistry: lifecycleOptions.resolvedSecretTraceRegistry, + environmentContext: lifecycleOptions.environmentContext, + userPermission: lifecycleOptions.userPermission, + secretMountPolicy: lifecycleOptions.secretMountPolicy, + secretActorUserId: lifecycleOptions.secretActorUserId, })) const shouldUseHostedBillingProtocol = isHosted && isCopilotBillingAttributionV1Enabled if ( @@ -1000,6 +1018,10 @@ async function buildExecutionContext( abortSignal?: AbortSignal billingAttribution?: BillingAttributionSnapshot resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry + environmentContext?: CopilotEnvironmentContext + userPermission?: PermissionType + secretMountPolicy?: SecretMountPolicy + secretActorUserId?: string | null } ): Promise { const { @@ -1012,27 +1034,31 @@ async function buildExecutionContext( abortSignal, billingAttribution, resolvedSecretTraceRegistry, + environmentContext, + userPermission, + secretMountPolicy, + secretActorUserId, } = params const userTimezone = typeof requestPayload?.userTimezone === 'string' ? requestPayload.userTimezone : undefined const requestMode = typeof requestPayload?.mode === 'string' ? requestPayload.mode : undefined - const userPermission = - typeof requestPayload?.userPermission === 'string' ? requestPayload.userPermission : undefined let execContext: ExecutionContext if (workflowId) { execContext = await prepareExecutionContext(userId, workflowId, chatId, { workspaceId, billingAttribution, + environmentContext, }) } else { - const decryptedEnvVars = await getEffectiveDecryptedEnv(userId, workspaceId) + const activeEnvironmentContext = + environmentContext ?? (await prepareCopilotEnvironmentContext(userId, workspaceId)) execContext = { userId, workflowId: '', workspaceId, chatId, - decryptedEnvVars, + ...activeEnvironmentContext, billingAttribution, } } @@ -1050,6 +1076,8 @@ async function buildExecutionContext( if (resolvedSecretTraceRegistry) { execContext.resolvedSecretTraceRegistry = resolvedSecretTraceRegistry } + if (secretMountPolicy) execContext.secretMountPolicy = secretMountPolicy + if (secretActorUserId !== undefined) execContext.secretActorUserId = secretActorUserId return execContext } diff --git a/apps/sim/lib/copilot/request/tools/client-completion-seal.server.ts b/apps/sim/lib/copilot/request/tools/client-completion-seal.server.ts new file mode 100644 index 00000000000..1204c5f7b92 --- /dev/null +++ b/apps/sim/lib/copilot/request/tools/client-completion-seal.server.ts @@ -0,0 +1,136 @@ +import { generateId } from '@sim/utils/id' +import { isPlainRecord } from '@sim/utils/object' +import type { AsyncCompletionData } from '@/lib/copilot/async-runs/lifecycle' +import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' +import { + isResolvedSecretTraceProvenanceV1, + type ResolvedSecretTraceProvenanceV1, + type ResolvedSecretTraceRegistry, +} from '@/executor/utils/resolved-secret-trace-registry' + +export const SEALED_CLIENT_TOOL_COMPLETION_FIELD = '__sealedClientToolCompletionV1' +export const SEALED_CLIENT_TOOL_CONTEXT_FIELD = '__sealedClientToolContextV1' + +interface ClientToolBinding { + toolCallId: string + runId: string + userId: string +} + +interface ClientToolCompletionContent extends ClientToolBinding { + message?: string + data?: AsyncCompletionData +} + +interface ClientToolContext extends ClientToolBinding { + registryInstanceId: string + provenance: ResolvedSecretTraceProvenanceV1 +} + +interface SealClientToolContextInput extends ClientToolBinding { + registry: ResolvedSecretTraceRegistry +} + +type ClientCompletionSealGlobal = typeof globalThis & { + _clientToolRegistryInstanceIds?: WeakMap +} + +const sealGlobal = globalThis as ClientCompletionSealGlobal +sealGlobal._clientToolRegistryInstanceIds ??= new WeakMap() +const registryInstanceIds = sealGlobal._clientToolRegistryInstanceIds + +function getRegistryInstanceId(registry: ResolvedSecretTraceRegistry): string { + const existing = registryInstanceIds.get(registry) + if (existing) return existing + + const created = generateId() + registryInstanceIds.set(registry, created) + return created +} + +function bindingMatches(value: Record, expected: ClientToolBinding): boolean { + return ( + value.toolCallId === expected.toolCallId && + value.runId === expected.runId && + value.userId === expected.userId + ) +} + +export async function sealClientToolCompletion( + content: ClientToolCompletionContent +): Promise> { + const { encrypted } = await encryptSecret(JSON.stringify(content)) + return { [SEALED_CLIENT_TOOL_COMPLETION_FIELD]: encrypted } +} + +export async function unsealClientToolCompletion( + value: unknown, + expected: ClientToolBinding +): Promise { + if (!isPlainRecord(value)) return null + const sealed = value[SEALED_CLIENT_TOOL_COMPLETION_FIELD] + if (typeof sealed !== 'string' || sealed.length === 0) return null + + try { + const { decrypted } = await decryptSecret(sealed) + const content: unknown = JSON.parse(decrypted) + if (!isPlainRecord(content)) return null + if (!bindingMatches(content, expected)) return null + if (content.message !== undefined && typeof content.message !== 'string') return null + return { + ...expected, + ...(content.message !== undefined ? { message: content.message } : {}), + ...(Object.hasOwn(content, 'data') ? { data: content.data } : {}), + } + } catch { + return null + } +} + +export async function sealClientToolContext( + input: SealClientToolContextInput +): Promise> { + const { registry, ...binding } = input + const context: ClientToolContext = { + ...binding, + registryInstanceId: getRegistryInstanceId(registry), + provenance: registry.exportProvenance(), + } + const { encrypted } = await encryptSecret(JSON.stringify(context)) + return { [SEALED_CLIENT_TOOL_CONTEXT_FIELD]: encrypted } +} + +export function retainSealedClientToolContext( + value: unknown +): Partial> { + if (!isPlainRecord(value)) return {} + const sealed = value[SEALED_CLIENT_TOOL_CONTEXT_FIELD] + return typeof sealed === 'string' && sealed.length > 0 + ? { [SEALED_CLIENT_TOOL_CONTEXT_FIELD]: sealed } + : {} +} + +export async function unsealClientToolContext( + value: unknown, + expected: ClientToolBinding, + registry: ResolvedSecretTraceRegistry +): Promise { + if (!isPlainRecord(value)) return null + const sealed = value[SEALED_CLIENT_TOOL_CONTEXT_FIELD] + if (typeof sealed !== 'string' || sealed.length === 0) return null + + try { + const { decrypted } = await decryptSecret(sealed) + const context: unknown = JSON.parse(decrypted) + if (!isPlainRecord(context) || !bindingMatches(context, expected)) return null + if (context.registryInstanceId !== getRegistryInstanceId(registry)) return null + if (!isResolvedSecretTraceProvenanceV1(context.provenance)) return null + return { + ...expected, + registryInstanceId: context.registryInstanceId, + provenance: context.provenance, + } + } catch { + return null + } +} diff --git a/apps/sim/lib/copilot/request/tools/client.test.ts b/apps/sim/lib/copilot/request/tools/client.test.ts new file mode 100644 index 00000000000..16e798fe1ea --- /dev/null +++ b/apps/sim/lib/copilot/request/tools/client.test.ts @@ -0,0 +1,764 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + encryptSecret, + decryptSecret, + waitForToolConfirmation, + replaceTerminalAsyncToolCallResult, + getTrustedWorkflowToolExecution, +} = vi.hoisted(() => ({ + encryptSecret: vi.fn(), + decryptSecret: vi.fn(), + waitForToolConfirmation: vi.fn(), + replaceTerminalAsyncToolCallResult: vi.fn(), + getTrustedWorkflowToolExecution: vi.fn(), +})) + +vi.mock('@/lib/core/security/encryption', () => ({ + encryptSecret, + decryptSecret, +})) + +vi.mock('@/lib/copilot/persistence/tool-confirm', () => ({ + waitForToolConfirmation, +})) + +vi.mock('@/lib/copilot/async-runs/repository', () => ({ + replaceTerminalAsyncToolCallResult, +})) + +vi.mock('@/lib/workflows/executor/execution-state', () => ({ + getTrustedWorkflowToolExecution, +})) + +import { + waitForClientToolCompletion, + waitForWorkflowToolCompletion, +} from '@/lib/copilot/request/tools/client' +import { sealClientToolContext } from '@/lib/copilot/request/tools/client-completion-seal.server' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' + +const TRACE_SCOPE = { userId: 'user-1', workspaceId: 'workspace-1' } + +function createParentRegistry(): ResolvedSecretTraceRegistry { + const registry = new ResolvedSecretTraceRegistry( + [ + { + name: 'PARENT_SECRET', + plaintext: 'parent-secret-value', + encryptedValue: 'encrypted-parent-secret', + }, + ], + TRACE_SCOPE + ) + registry.recordResolved('PARENT_SECRET', 'parent-secret-value') + return registry +} + +function createClientRegistry(): ResolvedSecretTraceRegistry { + const registry = new ResolvedSecretTraceRegistry( + [ + { + name: 'SECRET', + plaintext: 'resolved-secret', + encryptedValue: 'encrypted-secret', + }, + ], + TRACE_SCOPE + ) + registry.recordResolved('SECRET', 'resolved-secret') + return registry +} + +function trustedExecution(executionId: string) { + return { + executionId, + workflowId: 'workflow-1', + status: 'completed' as const, + contentAvailable: true as const, + finalOutput: { value: `child read parent-secret-value from ${executionId}` }, + blockLogs: [], + provenance: { + version: 1 as const, + complete: true, + entries: [], + scope: TRACE_SCOPE, + }, + } +} + +describe('workflow client tool completion', () => { + beforeEach(() => { + vi.clearAllMocks() + decryptSecret.mockResolvedValue({ decrypted: 'child-secret-value' }) + replaceTerminalAsyncToolCallResult.mockResolvedValue({ status: 'completed' }) + }) + + it('projects a parent secret laundered through a child workflow before every live sink', async () => { + const registry = createParentRegistry() + waitForToolConfirmation.mockResolvedValue({ + status: 'success', + data: { workflowId: 'workflow-1', executionId: 'execution-1' }, + }) + getTrustedWorkflowToolExecution.mockResolvedValue(trustedExecution('execution-1')) + + const completion = await waitForWorkflowToolCompletion({ + toolCallId: 'tool-1', + workflowId: 'workflow-1', + timeoutMs: 1_000, + registry, + }) + + expect(getTrustedWorkflowToolExecution).toHaveBeenCalledWith( + 'execution-1', + 'workflow-1', + 'tool-1' + ) + expect(completion).toEqual({ + status: 'success', + message: 'Workflow execution completed.', + data: { + success: true, + workflowId: 'workflow-1', + executionId: 'execution-1', + output: { value: 'child read {{PARENT_SECRET}} from execution-1' }, + logs: [], + }, + }) + expect(replaceTerminalAsyncToolCallResult).toHaveBeenCalledWith({ + toolCallId: 'tool-1', + status: 'completed', + result: completion?.data, + error: null, + }) + expect(JSON.stringify(completion)).not.toContain('parent-secret-value') + expect(JSON.stringify(replaceTerminalAsyncToolCallResult.mock.calls)).not.toContain( + 'parent-secret-value' + ) + }) + + it('preserves the server-confirmed status while omitting unavailable execution content', async () => { + const registry = createParentRegistry() + waitForToolConfirmation.mockResolvedValue({ + status: 'success', + data: { workflowId: 'workflow-1', executionId: 'execution-1', output: 'untrusted' }, + }) + getTrustedWorkflowToolExecution.mockResolvedValue(null) + + const completion = await waitForWorkflowToolCompletion({ + toolCallId: 'tool-1', + workflowId: 'workflow-1', + timeoutMs: 1_000, + registry, + }) + + expect(completion).toEqual({ + status: 'success', + message: 'Workflow execution completed.', + data: { + success: true, + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + }) + expect(registry.isComplete()).toBe(false) + expect(replaceTerminalAsyncToolCallResult).not.toHaveBeenCalled() + expect(JSON.stringify(completion)).not.toContain('untrusted') + }) + + it('uses compacted terminal status without exposing unavailable execution content', async () => { + const registry = createParentRegistry() + waitForToolConfirmation.mockResolvedValue({ + status: 'success', + data: { workflowId: 'workflow-1', executionId: 'execution-1' }, + }) + getTrustedWorkflowToolExecution.mockResolvedValue({ + executionId: 'execution-1', + workflowId: 'workflow-1', + status: 'failed', + contentAvailable: false, + }) + + const completion = await waitForWorkflowToolCompletion({ + toolCallId: 'tool-1', + workflowId: 'workflow-1', + timeoutMs: 1_000, + registry, + }) + + expect(completion).toEqual({ + status: 'error', + message: 'Workflow execution failed.', + data: { + success: false, + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + }) + expect(registry.isComplete()).toBe(false) + expect(replaceTerminalAsyncToolCallResult).not.toHaveBeenCalled() + }) + + it('preserves cancellation when the bound terminal execution is not yet readable', async () => { + const registry = createParentRegistry() + waitForToolConfirmation.mockResolvedValue({ + status: 'cancelled', + data: { workflowId: 'workflow-1', executionId: 'execution-1' }, + }) + getTrustedWorkflowToolExecution.mockResolvedValue(null) + + const completion = await waitForWorkflowToolCompletion({ + toolCallId: 'tool-1', + workflowId: 'workflow-1', + timeoutMs: 1_000, + registry, + }) + + expect(completion).toEqual({ + status: 'cancelled', + message: 'Workflow execution was cancelled.', + data: { + success: false, + workflowId: 'workflow-1', + executionId: 'execution-1', + reason: 'user_cancelled', + cancelledByUser: true, + }, + }) + expect(registry.isComplete()).toBe(false) + expect(replaceTerminalAsyncToolCallResult).not.toHaveBeenCalled() + }) + + it('rejects a legacy success without a trusted execution identity', async () => { + const registry = createParentRegistry() + waitForToolConfirmation.mockResolvedValue({ + status: 'success', + data: { workflowId: 'workflow-1', output: 'untrusted' }, + }) + + const completion = await waitForWorkflowToolCompletion({ + toolCallId: 'tool-1', + workflowId: 'workflow-1', + timeoutMs: 1_000, + registry, + }) + + expect(completion).toEqual({ + status: 'error', + message: 'Workflow execution failed.', + data: { success: false, workflowId: 'workflow-1' }, + }) + expect(registry.isComplete()).toBe(false) + expect(getTrustedWorkflowToolExecution).not.toHaveBeenCalled() + expect(JSON.stringify(completion)).not.toContain('untrusted') + }) + + it('uses the bound execution status when provenance is incomplete', async () => { + const registry = createParentRegistry() + waitForToolConfirmation.mockResolvedValue({ + status: 'success', + data: { workflowId: 'workflow-1', executionId: 'execution-1' }, + }) + getTrustedWorkflowToolExecution.mockResolvedValue({ + ...trustedExecution('execution-1'), + status: 'failed', + error: 'trusted failure', + provenance: { + version: 1, + complete: false, + entries: [], + scope: TRACE_SCOPE, + }, + }) + + const completion = await waitForWorkflowToolCompletion({ + toolCallId: 'tool-1', + workflowId: 'workflow-1', + timeoutMs: 1_000, + registry, + }) + + expect(completion).toEqual({ + status: 'error', + message: 'Workflow execution failed.', + data: { + success: false, + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + }) + expect(registry.isComplete()).toBe(false) + expect(replaceTerminalAsyncToolCallResult).not.toHaveBeenCalled() + }) + + it('imports and projects a secret activated only inside the child workflow', async () => { + const registry = new ResolvedSecretTraceRegistry([], TRACE_SCOPE) + waitForToolConfirmation.mockResolvedValue({ + status: 'success', + data: { workflowId: 'workflow-1', executionId: 'execution-1' }, + }) + getTrustedWorkflowToolExecution.mockResolvedValue({ + executionId: 'execution-1', + workflowId: 'workflow-1', + status: 'completed', + contentAvailable: true, + finalOutput: { value: 'child-secret-value' }, + blockLogs: [], + provenance: { + version: 1, + complete: true, + entries: [{ name: 'CHILD_SECRET', encryptedValue: 'encrypted-child-secret' }], + scope: TRACE_SCOPE, + }, + }) + + const completion = await waitForWorkflowToolCompletion({ + toolCallId: 'tool-1', + workflowId: 'workflow-1', + timeoutMs: 1_000, + registry, + }) + + expect(decryptSecret).toHaveBeenCalledWith('encrypted-child-secret') + expect(completion?.data).toEqual({ + success: true, + workflowId: 'workflow-1', + executionId: 'execution-1', + output: { value: '{{CHILD_SECRET}}' }, + logs: [], + }) + expect(JSON.stringify(completion)).not.toContain('child-secret-value') + }) + + it('corrects the client terminal status from the bound execution log', async () => { + const registry = new ResolvedSecretTraceRegistry([], TRACE_SCOPE) + waitForToolConfirmation.mockResolvedValue({ + status: 'success', + data: { workflowId: 'workflow-1', executionId: 'execution-1' }, + }) + getTrustedWorkflowToolExecution.mockResolvedValue({ + executionId: 'execution-1', + workflowId: 'workflow-1', + status: 'failed', + contentAvailable: true, + error: 'trusted failure', + blockLogs: [], + provenance: { version: 1, complete: true, entries: [], scope: TRACE_SCOPE }, + }) + + const completion = await waitForWorkflowToolCompletion({ + toolCallId: 'tool-1', + workflowId: 'workflow-1', + timeoutMs: 1_000, + registry, + }) + + expect(completion).toMatchObject({ + status: 'error', + message: 'trusted failure', + data: { + success: false, + workflowId: 'workflow-1', + executionId: 'execution-1', + error: 'trusted failure', + }, + }) + expect(replaceTerminalAsyncToolCallResult).toHaveBeenCalledWith({ + toolCallId: 'tool-1', + status: 'failed', + result: completion?.data, + error: 'trusted failure', + }) + }) + + it('treats background completion as structural and incomplete', async () => { + const registry = createParentRegistry() + waitForToolConfirmation.mockResolvedValue({ + status: 'background', + data: { + workflowId: 'workflow-1', + executionId: 'execution-1', + output: 'untrusted-background-output', + }, + }) + + const completion = await waitForWorkflowToolCompletion({ + toolCallId: 'tool-1', + workflowId: 'workflow-1', + timeoutMs: 1_000, + registry, + }) + + expect(completion).toEqual({ + status: 'background', + message: 'Workflow execution is continuing in the background.', + data: { workflowId: 'workflow-1', executionId: 'execution-1' }, + }) + expect(registry.isComplete()).toBe(false) + expect(getTrustedWorkflowToolExecution).not.toHaveBeenCalled() + expect(replaceTerminalAsyncToolCallResult).not.toHaveBeenCalled() + }) + + it('fails structurally when trusted child provenance cannot be imported', async () => { + const registry = createParentRegistry() + vi.spyOn(registry, 'importCrossingProvenance').mockRejectedValueOnce( + new Error('decryption unavailable') + ) + waitForToolConfirmation.mockResolvedValue({ + status: 'success', + data: { workflowId: 'workflow-1', executionId: 'execution-1' }, + }) + getTrustedWorkflowToolExecution.mockResolvedValue(trustedExecution('execution-1')) + + const completion = await waitForWorkflowToolCompletion({ + toolCallId: 'tool-1', + workflowId: 'workflow-1', + timeoutMs: 1_000, + registry, + }) + + expect(completion).toEqual({ + status: 'success', + message: 'Workflow execution completed.', + data: { + success: true, + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + }) + expect(registry.isComplete()).toBe(false) + expect(JSON.stringify(completion)).not.toContain('parent-secret-value') + }) + + it('keeps parallel workflow results safe while sibling provenance is unresolved', async () => { + const registry = createParentRegistry() + waitForToolConfirmation.mockImplementation((toolCallId: string) => + Promise.resolve({ + status: 'success', + data: { + workflowId: 'workflow-1', + executionId: toolCallId === 'tool-1' ? 'execution-1' : 'execution-2', + }, + }) + ) + + const resolvers = new Map) => void>() + getTrustedWorkflowToolExecution.mockImplementation( + (executionId: string) => + new Promise((resolve) => { + resolvers.set(executionId, resolve) + }) + ) + + const firstPromise = waitForWorkflowToolCompletion({ + toolCallId: 'tool-1', + workflowId: 'workflow-1', + timeoutMs: 1_000, + registry, + }) + const secondPromise = waitForWorkflowToolCompletion({ + toolCallId: 'tool-2', + workflowId: 'workflow-1', + timeoutMs: 1_000, + registry, + }) + + await vi.waitFor(() => expect(resolvers.size).toBe(2)) + resolvers.get('execution-1')?.(trustedExecution('execution-1')) + const first = await firstPromise + + expect(first).toEqual({ + status: 'success', + message: 'Workflow execution completed.', + data: { + success: true, + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + }) + + resolvers.get('execution-2')?.(trustedExecution('execution-2')) + const second = await secondPromise + + expect(second).toEqual({ + status: 'success', + message: 'Workflow execution completed.', + data: { + success: true, + workflowId: 'workflow-1', + executionId: 'execution-2', + output: { value: 'child read {{PARENT_SECRET}} from execution-2' }, + logs: [], + }, + }) + expect(JSON.stringify([first, second])).not.toContain('parent-secret-value') + expect(JSON.stringify(replaceTerminalAsyncToolCallResult.mock.calls)).not.toContain( + 'parent-secret-value' + ) + }) +}) + +describe('generic client tool completion', () => { + beforeEach(() => { + vi.clearAllMocks() + encryptSecret.mockImplementation(async (plaintext: string) => ({ + encrypted: plaintext, + iv: 'iv', + })) + decryptSecret.mockImplementation(async (encrypted: string) => ({ + decrypted: encrypted === 'encrypted-secret' ? 'resolved-secret' : encrypted, + })) + replaceTerminalAsyncToolCallResult.mockResolvedValue({ status: 'completed' }) + }) + + it('unseals exact-bound content and provenance, then persists only the projected result', async () => { + const registry = createClientRegistry() + const sealedContext = await sealClientToolContext({ + toolCallId: 'tool-1', + runId: 'run-1', + userId: 'user-1', + registry, + }) + waitForToolConfirmation.mockResolvedValue({ + status: 'success', + data: { + __sealedClientToolCompletionV1: JSON.stringify({ + toolCallId: 'tool-1', + runId: 'run-1', + userId: 'user-1', + message: 'Read resolved-secret', + data: { content: 'prefix-resolved-secret-suffix' }, + }), + ...sealedContext, + }, + }) + + const completion = await waitForClientToolCompletion({ + toolCallId: 'tool-1', + runId: 'run-1', + userId: 'user-1', + timeoutMs: 1_000, + registry, + }) + + expect(completion).toEqual({ + status: 'success', + message: 'Read {{SECRET}}', + data: { content: 'prefix-{{SECRET}}-suffix' }, + }) + expect(replaceTerminalAsyncToolCallResult).toHaveBeenCalledWith({ + toolCallId: 'tool-1', + status: 'completed', + result: { content: 'prefix-{{SECRET}}-suffix' }, + error: null, + }) + expect(JSON.stringify(completion)).not.toContain('resolved-secret') + expect(JSON.stringify(replaceTerminalAsyncToolCallResult.mock.calls)).not.toContain( + 'resolved-secret' + ) + }) + + it('does not invalidate later tool results while a sibling activation is pending', async () => { + const registry = createClientRegistry() + const firstContext = await sealClientToolContext({ + toolCallId: 'tool-1', + runId: 'run-1', + userId: 'user-1', + registry, + }) + waitForToolConfirmation.mockResolvedValueOnce({ + status: 'success', + data: { + __sealedClientToolCompletionV1: JSON.stringify({ + toolCallId: 'tool-1', + runId: 'run-1', + userId: 'user-1', + data: { content: 'resolved-secret' }, + }), + ...firstContext, + }, + }) + + const finishSiblingActivation = registry.beginPendingActivation() + const first = await waitForClientToolCompletion({ + toolCallId: 'tool-1', + runId: 'run-1', + userId: 'user-1', + timeoutMs: 1_000, + registry, + }) + + expect(first).toEqual({ status: 'success', message: 'Tool completed' }) + expect(registry.isPermanentlyIncomplete()).toBe(false) + finishSiblingActivation() + expect(registry.isComplete()).toBe(true) + + const secondContext = await sealClientToolContext({ + toolCallId: 'tool-2', + runId: 'run-1', + userId: 'user-1', + registry, + }) + waitForToolConfirmation.mockResolvedValueOnce({ + status: 'success', + data: { + __sealedClientToolCompletionV1: JSON.stringify({ + toolCallId: 'tool-2', + runId: 'run-1', + userId: 'user-1', + data: { content: 'resolved-secret' }, + }), + ...secondContext, + }, + }) + + const second = await waitForClientToolCompletion({ + toolCallId: 'tool-2', + runId: 'run-1', + userId: 'user-1', + timeoutMs: 1_000, + registry, + }) + + expect(second).toEqual({ + status: 'success', + message: 'Tool completed', + data: { content: '{{SECRET}}' }, + }) + }) + + it('fails structurally without an execution registry', async () => { + waitForToolConfirmation.mockResolvedValue({ + status: 'success', + data: { + __sealedClientToolCompletionV1: 'sealed-completion', + __sealedClientToolContextV1: 'sealed-context', + }, + }) + + const completion = await waitForClientToolCompletion({ + toolCallId: 'tool-1', + runId: 'run-1', + userId: 'user-1', + timeoutMs: 1_000, + }) + + expect(completion).toEqual({ status: 'success', message: 'Tool completed' }) + expect(decryptSecret).not.toHaveBeenCalled() + expect(replaceTerminalAsyncToolCallResult).toHaveBeenCalledWith({ + toolCallId: 'tool-1', + status: 'completed', + result: null, + error: null, + }) + }) + + it.each([ + ['wrong tool', { toolCallId: 'other-tool', runId: 'run-1', userId: 'user-1' }], + ['wrong run', { toolCallId: 'tool-1', runId: 'other-run', userId: 'user-1' }], + ['wrong user', { toolCallId: 'tool-1', runId: 'run-1', userId: 'other-user' }], + ])('fails structurally for a completion bound to the %s', async (_label, sealedBinding) => { + const registry = createClientRegistry() + const sealedContext = await sealClientToolContext({ ...sealedBinding, registry }) + waitForToolConfirmation.mockResolvedValue({ + status: 'success', + data: { + __sealedClientToolCompletionV1: JSON.stringify({ + ...sealedBinding, + data: { content: 'untrusted-secret' }, + }), + ...sealedContext, + }, + }) + + const completion = await waitForClientToolCompletion({ + toolCallId: 'tool-1', + runId: 'run-1', + userId: 'user-1', + timeoutMs: 1_000, + registry, + }) + + expect(completion).toEqual({ status: 'success', message: 'Tool completed' }) + expect(registry.isComplete()).toBe(false) + expect(replaceTerminalAsyncToolCallResult).toHaveBeenCalledWith({ + toolCallId: 'tool-1', + status: 'completed', + result: null, + error: null, + }) + expect(JSON.stringify(completion)).not.toContain('untrusted-secret') + }) + + it('fails structurally when a restarted execution uses a new registry instance', async () => { + const sourceRegistry = createClientRegistry() + const resumedRegistry = new ResolvedSecretTraceRegistry([], TRACE_SCOPE) + const sealedContext = await sealClientToolContext({ + toolCallId: 'tool-1', + runId: 'run-1', + userId: 'user-1', + registry: sourceRegistry, + }) + waitForToolConfirmation.mockResolvedValue({ + status: 'success', + data: { + __sealedClientToolCompletionV1: JSON.stringify({ + toolCallId: 'tool-1', + runId: 'run-1', + userId: 'user-1', + data: { content: 'resolved-secret' }, + }), + ...sealedContext, + }, + }) + + const completion = await waitForClientToolCompletion({ + toolCallId: 'tool-1', + runId: 'run-1', + userId: 'user-1', + timeoutMs: 1_000, + registry: resumedRegistry, + }) + + expect(completion).toEqual({ status: 'success', message: 'Tool completed' }) + expect(resumedRegistry.isComplete()).toBe(false) + expect(replaceTerminalAsyncToolCallResult).toHaveBeenCalledWith({ + toolCallId: 'tool-1', + status: 'completed', + result: null, + error: null, + }) + expect(JSON.stringify(completion)).not.toContain('resolved-secret') + }) + + it('fails structurally for a legacy raw confirmation without sealed provenance', async () => { + const registry = new ResolvedSecretTraceRegistry([], TRACE_SCOPE) + waitForToolConfirmation.mockResolvedValue({ + status: 'error', + message: 'raw error secret', + data: { content: 'raw result secret' }, + }) + + const completion = await waitForClientToolCompletion({ + toolCallId: 'tool-1', + runId: 'run-1', + userId: 'user-1', + timeoutMs: 1_000, + registry, + }) + + expect(completion).toEqual({ status: 'error', message: 'Tool result omitted' }) + expect(registry.isComplete()).toBe(false) + expect(replaceTerminalAsyncToolCallResult).toHaveBeenCalledWith({ + toolCallId: 'tool-1', + status: 'failed', + result: null, + error: 'Tool result omitted', + }) + expect(JSON.stringify(completion)).not.toContain('raw') + }) +}) diff --git a/apps/sim/lib/copilot/request/tools/client.ts b/apps/sim/lib/copilot/request/tools/client.ts index 43c42de6a8c..f6c7ebede0f 100644 --- a/apps/sim/lib/copilot/request/tools/client.ts +++ b/apps/sim/lib/copilot/request/tools/client.ts @@ -1,10 +1,29 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { isPlainRecord } from '@sim/utils/object' import { ASYNC_TOOL_CONFIRMATION_STATUS, type AsyncTerminalCompletionSnapshot, isAsyncTerminalConfirmationStatus, } from '@/lib/copilot/async-runs/lifecycle' +import { replaceTerminalAsyncToolCallResult } from '@/lib/copilot/async-runs/repository' import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothership-stream-v1' import { waitForToolConfirmation } from '@/lib/copilot/persistence/tool-confirm' +import { + unsealClientToolCompletion, + unsealClientToolContext, +} from '@/lib/copilot/request/tools/client-completion-seal.server' +import { projectToolResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' +import { + createStructuralWorkflowToolCompletionData, + getWorkflowToolCompletionExecutionId, + getWorkflowToolCompletionMessage, + getWorkflowToolConfirmationStatus, +} from '@/lib/copilot/tools/workflow-tools' +import { getTrustedWorkflowToolExecution } from '@/lib/workflows/executor/execution-state' +import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' + +const logger = createLogger('CopilotClientToolWaiter') /** * Wait for a client-executable workflow tool to report back. @@ -31,3 +50,307 @@ export async function waitForToolCompletion( } return null } + +interface WaitForClientToolCompletionOptions { + toolCallId: string + runId?: string + userId: string + timeoutMs: number + abortSignal?: AbortSignal + registry?: ResolvedSecretTraceRegistry +} + +function getGenericCompletionMessage(status: AsyncTerminalCompletionSnapshot['status']): string { + if (status === MothershipStreamV1ToolOutcome.success) return 'Tool completed' + if (status === ASYNC_TOOL_CONFIRMATION_STATUS.background) return 'Tool is running in background' + if (status === MothershipStreamV1ToolOutcome.cancelled) return 'Tool cancelled' + return 'Tool failed' +} + +/** + * Restores a generic browser/terminal result from its sealed transport envelope, + * projects active Secrets values, then replaces the durable row before delivery. + */ +export async function waitForClientToolCompletion({ + toolCallId, + runId, + userId, + timeoutMs, + abortSignal, + registry, +}: WaitForClientToolCompletionOptions): Promise { + const completion = await waitForToolCompletion(toolCallId, timeoutMs, abortSignal) + if (!completion) return null + + const genericMessage = getGenericCompletionMessage(completion.status) + const binding = runId ? { toolCallId, runId, userId } : undefined + const registryCanImport = registry !== undefined && !registry.isPermanentlyIncomplete() + const finishPendingActivation = registry?.beginPendingActivation() + let content: Awaited> = null + try { + const [sealedContent, sealedContext] = + binding && registry && registryCanImport + ? await Promise.all([ + unsealClientToolCompletion(completion.data, binding), + unsealClientToolContext(completion.data, binding, registry), + ]) + : [null, null] + if (registry && registryCanImport) { + if (!sealedContent || !sealedContext) { + registry.markIncomplete() + } else { + const imported = await registry.importProvenance(sealedContext.provenance, { + trusted: true, + }) + if (!imported || !sealedContext.provenance.complete) { + registry.markIncomplete() + } else { + content = sealedContent + } + } + } + } catch { + registry?.markIncomplete() + } finally { + finishPendingActivation?.() + } + if (!registry?.isComplete()) content = null + + const rawOutput: Record = { + ...(content?.message !== undefined ? { message: content.message } : {}), + ...(content && Object.hasOwn(content, 'data') ? { data: content.data } : {}), + } + const succeeded = completion.status === MothershipStreamV1ToolOutcome.success + const projected = projectToolResultForCopilot( + { + success: succeeded, + output: rawOutput, + ...(!succeeded ? { error: content?.message ?? genericMessage } : {}), + }, + registry + ) + const projectedOutput = isPlainRecord(projected.output) ? projected.output : undefined + const message = + typeof projectedOutput?.message === 'string' + ? projectedOutput.message + : !succeeded && projected.error + ? projected.error + : genericMessage + const data = + projectedOutput && Object.hasOwn(projectedOutput, 'data') ? projectedOutput.data : undefined + + if (completion.status !== ASYNC_TOOL_CONFIRMATION_STATUS.background) { + const status = + completion.status === MothershipStreamV1ToolOutcome.success + ? 'completed' + : completion.status === MothershipStreamV1ToolOutcome.cancelled + ? 'cancelled' + : 'failed' + try { + const updated = await replaceTerminalAsyncToolCallResult({ + toolCallId, + status, + result: data ?? null, + error: succeeded ? null : message, + }) + if (!updated) { + logger.warn('Client tool row was no longer terminal during safe payload update', { + toolCallId, + }) + } + } catch (error) { + logger.warn('Failed to persist projected client tool result', { + toolCallId, + error: getErrorMessage(error), + }) + } + } + + return { + status: completion.status, + message, + ...(data !== undefined ? { data } : {}), + } +} + +interface WaitForWorkflowToolCompletionOptions { + toolCallId: string + workflowId?: string + timeoutMs: number + abortSignal?: AbortSignal + registry?: ResolvedSecretTraceRegistry +} + +function structuralWorkflowCompletion( + status: AsyncTerminalCompletionSnapshot['status'], + workflowId?: string, + executionId?: string +): AsyncTerminalCompletionSnapshot { + return { + status, + message: getWorkflowToolCompletionMessage(status), + data: createStructuralWorkflowToolCompletionData(status, workflowId, executionId), + } +} + +/** + * Restores a client-run workflow result from the bound server execution log. + * The browser confirmation is only a wakeup and structural identity carrier. + */ +export async function waitForWorkflowToolCompletion({ + toolCallId, + workflowId, + timeoutMs, + abortSignal, + registry, +}: WaitForWorkflowToolCompletionOptions): Promise { + const finishPendingActivation = registry?.beginPendingActivation() + let completion: AsyncTerminalCompletionSnapshot | null = null + let trustedExecution: Awaited> = null + + try { + completion = await waitForToolCompletion(toolCallId, timeoutMs, abortSignal) + if (!completion) { + registry?.markIncomplete() + return null + } + + const executionId = getWorkflowToolCompletionExecutionId(completion.data) + if (completion.status === ASYNC_TOOL_CONFIRMATION_STATUS.background) { + registry?.markIncomplete() + return structuralWorkflowCompletion(completion.status, workflowId, executionId) + } + if (!workflowId || !executionId) { + registry?.markIncomplete() + const structuralStatus = + completion.status === MothershipStreamV1ToolOutcome.success + ? MothershipStreamV1ToolOutcome.error + : completion.status + return structuralWorkflowCompletion(structuralStatus, workflowId, executionId) + } + + try { + trustedExecution = await getTrustedWorkflowToolExecution(executionId, workflowId, toolCallId) + } catch (error) { + logger.warn('Failed to restore bound workflow tool execution', { + toolCallId, + workflowId, + executionId, + error: getErrorMessage(error), + }) + } + + if (!trustedExecution) { + registry?.markIncomplete() + return structuralWorkflowCompletion(completion.status, workflowId, executionId) + } + + if (!trustedExecution.contentAvailable) { + registry?.markIncomplete() + return structuralWorkflowCompletion( + getWorkflowToolConfirmationStatus(trustedExecution.status), + workflowId, + executionId + ) + } + + if (!registry || registry.isPermanentlyIncomplete() || !trustedExecution.provenance.complete) { + if (!trustedExecution.provenance.complete) registry?.markIncomplete() + return structuralWorkflowCompletion( + getWorkflowToolConfirmationStatus(trustedExecution.status), + workflowId, + executionId + ) + } + + try { + const imported = await registry.importCrossingProvenance( + trustedExecution.provenance, + { + ...(Object.hasOwn(trustedExecution, 'finalOutput') + ? { finalOutput: trustedExecution.finalOutput } + : {}), + blockLogs: trustedExecution.blockLogs, + ...(trustedExecution.error !== undefined ? { error: trustedExecution.error } : {}), + }, + { trusted: true } + ) + if (!imported) registry.markIncomplete() + } catch (error) { + registry.markIncomplete() + logger.warn('Failed to import bound workflow provenance', { + toolCallId, + workflowId, + executionId, + error: getErrorMessage(error), + }) + } + } finally { + finishPendingActivation?.() + } + + if (!completion || !trustedExecution || !workflowId) return completion + + const executionId = trustedExecution.executionId + const status = getWorkflowToolConfirmationStatus(trustedExecution.status) + const genericMessage = getWorkflowToolCompletionMessage(status) + const rawData: Record = { + success: status === MothershipStreamV1ToolOutcome.success, + workflowId, + executionId, + ...(Object.hasOwn(trustedExecution, 'finalOutput') + ? { output: trustedExecution.finalOutput } + : {}), + logs: trustedExecution.blockLogs, + ...(trustedExecution.error !== undefined ? { error: trustedExecution.error } : {}), + ...(status === MothershipStreamV1ToolOutcome.cancelled + ? { reason: 'user_cancelled', cancelledByUser: true } + : {}), + } + const projected = projectToolResultForCopilot( + { + success: status === MothershipStreamV1ToolOutcome.success, + output: rawData, + ...(status !== MothershipStreamV1ToolOutcome.success + ? { error: trustedExecution.error ?? genericMessage } + : {}), + }, + registry + ) + const projectedData = isPlainRecord(projected.output) ? projected.output : {} + const data = { + ...projectedData, + ...createStructuralWorkflowToolCompletionData(status, workflowId, executionId), + } + const message = + status === MothershipStreamV1ToolOutcome.success + ? genericMessage + : Object.hasOwn(projected, 'output') && projected.error + ? projected.error + : genericMessage + + try { + const updated = await replaceTerminalAsyncToolCallResult({ + toolCallId, + status: trustedExecution.status, + result: data, + error: status === MothershipStreamV1ToolOutcome.success ? null : message, + }) + if (!updated) { + logger.warn('Bound workflow tool row was no longer terminal during safe payload update', { + toolCallId, + workflowId, + executionId, + }) + } + } catch (error) { + logger.warn('Failed to persist projected workflow tool result', { + toolCallId, + workflowId, + executionId, + error: getErrorMessage(error), + }) + } + + return { status, message, data } +} diff --git a/apps/sim/lib/copilot/request/tools/executor.ts b/apps/sim/lib/copilot/request/tools/executor.ts index fd59ba2fa8b..17106c64ce3 100644 --- a/apps/sim/lib/copilot/request/tools/executor.ts +++ b/apps/sim/lib/copilot/request/tools/executor.ts @@ -53,6 +53,7 @@ import { setTerminalToolCallState, } from '@/lib/copilot/request/tool-call-state' import { maybeWriteOutputToFile } from '@/lib/copilot/request/tools/files' +import { projectToolResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' import { handleResourceSideEffects } from '@/lib/copilot/request/tools/resources' import { maybeWriteOutputToTable, @@ -558,6 +559,10 @@ async function executeToolAndReportInner( return terminalCompletionFromToolCall(toolCall) } if (abortRequested(context, execContext, options)) { + const copilotResult = projectToolResultForCopilot( + result, + execContext.resolvedSecretTraceRegistry + ) markToolCallCancelled('Request aborted during tool execution') markToolResultSeen(toolCall.id) await completeAsyncToolCall({ @@ -579,7 +584,7 @@ async function executeToolAndReportInner( }) endToolSpan('cancelled', { cancelReason: 'abort_during_execution', - error: result.success === false ? result.error : undefined, + error: copilotResult.success === false ? copilotResult.error : undefined, }) return cancelledCompletion('Request aborted during tool execution') } @@ -655,17 +660,22 @@ async function executeToolAndReportInner( endToolSpan('cancelled', { cancelReason: 'abort_during_post_processing_csv' }) return cancelledCompletion('Request aborted during tool post-processing') } + const copilotResult = projectToolResultForCopilot( + result, + execContext.resolvedSecretTraceRegistry + ) + toolSpan.attributes = { ...toolSpan.attributes, - ...summarizeToolResultForSpan(result), + ...summarizeToolResultForSpan(copilotResult), } setTerminalToolCallState(toolCall, { - status: result.success + status: copilotResult.success ? MothershipStreamV1ToolOutcome.success : MothershipStreamV1ToolOutcome.error, - ...(hasOutputValue(result) ? { output: result.output } : {}), - ...(result.success ? {} : { error: result.error || 'Tool failed' }), + ...(hasOutputValue(copilotResult) ? { output: copilotResult.output } : {}), + ...(copilotResult.success ? {} : { error: copilotResult.error || 'Tool failed' }), }) if (result.success) { @@ -688,7 +698,7 @@ async function executeToolAndReportInner( logger.warn('Tool execution failed', { toolCallId: toolCall.id, toolName: toolCall.name, - error: result.error, + error: copilotResult.error, params: toolCall.params, }) } @@ -741,7 +751,7 @@ async function executeToolAndReportInner( mode: MothershipStreamV1ToolMode.async, phase: MothershipStreamV1ToolPhase.result, success: result.success, - output: result.output, + output: copilotResult.output, ...(result.success ? { status: MothershipStreamV1ToolOutcome.success } : { status: MothershipStreamV1ToolOutcome.error }), @@ -760,6 +770,7 @@ async function executeToolAndReportInner( toolCall.name, toolCall.params, result, + copilotResult, execContext.chatId, options?.onEvent, () => abortRequested(context, execContext, options) @@ -776,6 +787,11 @@ async function executeToolAndReportInner( }) } catch (error) { const thrownMessage = toError(error).message + const copilotError = projectToolResultForCopilot( + { success: false, error: thrownMessage }, + execContext.resolvedSecretTraceRegistry + ) + const safeThrownMessage = copilotError.error || 'Tool failed' if (abortRequested(context, execContext, options)) { markToolCallCancelled('Request aborted during tool execution') markToolResultSeen(toolCall.id) @@ -798,13 +814,13 @@ async function executeToolAndReportInner( }) endToolSpan('cancelled', { cancelReason: 'abort_during_execution_catch', - error: thrownMessage, + error: safeThrownMessage, }) return cancelledCompletion('Request aborted during tool execution') } setTerminalToolCallState(toolCall, { status: MothershipStreamV1ToolOutcome.error, - error: thrownMessage, + error: safeThrownMessage, }) logger.error('Tool execution threw', { @@ -848,7 +864,7 @@ async function executeToolAndReportInner( }, } await options?.onEvent?.(errorEvent) - endToolSpan('error', { error: thrownMessage }) + endToolSpan('error', { error: safeThrownMessage }) return buildCompletionSignal({ status: MothershipStreamV1ToolOutcome.error, message: toolCall.error, diff --git a/apps/sim/lib/copilot/request/tools/files.ts b/apps/sim/lib/copilot/request/tools/files.ts index 5fe59ef72a2..048fff75196 100644 --- a/apps/sim/lib/copilot/request/tools/files.ts +++ b/apps/sim/lib/copilot/request/tools/files.ts @@ -7,6 +7,7 @@ import { TraceEvent } from '@/lib/copilot/generated/trace-events-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' import { withCopilotSpan } from '@/lib/copilot/request/otel' import { denyOutputWriteWithoutWritePermission } from '@/lib/copilot/request/tools/permissions' +import { projectToolErrorMessageForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' import { writeWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' @@ -342,14 +343,18 @@ export async function maybeWriteOutputToFile( } } catch (err) { const message = toError(err).message + const projectedMessage = projectToolErrorMessageForCopilot( + message, + context.resolvedSecretTraceRegistry + ) logger.warn('Failed to write tool output to file', { toolName, outputPaths: outputFiles.map((file) => file.path), - error: message, + error: projectedMessage, }) span.setAttribute(TraceAttr.CopilotOutputFileOutcome, CopilotOutputFileOutcome.Failed) span.addEvent(TraceEvent.CopilotOutputFileError, { - [TraceAttr.ErrorMessage]: message.slice(0, 500), + [TraceAttr.ErrorMessage]: projectedMessage.slice(0, 500), }) return { success: false, diff --git a/apps/sim/lib/copilot/request/tools/permission.test.ts b/apps/sim/lib/copilot/request/tools/permission.test.ts index 78096cdc61b..4cd2d3f7140 100644 --- a/apps/sim/lib/copilot/request/tools/permission.test.ts +++ b/apps/sim/lib/copilot/request/tools/permission.test.ts @@ -33,7 +33,10 @@ import type { StreamEvent, ToolCallState } from '@/lib/copilot/request/types' function makeContext() { const context = createStreamingContext({ runId: 'run-1' }) - context.toolPermissions = { enabled: true, autoAllowed: new Set() } + context.toolPermissions = { + enabled: true, + autoAllowed: new Set(), + } context.trace = new TraceCollector() return context } @@ -91,6 +94,18 @@ describe('toolCallNeedsApproval', () => { expect(toolCallNeedsApproval('terminal', context, {}, false, runCall)).toBe(false) }) + it('applies the normal saved permission to code with a secret reference', () => { + const context = makeContext() + context.toolPermissions.autoAllowed.add('function_execute') + + expect( + toolCallNeedsApproval('function_execute', context, {}, false, { + language: 'javascript', + code: 'return {{API_KEY}}', + }) + ).toBe(false) + }) + it('never gates a non-interactive run, which has nobody to answer the prompt', () => { expect( toolCallNeedsApproval('terminal', makeContext(), { interactive: false }, false, runCall) @@ -103,6 +118,18 @@ describe('toolCallNeedsApproval', () => { expect(toolCallNeedsApproval('terminal', context, {}, false, runCall)).toBe(false) }) + it('does not add a secret-specific gate when the permission feature is off', () => { + const context = makeContext() + context.toolPermissions.enabled = false + + expect( + toolCallNeedsApproval('function_execute', context, {}, false, { + language: 'javascript', + code: 'return {{API_KEY}}', + }) + ).toBe(false) + }) + it('gates a resolved integration operation off the frame Go stamped', () => { // gmail_read_v2 is request-local: it is not in the catalog at all, so the // only thing marking it is the awaiting_approval status on the frame. @@ -281,6 +308,23 @@ describe('runGatedToolExecution', () => { expect(context.toolPermissions.autoAllowed.has('terminal')).toBe(true) }) + it('accepts the normal chat-level decision for code with a secret reference', async () => { + const context = makeContext() + const toolCall = makeToolCall() + toolCall.name = 'function_execute' + toolCall.params = { language: 'javascript', code: 'return {{API_KEY}}' } + const execute = vi.fn().mockResolvedValue({ status: 'success' }) + waitForToolPermissionDecision.mockResolvedValue({ + toolCallId: 'call-1', + decision: 'allow_chat', + }) + + await gate(context, toolCall, execute, []) + + expect(execute).toHaveBeenCalledTimes(1) + expect(context.toolPermissions.autoAllowed.has('function_execute')).toBe(true) + }) + it('does not suppress later prompts for a one-off allow', async () => { const context = makeContext() const toolCall = makeToolCall() diff --git a/apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts b/apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts new file mode 100644 index 00000000000..fc8a307b05b --- /dev/null +++ b/apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts @@ -0,0 +1,219 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { FunctionExecute, RunCode } from '@/lib/copilot/generated/tool-catalog-v1' +import { + projectToolResultForCopilot, + TOOL_RESULT_OMITTED_ERROR, +} from '@/lib/copilot/request/tools/resolved-secret-result' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' + +function createRegistry(): ResolvedSecretTraceRegistry { + return new ResolvedSecretTraceRegistry([ + { + name: 'SECRET', + plaintext: 'secret-value', + encryptedValue: 'encrypted-secret-value', + }, + ]) +} + +describe('projectToolResultForCopilot', () => { + it.each([FunctionExecute.id, RunCode.id])( + 'projects active exact and embedded secrets for %s without mutating runtime output', + (toolName) => { + const registry = createRegistry() + registry.recordResolved('SECRET', 'secret-value') + const runtimeResult = { + success: true, + output: { + result: 'secret-value', + stdout: 'prefix-secret-value-suffix', + values: ['safe', 'secret-value'], + }, + } + const runtimeSnapshot = structuredClone(runtimeResult) + + expect(projectToolResultForCopilot(runtimeResult, registry)).toEqual({ + success: true, + output: { + result: '{{SECRET}}', + stdout: 'prefix-{{SECRET}}-suffix', + values: ['safe', '{{SECRET}}'], + }, + }) + expect(runtimeResult).toEqual(runtimeSnapshot) + } + ) + + it('projects both output and error from a failed Function execution', () => { + const registry = createRegistry() + registry.recordResolved('SECRET', 'secret-value') + + expect( + projectToolResultForCopilot( + { + success: false, + output: { stdout: 'printed secret-value' }, + error: 'Function failed near secret-value', + }, + registry + ) + ).toEqual({ + success: false, + output: { stdout: 'printed {{SECRET}}' }, + error: 'Function failed near {{SECRET}}', + }) + }) + + it('projects secret-bearing object keys and omits content when replacement collides', () => { + const registry = createRegistry() + registry.recordResolved('SECRET', 'secret-value') + + expect( + projectToolResultForCopilot( + { + success: true, + output: { 'prefix-secret-value': 'safe' }, + }, + registry + ) + ).toEqual({ + success: true, + output: { 'prefix-{{SECRET}}': 'safe' }, + }) + + expect( + projectToolResultForCopilot( + { + success: true, + output: { 'secret-value': 'first', '{{SECRET}}': 'second' }, + }, + registry + ) + ).toEqual({ + success: true, + }) + }) + + it('omits content when one replacement creates another active literal', () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'MIDDLE', plaintext: 'B', encryptedValue: 'encrypted-b' }, + { name: 'BRACE', plaintext: '{', encryptedValue: 'encrypted-brace' }, + { name: 'JOINED', plaintext: 'ac', encryptedValue: 'encrypted-ac' }, + ]) + registry.recordResolved('MIDDLE', 'B') + registry.recordResolved('BRACE', '{') + registry.recordResolved('JOINED', 'ac') + + expect(projectToolResultForCopilot({ success: true, output: 'aBc' }, registry)).toEqual({ + success: true, + }) + }) + + it('keeps the control error safe from active one-character values', () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'F_SECRET', plaintext: 'F', encryptedValue: 'encrypted-f' }, + ]) + registry.recordResolved('F_SECRET', 'F') + + const projected = projectToolResultForCopilot( + { + success: false, + output: { F: 'first', '': 'second' }, + error: 'F', + }, + registry + ) + + expect(projected.success).toBe(false) + expect(projected).not.toHaveProperty('output') + expect(projected.error).toBeTruthy() + expect(projected.error).not.toContain('F') + }) + + it('does not project transformed values', () => { + const registry = createRegistry() + registry.recordResolved('SECRET', 'secret-value') + const encoded = Buffer.from('secret-value').toString('base64') + + expect( + projectToolResultForCopilot({ success: true, output: { result: encoded } }, registry) + ).toEqual({ success: true, output: { result: encoded } }) + }) + + it('leaves configured but unused values unchanged', () => { + const registry = createRegistry() + const result = { + success: true, + output: { result: 'secret-value', stdout: '' }, + } + + expect(projectToolResultForCopilot(result, registry)).toEqual(result) + }) + + it.each([ + ['missing', undefined], + [ + 'incomplete', + (() => { + const registry = createRegistry() + registry.markIncomplete() + return registry + })(), + ], + ])('fails closed for %s provenance without changing structural fields', (_label, registry) => { + expect( + projectToolResultForCopilot( + { + success: false, + output: { result: 'possibly-secret' }, + error: 'possibly-secret-error', + resources: [{ type: 'file', id: 'file-1', title: 'report.txt' }], + }, + registry + ) + ).toEqual({ + success: false, + error: TOOL_RESULT_OMITTED_ERROR, + }) + }) + + it('projects Copilot-visible resource metadata without changing the runtime result', () => { + const registry = createRegistry() + registry.recordResolved('SECRET', 'secret-value') + const result = { + success: true, + resources: [{ type: 'file' as const, id: 'file-secret-value', title: 'secret-value.txt' }], + } + + expect(projectToolResultForCopilot(result, registry)).toEqual({ + success: true, + resources: [{ type: 'file', id: 'file-secret-value', title: '{{SECRET}}.txt' }], + }) + expect(result.resources[0]).toEqual({ + type: 'file', + id: 'file-secret-value', + title: 'secret-value.txt', + }) + }) + + it('projects every tool result once provenance is active', () => { + const registry = createRegistry() + registry.recordResolved('SECRET', 'secret-value') + const result = { success: true, output: 'secret-value' } + + expect(projectToolResultForCopilot(result, registry)).toEqual({ + success: true, + output: '{{SECRET}}', + }) + expect(result).toEqual({ success: true, output: 'secret-value' }) + }) + + it('omits every tool result when no trusted provenance registry exists', () => { + expect( + projectToolResultForCopilot({ success: true, output: 'possibly-secret' }, undefined) + ).toEqual({ success: true }) + }) +}) diff --git a/apps/sim/lib/copilot/request/tools/resolved-secret-result.ts b/apps/sim/lib/copilot/request/tools/resolved-secret-result.ts new file mode 100644 index 00000000000..b7a3b1f4e89 --- /dev/null +++ b/apps/sim/lib/copilot/request/tools/resolved-secret-result.ts @@ -0,0 +1,139 @@ +import { isPlainRecord, omit } from '@sim/utils/object' +import type { MothershipResource } from '@/lib/copilot/resources/types' +import type { ToolExecutionResult } from '@/lib/copilot/tool-executor/types' +import { + containsResolvedSecret, + createResolvedSecretMatcher, + projectResolvedSecretContent, + type ResolvedSecretMatcher, + sanitizeResolvedSecretString, +} from '@/executor/utils/resolved-secret-content-projection' +import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' + +export const TOOL_RESULT_OMITTED_ERROR = 'Tool result omitted' + +function omitContent(result: ToolExecutionResult): ToolExecutionResult { + return omit(result, ['output', 'error', 'resources']) +} + +function resourceContent(resources: MothershipResource[]): Array<{ title: string; path?: string }> { + return resources.map((resource) => ({ + title: resource.title, + ...(resource.path !== undefined ? { path: resource.path } : {}), + })) +} + +function restoreProjectedResources( + resources: MothershipResource[], + projectedContent: unknown +): MothershipResource[] | undefined { + if (!Array.isArray(projectedContent) || projectedContent.length !== resources.length) { + return undefined + } + + const projectedResources: MothershipResource[] = [] + for (let index = 0; index < resources.length; index += 1) { + const content = projectedContent[index] + if ( + !isPlainRecord(content) || + typeof content.title !== 'string' || + (content.path !== undefined && typeof content.path !== 'string') + ) { + return undefined + } + + const resource = resources[index] + projectedResources.push({ + type: resource.type, + id: resource.id, + title: content.title, + ...(content.path !== undefined ? { path: content.path } : {}), + }) + } + + return projectedResources +} + +/** Returns a nonempty control error that cannot contain any active literal. */ +function createSafeControlError(matcher: ResolvedSecretMatcher | undefined): string { + if (!matcher) return TOOL_RESULT_OMITTED_ERROR + + try { + const projected = sanitizeResolvedSecretString(TOOL_RESULT_OMITTED_ERROR, matcher) + if (projected.length > 0 && !containsResolvedSecret(projected, matcher)) return projected + } catch {} + + for (let codePoint = 0x21; codePoint <= 0x10ffff; codePoint += 1) { + if (codePoint >= 0xd800 && codePoint <= 0xdfff) { + codePoint = 0xdfff + continue + } + const candidate = String.fromCodePoint(codePoint) + if (!containsResolvedSecret(candidate, matcher)) return candidate + } + + throw new Error('Active secret matcher covers every Unicode scalar') +} + +function omittedResult( + result: ToolExecutionResult, + matcher: ResolvedSecretMatcher | undefined +): ToolExecutionResult { + const structural = omitContent(result) + return result.success ? structural : { ...structural, error: createSafeControlError(matcher) } +} + +/** + * Projects terminal tool content before it can cross back into Copilot. + * Runtime output remains unchanged for raw post-processing and context updates. + */ +export function projectToolResultForCopilot( + result: ToolExecutionResult, + registry: ResolvedSecretTraceRegistry | undefined +): ToolExecutionResult { + if (!registry?.isComplete()) return omittedResult(result, undefined) + + let matcher: ResolvedSecretMatcher | undefined + try { + matcher = createResolvedSecretMatcher(registry.getActiveMatches()) + if (!matcher) return result + + const content: Record = {} + if (Object.hasOwn(result, 'output')) content.output = result.output + if (Object.hasOwn(result, 'error')) content.error = result.error + if (result.resources !== undefined) content.resources = resourceContent(result.resources) + const projection = projectResolvedSecretContent(content, matcher) + if (!projection.safe || !projection.value || typeof projection.value !== 'object') { + return omittedResult(result, matcher) + } + + const projectedContent = projection.value as Record + const projected = omitContent(result) + if (Object.hasOwn(projectedContent, 'output')) projected.output = projectedContent.output + if (Object.hasOwn(projectedContent, 'error')) { + projected.error = String(projectedContent.error) + } + if (result.resources !== undefined) { + const resources = restoreProjectedResources(result.resources, projectedContent.resources) + if (!resources) return omittedResult(result, matcher) + projected.resources = resources + } + if (!projected.success && !projected.error) { + projected.error = createSafeControlError(matcher) + } + return projected + } catch { + return omittedResult(result, matcher) + } +} + +/** Projects an error before post-processing can attach it to application logs or OTel events. */ +export function projectToolErrorMessageForCopilot( + error: string, + registry: ResolvedSecretTraceRegistry | undefined +): string { + return ( + projectToolResultForCopilot({ success: false, error }, registry).error ?? + TOOL_RESULT_OMITTED_ERROR + ) +} diff --git a/apps/sim/lib/copilot/request/tools/resources.ts b/apps/sim/lib/copilot/request/tools/resources.ts index 361f4105201..88ee1f01a07 100644 --- a/apps/sim/lib/copilot/request/tools/resources.ts +++ b/apps/sim/lib/copilot/request/tools/resources.ts @@ -29,6 +29,7 @@ export async function handleResourceSideEffects( toolName: string, params: Record | undefined, result: ToolCallResult, + projectedResult: ToolCallResult, chatId: string, onEvent: ((event: StreamEvent) => void | Promise) | undefined, isAborted: () => boolean @@ -57,6 +58,11 @@ export async function handleResourceSideEffects( if (hasDeleteCapability(toolName)) { const deleted = extractDeletedResourcesFromToolResult(toolName, params, result.output) + const projectedDeleted = extractDeletedResourcesFromToolResult( + toolName, + params, + projectedResult.output + ) if (deleted.length > 0) { isDeleteOp = true removedCount = deleted.length @@ -71,13 +77,19 @@ export async function handleResourceSideEffects( }) }) - for (const resource of deleted) { + for (let index = 0; index < deleted.length; index += 1) { if (isAborted()) break + const resource = deleted[index] + const projected = projectedDeleted[index] await onEvent?.({ type: MothershipStreamV1EventType.resource, payload: { op: MothershipStreamV1ResourceOp.remove, - resource: { type: resource.type, id: resource.id, title: resource.title }, + resource: { + type: resource.type, + id: resource.id, + title: projected?.title ?? '', + }, }, }) } @@ -85,12 +97,29 @@ export async function handleResourceSideEffects( } if (!isDeleteOp && !isAborted()) { - const resources = + const rawResources = result.resources && result.resources.length > 0 ? result.resources : isResourceToolName(toolName) ? extractResourcesFromToolResult(toolName, params, result.output) : [] + const projectedResources = + result.resources && result.resources.length > 0 + ? (projectedResult.resources ?? []) + : isResourceToolName(toolName) + ? extractResourcesFromToolResult(toolName, params, projectedResult.output) + : [] + const resources = + projectedResources.length === rawResources.length + ? rawResources.map((resource, index) => ({ + type: resource.type, + id: resource.id, + title: projectedResources[index].title, + ...(projectedResources[index].path !== undefined + ? { path: projectedResources[index].path } + : {}), + })) + : [] if (resources.length > 0) { upsertedCount = resources.length diff --git a/apps/sim/lib/copilot/request/tools/tables.test.ts b/apps/sim/lib/copilot/request/tools/tables.test.ts index c9ab80ae41b..f90edf31e66 100644 --- a/apps/sim/lib/copilot/request/tools/tables.test.ts +++ b/apps/sim/lib/copilot/request/tools/tables.test.ts @@ -2,12 +2,14 @@ * @vitest-environment node */ +import { loggerMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { TableDefinition } from '@/lib/table' -const { mockGetTableById, mockReplaceTableRows } = vi.hoisted(() => ({ +const { mockGetTableById, mockReplaceTableRows, mockSpanAddEvent } = vi.hoisted(() => ({ mockGetTableById: vi.fn(), mockReplaceTableRows: vi.fn(), + mockSpanAddEvent: vi.fn(), })) vi.mock('@/lib/table/service', () => ({ @@ -23,7 +25,7 @@ vi.mock('@/lib/copilot/request/otel', () => ({ _name: string, _attrs: Record | undefined, fn: (span: unknown) => Promise - ) => fn({ setAttribute: vi.fn(), setAttributes: vi.fn(), addEvent: vi.fn() }), + ) => fn({ setAttribute: vi.fn(), setAttributes: vi.fn(), addEvent: mockSpanAddEvent }), })) import { FunctionExecute, Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1' @@ -32,6 +34,13 @@ import { maybeWriteReadCsvToTable, } from '@/lib/copilot/request/tools/tables' import type { ExecutionContext } from '@/lib/copilot/request/types' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' + +const tableLogger = vi.mocked(loggerMock.createLogger).mock.results[ + vi + .mocked(loggerMock.createLogger) + .mock.calls.findIndex(([name]) => name === 'CopilotToolResultTables') +]?.value function buildTable(overrides: Partial = {}): TableDefinition { return { @@ -172,6 +181,27 @@ describe('maybeWriteOutputToTable', () => { expect(result.success).toBe(false) expect(result.error).toContain('Row 1: name is required') }) + + it('keeps raw errors for terminal projection but projects application logs and OTel events', async () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'SECRET', plaintext: 'secret-value', encryptedValue: 'encrypted-secret-value' }, + ]) + registry.recordResolved('SECRET', 'secret-value') + mockReplaceTableRows.mockRejectedValue(new Error('Duplicate value "secret-value"')) + + const result = await maybeWriteOutputToTable( + FunctionExecute.id, + { outputTable: 'tbl_1' }, + { success: true, output: { result: [{ name: 'secret-value' }] } }, + buildContext({ resolvedSecretTraceRegistry: registry }) + ) + + expect(result.error).toContain('secret-value') + expect(JSON.stringify(tableLogger?.warn.mock.calls)).toContain('{{SECRET}}') + expect(JSON.stringify(tableLogger?.warn.mock.calls)).not.toContain('secret-value') + expect(JSON.stringify(mockSpanAddEvent.mock.calls)).toContain('{{SECRET}}') + expect(JSON.stringify(mockSpanAddEvent.mock.calls)).not.toContain('secret-value') + }) }) describe('maybeWriteReadCsvToTable', () => { @@ -251,4 +281,25 @@ describe('maybeWriteReadCsvToTable', () => { expect(result.success).toBe(false) expect(result.error).toContain('Row 1: name is required') }) + + it('projects active secret literals in CSV-import log and OTel errors', async () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'SECRET', plaintext: 'secret-value', encryptedValue: 'encrypted-secret-value' }, + ]) + registry.recordResolved('SECRET', 'secret-value') + mockReplaceTableRows.mockRejectedValue(new Error('Duplicate value "secret-value"')) + + const result = await maybeWriteReadCsvToTable( + ReadTool.id, + { outputTable: 'tbl_1', path: 'files/people.csv' }, + { success: true, output: { content: 'name\nsecret-value' } }, + buildContext({ resolvedSecretTraceRegistry: registry }) + ) + + expect(result.error).toContain('secret-value') + expect(JSON.stringify(tableLogger?.warn.mock.calls)).toContain('{{SECRET}}') + expect(JSON.stringify(tableLogger?.warn.mock.calls)).not.toContain('secret-value') + expect(JSON.stringify(mockSpanAddEvent.mock.calls)).toContain('{{SECRET}}') + expect(JSON.stringify(mockSpanAddEvent.mock.calls)).not.toContain('secret-value') + }) }) diff --git a/apps/sim/lib/copilot/request/tools/tables.ts b/apps/sim/lib/copilot/request/tools/tables.ts index 5d1aaf310a3..053c37de141 100644 --- a/apps/sim/lib/copilot/request/tools/tables.ts +++ b/apps/sim/lib/copilot/request/tools/tables.ts @@ -9,6 +9,7 @@ import { TraceEvent } from '@/lib/copilot/generated/trace-events-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' import { withCopilotSpan } from '@/lib/copilot/request/otel' import { denyOutputWriteWithoutWritePermission } from '@/lib/copilot/request/tools/permissions' +import { projectToolErrorMessageForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import type { RowData, TableDefinition } from '@/lib/table' import { buildIdByName, rowDataNameToId } from '@/lib/table/column-keys' @@ -151,18 +152,23 @@ export async function maybeWriteOutputToTable( }, } } catch (err) { + const rawMessage = toError(err).message + const projectedMessage = projectToolErrorMessageForCopilot( + rawMessage, + context.resolvedSecretTraceRegistry + ) logger.warn('Failed to write tool output to table', { toolName, outputTable, - error: toError(err).message, + error: projectedMessage, }) span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.Failed) span.addEvent(TraceEvent.CopilotTableError, { - [TraceAttr.ErrorMessage]: toError(err).message.slice(0, 500), + [TraceAttr.ErrorMessage]: projectedMessage.slice(0, 500), }) return { success: false, - error: `Failed to write to table: ${toError(err).message}`, + error: `Failed to write to table: ${rawMessage}`, } } } @@ -281,18 +287,23 @@ export async function maybeWriteReadCsvToTable( }, } } catch (err) { + const rawMessage = toError(err).message + const projectedMessage = projectToolErrorMessageForCopilot( + rawMessage, + context.resolvedSecretTraceRegistry + ) logger.warn('Failed to write read output to table', { toolName, outputTable, - error: toError(err).message, + error: projectedMessage, }) span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.Failed) span.addEvent(TraceEvent.CopilotTableError, { - [TraceAttr.ErrorMessage]: toError(err).message.slice(0, 500), + [TraceAttr.ErrorMessage]: projectedMessage.slice(0, 500), }) return { success: false, - error: `Failed to import into table: ${toError(err).message}`, + error: `Failed to import into table: ${rawMessage}`, } } } diff --git a/apps/sim/lib/copilot/secret-mount-policy.test.ts b/apps/sim/lib/copilot/secret-mount-policy.test.ts new file mode 100644 index 00000000000..49d355cc39d --- /dev/null +++ b/apps/sim/lib/copilot/secret-mount-policy.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { + applySecretMountPolicy, + normalizeSecretMountPolicy, +} from '@/lib/copilot/secret-mount-policy' + +describe('normalizeSecretMountPolicy', () => { + it('defaults missing legacy policy data to all and fails malformed scopes closed', () => { + expect(normalizeSecretMountPolicy()).toEqual({ secretScope: 'all', mountedSecrets: [] }) + expect( + normalizeSecretMountPolicy({ secretScope: 'unknown', mountedSecrets: ['SECRET'] }) + ).toEqual({ secretScope: 'selected', mountedSecrets: [] }) + }) + + it('canonicalizes a selected names-only allowlist', () => { + expect( + normalizeSecretMountPolicy({ + secretScope: 'selected', + mountedSecrets: [' B ', 'A', 'B', '', 42], + }) + ).toEqual({ secretScope: 'selected', mountedSecrets: ['B', 'A'] }) + }) + + it('preserves selected with an empty list as no access', () => { + expect(normalizeSecretMountPolicy({ secretScope: 'selected' })).toEqual({ + secretScope: 'selected', + mountedSecrets: [], + }) + }) +}) + +describe('applySecretMountPolicy', () => { + it('allows every explicit reference under the all policy', () => { + expect(applySecretMountPolicy(['B', ' A ', 'B'])).toEqual(['B', 'A']) + }) + + it('returns exact explicit references under a selected policy', () => { + expect( + applySecretMountPolicy(['B'], { + secretScope: 'selected', + mountedSecrets: ['A', 'B'], + }) + ).toEqual(['B']) + }) + + it('fails atomically when selected policy denies any reference', () => { + expect(() => + applySecretMountPolicy(['A', 'B'], { + secretScope: 'selected', + mountedSecrets: ['A'], + }) + ).toThrow('Secret access is not allowed for: B') + }) +}) diff --git a/apps/sim/lib/copilot/secret-mount-policy.ts b/apps/sim/lib/copilot/secret-mount-policy.ts new file mode 100644 index 00000000000..3a7305a8e8b --- /dev/null +++ b/apps/sim/lib/copilot/secret-mount-policy.ts @@ -0,0 +1,75 @@ +export type SecretMountScope = 'all' | 'selected' + +export interface SecretMountPolicy { + secretScope: SecretMountScope + mountedSecrets: string[] +} + +export const MAX_SECRET_MOUNT_NAMES = 100 +export const MAX_SECRET_MOUNT_NAME_LENGTH = 1024 + +export const DEFAULT_SECRET_MOUNT_POLICY: SecretMountPolicy = { + secretScope: 'all', + mountedSecrets: [], +} + +interface SecretMountPolicyInput { + secretScope?: unknown + mountedSecrets?: unknown +} + +function normalizeSecretNames(value: unknown): string[] { + if (!Array.isArray(value)) return [] + + const names = new Set() + for (const candidate of value) { + if (typeof candidate !== 'string') continue + const name = candidate.trim() + if (name) names.add(name) + } + return [...names] +} + +/** + * Normalizes persisted or legacy policy data. A missing scope uses the backwards-compatible + * `all` policy; an explicit invalid scope fails closed. Selected policies keep a canonical, + * de-duplicated names-only allowlist. + */ +export function normalizeSecretMountPolicy( + input?: SecretMountPolicyInput | null +): SecretMountPolicy { + if (input?.secretScope === undefined || input.secretScope === 'all') { + return { ...DEFAULT_SECRET_MOUNT_POLICY } + } + + if (input.secretScope !== 'selected') { + return { secretScope: 'selected', mountedSecrets: [] } + } + + return { + secretScope: 'selected', + mountedSecrets: normalizeSecretNames(input.mountedSecrets), + } +} + +/** + * Applies a normalized headless allowlist to explicitly referenced secret + * names. A selected policy denies the whole request when any reference is not + * listed so code never runs with a surprising partial environment. + */ +export function applySecretMountPolicy( + requestedNames: readonly string[], + input?: SecretMountPolicyInput | null +): string[] { + const policy = normalizeSecretMountPolicy(input) + const requested = normalizeSecretNames(requestedNames) + if (policy.secretScope === 'all') return requested + + const allowed = new Set(policy.mountedSecrets) + const denied = requested.filter((name) => !allowed.has(name)) + if (denied.length > 0) { + throw new Error(`Secret access is not allowed for: ${denied.join(', ')}`) + } + + return requested +} diff --git a/apps/sim/lib/copilot/tool-executor/executor.test.ts b/apps/sim/lib/copilot/tool-executor/executor.test.ts index 2342f31efbe..30b5d8d4f17 100644 --- a/apps/sim/lib/copilot/tool-executor/executor.test.ts +++ b/apps/sim/lib/copilot/tool-executor/executor.test.ts @@ -2,11 +2,13 @@ * @vitest-environment node */ +import { loggerMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/execution/constants' -import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' -const { isKnownTool, isSimExecuted, isClientExecuted } = vi.hoisted(() => ({ +const { getToolEntry, isKnownTool, isSimExecuted, isClientExecuted } = vi.hoisted(() => ({ + getToolEntry: vi.fn(), isKnownTool: vi.fn(), isSimExecuted: vi.fn(), isClientExecuted: vi.fn(), @@ -17,6 +19,7 @@ const { executeAppTool } = vi.hoisted(() => ({ })) vi.mock('./router', () => ({ + getToolEntry, isKnownTool, isSimExecuted, isClientExecuted, @@ -28,10 +31,87 @@ vi.mock('@/tools', () => ({ import { clearHandlers, executeTool, registerHandler } from './executor' +const toolExecutorLogger = vi.mocked(loggerMock.createLogger).mock.results[ + vi.mocked(loggerMock.createLogger).mock.calls.findIndex(([name]) => name === 'ToolExecutor') +]?.value + describe('copilot tool executor fallback', () => { beforeEach(() => { vi.clearAllMocks() clearHandlers() + getToolEntry.mockReturnValue(undefined) + }) + + it('enforces catalog-required permissions before dispatch and fails closed when absent', async () => { + getToolEntry.mockReturnValue({ requiredPermission: 'write' }) + isKnownTool.mockReturnValue(true) + isSimExecuted.mockReturnValue(true) + isClientExecuted.mockReturnValue(false) + const handler = vi.fn().mockResolvedValue({ success: true }) + registerHandler('function_execute', handler) + + await expect( + executeTool('function_execute', { code: 'return 1' }, { userId: 'user-1', workflowId: '' }) + ).resolves.toEqual({ + success: false, + error: + "Permission denied: function_execute requires write access. You have 'none' permission.", + }) + await expect( + executeTool( + 'function_execute', + { code: 'return 1' }, + { userId: 'user-1', workflowId: '', userPermission: 'read' } + ) + ).resolves.toEqual({ + success: false, + error: + "Permission denied: function_execute requires write access. You have 'read' permission.", + }) + expect(handler).not.toHaveBeenCalled() + }) + + it('dispatches catalog-protected tools when the current permission satisfies the requirement', async () => { + getToolEntry.mockReturnValue({ requiredPermission: 'write' }) + isKnownTool.mockReturnValue(true) + isSimExecuted.mockReturnValue(true) + isClientExecuted.mockReturnValue(false) + const handler = vi.fn().mockResolvedValue({ success: true, output: 'ok' }) + registerHandler('function_execute', handler) + + await expect( + executeTool( + 'function_execute', + { code: 'return 1' }, + { userId: 'user-1', workflowId: '', userPermission: 'write' } + ) + ).resolves.toEqual({ success: true, output: 'ok' }) + expect(handler).toHaveBeenCalledOnce() + }) + + it('projects resolved secrets before logging registered handler failures', async () => { + const secret = 'mounted-secret-value' + const registry = new ResolvedSecretTraceRegistry([ + { name: 'API_KEY', plaintext: secret, encryptedValue: 'encrypted-secret' }, + ]) + registry.recordResolved('API_KEY', secret) + isKnownTool.mockReturnValue(true) + isSimExecuted.mockReturnValue(true) + isClientExecuted.mockReturnValue(false) + registerHandler('throwing_tool', async () => { + throw new Error(`Provider reflected ${secret}`) + }) + + await expect( + executeTool('throwing_tool', {}, { userId: 'user-1', resolvedSecretTraceRegistry: registry }) + ).resolves.toEqual({ success: false, error: `Provider reflected ${secret}` }) + + expect(toolExecutorLogger?.error).toHaveBeenCalledWith('Tool execution failed', { + toolId: 'throwing_tool', + error: 'Provider reflected {{API_KEY}}', + abortSignalAborted: false, + }) + expect(JSON.stringify(toolExecutorLogger?.error.mock.calls)).not.toContain(secret) }) it('falls back to app tool executor for dynamic sim tools', async () => { diff --git a/apps/sim/lib/copilot/tool-executor/executor.ts b/apps/sim/lib/copilot/tool-executor/executor.ts index 3b9efa8438b..6488b695f25 100644 --- a/apps/sim/lib/copilot/tool-executor/executor.ts +++ b/apps/sim/lib/copilot/tool-executor/executor.ts @@ -1,8 +1,10 @@ import { createLogger } from '@sim/logger' +import { type PermissionType, permissionSatisfies } from '@sim/platform-authz/workspace' import { toError } from '@sim/utils/errors' +import { projectToolErrorMessageForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/execution/constants' import { executeTool as executeAppTool } from '@/tools' -import { isClientExecuted, isKnownTool, isSimExecuted } from './router' +import { getToolEntry, isClientExecuted, isKnownTool, isSimExecuted } from './router' import type { ToolCallDescriptor, ToolExecutionContext, @@ -44,6 +46,20 @@ export async function executeTool( params: Record, context: ToolExecutionContext ): Promise { + const requiredPermission = getToolEntry(toolId)?.requiredPermission + if ( + requiredPermission && + !permissionSatisfies( + (context.userPermission ?? null) as PermissionType | null, + requiredPermission + ) + ) { + return { + success: false, + error: `Permission denied: ${toolId} requires ${requiredPermission} access. You have '${context.userPermission ?? 'none'}' permission.`, + } + } + const normalizedParams = normalizeToolParams(toolId, params, context) // Client-routed tools (e.g. run_workflow) are normally executed in the browser and never @@ -82,7 +98,7 @@ export async function executeTool( const message = toError(error).message logger.error('Tool execution failed', { toolId, - error: message, + error: projectToolErrorMessageForCopilot(message, context.resolvedSecretTraceRegistry), abortSignalAborted: context.abortSignal?.aborted ?? false, }) return { success: false, error: message } diff --git a/apps/sim/lib/copilot/tool-executor/types.ts b/apps/sim/lib/copilot/tool-executor/types.ts index a08fda51758..93db4b4eb23 100644 --- a/apps/sim/lib/copilot/tool-executor/types.ts +++ b/apps/sim/lib/copilot/tool-executor/types.ts @@ -1,5 +1,6 @@ import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' import type { MothershipResource } from '@/lib/copilot/resources/types' +import type { SecretMountPolicy } from '@/lib/copilot/secret-mount-policy' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' export interface ToolExecutionContext { @@ -24,7 +25,9 @@ export interface ToolExecutionContext { abortSignal?: AbortSignal userTimezone?: string userPermission?: string - decryptedEnvVars?: Record + secretMountPolicy?: SecretMountPolicy + /** Undefined uses the execution actor; null explicitly disables raw secret mounting. */ + secretActorUserId?: string | null resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry } diff --git a/apps/sim/lib/copilot/tools/client/completion.ts b/apps/sim/lib/copilot/tools/client/completion.ts index ef66300b447..b99cb55cf98 100644 --- a/apps/sim/lib/copilot/tools/client/completion.ts +++ b/apps/sim/lib/copilot/tools/client/completion.ts @@ -26,10 +26,12 @@ export async function reportClientToolCompletion( toolCallId: string, status: AsyncConfirmationStatus, message?: string, - data?: AsyncCompletionData + data?: AsyncCompletionData, + executionId?: string ): Promise { const basePayload = { toolCallId, + ...(executionId ? { executionId } : {}), status, message: message || (status === 'success' ? 'Tool completed' : 'Tool failed'), ...(data !== undefined ? { data } : {}), @@ -61,6 +63,7 @@ export async function reportClientToolCompletion( const retryResponse = await send( JSON.stringify({ toolCallId, + ...(executionId ? { executionId } : {}), status, message: message || (status === 'success' ? 'Tool completed' : 'Tool failed'), data: dataWithoutLogs, 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 ac5fff66d70..873497f3407 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 @@ -102,6 +102,7 @@ import { describe('run tool execution cancellation', () => { beforeEach(() => { vi.clearAllMocks() + window.sessionStorage.clear() getCurrentExecutionId.mockReturnValue(null) getWorkflowEntries.mockReturnValue([]) loadExecutionPointer.mockResolvedValue(null) @@ -133,6 +134,7 @@ describe('run tool execution cancellation', () => { it('can report a manual stop using the explicit toolCallId override', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true }) vi.stubGlobal('fetch', fetchMock) + getCurrentExecutionId.mockReturnValueOnce('exec-manual') await reportManualRunToolStop('wf-1', 'tool-override') @@ -143,13 +145,14 @@ describe('run tool execution cancellation', () => { body: expect.stringContaining('"toolCallId":"tool-override"'), }) ) + expect(fetchMock.mock.calls[0][1]?.body).toContain('"executionId":"exec-manual"') }) it('prefers workflow_input, forwards triggerBlockId, and respects useDeployedState', async () => { executeWorkflowWithFullLogging.mockResolvedValueOnce({ success: true, - output: { ok: true }, - logs: [], + output: { token: 'raw-secret-output' }, + logs: [{ output: 'raw-secret-log' }], }) executeRunToolOnClient('tool-2', 'run_workflow', { @@ -172,6 +175,41 @@ describe('run tool execution cancellation', () => { useDraftState: false, }) ) + const executionId = executeWorkflowWithFullLogging.mock.calls[0][0].executionId + await vi.waitFor(() => { + expect(fetch).toHaveBeenCalledWith( + '/api/copilot/confirm', + expect.objectContaining({ + body: expect.stringContaining(`"executionId":"${executionId}"`), + }) + ) + }) + expect(fetch.mock.calls[0][1]?.body).not.toContain('raw-secret') + }) + + it('reports the workflow execution id with terminal error results', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }) + vi.stubGlobal('fetch', fetchMock) + executeWorkflowWithFullLogging.mockResolvedValueOnce({ + success: false, + output: {}, + error: 'workflow failed', + logs: [], + }) + + executeRunToolOnClient('tool-error', 'run_workflow', { workflowId: 'wf-1' }) + + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith( + '/api/copilot/confirm', + expect.objectContaining({ + body: expect.stringContaining('"status":"error"'), + }) + ) + }) + const executionId = executeWorkflowWithFullLogging.mock.calls[0][0].executionId + expect(fetchMock.mock.calls[0][1]?.body).toContain(`"executionId":"${executionId}"`) + expect(fetchMock.mock.calls[0][1]?.body).not.toContain('workflow failed') }) it('treats a tab-local execution pointer as handled in background', async () => { @@ -197,6 +235,33 @@ describe('run tool execution cancellation', () => { body: expect.stringContaining('"status":"background"'), }) ) + expect(fetchMock.mock.calls[0][1]?.body).toContain('"executionId":"exec-existing"') + }) + + it('strips raw payloads from legacy pending completion recovery', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }) + vi.stubGlobal('fetch', fetchMock) + loadExecutionPointer.mockResolvedValueOnce({ + workflowId: 'wf-1', + executionId: 'exec-existing', + lastEventId: 7, + }) + window.sessionStorage.setItem( + 'sim:copilot:run-tool-completion:tool-recovered', + JSON.stringify({ + status: 'success', + message: 'legacy raw-secret-error', + data: { output: 'legacy raw-secret-output', logs: ['legacy raw-secret-log'] }, + executionId: 'exec-existing', + }) + ) + + await expect(bindRunToolToExecution('tool-recovered', 'wf-1')).resolves.toBe(true) + + const body = fetchMock.mock.calls[0][1]?.body + expect(body).toContain('"status":"success"') + expect(body).toContain('"executionId":"exec-existing"') + expect(body).not.toContain('raw-secret') }) it('does not recover from shared console rows without a tab-local pointer', async () => { @@ -241,6 +306,7 @@ describe('run tool execution cancellation', () => { }) expect(clearExecutionPointer).not.toHaveBeenCalled() expect(setIsExecuting).toHaveBeenCalledWith('wf-1', false) + expect(fetchMock.mock.calls[0][1]?.body).toContain('"executionId":"exec-1"') expect(fetchMock).not.toHaveBeenCalledWith( '/api/copilot/confirm', expect.objectContaining({ 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 cd66f64031f..62a7bc15110 100644 --- a/apps/sim/lib/copilot/tools/client/run-tool-execution.ts +++ b/apps/sim/lib/copilot/tools/client/run-tool-execution.ts @@ -1,9 +1,9 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { isPlainRecord } from '@sim/utils/object' import { ASYNC_TOOL_CONFIRMATION_STATUS, - type AsyncCompletionData, type AsyncConfirmationStatus, } from '@/lib/copilot/async-runs/lifecycle' import { COPILOT_CONFIRM_API_PATH } from '@/lib/copilot/constants' @@ -17,6 +17,7 @@ import { CompletionReportError, reportClientToolCompletion as reportCompletion, } from '@/lib/copilot/tools/client/completion' +import { getWorkflowToolCompletionMessage } from '@/lib/copilot/tools/workflow-tools' import { executeWorkflowWithFullLogging } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils' import { SSEEventHandlerError, SSEStreamInterruptedError } from '@/hooks/use-execution-stream' import { useExecutionStore } from '@/stores/execution/store' @@ -36,8 +37,7 @@ const PENDING_COMPLETION_STORAGE_PREFIX = 'sim:copilot:run-tool-completion:' interface PendingCompletionReport { status: AsyncConfirmationStatus - message?: string - data?: AsyncCompletionData + executionId?: string } function resolveWorkflowInput(params: Record): unknown { @@ -141,8 +141,11 @@ export async function bindRunToolToExecution( await reportCompletion( toolCallId, pendingCompletion.status, - pendingCompletion.message, - pendingCompletion.data + getWorkflowToolCompletionMessage(pendingCompletion.status), + pendingCompletion.status === MothershipStreamV1ToolOutcome.cancelled + ? { reason: 'user_cancelled', cancelledByUser: true } + : undefined, + pendingCompletion.executionId ?? pointer.executionId ) clearPendingCompletionReport(toolCallId) } catch (error) { @@ -160,12 +163,9 @@ export async function bindRunToolToExecution( await reportCompletion( toolCallId, ASYNC_TOOL_CONFIRMATION_STATUS.background, - 'Client recovered an existing workflow execution; continuing in background.', - { - workflowId, - executionId: pointer.executionId, - lastEventId: pointer.lastEventId, - } + getWorkflowToolCompletionMessage(ASYNC_TOOL_CONFIRMATION_STATUS.background), + undefined, + pointer.executionId ) } catch (error) { logger.warn('[RunTool] Failed to report recovered execution as background', { @@ -186,8 +186,8 @@ export async function bindRunToolToExecution( * Mirrors staging's RunWorkflowClientTool.handleAccept(): * 1. Execute via executeWorkflowWithFullLogging * 2. Update client tool state directly (success/error) - * 3. Report completion to server via /api/copilot/confirm (Redis), - * where the server-side handler picks it up and tells Go + * 3. Report a structural completion notification; the server restores the + * bound execution result from its log before resuming Copilot */ export function executeRunToolOnClient( toolCallId: string, @@ -246,15 +246,19 @@ export async function reportManualRunToolStop( manuallyStoppedToolCallIds.add(toolCallId) } + const executionId = + useExecutionStore.getState().getCurrentExecutionId(workflowId) ?? + (await loadExecutionPointer(workflowId).catch(() => null))?.executionId + await reportCompletion( toolCallId, MothershipStreamV1ToolOutcome.cancelled, - 'Workflow execution was stopped manually by the user.', + getWorkflowToolCompletionMessage(MothershipStreamV1ToolOutcome.cancelled), { reason: 'user_cancelled', cancelledByUser: true, - workflowId, - } + }, + executionId ) } @@ -347,7 +351,6 @@ async function doExecuteRunTool( const executionId = generateId() setCurrentExecutionId(targetWorkflowId, executionId) saveExecutionPointer({ workflowId: targetWorkflowId, executionId, lastEventId: 0 }) - const executionStartTime = new Date().toISOString() const releaseVisibleExecutionForBackground = () => { const { setCurrentExecutionId: clearExecId, setActiveBlocks } = useExecutionStore.getState() if (activeRunToolByWorkflowId.get(targetWorkflowId) === toolCallId) { @@ -360,12 +363,15 @@ async function doExecuteRunTool( const onPageHide = () => { if (manuallyStoppedToolCallIds.has(toolCallId)) return + const activeExecutionId = + useExecutionStore.getState().getCurrentExecutionId(targetWorkflowId) ?? executionId navigator.sendBeacon( COPILOT_CONFIRM_API_PATH, new Blob( [ JSON.stringify({ toolCallId, + executionId: activeExecutionId, status: 'background', message: 'Client disconnected, execution continuing server-side', }), @@ -397,6 +403,7 @@ async function doExecuteRunTool( workflowId: targetWorkflowId, workflowInput, executionId, + copilotToolCallId: toolCallId, overrideTriggerType: 'copilot', triggerBlockId, useDraftState, @@ -406,28 +413,16 @@ async function doExecuteRunTool( preserveExecutionOnTerminal: true, }) + const completedExecutionId = + useExecutionStore.getState().getCurrentExecutionId(targetWorkflowId) ?? executionId + // Determine success (same logic as staging's RunWorkflowClientTool) - let succeeded = true - let errorMessage: string | undefined - try { - if (result && typeof result === 'object' && 'success' in (result as any)) { - succeeded = Boolean((result as any).success) - if (!succeeded) { - errorMessage = (result as any)?.error || (result as any)?.output?.error - } - } else if ( - result && - typeof result === 'object' && - 'execution' in (result as any) && - (result as any).execution - ) { - succeeded = Boolean((result as any).execution.success) - if (!succeeded) { - errorMessage = - (result as any).execution?.error || (result as any).execution?.output?.error - } - } - } catch {} + const succeeded = + isPlainRecord(result) && Object.hasOwn(result, 'success') + ? Boolean(result.success) + : isPlainRecord(result) && isPlainRecord(result.execution) + ? Boolean(result.execution.success) + : true if (manuallyStoppedToolCallIds.has(toolCallId)) { logger.info('[RunTool] Skipping generic completion — already manually stopped', { @@ -438,31 +433,30 @@ async function doExecuteRunTool( logger.info('[RunTool] Workflow execution succeeded', { toolCallId, toolName }) const pendingCompletion = { status: MothershipStreamV1ToolOutcome.success, - message: `Workflow execution completed. Started at: ${executionStartTime}`, - data: buildResultData(result), + executionId: completedExecutionId, } savePendingCompletionReport(toolCallId, pendingCompletion) await reportCompletion( toolCallId, pendingCompletion.status, - pendingCompletion.message, - pendingCompletion.data + getWorkflowToolCompletionMessage(pendingCompletion.status), + undefined, + pendingCompletion.executionId ) clearPendingCompletionReport(toolCallId) } else { - const msg = errorMessage || 'Workflow execution failed' - logger.error('[RunTool] Workflow execution failed', { toolCallId, toolName, error: msg }) + logger.error('[RunTool] Workflow execution failed', { toolCallId, toolName }) const pendingCompletion = { status: MothershipStreamV1ToolOutcome.error, - message: msg, - data: buildResultData(result), + executionId: completedExecutionId, } savePendingCompletionReport(toolCallId, pendingCompletion) await reportCompletion( toolCallId, pendingCompletion.status, - pendingCompletion.message, - pendingCompletion.data + getWorkflowToolCompletionMessage(pendingCompletion.status), + undefined, + pendingCompletion.executionId ) clearPendingCompletionReport(toolCallId) } @@ -489,7 +483,9 @@ async function doExecuteRunTool( await reportCompletion( toolCallId, ASYNC_TOOL_CONFIRMATION_STATUS.background, - 'Client lost local stream processing; workflow execution may still be continuing server-side.' + getWorkflowToolCompletionMessage(ASYNC_TOOL_CONFIRMATION_STATUS.background), + undefined, + err.executionId ?? executionId ) return } @@ -504,7 +500,15 @@ async function doExecuteRunTool( return } logger.error('[RunTool] Workflow execution threw', { toolCallId, toolName, error: msg }) - await reportCompletion(toolCallId, MothershipStreamV1ToolOutcome.error, msg) + const failedExecutionId = + useExecutionStore.getState().getCurrentExecutionId(targetWorkflowId) ?? executionId + await reportCompletion( + toolCallId, + MothershipStreamV1ToolOutcome.error, + getWorkflowToolCompletionMessage(MothershipStreamV1ToolOutcome.error), + undefined, + failedExecutionId + ) } } finally { if (typeof window !== 'undefined') { @@ -529,34 +533,3 @@ async function doExecuteRunTool( } } } - -/** - * Extract a structured result payload from the raw execution result - * for the LLM to see the actual workflow output. - */ -function buildResultData(result: unknown): Record | undefined { - if (!result || typeof result !== 'object') return undefined - - const r = result as Record - - if ('success' in r) { - return { - success: r.success, - output: r.output, - logs: r.logs, - error: r.error, - } - } - - if ('execution' in r && r.execution && typeof r.execution === 'object') { - const exec = r.execution as Record - return { - success: exec.success, - output: exec.output, - logs: exec.logs, - error: exec.error, - } - } - - return undefined -} diff --git a/apps/sim/lib/copilot/tools/handlers/context.ts b/apps/sim/lib/copilot/tools/handlers/context.ts index 02ba4d078be..06f1c05716c 100644 --- a/apps/sim/lib/copilot/tools/handlers/context.ts +++ b/apps/sim/lib/copilot/tools/handlers/context.ts @@ -3,8 +3,11 @@ import { type BillingAttributionSnapshot, resolveBillingAttribution, } from '@/lib/billing/core/billing-attribution' +import { + type CopilotEnvironmentContext, + prepareCopilotEnvironmentContext, +} from '@/lib/copilot/environment-context' import type { ExecutionContext } from '@/lib/copilot/request/types' -import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' import { getWorkflowById } from '@/lib/workflows/utils' export async function prepareExecutionContext( @@ -13,14 +16,14 @@ export async function prepareExecutionContext( chatId?: string, options?: { workspaceId?: string - decryptedEnvVars?: Record + environmentContext?: CopilotEnvironmentContext billingAttribution?: BillingAttributionSnapshot } ): Promise { const workspaceId = options?.workspaceId ?? (await getWorkflowById(workflowId))?.workspaceId ?? undefined - const [decryptedEnvVars, billingAttribution] = await Promise.all([ - options?.decryptedEnvVars ?? getEffectiveDecryptedEnv(userId, workspaceId), + const [environmentContext, billingAttribution] = await Promise.all([ + options?.environmentContext ?? prepareCopilotEnvironmentContext(userId, workspaceId), options?.billingAttribution ? Promise.resolve(assertBillingAttributionSnapshot(options.billingAttribution)) : workspaceId @@ -39,7 +42,7 @@ export async function prepareExecutionContext( workflowId, workspaceId, chatId, - decryptedEnvVars, + ...environmentContext, billingAttribution, } } diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts index 0320fb41f35..cef1cf7e4a9 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts @@ -1,6 +1,8 @@ /** * @vitest-environment node */ + +import { encryptionMock, encryptionMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' @@ -20,6 +22,7 @@ const { mockFetchServableWorkspaceFileBuffer, mockGetSandboxWorkspaceFilePath, mockListWorkspaceFileFolders, + mockMaterializeCopilotCodeSecrets, } = vi.hoisted(() => ({ mockIsFeatureEnabled: vi.fn(), mockGetTableById: vi.fn(), @@ -36,9 +39,11 @@ const { mockFetchServableWorkspaceFileBuffer: vi.fn(), mockGetSandboxWorkspaceFilePath: vi.fn(), mockListWorkspaceFileFolders: vi.fn(), + mockMaterializeCopilotCodeSecrets: vi.fn(), })) vi.mock('@/lib/core/config/feature-flags', () => ({ isFeatureEnabled: mockIsFeatureEnabled })) +vi.mock('@/lib/core/security/encryption', () => encryptionMock) vi.mock('@/lib/table/service', () => ({ getTableById: mockGetTableById, listTables: mockListTables, @@ -68,8 +73,14 @@ vi.mock('@/lib/copilot/vfs/path-utils', () => ({ decodeVfsPathSegments: (p: string) => p.split('/'), encodeVfsPathSegments: (s: string[]) => s.join('/'), })) +vi.mock('@/lib/copilot/tools/secret-mount-materializer.server', () => ({ + CopilotCodeSecretAccessError: class CopilotCodeSecretAccessError extends Error {}, + materializeCopilotCodeSecrets: mockMaterializeCopilotCodeSecrets, +})) +import { projectToolResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' import { executeFunctionExecute } from '@/lib/copilot/tools/handlers/function-execute' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' const table = { id: 'tbl_1', @@ -93,25 +104,251 @@ describe('executeFunctionExecute trace-secret provenance', () => { beforeEach(() => { vi.clearAllMocks() mockExecuteTool.mockResolvedValue({ success: true }) + mockMaterializeCopilotCodeSecrets.mockResolvedValue({ envVars: {}, catalogEntries: [] }) + encryptionMockFns.mockDecryptSecret.mockResolvedValue({ decrypted: 'secret-value' }) }) - it('forwards the registry only through server execution options', async () => { - const resolvedSecretTraceRegistry = { recordResolved: vi.fn() } - - await executeFunctionExecute({ code: 'return {{API_KEY}}' }, { - userId: 'u1', - workspaceId: 'ws_1', - resolvedSecretTraceRegistry, - } as never) + it('mounts only explicit references and imports active provenance out of band', async () => { + mockMaterializeCopilotCodeSecrets.mockResolvedValue({ + envVars: { API_KEY: 'secret-value' }, + catalogEntries: [ + { + name: 'API_KEY', + plaintext: 'secret-value', + encryptedValue: 'encrypted-secret-value', + }, + ], + }) + mockExecuteTool.mockImplementationOnce(async (_toolId, _params, options) => { + options.resolvedSecretTraceRegistry.recordResolved('API_KEY', 'secret-value') + return { success: true, output: { result: 'secret-value' } } + }) + const resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry( + [ + { + name: 'API_KEY', + plaintext: 'secret-value', + encryptedValue: 'encrypted-secret-value', + }, + ], + { userId: 'u1', workspaceId: 'ws_1' } + ) + const runtimeResult = await executeFunctionExecute( + { + code: 'return {{API_KEY}}', + envVars: { ATTACKER_KEY: 'attacker-value' }, + secretScope: 'all', + mountedSecrets: ['ATTACKER_KEY'], + _context: { resolvedSecretTraceRegistry: 'attacker-value' }, + }, + { + userId: 'u1', + workflowId: '', + workspaceId: 'ws_1', + resolvedSecretTraceRegistry, + } + ) expect(mockExecuteTool).toHaveBeenCalledWith( 'function_execute', - expect.not.objectContaining({ resolvedSecretTraceRegistry: expect.anything() }), - { resolvedSecretTraceRegistry } + expect.objectContaining({ + envVars: { API_KEY: 'secret-value' }, + secretScope: 'selected', + mountedSecrets: ['API_KEY'], + _context: expect.not.objectContaining({ resolvedSecretTraceRegistry: expect.anything() }), + }), + { resolvedSecretTraceRegistry: expect.any(ResolvedSecretTraceRegistry) } ) const appParams = mockExecuteTool.mock.calls[0]?.[1] as Record - expect(appParams._context).not.toHaveProperty('resolvedSecretTraceRegistry') expect(JSON.stringify(appParams)).not.toContain('resolvedSecretTraceRegistry') + expect(runtimeResult).toEqual({ success: true, output: { result: 'secret-value' } }) + expect(resolvedSecretTraceRegistry.getActiveMatches()).toEqual([ + { plaintext: 'secret-value', replacement: '{{API_KEY}}' }, + ]) + }) + + it('does not mount direct environment-map or shell-variable access', async () => { + await executeFunctionExecute( + { code: 'return environmentVariables.API_KEY + "$API_KEY"' }, + { userId: 'u1', workflowId: '', workspaceId: 'ws_1' } + ) + + expect(mockMaterializeCopilotCodeSecrets).not.toHaveBeenCalled() + expect(mockExecuteTool).toHaveBeenCalledWith( + 'function_execute', + expect.objectContaining({ envVars: {}, secretScope: 'selected', mountedSecrets: [] }), + { resolvedSecretTraceRegistry: expect.any(ResolvedSecretTraceRegistry) } + ) + }) + + it('returns the raw runtime result when provenance import fails', async () => { + mockMaterializeCopilotCodeSecrets.mockResolvedValue({ + envVars: { API_KEY: 'secret-value' }, + catalogEntries: [ + { + name: 'API_KEY', + plaintext: 'secret-value', + encryptedValue: 'encrypted-secret-value', + }, + ], + }) + const runtimeResult = { success: true, output: { result: 'secret-value' } } + mockExecuteTool.mockResolvedValue(runtimeResult) + const resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry([], { + userId: 'u1', + workspaceId: 'ws_1', + }) + vi.spyOn(resolvedSecretTraceRegistry, 'importProvenance').mockRejectedValueOnce( + new Error('provenance import failed') + ) + + await expect( + executeFunctionExecute( + { code: 'return {{API_KEY}}' }, + { + userId: 'u1', + workflowId: '', + workspaceId: 'ws_1', + resolvedSecretTraceRegistry, + } + ) + ).resolves.toBe(runtimeResult) + expect(resolvedSecretTraceRegistry.isComplete()).toBe(false) + }) + + it('fails parallel projections closed until exact mounted provenance is active', async () => { + let completeMaterialization: ((value: unknown) => void) | undefined + mockMaterializeCopilotCodeSecrets.mockReturnValueOnce( + new Promise((resolve) => { + completeMaterialization = resolve + }) + ) + const resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry( + [ + { + name: 'API_KEY', + plaintext: 'secret-value', + encryptedValue: 'encrypted-secret-value', + }, + ], + { userId: 'u1', workspaceId: 'ws_1' } + ) + mockExecuteTool.mockImplementationOnce(async (_toolId, _params, options) => { + expect(resolvedSecretTraceRegistry.isComplete()).toBe(false) + options.resolvedSecretTraceRegistry.recordResolved('API_KEY', 'secret-value') + return { success: true, output: { result: 'secret-value' } } + }) + + const execution = executeFunctionExecute( + { code: 'return {{API_KEY}}' }, + { + userId: 'u1', + workflowId: '', + workspaceId: 'ws_1', + resolvedSecretTraceRegistry, + } + ) + + expect(resolvedSecretTraceRegistry.isComplete()).toBe(false) + expect( + projectToolResultForCopilot( + { success: true, output: { result: 'secret-value' } }, + resolvedSecretTraceRegistry + ) + ).toEqual({ success: true }) + + completeMaterialization?.({ + envVars: { API_KEY: 'secret-value' }, + catalogEntries: [ + { + name: 'API_KEY', + plaintext: 'secret-value', + encryptedValue: 'encrypted-secret-value', + }, + ], + }) + await execution + + expect(resolvedSecretTraceRegistry.isComplete()).toBe(true) + expect(resolvedSecretTraceRegistry.getActiveMatches()).toEqual([ + { plaintext: 'secret-value', replacement: '{{API_KEY}}' }, + ]) + expect(mockExecuteTool).toHaveBeenCalledOnce() + }) + + it('does not activate a mounted reference when the Function route rejects before resolution', async () => { + mockMaterializeCopilotCodeSecrets.mockResolvedValue({ + envVars: { API_KEY: 'secret-value' }, + catalogEntries: [ + { + name: 'API_KEY', + plaintext: 'secret-value', + encryptedValue: 'encrypted-secret-value', + }, + ], + }) + mockExecuteTool.mockResolvedValueOnce({ + success: false, + error: 'Too many sandbox output files requested', + }) + const resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry( + [ + { + name: 'API_KEY', + plaintext: 'secret-value', + encryptedValue: 'encrypted-secret-value', + }, + ], + { userId: 'u1', workspaceId: 'ws_1' } + ) + + await expect( + executeFunctionExecute( + { code: 'return {{API_KEY}}' }, + { + userId: 'u1', + workflowId: '', + workspaceId: 'ws_1', + resolvedSecretTraceRegistry, + } + ) + ).resolves.toEqual({ + success: false, + error: 'Too many sandbox output files requested', + }) + + expect(resolvedSecretTraceRegistry.isComplete()).toBe(true) + expect(resolvedSecretTraceRegistry.getActiveMatches()).toEqual([]) + }) + + it('releases pending provenance without activation when mounting is denied', async () => { + mockMaterializeCopilotCodeSecrets.mockRejectedValueOnce(new Error('mount denied')) + const resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry( + [ + { + name: 'API_KEY', + plaintext: 'secret-value', + encryptedValue: 'encrypted-secret-value', + }, + ], + { userId: 'u1', workspaceId: 'ws_1' } + ) + + await expect( + executeFunctionExecute( + { code: 'return {{API_KEY}}' }, + { + userId: 'u1', + workflowId: '', + workspaceId: 'ws_1', + resolvedSecretTraceRegistry, + } + ) + ).rejects.toThrow('mount denied') + + expect(resolvedSecretTraceRegistry.isComplete()).toBe(true) + expect(resolvedSecretTraceRegistry.getActiveMatches()).toEqual([]) + expect(mockExecuteTool).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.ts index e064b40d579..45634e9792d 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.ts @@ -1,4 +1,11 @@ import { createLogger } from '@sim/logger' +import { applySecretMountPolicy } from '@/lib/copilot/secret-mount-policy' +import type { ToolExecutionContext, ToolExecutionResult } from '@/lib/copilot/tool-executor/types' +import { + CopilotCodeSecretAccessError, + type MaterializedCopilotCodeSecrets, + materializeCopilotCodeSecrets, +} from '@/lib/copilot/tools/secret-mount-materializer.server' import { decodeVfsPathSegments, encodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' @@ -23,8 +30,9 @@ import { hasCloudStorage, } from '@/lib/uploads/core/storage-service' import { isGeneratedDocumentSourceType } from '@/lib/uploads/utils/file-utils' +import { extractCodeSecretNames } from '@/executor/utils/code-secret-references' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' import { executeTool as executeAppTool } from '@/tools' -import type { ToolExecutionContext, ToolExecutionResult } from '../../tool-executor/types' const logger = createLogger('CopilotFunctionExecute') @@ -451,67 +459,112 @@ export async function resolveInputFiles( return sandboxFiles } +async function importMountedProvenance( + source: ResolvedSecretTraceRegistry, + target: ResolvedSecretTraceRegistry | undefined +): Promise { + if (!target) return + + try { + const imported = await target.importProvenance(source.exportProvenance(), { trusted: true }) + if (!imported) target.markIncomplete() + } catch { + target.markIncomplete() + } +} + export async function executeFunctionExecute( params: Record, context: ToolExecutionContext ): Promise { const enrichedParams = { ...params } - - if (context.decryptedEnvVars && Object.keys(context.decryptedEnvVars).length > 0) { - enrichedParams.envVars = { - ...context.decryptedEnvVars, - ...((enrichedParams.envVars as Record) || {}), + const requestedNames = applySecretMountPolicy( + extractCodeSecretNames(params.code, params.language), + context.secretMountPolicy + ) + const completePendingActivation = + requestedNames.length > 0 + ? context.resolvedSecretTraceRegistry?.beginPendingActivation() + : undefined + let mountedRegistry: ResolvedSecretTraceRegistry | undefined + + try { + const secretActorUserId = + context.secretActorUserId === undefined ? context.userId : context.secretActorUserId + let mounted: MaterializedCopilotCodeSecrets = { envVars: {}, catalogEntries: [] } + if (requestedNames.length > 0) { + if (!secretActorUserId) { + throw new CopilotCodeSecretAccessError('Secret access is unavailable for this Copilot run') + } + if (!context.workspaceId) { + throw new CopilotCodeSecretAccessError( + 'A workspace is required to mount secrets into Copilot code' + ) + } + mounted = await materializeCopilotCodeSecrets({ + actorUserId: secretActorUserId, + workspaceId: context.workspaceId, + requestedNames, + }) } - } + mountedRegistry = new ResolvedSecretTraceRegistry(mounted.catalogEntries, { + userId: secretActorUserId ?? context.userId, + ...(context.workspaceId ? { workspaceId: context.workspaceId } : {}), + }) - if (context.workspaceId) { - const inputs = enrichedParams.inputs as - | { - files?: CanonicalFileInput[] - directories?: CanonicalDirectoryInput[] - tables?: CanonicalTableInput[] + enrichedParams.envVars = mounted.envVars + enrichedParams.secretScope = 'selected' + enrichedParams.mountedSecrets = requestedNames + + if (context.workspaceId) { + const inputs = enrichedParams.inputs as + | { + files?: CanonicalFileInput[] + directories?: CanonicalDirectoryInput[] + tables?: CanonicalTableInput[] + } + | undefined + const inputFiles = [ + ...((enrichedParams.inputFiles as unknown[] | undefined) ?? []), + ...(inputs?.files ?? []), + ] + const inputDirectories = inputs?.directories ?? [] + const inputTables = [ + ...((enrichedParams.inputTables as unknown[] | undefined) ?? []), + ...(inputs?.tables ?? []), + ] + + if (inputFiles?.length || inputTables?.length || inputDirectories.length) { + const resolved = await resolveInputFiles( + context.workspaceId, + inputFiles, + inputTables, + inputDirectories + ) + if (resolved.length > 0) { + const existing = (enrichedParams._sandboxFiles as SandboxFile[]) || [] + enrichedParams._sandboxFiles = [...existing, ...resolved] } - | undefined - const inputFiles = [ - ...((enrichedParams.inputFiles as unknown[] | undefined) ?? []), - ...(inputs?.files ?? []), - ] - const inputDirectories = inputs?.directories ?? [] - const inputTables = [ - ...((enrichedParams.inputTables as unknown[] | undefined) ?? []), - ...(inputs?.tables ?? []), - ] - - if (inputFiles?.length || inputTables?.length || inputDirectories.length) { - const resolved = await resolveInputFiles( - context.workspaceId, - inputFiles, - inputTables, - inputDirectories - ) - if (resolved.length > 0) { - const existing = (enrichedParams._sandboxFiles as SandboxFile[]) || [] - enrichedParams._sandboxFiles = [...existing, ...resolved] } } - } - enrichedParams._context = { - ...(typeof enrichedParams._context === 'object' && enrichedParams._context !== null - ? (enrichedParams._context as object) - : {}), - userId: context.userId, - workflowId: context.workflowId, - workspaceId: context.workspaceId, - chatId: context.chatId, - executionId: context.executionId, - runId: context.runId, - enforceCredentialAccess: true, - } + enrichedParams._context = { + userId: context.userId, + workflowId: context.workflowId, + workspaceId: context.workspaceId, + chatId: context.chatId, + executionId: context.executionId, + runId: context.runId, + enforceCredentialAccess: true, + } - return context.resolvedSecretTraceRegistry - ? executeAppTool('function_execute', enrichedParams, { - resolvedSecretTraceRegistry: context.resolvedSecretTraceRegistry, - }) - : executeAppTool('function_execute', enrichedParams) + return await executeAppTool('function_execute', enrichedParams, { + resolvedSecretTraceRegistry: mountedRegistry, + }) + } finally { + if (mountedRegistry) { + await importMountedProvenance(mountedRegistry, context.resolvedSecretTraceRegistry) + } + completePendingActivation?.() + } } diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts index 097794dac06..b7d1559d44d 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts @@ -138,6 +138,7 @@ vi.mock('../access', () => ({ getDefaultWorkspaceId: vi.fn(), })) +import { projectToolResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' import { applyCreateWorkflowOutputToContext } from '@/lib/copilot/request/tools/workflow-context' import { performUpdateWorkflow } from '@/lib/workflows/orchestration' import { listFolders, verifyFolderWorkspace } from '@/lib/workflows/utils' @@ -716,6 +717,61 @@ describe('Copilot workflow execution billing attribution', () => { expect(JSON.stringify(result)).not.toContain('encrypted-secret') }) + it('fails concurrent tool-result projection closed until child provenance is imported', async () => { + const registry = new ResolvedSecretTraceRegistry([], { + userId: 'user-1', + workspaceId: 'workspace-1', + }) + const context: ExecutionContext = { + ...executionContext, + resolvedSecretTraceRegistry: registry, + } + let resolveExecution!: (value: unknown) => void + let markExecutionStarted!: () => void + const executionStarted = new Promise((resolve) => { + markExecutionStarted = resolve + }) + executeWorkflowMock.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveExecution = resolve + markExecutionStarted() + }) + ) + + const execution = executeRunWorkflow( + { workflowId: 'workflow-1', useMockPayload: true }, + context + ) + await executionStarted + + expect(registry.isComplete()).toBe(false) + expect( + projectToolResultForCopilot({ success: true, output: { value: 'secret-value' } }, registry) + ).not.toHaveProperty('output') + + resolveExecution({ + success: true, + output: { value: 'secret-value' }, + logs: [], + metadata: { executionId: 'new-execution-1' }, + executionState: { + resolvedSecretTraceProvenance: { + version: 1, + complete: true, + entries: [{ name: 'API_KEY', encryptedValue: 'encrypted-secret' }], + scope: { userId: 'user-1', workspaceId: 'workspace-1' }, + }, + }, + }) + + await expect(execution).resolves.toMatchObject({ success: true }) + expect(registry.isComplete()).toBe(true) + expect( + projectToolResultForCopilot({ success: true, output: { value: 'secret-value' } }, registry) + ).toMatchObject({ output: { value: '{{API_KEY}}' } }) + }) + it('marks provenance incomplete when child execution returns no trusted state', async () => { const registry = new ResolvedSecretTraceRegistry() const context: ExecutionContext = { diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts index 4a8823cd717..260bf73fbd2 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts @@ -95,10 +95,12 @@ async function executeCopilotWorkflowTarget(params: { params.workflow.workspaceId, childExecutionId ) + const trustedInitialResolvedSecretTraceProvenance = + params.context.resolvedSecretTraceRegistry?.exportProvenanceForValue(params.input) + const completePendingActivation = + params.context.resolvedSecretTraceRegistry?.beginPendingActivation() try { - const trustedInitialResolvedSecretTraceProvenance = - params.context.resolvedSecretTraceRegistry?.exportProvenanceForValue(params.input) const result = await executeWorkflow( params.workflow, generateRequestId(), @@ -139,6 +141,8 @@ async function executeCopilotWorkflowTarget(params: { await releaseExecutionSlot(childExecutionId) } throw error + } finally { + completePendingActivation?.() } } diff --git a/apps/sim/lib/copilot/tools/secret-mount-materializer.server.test.ts b/apps/sim/lib/copilot/tools/secret-mount-materializer.server.test.ts new file mode 100644 index 00000000000..14313766c5d --- /dev/null +++ b/apps/sim/lib/copilot/tools/secret-mount-materializer.server.test.ts @@ -0,0 +1,456 @@ +/** + * @vitest-environment node + */ +import { credential, environment, workspaceEnvironment } from '@sim/db/schema' +import { + dbChainMockFns, + encryptionMock, + encryptionMockFns, + queueTableRows, + resetDbChainMock, +} from '@sim/testing' +import { or } from 'drizzle-orm' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckWorkspaceAccess } = vi.hoisted(() => ({ + mockCheckWorkspaceAccess: vi.fn(), +})) + +vi.mock('@/lib/core/security/encryption', () => encryptionMock) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + checkWorkspaceAccess: mockCheckWorkspaceAccess, +})) + +import { + CopilotCodeSecretAccessError, + MAX_SECRET_MOUNT_NAME_LENGTH, + MAX_SECRET_MOUNT_NAMES, + materializeCopilotCodeSecrets, +} from '@/lib/copilot/tools/secret-mount-materializer.server' + +interface CredentialRow { + type: 'env_personal' | 'env_workspace' + envKey: string + envOwnerUserId: string | null + role: 'admin' | 'member' | null + status: 'active' | 'pending' | 'revoked' | null + updatedAt: Date + encryptedValue: string | null + encryptedValueBytes: number | null +} + +function queueSources(input: { + personal?: Record + personalOverLimit?: string[] + workspace?: Record + workspaceOverLimit?: string[] + credentials?: CredentialRow[] +}): void { + queueTableRows(environment, [ + { variables: input.personal ?? {}, overLimitNames: input.personalOverLimit ?? [] }, + ]) + queueTableRows(workspaceEnvironment, [ + { variables: input.workspace ?? {}, overLimitNames: input.workspaceOverLimit ?? [] }, + ]) + queueTableRows(credential, input.credentials ?? []) +} + +function credentialRow( + overrides: Partial & Pick +): CredentialRow { + return { + envOwnerUserId: null, + role: null, + status: null, + updatedAt: new Date('2026-08-01T00:00:00.000Z'), + encryptedValue: null, + encryptedValueBytes: null, + ...overrides, + } +} + +function mockSqlText(value: unknown): string { + if (typeof value !== 'object' || value === null || !('toSQL' in value)) { + throw new Error('Expected a mock SQL fragment') + } + const toSQL = value.toSQL + if (typeof toSQL !== 'function') throw new Error('Expected a mock SQL renderer') + const rendered = toSQL.call(value) as { sql?: unknown } + if (typeof rendered.sql !== 'string') throw new Error('Expected rendered SQL text') + return rendered.sql +} + +describe('materializeCopilotCodeSecrets', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockCheckWorkspaceAccess.mockResolvedValue({ + exists: true, + hasAccess: true, + canWrite: true, + canAdmin: false, + }) + encryptionMockFns.mockDecryptSecret.mockImplementation(async (encryptedValue: string) => ({ + decrypted: `plain:${encryptedValue}`, + })) + }) + + it('mounts the actor own personal secret', async () => { + queueSources({ personal: { API_KEY: 'personal-cipher' } }) + + await expect( + materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + ).resolves.toEqual({ + envVars: { API_KEY: 'plain:personal-cipher' }, + catalogEntries: [ + { name: 'API_KEY', plaintext: 'plain:personal-cipher', encryptedValue: 'personal-cipher' }, + ], + }) + }) + + it('mounts an own __proto__ secret as data without mutating record prototypes', async () => { + queueSources({ personal: Object.fromEntries([['__proto__', 'personal-cipher']]) }) + + const result = await materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['__proto__'], + }) + + expect(Object.hasOwn(result.envVars, '__proto__')).toBe(true) + expect(result.envVars.__proto__).toBe('plain:personal-cipher') + expect(Object.getPrototypeOf(result.envVars)).toBe(Object.prototype) + }) + + it('casts stored JSON values before using JSONB operators', async () => { + queueSources({ personal: { API_KEY: 'personal-cipher' } }) + + await materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + + for (const [selection] of dbChainMockFns.select.mock.calls.slice(0, 2)) { + const fields = selection as Record + expect(mockSqlText(fields.variables)).toContain("coalesce(?::jsonb, '{}'::jsonb)") + expect(mockSqlText(fields.overLimitNames)).toContain("coalesce(?::jsonb, '{}'::jsonb)") + } + + const personalCredentialPredicate = vi.mocked(or).mock.calls[0]?.[1] + expect(mockSqlText(personalCredentialPredicate)).toContain( + "coalesce(?::jsonb, '{}'::jsonb) ? ?" + ) + }) + + it('lets a workspace admin mount workspace secrets with workspace precedence', async () => { + mockCheckWorkspaceAccess.mockResolvedValue({ + exists: true, + hasAccess: true, + canWrite: true, + canAdmin: true, + }) + queueSources({ + personal: { API_KEY: 'personal-cipher' }, + workspace: { API_KEY: 'workspace-cipher' }, + }) + + const result = await materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + + expect(result.envVars).toEqual({ API_KEY: 'plain:workspace-cipher' }) + }) + + it('lets an active per-secret admin mount a workspace secret', async () => { + queueSources({ + workspace: { API_KEY: 'workspace-cipher' }, + credentials: [ + credentialRow({ + type: 'env_workspace', + envKey: 'API_KEY', + role: 'admin', + status: 'active', + }), + ], + }) + + const result = await materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + + expect(result.envVars).toEqual({ API_KEY: 'plain:workspace-cipher' }) + }) + + it.each([ + ['member', 'active'], + ['admin', 'revoked'], + ['admin', 'pending'], + ] as const)('denies a workspace secret for a %s/%s credential grant', async (role, status) => { + queueSources({ + workspace: { API_KEY: 'workspace-cipher' }, + credentials: [credentialRow({ type: 'env_workspace', envKey: 'API_KEY', role, status })], + }) + + await expect( + materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + ).rejects.toBeInstanceOf(CopilotCodeSecretAccessError) + expect(encryptionMockFns.mockDecryptSecret).not.toHaveBeenCalled() + }) + + it('denies workspace secrets when the actor has zero credential grants', async () => { + queueSources({ workspace: { API_KEY: 'workspace-cipher' }, credentials: [] }) + + await expect( + materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + ).rejects.toThrow('Copilot code cannot access the requested secret: API_KEY') + expect(encryptionMockFns.mockDecryptSecret).not.toHaveBeenCalled() + }) + + it('lets an authorized personal value win over an unauthorized same-name workspace value', async () => { + queueSources({ + personal: { API_KEY: 'personal-cipher' }, + workspace: { API_KEY: 'workspace-cipher' }, + credentials: [ + credentialRow({ + type: 'env_workspace', + envKey: 'API_KEY', + role: 'member', + status: 'active', + }), + ], + }) + + const result = await materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + + expect(result.envVars).toEqual({ API_KEY: 'plain:personal-cipher' }) + }) + + it('lets an authorized personal value win over an unauthorized over-limit workspace value', async () => { + queueSources({ + personal: { API_KEY: 'personal-cipher' }, + workspaceOverLimit: ['API_KEY'], + credentials: [ + credentialRow({ + type: 'env_workspace', + envKey: 'API_KEY', + role: 'member', + status: 'active', + }), + ], + }) + + const result = await materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + + expect(result.envVars).toEqual({ API_KEY: 'plain:personal-cipher' }) + }) + + it('does not fall back when an authorized workspace value exceeds the encrypted byte limit', async () => { + mockCheckWorkspaceAccess.mockResolvedValue({ + exists: true, + hasAccess: true, + canWrite: true, + canAdmin: true, + }) + queueSources({ + personal: { API_KEY: 'personal-cipher' }, + workspaceOverLimit: ['API_KEY'], + }) + + await expect( + materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + ).rejects.toThrow('Requested secrets exceed the Copilot mount size limit') + expect(encryptionMockFns.mockDecryptSecret).not.toHaveBeenCalled() + }) + + it('does not fall back when the actor own personal value exceeds the encrypted byte limit', async () => { + queueSources({ + personalOverLimit: ['API_KEY'], + credentials: [ + credentialRow({ + type: 'env_personal', + envKey: 'API_KEY', + envOwnerUserId: 'owner-2', + role: 'admin', + status: 'active', + encryptedValue: 'shared-cipher', + encryptedValueBytes: 13, + }), + ], + }) + + await expect( + materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + ).rejects.toThrow('Requested secrets exceed the Copilot mount size limit') + expect(encryptionMockFns.mockDecryptSecret).not.toHaveBeenCalled() + }) + + it('mounts another owner personal secret only for an active per-secret admin', async () => { + queueSources({ + credentials: [ + credentialRow({ + type: 'env_personal', + envKey: 'SHARED_KEY', + envOwnerUserId: 'owner-2', + role: 'admin', + status: 'active', + encryptedValue: 'shared-cipher', + encryptedValueBytes: 13, + }), + ], + }) + + const result = await materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['SHARED_KEY'], + }) + + expect(result.envVars).toEqual({ SHARED_KEY: 'plain:shared-cipher' }) + }) + + it('uses the current encrypted value on every call so rotation is observed', async () => { + mockCheckWorkspaceAccess.mockResolvedValue({ + exists: true, + hasAccess: true, + canWrite: true, + canAdmin: true, + }) + queueSources({ workspace: { API_KEY: 'rotated-cipher' } }) + + const result = await materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + + expect(result.envVars).toEqual({ API_KEY: 'plain:rotated-cipher' }) + expect(encryptionMockFns.mockDecryptSecret).toHaveBeenCalledWith('rotated-cipher') + }) + + it('fails atomically for missing or deleted names before decrypting authorized values', async () => { + queueSources({ personal: { ALLOWED: 'allowed-cipher' } }) + + await expect( + materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['ALLOWED', 'DELETED'], + }) + ).rejects.toThrow('Copilot code cannot access the requested secret: DELETED') + expect(encryptionMockFns.mockDecryptSecret).not.toHaveBeenCalled() + }) + + it('fails the whole call when decryption fails', async () => { + queueSources({ personal: { API_KEY: 'broken-cipher' } }) + encryptionMockFns.mockDecryptSecret.mockRejectedValue(new Error('decrypt failed')) + + await expect( + materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + ).rejects.toThrow('One or more requested secrets could not be decrypted') + }) + + it('fails the whole call when mounted plaintext exceeds the byte budget', async () => { + queueSources({ personal: { API_KEY: 'large-cipher' } }) + encryptionMockFns.mockDecryptSecret.mockResolvedValue({ decrypted: 'x'.repeat(64 * 1024 + 1) }) + + await expect( + materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + ).rejects.toThrow('Requested secrets exceed the Copilot mount size limit') + }) + + it('fails the whole call when an authorized shared personal ciphertext exceeds the byte limit', async () => { + queueSources({ + credentials: [ + credentialRow({ + type: 'env_personal', + envKey: 'API_KEY', + envOwnerUserId: 'owner-2', + role: 'admin', + status: 'active', + encryptedValueBytes: 512 * 1024 + 1, + }), + ], + }) + + await expect( + materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['API_KEY'], + }) + ).rejects.toThrow('Requested secrets exceed the Copilot mount size limit') + expect(encryptionMockFns.mockDecryptSecret).not.toHaveBeenCalled() + }) + + it('rejects over-limit requests before access checks, database reads, or decryption', async () => { + const requestedNames = Array.from( + { length: MAX_SECRET_MOUNT_NAMES + 1 }, + (_, index) => `SECRET_${index}` + ) + + await expect( + materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames, + }) + ).rejects.toThrow(`at most ${MAX_SECRET_MOUNT_NAMES} secrets`) + expect(mockCheckWorkspaceAccess).not.toHaveBeenCalled() + expect(dbChainMockFns.select).not.toHaveBeenCalled() + expect(encryptionMockFns.mockDecryptSecret).not.toHaveBeenCalled() + }) + + it('rejects overlong names before access checks, database reads, or decryption', async () => { + await expect( + materializeCopilotCodeSecrets({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + requestedNames: ['S'.repeat(MAX_SECRET_MOUNT_NAME_LENGTH + 1)], + }) + ).rejects.toThrow(`at most ${MAX_SECRET_MOUNT_NAME_LENGTH} characters`) + expect(mockCheckWorkspaceAccess).not.toHaveBeenCalled() + expect(dbChainMockFns.select).not.toHaveBeenCalled() + expect(encryptionMockFns.mockDecryptSecret).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/copilot/tools/secret-mount-materializer.server.ts b/apps/sim/lib/copilot/tools/secret-mount-materializer.server.ts new file mode 100644 index 00000000000..47d81467c8f --- /dev/null +++ b/apps/sim/lib/copilot/tools/secret-mount-materializer.server.ts @@ -0,0 +1,312 @@ +import { db } from '@sim/db' +import { credential, credentialMember, environment, workspaceEnvironment } from '@sim/db/schema' +import { and, desc, eq, inArray, or, sql } from 'drizzle-orm' +import type { AnyPgColumn } from 'drizzle-orm/pg-core' +import { + MAX_SECRET_MOUNT_NAME_LENGTH, + MAX_SECRET_MOUNT_NAMES, +} from '@/lib/copilot/secret-mount-policy' +import { decryptSecret } from '@/lib/core/security/encryption' +import { setRecordValue } from '@/lib/core/utils/records' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' +import type { ResolvedSecretTraceCatalogEntry } from '@/executor/utils/resolved-secret-trace-registry' + +export { MAX_SECRET_MOUNT_NAME_LENGTH, MAX_SECRET_MOUNT_NAMES } + +const MAX_SECRET_MOUNT_ENCRYPTED_BYTES = 512 * 1024 +const MAX_SECRET_MOUNT_PLAINTEXT_BYTES = 64 * 1024 +const MAX_SECRET_MOUNT_TOTAL_PLAINTEXT_BYTES = 256 * 1024 + +interface CredentialAccessRow { + type: 'env_personal' | 'env_workspace' + envKey: string + envOwnerUserId: string | null + role: 'admin' | 'member' + status: 'active' | 'pending' | 'revoked' + updatedAt: Date + encryptedValue: string | null + encryptedValueBytes: number | null +} + +interface AuthorizedEncryptedSecret { + name: string + encryptedValue: string +} + +export interface MaterializedCopilotCodeSecrets { + envVars: Record + catalogEntries: ResolvedSecretTraceCatalogEntry[] +} + +export class CopilotCodeSecretAccessError extends Error { + constructor(message: string) { + super(message) + this.name = 'CopilotCodeSecretAccessError' + } +} + +function normalizeRequestedNames(names: readonly string[]): string[] { + const normalized: string[] = [] + const seen = new Set() + for (const name of names) { + if (name.length === 0 || seen.has(name)) continue + if (name.length > MAX_SECRET_MOUNT_NAME_LENGTH) { + throw new CopilotCodeSecretAccessError( + `Copilot secret names may be at most ${MAX_SECRET_MOUNT_NAME_LENGTH} characters` + ) + } + seen.add(name) + normalized.push(name) + } + if (normalized.length > MAX_SECRET_MOUNT_NAMES) { + throw new CopilotCodeSecretAccessError( + `Copilot code may request at most ${MAX_SECRET_MOUNT_NAMES} secrets per call` + ) + } + return normalized +} + +function encryptedVariables(row: { variables: unknown } | undefined): Record { + if (!row?.variables || typeof row.variables !== 'object' || Array.isArray(row.variables)) + return {} + const result: Record = {} + for (const [name, value] of Object.entries(row.variables)) { + if (typeof value === 'string') setRecordValue(result, name, value) + } + return result +} + +function requestedVariables(column: AnyPgColumn, names: readonly string[]) { + const keys = sql.join( + names.map((name) => sql`${name}`), + sql`, ` + ) + return sql>`coalesce( + ( + select jsonb_object_agg(entry.key, entry.value) + from jsonb_each_text(coalesce(${column}::jsonb, '{}'::jsonb)) as entry(key, value) + where entry.key in (${keys}) + and octet_length(entry.value) <= ${MAX_SECRET_MOUNT_ENCRYPTED_BYTES} + ), + '{}'::jsonb + )`.as('variables') +} + +function requestedOverLimitNames(column: AnyPgColumn, names: readonly string[]) { + const keys = sql.join( + names.map((name) => sql`${name}`), + sql`, ` + ) + return sql`coalesce( + ( + select jsonb_agg(entry.key order by entry.key) + from jsonb_each_text(coalesce(${column}::jsonb, '{}'::jsonb)) as entry(key, value) + where entry.key in (${keys}) + and octet_length(entry.value) > ${MAX_SECRET_MOUNT_ENCRYPTED_BYTES} + ), + '[]'::jsonb + )`.as('over_limit_names') +} + +function overLimitNames(row: { overLimitNames?: unknown } | undefined): Set { + if (!Array.isArray(row?.overLimitNames)) return new Set() + return new Set(row.overLimitNames.filter((name): name is string => typeof name === 'string')) +} + +function activeAdmin(row: CredentialAccessRow): boolean { + return row.role === 'admin' && row.status === 'active' +} + +function unavailableError(names: readonly string[]): CopilotCodeSecretAccessError { + return new CopilotCodeSecretAccessError( + `Copilot code cannot access the requested secret${names.length === 1 ? '' : 's'}: ${names.join(', ')}` + ) +} + +/** + * Resolves exact Secrets-tab values for arbitrary Copilot code after rechecking current authority. + * No plaintext is produced until every requested name has an authorized source. + */ +export async function materializeCopilotCodeSecrets(params: { + actorUserId: string + workspaceId: string + requestedNames: readonly string[] +}): Promise { + const requestedNames = normalizeRequestedNames(params.requestedNames) + if (requestedNames.length === 0) return { envVars: {}, catalogEntries: [] } + + const access = await checkWorkspaceAccess(params.workspaceId, params.actorUserId) + if (!access.exists || !access.canWrite) { + throw new CopilotCodeSecretAccessError( + 'Write access is required to mount secrets into Copilot code' + ) + } + + const [personalRows, workspaceRows, credentialRows] = await Promise.all([ + db + .select({ + variables: requestedVariables(environment.variables, requestedNames), + overLimitNames: requestedOverLimitNames(environment.variables, requestedNames), + }) + .from(environment) + .where(eq(environment.userId, params.actorUserId)) + .limit(1), + db + .select({ + variables: requestedVariables(workspaceEnvironment.variables, requestedNames), + overLimitNames: requestedOverLimitNames(workspaceEnvironment.variables, requestedNames), + }) + .from(workspaceEnvironment) + .where(eq(workspaceEnvironment.workspaceId, params.workspaceId)) + .limit(1), + db + .selectDistinctOn([credential.type, credential.envKey], { + type: credential.type, + envKey: credential.envKey, + envOwnerUserId: credential.envOwnerUserId, + role: credentialMember.role, + status: credentialMember.status, + updatedAt: credential.updatedAt, + encryptedValue: sql`case + when octet_length(${environment.variables} ->> ${credential.envKey}) <= ${MAX_SECRET_MOUNT_ENCRYPTED_BYTES} + then ${environment.variables} ->> ${credential.envKey} + else null + end`.as('encrypted_value'), + encryptedValueBytes: sql< + number | null + >`octet_length(${environment.variables} ->> ${credential.envKey})`.as( + 'encrypted_value_bytes' + ), + }) + .from(credential) + .innerJoin( + credentialMember, + and( + eq(credentialMember.credentialId, credential.id), + eq(credentialMember.userId, params.actorUserId) + ) + ) + .leftJoin(environment, eq(environment.userId, credential.envOwnerUserId)) + .where( + and( + eq(credential.workspaceId, params.workspaceId), + inArray(credential.type, ['env_workspace', 'env_personal']), + inArray(credential.envKey, requestedNames), + eq(credentialMember.role, 'admin'), + eq(credentialMember.status, 'active'), + or( + eq(credential.type, 'env_workspace'), + sql`coalesce(${environment.variables}::jsonb, '{}'::jsonb) ? ${credential.envKey}` + ) + ) + ) + .orderBy(credential.type, credential.envKey, desc(credential.updatedAt)) + .limit(MAX_SECRET_MOUNT_NAMES * 2), + ]) + + const ownPersonalEncrypted = encryptedVariables(personalRows[0]) + const workspaceEncrypted = encryptedVariables(workspaceRows[0]) + const ownPersonalOverLimit = overLimitNames(personalRows[0]) + const workspaceOverLimit = overLimitNames(workspaceRows[0]) + const envCredentialRows = credentialRows.filter( + (row): row is CredentialAccessRow => + (row.type === 'env_personal' || row.type === 'env_workspace') && + typeof row.envKey === 'string' + ) + const authorizedSharedPersonalRows = envCredentialRows.filter( + (row) => + row.type === 'env_personal' && + row.envOwnerUserId !== null && + row.envOwnerUserId !== params.actorUserId && + activeAdmin(row) + ) + + const authorizedSources: AuthorizedEncryptedSecret[] = [] + const unavailable: string[] = [] + const overLimit: string[] = [] + for (const name of requestedNames) { + const workspaceValue = workspaceEncrypted[name] + const workspaceExists = workspaceValue !== undefined || workspaceOverLimit.has(name) + const workspaceAuthorized = + workspaceExists && + (access.canAdmin || + envCredentialRows.some( + (row) => row.type === 'env_workspace' && row.envKey === name && activeAdmin(row) + )) + + if (workspaceAuthorized) { + if (workspaceValue === undefined) { + overLimit.push(name) + continue + } + authorizedSources.push({ name, encryptedValue: workspaceValue }) + continue + } + + const ownPersonalValue = ownPersonalEncrypted[name] + if (ownPersonalOverLimit.has(name)) { + overLimit.push(name) + continue + } + if (ownPersonalValue !== undefined) { + authorizedSources.push({ name, encryptedValue: ownPersonalValue }) + continue + } + + const sharedPersonal = authorizedSharedPersonalRows + .filter((row) => row.envKey === name) + .sort((left, right) => right.updatedAt.getTime() - left.updatedAt.getTime()) + .at(0) + if ( + sharedPersonal && + sharedPersonal.encryptedValueBytes !== null && + sharedPersonal.encryptedValueBytes > MAX_SECRET_MOUNT_ENCRYPTED_BYTES + ) { + overLimit.push(name) + continue + } + const sharedPersonalValue = sharedPersonal?.encryptedValue ?? undefined + if (sharedPersonalValue !== undefined) { + authorizedSources.push({ name, encryptedValue: sharedPersonalValue }) + continue + } + + unavailable.push(name) + } + + if (overLimit.length > 0) { + throw new CopilotCodeSecretAccessError('Requested secrets exceed the Copilot mount size limit') + } + if (unavailable.length > 0) throw unavailableError(unavailable) + + let decryptedEntries: Array<{ name: string; plaintext: string; encryptedValue: string }> + try { + decryptedEntries = await Promise.all( + authorizedSources.map(async ({ name, encryptedValue }) => { + const { decrypted } = await decryptSecret(encryptedValue) + return { name, plaintext: decrypted, encryptedValue } + }) + ) + } catch { + throw new CopilotCodeSecretAccessError('One or more requested secrets could not be decrypted') + } + + let totalPlaintextBytes = 0 + for (const entry of decryptedEntries) { + const plaintextBytes = Buffer.byteLength(entry.plaintext, 'utf8') + totalPlaintextBytes += plaintextBytes + if ( + plaintextBytes > MAX_SECRET_MOUNT_PLAINTEXT_BYTES || + totalPlaintextBytes > MAX_SECRET_MOUNT_TOTAL_PLAINTEXT_BYTES + ) { + throw new CopilotCodeSecretAccessError( + 'Requested secrets exceed the Copilot mount size limit' + ) + } + } + + return { + envVars: Object.fromEntries(decryptedEntries.map((entry) => [entry.name, entry.plaintext])), + catalogEntries: decryptedEntries, + } +} diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.test.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.test.ts new file mode 100644 index 00000000000..f8d84b3fe93 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.test.ts @@ -0,0 +1,16 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it } from 'vitest' +import { computeBlockLevelInputs } from '@/lib/copilot/tools/server/blocks/get-blocks-metadata-tool' +import { MothershipBlock } from '@/blocks/blocks/mothership' + +describe('get blocks metadata', () => { + it('omits server-only Mothership policy inputs from block metadata definitions', () => { + const definitions = computeBlockLevelInputs(MothershipBlock) + + expect(definitions).not.toHaveProperty('secretScope') + expect(definitions).not.toHaveProperty('mountedSecrets') + }) +}) diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts index 6dbb214de80..5a7c1f2d3a9 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts @@ -180,7 +180,9 @@ export const getBlocksMetadataServerTool: BaseServerTool< // `workflow_executor`; the agent never configures a workflowId/inputMapping. // Present it as self-contained: its visible input fields + curated outputs, // no tools/operations. - const visibleSubBlocks = (blockConfig.subBlocks || []).filter((sb) => !sb.hidden) + const visibleSubBlocks = (blockConfig.subBlocks || []).filter( + (sb) => !sb.hidden && !sb.hideFromCopilot + ) const outputs = blockConfig.outputs ? Object.fromEntries( Object.entries(blockConfig.outputs).filter(([_, def]) => !isHiddenFromDisplay(def)) @@ -273,11 +275,13 @@ export const getBlocksMetadataServerTool: BaseServerTool< }) } - const blockInputs = computeBlockLevelInputs(blockConfig) + const hiddenParamKeys = getCopilotHiddenParamKeys(blockConfig) + const blockInputs = computeBlockLevelInputs(blockConfig, hiddenParamKeys) const { commonParameters, operationParameters } = splitParametersByOperation( Array.isArray(blockConfig.subBlocks) ? blockConfig.subBlocks.filter( - (sb) => sb.mode !== 'trigger' && sb.mode !== 'trigger-advanced' + (sb) => + !sb.hideFromCopilot && sb.mode !== 'trigger' && sb.mode !== 'trigger-advanced' ) : [], blockInputs @@ -297,7 +301,7 @@ export const getBlocksMetadataServerTool: BaseServerTool< : {} const filteredToolParams: Record = {} for (const [k, v] of Object.entries(toolParams)) { - if (!(k in blockInputs)) filteredToolParams[k] = v + if (!(k in blockInputs) && !hiddenParamKeys.has(k)) filteredToolParams[k] = v } operations[opId] = { toolId: resolvedToolId, @@ -968,10 +972,25 @@ function splitParametersByOperation( return { commonParameters, operationParameters } } -function computeBlockLevelInputs(blockConfig: BlockConfig): Record { +function getCopilotHiddenParamKeys(blockConfig: BlockConfig): Set { + const hiddenParamKeys = new Set() + for (const subBlock of blockConfig.subBlocks ?? []) { + if (!subBlock.hideFromCopilot) continue + if (subBlock.id) hiddenParamKeys.add(subBlock.id) + if (subBlock.canonicalParamId) hiddenParamKeys.add(subBlock.canonicalParamId) + } + return hiddenParamKeys +} + +export function computeBlockLevelInputs( + blockConfig: BlockConfig, + hiddenParamKeys = getCopilotHiddenParamKeys(blockConfig) +): Record { const inputs = blockConfig.inputs || {} const subBlocks: any[] = Array.isArray(blockConfig.subBlocks) - ? blockConfig.subBlocks.filter((sb) => sb.mode !== 'trigger' && sb.mode !== 'trigger-advanced') + ? blockConfig.subBlocks.filter( + (sb) => !sb.hideFromCopilot && sb.mode !== 'trigger' && sb.mode !== 'trigger-advanced' + ) : [] const byParamKey: Record = {} @@ -988,6 +1007,7 @@ function computeBlockLevelInputs(blockConfig: BlockConfig): Record const blockInputs: Record = {} for (const key of Object.keys(inputs)) { + if (hiddenParamKeys.has(key)) continue const sbs = byParamKey[key] || [] const isOperationGated = sbs.some((sb) => { const cond = normalizeCondition(sb.condition) @@ -1006,7 +1026,9 @@ function computeOperationLevelInputs( ): Record> { const inputs = blockConfig.inputs || {} const subBlocks = Array.isArray(blockConfig.subBlocks) - ? blockConfig.subBlocks.filter((sb) => sb.mode !== 'trigger' && sb.mode !== 'trigger-advanced') + ? blockConfig.subBlocks.filter( + (sb) => !sb.hideFromCopilot && sb.mode !== 'trigger' && sb.mode !== 'trigger-advanced' + ) : [] const opInputs: Record> = {} diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts index 295ba44db98..8ae05a41465 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts @@ -150,6 +150,17 @@ const genericWebhookBlockConfig = { ], } +const mothershipBlockConfig = { + type: 'mothership', + name: 'Sim Chat', + outputs: {}, + subBlocks: [ + { id: 'prompt', type: 'long-input' }, + { id: 'secretScope', type: 'dropdown', hideFromCopilot: true }, + { id: 'mountedSecrets', type: 'dropdown', hideFromCopilot: true }, + ], +} + // Block whose tool selector throws — should fall back to scanning access tools (video_falai). const throwSelectorBlockConfig = { type: 'throw_selector_block', @@ -204,6 +215,7 @@ const blockConfigsByType: Record = { throw_gate_block: throwGateBlockConfig, throw_selector_block: throwSelectorBlockConfig, generic_webhook: genericWebhookBlockConfig, + mothership: mothershipBlockConfig, } vi.mock('@/blocks/registry', () => ({ @@ -358,6 +370,17 @@ describe('validateInputsForBlock', () => { expect(result.errors[0]?.error).toContain('read-only') }) + it('rejects server-only Sim Chat secret-mount policy inputs', () => { + const result = validateInputsForBlock( + 'mothership', + { prompt: 'Keep this', secretScope: 'all', mountedSecrets: ['API_KEY'] }, + 'chat-1' + ) + + expect(result.validInputs).toEqual({ prompt: 'Keep this' }) + expect(result.errors.map((error) => error.field)).toEqual(['secretScope', 'mountedSecrets']) + }) + it('accepts known agent model ids', () => { const result = validateInputsForBlock('agent', { model: 'claude-sonnet-4-6' }, 'agent-1') diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts index 36bc722a0f8..48d44f21dbc 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts @@ -128,6 +128,17 @@ export function validateInputsForBlock( continue } + if (subBlockConfig.hideFromCopilot === true) { + errors.push({ + blockId, + blockType, + field: key, + value, + error: `Field "${key}" on block type "${blockType}" is server-managed and cannot be set by Copilot`, + }) + continue + } + // Note: We do NOT check subBlockConfig.condition here. // Conditions are for UI display logic (show/hide fields in the editor). // For API/Copilot, any valid field in the block schema should be accepted. diff --git a/apps/sim/lib/copilot/tools/workflow-tools.ts b/apps/sim/lib/copilot/tools/workflow-tools.ts index fc750614dfb..c7c6c103d92 100644 --- a/apps/sim/lib/copilot/tools/workflow-tools.ts +++ b/apps/sim/lib/copilot/tools/workflow-tools.ts @@ -1,3 +1,9 @@ +import { isPlainRecord } from '@sim/utils/object' +import { + ASYNC_TOOL_CONFIRMATION_STATUS, + type AsyncConfirmationStatus, +} from '@/lib/copilot/async-runs/lifecycle' + const WORKFLOW_TOOL_NAMES = [ 'run_workflow', 'run_workflow_until_block', @@ -10,3 +16,64 @@ const WORKFLOW_TOOL_NAME_SET = new Set(WORKFLOW_TOOL_NAMES) export function isWorkflowToolName(name: string): boolean { return WORKFLOW_TOOL_NAME_SET.has(name) } + +/** Resolves the workflow target from immutable tool arguments, then the owning Copilot run. */ +export function resolveWorkflowToolTargetId( + args: unknown, + runWorkflowId?: string | null +): string | undefined { + if (isPlainRecord(args) && typeof args.workflowId === 'string' && args.workflowId.length > 0) { + return args.workflowId + } + return typeof runWorkflowId === 'string' && runWorkflowId.length > 0 ? runWorkflowId : undefined +} + +export function getWorkflowToolCompletionExecutionId(data: unknown): string | undefined { + if (!isPlainRecord(data)) return undefined + return typeof data.executionId === 'string' && data.executionId.length > 0 + ? data.executionId + : undefined +} + +export function getWorkflowToolCompletionMessage(status: AsyncConfirmationStatus): string { + if (status === ASYNC_TOOL_CONFIRMATION_STATUS.success) { + return 'Workflow execution completed.' + } + if (status === ASYNC_TOOL_CONFIRMATION_STATUS.cancelled) { + return 'Workflow execution was cancelled.' + } + if (status === ASYNC_TOOL_CONFIRMATION_STATUS.background) { + return 'Workflow execution is continuing in the background.' + } + return 'Workflow execution failed.' +} + +export function getWorkflowToolConfirmationStatus( + status: 'completed' | 'failed' | 'cancelled' +): AsyncConfirmationStatus { + if (status === 'completed') return ASYNC_TOOL_CONFIRMATION_STATUS.success + if (status === 'cancelled') return ASYNC_TOOL_CONFIRMATION_STATUS.cancelled + return ASYNC_TOOL_CONFIRMATION_STATUS.error +} + +export function createStructuralWorkflowToolCompletionData( + status: AsyncConfirmationStatus, + workflowId?: string, + executionId?: string +): Record { + const data: Record = {} + if (status === ASYNC_TOOL_CONFIRMATION_STATUS.success) data.success = true + if ( + status === ASYNC_TOOL_CONFIRMATION_STATUS.error || + status === ASYNC_TOOL_CONFIRMATION_STATUS.cancelled + ) { + data.success = false + } + if (workflowId) data.workflowId = workflowId + if (executionId) data.executionId = executionId + if (status === ASYNC_TOOL_CONFIRMATION_STATUS.cancelled) { + data.reason = 'user_cancelled' + data.cancelledByUser = true + } + return data +} diff --git a/apps/sim/lib/copilot/vfs/serializers.test.ts b/apps/sim/lib/copilot/vfs/serializers.test.ts index daf3c3c8086..4394b746e0b 100644 --- a/apps/sim/lib/copilot/vfs/serializers.test.ts +++ b/apps/sim/lib/copilot/vfs/serializers.test.ts @@ -217,6 +217,44 @@ describe('hosted-key VFS metadata', () => { expect(schema.inputs.apiKey).toBeDefined() expect(schema.toolAuth.search.mode).toBe('hosted_or_byok') }) + + it('omits server-only lifecycle inputs from block schemas', () => { + const block = { + type: 'mothership', + name: 'Sim Chat', + description: 'Talk to Sim', + category: 'blocks', + bgColor: '#000000', + icon: () => null, + subBlocks: [ + { id: 'prompt', title: 'Prompt', type: 'long-input' }, + { + id: 'secretScope', + title: 'Secret access', + type: 'dropdown', + hideFromCopilot: true, + }, + { + id: 'mountedSecrets', + title: 'Secrets', + type: 'dropdown', + hideFromCopilot: true, + }, + ], + tools: { access: [] }, + inputs: { + prompt: { type: 'string' }, + secretScope: { type: 'string' }, + mountedSecrets: { type: 'json' }, + }, + outputs: {}, + } as unknown as BlockConfig + + const schema = JSON.parse(serializeBlockSchema(block)) + + expect(schema.subBlocks.map((subBlock: { id: string }) => subBlock.id)).toEqual(['prompt']) + expect(schema.inputs).toEqual({ prompt: { type: 'string' } }) + }) }) describe('serializeKBMeta', () => { diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts index 0972b7a0d1f..5dd5959a86e 100644 --- a/apps/sim/lib/copilot/vfs/serializers.ts +++ b/apps/sim/lib/copilot/vfs/serializers.ts @@ -573,12 +573,14 @@ export function serializeBlockSchema( const customBlock = isCustomBlockType(block.type) const hosted = options?.hosted ?? isHosted const visibleSubBlocks = block.subBlocks.filter( - (sb) => !isSubBlockHidden(sb, { hosted }) && !(customBlock && sb.hidden) + (sb) => !sb.hideFromCopilot && !isSubBlockHidden(sb, { hosted }) && !(customBlock && sb.hidden) ) const visibleIds = new Set(visibleSubBlocks.map((sb) => sb.id)) const hiddenIds = new Set( block.subBlocks - .filter((sb) => isSubBlockHidden(sb, { hosted }) || (customBlock && sb.hidden)) + .filter( + (sb) => sb.hideFromCopilot || isSubBlockHidden(sb, { hosted }) || (customBlock && sb.hidden) + ) .map((sb) => sb.id) .filter((id) => !visibleIds.has(id)) ) diff --git a/apps/sim/lib/core/async-jobs/types.ts b/apps/sim/lib/core/async-jobs/types.ts index 12b852165f1..9a1ee04aefa 100644 --- a/apps/sim/lib/core/async-jobs/types.ts +++ b/apps/sim/lib/core/async-jobs/types.ts @@ -39,6 +39,8 @@ export interface AsyncExecutionCorrelation { requestId: string source: AsyncExecutionCorrelationSource workflowId: string + /** Server-validated binding for a browser-routed Copilot workflow tool execution. */ + copilotToolCallId?: string triggerType?: string webhookId?: string scheduleId?: string diff --git a/apps/sim/lib/core/utils/records.test.ts b/apps/sim/lib/core/utils/records.test.ts index 80195d71ccc..9bc22e8f07e 100644 --- a/apps/sim/lib/core/utils/records.test.ts +++ b/apps/sim/lib/core/utils/records.test.ts @@ -29,6 +29,14 @@ describe('record normalization utilities', () => { expect(normalizeStringRecord([])).toEqual({}) }) + it('preserves own __proto__ keys without changing the record prototype', () => { + const normalized = normalizeStringRecord(Object.fromEntries([['__proto__', 'secret-value']])) + + expect(Object.hasOwn(normalized, '__proto__')).toBe(true) + expect(normalized.__proto__).toBe('secret-value') + expect(Object.getPrototypeOf(normalized)).toBe(Object.prototype) + }) + it('normalizes record maps by dropping malformed entries', () => { expect( normalizeRecordMap({ diff --git a/apps/sim/lib/core/utils/records.ts b/apps/sim/lib/core/utils/records.ts index b13554b5c54..aea457c67d1 100644 --- a/apps/sim/lib/core/utils/records.ts +++ b/apps/sim/lib/core/utils/records.ts @@ -3,6 +3,15 @@ import { isPlainRecord } from '@sim/utils/object' export type UnknownRecord = Record export type StringRecord = Record +export function setRecordValue(record: Record, key: string, value: unknown): void { + Object.defineProperty(record, key, { + value, + enumerable: true, + configurable: true, + writable: true, + }) +} + /** * Normalizes optional execution context maps to the record shape expected by * internal API contracts. @@ -25,7 +34,11 @@ export function normalizeStringRecord(value: unknown): StringRecord { if (entryValue === undefined || entryValue === null) { continue } - normalized[key] = typeof entryValue === 'string' ? entryValue : String(entryValue) + setRecordValue( + normalized, + key, + typeof entryValue === 'string' ? entryValue : String(entryValue) + ) } return normalized } @@ -41,7 +54,7 @@ export function normalizeRecordMap(value: unknown): Record = {} for (const [key, entryValue] of Object.entries(value)) { if (isPlainRecord(entryValue)) { - normalized[key] = entryValue + setRecordValue(normalized, key, entryValue) } } return normalized @@ -72,7 +85,7 @@ export function normalizeWorkflowVariables(value: unknown): UnknownRecord { const key = id ?? name if (key) { - normalized[key] = variable + setRecordValue(normalized, key, variable) } } diff --git a/apps/sim/lib/credentials/environment.test.ts b/apps/sim/lib/credentials/environment.test.ts index 64c89d058d8..349a94d5db0 100644 --- a/apps/sim/lib/credentials/environment.test.ts +++ b/apps/sim/lib/credentials/environment.test.ts @@ -15,10 +15,93 @@ vi.mock('@/lib/billing/organizations/billing-identity-lock', () => ({ })) import { + getPersonalEnvKeyRawAccess, getWorkspaceEnvKeyAdminAccess, syncPersonalEnvCredentialsForUser, } from '@/lib/credentials/environment' +describe('getPersonalEnvKeyRawAccess', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('returns own values without querying credential grants', async () => { + const result = await getPersonalEnvKeyRawAccess({ + workspaceId: 'ws-1', + userId: 'u-1', + personalOwners: { OWN_KEY: 'u-1' }, + }) + + expect([...result.ownedKeys]).toEqual(['OWN_KEY']) + expect(result.adminKeys.size).toBe(0) + expect(dbChainMockFns.where).not.toHaveBeenCalled() + }) + + it('allows own values and only active admin grants for other personal values', async () => { + queueTableRows(credential, [ + { + envKey: 'SHARED_ADMIN', + envOwnerUserId: 'owner-2', + role: 'admin', + status: 'active', + }, + { + envKey: 'SHARED_MEMBER', + envOwnerUserId: 'owner-3', + role: 'member', + status: 'active', + }, + { + envKey: 'REVOKED_ADMIN', + envOwnerUserId: 'owner-4', + role: 'admin', + status: 'revoked', + }, + ]) + + const result = await getPersonalEnvKeyRawAccess({ + workspaceId: 'ws-1', + userId: 'u-1', + personalOwners: { + OWN_KEY: 'u-1', + SHARED_ADMIN: 'owner-2', + SHARED_MEMBER: 'owner-3', + REVOKED_ADMIN: 'owner-4', + }, + }) + + expect([...result.ownedKeys]).toEqual(['OWN_KEY']) + expect([...result.adminKeys]).toEqual(['SHARED_ADMIN']) + }) + + it('requires the admin grant to belong to the exact effective secret owner', async () => { + queueTableRows(credential, [ + { + envKey: 'COLLISION', + envOwnerUserId: 'owner-a', + role: 'admin', + status: 'active', + }, + { + envKey: 'COLLISION', + envOwnerUserId: 'owner-b', + role: 'member', + status: 'active', + }, + ]) + + const result = await getPersonalEnvKeyRawAccess({ + workspaceId: 'ws-1', + userId: 'u-1', + personalOwners: { COLLISION: 'owner-b' }, + }) + + expect(result.ownedKeys.size).toBe(0) + expect(result.adminKeys.size).toBe(0) + }) +}) + describe('getWorkspaceEnvKeyAdminAccess', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/lib/credentials/environment.ts b/apps/sim/lib/credentials/environment.ts index da4f743cbab..e70be39ebff 100644 --- a/apps/sim/lib/credentials/environment.ts +++ b/apps/sim/lib/credentials/environment.ts @@ -107,6 +107,67 @@ export interface WorkspaceEnvKeyAdminAccess { knownKeys: Set } +export interface PersonalEnvKeyRawAccess { + /** Keys stored in the caller's own personal Secrets catalog. */ + ownedKeys: Set + /** Keys owned by someone else for which the caller is an active credential admin. */ + adminKeys: Set +} + +/** Resolves which personal secret values a workspace viewer may read as plaintext. */ +export async function getPersonalEnvKeyRawAccess(params: { + workspaceId: string + personalOwners: Record + userId: string +}): Promise { + const keys = Object.keys(params.personalOwners) + if (keys.length === 0) return { ownedKeys: new Set(), adminKeys: new Set() } + + const ownedKeys = new Set( + keys.filter((envKey) => params.personalOwners[envKey] === params.userId) + ) + const sharedKeys = keys.filter((envKey) => !ownedKeys.has(envKey)) + if (sharedKeys.length === 0) return { ownedKeys, adminKeys: new Set() } + + const credentialRows = await db + .select({ + envKey: credential.envKey, + envOwnerUserId: credential.envOwnerUserId, + role: credentialMember.role, + status: credentialMember.status, + }) + .from(credential) + .leftJoin( + credentialMember, + and( + eq(credentialMember.credentialId, credential.id), + eq(credentialMember.userId, params.userId) + ) + ) + .where( + and( + eq(credential.workspaceId, params.workspaceId), + eq(credential.type, 'env_personal'), + inArray(credential.envKey, sharedKeys) + ) + ) + + const adminKeys = new Set() + for (const row of credentialRows) { + if ( + row.envKey && + row.envOwnerUserId === params.personalOwners[row.envKey] && + row.envOwnerUserId !== params.userId && + row.role === 'admin' && + row.status === 'active' + ) { + adminKeys.add(row.envKey) + } + } + + return { ownedKeys, adminKeys } +} + /** * For a set of workspace env keys, resolves which the caller may administer * (active `credential_member` with role `admin`) and which already have an diff --git a/apps/sim/lib/credentials/secret-mount-options.test.ts b/apps/sim/lib/credentials/secret-mount-options.test.ts new file mode 100644 index 00000000000..ae688715dbc --- /dev/null +++ b/apps/sim/lib/credentials/secret-mount-options.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import type { WorkspaceCredential } from '@/lib/api/contracts' +import { selectRawMountableSecretNames } from '@/lib/credentials/secret-mount-options' + +function credential( + overrides: Partial & Pick +): WorkspaceCredential { + return { + workspaceId: 'workspace-1', + displayName: overrides.id, + description: null, + providerId: null, + accountId: null, + envKey: null, + envOwnerUserId: null, + createdBy: 'user-1', + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-01T00:00:00.000Z', + ...overrides, + } +} + +describe('selectRawMountableSecretNames', () => { + it('keeps only admin environment credentials and returns unique sorted names', () => { + const credentials = [ + credential({ id: 'workspace-z', type: 'env_workspace', envKey: 'ZETA', role: 'admin' }), + credential({ id: 'personal-a', type: 'env_personal', envKey: 'ALPHA', role: 'admin' }), + credential({ id: 'duplicate-a', type: 'env_workspace', envKey: 'ALPHA', role: 'admin' }), + credential({ id: 'member', type: 'env_workspace', envKey: 'MEMBER', role: 'member' }), + credential({ id: 'oauth', type: 'oauth', envKey: 'OAUTH', role: 'admin' }), + credential({ id: 'missing-key', type: 'env_personal', envKey: null, role: 'admin' }), + ] + + expect(selectRawMountableSecretNames(credentials)).toEqual(['ALPHA', 'ZETA']) + }) +}) diff --git a/apps/sim/lib/credentials/secret-mount-options.ts b/apps/sim/lib/credentials/secret-mount-options.ts new file mode 100644 index 00000000000..d159601ac69 --- /dev/null +++ b/apps/sim/lib/credentials/secret-mount-options.ts @@ -0,0 +1,22 @@ +import type { WorkspaceCredential } from '@/lib/api/contracts' + +/** + * Returns the secret names the current credential-list actor may mount as plaintext. + * The credentials API has already derived workspace-admin and per-credential roles; + * this selector deliberately keeps only environment credentials with effective admin access. + */ +export function selectRawMountableSecretNames(credentials: WorkspaceCredential[]): string[] { + const names = new Set() + + for (const credential of credentials) { + if ( + (credential.type === 'env_workspace' || credential.type === 'env_personal') && + credential.role === 'admin' && + credential.envKey + ) { + names.add(credential.envKey) + } + } + + return [...names].sort() +} diff --git a/apps/sim/lib/environment/utils.test.ts b/apps/sim/lib/environment/utils.test.ts index feb5c95bef0..91f0bdf6b3b 100644 --- a/apps/sim/lib/environment/utils.test.ts +++ b/apps/sim/lib/environment/utils.test.ts @@ -1,16 +1,27 @@ /** * @vitest-environment node */ -import { dbChainMockFns, encryptionMock, encryptionMockFns, resetDbChainMock } from '@sim/testing' +import { environment, workspaceEnvironment } from '@sim/db/schema' +import { + dbChainMockFns, + encryptionMock, + encryptionMockFns, + queueTableRows, + resetDbChainMock, +} from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockCreateWorkspaceEnvCredentials, + mockCheckWorkspaceAccess, + mockGetAccessibleEnvCredentials, mockGetUserEntityPermissions, mockGetWorkspaceEnvKeyAdminAccess, mockRecordAudit, } = vi.hoisted(() => ({ mockCreateWorkspaceEnvCredentials: vi.fn(), + mockCheckWorkspaceAccess: vi.fn(), + mockGetAccessibleEnvCredentials: vi.fn(), mockGetUserEntityPermissions: vi.fn(), mockGetWorkspaceEnvKeyAdminAccess: vi.fn(), mockRecordAudit: vi.fn(), @@ -27,23 +38,87 @@ vi.mock('@sim/audit', () => ({ })) vi.mock('@/lib/credentials/environment', () => ({ createWorkspaceEnvCredentials: mockCreateWorkspaceEnvCredentials, - getAccessibleEnvCredentials: vi.fn(), + getAccessibleEnvCredentials: mockGetAccessibleEnvCredentials, getWorkspaceEnvKeyAdminAccess: mockGetWorkspaceEnvKeyAdminAccess, syncPersonalEnvCredentialsForUser: vi.fn(), })) vi.mock('@/lib/workspaces/permissions/utils', () => ({ - checkWorkspaceAccess: vi.fn(), + checkWorkspaceAccess: mockCheckWorkspaceAccess, getUserEntityPermissions: mockGetUserEntityPermissions, })) import { getEffectiveDecryptedEnv, getEffectiveEnvironmentSnapshot, + getPersonalAndWorkspaceEnv, invalidateEffectiveDecryptedEnvCache, upsertWorkspaceEnvVars, WorkspaceEnvAccessError, } from '@/lib/environment/utils' +describe('getPersonalAndWorkspaceEnv access filtering', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockCheckWorkspaceAccess.mockResolvedValue({ + exists: true, + hasAccess: true, + canWrite: true, + canAdmin: false, + }) + mockGetAccessibleEnvCredentials.mockResolvedValue([]) + encryptionMockFns.mockDecryptSecret.mockImplementation(async (encryptedValue: string) => ({ + decrypted: `plain:${encryptedValue}`, + })) + }) + + it('filters every workspace secret when the caller has zero credential grants', async () => { + queueTableRows(environment, [{ variables: { PERSONAL_KEY: 'personal-cipher' } }]) + queueTableRows(workspaceEnvironment, [{ variables: { WORKSPACE_KEY: 'workspace-cipher' } }]) + + const snapshot = await getPersonalAndWorkspaceEnv('user-1', 'workspace-1') + + expect(snapshot.personalDecrypted).toEqual({ PERSONAL_KEY: 'plain:personal-cipher' }) + expect(snapshot.workspaceDecrypted).toEqual({}) + expect(encryptionMockFns.mockDecryptSecret).toHaveBeenCalledOnce() + }) + + it('preserves legacy workspace secrets without credential rows for workspace admins', async () => { + mockCheckWorkspaceAccess.mockResolvedValue({ + exists: true, + hasAccess: true, + canWrite: true, + canAdmin: true, + }) + queueTableRows(environment, [{ variables: {} }]) + queueTableRows(workspaceEnvironment, [{ variables: { LEGACY_KEY: 'legacy-cipher' } }]) + + const snapshot = await getPersonalAndWorkspaceEnv('admin-1', 'workspace-1') + + expect(snapshot.workspaceDecrypted).toEqual({ LEGACY_KEY: 'plain:legacy-cipher' }) + expect(encryptionMockFns.mockDecryptSecret).toHaveBeenCalledOnce() + }) + + it('preserves shared-personal precedence when an accessible owner shares the same name', async () => { + mockGetAccessibleEnvCredentials.mockResolvedValue([ + { + type: 'env_personal', + envKey: 'SHARED_KEY', + envOwnerUserId: 'owner-2', + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + }, + ]) + queueTableRows(environment, [{ variables: { SHARED_KEY: 'own-cipher' } }]) + queueTableRows(environment, [{ userId: 'owner-2', variables: { SHARED_KEY: 'shared-cipher' } }]) + queueTableRows(workspaceEnvironment, [{ variables: {} }]) + + const snapshot = await getPersonalAndWorkspaceEnv('user-1', 'workspace-1') + + expect(snapshot.personalDecrypted).toEqual({ SHARED_KEY: 'plain:shared-cipher' }) + expect(snapshot.personalOwners).toEqual({ SHARED_KEY: 'owner-2' }) + }) +}) + describe('upsertWorkspaceEnvVars', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/lib/environment/utils.ts b/apps/sim/lib/environment/utils.ts index 37089698158..65d9b89be0b 100644 --- a/apps/sim/lib/environment/utils.ts +++ b/apps/sim/lib/environment/utils.ts @@ -48,6 +48,7 @@ export interface EnvironmentResolutionSnapshot { workspaceEncrypted: Record personalDecrypted: Record workspaceDecrypted: Record + personalOwners: Record conflicts: string[] decryptionFailures: string[] } @@ -75,6 +76,7 @@ function cloneEnvironmentResolutionSnapshot( workspaceEncrypted: { ...snapshot.workspaceEncrypted }, personalDecrypted: { ...snapshot.personalDecrypted }, workspaceDecrypted: { ...snapshot.workspaceDecrypted }, + personalOwners: { ...snapshot.personalOwners }, conflicts: [...snapshot.conflicts], decryptionFailures: [...snapshot.decryptionFailures], } @@ -165,7 +167,7 @@ export async function getPersonalAndWorkspaceEnv( const ownPersonalEncrypted: Record = (personalRows[0]?.variables as any) || {} const allWorkspaceEncrypted: Record = (workspaceRows[0]?.variables as any) || {} - const hasCredentialFiltering = Boolean(workspaceId) && accessibleEnvCredentials.length > 0 + const hasCredentialFiltering = Boolean(workspaceId) const workspaceCredentialKeys = new Set( accessibleEnvCredentials.filter((row) => row.type === 'env_workspace').map((row) => row.envKey) ) @@ -205,6 +207,9 @@ export async function getPersonalAndWorkspaceEnv( let personalEncrypted: Record = ownPersonalEncrypted let workspaceEncrypted: Record = allWorkspaceEncrypted + const personalOwners: Record = Object.fromEntries( + Object.keys(ownPersonalEncrypted).map((envKey) => [envKey, userId]) + ) if (hasCredentialFiltering) { personalEncrypted = { ...ownPersonalEncrypted } @@ -213,14 +218,17 @@ export async function getPersonalAndWorkspaceEnv( const encryptedValue = ownerVariables?.[envKey] if (encryptedValue) { personalEncrypted[envKey] = encryptedValue + personalOwners[envKey] = ownerUserId } } - workspaceEncrypted = Object.fromEntries( - Object.entries(allWorkspaceEncrypted).filter(([envKey]) => - workspaceCredentialKeys.has(envKey) - ) - ) + workspaceEncrypted = workspaceCanAdmin + ? { ...allWorkspaceEncrypted } + : Object.fromEntries( + Object.entries(allWorkspaceEncrypted).filter(([envKey]) => + workspaceCredentialKeys.has(envKey) + ) + ) } const decryptionFailures: string[] = [] @@ -268,6 +276,7 @@ export async function getPersonalAndWorkspaceEnv( workspaceEncrypted, personalDecrypted, workspaceDecrypted, + personalOwners, conflicts, decryptionFailures, } diff --git a/apps/sim/lib/logs/execution/logger.test.ts b/apps/sim/lib/logs/execution/logger.test.ts index f79f83d915d..ef73b1c11b6 100644 --- a/apps/sim/lib/logs/execution/logger.test.ts +++ b/apps/sim/lib/logs/execution/logger.test.ts @@ -380,6 +380,42 @@ describe('ExecutionLogger', () => { expect(compacted.traceSpans?.[0]?.children?.[0]).not.toHaveProperty('input') }) + test('retains the trusted Copilot binding in metadata-only compaction', () => { + const loggerInstance = new ExecutionLogger() as unknown as { + compactExecutionDataForStorage( + executionData: WorkflowExecutionLog['executionData'], + executionId: string + ): WorkflowExecutionLog['executionData'] + } + const correlation = { + executionId: 'execution-metadata-only', + requestId: 'request-1', + source: 'workflow' as const, + workflowId: 'workflow-1', + copilotToolCallId: 'tool-call-1', + } + + const compacted = loggerInstance.compactExecutionDataForStorage( + { + environment: { + variables: { OVERSIZED: 'x'.repeat(3.5 * 1024 * 1024) }, + workflowId: 'workflow-1', + executionId: 'execution-metadata-only', + userId: 'user-1', + workspaceId: 'workspace-1', + }, + correlation, + hasTraceSpans: false, + traceSpanCount: 0, + }, + 'execution-metadata-only' + ) + + expect(compacted.executionDataTruncated).toBe(true) + expect(compacted.correlation).toEqual(correlation) + expect(compacted).not.toHaveProperty('environment') + }) + test('retains tool-call structure when aggregate trace content exceeds the compaction cap', () => { const loggerInstance = new ExecutionLogger() as unknown as { compactExecutionDataForStorage( diff --git a/apps/sim/lib/logs/execution/logger.ts b/apps/sim/lib/logs/execution/logger.ts index 897275d78f8..5394919c439 100644 --- a/apps/sim/lib/logs/execution/logger.ts +++ b/apps/sim/lib/logs/execution/logger.ts @@ -491,6 +491,7 @@ export class ExecutionLogger implements IExecutionLoggerService { ...(executionData.billingAttribution ? { billingAttribution: executionData.billingAttribution } : {}), + ...(executionData.correlation ? { correlation: executionData.correlation } : {}), hasTraceSpans: executionData.hasTraceSpans, traceSpanCount: executionData.traceSpanCount, tokens: executionData.tokens, diff --git a/apps/sim/lib/logs/execution/logging-session.test.ts b/apps/sim/lib/logs/execution/logging-session.test.ts index b4e79338534..eb1616e0869 100644 --- a/apps/sim/lib/logs/execution/logging-session.test.ts +++ b/apps/sim/lib/logs/execution/logging-session.test.ts @@ -92,7 +92,12 @@ vi.mock('@/lib/logs/execution/logging-factory', () => ({ models: {}, }), createEnvironmentObject: vi.fn(), - createTriggerObject: vi.fn(), + createTriggerObject: vi.fn((type: string, additionalData?: Record) => ({ + type, + source: type, + timestamp: '2026-01-01T00:00:00.000Z', + ...(additionalData ? { data: additionalData } : {}), + })), loadDeployedWorkflowStateForLogging: vi.fn(), loadWorkflowStateForExecution: loadWorkflowStateForExecutionMock, })) @@ -234,6 +239,40 @@ describe('LoggingSession start snapshots', () => { ) }) + it('persists only the server-validated execution correlation', async () => { + const session = new LoggingSession('workflow-1', 'execution-1', 'copilot', 'req-1') + const trustedCorrelation = { + executionId: 'execution-1', + requestId: 'req-1', + source: 'workflow', + workflowId: 'workflow-1', + triggerType: 'copilot', + copilotToolCallId: 'trusted-tool-call', + } + session.setTrustedExecutionCorrelation(trustedCorrelation) + + await session.start({ + userId: 'user-1', + workspaceId: 'workspace-1', + triggerData: { + correlation: { + executionId: 'submitted-execution', + requestId: 'submitted-request', + source: 'workflow', + copilotToolCallId: 'submitted-tool-call', + }, + }, + }) + + expect(startWorkflowExecutionMock).toHaveBeenCalledWith( + expect.objectContaining({ + trigger: expect.objectContaining({ + data: expect.objectContaining({ correlation: trustedCorrelation }), + }), + }) + ) + }) + it('does not create a log when hydrating a persisted execution for completion', async () => { const session = new LoggingSession('workflow-1', 'execution-existing', 'manual', 'req-existing') diff --git a/apps/sim/lib/logs/execution/logging-session.ts b/apps/sim/lib/logs/execution/logging-session.ts index 9703d5b57bd..bf96ef4022d 100644 --- a/apps/sim/lib/logs/execution/logging-session.ts +++ b/apps/sim/lib/logs/execution/logging-session.ts @@ -185,6 +185,7 @@ export class LoggingSession { private environment?: ExecutionEnvironment private workflowState?: WorkflowState private correlation?: NonNullable['correlation'] + private trustedExecutionCorrelation?: NonNullable['correlation'] private actorUserId: string | null = null private billingAttribution?: BillingAttributionSnapshot private isResume = false @@ -225,6 +226,13 @@ export class LoggingSession { this.resolvedSecretTraceRegistry = registry } + /** Adds server-validated lifecycle correlation without exposing it to executor metadata. */ + setTrustedExecutionCorrelation( + correlation: NonNullable['correlation']> + ): void { + this.trustedExecutionCorrelation = { ...correlation } + } + /** Adds the trusted execution-ref scope needed to rewrite offloaded trace content. */ setTraceLargeValueAccess(context: LargeValueStoreContext): void { this.traceLargeValueAccess = context @@ -618,8 +626,11 @@ export class LoggingSession { } try { - this.trigger = createTriggerObject(this.triggerType, triggerData) - this.correlation = triggerData?.correlation + const effectiveTriggerData = this.trustedExecutionCorrelation + ? { ...triggerData, correlation: this.trustedExecutionCorrelation } + : triggerData + this.trigger = createTriggerObject(this.triggerType, effectiveTriggerData) + this.correlation = effectiveTriggerData?.correlation this.environment = createEnvironmentObject( this.workflowId, this.executionId, @@ -1081,8 +1092,11 @@ export class LoggingSession { deploymentVersionId, workflowState, } = params - this.trigger = createTriggerObject(this.triggerType, triggerData) - this.correlation = triggerData?.correlation + const effectiveTriggerData = this.trustedExecutionCorrelation + ? { ...triggerData, correlation: this.trustedExecutionCorrelation } + : triggerData + this.trigger = createTriggerObject(this.triggerType, effectiveTriggerData) + this.correlation = effectiveTriggerData?.correlation this.environment = createEnvironmentObject( this.workflowId, this.executionId, diff --git a/apps/sim/lib/logs/execution/trace-secret-projection.ts b/apps/sim/lib/logs/execution/trace-secret-projection.ts index 0632dc231c7..9164002569d 100644 --- a/apps/sim/lib/logs/execution/trace-secret-projection.ts +++ b/apps/sim/lib/logs/execution/trace-secret-projection.ts @@ -19,18 +19,18 @@ import type { LargeValueStoreContext } from '@/lib/execution/payloads/store' import { materializeLargeValueRef, storeLargeValue } from '@/lib/execution/payloads/store' import type { ToolCall, TraceSpan } from '@/lib/logs/types' import type { IterationToolCall, ProviderTimingSegment } from '@/executor/types' -import type { - ResolvedSecretTraceMatch, - ResolvedSecretTraceRegistry, -} from '@/executor/utils/resolved-secret-trace-registry' +import { + containsResolvedSecret, + createResolvedSecretMatcher, + projectResolvedSecretContent, + type ResolvedSecretMatcher, +} from '@/executor/utils/resolved-secret-content-projection' +import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' const logger = createLogger('TraceSecretProjection') const REF_CONCURRENCY = 4 const MAX_CONTENT_NODES = 100_000 const MAX_CONTENT_DEPTH = 100 -const MAX_MATCHER_NODES = 250_000 -const MAX_SECRET_LITERAL_LENGTH = 64 * 1024 -const MAX_MATCH_EVENTS = 1_000_000 const MAX_LARGE_VALUES = 1_024 const MAX_LARGE_VALUE_CHAIN_DEPTH = 32 const MAX_LARGE_MANIFEST_CHUNKS = MAX_LARGE_VALUES @@ -65,25 +65,8 @@ const LARGE_ARRAY_MANIFEST_KEYS = new Set([ ]) const LARGE_ARRAY_MANIFEST_CHUNK_KEYS = new Set(['ref', 'count', 'byteSize']) -interface SecretReplacement { - plaintext: string - replacement: string -} - -interface SecretTrieNode { - children: Map - failure?: SecretTrieNode - outputLink?: SecretTrieNode - replacement?: SecretReplacement -} - -interface SecretMatcher { - root: SecretTrieNode - maxPatternLength: number -} - interface ProjectionContext { - matcher: SecretMatcher + matcher: ResolvedSecretMatcher store: LargeValueStoreContext allowLargeValueWrites: boolean safeLargeValues: WeakSet @@ -113,13 +96,8 @@ interface TraversalState { ancestors: WeakSet } -interface SanitizationTraversalState extends TraversalState { - outputBytes: number - maxBytes: number -} - interface PlaintextInvariantContext { - matcher: SecretMatcher + matcher: ResolvedSecretMatcher safeLargeValues: WeakSet /** Valid refs are trusted only after the full projector has already rewritten them. */ allowVerifiedLargeValues?: boolean @@ -150,95 +128,8 @@ class TraceSecretProjectionError extends Error { } } -function compareStrings(left: string, right: string): number { - if (left < right) return -1 - if (left > right) return 1 - return 0 -} - -function normalizeReplacements(matches: readonly ResolvedSecretTraceMatch[]): SecretReplacement[] { - const replacementByPlaintext = new Map() - - for (const match of matches) { - if (!match.plaintext) continue - const current = replacementByPlaintext.get(match.plaintext) - if (current === undefined || compareStrings(match.replacement, current) < 0) { - replacementByPlaintext.set(match.plaintext, match.replacement) - } - } - - const provisional = [...replacementByPlaintext.keys()] - .map((plaintext) => { - const requested = replacementByPlaintext.get(plaintext) ?? '' - return { plaintext, replacement: requested } - }) - .sort( - (left, right) => - right.plaintext.length - left.plaintext.length || - compareStrings(left.replacement, right.replacement) || - compareStrings(left.plaintext, right.plaintext) - ) - - const detector = createSecretMatcher( - provisional.map(({ plaintext }) => ({ plaintext, replacement: '' })) - ) - return provisional.map(({ plaintext, replacement }) => ({ - plaintext, - replacement: containsSecret(replacement, detector) ? '' : replacement, - })) -} - -function createSecretMatcher(replacements: readonly SecretReplacement[]): SecretMatcher { - const root: SecretTrieNode = { children: new Map() } - root.failure = root - let nodeCount = 1 - let maxPatternLength = 0 - for (const replacement of replacements) { - if (replacement.plaintext.length > MAX_SECRET_LITERAL_LENGTH) { - throw new TraceSecretProjectionError('Secret literal exceeds the matcher size limit') - } - maxPatternLength = Math.max(maxPatternLength, replacement.plaintext.length) - let node = root - for (let index = 0; index < replacement.plaintext.length; index += 1) { - const character = replacement.plaintext[index] - let child = node.children.get(character) - if (!child) { - child = { children: new Map() } - node.children.set(character, child) - nodeCount += 1 - if (nodeCount > MAX_MATCHER_NODES) { - throw new TraceSecretProjectionError('Secret matcher node limit exceeded') - } - } - node = child - } - node.replacement = replacement - } - - const queue: SecretTrieNode[] = [] - for (const child of root.children.values()) { - child.failure = root - queue.push(child) - } - for (let cursor = 0; cursor < queue.length; cursor += 1) { - const node = queue[cursor] - for (const [character, child] of node.children) { - let fallback = node.failure ?? root - while (fallback !== root && !fallback.children.has(character)) { - fallback = fallback.failure ?? root - } - const transition = fallback.children.get(character) - child.failure = transition && transition !== child ? transition : root - child.outputLink = child.failure.replacement ? child.failure : child.failure.outputLink - queue.push(child) - } - } - - return { root, maxPatternLength } -} - function createProjectionContext( - matcher: SecretMatcher, + matcher: ResolvedSecretMatcher, store: LargeValueStoreContext, allowLargeValueWrites: boolean ): ProjectionContext { @@ -260,108 +151,6 @@ function createProjectionContext( } } -function advanceMatcher( - matcher: SecretMatcher, - node: SecretTrieNode, - character: string -): SecretTrieNode { - let current = node - while (current !== matcher.root && !current.children.has(character)) { - current = current.failure ?? matcher.root - } - return current.children.get(character) ?? matcher.root -} - -function containsSecret(value: string, matcher: SecretMatcher): boolean { - let node = matcher.root - for (let index = 0; index < value.length; index += 1) { - node = advanceMatcher(matcher, node, value[index]) - if (node.replacement || node.outputLink) return true - } - return false -} - -function sanitizeString( - value: string, - matcher: SecretMatcher, - maxBytes = MAX_INLINE_MATERIALIZATION_BYTES -): string { - if (maxBytes < 0) { - throw new TraceSecretProjectionError('Sanitized trace string exceeds the size limit') - } - if (Buffer.byteLength(value, 'utf8') > maxBytes) { - throw new TraceSecretProjectionError('Trace string exceeds the size limit') - } - if (matcher.maxPatternLength === 0 || value.length === 0) return value - - let emitCursor = 0 - let literalStart = 0 - let outputBytes = 0 - let matchEvents = 0 - const chunks: string[] = [] - const windowSize = matcher.maxPatternLength - const slotStarts = new Int32Array(windowSize) - const slotEnds = new Int32Array(windowSize) - slotStarts.fill(-1) - const slotReplacements = new Array(windowSize) - - const append = (chunk: string): void => { - if (!chunk) return - outputBytes += Buffer.byteLength(chunk, 'utf8') - if (outputBytes > maxBytes) { - throw new TraceSecretProjectionError('Sanitized trace string exceeds the size limit') - } - const lastIndex = chunks.length - 1 - if (lastIndex >= 0 && chunks[lastIndex].length + chunk.length <= 64 * 1024) { - chunks[lastIndex] += chunk - } else { - chunks.push(chunk) - } - } - - const finalizeThrough = (limit: number): void => { - while (emitCursor <= limit && emitCursor < value.length) { - const slot = emitCursor % windowSize - if (slotStarts[slot] === emitCursor && slotReplacements[slot] !== undefined) { - append(value.slice(literalStart, emitCursor)) - append(slotReplacements[slot] ?? '') - emitCursor = slotEnds[slot] - literalStart = emitCursor - } else { - emitCursor += 1 - } - } - } - - let node = matcher.root - for (let index = 0; index < value.length; index += 1) { - node = advanceMatcher(matcher, node, value[index]) - let outputNode: SecretTrieNode | undefined = node.replacement ? node : node.outputLink - while (outputNode?.replacement) { - matchEvents += 1 - if (matchEvents > MAX_MATCH_EVENTS) { - throw new TraceSecretProjectionError('Secret matcher event limit exceeded') - } - const start = index - outputNode.replacement.plaintext.length + 1 - if (start >= emitCursor) { - const slot = start % windowSize - const end = index + 1 - if (slotStarts[slot] !== start || end > slotEnds[slot]) { - slotStarts[slot] = start - slotEnds[slot] = end - slotReplacements[slot] = outputNode.replacement.replacement - } - } - outputNode = outputNode.outputLink - } - finalizeThrough(index - matcher.maxPatternLength + 1) - } - - finalizeThrough(value.length - 1) - append(value.slice(literalStart)) - return chunks.join('') -} - function visitNode(state: TraversalState, depth: number): void { state.nodes += 1 if (state.nodes > MAX_CONTENT_NODES) { @@ -591,75 +380,6 @@ function getLargeValueCandidate(value: unknown): LargeValueCandidate | undefined return value as LargeArrayManifest } -function sanitizeInlineValue( - value: unknown, - matcher: SecretMatcher, - safeLargeValues: WeakSet, - state: SanitizationTraversalState, - depth = 0 -): unknown { - visitNode(state, depth) - if (typeof value === 'string') { - const sanitized = sanitizeString(value, matcher, state.maxBytes - state.outputBytes) - state.outputBytes += Buffer.byteLength(sanitized, 'utf8') - return sanitized - } - if (value === null || typeof value === 'number' || typeof value === 'boolean') { - const rendered = String(value) - if (!containsSecret(rendered, matcher)) return value - const sanitized = sanitizeString(rendered, matcher, state.maxBytes - state.outputBytes) - state.outputBytes += Buffer.byteLength(sanitized, 'utf8') - return sanitized - } - if (value === undefined) return value - if (typeof value !== 'object') { - throw new TraceSecretProjectionError('Unsupported trace content value') - } - const largeValue = getLargeValueCandidate(value) - if (largeValue) { - if (!safeLargeValues.has(value as object)) { - throw new TraceSecretProjectionError('Trace content contains an unverified large value') - } - return value - } - if (!Array.isArray(value) && !isPlainRecord(value)) { - throw new TraceSecretProjectionError('Unsupported trace content object') - } - - enterObject(value, state) - try { - if (Array.isArray(value)) { - assertArrayFitsTraversal(value, state) - const sanitized = new Array(value.length) - for (const [index, item] of arrayDataEntries(value)) { - sanitized[index] = sanitizeInlineValue(item, matcher, safeLargeValues, state, depth + 1) - } - return sanitized - } - - const prototype = Object.getPrototypeOf(value) - const sanitized = Object.create(prototype) as Record - const sanitizedKeys = new Set() - for (const [key, item] of enumerableDataEntries(value)) { - const sanitizedKey = sanitizeString(key, matcher, state.maxBytes - state.outputBytes) - state.outputBytes += Buffer.byteLength(sanitizedKey, 'utf8') - if (sanitizedKeys.has(sanitizedKey)) { - throw new TraceSecretProjectionError('Secret replacement caused an object-key collision') - } - sanitizedKeys.add(sanitizedKey) - Object.defineProperty(sanitized, sanitizedKey, { - value: sanitizeInlineValue(item, matcher, safeLargeValues, state, depth + 1), - enumerable: true, - configurable: true, - writable: true, - }) - } - return sanitized - } finally { - leaveObject(value, state) - } -} - function collectLargeValues( value: unknown, refs: object[], @@ -840,12 +560,13 @@ async function sanitizeMaterializedValue( withinRefWorker = false ): Promise { const withSafeRefs = await replaceLargeValues(value, context, path, withinRefWorker) - return sanitizeInlineValue(withSafeRefs, context.matcher, context.safeLargeValues, { - nodes: 0, - ancestors: new WeakSet(), - outputBytes: 0, - maxBytes, + const projection = projectResolvedSecretContent(withSafeRefs, context.matcher, maxBytes, { + isOpaqueSafeObject: (candidate) => context.safeLargeValues.has(candidate), }) + if (!projection.safe) { + throw new TraceSecretProjectionError('Trace content could not be sanitized') + } + return projection.value } async function storeSanitizedLargeValue( @@ -1473,13 +1194,13 @@ function assertNoPlaintext( ): void { visitNode(state, depth) if (typeof value === 'string') { - if (containsSecret(value, context.matcher)) { + if (containsResolvedSecret(value, context.matcher)) { throw new TraceSecretProjectionError('Sanitized trace content still contains a secret') } return } if (value === null || typeof value === 'number' || typeof value === 'boolean') { - if (containsSecret(String(value), context.matcher)) { + if (containsResolvedSecret(String(value), context.matcher)) { throw new TraceSecretProjectionError('Sanitized trace primitive still contains a secret') } return @@ -1519,7 +1240,7 @@ function assertNoPlaintext( return } for (const [key, item] of enumerableDataEntries(value)) { - if (containsSecret(key, context.matcher)) { + if (containsResolvedSecret(key, context.matcher)) { throw new TraceSecretProjectionError('Sanitized trace key still contains a secret') } assertNoPlaintext(item, context, state, depth + 1) @@ -1746,7 +1467,7 @@ async function verifyPostTransformLargeValues( async function assertPostTransformTraceSpansAreSafe( traceSpans: TraceSpan[], - matcher: SecretMatcher, + matcher: ResolvedSecretMatcher, store: LargeValueStoreContext ): Promise { const projection = createProjectionContext(matcher, store, false) @@ -1788,14 +1509,10 @@ export async function enforceTraceSpanSecretInvariant( try { if (!options.registry?.isComplete()) return structuralOnlyTraceSpans(traceSpans) - const replacements = normalizeReplacements(options.registry.getActiveMatches()) - if (replacements.length === 0) return traceSpans + const matcher = createResolvedSecretMatcher(options.registry.getActiveMatches()) + if (!matcher) return traceSpans - await assertPostTransformTraceSpansAreSafe( - traceSpans, - createSecretMatcher(replacements), - options.store - ) + await assertPostTransformTraceSpansAreSafe(traceSpans, matcher, options.store) return traceSpans } catch { logger.warn('Trace secret invariant failed; retaining structural spans only') @@ -1816,11 +1533,11 @@ export async function projectTraceSpansForSecrets( } try { - const replacements = normalizeReplacements(options.registry.getActiveMatches()) - if (replacements.length === 0) return cloneTraceSpansForProjection(traceSpans) + const matcher = createResolvedSecretMatcher(options.registry.getActiveMatches()) + if (!matcher) return cloneTraceSpansForProjection(traceSpans) const context = createProjectionContext( - createSecretMatcher(replacements), + matcher, options.store, options.allowLargeValueWrites !== false ) diff --git a/apps/sim/lib/logs/execution/trace-store.test.ts b/apps/sim/lib/logs/execution/trace-store.test.ts index b1341811878..5655bbe877b 100644 --- a/apps/sim/lib/logs/execution/trace-store.test.ts +++ b/apps/sim/lib/logs/execution/trace-store.test.ts @@ -3,15 +3,27 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { decryptSecretMock } = vi.hoisted(() => ({ +const { decryptSecretMock, materializeLargeValueRefMock, storeLargeValueMock } = vi.hoisted(() => ({ decryptSecretMock: vi.fn(), + materializeLargeValueRefMock: vi.fn(), + storeLargeValueMock: vi.fn(), })) vi.mock('@/lib/core/security/encryption', () => ({ decryptSecret: decryptSecretMock, })) -import { projectExecutionDataForDisplay } from '@/lib/logs/execution/trace-store' +vi.mock('@/lib/execution/payloads/store', () => ({ + materializeLargeValueRef: materializeLargeValueRefMock, + storeLargeValue: storeLargeValueMock, +})) + +import { + externalizeExecutionData, + materializeExecutionData, + projectExecutionDataForDisplay, + TRACE_STORE_REF_KEY, +} from '@/lib/logs/execution/trace-store' const CONTEXT = { workspaceId: 'workspace-1', @@ -25,6 +37,55 @@ beforeEach(() => { decryptSecretMock.mockResolvedValue({ decrypted: '1234' }) }) +describe('execution data storage', () => { + it('keeps the trusted Copilot binding when an externalized payload is unavailable', async () => { + const correlation = { copilotToolCallId: 'tool-call-1' } + const ref = { + __simLargeValueRef: true, + version: 1, + id: 'lv_bbbbbbbbbbbb', + kind: 'object', + size: 128, + key: 'execution/workspace-1/workflow-1/execution-1/large-value-lv_bbbbbbbbbbbb.json', + executionId: 'execution-1', + preview: { unsafe: 'must-not-remain-inline' }, + } as const + storeLargeValueMock.mockResolvedValue(ref) + materializeLargeValueRefMock.mockRejectedValue(new Error('object unavailable')) + + const slim = await externalizeExecutionData( + { + correlation, + hasTraceSpans: true, + traceSpanCount: 2, + finalOutput: { unsafe: 'must-not-remain-inline' }, + }, + CONTEXT + ) + + expect(slim).toEqual({ + [TRACE_STORE_REF_KEY]: { + __simLargeValueRef: true, + version: 1, + id: 'lv_bbbbbbbbbbbb', + kind: 'object', + size: 128, + key: 'execution/workspace-1/workflow-1/execution-1/large-value-lv_bbbbbbbbbbbb.json', + executionId: 'execution-1', + }, + correlation, + hasTraceSpans: true, + traceSpanCount: 2, + }) + + await expect(materializeExecutionData(slim, CONTEXT)).resolves.toEqual({ + correlation, + hasTraceSpans: true, + traceSpanCount: 2, + }) + }) +}) + describe('projectExecutionDataForDisplay', () => { it('projects persisted output, input, errors, and spans from trusted provenance', async () => { const executionData = { diff --git a/apps/sim/lib/logs/execution/trace-store.ts b/apps/sim/lib/logs/execution/trace-store.ts index 16bf3b6cd75..f429bade600 100644 --- a/apps/sim/lib/logs/execution/trace-store.ts +++ b/apps/sim/lib/logs/execution/trace-store.ts @@ -20,14 +20,13 @@ export const TRACE_STORE_REF_KEY = 'traceStoreRef' /** * The only metadata kept inline on the slim row (everything else lives in the - * externalized object). These two describe trace presence/count and uniquely - * survive object expiry — so a reader can still report "trace data expired (N - * spans)" after retention without an object fetch. All other fields + * externalized object). Trace presence/count survives object expiry for log + * diagnostics, while correlation preserves the server-issued binding used to + * authenticate terminal Copilot workflow-tool executions. All other fields * (environment, trigger, tokens, models, truncation flags, and of course the - * heavy payloads) are in the stored object and recovered on materialize, so - * keeping them inline too would just be duplication. + * heavy payloads) are recovered from the stored object. */ -const INLINE_MARKER_KEYS = ['hasTraceSpans', 'traceSpanCount'] as const +const INLINE_MARKER_KEYS = ['hasTraceSpans', 'traceSpanCount', 'correlation'] as const /** * Read-path context. Resolves an externalized payload by storage key, authorized diff --git a/apps/sim/lib/mothership/inbox/executor.test.ts b/apps/sim/lib/mothership/inbox/executor.test.ts new file mode 100644 index 00000000000..f8231124a92 --- /dev/null +++ b/apps/sim/lib/mothership/inbox/executor.test.ts @@ -0,0 +1,181 @@ +/** + * @vitest-environment node + */ +import { + dbChainMock, + dbChainMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckWorkspaceAccess, + mockGetUserEntityPermissions, + mockRunHeadlessCopilotLifecycle, + mockSendInboxResponse, +} = vi.hoisted(() => ({ + mockCheckWorkspaceAccess: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), + mockRunHeadlessCopilotLifecycle: vi.fn(), + mockSendInboxResponse: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) + +vi.mock('@/lib/auth/ban', () => ({ + getActivelyBannedUserIds: vi.fn().mockResolvedValue([]), + isEmailBlocked: vi.fn().mockResolvedValue(false), +})) + +vi.mock('@/lib/billing/core/billing-attribution', () => ({ + resolveBillingAttribution: vi.fn().mockResolvedValue({}), +})) + +vi.mock('@/lib/copilot/chat/lifecycle', () => ({ + resolveOrCreateChat: vi.fn(), +})) + +vi.mock('@/lib/copilot/chat/messages-store', () => ({ + appendCopilotChatMessages: vi.fn(), +})) + +vi.mock('@/lib/copilot/chat/payload', () => ({ + buildIntegrationToolSchemas: vi.fn().mockResolvedValue([]), +})) + +vi.mock('@/lib/copilot/chat/persisted-message', () => ({ + buildPersistedAssistantMessage: vi.fn().mockReturnValue({ id: 'assistant-message' }), + buildPersistedUserMessage: vi.fn().mockReturnValue({ id: 'user-message' }), +})) + +vi.mock('@/lib/copilot/chat/workspace-context', () => ({ + generateWorkspaceContext: vi.fn().mockResolvedValue({}), +})) + +vi.mock('@/lib/copilot/chat-status', () => ({ + chatPubSub: { publishStatusChanged: vi.fn() }, +})) + +vi.mock('@/lib/copilot/entitlements', () => ({ + computeWorkspaceEntitlements: vi.fn().mockResolvedValue([]), +})) + +vi.mock('@/lib/copilot/request/lifecycle/headless', () => ({ + runHeadlessCopilotLifecycle: mockRunHeadlessCopilotLifecycle, +})) + +vi.mock('@/lib/copilot/request/lifecycle/start', () => ({ + requestChatTitle: vi.fn(), +})) + +vi.mock('@/lib/core/config/env-flags', () => ({ + isDocSandboxEnabled: false, + isHosted: true, +})) + +vi.mock('@/lib/mothership/inbox/agentmail-client', () => ({})) + +vi.mock('@/lib/mothership/inbox/response', () => ({ + sendInboxResponse: mockSendInboxResponse, +})) + +vi.mock('@/lib/uploads/core/storage-service', () => ({ + uploadFile: vi.fn(), +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + checkWorkspaceAccess: mockCheckWorkspaceAccess, + getUserEntityPermissions: mockGetUserEntityPermissions, +})) + +vi.mock('@/lib/workspaces/utils', () => ({ + getWorkspaceBilledAccountUserId: vi.fn().mockResolvedValue('owner-1'), +})) + +import { executeInboxTask } from '@/lib/mothership/inbox/executor' + +const INBOX_TASK = { + id: 'task-1', + workspaceId: 'workspace-1', + status: 'received', + fromEmail: 'sender@example.com', + fromName: 'Sender', + subject: 'Task', + bodyPreview: 'Please do this', + bodyText: 'Please do this', + bodyHtml: null, + hasAttachments: false, + agentmailMessageId: null, + chatId: 'chat-1', +} + +const WORKSPACE = { + id: 'workspace-1', + ownerId: 'owner-1', + inboxProviderId: 'provider-1', + inboxSecretScope: 'selected', + inboxMountedSecrets: ['INBOX_KEY'], +} + +describe('Inbox raw-secret actor', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockCheckWorkspaceAccess.mockResolvedValue({ permission: 'write' }) + mockRunHeadlessCopilotLifecycle.mockResolvedValue({ + success: true, + content: 'done', + contentBlocks: [], + toolCalls: [], + chatId: 'chat-1', + }) + mockSendInboxResponse.mockResolvedValue('response-1') + dbChainMockFns.returning + .mockResolvedValueOnce([{ id: 'task-1' }]) + .mockResolvedValueOnce([{ model: 'claude-opus-4-8' }]) + }) + + it('gives a workspace member their own raw-secret authority', async () => { + queueTableRows(schemaMock.mothershipInboxTask, [INBOX_TASK]) + queueTableRows(schemaMock.workspace, [WORKSPACE]) + queueTableRows(schemaMock.user, [{ id: 'member-1' }]) + mockGetUserEntityPermissions.mockResolvedValue('write') + + await executeInboxTask('task-1') + + expect(mockRunHeadlessCopilotLifecycle).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + userId: 'member-1', + secretActorUserId: 'member-1', + secretMountPolicy: { + secretScope: 'selected', + mountedSecrets: ['INBOX_KEY'], + }, + }) + ) + }) + + it('keeps owner execution fallback but removes raw-secret authority for an external sender', async () => { + queueTableRows(schemaMock.mothershipInboxTask, [INBOX_TASK]) + queueTableRows(schemaMock.workspace, [WORKSPACE]) + queueTableRows(schemaMock.user, []) + + await executeInboxTask('task-1') + + expect(mockRunHeadlessCopilotLifecycle).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + userId: 'owner-1', + secretActorUserId: null, + secretMountPolicy: { + secretScope: 'selected', + mountedSecrets: ['INBOX_KEY'], + }, + }) + ) + expect(mockGetUserEntityPermissions).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/mothership/inbox/executor.ts b/apps/sim/lib/mothership/inbox/executor.ts index 21fcc48c286..4359130a79d 100644 --- a/apps/sim/lib/mothership/inbox/executor.ts +++ b/apps/sim/lib/mothership/inbox/executor.ts @@ -18,6 +18,7 @@ import { computeWorkspaceEntitlements } from '@/lib/copilot/entitlements' import { runHeadlessCopilotLifecycle } from '@/lib/copilot/request/lifecycle/headless' import { requestChatTitle } from '@/lib/copilot/request/lifecycle/start' import type { OrchestratorResult } from '@/lib/copilot/request/types' +import { normalizeSecretMountPolicy } from '@/lib/copilot/secret-mount-policy' import { isDocSandboxEnabled, isHosted } from '@/lib/core/config/env-flags' import * as agentmail from '@/lib/mothership/inbox/agentmail-client' import { formatEmailAsMessage } from '@/lib/mothership/inbox/format' @@ -64,6 +65,8 @@ export async function executeInboxTask(taskId: string): Promise { id: workspace.id, ownerId: workspace.ownerId, inboxProviderId: workspace.inboxProviderId, + inboxSecretScope: workspace.inboxSecretScope, + inboxMountedSecrets: workspace.inboxMountedSecrets, }) .from(workspace) .where(eq(workspace.id, inboxTask.workspaceId)) @@ -82,14 +85,15 @@ export async function executeInboxTask(taskId: string): Promise { let responseSent = false try { - const [[claimed], userId] = await Promise.all([ + const [[claimed], actor] = await Promise.all([ db .update(mothershipInboxTask) .set({ status: 'processing', processingStartedAt: new Date() }) .where(and(eq(mothershipInboxTask.id, taskId), eq(mothershipInboxTask.status, 'received'))) .returning({ id: mothershipInboxTask.id }), - resolveUserId(inboxTask.fromEmail, ws), + resolveInboxExecutionActor(inboxTask.fromEmail, ws), ]) + const userId = actor.executionUserId if (!claimed) { logger.info('Task already claimed by another execution, skipping', { taskId }) @@ -252,6 +256,12 @@ export async function executeInboxTask(taskId: string): Promise { autoExecuteTools: true, interactive: false, billingAttribution, + ...(userPermission ? { userPermission } : {}), + secretActorUserId: actor.secretActorUserId, + secretMountPolicy: normalizeSecretMountPolicy({ + secretScope: ws.inboxSecretScope, + mountedSecrets: ws.inboxMountedSecrets, + }), }) const cleanContent = stripThinkingTags(result.content || '') @@ -328,13 +338,19 @@ export async function executeInboxTask(taskId: string): Promise { } /** - * Resolve which user ID to use for execution. - * Match sender email to a workspace member, fallback to workspace owner. + * Resolve the execution and raw-secret actors independently. Workspace members + * execute and mount secrets as themselves. External senders retain the existing + * owner execution fallback but receive no raw-secret actor. */ -async function resolveUserId( +interface InboxExecutionActor { + executionUserId: string + secretActorUserId: string | null +} + +async function resolveInboxExecutionActor( senderEmail: string, ws: { id: string; ownerId: string } -): Promise { +): Promise { const [matchedUser] = await db .select({ id: user.id }) .from(user) @@ -345,11 +361,11 @@ async function resolveUserId( if (matchedUser) { const permission = await getUserEntityPermissions(matchedUser.id, 'workspace', ws.id) if (permission !== null) { - return matchedUser.id + return { executionUserId: matchedUser.id, secretActorUserId: matchedUser.id } } } - return ws.ownerId + return { executionUserId: ws.ownerId, secretActorUserId: null } } /** diff --git a/apps/sim/lib/workflows/executor/execution-state.test.ts b/apps/sim/lib/workflows/executor/execution-state.test.ts index 39aa68c066d..ae72a5e218f 100644 --- a/apps/sim/lib/workflows/executor/execution-state.test.ts +++ b/apps/sim/lib/workflows/executor/execution-state.test.ts @@ -19,6 +19,7 @@ import { getExecutionInputForWorkflow, getExecutionStateForWorkflow, getLatestExecutionStateWithExecutionId, + getTrustedWorkflowToolExecution, } from '@/lib/workflows/executor/execution-state' const EXECUTION_STATE = { @@ -75,6 +76,186 @@ describe('execution state lookup', () => { expect(result).toEqual(EXECUTION_STATE) }) + it('loads a terminal workflow result with an exact persisted Copilot binding', async () => { + const provenance = { + version: 1 as const, + complete: true, + entries: [{ name: 'API_KEY', encryptedValue: 'encrypted-secret' }], + scope: { userId: 'user-1', workspaceId: 'workspace-1' }, + } + queueTableRows(schemaMock.workflowExecutionLogs, [ + { + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + status: 'completed', + executionData: {}, + }, + ]) + mockMaterializeExecutionData.mockResolvedValueOnce({ + correlation: { copilotToolCallId: 'tool-call-1' }, + finalOutput: { token: 'raw-secret' }, + executionState: { ...EXECUTION_STATE, resolvedSecretTraceProvenance: provenance }, + }) + + await expect( + getTrustedWorkflowToolExecution('execution-1', 'workflow-1', 'tool-call-1') + ).resolves.toEqual({ + executionId: 'execution-1', + workflowId: 'workflow-1', + status: 'completed', + contentAvailable: true, + finalOutput: { token: 'raw-secret' }, + blockLogs: [], + provenance, + }) + }) + + it('accepts a bound complete execution with no activated secrets', async () => { + const provenance = { version: 1 as const, complete: true, entries: [] } + queueTableRows(schemaMock.workflowExecutionLogs, [ + { + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + status: 'completed', + executionData: {}, + }, + ]) + mockMaterializeExecutionData.mockResolvedValueOnce({ + trigger: { data: { correlation: { copilotToolCallId: 'tool-call-1' } } }, + executionState: { ...EXECUTION_STATE, resolvedSecretTraceProvenance: provenance }, + }) + + await expect( + getTrustedWorkflowToolExecution('execution-1', 'workflow-1', 'tool-call-1') + ).resolves.toMatchObject({ provenance }) + }) + + it('returns validated incomplete provenance so the terminal projector can fail closed', async () => { + queueTableRows(schemaMock.workflowExecutionLogs, [ + { + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + status: 'failed', + executionData: {}, + }, + ]) + mockMaterializeExecutionData.mockResolvedValueOnce({ + correlation: { copilotToolCallId: 'tool-call-1' }, + executionState: { + ...EXECUTION_STATE, + resolvedSecretTraceProvenance: { version: 1, complete: false, entries: [] }, + }, + }) + + await expect( + getTrustedWorkflowToolExecution('execution-1', 'workflow-1', 'tool-call-1') + ).resolves.toMatchObject({ + status: 'failed', + provenance: { version: 1, complete: false, entries: [] }, + }) + }) + + it('trusts compacted terminal status while withholding unavailable execution content', async () => { + queueTableRows(schemaMock.workflowExecutionLogs, [ + { + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + status: 'completed', + executionData: {}, + }, + ]) + mockMaterializeExecutionData.mockResolvedValueOnce({ + correlation: { copilotToolCallId: 'tool-call-1' }, + executionStateSummary: { + executedBlockCount: 1, + blockLogCount: 1, + completedLoopCount: 0, + activeExecutionPathLength: 0, + pendingQueueLength: 0, + }, + finalOutput: { token: 'must-not-cross' }, + }) + + await expect( + getTrustedWorkflowToolExecution('execution-1', 'workflow-1', 'tool-call-1') + ).resolves.toEqual({ + executionId: 'execution-1', + workflowId: 'workflow-1', + status: 'completed', + contentAvailable: false, + }) + }) + + it('withholds execution content when persisted provenance is malformed', async () => { + queueTableRows(schemaMock.workflowExecutionLogs, [ + { + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + status: 'completed', + executionData: {}, + }, + ]) + mockMaterializeExecutionData.mockResolvedValueOnce({ + correlation: { copilotToolCallId: 'tool-call-1' }, + finalOutput: { token: 'must-not-cross' }, + executionState: { + ...EXECUTION_STATE, + resolvedSecretTraceProvenance: { version: 2, complete: true, entries: [] }, + }, + }) + + await expect( + getTrustedWorkflowToolExecution('execution-1', 'workflow-1', 'tool-call-1') + ).resolves.toEqual({ + executionId: 'execution-1', + workflowId: 'workflow-1', + status: 'completed', + contentAvailable: false, + }) + }) + + it('rejects mismatched bindings and nonterminal rows', async () => { + queueTableRows(schemaMock.workflowExecutionLogs, [ + { + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + status: 'completed', + executionData: {}, + }, + ]) + mockMaterializeExecutionData.mockResolvedValueOnce({ + correlation: { copilotToolCallId: 'another-tool-call' }, + executionState: { + ...EXECUTION_STATE, + resolvedSecretTraceProvenance: { version: 1, complete: true, entries: [] }, + }, + }) + + await expect( + getTrustedWorkflowToolExecution('execution-1', 'workflow-1', 'tool-call-1') + ).resolves.toBeNull() + + queueTableRows(schemaMock.workflowExecutionLogs, [ + { + executionId: 'execution-2', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + status: 'running', + executionData: {}, + }, + ]) + + await expect( + getTrustedWorkflowToolExecution('execution-2', 'workflow-1', 'tool-call-1') + ).resolves.toBeNull() + }) + it('materializes externalized execution data when reusing workflow input', async () => { const slimExecutionData = { traceStoreRef: { diff --git a/apps/sim/lib/workflows/executor/execution-state.ts b/apps/sim/lib/workflows/executor/execution-state.ts index c854890d71e..4f0b8eacfbf 100644 --- a/apps/sim/lib/workflows/executor/execution-state.ts +++ b/apps/sim/lib/workflows/executor/execution-state.ts @@ -4,6 +4,10 @@ import { isRecordLike } from '@sim/utils/object' import { and, desc, eq, or, sql } from 'drizzle-orm' import { materializeExecutionData, TRACE_STORE_REF_KEY } from '@/lib/logs/execution/trace-store' import type { SerializableExecutionState } from '@/executor/execution/types' +import { + isResolvedSecretTraceProvenanceV1, + type ResolvedSecretTraceProvenanceV1, +} from '@/executor/utils/resolved-secret-trace-registry' const LATEST_EXECUTION_STATE_CANDIDATE_LIMIT = 10 @@ -54,37 +58,43 @@ interface ExecutionStateRow { executionId: string workflowId: string | null workspaceId: string + status?: string executionData: unknown } -async function materializeExecutionDataFromRow( - row: ExecutionStateRow | undefined -): Promise | null> { - if (!row) return null +interface TrustedWorkflowToolExecutionBase { + executionId: string + workflowId: string + status: 'completed' | 'failed' | 'cancelled' +} - return materializeExecutionData(row.executionData as Record | null, { - workspaceId: row.workspaceId, - workflowId: row.workflowId, - executionId: row.executionId, - }) +export interface TrustedWorkflowToolExecutionWithoutContent + extends TrustedWorkflowToolExecutionBase { + contentAvailable: false } -async function extractExecutionStateFromRow( - row: ExecutionStateRow | undefined -): Promise { - const executionData = await materializeExecutionDataFromRow(row) - return extractExecutionState(executionData) +export interface TrustedWorkflowToolExecutionWithContent extends TrustedWorkflowToolExecutionBase { + contentAvailable: true + finalOutput?: unknown + error?: string + blockLogs: SerializableExecutionState['blockLogs'] + provenance: ResolvedSecretTraceProvenanceV1 } -export async function getExecutionStateForWorkflow( +export type TrustedWorkflowToolExecution = + | TrustedWorkflowToolExecutionWithoutContent + | TrustedWorkflowToolExecutionWithContent + +async function getExecutionStateRow( executionId: string, workflowId: string -): Promise { +): Promise { const [row] = await db .select({ executionId: workflowExecutionLogs.executionId, workflowId: workflowExecutionLogs.workflowId, workspaceId: workflowExecutionLogs.workspaceId, + status: workflowExecutionLogs.status, executionData: workflowExecutionLogs.executionData, }) .from(workflowExecutionLogs) @@ -96,9 +106,94 @@ export async function getExecutionStateForWorkflow( ) .limit(1) + return row +} + +async function materializeExecutionDataFromRow( + row: ExecutionStateRow | undefined +): Promise | null> { + if (!row) return null + + return materializeExecutionData(row.executionData as Record | null, { + workspaceId: row.workspaceId, + workflowId: row.workflowId, + executionId: row.executionId, + }) +} + +async function extractExecutionStateFromRow( + row: ExecutionStateRow | undefined +): Promise { + const executionData = await materializeExecutionDataFromRow(row) + return extractExecutionState(executionData) +} + +export async function getExecutionStateForWorkflow( + executionId: string, + workflowId: string +): Promise { + const row = await getExecutionStateRow(executionId, workflowId) return extractExecutionStateFromRow(row) } +/** Loads a terminal workflow result only when its server-persisted Copilot binding matches. */ +export async function getTrustedWorkflowToolExecution( + executionId: string, + workflowId: string, + copilotToolCallId: string +): Promise { + const row = await getExecutionStateRow(executionId, workflowId) + if ( + !row || + (row.status !== 'completed' && row.status !== 'failed' && row.status !== 'cancelled') + ) { + return null + } + + const executionData = await materializeExecutionDataFromRow(row) + const state = extractExecutionState(executionData) + const provenance = state?.resolvedSecretTraceProvenance + const topLevelCorrelation = executionData?.correlation + const triggerCorrelation = isRecordLike(executionData?.trigger) + ? executionData.trigger.data + : undefined + const correlation = isRecordLike(topLevelCorrelation) + ? topLevelCorrelation + : isRecordLike(triggerCorrelation) && isRecordLike(triggerCorrelation.correlation) + ? triggerCorrelation.correlation + : undefined + + if ( + !executionData || + !isRecordLike(correlation) || + correlation.copilotToolCallId !== copilotToolCallId + ) { + return null + } + + if (!state || !isResolvedSecretTraceProvenanceV1(provenance)) { + return { + executionId, + workflowId, + status: row.status, + contentAvailable: false, + } + } + + return { + executionId, + workflowId, + status: row.status, + contentAvailable: true, + ...(Object.hasOwn(executionData, 'finalOutput') + ? { finalOutput: executionData.finalOutput } + : {}), + ...(typeof executionData.error === 'string' ? { error: executionData.error } : {}), + blockLogs: state.blockLogs, + provenance, + } +} + /** * Returns the workflow input recorded for a past execution so a new run can * reuse it by reference. `found` distinguishes a missing execution from an @@ -108,21 +203,7 @@ export async function getExecutionInputForWorkflow( executionId: string, workflowId: string ): Promise<{ found: boolean; input?: unknown }> { - const [row] = await db - .select({ - executionId: workflowExecutionLogs.executionId, - workflowId: workflowExecutionLogs.workflowId, - workspaceId: workflowExecutionLogs.workspaceId, - executionData: workflowExecutionLogs.executionData, - }) - .from(workflowExecutionLogs) - .where( - and( - eq(workflowExecutionLogs.executionId, executionId), - eq(workflowExecutionLogs.workflowId, workflowId) - ) - ) - .limit(1) + const row = await getExecutionStateRow(executionId, workflowId) if (!row) { return { found: false } diff --git a/apps/sim/lib/workflows/sanitization/json-sanitizer.test.ts b/apps/sim/lib/workflows/sanitization/json-sanitizer.test.ts index 404af18c04f..07f541e553f 100644 --- a/apps/sim/lib/workflows/sanitization/json-sanitizer.test.ts +++ b/apps/sim/lib/workflows/sanitization/json-sanitizer.test.ts @@ -42,13 +42,27 @@ const multiTriggerConfig = { ], } +const mothershipConfig = { + type: 'mothership', + name: 'Sim Chat', + category: 'blocks', + outputs: {}, + subBlocks: [ + { id: 'prompt', type: 'long-input' }, + { id: 'secretScope', type: 'dropdown', hideFromCopilot: true }, + { id: 'mountedSecrets', type: 'dropdown', hideFromCopilot: true }, + ], +} + vi.mock('@/blocks/registry', () => ({ getBlock: (type: string) => type === 'generic_webhook' ? genericWebhookConfig : type === 'github_v2' ? multiTriggerConfig - : undefined, + : type === 'mothership' + ? mothershipConfig + : undefined, })) /** @@ -112,6 +126,29 @@ describe('sanitizeForCopilot knowledge tag subblocks', () => { }) }) +describe('sanitizeForCopilot server-only block inputs', () => { + it('omits Sim Chat secret-mount policy while retaining model-visible inputs', () => { + const result = sanitizeForCopilot( + makeSingleBlockWorkflow('chat-1', { + type: 'mothership', + name: 'Sim Chat 1', + enabled: true, + subBlocks: { + prompt: { id: 'prompt', type: 'long-input', value: 'Help me' }, + secretScope: { id: 'secretScope', type: 'dropdown', value: 'selected' }, + mountedSecrets: { + id: 'mountedSecrets', + type: 'dropdown', + value: ['OPENAI_API_KEY'], + }, + }, + }) + ) + + expect(result.blocks['chat-1'].inputs).toEqual({ prompt: 'Help me' }) + }) +}) + /** Builds a one-block workflow for webhook-URL synthesis tests. */ function makeSingleBlockWorkflow(blockId: string, block: Record): WorkflowState { return { diff --git a/apps/sim/lib/workflows/sanitization/json-sanitizer.ts b/apps/sim/lib/workflows/sanitization/json-sanitizer.ts index 2d4f09bba40..812dbb62f6f 100644 --- a/apps/sim/lib/workflows/sanitization/json-sanitizer.ts +++ b/apps/sim/lib/workflows/sanitization/json-sanitizer.ts @@ -270,11 +270,14 @@ function isToolInput(value: unknown): value is ToolInput { * already handled by `sanitizeWorkflowForSharing`. */ function sanitizeSubBlocks( - subBlocks: BlockState['subBlocks'] + subBlocks: BlockState['subBlocks'], + hiddenIds: ReadonlySet ): Record { const sanitized: Record = {} Object.entries(subBlocks).forEach(([key, subBlock]) => { + if (hiddenIds.has(key)) return + // Skip null/undefined values if (subBlock.value === null || subBlock.value === undefined) { return @@ -569,7 +572,12 @@ export function sanitizeForCopilot(state: WorkflowState): CopilotWorkflowState { inputs = loopInputs } else { // For regular blocks, sanitize subBlocks - inputs = sanitizeSubBlocks(block.subBlocks) + const hiddenIds = new Set( + (getBlock(block.type)?.subBlocks ?? []) + .filter((subBlock) => subBlock.hideFromCopilot) + .map((subBlock) => subBlock.id) + ) + inputs = sanitizeSubBlocks(block.subBlocks, hiddenIds) const webhookUrl = resolveTriggerWebhookUrl(blockId, block) if (webhookUrl) { diff --git a/apps/sim/lib/workflows/schedules/orchestration.test.ts b/apps/sim/lib/workflows/schedules/orchestration.test.ts index b656490043d..771cdd5840c 100644 --- a/apps/sim/lib/workflows/schedules/orchestration.test.ts +++ b/apps/sim/lib/workflows/schedules/orchestration.test.ts @@ -32,12 +32,15 @@ import { performUpdateJob } from '@/lib/workflows/schedules/orchestration' const BASE_JOB = { id: 'job-1', sourceWorkspaceId: 'workspace-1', + sourceUserId: 'user-1', sourceType: 'job', archivedAt: null, timezone: 'UTC', cronExpression: null, jobTitle: 'Nightly task', status: 'disabled', + secretScope: 'all', + mountedSecrets: [], } describe('performUpdateJob', () => { @@ -81,4 +84,50 @@ describe('performUpdateJob', () => { nextRunAt: new Date('2099-01-01T09:00:00Z'), }) }) + + it('denies task content edits from a non-creator without writing', async () => { + queueTableRows(schemaMock.workflowSchedule, [BASE_JOB]) + + const result = await performUpdateJob({ + jobId: 'job-1', + workspaceId: 'workspace-1', + userId: 'workspace-writer', + prompt: 'Changed prompt', + }) + + expect(result).toMatchObject({ success: false, errorCode: 'forbidden' }) + expect(dbChainMockFns.set).not.toHaveBeenCalled() + }) + + it('allows a non-creator to pause a task', async () => { + queueTableRows(schemaMock.workflowSchedule, [{ ...BASE_JOB, status: 'active' }]) + + const result = await performUpdateJob({ + jobId: 'job-1', + workspaceId: 'workspace-1', + userId: 'workspace-writer', + status: 'paused', + }) + + expect(result.success).toBe(true) + expect(dbChainMockFns.set.mock.calls[0][0]).toMatchObject({ status: 'disabled' }) + }) + + it('persists a canonical selected secret policy for the creator', async () => { + queueTableRows(schemaMock.workflowSchedule, [BASE_JOB]) + + const result = await performUpdateJob({ + jobId: 'job-1', + workspaceId: 'workspace-1', + userId: 'user-1', + secretScope: 'selected', + mountedSecrets: [' B ', 'A', 'B'], + }) + + expect(result.success).toBe(true) + expect(dbChainMockFns.set.mock.calls[0][0]).toMatchObject({ + secretScope: 'selected', + mountedSecrets: ['B', 'A'], + }) + }) }) diff --git a/apps/sim/lib/workflows/schedules/orchestration.ts b/apps/sim/lib/workflows/schedules/orchestration.ts index 47da9c5e71d..28a49eec405 100644 --- a/apps/sim/lib/workflows/schedules/orchestration.ts +++ b/apps/sim/lib/workflows/schedules/orchestration.ts @@ -6,6 +6,10 @@ import { generateId } from '@sim/utils/id' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import type { ScheduleContext } from '@/lib/api/contracts/schedules' +import { + normalizeSecretMountPolicy, + type SecretMountScope, +} from '@/lib/copilot/secret-mount-policy' import { captureServerEvent } from '@/lib/posthog/server' import { computeNextRunAt, @@ -15,7 +19,7 @@ import { const logger = createLogger('ScheduleOrchestration') -type ScheduleErrorCode = 'not_found' | 'validation' | 'internal' +type ScheduleErrorCode = 'not_found' | 'forbidden' | 'validation' | 'internal' interface ActorMetadata { actorName?: string | null @@ -39,6 +43,8 @@ export interface PerformCreateJobParams extends ActorMetadata { endsAt?: string | null /** `@`-mentioned resources / `/`-invoked skills captured with the prompt. */ contexts?: ScheduleContext[] | null + secretScope?: SecretMountScope + mountedSecrets?: string[] sourceChatId?: string | null sourceTaskName?: string | null } @@ -68,6 +74,8 @@ export interface PerformUpdateJobParams extends ActorMetadata { maxRuns?: number | null endsAt?: string | null contexts?: ScheduleContext[] | null + secretScope?: SecretMountScope + mountedSecrets?: string[] } export interface PerformExcludeOccurrenceParams extends ActorMetadata { @@ -192,6 +200,7 @@ export async function performCreateJob( try { const id = generateId() const now = new Date() + const secretMountPolicy = normalizeSecretMountPolicy(params) await db.insert(workflowSchedule).values({ id, workflowId: null, @@ -217,6 +226,8 @@ export async function performCreateJob( sourceTaskName: params.sourceTaskName || null, sourceUserId: params.userId, sourceWorkspaceId: params.workspaceId, + secretScope: secretMountPolicy.secretScope, + mountedSecrets: secretMountPolicy.mountedSecrets, }) const [schedule] = await db @@ -284,6 +295,27 @@ export async function performUpdateJob( if (!job) return { success: false, error: `Job not found: ${params.jobId}`, errorCode: 'not_found' } + const hasCreatorOnlyUpdate = + params.title !== undefined || + params.prompt !== undefined || + params.cronExpression !== undefined || + params.time !== undefined || + params.timezone !== undefined || + params.lifecycle !== undefined || + params.successCondition !== undefined || + params.maxRuns !== undefined || + params.endsAt !== undefined || + params.contexts !== undefined || + params.secretScope !== undefined || + params.mountedSecrets !== undefined + if (hasCreatorOnlyUpdate && job.sourceUserId !== params.userId) { + return { + success: false, + error: 'Only the task creator can edit this task', + errorCode: 'forbidden', + } + } + const updates: Partial = { updatedAt: new Date() } if (params.title !== undefined) updates.jobTitle = params.title.trim() if (params.prompt !== undefined) updates.prompt = params.prompt.trim() @@ -312,6 +344,14 @@ export async function performUpdateJob( if (params.successCondition !== undefined) updates.successCondition = params.successCondition if (params.maxRuns !== undefined) updates.maxRuns = params.maxRuns if (params.contexts !== undefined) updates.contexts = params.contexts + if (params.secretScope !== undefined || params.mountedSecrets !== undefined) { + const secretMountPolicy = normalizeSecretMountPolicy({ + secretScope: params.secretScope ?? job.secretScope, + mountedSecrets: params.mountedSecrets ?? job.mountedSecrets, + }) + updates.secretScope = secretMountPolicy.secretScope + updates.mountedSecrets = secretMountPolicy.mountedSecrets + } const effectiveStatus = updates.status ?? job.status let endsAt: Date | null = job.endsAt diff --git a/apps/sim/lib/workflows/subblocks/options.ts b/apps/sim/lib/workflows/subblocks/options.ts index 8f5baab9b33..bf97f149a21 100644 --- a/apps/sim/lib/workflows/subblocks/options.ts +++ b/apps/sim/lib/workflows/subblocks/options.ts @@ -1,7 +1,13 @@ +import { selectRawMountableSecretNames } from '@/lib/credentials/secret-mount-options' import { fetchWorkspaceEnvironment } from '@/lib/environment/api' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { environmentKeys, WORKSPACE_ENVIRONMENT_STALE_TIME } from '@/hooks/queries/environment' import { getSandboxListQueryOptions, type SandboxListResponse } from '@/hooks/queries/sandboxes' +import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys' +import { + fetchWorkspaceCredentialList, + WORKSPACE_CREDENTIAL_LIST_STALE_TIME, +} from '@/hooks/queries/utils/fetch-workspace-credentials' import { getWorkflowListQueryOptions } from '@/hooks/queries/utils/workflow-list-query' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' @@ -70,6 +76,21 @@ export async function fetchWorkspaceSecretNameOptions(): Promise ({ id: name, label: name })) } +/** Loads only secret names the current actor may mount as plaintext into Copilot code. */ +export async function fetchWorkspaceRawSecretNameOptions(): Promise { + const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId + if (!workspaceId) return [] + + const credentials = await getQueryClient().fetchQuery({ + queryKey: workspaceCredentialKeys.list(workspaceId), + queryFn: ({ signal }: { signal?: AbortSignal }) => + fetchWorkspaceCredentialList(workspaceId, signal), + staleTime: WORKSPACE_CREDENTIAL_LIST_STALE_TIME, + }) + + return selectRawMountableSecretNames(credentials).map((name) => ({ id: name, label: name })) +} + /** * Labels a sandbox for the picker. The name is what identifies it, so that is all * the label carries by default — the block's own list is already scoped to one diff --git a/apps/sim/serializer/index.ts b/apps/sim/serializer/index.ts index f39614c6987..ef27aa74c40 100644 --- a/apps/sim/serializer/index.ts +++ b/apps/sim/serializer/index.ts @@ -329,6 +329,16 @@ export class Serializer { enabled: block.enabled, } + const privateInputIds = new Set() + for (const subBlock of blockConfig.subBlocks) { + if (!subBlock.hideFromCopilot) continue + privateInputIds.add(subBlock.id) + if (subBlock.canonicalParamId) privateInputIds.add(subBlock.canonicalParamId) + } + if (privateInputIds.size > 0) { + serialized.privateInputIds = [...privateInputIds] + } + if (block.data?.canonicalModes) { serialized.canonicalModes = block.data.canonicalModes as Record } diff --git a/apps/sim/serializer/private-inputs.test.ts b/apps/sim/serializer/private-inputs.test.ts new file mode 100644 index 00000000000..d97e0e6d109 --- /dev/null +++ b/apps/sim/serializer/private-inputs.test.ts @@ -0,0 +1,80 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { BlockState } from '@/stores/workflows/workflow/types' + +const { mockGetBlock } = vi.hoisted(() => ({ + mockGetBlock: vi.fn(), +})) + +vi.mock('@/blocks', () => ({ + getBlock: mockGetBlock, +})) + +vi.mock('@/tools/metadata', () => ({ + getToolParams: vi.fn(() => undefined), +})) + +import { Serializer } from '@/serializer' + +describe('Serializer private inputs', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetBlock.mockReturnValue({ + name: 'Private lifecycle block', + description: 'Test block', + category: 'blocks', + bgColor: '#000000', + tools: { + access: ['private_lifecycle'], + config: { tool: () => 'private_lifecycle' }, + }, + subBlocks: [ + { id: 'prompt', type: 'long-input' }, + { id: 'secretScope', type: 'dropdown', hideFromCopilot: true }, + { + id: 'mountedSecretsAdvanced', + canonicalParamId: 'mountedSecrets', + type: 'dropdown', + hideFromCopilot: true, + }, + ], + inputs: { + prompt: { type: 'string' }, + secretScope: { type: 'string' }, + mountedSecrets: { type: 'json' }, + }, + outputs: {}, + }) + }) + + it('derives executor-private input ids from block metadata', () => { + const block = { + id: 'block-1', + type: 'private_lifecycle', + name: 'Private lifecycle block', + position: { x: 0, y: 0 }, + subBlocks: { + prompt: { id: 'prompt', type: 'long-input', value: 'Run the task' }, + secretScope: { id: 'secretScope', type: 'dropdown', value: 'selected' }, + mountedSecretsAdvanced: { + id: 'mountedSecretsAdvanced', + type: 'dropdown', + value: ['API_KEY'], + }, + }, + outputs: {}, + enabled: true, + } as BlockState + + const serialized = new Serializer().serializeWorkflow({ [block.id]: block }, [], {}) + + expect(serialized.blocks[0].privateInputIds).toEqual([ + 'secretScope', + 'mountedSecretsAdvanced', + 'mountedSecrets', + ]) + }) +}) diff --git a/apps/sim/serializer/types.ts b/apps/sim/serializer/types.ts index 8d7bc56e4ed..2fb123ecee9 100644 --- a/apps/sim/serializer/types.ts +++ b/apps/sim/serializer/types.ts @@ -40,6 +40,8 @@ export interface SerializedBlock { enabled: boolean /** Canonical mode overrides from block.data (used by agent handler for tool param resolution) */ canonicalModes?: Record + /** Server-only lifecycle input ids omitted from execution-log projections. */ + privateInputIds?: string[] } export interface SerializedLoop { diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index d3895b9c367..579dedc54ff 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -26,6 +26,7 @@ import { import { sleep } from '@sim/utils/helpers' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' +import { projectToolResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' import { ANONYMOUS_SECRET_TRACE_REPLACEMENT, ResolvedSecretTraceRegistry, @@ -700,12 +701,97 @@ describe('executeTool Function', () => { expect(new Headers(requestInit?.headers).get('x-sim-request-private-tool-metadata')).toBe( 'resolved-secret-names-v1' ) - expect(result.output).not.toHaveProperty('__resolvedSecretNames') + expect(result.output).toEqual({ + success: true, + output: { result: 'secret-value', stdout: '' }, + }) expect(registry.getActiveMatches()).toEqual([ { plaintext: 'secret-value', replacement: '{{API_KEY}}' }, ]) }) + it('fails concurrent projection closed while custom-tool provenance is pending', async () => { + const secret = 'custom-tool-secret-value' + const registry = new ResolvedSecretTraceRegistry([ + { + name: 'API_KEY', + plaintext: secret, + encryptedValue: 'encrypted-value', + }, + ]) + mockGetToolAsync.mockResolvedValueOnce({ + id: 'custom_pending-provenance', + name: 'Pending provenance custom tool', + description: 'Tests late provenance activation', + version: '1.0.0', + params: {}, + request: { + url: '/api/function/execute', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: () => ({ code: 'return {{API_KEY}}', envVars: { API_KEY: secret } }), + }, + transformResponse: async (response: Response) => { + const data = await response.json() + return { success: true, output: data.output } + }, + }) + + let resolveRequest!: (response: Response) => void + let markRequestStarted!: () => void + const requestStarted = new Promise((resolve) => { + markRequestStarted = resolve + }) + global.fetch = Object.assign( + vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveRequest = resolve + markRequestStarted() + }) + ), + { preconnect: vi.fn() } + ) as typeof fetch + + const execution = executeTool( + 'custom_pending-provenance', + { envVars: { API_KEY: secret } }, + { + executionContext: createToolExecutionContext(), + resolvedSecretTraceRegistry: registry, + } + ) + await requestStarted + + expect(registry.isComplete()).toBe(false) + expect( + projectToolResultForCopilot({ success: true, output: { result: secret } }, registry) + ).not.toHaveProperty('output') + + resolveRequest( + new Response( + JSON.stringify({ + success: true, + output: { result: secret }, + __resolvedSecretNames: ['API_KEY'], + }), + { + status: 200, + headers: { + 'content-type': 'application/json', + 'x-sim-private-tool-metadata': 'resolved-secret-names-v1', + }, + } + ) + ) + + await expect(execution).resolves.toMatchObject({ success: true }) + expect(registry.isComplete()).toBe(true) + expect( + projectToolResultForCopilot({ success: true, output: { result: secret } }, registry) + ).toMatchObject({ output: { result: '{{API_KEY}}' } }) + }) + it('keeps the Function result unchanged when requested provenance is missing', async () => { const registry = new ResolvedSecretTraceRegistry() global.fetch = Object.assign( @@ -1778,6 +1864,53 @@ describe('Copilot Env Variable Reference Resolution', () => { expect(sentRequestBody(fetchMock).apiKey).toBe('sntrys_real_token') }) + it('fails concurrent projection closed while a user-only secret reference is resolving', async () => { + const secret = 'sntrys_real_token' + const registry = new ResolvedSecretTraceRegistry([ + { + name: 'SENTRY_AUTH_TOKEN', + plaintext: secret, + encryptedValue: 'encrypted-token', + }, + ]) + let resolveEnvironment!: (variables: Record) => void + let markResolutionStarted!: () => void + const resolutionStarted = new Promise((resolve) => { + markResolutionStarted = resolve + }) + mockGetEffectiveDecryptedEnv.mockImplementationOnce( + () => + new Promise>((resolve) => { + resolveEnvironment = resolve + markResolutionStarted() + }) + ) + mockJsonFetch() + + const execution = executeTool( + 'test_env_ref_tool', + { apiKey: '{{SENTRY_AUTH_TOKEN}}' }, + { + executionContext: copilotContext(), + resolvedSecretTraceRegistry: registry, + } + ) + await resolutionStarted + + expect(registry.isComplete()).toBe(false) + expect( + projectToolResultForCopilot({ success: true, output: { result: secret } }, registry) + ).not.toHaveProperty('output') + + resolveEnvironment({ SENTRY_AUTH_TOKEN: secret }) + await expect(execution).resolves.toMatchObject({ success: true }) + + expect(registry.isComplete()).toBe(true) + expect( + projectToolResultForCopilot({ success: true, output: { result: secret } }, registry) + ).toMatchObject({ output: { result: '{{SENTRY_AUTH_TOKEN}}' } }) + }) + it('trims whitespace inside the braces like the executor resolver', async () => { const fetchMock = mockJsonFetch() @@ -2251,6 +2384,74 @@ describe('MCP Tool Execution', () => { ]) }) + it('fails concurrent projection closed while MCP provenance is pending', async () => { + const secret = 'mcp-secret-value' + const registry = new ResolvedSecretTraceRegistry([], { + userId: 'test-user', + workspaceId: 'workspace-456', + }) + encryptionMockFns.mockDecryptSecret.mockResolvedValueOnce({ decrypted: secret }) + + let resolveRequest!: (response: Response) => void + let markRequestStarted!: () => void + const requestStarted = new Promise((resolve) => { + markRequestStarted = resolve + }) + global.fetch = Object.assign( + vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveRequest = resolve + markRequestStarted() + }) + ), + { preconnect: vi.fn() } + ) as typeof fetch + + const execution = executeTool( + 'mcp-123-list_files', + { path: '/test' }, + { + executionContext: createToolExecutionContext(), + resolvedSecretTraceRegistry: registry, + } + ) + await requestStarted + + expect(registry.isComplete()).toBe(false) + expect( + projectToolResultForCopilot({ success: true, output: { value: secret } }, registry) + ).not.toHaveProperty('output') + + resolveRequest( + new Response( + JSON.stringify({ + success: true, + data: { output: { content: [{ type: 'text', text: secret }] } }, + __resolvedSecretTraceProvenance: { + version: 1, + complete: true, + entries: [{ name: 'MCP_TOKEN', encryptedValue: 'encrypted-token' }], + scope: { userId: 'test-user', workspaceId: 'workspace-456' }, + }, + }), + { + status: 200, + headers: { + 'content-type': 'application/json', + 'x-sim-private-tool-metadata': 'resolved-secret-provenance-v1', + }, + } + ) + ) + + await expect(execution).resolves.toMatchObject({ success: true }) + expect(registry.isComplete()).toBe(true) + expect( + projectToolResultForCopilot({ success: true, output: { value: secret } }, registry) + ).toMatchObject({ output: { value: '{{MCP_TOKEN}}' } }) + }) + it('rejects unmarked MCP provenance instead of trusting a response body field', async () => { const registry = new ResolvedSecretTraceRegistry() global.fetch = Object.assign( diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 36f9a0eeb28..3d20a4faaaf 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -243,28 +243,33 @@ async function resolveCopilotEnvReferences( ) } - const { getEffectiveDecryptedEnv } = await import('@/lib/environment/utils') - const envVars = await getEffectiveDecryptedEnv(scope.userId, scope.workspaceId) - - for (const { paramId, value } of pending) { - const missingKeys: string[] = [] - const resolved = resolveEnvVarReferences(value, envVars, { - allowEmbedded: false, - missingKeys, - onResolved: (name, resolvedValue) => { - resolvedSecretTraceRegistry?.recordResolved(name, resolvedValue) - }, - }) - if (missingKeys.length > 0) { - const scopeHint = scope.workspaceId - ? '' - : ' (no workspace context — only personal variables are available here)' - throw new Error( - `Environment variable "${missingKeys[0]}" referenced by parameter "${paramId}" was not found${scopeHint}. ` + - `Check environment/variables.json for available variable names.` - ) + const completePendingActivation = resolvedSecretTraceRegistry?.beginPendingActivation() + try { + const { getEffectiveDecryptedEnv } = await import('@/lib/environment/utils') + const envVars = await getEffectiveDecryptedEnv(scope.userId, scope.workspaceId) + + for (const { paramId, value } of pending) { + const missingKeys: string[] = [] + const resolved = resolveEnvVarReferences(value, envVars, { + allowEmbedded: false, + missingKeys, + onResolved: (name, resolvedValue) => { + resolvedSecretTraceRegistry?.recordResolved(name, resolvedValue) + }, + }) + if (missingKeys.length > 0) { + const scopeHint = scope.workspaceId + ? '' + : ' (no workspace context — only personal variables are available here)' + throw new Error( + `Environment variable "${missingKeys[0]}" referenced by parameter "${paramId}" was not found${scopeHint}. ` + + `Check environment/variables.json for available variable names.` + ) + } + params[paramId] = resolved as string } - params[paramId] = resolved as string + } finally { + completePendingActivation?.() } } @@ -1246,6 +1251,7 @@ export async function executeTool( // Hoisted so the outer catch can attribute a thrown failure to the chosen key. let hostedKeyForMetrics: { provider: string; tool: string; key: string } | undefined + let completePendingSecretActivation: (() => void) | undefined try { let tool: ToolConfig | undefined @@ -1271,6 +1277,10 @@ export async function executeTool( ? RESOLVED_SECRET_NAMES_METADATA_V1 : undefined + if (resolvedSecretTraceRegistry && (privateToolMetadataType || toolKind === 'mcp')) { + completePendingSecretActivation = resolvedSecretTraceRegistry.beginPendingActivation() + } + // Runs for ALL tools (not just kinded ones) so the per-tool `deniedTools` // denylist is enforced alongside the existing mcp/custom/skill gates. if (scope.userId && scope.workspaceId) { @@ -1773,6 +1783,8 @@ export async function executeTool( duration, }, } + } finally { + completePendingSecretActivation?.() } } diff --git a/packages/db/migrations/0280_great_riptide.sql b/packages/db/migrations/0280_great_riptide.sql new file mode 100644 index 00000000000..e895e1ec040 --- /dev/null +++ b/packages/db/migrations/0280_great_riptide.sql @@ -0,0 +1,5 @@ +-- migration-safe: additive columns use non-null defaults, so old and new app versions can read and write these rows throughout the deploy. +ALTER TABLE "workflow_schedule" ADD COLUMN "secret_scope" text DEFAULT 'all' NOT NULL;--> statement-breakpoint +ALTER TABLE "workflow_schedule" ADD COLUMN "mounted_secrets" jsonb DEFAULT '[]'::jsonb NOT NULL;--> statement-breakpoint +ALTER TABLE "workspace" ADD COLUMN "inbox_secret_scope" text DEFAULT 'all' NOT NULL;--> statement-breakpoint +ALTER TABLE "workspace" ADD COLUMN "inbox_mounted_secrets" jsonb DEFAULT '[]'::jsonb NOT NULL; diff --git a/packages/db/migrations/meta/0280_snapshot.json b/packages/db/migrations/meta/0280_snapshot.json new file mode 100644 index 00000000000..0a4dee2c63f --- /dev/null +++ b/packages/db/migrations/meta/0280_snapshot.json @@ -0,0 +1,18398 @@ +{ + "id": "d1c2701c-3233-4ce4-9bf1-3ee3065c8e9b", + "prevId": "4b619949-ee98-4251-b621-5f37a9fa23a3", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_number_idx": { + "name": "academy_certificate_number_idx", + "columns": [ + { + "expression": "certificate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_work_status": { + "name": "background_work_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "background_work_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_work_status_value", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_work_status_workspace_status_idx": { + "name": "background_work_status_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_workflow_status_idx": { + "name": "background_work_status_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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_tool_call_id_idx": { + "name": "copilot_async_tool_calls_tool_call_id_idx", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "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 + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_block": { + "name": "custom_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_block_organization_id_idx": { + "name": "custom_block_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_workflow_id_idx": { + "name": "custom_block_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_organization_type_unique": { + "name": "custom_block_organization_type_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_block_organization_id_organization_id_fk": { + "name": "custom_block_organization_id_organization_id_fk", + "tableFrom": "custom_block", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_workflow_id_workflow_id_fk": { + "name": "custom_block_workflow_id_workflow_id_fk", + "tableFrom": "custom_block", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_created_by_user_id_fk": { + "name": "custom_block_created_by_user_id_fk", + "tableFrom": "custom_block", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_id_idx": { + "name": "kcsl_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mothership_settings_workspace_id_idx": { + "name": "mothership_settings_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "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_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_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.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.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 + }, + "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.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.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 + } + }, + "indexes": { + "sso_provider_provider_id_idx": { + "name": "sso_provider_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_jobs": { + "name": "table_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enrichment_details": { + "name": "enrichment_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.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_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.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_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_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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": {} + }, + "webhook_tiktok_credential_id_idx": { + "name": "webhook_tiktok_credential_id_idx", + "columns": [ + { + "expression": "((\"provider_config\")::jsonb ->> 'credentialId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"provider\" = 'tiktok' AND \"webhook\".\"is_active\" = true AND \"webhook\".\"archived_at\" IS NULL", + "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_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 + }, + "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 + }, + "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_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": {} + } + }, + "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_key_idx": { + "name": "workspace_file_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_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_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 + }, + "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()" + } + }, + "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" + }, + "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_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": ["oauth", "env_workspace", "env_personal", "service_account"] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.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.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.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment" + ] + }, + "public.workspace_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": ["push", "pull"] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "mcp_server", + "workflow_mcp_server", + "custom_tool", + "skill" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 30be907c184..64de524fd6e 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1954,6 +1954,13 @@ "when": 1785542556609, "tag": "0279_collab_doc_state_and_content_version", "breakpoints": true + }, + { + "idx": 280, + "version": "7", + "when": 1785640502989, + "tag": "0280_great_riptide", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 87805620307..e29c6825f91 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -756,6 +756,8 @@ export const workflowSchedule = pgTable( sourceWorkspaceId: text('source_workspace_id').references(() => workspace.id, { onDelete: 'cascade', }), + secretScope: text('secret_scope').notNull().default('all'), + mountedSecrets: jsonb('mounted_secrets').$type().notNull().default([]), jobHistory: jsonb('job_history').$type>(), /** `@`-mentioned resources / `/`-invoked skills captured with the prompt, resolved into the agent run at fire time. */ contexts: jsonb('contexts').$type>>(), @@ -1595,6 +1597,8 @@ export const workspace = pgTable( inboxEnabled: boolean('inbox_enabled').notNull().default(false), inboxAddress: text('inbox_address'), inboxProviderId: text('inbox_provider_id'), + inboxSecretScope: text('inbox_secret_scope').notNull().default('all'), + inboxMountedSecrets: jsonb('inbox_mounted_secrets').$type().notNull().default([]), archivedAt: timestamp('archived_at'), organizationAssignedAt: timestamp('organization_assigned_at'), forkedFromWorkspaceId: text('forked_from_workspace_id').references( diff --git a/packages/testing/src/mocks/logging-session.mock.ts b/packages/testing/src/mocks/logging-session.mock.ts index 0f951db2484..3cefc0eb2e0 100644 --- a/packages/testing/src/mocks/logging-session.mock.ts +++ b/packages/testing/src/mocks/logging-session.mock.ts @@ -5,7 +5,8 @@ import { vi } from 'vitest' * `@/lib/logs/execution/logging-session`. Every instance method is backed by a * shared `vi.fn()` so tests that construct multiple sessions observe identical * mock state. `mockSafeStart` defaults to `true` because callers branch on the - * boolean result. All other methods resolve to `undefined`. + * boolean result. Projection methods return their input; other methods resolve + * to `undefined`. * * @example * ```ts @@ -24,6 +25,10 @@ export const loggingSessionMockFns = { mockSafeStart: vi.fn().mockResolvedValue(true), mockWaitForCompletion: vi.fn().mockResolvedValue(undefined), mockWaitForPostExecution: vi.fn().mockResolvedValue(undefined), + mockSetTrustedExecutionCorrelation: vi.fn(), + mockProjectBlockLogsForDisplay: vi.fn(async (logs: unknown) => logs), + mockProjectDisplayContent: vi.fn(async (content: unknown) => content), + mockProjectLiveDisplayText: vi.fn(async (_field: string, value: string) => ({ value })), mockSafeComplete: vi.fn().mockResolvedValue(undefined), mockSafeCompleteWithError: vi.fn().mockResolvedValue(undefined), mockSafeCompleteWithCancellation: vi.fn().mockResolvedValue(undefined), @@ -47,6 +52,10 @@ function buildLoggingSessionInstance() { safeStart: loggingSessionMockFns.mockSafeStart, waitForCompletion: loggingSessionMockFns.mockWaitForCompletion, waitForPostExecution: loggingSessionMockFns.mockWaitForPostExecution, + setTrustedExecutionCorrelation: loggingSessionMockFns.mockSetTrustedExecutionCorrelation, + projectBlockLogsForDisplay: loggingSessionMockFns.mockProjectBlockLogsForDisplay, + projectDisplayContent: loggingSessionMockFns.mockProjectDisplayContent, + projectLiveDisplayText: loggingSessionMockFns.mockProjectLiveDisplayText, safeComplete: loggingSessionMockFns.mockSafeComplete, safeCompleteWithError: loggingSessionMockFns.mockSafeCompleteWithError, safeCompleteWithCancellation: loggingSessionMockFns.mockSafeCompleteWithCancellation, From 746756d8a95bcd5dc8a66e71ca777cc94032a6d0 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sun, 2 Aug 2026 14:07:19 -0700 Subject: [PATCH 02/11] improvement(misc): settings organization, chat agent deploy request keys (#6195) * improvement(misc): settings organization, chat agent deploy request keys * fix subtitle * adjust perms --- .../settings/[section]/settings.tsx | 1 + .../credit-usage/credit-usage-view.tsx | 2 +- .../settings/billing/credit-usage/loading.tsx | 2 +- .../components/billing/billing.test.tsx | 60 ++++- .../settings/components/billing/billing.tsx | 17 +- .../[workspaceId]/settings/navigation.test.ts | 104 +++++--- .../[workspaceId]/settings/navigation.ts | 9 +- .../settings-sidebar/settings-sidebar.tsx | 4 +- .../components/settings/navigation.test.ts | 34 ++- apps/sim/components/settings/navigation.ts | 106 ++++---- .../standalone-settings-shell.test.ts | 11 + apps/sim/lib/api/contracts/deployments.ts | 1 + apps/sim/lib/api/contracts/v1/workflows.ts | 4 +- .../copilot/request/tools/executor.test.ts | 26 ++ .../sim/lib/copilot/request/tools/executor.ts | 19 +- .../copilot/request/tools/permission.test.ts | 12 + apps/sim/lib/copilot/tool-executor/types.ts | 2 + .../tools/handlers/deployment/context.test.ts | 39 +++ .../tools/handlers/deployment/context.ts | 35 +++ .../tools/handlers/deployment/deploy.test.ts | 237 ++++++++++++++++++ .../tools/handlers/deployment/deploy.ts | 14 ++ .../tools/handlers/deployment/manage.test.ts | 80 ++++++ .../tools/handlers/deployment/manage.ts | 13 +- .../orchestration/chat-deploy.test.ts | 20 ++ .../workflows/orchestration/chat-deploy.ts | 16 ++ .../workflows/orchestration/deploy.test.ts | 101 ++++++++ .../sim/lib/workflows/orchestration/deploy.ts | 60 ++++- 27 files changed, 919 insertions(+), 110 deletions(-) create mode 100644 apps/sim/lib/copilot/tools/handlers/deployment/context.test.ts create mode 100644 apps/sim/lib/copilot/tools/handlers/deployment/context.ts create mode 100644 apps/sim/lib/copilot/tools/handlers/deployment/deploy.test.ts diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx index b9a95f6564c..9409ccc55e2 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx @@ -168,6 +168,7 @@ export function SettingsPage({ section }: SettingsPageProps) { )} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view.tsx b/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view.tsx index 2c9f36ab1db..18402ab42ae 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view.tsx @@ -151,7 +151,7 @@ export function CreditUsageView({ backHref = '/account/settings/billing' }: Cred return ( router.push(backHref), }} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/loading.tsx b/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/loading.tsx index 934c025f57f..6071a86e11a 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/loading.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/loading.tsx @@ -17,7 +17,7 @@ export function CreditUsageLoading({ backHref }: CreditUsageLoadingProps) { return ( router.push(backHref), }} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.test.tsx index 05da1f82dbe..a00cc5a639d 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.test.tsx @@ -161,7 +161,12 @@ vi.mock( ) vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-panel', () => ({ - SettingsPanel: ({ children }: { children: ReactNode }) =>
{children}
, + SettingsPanel: ({ children, description }: { children: ReactNode; description?: string }) => ( +
+ {description &&

{description}

} + {children} +
+ ), })) vi.mock( @@ -259,7 +264,13 @@ describe('Billing payer scope', () => { it('uses the target organization DTO for annual, canceled, credit, cap, and link state', async () => { await act(async () => { - root.render() + root.render( + + ) }) expect(mockUseSubscriptionData).toHaveBeenCalledWith( @@ -270,6 +281,9 @@ describe('Billing payer scope', () => { container.querySelector('a[href="/workspace/organization-workspace/upgrade"]')?.textContent ).toBe('Explore organization plans') expect(container.textContent).toContain('Organization Max for Teams plan') + expect(container.textContent).toContain( + 'Target organization’s subscription governs Production.' + ) expect(container.textContent).toContain('billed annually') expect(container.textContent).toContain('Access until') expect(container.textContent).toContain('Subscription canceled') @@ -293,13 +307,35 @@ describe('Billing payer scope', () => { it('uses a guaranteed personal payer workspace for account upgrades', async () => { await act(async () => { - root.render() + root.render() }) expect( container.querySelector('a[href="/workspace/personal-workspace/upgrade"]')?.textContent ).toBe('Explore personal plans') expect(container.textContent).toContain('Personal Pro plan') + expect(container.textContent).toContain( + 'Your personal subscription governs Personal workspace.' + ) + }) + + it('does not show a governing subscription description for a free personal workspace', async () => { + mockPersonalQuery.current = { + data: { + success: true, + context: 'user', + data: { ...PERSONAL_DATA, plan: 'free', status: 'active' }, + }, + isLoading: false, + refetch: vi.fn(), + } + + await act(async () => { + root.render() + }) + + expect(container.textContent).toContain('Personal Free plan') + expect(container.querySelector('main > p')).toBeNull() }) it('renders an explicit free organization state without subscription controls', async () => { @@ -319,12 +355,19 @@ describe('Billing payer scope', () => { } await act(async () => { - root.render() + root.render( + + ) }) expect(container.textContent).toContain('Organization Free plan') expect(container.textContent).toContain('No active organization subscription') expect(container.textContent).not.toContain('Payment method') + expect(container.querySelector('main > p')).toBeNull() }) it('renders lapsed organization plans as ended rather than active', async () => { @@ -342,12 +385,19 @@ describe('Billing payer scope', () => { } await act(async () => { - root.render() + root.render( + + ) }) expect(container.textContent).toContain('Organization Max for Teams plan ended') expect(container.textContent).toContain('Choose a new plan for this organization') expect(container.textContent).not.toContain('Cancel subscription') + expect(container.querySelector('main > p')).toBeNull() expect( container.querySelector('a[href="/workspace/organization-workspace/upgrade"]')?.textContent ).toBe('Explore organization plans') diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx index 851bc668d9c..76c36f517f3 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx @@ -102,9 +102,15 @@ interface BillingProps { scope: 'account' | 'organization' organizationId?: string creditUsageHref?: string + governingWorkspaceName?: string } -export function Billing({ scope, organizationId, creditUsageHref }: BillingProps) { +export function Billing({ + scope, + organizationId, + creditUsageHref, + governingWorkspaceName, +}: BillingProps) { const router = useRouter() const isOrganizationScope = scope === 'organization' @@ -447,9 +453,16 @@ export function Billing({ scope, organizationId, creditUsageHref }: BillingProps const explorePlansLabel = isOrganizationScope ? 'Explore organization plans' : 'Explore personal plans' + const subscriptionOwner = isOrganizationScope + ? `${organizationBilling?.organizationName ?? 'The organization'}’s subscription` + : 'Your personal subscription' + const settingsDescription = + governingWorkspaceName && subscription.isPaid + ? `${subscriptionOwner} governs ${governingWorkspaceName}.` + : undefined return ( - +
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts index 5c2a5bfe3e8..ed53da7b24d 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts @@ -21,50 +21,88 @@ import { } from '@/app/workspace/[workspaceId]/settings/navigation' describe('unified settings navigation', () => { - it('preserves the original settings groups', () => { + it('groups settings by the scope they affect', () => { expect(sectionConfig).toEqual([ { key: 'account', title: 'Account' }, - { key: 'tools', title: 'Tools' }, - { key: 'subscription', title: 'Subscription' }, - { key: 'system', title: 'System' }, - { key: 'desktop', title: 'Desktop' }, - { key: 'enterprise', title: 'Enterprise' }, - { key: 'superuser', title: 'Superuser' }, + { key: 'workspace', title: 'Workspace' }, + { key: 'organization', title: 'Organization' }, + { key: 'platform', title: 'Platform' }, ]) }) it('keeps account, workspace, organization, and platform settings in one catalog', () => { expect(allNavigationItems.map(({ id, label, section }) => ({ id, label, section }))).toEqual([ { id: 'general', label: 'General', section: 'account' }, - { id: 'desktop', label: 'Desktop', section: 'desktop' }, - { id: 'browser', label: 'Browser', section: 'desktop' }, - { id: 'terminal', label: 'Terminal', section: 'desktop' }, - { id: 'access-control', label: 'Access control', section: 'enterprise' }, - { id: 'audit-logs', label: 'Audit logs', section: 'enterprise' }, - { id: 'forks', label: 'Workspace Forks', section: 'enterprise' }, - { id: 'billing', label: 'Billing', section: 'subscription' }, - { id: 'teammates', label: 'Teammates', section: 'subscription' }, - { id: 'organization', label: 'Organization', section: 'subscription' }, - { id: 'secrets', label: 'Secrets', section: 'account' }, - { id: 'custom-tools', label: 'Custom tools', section: 'tools' }, - { id: 'mcp', label: 'MCP tools', section: 'tools' }, - { id: 'apikeys', label: 'Sim API keys', section: 'system' }, - { id: 'workflow-mcp-servers', label: 'MCP servers', section: 'system' }, - { id: 'byok', label: 'BYOK', section: 'system' }, - { id: 'sandboxes', label: 'Sandboxes', section: 'system' }, - { id: 'inbox', label: 'Sim mailer', section: 'system' }, - { id: 'recently-deleted', label: 'Recently deleted', section: 'system' }, - { id: 'sso', label: 'Single sign-on', section: 'enterprise' }, - { id: 'sessions', label: 'Session policies', section: 'enterprise' }, - { id: 'data-retention', label: 'Data retention', section: 'enterprise' }, - { id: 'data-drains', label: 'Data drains', section: 'enterprise' }, - { id: 'whitelabeling', label: 'Whitelabeling', section: 'enterprise' }, - { id: 'custom-blocks', label: 'Custom blocks', section: 'enterprise' }, - { id: 'admin', label: 'Admin', section: 'superuser' }, - { id: 'mothership', label: 'Mothership', section: 'superuser' }, + { id: 'desktop', label: 'Desktop', section: 'account' }, + { id: 'browser', label: 'Browser', section: 'account' }, + { id: 'terminal', label: 'Terminal', section: 'account' }, + { id: 'access-control', label: 'Permission groups', section: 'organization' }, + { id: 'audit-logs', label: 'Audit logs', section: 'organization' }, + { id: 'forks', label: 'Workspace forks', section: 'organization' }, + { id: 'billing', label: 'Subscription', section: 'account' }, + { id: 'teammates', label: 'Teammates', section: 'workspace' }, + { id: 'organization', label: 'Members', section: 'organization' }, + { id: 'secrets', label: 'Secrets', section: 'workspace' }, + { id: 'custom-tools', label: 'Custom tools', section: 'workspace' }, + { id: 'mcp', label: 'MCP tools', section: 'workspace' }, + { id: 'apikeys', label: 'Sim API keys', section: 'workspace' }, + { id: 'workflow-mcp-servers', label: 'MCP servers', section: 'workspace' }, + { id: 'byok', label: 'BYOK', section: 'workspace' }, + { id: 'sandboxes', label: 'Sandboxes', section: 'workspace' }, + { id: 'inbox', label: 'Sim Mailer', section: 'workspace' }, + { id: 'recently-deleted', label: 'Recently deleted', section: 'workspace' }, + { id: 'sso', label: 'Single sign-on', section: 'organization' }, + { id: 'sessions', label: 'Session policies', section: 'organization' }, + { id: 'data-retention', label: 'Data retention', section: 'organization' }, + { id: 'data-drains', label: 'Data drains', section: 'organization' }, + { id: 'whitelabeling', label: 'White-labeling', section: 'organization' }, + { id: 'custom-blocks', label: 'Custom blocks', section: 'organization' }, + { id: 'admin', label: 'Admin', section: 'platform' }, + { id: 'mothership', label: 'Mothership', section: 'platform' }, ]) }) + it('orders each scope around its primary settings', () => { + const idsForSection = (section: (typeof sectionConfig)[number]['key']) => + allNavigationItems + .filter((item) => item.section === section) + .sort((left, right) => left.order - right.order) + .map(({ id }) => id) + + expect(idsForSection('account')).toEqual([ + 'general', + 'billing', + 'desktop', + 'browser', + 'terminal', + ]) + expect(idsForSection('workspace')).toEqual([ + 'teammates', + 'secrets', + 'mcp', + 'custom-tools', + 'byok', + 'inbox', + 'workflow-mcp-servers', + 'apikeys', + 'sandboxes', + 'recently-deleted', + ]) + expect(idsForSection('organization')).toEqual([ + 'organization', + 'custom-blocks', + 'forks', + 'access-control', + 'audit-logs', + 'whitelabeling', + 'sso', + 'sessions', + 'data-retention', + 'data-drains', + ]) + expect(idsForSection('platform')).toEqual(['admin', 'mothership']) + }) + it('derives every unified item from exactly one registry entry', () => { expect(allNavigationItems).toHaveLength( SETTINGS_SECTION_REGISTRY.filter(({ unified }) => unified).length diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts index bcd36c0299e..d659a983c0b 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts @@ -16,12 +16,9 @@ export const isBillingEnabled = SETTINGS_NAVIGATION_BILLING_ENABLED export const sectionConfig: { key: NavigationSection; title: string }[] = [ { key: 'account', title: 'Account' }, - { key: 'tools', title: 'Tools' }, - { key: 'subscription', title: 'Subscription' }, - { key: 'system', title: 'System' }, - { key: 'desktop', title: 'Desktop' }, - { key: 'enterprise', title: 'Enterprise' }, - { key: 'superuser', title: 'Superuser' }, + { key: 'workspace', title: 'Workspace' }, + { key: 'organization', title: 'Organization' }, + { key: 'platform', title: 'Platform' }, ] export const allNavigationItems: NavigationItem[] = buildUnifiedSettingsNavigation() diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx index 64c756ea880..5654c2e6cb1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx @@ -316,7 +316,9 @@ export function SettingsSidebar({ .map(({ key, title }) => ({ key, title, - items: navigationItems.filter((item) => item.section === key), + items: navigationItems + .filter((item) => item.section === key) + .sort((left, right) => left.order - right.order), })) .filter(({ items }) => items.length > 0) .map(({ key, title, items: sectionItems }, index) => ( diff --git a/apps/sim/components/settings/navigation.test.ts b/apps/sim/components/settings/navigation.test.ts index 0bf912e7df4..ca1b92fd241 100644 --- a/apps/sim/components/settings/navigation.test.ts +++ b/apps/sim/components/settings/navigation.test.ts @@ -191,7 +191,7 @@ describe('settings navigation boundaries', () => { expect(organizationSso?.docsLink).toBe(unifiedSso?.docsLink) }) - it('keeps scope-specific labels only where the surface genuinely differs', () => { + it('uses scope-specific labels consistently across settings surfaces', () => { const organizationMembers = ORGANIZATION_SETTINGS_ITEMS.find(({ id }) => id === 'members') const unifiedOrganization = buildUnifiedSettingsNavigation().find( ({ id }) => id === 'organization' @@ -199,7 +199,37 @@ describe('settings navigation boundaries', () => { expect(organizationMembers?.label).toBe('Members') expect(organizationMembers?.description).toBe('Manage organization members, roles, and seats.') - expect(unifiedOrganization?.label).toBe('Organization') + expect(unifiedOrganization?.label).toBe('Members') + }) + + it('keeps self-host settings on their standalone account projection', () => { + expect( + SELFHOST_SETTINGS_ITEMS.map(({ id, label, description, group }) => ({ + id, + label, + description, + group, + })) + ).toEqual([ + { + id: 'general', + label: 'General', + description: 'Manage your profile, appearance, and preferences.', + group: 'account', + }, + { + id: 'billing', + label: 'Subscription', + description: 'Manage your personal plan, usage, and invoices.', + group: 'account', + }, + { + id: 'chat-keys', + label: 'Chat keys', + description: 'Manage the model-provider keys that power Chat.', + group: 'developer', + }, + ]) }) it('builds canonical settings hrefs across all three planes', () => { diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts index 3557160e1d1..1e9a2c79c79 100644 --- a/apps/sim/components/settings/navigation.ts +++ b/apps/sim/components/settings/navigation.ts @@ -120,14 +120,7 @@ export type UnifiedSettingsSection = | 'mothership' | 'recently-deleted' -export type UnifiedNavigationSection = - | 'account' - | 'subscription' - | 'tools' - | 'system' - | 'desktop' - | 'enterprise' - | 'superuser' +export type UnifiedNavigationSection = 'account' | 'workspace' | 'organization' | 'platform' /** * A bridge surface the desktop shell must expose for a section to be worth @@ -142,6 +135,7 @@ export interface UnifiedSettingsNavigationItem { description: string icon: ComponentType<{ className?: string }> section: UnifiedNavigationSection + order: number hideWhenBillingDisabled?: boolean requiresTeam?: boolean requiresEnterprise?: boolean @@ -384,6 +378,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] id: 'general', description: 'Manage your profile, appearance, and preferences.', group: 'account', + order: 0, }, planes: { account: { id: 'general', group: 'account', order: 0 }, @@ -396,7 +391,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'desktop', description: 'Manage notifications, startup, local folders, and updates.', - group: 'desktop', + group: 'account', + order: 2, requiresDesktopSurface: 'settings', }, }, @@ -406,7 +402,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'browser', description: 'Control the browser Chat drives and the data it keeps.', - group: 'desktop', + group: 'account', + order: 3, requiresDesktopSurface: 'browser', }, }, @@ -416,18 +413,20 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'terminal', description: 'Control the shells Chat runs commands in.', - group: 'desktop', + group: 'account', + order: 4, requiresDesktopSurface: 'terminal', }, }, { - label: 'Access control', + label: 'Permission groups', icon: ShieldCheck, docsLink: 'https://docs.sim.ai/platform/enterprise/access-control', unified: { id: 'access-control', description: 'Manage permission groups across your organization.', - group: 'enterprise', + group: 'organization', + order: 3, requiresHosted: true, requiresEnterprise: true, selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.accessControl, @@ -443,7 +442,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'audit-logs', description: 'Review activity and changes across your organization.', - group: 'enterprise', + group: 'organization', + order: 4, requiresHosted: true, requiresEnterprise: true, selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.auditLogs, @@ -453,25 +453,27 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] }, }, { - label: 'Workspace Forks', + label: 'Workspace forks', icon: Shuffle, docsLink: 'https://docs.sim.ai/platform/enterprise/forks', unified: { id: 'forks', description: 'Fork this workspace and sync changes with its parent.', - group: 'enterprise', + group: 'organization', + order: 2, }, planes: { workspace: { id: 'forks', group: 'enterprise', order: 10 }, }, }, { - label: 'Billing', + label: 'Subscription', icon: ClipboardList, unified: { id: 'billing', description: 'Manage your plan, pricing, and invoices.', - group: 'subscription', + group: 'account', + order: 1, hideWhenBillingDisabled: true, }, planes: { @@ -501,19 +503,21 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'teammates', description: 'Manage your teammates in this workspace.', - group: 'subscription', + group: 'workspace', + order: 0, }, planes: { workspace: { id: 'teammates', group: 'workspace', order: 0 }, }, }, { - label: 'Organization', + label: 'Members', icon: Users, unified: { id: 'organization', description: "Manage your organization's members and seats.", - group: 'subscription', + group: 'organization', + order: 0, hideWhenBillingDisabled: true, requiresHosted: true, requiresTeam: true, @@ -521,7 +525,6 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] planes: { organization: { id: 'members', - label: 'Members', description: 'Manage organization members, roles, and seats.', group: 'organization', order: 0, @@ -534,7 +537,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'secrets', description: 'Store environment variables for your workflows.', - group: 'account', + group: 'workspace', + order: 1, }, planes: { workspace: { id: 'secrets', group: 'workspace', order: 1 }, @@ -546,7 +550,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'custom-tools', description: 'Create and manage custom tools for your agents.', - group: 'tools', + group: 'workspace', + order: 3, }, planes: { workspace: { id: 'custom-tools', group: 'tools', order: 4 }, @@ -557,8 +562,9 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] icon: McpIcon, unified: { id: 'mcp', - description: 'Connect MCP servers and use their tools in workflows.', - group: 'tools', + description: 'Connect external MCP servers and use their tools in this workspace.', + group: 'workspace', + order: 2, }, planes: { workspace: { id: 'mcp', group: 'tools', order: 5 }, @@ -570,7 +576,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'apikeys', description: 'Create and manage API keys for the Sim API.', - group: 'system', + group: 'workspace', + order: 7, }, planes: { account: { @@ -592,8 +599,9 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] icon: Server, unified: { id: 'workflow-mcp-servers', - description: 'Expose your workflows as tools on an MCP server.', - group: 'system', + description: 'Expose workflows from this workspace as tools on an MCP server.', + group: 'workspace', + order: 6, }, planes: { workspace: { id: 'workflow-mcp-servers', group: 'tools', order: 6 }, @@ -605,7 +613,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'byok', description: 'Bring your own model-provider API keys.', - group: 'system', + group: 'workspace', + order: 4, requiresHosted: true, }, planes: { @@ -619,7 +628,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'sandboxes', description: 'Install Python or npm packages for Function blocks to import.', - group: 'system', + group: 'workspace', + order: 8, requiresMax: true, selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.sandboxes, showWhenLocked: true, @@ -641,12 +651,13 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] }, }, { - label: 'Sim mailer', + label: 'Sim Mailer', icon: Send, unified: { id: 'inbox', description: 'Trigger and process workflows from incoming email.', - group: 'system', + group: 'workspace', + order: 5, requiresMax: true, requiresHosted: true, selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.inbox, @@ -662,7 +673,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'recently-deleted', description: 'Restore items deleted in the last 30 days.', - group: 'system', + group: 'workspace', + order: 9, }, planes: { workspace: { id: 'recently-deleted', group: 'system', order: 9 }, @@ -675,7 +687,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'sso', description: 'Configure single sign-on for your organization.', - group: 'enterprise', + group: 'organization', + order: 6, requiresHosted: true, requiresEnterprise: true, selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.sso, @@ -691,7 +704,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'sessions', description: 'Limit session lifetimes and sign out members org-wide.', - group: 'enterprise', + group: 'organization', + order: 7, requiresHosted: true, requiresEnterprise: true, selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.sessionPolicies, @@ -708,7 +722,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] id: 'data-retention', description: 'Control data retention windows and PII redaction. Workspaces without an override inherit the organization defaults.', - group: 'enterprise', + group: 'organization', + order: 8, requiresHosted: true, requiresEnterprise: true, selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.dataRetention, @@ -724,7 +739,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'data-drains', description: 'Stream your logs and events to external destinations.', - group: 'enterprise', + group: 'organization', + order: 9, requiresHosted: true, requiresEnterprise: true, selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.dataDrains, @@ -734,13 +750,14 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] }, }, { - label: 'Whitelabeling', + label: 'White-labeling', icon: Palette, docsLink: 'https://docs.sim.ai/platform/enterprise/whitelabeling', unified: { id: 'whitelabeling', description: 'Customize your workspace branding and appearance.', - group: 'enterprise', + group: 'organization', + order: 5, requiresHosted: true, requiresEnterprise: true, selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.whitelabeling, @@ -756,7 +773,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'custom-blocks', description: 'Publish workflows as reusable blocks for your organization.', - group: 'enterprise', + group: 'organization', + order: 1, requiresHosted: true, requiresEnterprise: true, allowNonOrgAdmin: true, @@ -772,7 +790,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'admin', description: 'Superuser administration and workspace tools.', - group: 'superuser', + group: 'platform', + order: 0, requiresAdminRole: true, }, planes: { @@ -785,7 +804,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'mothership', description: 'Internal Sim operations and license management.', - group: 'superuser', + group: 'platform', + order: 1, requiresAdminRole: true, }, planes: { diff --git a/apps/sim/components/settings/standalone-settings-shell.test.ts b/apps/sim/components/settings/standalone-settings-shell.test.ts index 6927d523def..720bcdfff56 100644 --- a/apps/sim/components/settings/standalone-settings-shell.test.ts +++ b/apps/sim/components/settings/standalone-settings-shell.test.ts @@ -8,6 +8,7 @@ import { ORGANIZATION_SETTINGS_ITEMS, ORGANIZATION_SETTINGS_PATH_ALIASES, parseSettingsPathSection, + SELFHOST_SETTINGS_ITEMS, } from '@/components/settings/navigation' describe('standalone settings section resolution', () => { @@ -32,4 +33,14 @@ describe('standalone settings section resolution', () => { }) ).toBe('audit-logs') }) + + it('keeps Subscription active for the self-host billing route', () => { + expect( + parseSettingsPathSection({ + path: '/selfhost/settings/billing', + items: SELFHOST_SETTINGS_ITEMS, + defaultSection: 'general', + }) + ).toBe('billing') + }) }) diff --git a/apps/sim/lib/api/contracts/deployments.ts b/apps/sim/lib/api/contracts/deployments.ts index db8e82adb6b..197ebec5c22 100644 --- a/apps/sim/lib/api/contracts/deployments.ts +++ b/apps/sim/lib/api/contracts/deployments.ts @@ -80,6 +80,7 @@ export const deploymentOperationSummarySchema = z.object({ version: z.number().int().positive(), action: z.enum(DEPLOYMENT_OPERATION_ACTIONS), status: deploymentOperationStatusSchema, + isCurrent: z.boolean().optional().default(true), readiness: deploymentReadinessSchema, requestedAt: z.string(), activatedAt: z.string().nullable().optional(), diff --git a/apps/sim/lib/api/contracts/v1/workflows.ts b/apps/sim/lib/api/contracts/v1/workflows.ts index 10ef1c35205..8f15312ffaf 100644 --- a/apps/sim/lib/api/contracts/v1/workflows.ts +++ b/apps/sim/lib/api/contracts/v1/workflows.ts @@ -102,7 +102,9 @@ const v1DeploymentStateSchema = z.object({ * accepted, while `isDeployed` reflects whether a version is actually live. * `latestDeploymentAttempt` carries the lifecycle status * (preparing/activating/active/failed/superseded) so API consumers can poll - * to a terminal state instead of guessing from `isDeployed` alone. + * to a terminal state instead of guessing from `isDeployed` alone. Its + * `isCurrent` field is false when the operation is historical and no longer + * describes the active deployment. */ const v1DeploymentLifecycleSchema = v1DeploymentStateSchema.extend({ activeDeployment: activeDeploymentSummarySchema.nullable(), diff --git a/apps/sim/lib/copilot/request/tools/executor.test.ts b/apps/sim/lib/copilot/request/tools/executor.test.ts index 656feb72663..8d60e889a05 100644 --- a/apps/sim/lib/copilot/request/tools/executor.test.ts +++ b/apps/sim/lib/copilot/request/tools/executor.test.ts @@ -3,9 +3,11 @@ import '@sim/testing/mocks/executor' import { describe, expect, it } from 'vitest' import { TOOL_WATCHDOG_DEFAULT_MS, TOOL_WATCHDOG_LONG_RUNNING_MS } from '@/lib/copilot/constants' import { + buildToolExecutionContext, pendingToolWaitBudgetMs, toolWatchdogTimeoutMs, } from '@/lib/copilot/request/tools/executor' +import type { ExecutionContext } from '@/lib/copilot/request/types' describe('toolWatchdogTimeoutMs', () => { it('gives request-scoped MCP tools the long-running watchdog', () => { @@ -32,3 +34,27 @@ describe('pendingToolWaitBudgetMs', () => { ) }) }) + +describe('buildToolExecutionContext', () => { + it('threads logical tool-call identity into the handler context', () => { + const executionContext: ExecutionContext = { + userId: 'user-1', + workflowId: 'workflow-1', + runId: 'run-1', + } + + expect( + buildToolExecutionContext( + { + id: 'call-1', + parentToolCallId: 'parent-1', + }, + executionContext + ) + ).toMatchObject({ + runId: 'run-1', + toolCallId: 'call-1', + parentToolCallId: 'parent-1', + }) + }) +}) diff --git a/apps/sim/lib/copilot/request/tools/executor.ts b/apps/sim/lib/copilot/request/tools/executor.ts index 17106c64ce3..10f0f84402c 100644 --- a/apps/sim/lib/copilot/request/tools/executor.ts +++ b/apps/sim/lib/copilot/request/tools/executor.ts @@ -255,6 +255,18 @@ class ToolExecutionTimeoutError extends Error { } } +/** Builds the per-call context from the turn-scoped execution context. */ +export function buildToolExecutionContext( + toolCall: Pick, + execContext: ExecutionContext +): ExecutionContext { + return { + ...execContext, + toolCallId: toolCall.id, + ...(toolCall.parentToolCallId ? { parentToolCallId: toolCall.parentToolCallId } : {}), + } +} + /** * Execute a tool with a hard settlement guarantee. If the handler neither * resolves nor rejects within the tool's watchdog cap, throw a timeout error @@ -265,12 +277,7 @@ class ToolExecutionTimeoutError extends Error { */ async function executeToolWithWatchdog(toolCall: ToolCallState, execContext: ExecutionContext) { const timeoutMs = toolWatchdogTimeoutMs(toolCall.name) - // Thread the invoking subagent's channel id per call (execContext is shared - // across the whole turn, so the channel id can't live on it) — server tools - // use it to scope the workspace_file -> edit_content intent handoff. - const toolContext = toolCall.parentToolCallId - ? { ...execContext, parentToolCallId: toolCall.parentToolCallId } - : execContext + const toolContext = buildToolExecutionContext(toolCall, execContext) const execution = executeTool(toolCall.name, toolCall.params || {}, toolContext) let timer: ReturnType | undefined try { diff --git a/apps/sim/lib/copilot/request/tools/permission.test.ts b/apps/sim/lib/copilot/request/tools/permission.test.ts index 4cd2d3f7140..5c75b645730 100644 --- a/apps/sim/lib/copilot/request/tools/permission.test.ts +++ b/apps/sim/lib/copilot/request/tools/permission.test.ts @@ -94,6 +94,18 @@ describe('toolCallNeedsApproval', () => { expect(toolCallNeedsApproval('terminal', context, {}, false, runCall)).toBe(false) }) + it.each(['deploy_api', 'deploy_chat', 'deploy_mcp'])( + 'honors the saved permission for a %s undeploy', + (toolName) => { + const context = makeContext() + context.toolPermissions.autoAllowed.add(toolName) + + expect(toolCallNeedsApproval(toolName, context, {}, false, { action: 'undeploy' })).toBe( + false + ) + } + ) + it('applies the normal saved permission to code with a secret reference', () => { const context = makeContext() context.toolPermissions.autoAllowed.add('function_execute') diff --git a/apps/sim/lib/copilot/tool-executor/types.ts b/apps/sim/lib/copilot/tool-executor/types.ts index 93db4b4eb23..f25e27e24c2 100644 --- a/apps/sim/lib/copilot/tool-executor/types.ts +++ b/apps/sim/lib/copilot/tool-executor/types.ts @@ -11,6 +11,8 @@ export interface ToolExecutionContext { messageId?: string executionId?: string runId?: string + /** Stable identity of the individual tool call being executed. */ + toolCallId?: string billingAttribution?: BillingAttributionSnapshot copilotToolExecution?: boolean requestMode?: string diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/context.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/context.test.ts new file mode 100644 index 00000000000..3fb9d61ea97 --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/deployment/context.test.ts @@ -0,0 +1,39 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + getCopilotDeploymentIdempotencyKey, + getHistoricalDeploymentAttemptError, +} from '@/lib/copilot/tools/handlers/deployment/context' + +describe('getCopilotDeploymentIdempotencyKey', () => { + it('is stable for a replay of the same logical tool call', () => { + const context = { executionId: 'execution-1', runId: 'run-1', toolCallId: 'call-1' } + + expect(getCopilotDeploymentIdempotencyKey(context)).toBe( + getCopilotDeploymentIdempotencyKey(context) + ) + }) + + it('separates different tool calls within the same Mothership execution', () => { + expect( + getCopilotDeploymentIdempotencyKey({ executionId: 'execution-1', toolCallId: 'call-1' }) + ).not.toBe( + getCopilotDeploymentIdempotencyKey({ executionId: 'execution-1', toolCallId: 'call-2' }) + ) + }) + + it('does not derive a turn-wide key when the tool-call identity is unavailable', () => { + expect(getCopilotDeploymentIdempotencyKey({ executionId: 'execution-1' })).toBeUndefined() + }) +}) + +describe('getHistoricalDeploymentAttemptError', () => { + it('requires a new tool call when the persisted attempt is no longer current', () => { + expect(getHistoricalDeploymentAttemptError({ isCurrent: false }, 'redeploy')).toContain( + 'Start a new tool call' + ) + expect(getHistoricalDeploymentAttemptError({ isCurrent: true }, 'redeploy')).toBeNull() + }) +}) diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/context.ts b/apps/sim/lib/copilot/tools/handlers/deployment/context.ts new file mode 100644 index 00000000000..c4affb106a6 --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/deployment/context.ts @@ -0,0 +1,35 @@ +import type { ToolExecutionContext } from '@/lib/copilot/tool-executor/types' + +type DeploymentToolContext = Pick< + ToolExecutionContext, + 'executionId' | 'messageId' | 'runId' | 'toolCallId' +> + +interface DeploymentAttemptCurrentState { + isCurrent?: boolean +} + +/** + * Builds a replay-stable idempotency key for one logical Copilot tool call. + * The orchestration layer generates a fresh key when legacy callers do not + * provide a tool-call identity. + */ +export function getCopilotDeploymentIdempotencyKey( + context: DeploymentToolContext +): string | undefined { + if (!context.toolCallId) return undefined + + const executionScope = context.executionId ?? context.runId ?? context.messageId + return executionScope + ? `copilot:${executionScope}:tool-call:${context.toolCallId}` + : `copilot:tool-call:${context.toolCallId}` +} + +/** Rejects a replay whose persisted operation no longer describes production. */ +export function getHistoricalDeploymentAttemptError( + attempt: DeploymentAttemptCurrentState | null | undefined, + action: string +): string | null { + if (attempt?.isCurrent !== false) return null + return `The ${action} operation associated with this tool call is historical and no longer describes production. Start a new tool call to create a new logical deployment operation.` +} diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.test.ts new file mode 100644 index 00000000000..e0595796572 --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.test.ts @@ -0,0 +1,237 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckChatAccess, + mockEnsureWorkflowAccess, + mockPerformChatUndeploy, + mockPerformDeleteWorkflowMcpTool, + mockPerformFullDeploy, + mockPerformFullUndeploy, +} = vi.hoisted(() => ({ + mockCheckChatAccess: vi.fn(), + mockEnsureWorkflowAccess: vi.fn(), + mockPerformChatUndeploy: vi.fn(), + mockPerformDeleteWorkflowMcpTool: vi.fn(), + mockPerformFullDeploy: vi.fn(), + mockPerformFullUndeploy: vi.fn(), +})) + +vi.mock('@/lib/workflows/orchestration', () => ({ + performChatDeploy: vi.fn(), + performChatUndeploy: mockPerformChatUndeploy, + performFullDeploy: mockPerformFullDeploy, + performFullUndeploy: mockPerformFullUndeploy, +})) + +vi.mock('@/lib/mcp/orchestration', () => ({ + performCreateWorkflowMcpTool: vi.fn(), + performDeleteWorkflowMcpTool: mockPerformDeleteWorkflowMcpTool, + performUpdateWorkflowMcpTool: vi.fn(), +})) + +vi.mock('@/lib/mcp/workflow-mcp-sync', () => ({ + getDeployedWorkflowInputFormat: vi.fn(), +})) + +vi.mock('@/lib/mcp/workflow-tool-schema', () => ({ + applyDescriptionOverrides: vi.fn(), + generateToolInputSchema: vi.fn(), + sanitizeToolName: vi.fn(), +})) + +vi.mock('@/app/api/chat/utils', () => ({ + checkChatAccess: mockCheckChatAccess, + checkWorkflowAccessForChatCreation: vi.fn(), +})) + +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + ChatDeployAuthNotAllowedError: class ChatDeployAuthNotAllowedError extends Error {}, + validateChatDeployAuth: vi.fn(), +})) + +vi.mock('@/lib/copilot/tools/handlers/access', () => ({ + ensureWorkflowAccess: mockEnsureWorkflowAccess, +})) + +import { + executeDeployApi, + executeDeployChat, + executeDeployMcp, + executeRedeploy, +} from '@/lib/copilot/tools/handlers/deployment/deploy' + +describe('deployment handlers', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockEnsureWorkflowAccess.mockResolvedValue({ + workflow: { id: 'workflow-1', workspaceId: 'workspace-1' }, + }) + }) + + it('undeploys the API without approval context when permission gating is disabled', async () => { + mockPerformFullUndeploy.mockResolvedValue({ success: true }) + + const result = await executeDeployApi( + { workflowId: 'workflow-1', action: 'undeploy' }, + { + userId: 'user-1', + workflowId: 'workflow-1', + toolCallId: 'call-1', + } + ) + + expect(result.success).toBe(true) + expect(mockPerformFullUndeploy).toHaveBeenCalledWith({ + workflowId: 'workflow-1', + userId: 'user-1', + }) + }) + + it('uses the tool-call identity for deployment idempotency', async () => { + mockPerformFullDeploy.mockResolvedValue({ + success: true, + activeDeployment: null, + latestDeploymentAttempt: { status: 'preparing' }, + }) + + await executeDeployApi( + { + workflowId: 'workflow-1', + action: 'deploy', + versionName: 'Safe deploy', + versionDescription: 'Deploy the latest workflow changes', + }, + { + userId: 'user-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + toolCallId: 'call-1', + } + ) + + expect(mockPerformFullDeploy).toHaveBeenCalledWith( + expect.objectContaining({ + idempotencyKey: 'copilot:execution-1:tool-call:call-1', + }) + ) + }) + + it('rejects a replay whose active deployment attempt became historical', async () => { + mockPerformFullDeploy.mockResolvedValue({ + success: true, + activeDeployment: null, + latestDeploymentAttempt: { status: 'active', isCurrent: false }, + }) + + const result = await executeDeployApi( + { + workflowId: 'workflow-1', + action: 'deploy', + versionName: 'Safe deploy', + versionDescription: 'Deploy the latest workflow changes', + }, + { + userId: 'user-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + toolCallId: 'call-1', + } + ) + + expect(result).toMatchObject({ + success: false, + error: expect.stringContaining('historical'), + }) + }) + + it('does not report a historical active attempt as a successful redeploy', async () => { + mockPerformFullDeploy.mockResolvedValue({ + success: true, + activeDeployment: null, + latestDeploymentAttempt: { status: 'active', isCurrent: false }, + }) + + const result = await executeRedeploy( + { + workflowId: 'workflow-1', + versionName: 'Safe redeploy', + versionDescription: 'Redeploy the latest workflow changes', + }, + { + userId: 'user-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + toolCallId: 'call-1', + } + ) + + expect(result).toMatchObject({ + success: false, + error: expect.stringContaining('historical'), + }) + }) + + it('undeploys chat without approval context when permission gating is disabled', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'chat-1', + identifier: 'production-helper', + title: 'Production Helper', + description: null, + authType: 'public', + allowedEmails: [], + outputConfigs: [], + includeThinking: false, + includeToolCalls: false, + customizations: null, + }, + ]) + mockCheckChatAccess.mockResolvedValue({ hasAccess: true, workspaceId: 'workspace-1' }) + mockPerformChatUndeploy.mockResolvedValue({ success: true }) + + const result = await executeDeployChat( + { workflowId: 'workflow-1', action: 'undeploy' }, + { + userId: 'user-1', + workflowId: 'workflow-1', + toolCallId: 'call-1', + } + ) + + expect(result.success).toBe(true) + expect(mockPerformChatUndeploy).toHaveBeenCalledWith({ + chatId: 'chat-1', + userId: 'user-1', + workspaceId: 'workspace-1', + }) + }) + + it('undeploys MCP without approval context when permission gating is disabled', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([{ id: 'server-1', name: 'Production MCP' }]) + .mockResolvedValueOnce([{ id: 'tool-1' }]) + mockPerformDeleteWorkflowMcpTool.mockResolvedValue({ success: true }) + + const result = await executeDeployMcp( + { workflowId: 'workflow-1', serverId: 'server-1', action: 'undeploy' }, + { + userId: 'user-1', + workflowId: 'workflow-1', + toolCallId: 'call-1', + } + ) + + expect(result.success).toBe(true) + expect(mockPerformDeleteWorkflowMcpTool).toHaveBeenCalledWith({ + serverId: 'server-1', + toolId: 'tool-1', + workspaceId: 'workspace-1', + userId: 'user-1', + }) + }) +}) diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts index f0d14b9ba29..a1db42f3e4c 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts @@ -28,6 +28,7 @@ import { } from '@/ee/access-control/utils/permission-check' import { ensureWorkflowAccess } from '../access' import type { DeployApiParams, DeployChatParams, DeployMcpParams } from '../param-types' +import { getCopilotDeploymentIdempotencyKey, getHistoricalDeploymentAttemptError } from './context' function buildWorkflowApiEndpoint(baseUrl: string, workflowId: string): string { return `${baseUrl}/api/workflows/${workflowId}/execute` @@ -190,10 +191,16 @@ export async function executeDeployApi( userId: context.userId, versionDescription, versionName, + idempotencyKey: getCopilotDeploymentIdempotencyKey(context), }) if (!result.success) { return { success: false, error: result.error || 'Failed to deploy workflow' } } + const historicalAttemptError = getHistoricalDeploymentAttemptError( + result.latestDeploymentAttempt, + 'deploy' + ) + if (historicalAttemptError) return { success: false, error: historicalAttemptError } const baseUrl = getBaseUrl() const apiEndpoint = buildWorkflowApiEndpoint(baseUrl, workflowId) @@ -451,6 +458,7 @@ export async function executeDeployChat( includeThinking: resolvedIncludeThinking, includeToolCalls: resolvedIncludeToolCalls, workspaceId: workflowRecord.workspaceId, + idempotencyKey: getCopilotDeploymentIdempotencyKey(context), }) if (!result.success) { @@ -827,10 +835,16 @@ export async function executeRedeploy( userId: context.userId, versionDescription, versionName, + idempotencyKey: getCopilotDeploymentIdempotencyKey(context), }) if (!result.success) { return { success: false, error: result.error || 'Failed to redeploy workflow' } } + const historicalAttemptError = getHistoricalDeploymentAttemptError( + result.latestDeploymentAttempt, + 'redeploy' + ) + if (historicalAttemptError) return { success: false, error: historicalAttemptError } const baseUrl = getBaseUrl() const apiEndpoint = buildWorkflowApiEndpoint(baseUrl, workflowId) const apiConfig = buildWorkflowApiConfig(baseUrl, apiEndpoint) diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts index eb2cc11467b..fd8ba3c06d2 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts @@ -169,12 +169,18 @@ describe('executePromoteToLive', () => { performActivateVersionMock.mockResolvedValue({ success: true, deployedAt: new Date('2026-05-30T00:00:00.000Z'), + activeDeployment: { + deploymentVersionId: 'dv-3', + version: 3, + deployedAt: '2026-05-30T00:00:00.000Z', + }, latestDeploymentAttempt: { id: 'op-1', deploymentVersionId: 'dv-3', version: 3, action: 'activate', status: 'active', + isCurrent: true, readiness: { webhooks: 'ready', schedules: 'ready', mcp: 'ready' }, requestedAt: '2026-05-30T00:00:00.000Z', activatedAt: '2026-05-30T00:00:00.000Z', @@ -185,6 +191,8 @@ describe('executePromoteToLive', () => { const result = await executePromoteToLive({ workflowId: 'wf-1', version: 3 }, { userId: 'user-1', workflowId: 'wf-1', + executionId: 'execution-1', + toolCallId: 'call-1', } as ExecutionContext) expect(ensureWorkflowAccessMock).toHaveBeenCalledWith('wf-1', 'user-1', 'admin') @@ -192,6 +200,7 @@ describe('executePromoteToLive', () => { workflowId: 'wf-1', version: 3, userId: 'user-1', + idempotencyKey: 'copilot:execution-1:tool-call:call-1', }) expect(result.success).toBe(true) expect(result.output).toMatchObject({ @@ -203,6 +212,37 @@ describe('executePromoteToLive', () => { }) }) + it('does not report a historical active operation as a successful promotion', async () => { + performActivateVersionMock.mockResolvedValue({ + success: true, + activeDeployment: null, + latestDeploymentAttempt: { + id: 'op-old', + deploymentVersionId: 'dv-3', + version: 3, + action: 'activate', + status: 'active', + isCurrent: false, + readiness: { webhooks: 'ready', schedules: 'ready', mcp: 'ready' }, + requestedAt: '2026-05-30T00:00:00.000Z', + activatedAt: '2026-05-30T00:00:00.000Z', + error: null, + }, + }) + + const result = await executePromoteToLive({ workflowId: 'wf-1', version: 3 }, { + userId: 'user-1', + workflowId: 'wf-1', + executionId: 'execution-1', + toolCallId: 'call-1', + } as ExecutionContext) + + expect(result).toMatchObject({ + success: false, + error: expect.stringContaining('historical'), + }) + }) + it('rejects a non-numeric version like "live"', async () => { const result = await executePromoteToLive({ workflowId: 'wf-1', version: 'live' as never }, { userId: 'user-1', @@ -376,4 +416,44 @@ describe('executeCheckDeploymentStatus', () => { }, }) }) + + it('separates a historical active attempt from the current undeployed state', async () => { + getWorkflowDeploymentSummaryMock.mockResolvedValue({ + activeDeployment: null, + latestDeploymentAttempt: { + id: 'op-historical', + deploymentVersionId: 'dv-old', + version: 1, + action: 'deploy', + status: 'active', + isCurrent: false, + readiness: { webhooks: 'ready', schedules: 'ready', mcp: 'ready' }, + requestedAt: '2026-05-28T00:00:00.000Z', + activatedAt: '2026-05-28T00:00:00.000Z', + error: null, + }, + warnings: ['The latest successful deployment attempt is historical.'], + }) + queueTableRows(schemaMock.workflow, [{ deployedAt: null }]) + + const result = await executeCheckDeploymentStatus({ workflowId: 'wf-1' }, { + userId: 'user-1', + workflowId: 'wf-1', + } as ExecutionContext) + + expect(result.success).toBe(true) + expect(result.output).toMatchObject({ + isDeployed: false, + api: { + isDeployed: false, + activeDeployment: null, + latestDeploymentAttempt: { + status: 'active', + isCurrent: false, + }, + currentDeploymentAttempt: null, + warnings: [expect.stringContaining('historical')], + }, + }) + }) }) diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts index ff61d1762d1..4224a0d0ce5 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts @@ -32,6 +32,7 @@ import type { UpdateDeploymentVersionParams, UpdateWorkspaceMcpServerParams, } from '../param-types' +import { getCopilotDeploymentIdempotencyKey, getHistoricalDeploymentAttemptError } from './context' import { resolveWorkflowStateRef } from './state-refs' export async function executeCheckDeploymentStatus( @@ -79,6 +80,9 @@ export async function executeCheckDeploymentStatus( */ const isApiDeployed = deploymentSummary.activeDeployment !== null const needsRedeployment = isApiDeployed ? await checkNeedsRedeployment(workflowId) : false + const currentDeploymentAttempt = deploymentSummary.latestDeploymentAttempt?.isCurrent + ? deploymentSummary.latestDeploymentAttempt + : null const apiDetails = { isDeployed: isApiDeployed, deployedAt: apiDeploy[0]?.deployedAt || null, @@ -87,6 +91,7 @@ export async function executeCheckDeploymentStatus( needsRedeployment, activeDeployment: deploymentSummary.activeDeployment, latestDeploymentAttempt: deploymentSummary.latestDeploymentAttempt, + currentDeploymentAttempt, warnings: deploymentSummary.warnings ?? [], } @@ -557,13 +562,19 @@ export async function executePromoteToLive( workflowId, version, userId: context.userId, + idempotencyKey: getCopilotDeploymentIdempotencyKey(context), }) if (!result.success) { return { success: false, error: result.error || 'Failed to promote version' } } + const historicalAttemptError = getHistoricalDeploymentAttemptError( + result.latestDeploymentAttempt, + 'promotion' + ) + if (historicalAttemptError) return { success: false, error: historicalAttemptError } - const isActive = result.latestDeploymentAttempt?.status === 'active' + const isActive = result.activeDeployment?.version === version return { success: true, output: { diff --git a/apps/sim/lib/workflows/orchestration/chat-deploy.test.ts b/apps/sim/lib/workflows/orchestration/chat-deploy.test.ts index 7b48bab36e1..9ee006b26be 100644 --- a/apps/sim/lib/workflows/orchestration/chat-deploy.test.ts +++ b/apps/sim/lib/workflows/orchestration/chat-deploy.test.ts @@ -89,4 +89,24 @@ describe('performChatDeploy password guards', () => { error: 'Password is required when using password protection', }) }) + + it('does not create a chat from a historical active deployment attempt', async () => { + mockGetWorkflowDeploymentSummary.mockResolvedValue({ + activeDeployment: null, + latestDeploymentAttempt: { status: 'active', isCurrent: false }, + warnings: [], + }) + mockPerformFullDeploy.mockResolvedValue({ + success: true, + activeDeployment: null, + latestDeploymentAttempt: { status: 'active', isCurrent: false }, + }) + + const result = await performChatDeploy(basePayload) + + expect(result).toMatchObject({ + success: false, + error: expect.stringContaining('historical'), + }) + }) }) diff --git a/apps/sim/lib/workflows/orchestration/chat-deploy.ts b/apps/sim/lib/workflows/orchestration/chat-deploy.ts index 106cb090df0..cdfffe62237 100644 --- a/apps/sim/lib/workflows/orchestration/chat-deploy.ts +++ b/apps/sim/lib/workflows/orchestration/chat-deploy.ts @@ -35,6 +35,8 @@ export interface ChatDeployPayload { /** When true, public SSE may expose tool lifecycle if the client opts into agent-events-v1. */ includeToolCalls?: boolean workspaceId?: string | null + /** Stable identity for the underlying workflow deployment operation. */ + idempotencyKey?: string } export interface PerformChatDeployResult { @@ -114,10 +116,18 @@ export async function performChatDeploy( userId, versionDescription: params.versionDescription, versionName: params.versionName, + idempotencyKey: params.idempotencyKey, }) if (!deployResult.success) { return { success: false, error: deployResult.error || 'Failed to deploy workflow' } } + if (deployResult.latestDeploymentAttempt?.isCurrent === false) { + return { + success: false, + error: + 'The workflow deployment attempt is historical and no longer describes production. Retry chat deployment as a new tool call.', + } + } if (deployResult.latestDeploymentAttempt?.status !== 'active') { return { success: false, @@ -126,6 +136,12 @@ export async function performChatDeploy( 'Workflow deployment is still preparing. Retry chat deployment after it becomes active.', } } + if (!deployResult.activeDeployment) { + return { + success: false, + error: 'Workflow deployment reported active without a live deployment version.', + } + } } let encryptedPassword: string | null = null diff --git a/apps/sim/lib/workflows/orchestration/deploy.test.ts b/apps/sim/lib/workflows/orchestration/deploy.test.ts index e70aea0eba0..4d194892e17 100644 --- a/apps/sim/lib/workflows/orchestration/deploy.test.ts +++ b/apps/sim/lib/workflows/orchestration/deploy.test.ts @@ -101,6 +101,7 @@ vi.mock('@/lib/workflows/schedules', () => ({ // Resolves to the global @sim/platform-authz/workflow mock, so instanceof matches. import { WorkflowLockedError } from '@sim/platform-authz/workflow' import { + getWorkflowDeploymentSummary, performActivateVersion, performFullDeploy, performFullUndeploy, @@ -262,6 +263,50 @@ describe('performFullDeploy workspace event emission', () => { }) }) + it('marks the latest active operation historical when no matching version is live', async () => { + const now = new Date('2026-07-14T08:00:00.000Z') + mockGetWorkflowDeploymentStatus.mockResolvedValueOnce({ + activeDeployment: null, + latestOperation: { + id: 'operation-historical', + workflowId: 'workflow-1', + deploymentVersionId: 'dv-old', + version: 3, + previousActiveVersionId: null, + action: 'deploy', + protocolVersion: 2, + generation: 1, + status: 'active', + componentReadiness: { + webhooks: { status: 'ready', updatedAt: now.toISOString() }, + schedules: { status: 'ready', updatedAt: now.toISOString() }, + mcp: { status: 'ready', updatedAt: now.toISOString() }, + }, + errorCode: null, + errorMessage: null, + idempotencyKey: 'request-historical', + requestHash: 'hash', + actorId: 'user-1', + completedAt: now, + createdAt: now, + updatedAt: now, + }, + }) + + const result = await getWorkflowDeploymentSummary('workflow-1') + + expect(result).toMatchObject({ + activeDeployment: null, + latestDeploymentAttempt: { + id: 'operation-historical', + status: 'active', + isCurrent: false, + error: null, + }, + warnings: [expect.stringContaining('historical')], + }) + }) + it('always admits deploys through v2 without legacy immediate activation', async () => { const result = await performFullDeploy({ workflowId: 'workflow-1', @@ -277,6 +322,62 @@ describe('performFullDeploy workspace event emission', () => { expect(mockEmitWorkflowDeployedEvent).not.toHaveBeenCalled() }) + it('does not reuse a correlation request ID as an implicit idempotency key', async () => { + queueTableRows(schemaMock.workflow, [ + { id: 'workflow-1', name: 'My Workflow', workspaceId: 'workspace-1' }, + { id: 'workflow-1', name: 'My Workflow', workspaceId: 'workspace-1' }, + ]) + + const params = { workflowId: 'workflow-1', userId: 'user-1', requestId: 'request-1' } + await performFullDeploy(params) + await performFullDeploy(params) + + const firstKey = mockPrepareWorkflowDeployment.mock.calls[0][0].idempotencyKey + const secondKey = mockPrepareWorkflowDeployment.mock.calls[1][0].idempotencyKey + expect(firstKey).toEqual(expect.any(String)) + expect(secondKey).toEqual(expect.any(String)) + expect(firstKey).not.toBe('request-1') + expect(firstKey).not.toBe(secondKey) + }) + + it('keeps the request hash stable across snapshot timestamps and edge order', async () => { + queueTableRows(schemaMock.workflow, [ + { id: 'workflow-1', name: 'My Workflow', workspaceId: 'workspace-1' }, + { id: 'workflow-1', name: 'My Workflow', workspaceId: 'workspace-1' }, + ]) + const baseState = { + blocks: {}, + edges: [ + { id: 'edge-b', source: 'block-2', target: 'block-3' }, + { id: 'edge-a', source: 'block-1', target: 'block-2' }, + ], + loops: {}, + parallels: {}, + variables: {}, + lastSaved: 1, + } + mockLoadWorkflowDeploymentSnapshot.mockResolvedValueOnce(baseState).mockResolvedValueOnce({ + ...baseState, + edges: [...baseState.edges].reverse(), + lastSaved: 2, + }) + + const params = { + workflowId: 'workflow-1', + userId: 'user-1', + idempotencyKey: 'copilot:execution-1:tool-call:call-1', + } + await performFullDeploy(params) + await performFullDeploy(params) + + expect(mockPrepareWorkflowDeployment.mock.calls[0][0].requestHash).toBe( + mockPrepareWorkflowDeployment.mock.calls[1][0].requestHash + ) + expect(mockPrepareWorkflowDeployment.mock.calls[0][0].idempotencyKey).toBe( + 'copilot:execution-1:tool-call:call-1' + ) + }) + it('keeps a first deploy pending without claiming an active deployment', async () => { const now = new Date('2026-07-14T08:00:00.000Z') const operation = { diff --git a/apps/sim/lib/workflows/orchestration/deploy.ts b/apps/sim/lib/workflows/orchestration/deploy.ts index 2abd4e04e67..5ab57a53b0d 100644 --- a/apps/sim/lib/workflows/orchestration/deploy.ts +++ b/apps/sim/lib/workflows/orchestration/deploy.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' import { sha256Hex } from '@sim/security/hash' import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { env } from '@/lib/core/config/env' @@ -11,6 +12,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { getSocketServerUrl } from '@/lib/core/utils/urls' import { captureServerEvent } from '@/lib/posthog/server' import { validateTriggerWebhookConfigForDeploy } from '@/lib/webhooks/deploy' +import { normalizedStringify } from '@/lib/workflows/comparison/normalize' import { DEPLOYMENT_ERROR_CODES, type DeploymentComponentStatus, @@ -59,6 +61,8 @@ export interface DeploymentAttemptResult { version: number action: 'deploy' | 'activate' status: 'preparing' | 'activating' | 'active' | 'failed' | 'superseded' + /** Whether this attempt still describes the workflow's current deployment lifecycle. */ + isCurrent: boolean readiness: { webhooks: DeploymentReadinessSummaryStatus schedules: DeploymentReadinessSummaryStatus @@ -88,6 +92,9 @@ export interface PerformFullDeployParams { * endpoint, so it stays optional here. */ versionName?: string + /** Stable identity for one logical deployment operation. */ + idempotencyKey?: string + /** Correlation ID for logging and outbox tracing. */ requestId?: string /** * Override the actor ID used in audit logs and the `deployedBy` field. @@ -125,7 +132,9 @@ export interface PerformFullDeployResult { /** * Admits a deployment through the v2 prepare/activate protocol. The candidate - * version remains inactive until every required side effect is ready. + * version remains inactive until every required side effect is ready. Callers + * that can replay a logical operation must provide a stable `idempotencyKey`; + * `requestId` is correlation metadata only. */ export async function performFullDeploy( params: PerformFullDeployParams @@ -133,6 +142,7 @@ export async function performFullDeploy( const { workflowId, userId } = params const actorId = params.actorId ?? userId const requestId = params.requestId ?? generateRequestId() + const idempotencyKey = params.idempotencyKey ?? generateId() // Backstop for every caller — routes may assert first to render their own 423, // but the copilot deploy tools call this directly. @@ -154,6 +164,7 @@ export async function performFullDeploy( params, actorId, requestId, + idempotencyKey, }) } catch (error) { logger.error(`[${requestId}] Deployment preparation failed`, { workflowId, error }) @@ -169,6 +180,7 @@ async function performStableFullDeploy(params: { params: PerformFullDeployParams actorId: string requestId: string + idempotencyKey: string }): Promise { const workflowState = await loadWorkflowDeploymentSnapshot(params.params.workflowId) if (!workflowState) { @@ -190,11 +202,11 @@ async function performStableFullDeploy(params: { action: 'deploy', workflowId: params.params.workflowId, userId: params.params.userId, - workflowState, + workflowState: canonicalizeDeploymentWorkflowState(workflowState), versionName: params.params.versionName ?? null, versionDescription: params.params.versionDescription ?? null, }), - idempotencyKey: params.requestId, + idempotencyKey: params.idempotencyKey, workflowState, name: params.params.versionName, description: params.params.versionDescription, @@ -291,8 +303,22 @@ async function validateDeploymentState( return { success: true } } +function canonicalizeDeploymentWorkflowState( + workflowState: WorkflowState +): Record { + const { lastSaved: _lastSaved, edges, ...stableState } = workflowState + const sortedEdges = [...edges].sort((left, right) => { + if (left.id !== right.id) return left.id < right.id ? -1 : 1 + const normalizedLeft = normalizedStringify(left) + const normalizedRight = normalizedStringify(right) + if (normalizedLeft === normalizedRight) return 0 + return normalizedLeft < normalizedRight ? -1 : 1 + }) + return { ...stableState, edges: sortedEdges } +} + function createDeploymentRequestHash(value: Record): string { - return sha256Hex(JSON.stringify(value)) + return sha256Hex(normalizedStringify(value)) } function mapPrepareFailureCode( @@ -337,7 +363,10 @@ function buildStableDeploymentResult( deployedAt: status.activeDeployment.deployedAt.toISOString(), } : null - const latestDeploymentAttempt = summarizeDeploymentOperation(status.latestOperation) + const latestDeploymentAttempt = summarizeDeploymentOperation( + status.latestOperation, + status.activeDeployment?.deploymentVersionId ?? null + ) const warning = getStableDeploymentWarning( latestDeploymentAttempt, processResult, @@ -375,7 +404,8 @@ export async function getWorkflowDeploymentSummary(workflowId: string): Promise< } function summarizeDeploymentOperation( - operation: WorkflowDeploymentOperation | null + operation: WorkflowDeploymentOperation | null, + activeDeploymentVersionId: string | null ): DeploymentAttemptResult | null { if (!operation) return null if ( @@ -395,6 +425,10 @@ function summarizeDeploymentOperation( version: operation.version, action: operation.action, status: operation.status, + isCurrent: + operation.status === 'active' + ? operation.deploymentVersionId === activeDeploymentVersionId + : operation.status !== 'superseded', readiness: { webhooks: componentStatus('webhooks'), schedules: componentStatus('schedules'), @@ -420,6 +454,9 @@ function getStableDeploymentWarning( hasActiveDeployment: boolean ): string | undefined { if (!attempt) return undefined + if (attempt.status === 'active' && !attempt.isCurrent) { + return 'The latest successful deployment attempt is historical; no matching deployment version is currently active.' + } if (attempt.status === 'preparing' || attempt.status === 'activating') { if (processResult === 'processing_error') { return hasActiveDeployment @@ -547,6 +584,9 @@ export interface PerformActivateVersionParams { workflowId: string version: number userId: string + /** Stable identity for one logical activation operation. */ + idempotencyKey?: string + /** Correlation ID for logging and outbox tracing. */ requestId?: string /** Override the actor ID used in audit logs. Defaults to `userId`. */ actorId?: string @@ -582,7 +622,8 @@ export interface PerformRevertToVersionResult { } /** - * Admits an existing version through the v2 prepare/activate protocol. + * Admits an existing version through the v2 prepare/activate protocol. Callers + * that can replay a logical operation must provide a stable `idempotencyKey`. */ export async function performActivateVersion( params: PerformActivateVersionParams @@ -590,6 +631,7 @@ export async function performActivateVersion( const { workflowId, version, userId } = params const actorId = params.actorId ?? userId const requestId = params.requestId ?? generateRequestId() + const idempotencyKey = params.idempotencyKey ?? generateId() const lockDenial = await workflowLockDenial(workflowId) if (lockDenial) return { success: false, error: lockDenial, errorCode: 'validation' } @@ -665,6 +707,7 @@ export async function performActivateVersion( userId, actorId, requestId, + idempotencyKey, }) } catch (error) { logger.error(`[${requestId}] Version activation preparation failed`, { @@ -687,6 +730,7 @@ async function performStableVersionActivation(params: { userId: string actorId: string requestId: string + idempotencyKey: string }): Promise { let outboxEventId: string | undefined const prepared = await prepareWorkflowVersionActivation({ @@ -700,7 +744,7 @@ async function performStableVersionActivation(params: { version: params.version, userId: params.userId, }), - idempotencyKey: params.requestId, + idempotencyKey: params.idempotencyKey, readinessComponents: DEPLOYMENT_READINESS_COMPONENTS, onPrepareTransaction: async (tx, operation) => { if (!operation.deploymentVersionId || operation.version === null) { From 5460133e07533ad152882ed781772dde7301b41e Mon Sep 17 00:00:00 2001 From: Waleed Date: Sun, 2 Aug 2026 17:45:41 -0700 Subject: [PATCH 03/11] fix(files): collapse blank-line runs to markdown standard (no empty-paragraph explosion / reflow) (#6198) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseMarkdownToDoc now strips ALL top-level empty paragraphs (leading, interior, trailing), not just trailing. A run of blank lines between blocks is insignificant in markdown (CommonMark collapses it), but @tiptap/markdown reconstructs one empty paragraph per blank line — which made the mounted editor render vertical gaps that exist nowhere else the file is viewed (GitHub, download, our own static preview), let a pathological blank run explode into thousands of empty nodes, and caused the visible reflow on open (static preview collapses empty

; the live editor gives each a trailing-break line). Collapsing on parse keeps normal one-blank-line spacing, matches every standard renderer, and stays idempotent so the round-trip-safety probe still reaches a fixed point (files stay editable; existing files normalize on next cold-open + save). Serializer is intentionally NOT changed — a global blank-run collapse there would corrupt blank lines inside fenced code blocks. --- .../rich-markdown-editor/markdown-fidelity.ts | 11 ++- .../markdown-parse.test.ts | 89 +++++++++---------- .../rich-markdown-editor/markdown-parse.ts | 62 ++++++------- 3 files changed, 78 insertions(+), 84 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.ts index 94511d5ebad..43466f49861 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.ts @@ -172,9 +172,14 @@ function stripEmptyListItemLines(markdown: string): string { * Cleans up serializer output: drops empty list-item marker lines that would otherwise corrupt on * round-trip ({@link stripEmptyListItemLines}), restores callout markers the serializer * backslash-escapes (`> \[!NOTE\]` → `> [!NOTE]`), and collapses trailing blank lines to a single - * newline. The table serializer's spurious surrounding blank lines are trimmed at the source - * (PipeSafeTable), so no global leading-newline strip is needed here — avoiding clobbering content - * that legitimately begins with whitespace. + * newline. Interior blank runs are NOT collapsed here — blank lines inside a fenced code block (or a + * verbatim raw-markdown-snippet) are significant, and a global collapse would corrupt them. Spurious + * interior blank runs between top-level blocks are removed upstream instead, by + * {@link parseMarkdownToDoc} stripping empty paragraphs, so a doc that has been through the editor + * never serializes with an interior blank run outside code in the first place. The table serializer's + * spurious surrounding blank lines are trimmed at the source (PipeSafeTable), so no global + * leading-newline strip is needed here — avoiding clobbering content that legitimately begins with + * whitespace. */ export function postProcessSerializedMarkdown(markdown: string): string { return collapseAutolinkedUrls( diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.test.ts index d59ef6afe6e..0067ef31f47 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.test.ts @@ -7,6 +7,10 @@ import { createMarkdownContentExtensions } from './extensions' import { parseMarkdownToDoc, serializeMarkdownBody, splitMarkdownBlocks } from './markdown-parse' import { isRoundTripSafe } from './round-trip-safety' +/** Mirror of the production `isEmptyParagraph` (not exported): the shape a blank line reconstructs to. */ +const isEmptyPara = (n: { type?: string; content?: unknown[] }): boolean => + n.type === 'paragraph' && !n.content?.length + let editor: Editor | null = null afterEach(() => { editor?.destroy() @@ -59,12 +63,6 @@ const CASES: Array<[string, string]> = [ '1. First\n - sub bullet\n - another\n 1. deep ordered\n 2. item\n2. Second', ], ['heading-separated sections', '# A\n\nalpha\n\n## B\n\nbeta\n\n## C\n\ngamma'], - // Blank-line spacing: `@tiptap/markdown` reconstructs empty paragraphs from runs of blank lines, so - // the chunker must reinsert them or a saved blank line vanishes on reload. See the dedicated - // "empty paragraphs" suite below for the exact whole-document-parser parity. - ['one empty paragraph between paragraphs', 'first\n\n\n\nsecond'], - ['two empty paragraphs between paragraphs', 'first\n\n\n\n\n\nsecond'], - ['empty paragraphs between headings and text', '# A\n\n\n\nalpha\n\n\n\n## B'], ] describe('parseMarkdownToDoc (chunked)', () => { @@ -93,63 +91,62 @@ describe('parseMarkdownToDoc (chunked)', () => { expect(splitMarkdownBlocks('\n\n \n')).toEqual([]) }) - // The chunker used to drop empty paragraphs (visual blank lines between blocks) that the whole-document - // parser preserves, so a saved blank line silently vanished on the next load. These assert the chunked - // parse reconstructs the SAME empty-paragraph structure the whole-document parser does — at document - // edges and between blocks, for one or many blank lines, and around lists. - describe('empty paragraphs (blank-line spacing) match the whole-document parser', () => { - /** Block-type shape of a doc, `∅` for an empty paragraph, normalized through the editor. */ - function shapeOf(md: string, parse: 'chunked' | 'whole'): string { - editor = new Editor({ extensions: createMarkdownContentExtensions() }) - if (parse === 'whole') editor.commands.setContent(md, { contentType: 'markdown' }) - else editor.commands.setContent(parseMarkdownToDoc(md), { contentType: 'json' }) - const shape = (editor.getJSON().content ?? []) - .map((n) => (n.type === 'paragraph' && !n.content?.length ? '∅' : n.type)) + // Asserts the collapse documented on `stripEmptyParagraphs` — at document edges, between blocks, for + // one or many blank lines, and around lists. (Blank runs are insignificant in markdown, so a collapsed + // file renders identically everywhere it's viewed; the pathological case is a run of thousands.) + describe('collapses blank-line runs to markdown-standard spacing', () => { + /** Block-type shape of a doc after `parseMarkdownToDoc`, `∅` for any surviving empty paragraph. */ + function shapeOf(md: string): string { + return (parseMarkdownToDoc(md).content ?? []) + .map((n) => (isEmptyPara(n) ? '∅' : n.type)) .join(',') - editor.destroy() - editor = null - return shape } it.each([ - ['one empty between paragraphs', 'a\n\n\n\nb'], - ['two empties between paragraphs', 'a\n\n\n\n\n\nb'], - ['three empties between paragraphs', 'a\n\n\n\n\n\n\n\nb'], - ['even blank-line gap (rounds down)', 'a\n\n\n\n\nb'], - ['leading empties', '\n\n\n\na'], - ['leading + between', '\n\n\na\n\n\n\nb'], - ['empties between a heading and text', '# H\n\n\n\ntext'], - ['empties after a tight list', '- a\n- b\n\n\n\ntext'], - ['empties before a tight list', 'text\n\n\n\n- a\n- b'], - // Line-ending variants: the whole-vs-chunked routing must normalize first, or a `\r`-only body - // skips the empty-paragraph guard and is chunked (dropping the empties this fix restores). - ['CRLF between empties', 'a\r\n\r\n\r\n\r\nb'], - ['CR-only (classic Mac) between empties', 'a\r\r\r\rb'], - ])('chunked matches whole-doc: %s', (_label, md) => { - expect(shapeOf(md, 'chunked')).toBe(shapeOf(md, 'whole')) + ['one blank gap between paragraphs', 'a\n\n\n\nb', 'paragraph,paragraph'], + ['many blank lines between paragraphs', 'a\n\n\n\n\n\n\n\nb', 'paragraph,paragraph'], + ['leading blank lines', '\n\n\n\na', 'paragraph'], + ['leading + interior', '\n\n\na\n\n\n\nb', 'paragraph,paragraph'], + ['blank gap between a heading and text', '# H\n\n\n\ntext', 'heading,paragraph'], + ['blank gap after a tight list', '- a\n- b\n\n\n\ntext', 'bulletList,paragraph'], + ['blank gap before a tight list', 'text\n\n\n\n- a\n- b', 'paragraph,bulletList'], + // Line-ending variants normalize first, so `\r`-only / CRLF blank runs collapse identically. + ['CRLF between blocks', 'a\r\n\r\n\r\n\r\nb', 'paragraph,paragraph'], + ['CR-only (classic Mac) between blocks', 'a\r\r\r\rb', 'paragraph,paragraph'], + ])('collapses to no empty paragraphs: %s', (_label, md, expected) => { + expect(shapeOf(md)).toBe(expected) + }) + + it('a pathological blank run does not explode into empty paragraph nodes', () => { + // The production incident: an agent/paste artifact with a huge blank run became ~1959 empty + // paragraphs baked into the doc. Collapsing on parse neutralizes any such source. + const body = `Para A${'\n'.repeat(4000)}Para B` + const content = parseMarkdownToDoc(body).content ?? [] + expect(content.filter(isEmptyPara).length).toBe(0) + expect(content.map((n) => n.type)).toEqual(['paragraph', 'paragraph']) }) }) - // Regression: a file ending in a blank line (a trailing empty paragraph) must stay EDITABLE. Such an - // empty paragraph can't be serialized stably (postProcess collapses trailing newlines), so the parser - // strips it — keeping the doc round-trip-safe/idempotent instead of flipping the file read-only. - describe('trailing blank lines stay editable (regression)', () => { + // Regression: a file with blank lines (leading, interior, or trailing) must stay EDITABLE. Collapsing + // blank runs keeps serialize→parse idempotent, so the round-trip-safety probe reaches a fixed point + // instead of flipping the file read-only. + describe('blank lines stay editable (regression)', () => { it.each([ ['plain paragraph', 'abc\n\n'], ['heading + text', '# Title\n\nSome text\n\n'], ['three trailing newlines', 'hello\n\n\n'], ['two paragraphs', 'para one\n\npara two\n\n'], - ['interior empties + trailing', 'a\n\n\n\nb\n\n'], - ])('a file ending in a blank line is round-trip-safe: %s', (_label, md) => { + ['interior blank run + trailing', 'a\n\n\n\nb\n\n'], + ])('a file with blank lines is round-trip-safe: %s', (_label, md) => { expect(isRoundTripSafe(md)).toBe(true) }) - it('strips the trailing empty paragraph but keeps interior ones', () => { + it('removes only structurally-empty paragraphs — a paragraph with content survives', () => { + // The shape suite above already proves leading/interior/trailing blank runs collapse to zero empty + // paragraphs; this pins the complementary guarantee — a real (non-empty) paragraph is never dropped. const trailing = parseMarkdownToDoc('abc\n\n').content ?? [] expect(trailing.at(-1)?.type).toBe('paragraph') - expect(trailing.at(-1)?.content?.length ?? 0).toBeGreaterThan(0) - const interior = parseMarkdownToDoc('a\n\n\n\nb').content ?? [] - expect(interior.some((n) => n.type === 'paragraph' && !n.content?.length)).toBe(true) + expect(isEmptyPara(trailing.at(-1) ?? {})).toBe(false) }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.ts index d8255eef7e6..6cdeeabf4ae 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.ts @@ -47,20 +47,6 @@ const FENCE_CLOSE = /^ {0,3}(`{3,}|~{3,})[ \t]*$/ const LIST_MARKER = /^[ ]{0,3}(?:[-*+]|\d+[.)])\s/ const BLOCKQUOTE = /^[ ]{0,3}>/ -/** - * Blank-line spacing that `@tiptap/markdown` reconstructs as *interior* or *leading* empty paragraphs — - * a run of two or more blank lines somewhere, or blank line(s) at the document's leading edge. `[^\S\n]` - * matches horizontal whitespace, so a "blank" line may carry spaces/tabs. This is only ever tested - * against the `\r`-normalized body ({@link parseMarkdownToDoc}), so no CRLF handling is needed here. - * - * A *single* trailing blank line is deliberately not matched — purely to avoid routing an otherwise-plain - * file to the slower whole-document parser. Correctness does not depend on it: {@link parseMarkdownToDoc} - * strips trailing empty paragraphs on *both* parse paths ({@link stripTrailingEmptyParagraphs}), so - * serialize→parse stays idempotent regardless of which parser ran. (A trailing run of two or more blanks - * still matches the interior alternative — harmless, since the strip cleans it either way.) - */ -const EMPTY_PARAGRAPH_SPACING = /\n[^\S\n]*\n[^\S\n]*\n|^[^\S\n]*\n[^\S\n]*\n/ - /** * Split a markdown body into top-level blocks that can each be parsed independently and reassembled * without changing meaning. Blank lines separate candidate groups (fenced code blocks stay atomic), @@ -135,21 +121,20 @@ export function splitMarkdownBlocks(body: string): string[] { * Documents whose constructs span blocks ({@link NON_CHUNKABLE}) parse whole, and any failure falls * back to a single whole-document parse, so correctness never depends on the splitter. * - * Blank-line spacing ({@link EMPTY_PARAGRAPH_SPACING}) also parses whole: the chunker parses each block - * stripped of the blank lines between them, so it drops the empty paragraphs `@tiptap/markdown` builds - * from runs of blank lines — a saved visual blank line would silently vanish on reload. Whether a gap - * yields an empty paragraph is a global, block-type-dependent decision (kept between two paragraphs, - * dropped after a heading), so it can't be reconstructed block-locally; these documents parse whole for - * exact fidelity. Ordinary single-blank-line separation still takes the fast chunked path. + * Runs of blank lines take the fast chunked path too: the chunker parses each block stripped of the + * blank lines between them, which drops the empty paragraphs `@tiptap/markdown` reconstructs from a + * blank run — exactly what {@link stripEmptyParagraphs} does to the whole-parse output anyway. A blank + * run between blocks is insignificant in markdown, so collapsing it is the intended normalization (see + * {@link stripEmptyParagraphs}), and both parse paths converge on the same empty-paragraph-free result. */ export function parseMarkdownToDoc(body: string): JSONContent { const manager = markdownManager() - // Normalize line endings up front so the routing guards see the same `\n` the chunker and parser - // do — the guards' `\n`-anchored tests would otherwise miss a classic `\r`-only body (its blank - // lines are `\r`), routing it to the chunker that then drops its empty paragraphs. + // Normalize line endings up front so {@link NON_CHUNKABLE}'s `\n`-anchored tests see the same `\n` + // the chunker and parser do — a classic `\r`-only body would otherwise slip past the reference-def / + // block-HTML guard and be chunked, shattering a construct that must parse whole. const normalized = body.replace(/\r\n?/g, '\n') let doc: JSONContent - if (NON_CHUNKABLE.test(normalized) || EMPTY_PARAGRAPH_SPACING.test(normalized)) { + if (NON_CHUNKABLE.test(normalized)) { doc = manager.parse(normalized) } else { try { @@ -163,7 +148,7 @@ export function parseMarkdownToDoc(body: string): JSONContent { doc = manager.parse(normalized) } } - return stripTrailingEmptyParagraphs(doc) + return stripEmptyParagraphs(doc) } /** An empty paragraph node — the shape a blank line reconstructs to (no content, or `content: []`). */ @@ -172,19 +157,26 @@ function isEmptyParagraph(node: JSONContent): boolean { } /** - * Drop trailing empty paragraphs from a parsed doc. {@link postProcessSerializedMarkdown} collapses - * trailing blank lines to a single newline, so a trailing empty paragraph can never round-trip — the - * whole-document parser reconstructs one from a file ending in a blank line, but keeping it makes - * serialize→parse non-idempotent, which flips the file read-only via the round-trip-safety probe. - * Leading/interior empty paragraphs are untouched (postProcess never strips those). TipTap re-adds its - * own trailing filler paragraph on `setContent`, so the editor still has a place to type. + * Drop ALL top-level empty paragraphs from a parsed doc — leading, interior, and trailing. In markdown + * a run of blank lines between blocks is insignificant (CommonMark collapses it), so `@tiptap/markdown` + * reconstructing each blank as an empty paragraph node is not fidelity: it makes the editor render the + * file differently from every standard renderer (GitHub, the download, our own static preview), and a + * pathological blank run (an agent/paste artifact) explodes into thousands of empty nodes that persist + * forever and reflow the doc on open. Collapsing them here keeps normal single-blank-line block spacing + * while removing the spurious gaps, and stays idempotent so the round-trip-safety probe still passes: a + * doc parsed this way has no empty paragraphs, so re-serializing it never re-emits an interior blank run + * (the serializer is intentionally left alone — a blank line inside a fenced code block IS significant), + * and a second parse is a fixed point. Only TOP-LEVEL paragraphs are touched, so blank lines that carry + * meaning inside a construct (e.g. a loose list) are left to the block parser. TipTap re-adds its own + * trailing filler paragraph on `setContent`, so the editor still has a place to type. */ -function stripTrailingEmptyParagraphs(doc: JSONContent): JSONContent { +function stripEmptyParagraphs(doc: JSONContent): JSONContent { const content = doc.content if (!content || content.length === 0) return doc - let end = content.length - while (end > 0 && isEmptyParagraph(content[end - 1])) end-- - return end === content.length ? doc : { ...doc, content: content.slice(0, end) } + // The dominant (chunked) parse already emits no top-level empty paragraphs, so scan before allocating: + // return the doc untouched — no array copy — unless there is actually something to strip. + if (!content.some(isEmptyParagraph)) return doc + return { ...doc, content: content.filter((node) => !isEmptyParagraph(node)) } } /** From 2ccda18f3ec9fdb4615ac817f141142ed8b0a482 Mon Sep 17 00:00:00 2001 From: Waleed Date: Sun, 2 Aug 2026 19:21:18 -0700 Subject: [PATCH 04/11] test(files): collaborative agent-streaming coverage (two-writer, multi-editor, undo, persist round-trip) (#6199) * test(files): two-writer concurrent-editing regression coverage for agent streaming * test(files): multi-editor + undo + persist round-trip + late-joiner streaming integration coverage * test(files): make full-rewrite two-writer test actually exercise the concurrent peer edit * test(files): assert every peer edit lands (no false-green from a no-op peerInsertNear) --- ...apply-streamed-markdown.concurrent.test.ts | 214 ++++++++++++++++++ .../collab-streaming-integration.test.ts | 200 ++++++++++++++++ 2 files changed, 414 insertions(+) create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/apply-streamed-markdown.concurrent.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/collab-streaming-integration.test.ts diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/apply-streamed-markdown.concurrent.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/apply-streamed-markdown.concurrent.test.ts new file mode 100644 index 00000000000..19b02ea7182 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/apply-streamed-markdown.concurrent.test.ts @@ -0,0 +1,214 @@ +/** + * @vitest-environment jsdom + * + * Two-writer evaluation: does a PEER editing the shared doc WHILE the agent streams cause corruption, + * clobbering, duplication, or stray empty paragraphs? The agent applies via the real + * `beginAgentStream`/`applyAgentStreamFrame` path (a shadow doc diffed with `updateYFragment`, seeded + * once and never shown the peer's edits). A second editor is wired as a genuine Yjs peer (bidirectional + * update forwarding), so this reproduces the production two-client scenario, not a mock. + * + * Convergence is a hard invariant everywhere (CRDT MUST converge). Peer-edit survival is hard-asserted + * only for the NON-overlapping case (an agent that appends must not clobber an unrelated peer edit); for + * the overlapping case it is diagnostic (CRDT last-writer semantics are acceptable there), so those are + * logged for judgement. Run: bunx vitest run --disable-console-intercept + */ +import { Editor } from '@tiptap/core' +import { afterEach, beforeAll, describe, expect, it } from 'vitest' +import { Awareness } from 'y-protocols/awareness' +import * as Y from 'yjs' +import { createMarkdownEditorExtensions } from '../editor-extensions' +import { parseMarkdownToDoc } from '../markdown-parse' +import { applyAgentStreamFrame, beginAgentStream, endAgentStream } from './apply-streamed-markdown' + +beforeAll(() => { + if (!document.elementFromPoint) document.elementFromPoint = () => null +}) + +function makeCollabEditor() { + const doc = new Y.Doc() + const awareness = new Awareness(doc) + const editor = new Editor({ + extensions: createMarkdownEditorExtensions({ + placeholder: '', + collaboration: { doc, awareness, user: { name: 'U', color: '#fff', clientId: doc.clientID } }, + }), + content: '', + }) + return { editor, doc, awareness } +} + +const teardown: Array<() => void> = [] +afterEach(() => { + for (const fn of teardown.splice(0)) fn() +}) +function track(t: { editor: Editor; doc: Y.Doc; awareness: Awareness }) { + teardown.push(() => { + t.editor.destroy() + t.awareness.destroy() + t.doc.destroy() + }) + return t +} + +/** Wire two Y.Docs as real peers: forward each update to the other, origin-guarded to avoid echo. */ +function wirePeers(a: Y.Doc, b: Y.Doc) { + const A2B = Symbol('a->b') + const B2A = Symbol('b->a') + a.on('update', (u: Uint8Array, origin: unknown) => { + if (origin !== B2A) Y.applyUpdate(b, u, A2B) + }) + b.on('update', (u: Uint8Array, origin: unknown) => { + if (origin !== A2B) Y.applyUpdate(a, u, B2A) + }) +} + +/** Seed editor A with markdown (through the real parse), then bring up B as a synced peer. */ +function seededPair(markdown: string) { + const A = track(makeCollabEditor()) + A.editor.commands.setContent(parseMarkdownToDoc(markdown), { contentType: 'json' }) + const B = track(makeCollabEditor()) + Y.applyUpdate(B.doc, Y.encodeStateAsUpdate(A.doc)) + wirePeers(A.doc, B.doc) + return { A, B } +} + +/** A peer edit: insert `text` at the start of the first text node containing `needle`. */ +function peerInsertNear(editor: Editor, needle: string, text: string): boolean { + let pos: number | null = null + editor.state.doc.descendants((node, p) => { + if (pos !== null) return false + if (node.isText && node.text?.includes(needle)) pos = p + node.text.indexOf(needle) + }) + if (pos === null) return false + return editor.commands.insertContentAt(pos, text) +} + +function fragStr(doc: Y.Doc): string { + return doc.getXmlFragment('default').toString() +} +function count(hay: string, needle: string): number { + return hay.split(needle).length - 1 +} +function emptyParas(editor: Editor): number { + let n = 0 + editor.state.doc.descendants((node) => { + if (node.type.name === 'paragraph' && node.childCount === 0) n++ + }) + return n +} + +describe('two-writer: peer edits while the agent streams', () => { + it('SANITY: peers converge on seed and a plain peer edit with no agent activity', () => { + const { A, B } = seededPair('# Title\n\nAlpha\n\nBeta') + expect(peerInsertNear(B.editor, 'Alpha', 'PEER ')).toBe(true) + expect(fragStr(A.doc)).toBe(fragStr(B.doc)) + expect(A.editor.state.doc.textContent).toContain('PEER Alpha') + }) + + it('NON-OVERLAPPING: agent appends at the bottom while the peer edits the top — peer edit MUST survive', () => { + const { A, B } = seededPair('# Title\n\nAlpha\n\nBeta') + const session = beginAgentStream(A.editor)! + + // Frame 1: agent appends Gamma (region far from the peer's target). + applyAgentStreamFrame(A.editor, session, '# Title\n\nAlpha\n\nBeta\n\nGamma') + // Peer edits the TOP paragraph mid-stream (the agent never touches or knows about this). + expect(peerInsertNear(B.editor, 'Alpha', 'PEER ')).toBe(true) + // Frames 2-3: agent keeps appending. Its bodies say "Alpha" (no PEER) — the test is whether the + // (aggressive) updateYFragment re-emits/clobbers the unchanged Alpha paragraph. + applyAgentStreamFrame(A.editor, session, '# Title\n\nAlpha\n\nBeta\n\nGamma\n\nDelta') + applyAgentStreamFrame( + A.editor, + session, + '# Title\n\nAlpha\n\nBeta\n\nGamma\n\nDelta\n\nEpsilon' + ) + endAgentStream(session) + + const textA = A.editor.state.doc.textContent + console.log(`\n[NON-OVERLAP] A: ${JSON.stringify(textA)}`) + console.log( + `[NON-OVERLAP] converged=${fragStr(A.doc) === fragStr(B.doc)} peerCount=${count(textA, 'PEER ')} emptyParas=${emptyParas(A.editor)}` + ) + + expect(fragStr(A.doc)).toBe(fragStr(B.doc)) // CRDT convergence + expect(count(textA, 'PEER ')).toBe(1) // peer edit survives, exactly once (no clobber, no dup) + expect(textA).toContain('Epsilon') // agent's stream landed + expect(textA).toContain('Beta') // untouched content intact + expect(emptyParas(A.editor)).toBe(0) // no stray empties from the merge + }) + + it('POSITION DRIFT: agent inserts a paragraph ABOVE while the peer edits the paragraph BELOW', () => { + // The exact scenario relative-position anchoring is meant to protect: the agent shifts positions by + // inserting content above the region the peer is editing. Without anchoring, an offset-based writer + // would misplace the edit; a whole-doc CRDT diff should not. + const { A, B } = seededPair('# Title\n\nAlpha\n\nBeta') + const session = beginAgentStream(A.editor)! + + applyAgentStreamFrame(A.editor, session, '# Title\n\nAlpha\n\nMIDDLE\n\nBeta') + // Peer edits Beta, which just shifted down by the agent's inserted MIDDLE paragraph. + expect(peerInsertNear(B.editor, 'Beta', 'PEER ')).toBe(true) + applyAgentStreamFrame(A.editor, session, '# Title\n\nAlpha\n\nMIDDLE\n\nMIDDLE2\n\nBeta') + endAgentStream(session) + + const textA = A.editor.state.doc.textContent + console.log(`\n[POS-DRIFT] A: ${JSON.stringify(textA)}`) + console.log( + `[POS-DRIFT] converged=${fragStr(A.doc) === fragStr(B.doc)} peerCount=${count(textA, 'PEER ')} peerOnBeta=${textA.includes('PEER Beta')} emptyParas=${emptyParas(A.editor)}` + ) + + expect(fragStr(A.doc)).toBe(fragStr(B.doc)) // convergence + expect(count(textA, 'PEER ')).toBe(1) // no duplication + expect(textA).toContain('PEER Beta') // peer edit stayed attached to Beta despite the insert above + expect(textA).toContain('MIDDLE2') // agent's inserts landed + expect(emptyParas(A.editor)).toBe(0) + }) + + it('OVERLAPPING: agent rewrites the exact paragraph the peer is editing (diagnostic + must converge)', () => { + const { A, B } = seededPair('# Title\n\noriginal body text') + const session = beginAgentStream(A.editor)! + + applyAgentStreamFrame(A.editor, session, '# Title\n\noriginal body text extended') + // Peer edits the SAME paragraph the agent is rewriting. + expect(peerInsertNear(B.editor, 'original', 'PEER ')).toBe(true) + applyAgentStreamFrame(A.editor, session, '# Title\n\nagent fully rewrote this paragraph') + endAgentStream(session) + + const textA = A.editor.state.doc.textContent + console.log(`\n[OVERLAP] A: ${JSON.stringify(textA)}`) + console.log( + `[OVERLAP] converged=${fragStr(A.doc) === fragStr(B.doc)} peerSurvived=${textA.includes('PEER')} emptyParas=${emptyParas(A.editor)}` + ) + + expect(fragStr(A.doc)).toBe(fragStr(B.doc)) // convergence is non-negotiable even in conflict + expect(emptyParas(A.editor)).toBe(0) // conflict must not leave stray empty paragraphs + // peer survival here is CRDT-dependent — reported above, not hard-asserted. + }) + + it('FULL REWRITE: peer edits original content that the agent then deletes in a full rewrite', () => { + const { A, B } = seededPair('# Title\n\nAlpha\n\nBeta\n\nGamma') + const session = beginAgentStream(A.editor)! + + // Peer edits Beta WHILE it still exists — genuinely concurrent with the impending rewrite. + // (Asserting the insert landed guards against a false-green where the target was already gone.) + expect(peerInsertNear(B.editor, 'Beta', 'PEER ')).toBe(true) + // Agent replaces the WHOLE doc across two frames, deleting Alpha/Beta/Gamma. + applyAgentStreamFrame(A.editor, session, '# Report\n\nOne\n\nTwo') + applyAgentStreamFrame(A.editor, session, '# Report\n\nOne\n\nTwo\n\nThree') + endAgentStream(session) + + const textA = A.editor.state.doc.textContent + console.log(`\n[FULL-REWRITE] A: ${JSON.stringify(textA)}`) + console.log( + `[FULL-REWRITE] converged=${fragStr(A.doc) === fragStr(B.doc)} peerCount=${count(textA, 'PEER ')} oneCount=${count(textA, 'One')} threeCount=${count(textA, 'Three')} emptyParas=${emptyParas(A.editor)}` + ) + + expect(fragStr(A.doc)).toBe(fragStr(B.doc)) // convergence + expect(count(textA, 'One')).toBe(1) // agent content not duplicated by the concurrent merge + expect(count(textA, 'Three')).toBe(1) + expect(emptyParas(A.editor)).toBe(0) // no stray empties from a delete/insert conflict + // The peer's insert is NOT lost when the rewrite deletes its surrounding paragraph: Yjs preserves + // the inserted text and reattaches it to the nearest surviving anchor (it relocates into the + // rewritten content rather than vanishing). What matters is that it survives exactly once — never + // duplicated, never silently dropped. + expect(count(textA, 'PEER ')).toBe(1) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/collab-streaming-integration.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/collab-streaming-integration.test.ts new file mode 100644 index 00000000000..b8996800923 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/collab-streaming-integration.test.ts @@ -0,0 +1,200 @@ +/** + * @vitest-environment jsdom + * + * Integration coverage for the collaborative agent-streaming surface with all the moving pieces: + * multiple peers, undo isolation, the durable persist→reopen round-trip, empty-collapse on the live + * streaming path, and a late joiner. Editors are wired as genuine Yjs peers (mesh update forwarding). + * This exercises the CRDT/merge/convert LOGIC deterministically; it does NOT cover the realtime socket + * transport, RAF-paced stream loop, or real browser timing (those need a live 2-browser E2E harness). + * Run: bunx vitest run --disable-console-intercept + */ +import { Editor } from '@tiptap/core' +import { afterEach, beforeAll, describe, expect, it } from 'vitest' +import { Awareness } from 'y-protocols/awareness' +import * as Y from 'yjs' +import { markdownToYDoc, yDocToMarkdown } from '@/lib/collab-doc/converter' +import { createMarkdownEditorExtensions } from '../editor-extensions' +import { parseMarkdownToDoc } from '../markdown-parse' +import { applyAgentStreamFrame, beginAgentStream, endAgentStream } from './apply-streamed-markdown' + +beforeAll(() => { + if (!document.elementFromPoint) document.elementFromPoint = () => null +}) + +const teardown: Array<() => void> = [] +afterEach(() => { + for (const fn of teardown.splice(0)) fn() +}) + +function makeCollabEditor() { + const doc = new Y.Doc() + const awareness = new Awareness(doc) + const editor = new Editor({ + extensions: createMarkdownEditorExtensions({ + placeholder: '', + collaboration: { doc, awareness, user: { name: 'U', color: '#fff', clientId: doc.clientID } }, + }), + content: '', + }) + const t = { editor, doc, awareness } + teardown.push(() => { + editor.destroy() + awareness.destroy() + doc.destroy() + }) + return t +} + +/** Forward every local/agent update from each doc to all others (origin-guarded), a full mesh. */ +function wireMesh(docs: Y.Doc[]) { + const MESH = Symbol('mesh') + for (const d of docs) { + d.on('update', (u: Uint8Array, origin: unknown) => { + if (origin === MESH) return + for (const other of docs) if (other !== d) Y.applyUpdate(other, u, MESH) + }) + } +} + +function peerInsertNear(editor: Editor, needle: string, text: string): boolean { + let pos: number | null = null + editor.state.doc.descendants((node, p) => { + if (pos !== null) return false + if (node.isText && node.text?.includes(needle)) pos = p + node.text.indexOf(needle) + }) + if (pos === null) return false + return editor.commands.insertContentAt(pos, text) +} + +const fragStr = (doc: Y.Doc) => doc.getXmlFragment('default').toString() +const countText = (hay: string, needle: string) => hay.split(needle).length - 1 +function emptyParas(editor: Editor): number { + let n = 0 + editor.state.doc.descendants((node) => { + if (node.type.name === 'paragraph' && node.childCount === 0) n++ + }) + return n +} + +describe('collab streaming integration — moving pieces', () => { + it('THREE-WAY: agent + two peers editing different regions all converge, both peer edits survive', () => { + const A = makeCollabEditor() + A.editor.commands.setContent(parseMarkdownToDoc('# Title\n\nAlpha\n\nBeta\n\nGamma'), { + contentType: 'json', + }) + const B = makeCollabEditor() + const C = makeCollabEditor() + Y.applyUpdate(B.doc, Y.encodeStateAsUpdate(A.doc)) + Y.applyUpdate(C.doc, Y.encodeStateAsUpdate(A.doc)) + wireMesh([A.doc, B.doc, C.doc]) + + const session = beginAgentStream(A.editor)! + applyAgentStreamFrame(A.editor, session, '# Title\n\nAlpha\n\nBeta\n\nGamma\n\nDelta') + expect(peerInsertNear(B.editor, 'Alpha', 'B_EDIT ')).toBe(true) // peer B edits the top + expect(peerInsertNear(C.editor, 'Gamma', 'C_EDIT ')).toBe(true) // peer C edits the bottom + applyAgentStreamFrame( + A.editor, + session, + '# Title\n\nAlpha\n\nBeta\n\nGamma\n\nDelta\n\nEpsilon' + ) + endAgentStream(session) + + const textA = A.editor.state.doc.textContent + console.log(`\n[3-WAY] A: ${JSON.stringify(textA)}`) + console.log( + `[3-WAY] converged=${fragStr(A.doc) === fragStr(B.doc) && fragStr(B.doc) === fragStr(C.doc)} B_EDIT=${countText(textA, 'B_EDIT ')} C_EDIT=${countText(textA, 'C_EDIT ')} empty=${emptyParas(A.editor)}` + ) + + expect(fragStr(A.doc)).toBe(fragStr(B.doc)) + expect(fragStr(B.doc)).toBe(fragStr(C.doc)) + expect(countText(textA, 'B_EDIT ')).toBe(1) + expect(countText(textA, 'C_EDIT ')).toBe(1) + expect(textA).toContain('Epsilon') + expect(emptyParas(A.editor)).toBe(0) + }) + + it('UNDO ISOLATION: a peer undo reverts only the peer’s own edit, never the agent’s stream', () => { + const A = makeCollabEditor() + A.editor.commands.setContent(parseMarkdownToDoc('# Title\n\nbase'), { contentType: 'json' }) + const B = makeCollabEditor() + Y.applyUpdate(B.doc, Y.encodeStateAsUpdate(A.doc)) + wireMesh([A.doc, B.doc]) + + expect(peerInsertNear(B.editor, 'base', 'PEER_UNDOABLE ')).toBe(true) // peer's own edit (undo stack) + const session = beginAgentStream(A.editor)! + applyAgentStreamFrame( + A.editor, + session, + '# Title\n\nPEER_UNDOABLE base\n\nagent added this line' + ) + endAgentStream(session) + + const undid = B.editor.commands.undo() + const textB = B.editor.state.doc.textContent + console.log(`\n[UNDO] undoRan=${undid} afterUndo=${JSON.stringify(textB)}`) + + expect(fragStr(A.doc)).toBe(fragStr(B.doc)) // still converged after undo + expect(textB).toContain('agent added this line') // agent content NOT undone by the peer + expect(textB).not.toContain('PEER_UNDOABLE') // peer's own edit was undone + }) + + it('PERSIST ROUND-TRIP: stream → serialize to durable markdown → reopen yields the same content, no empties', () => { + const A = makeCollabEditor() + const session = beginAgentStream(A.editor)! + applyAgentStreamFrame(A.editor, session, '# Report\n\n## Section 1\n\nbody one') + applyAgentStreamFrame( + A.editor, + session, + '# Report\n\n## Section 1\n\nbody one\n\n## Section 2\n\nbody two' + ) + endAgentStream(session) + + const durable = yDocToMarkdown(A.doc) // server-side projection to durable markdown + const reopened = markdownToYDoc(durable) // cold reopen from durable + const reopenedMd = yDocToMarkdown(reopened) + const blankRuns = (durable.match(/\n{3,}/g) ?? []).length + console.log(`\n[ROUND-TRIP] durable=${JSON.stringify(durable)}`) + console.log(`[ROUND-TRIP] reopenStable=${reopenedMd === durable} blankRuns=${blankRuns}`) + + expect(durable).toContain('Section 1') + expect(durable).toContain('Section 2') + expect(durable).toContain('body two') + expect(blankRuns).toBe(0) // no pathological blank runs in the persisted markdown + expect(reopenedMd).toBe(durable) // reopen is a fixed point (stable) + reopened.destroy() + }) + + it('EMPTY-COLLAPSE ON THE STREAM PATH: an agent body with a huge blank run does not strand empties', () => { + const A = makeCollabEditor() + A.editor.commands.setContent(parseMarkdownToDoc('# Title\n\nintro'), { contentType: 'json' }) + const session = beginAgentStream(A.editor)! + // The agent emits a pathological blank run between two blocks (the original incident's shape). + applyAgentStreamFrame(A.editor, session, `# Title\n\nintro${'\n'.repeat(400)}tail paragraph`) + endAgentStream(session) + + console.log( + `\n[STREAM-COLLAPSE] text=${JSON.stringify(A.editor.state.doc.textContent)} empty=${emptyParas(A.editor)}` + ) + expect(emptyParas(A.editor)).toBe(0) // collapse protects the live streaming path, not just static open + expect(A.editor.state.doc.textContent).toContain('tail paragraph') + }) + + it('LATE JOINER: a peer that syncs AFTER the stream sees the full, clean document', () => { + const A = makeCollabEditor() + A.editor.commands.setContent(parseMarkdownToDoc('# Doc\n\nstart'), { contentType: 'json' }) + const session = beginAgentStream(A.editor)! + applyAgentStreamFrame(A.editor, session, '# Doc\n\nstart\n\nstreamed body') + endAgentStream(session) + + // A brand-new client joins now and syncs from the current state. + const D = makeCollabEditor() + Y.applyUpdate(D.doc, Y.encodeStateAsUpdate(A.doc)) + + console.log( + `\n[LATE-JOIN] D: ${JSON.stringify(D.editor.state.doc.textContent)} converged=${fragStr(A.doc) === fragStr(D.doc)}` + ) + expect(fragStr(A.doc)).toBe(fragStr(D.doc)) + expect(D.editor.state.doc.textContent).toContain('streamed body') + expect(emptyParas(D.editor)).toBe(0) + }) +}) From 786c854e0424a3a4e4715ad7731e132f8194fa51 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 3 Aug 2026 09:28:05 -0700 Subject: [PATCH 05/11] improvement(settings): consolidate resource UI onto shared primitives (#6202) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * improvement(settings): consolidate resource UI onto shared primitives Sandboxes, MCP tools, and MCP servers each modeled their list rows and detail headers differently. Collapse them — and the surfaces they drifted from — onto one set of primitives. SettingsResourceRow now owns the row entirely: tile, title/subtitle tokens, padding and bleed, hover band, hit area, focus ring, and the one navigation chevron. Adds onClick/href (a stretched overlay, so interactive trailing controls keep their clicks), navigable, iconVariant='custom', and a badge slot for decoration that must not swallow row clicks. Rows that open a detail page get a chevron and a whole-row click; flat records keep the "..." menu. Delete moves to the detail header as a destructive chip behind a confirm modal — sandboxes previously deleted with no confirmation at all. Also folds in SettingsField (new), SettingsEmptyState tone='error', RESOURCE_LIST_STACK/GRID, RESOURCE_TILE_PLAIN, and a shared MemberAvatar; deletes DetailIconTile (byte-identical to ResourceTile); and standardizes on the emcn ArrowRight, which is a visibly different glyph from lucide's. * fix(settings): correct row bleed, delete-confirm binding, and avatar seeding Follow-up from review of the consolidation. The sandbox delete confirmation was boolean-only state. Browser Back unmounts the modal without closing it, so opening another sandbox re-opened it already confirmed — against the wrong sandbox. Reset it where the draft guard already handles the same history hazard. RESOURCE_LIST_GRID kept an 8px column gap after the bleed moved onto the row, so neighbouring cards overlapped by exactly the gutter and the right-hand card's stretched hit area won it: clicking between two cards opened the wrong one. Budget the gap for the bleed instead. Also: restore the `group` the template-icon hover outlines depend on; add a `flush` row for headings and overflow boxes; seed MemberAvatar identically on every surface; keep the MCP transport label visible in all row states; give the Delete chip a stable id so it doesn't remount mid-delete; and stop an empty subtitle rendering a phantom line. Docs: widen the rule's path globs to the surfaces it now governs, drop the `text-[14px]` example that contradicted the token rule, and add a Mode C for migrating rows onto the primitive. * revert(settings): keep the dense member roster avatar Consolidating the Teammates/Organization avatar onto the credential member row's was a redesign, not a deduplication. The two encode different things: the roster is a dense list keyed on email where the avatar is a 14px neutral marker, while a member management row carries a name, an email, and a role control and earns a 36px hashed avatar. Merging them made those rows ~70% taller, gave every workspace in a permission group a colour-hashed "avatar" seeded on its name, and cut the Add Members picker from ~7 visible rows to ~5 inside its fixed-height box. Restores both avatars and the containers that owned their bleed, and records in the rule why they stay separate. Keeps only the unrelated fix in that area: the picker row was the one settings row rounded at `sm`. * fix(settings): wire flush, drop the lone skeleton, close review gaps Final review round. Two fixes the previous commit claimed but did not land: `CredentialDetailHeading` never passed `flush`, so every credential detail heading wore list-row padding, and the empty-subtitle guard was never applied. Both were scripted replacements that silently no-op'd. RESOURCE_LIST_GRID also dropped to one column 32px earlier than the grid it replaced — `auto-fit` measures tracks, not margin boxes, so widening the gap for the rows' bleed moved the breakpoint. Track minimum now budgets for it. Removes the BYOK skeleton rather than maintaining a second copy of the row: it was the only skeleton in settings, and it had already desynced from the row it imitates. Its peers render nothing while loading. Also: unify the glyph-tile treatment across MCP/sandboxes/workflow-MCP with custom tools; move decoration out of `trailing` in verified-domains and recently-deleted; convert the last hand-rolled row and empty states in workflow-MCP, api-keys, copilot and group-detail; give copilot's delete the same `...` affordance as api-keys; announce the row description via aria-describedby, which the stretched overlay had silenced; and let SettingsField render its own value so callers stop restating type tokens. Docs: correct claims that predate this PR — `aside` does not exist, the navigation source of truth is under components/, beforeunload mounts in the layouts, `getSettingsSectionMeta` takes two args — and stop asserting a literal-pixel grep returns zero when display type legitimately uses it. --- .claude/rules/sim-settings-pages.md | 165 ++++- .claude/rules/sim-styling.md | 8 +- .claude/skills/add-settings-page/SKILL.md | 34 +- .../components/credential-detail-heading.tsx | 24 +- .../components/credential-detail-layout.tsx | 4 +- .../components/detail-icon-tile.tsx | 14 - .../components/detail-section.tsx | 15 +- .../components/credential-detail/index.ts | 1 - .../components/resource-tile/index.ts | 1 + .../resource-tile/resource-tile.tsx | 3 + .../[block]/integration-block-detail.tsx | 72 +- .../[block]/integration-skills-section.tsx | 46 +- .../integration-section.tsx | 20 +- .../integrations-showcase.tsx | 16 +- .../showcase-with-explore.tsx | 2 +- .../connected-credential-detail.tsx | 12 +- .../integrations/integrations.tsx | 48 +- .../settings/components/admin/admin.tsx | 4 +- .../settings/components/api-keys/api-keys.tsx | 144 ++-- .../settings/components/billing/billing.tsx | 3 +- .../password-detail/password-detail.test.tsx | 2 + .../password-detail/password-detail.tsx | 41 +- .../passwords-view/passwords-view.test.tsx | 13 +- .../passwords-view/passwords-view.tsx | 55 +- .../components/byok/byok-key-manager.tsx | 43 +- .../components/byok/byok-skeleton.tsx | 22 - .../settings/components/copilot/copilot.tsx | 55 +- .../custom-tool-detail/custom-tool-detail.tsx | 24 +- .../components/custom-tools/custom-tools.tsx | 37 +- .../settings/components/desktop/desktop.tsx | 41 +- .../inbox-task-list/inbox-task-list.tsx | 10 +- .../settings/components/mcp/mcp.tsx | 191 +++-- .../recently-deleted/recently-deleted.tsx | 37 +- .../sandboxes/components/sandbox-editor.tsx | 11 +- .../components/sandboxes/sandboxes.tsx | 99 ++- .../settings/components/sandboxes/utils.ts | 14 + .../secrets-manager/secrets-manager.tsx | 13 +- .../settings-empty-state.tsx | 11 +- .../components/settings-field/index.ts | 1 + .../settings-field/settings-field.tsx | 30 + .../components/settings-resource-row/index.ts | 7 +- .../settings-resource-row.tsx | 188 ++++- .../settings-section/settings-section.tsx | 2 +- .../create-workflow-mcp-server-modal.tsx | 4 +- .../workflow-mcp-servers.tsx | 689 +++++++++--------- .../secrets/[credentialId]/secret-detail.tsx | 9 +- .../skills/[skillId]/skill-detail.tsx | 5 +- .../workspace/[workspaceId]/skills/skills.tsx | 86 +-- .../components/plan-card/plan-card.tsx | 8 +- .../components/access-control.tsx | 52 +- .../components/group-detail.tsx | 6 +- .../components/custom-blocks.tsx | 38 +- .../components/data-drains-settings.tsx | 61 +- .../components/data-retention-settings.tsx | 52 +- apps/sim/ee/sso/components/sso-auth.tsx | 2 +- apps/sim/ee/sso/components/sso-form.tsx | 2 +- .../components/verified-domains-section.tsx | 18 +- .../components/fork-sync/fork-sync-view.tsx | 2 +- 58 files changed, 1392 insertions(+), 1225 deletions(-) delete mode 100644 apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/detail-icon-tile.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/settings-field/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/settings-field/settings-field.tsx diff --git a/.claude/rules/sim-settings-pages.md b/.claude/rules/sim-settings-pages.md index 8710553e800..114bc1f30a8 100644 --- a/.claude/rules/sim-settings-pages.md +++ b/.claude/rules/sim-settings-pages.md @@ -1,6 +1,9 @@ --- paths: - "apps/sim/app/workspace/*/settings/**" + - "apps/sim/app/workspace/*/{integrations,skills,upgrade}/**" + - "apps/sim/app/workspace/*/components/{resource-tile,credential-detail}/**" + - "apps/sim/components/{settings,permissions}/**" - "apps/sim/ee/**/components/**" --- @@ -20,7 +23,7 @@ Do NOT hand-roll any of these in a settings page — they are owned by the layou shell (fed through `SettingsPanel`): - `

` shell -- the header bar (`flex flex-shrink-0 … px-[16px] pt-[8.5px] pb-[8.5px]`) +- the header bar — compose `PAGE_HEADER_BAR` (`@/components/page-header-bar`); never rewrite its padding - the scroll container (`min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]`) - the content column (`mx-auto … max-w-[48rem] … gap-7`) - a title block (`

` + `

`) @@ -55,21 +58,23 @@ return ( ## `SettingsPanel` props - `actions?: SettingsAction[]` — right-aligned header chips, **data only**: - `{ text, icon?, variant?: 'primary'|'destructive', active?, onSelect, disabled?, tooltip? }`. + `{ id?, text, textTone?: 'error', icon?, variant?: 'primary'|'destructive', active?, + onSelect, onPrefetch?, disabled?, tooltip? }`. The shell renders each as a `Chip` — never pass JSX, a `

`, or `className` (the locked contract: it's structurally impossible to vibe-code a padding change). Multiple/conditional actions are a plain array (`[...(canManage ? [{…}] : []), …]`). Labels are **sentence case** (`Add override`, not `Add Override`). A disabled action that needs to explain itself sets - `tooltip` (the shell renders the hover tooltip, disabled chip included) — never - hand-roll a tooltip-wrapped chip in `aside`. Save/Discard pairs come from the - `saveDiscardActions()` helper (spread it into `actions`). Only a widget that - genuinely cannot be a chip (e.g. one needing hover-prefetch) goes in `aside`. + `tooltip` (the shell renders the hover tooltip, disabled chip included). An action + that wants to warm a route on hover sets `onPrefetch`; the shell wires it. A label + that flips while pending (`Delete` → `Deleting...`) sets a stable `id`, or the chip + remounts mid-action. Save/Discard pairs come from the `saveDiscardActions()` + helper (spread it into `actions`). - `back?: SettingsBackAction` (`{ text, icon?, onSelect }`) — left-aligned back chip for a **detail sub-view** (e.g. a selected MCP server, a permission group, a retention policy). Detail sub-views render through `SettingsPanel` like list pages — they do NOT hand-roll their own shell. -- `aside?: ReactNode` — escape hatch for the rare non-chip header widget. Keep it rare. +- `docsLink?: string` — renders the header's `Docs` `ChipLink`. - `search?: { value; onChange: (value: string) => void; placeholder?; disabled? }` — renders the canonical search field directly below the title. Pass `setSearchTerm` straight to `onChange`. Use this for a standalone search; if search shares a row @@ -82,15 +87,15 @@ return ( ## Title + description live in navigation metadata -`apps/sim/app/workspace/[workspaceId]/settings/navigation.ts` is the single source -of truth. Every `NavigationItem` carries a one-line `description`; `SettingsPanel` -resolves both via `getSettingsSectionMeta(section)` and the +`apps/sim/components/settings/navigation.ts` is the single source of truth (the +`settings/navigation.ts` in the route tree is only a re-export shim). Every `NavigationItem` carries a one-line `description`; `SettingsPanel` +resolves both via `getSettingsSectionMeta(plane, section)` and the `SettingsSectionProvider` the settings shell wraps around the active section. Adding a new settings page: -1. Add the `SettingsSection` id + a `NavigationItem` (with `label` **and** - `description`) in `navigation.ts`. Keep descriptions verb-first, one line, +1. Add the section id to the `UnifiedSettingsSection` union + a `NavigationItem` + (with `label` **and** `description`) in `components/settings/navigation.ts`. Keep descriptions verb-first, one line, ~40–55 chars, in the product voice (see `.claude/rules/constitution.md`). 2. Render the component inside the shell's `effectiveSection` switch in `settings/[section]/settings.tsx`. @@ -107,19 +112,14 @@ token (if the pixel value matches one exactly) or a sign the page never migrated grep `text-\[1[0-8]px\]` under `apps/sim/app/workspace/*/settings/**` and `apps/sim/ee/**` to find stragglers. -For a two-line list row (title/value on top, a muted subtitle below — a name + -email, a tool name + description, a server name + status), the established -pairing is: +Watch `text-xs`: it is 11px here, so a "caption" written as `text-xs` is a pixel +short. See `sim-styling.md` for the full scale. -- **Title / row value**: `text-[var(--text-body)] text-sm` -- **Subtitle / muted description**: `text-[var(--text-muted)] text-caption` - -This is not a stylistic guess — it is the tokenized form of the literal-pixel -pairing (`text-[14px] text-[var(--text-body)]` / `text-[12px] -text-[var(--text-muted)]`) already used for this exact row shape across -`member-list.tsx`, `api-keys.tsx`, `mcp.tsx`, `billing.tsx`, -`workflow-mcp-servers.tsx`, and others — keep new rows consistent with it rather -than inventing a new size pairing. +The two-line list row (title over a muted subtitle — a name + email, a tool name ++ description, a server name + status) is **not something you build**: it is +`SettingsResourceRow`, which owns the pairing +(`text-[var(--text-body)] text-sm` over `text-[var(--text-muted)] text-caption`). +See "The resource row" below. For a toggle row (a `Switch` with a title and optional description), use the emcn `Label` component for the title — never a hand-rolled `` — paired with @@ -145,21 +145,114 @@ independently-defined tokens (not interchangeable — they resolve to different colors) and both see legitimate use across settings pages; this rule only pins down the **row title/subtitle** shape above, not every text element on every page. +## The resource row + +**`SettingsResourceRow`** (`…/components/settings-resource-row`) is *the* list row +for every settings resource — and for skills, integrations, and the `ee/` surfaces +too. It owns the tile, the title/subtitle tokens, the row padding and bleed +(`-mx-2 … rounded-lg p-2`), the hit area, the focus ring, the navigation chevron, +and — on activatable rows only — the hover band. Never hand-roll any of it, and never wrap the row in your own +` + clickLabel={`Use template ${title}`} + navigable + /> ) } diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section.tsx index ea4fa847a2b..575a448fe2f 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section.tsx @@ -6,6 +6,11 @@ import { Check, Plus } from 'lucide-react' import { usePostHog } from 'posthog-js/react' import { captureEvent } from '@/lib/posthog/client' import { SkillTile } from '@/app/workspace/[workspaceId]/components' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { isSkillNameConflictError } from '@/app/workspace/[workspaceId]/skills/components/utils' import type { SuggestedSkill } from '@/blocks/types' import { useCreateSkill, useSkills } from '@/hooks/queries/skills' @@ -26,22 +31,23 @@ interface SkillRowProps { function SkillRow({ skill, added, pending, disabled, onAdd }: SkillRowProps) { return ( -
- -
- {skill.name} - {skill.description} -
- {added ? ( - - Added - - ) : ( - - {pending ? 'Adding...' : 'Add'} - - )} -
+ } + title={skill.name} + description={skill.description} + trailing={ + added ? ( + + Added + + ) : ( + + {pending ? 'Adding...' : 'Add'} + + ) + } + /> ) } @@ -98,10 +104,8 @@ export function IntegrationSkillsSection({ } return ( -
- Skills -
-
+ +
{skills.map((skill, index) => ( ))}
-
+ ) } diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/integration-section/integration-section.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/integration-section/integration-section.tsx index 827cbe519c7..08dae05a2cf 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/integration-section/integration-section.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/integration-section/integration-section.tsx @@ -1,4 +1,6 @@ import type { ReactNode } from 'react' +import { RESOURCE_LIST_GRID } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' interface IntegrationSectionProps { label: string @@ -6,19 +8,15 @@ interface IntegrationSectionProps { } /** - * Labeled section used throughout the integrations surface. Renders a small - * caption, a divider, and a responsive auto-fit grid for its children so the - * vertical rhythm stays consistent across the integrations list, the connected - * credentials list, and the integration detail page templates. + * Labeled section used throughout the integrations surface: the shared + * {@link SettingsSection} label/divider chrome wrapped around the shared + * responsive card grid, so the integrations list, the connected credentials + * list, and the integration detail templates cannot drift from settings. */ export function IntegrationSection({ label, children }: IntegrationSectionProps) { return ( -
- {label} -
-
- {children} -
-
+ +
{children}
+
) } diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/integrations-showcase/integrations-showcase.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/integrations-showcase/integrations-showcase.tsx index e3b01070117..43a25c08ba1 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/integrations-showcase/integrations-showcase.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/integrations-showcase/integrations-showcase.tsx @@ -1,5 +1,9 @@ import type { ComponentType } from 'react' import { cn } from '@sim/emcn' +import { + RESOURCE_TILE_BASE, + RESOURCE_TILE_PLAIN, +} from '@/app/workspace/[workspaceId]/components/resource-tile' import { getBlock } from '@/blocks' import { getTileIconColorClass } from '@/blocks/icon-color' @@ -69,13 +73,11 @@ export function IntegrationTile({ blockType, icon: Icon, framed = false }: Integ if (!framed) { return ( -
-
- -
+
+
) } diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/showcase-with-explore.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/showcase-with-explore.tsx index 8009841fe31..f5363986d1f 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/showcase-with-explore.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/showcase-with-explore.tsx @@ -1,7 +1,7 @@ 'use client' import { Chip } from '@sim/emcn' -import { ArrowRight } from 'lucide-react' +import { ArrowRight } from '@sim/emcn/icons' import { useParams, useRouter } from 'next/navigation' import { isChatEnabled } from '@/lib/core/config/env-flags' import { IntegrationsShowcase } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx index fa40014816c..45ffddc13bf 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx @@ -8,6 +8,7 @@ import { ChipInput, ChipLink, ChipTextarea, + cn, Send, toast, } from '@sim/emcn' @@ -27,11 +28,16 @@ import { UnsavedChangesModal, useCredentialDetailForm, } from '@/app/workspace/[workspaceId]/components/credential-detail' +import { + RESOURCE_TILE_BASE, + RESOURCE_TILE_PLAIN, +} from '@/app/workspace/[workspaceId]/components/resource-tile' import { ConnectServiceAccountModal, type ServiceAccountProviderId, } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal' import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { useCreateCredentialDraft, useDeleteWorkspaceCredential, @@ -217,7 +223,7 @@ export function ConnectedCredentialDetail({ if (credentialsLoading && !credential) { return ( -

Loading…

+ Loading…
) } @@ -225,7 +231,7 @@ export function ConnectedCredentialDetail({ if (!credential) { return ( -

Credential not found.

+ Credential not found.
) } @@ -241,7 +247,7 @@ export function ConnectedCredentialDetail({ display?.icon ? ( ) : ( -
+
{resolveProviderLabel(credential.providerId).slice(0, 1) || '?'} diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx index 2f04ac50d33..c9a8aa539f4 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx @@ -2,7 +2,6 @@ import { type ComponentType, useCallback, useMemo, useRef } from 'react' import { - ArrowRight, ChevronDown, ChipInput, chipVariants, @@ -12,7 +11,6 @@ import { DropdownMenuTrigger, Search, } from '@sim/emcn' -import Link from 'next/link' import { useParams } from 'next/navigation' import { useQueryStates } from 'nuqs' import { @@ -34,18 +32,14 @@ import { integrationsParsers, integrationsUrlKeys, } from '@/app/workspace/[workspaceId]/integrations/search-params' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { useWorkspaceCredentials, type WorkspaceCredential } from '@/hooks/queries/credentials' import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' /** Slugs surfaced in the pinned Featured section, in display order. */ const FEATURED_SLUGS = ['slack', 'gmail', 'jira', 'github', 'google-sheets', 'hubspot'] as const -const LINK_ROW_CLASSES = - 'flex items-center gap-2.5 rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]' -const LINK_ROW_TITLE_CLASSES = 'truncate text-[14px] text-[var(--text-body)]' -const LINK_ROW_SUBTITLE_CLASSES = 'truncate text-[12px] text-[var(--text-muted)]' -const LINK_ROW_ARROW_CLASSES = 'size-4 flex-shrink-0 text-[var(--text-icon)]' - const FEATURED_INTEGRATIONS: readonly Integration[] = (() => { const bySlug = new Map(INTEGRATIONS.map((i) => [i.slug, i])) return FEATURED_SLUGS.map((slug) => bySlug.get(slug)).filter( @@ -85,14 +79,15 @@ function IntegrationItem({ icon: Icon, }: IntegrationItemProps) { return ( - - -
- {name} - {description && {description}} -
- - + } + title={name} + description={description || undefined} + href={`/workspace/${workspaceId}/integrations/${slug}`} + clickLabel={`Open ${name}`} + navigable + /> ) } @@ -122,14 +117,15 @@ interface ConnectedItemProps { function ConnectedItem({ href, blockType, name, description, icon: Icon }: ConnectedItemProps) { return ( - - -
- {name} - {description} -
- - + } + title={name} + description={description} + href={href} + clickLabel={`Open ${name}`} + navigable + /> ) } @@ -361,11 +357,11 @@ export function Integrations() { ))} {showNoResults && ( -
+ {urlSearchTerm.trim() ? `No integrations found matching “${urlSearchTerm}”` : 'No integrations in this category'} -
+ )}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx index 5a2daaa70ca..e456d250d1b 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx @@ -302,8 +302,8 @@ export function Admin() { <>
- -

+ +

Default uses the configured Sim agent URL.

diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx index 2c7b1561383..6daf1e083cb 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx @@ -13,6 +13,10 @@ import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/component import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { @@ -198,24 +202,50 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) { {showsWorkspaceKeys && !searchTerm.trim() ? ( {workspaceKeys.length === 0 ? ( -
No workspace API keys yet
+ + No workspace API keys yet + ) : ( -
+
{workspaceKeys.map((key) => ( -
-
-
- - {key.name} - - - (last used: {formatLastUsed(key.lastUsed).toLowerCase()}) - -
-

- {key.displayKey} -

-
+ + {`last used ${formatLastUsed(key.lastUsed).toLowerCase()}`} + + } + trailing={ + { + setDeleteKey(key) + setShowDeleteDialog(true) + }} + canDelete={canManageWorkspaceKeys} + /> + } + /> + ))} +
+ )} + + ) : showsWorkspaceKeys && filteredWorkspaceKeys.length > 0 ? ( + +
+ {filteredWorkspaceKeys.map(({ key }) => ( + + {`last used ${formatLastUsed(key.lastUsed).toLowerCase()}`} + + } + trailing={ { @@ -224,38 +254,8 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) { }} canDelete={canManageWorkspaceKeys} /> -
- ))} -
- )} - - ) : showsWorkspaceKeys && filteredWorkspaceKeys.length > 0 ? ( - -
- {filteredWorkspaceKeys.map(({ key }) => ( -
-
-
- - {key.name} - - - (last used: {formatLastUsed(key.lastUsed).toLowerCase()}) - -
-

- {key.displayKey} -

-
- { - setDeleteKey(key) - setShowDeleteDialog(true) - }} - canDelete={canManageWorkspaceKeys} - /> -
+ } + /> ))}
@@ -263,38 +263,34 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) { {showsPersonalKeys && (!searchTerm.trim() || filteredPersonalKeys.length > 0) && ( -
+
{filteredPersonalKeys.map(({ key }) => { const isConflict = conflictNames.has(key.name) return ( -
-
-
-
- - {key.name} - - - (last used: {formatLastUsed(key.lastUsed).toLowerCase()}) - -
-

- {key.displayKey} -

-
- { - setDeleteKey(key) - setShowDeleteDialog(true) - }} - /> -
+
+ + {`last used ${formatLastUsed(key.lastUsed).toLowerCase()}`} + + } + trailing={ + { + setDeleteKey(key) + setShowDeleteDialog(true) + }} + /> + } + /> {isConflict && ( -
+

Workspace API key with the same name overrides this. Rename your personal key to use it. -

+

)}
) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx index 76c36f517f3..0505c9fb8f1 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx @@ -47,6 +47,7 @@ import { CreditUsageSection } from '@/app/workspace/[workspaceId]/settings/compo import { UsageLimitField } from '@/app/workspace/[workspaceId]/settings/components/billing/components/usage-limit-field/usage-limit-field' import { getSubscriptionPermissions } from '@/app/workspace/[workspaceId]/settings/components/billing/subscription-permissions' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { RESOURCE_ROW_ARROW_CLASSES } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useBillingUsageNotifications, @@ -637,7 +638,7 @@ export function Billing({ {invoice.description ?? ''} - + ) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.test.tsx index e154e12b593..5d1d78265e3 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.test.tsx @@ -14,6 +14,8 @@ const { mockBridge, mockToast } = vi.hoisted(() => ({ })) vi.mock('@sim/emcn', () => ({ + /** `password-detail` composes the shared tile classes with `cn`. */ + cn: (...classes: unknown[]) => classes.filter(Boolean).join(' '), ArrowLeft: () => , Button: ({ children, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.tsx index 0c301136264..90091fb5f00 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.tsx @@ -8,6 +8,7 @@ import { ChipConfirmModal, ChipCopyInput, ChipInput, + cn, Duplicate, Eye, EyeOff, @@ -16,6 +17,11 @@ import { toast, } from '@sim/emcn' import { getDesktopBridge } from '@/lib/desktop' +import { + RESOURCE_TILE_BASE, + RESOURCE_TILE_PLAIN, +} from '@/app/workspace/[workspaceId]/components/resource-tile' +import { SettingsField } from '@/app/workspace/[workspaceId]/settings/components/settings-field' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' @@ -136,32 +142,27 @@ export function PasswordDetail({ credential, onBack, onForgotten }: PasswordDeta >
-
- Site +
-
-
- {credential.icon ? ( - // A `data:` URL copied from the source browser at import - // time — never a network request, which would disclose - // which sites the user has passwords for. - - ) : ( - - )} -
+
+ {credential.icon ? ( + // A `data:` URL copied from the source browser at import + // time — never a network request, which would disclose + // which sites the user has passwords for. + + ) : ( + + )}
-
+
-
- Username + -
+ -
- Password + } /> -
+
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.test.tsx index b38ba8b8149..b334e010c14 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.test.tsx @@ -175,11 +175,14 @@ async function click(button: HTMLButtonElement) { }) } -/** Cards are buttons outside the header; each shows a site and a username. */ -const cards = () => - [...container.querySelectorAll('main > button, main div button')].filter( +/** Each card's hit area — a stretched overlay button owned by `SettingsResourceRow`. */ +const cardButtons = () => + [...container.querySelectorAll('main button[aria-label^="Open "]')].filter( (node) => !node.closest('header') - ) + ) as HTMLButtonElement[] + +/** The row wrapping each hit area; it carries the visible site and username. */ +const cards = () => cardButtons().map((button) => button.parentElement as HTMLElement) const bridge = () => mockBridge.current as ReturnType @@ -220,7 +223,7 @@ describe('PasswordsView', () => { it('opens the detail page for the card that was clicked', async () => { await render() - await click(cards()[1] as HTMLButtonElement) + await click(cardButtons()[1]) expect(container.querySelector('[aria-label="Password detail"]')?.textContent).toBe( 'https://fubo.tv' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.tsx index aad358a1fba..afe1b8fe2e8 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.tsx @@ -7,21 +7,18 @@ import type { BrowserImportError, BrowserImportProfile, } from '@sim/desktop-bridge' -import { ArrowLeft, ArrowRight, ChipConfirmModal, Key, Plus, toast } from '@sim/emcn' +import { ArrowLeft, ChipConfirmModal, Key, Plus, toast } from '@sim/emcn' import { getDesktopBridge } from '@/lib/desktop' import { ImportModal } from '@/app/workspace/[workspaceId]/settings/components/browser/components/import-modal/import-modal' import { PasswordDetail } from '@/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' -import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { + RESOURCE_LIST_GRID, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' -/** The integrations page's responsive card grid (see `integration-section.tsx`, `skills.tsx`). */ -const CARD_GRID = '-mx-2 grid grid-cols-[repeat(auto-fit,minmax(280px,1fr))] gap-x-2 gap-y-0.5' -/** Card hit area; the row chrome inside it comes from {@link SettingsResourceRow}. */ -const CARD_CLASSES = - 'w-full rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]' - const IMPORT_ERROR_MESSAGES: Record = { 'unsupported-platform': 'Importing from another browser is only supported on macOS.', 'chrome-not-found': 'Could not find that browser profile.', @@ -193,32 +190,28 @@ export function PasswordsView({ credentials, onChange, onBack, onImported }: Pas ) : ( <> -
+
{filtered.map((credential) => ( - + clickLabel={`Open ${siteLabel(credential.origin)}`} + navigable + /> ))}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx index 51d3bd630a8..a9d10d9a10d 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx @@ -5,13 +5,13 @@ import { Button, Chip, ChipConfirmModal, + ChipInput, ChipModal, ChipModalBody, ChipModalError, ChipModalField, ChipModalFooter, ChipModalHeader, - cn, } from '@sim/emcn' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' @@ -21,9 +21,11 @@ import { CHIP_FIELD_SHELL, } from '@/app/workspace/[workspaceId]/components/credential-detail/components/chip-field' import { BYOKProviderKeysModal } from '@/app/workspace/[workspaceId]/settings/components/byok/byok-provider-keys-modal' -import { BYOKKeySkeleton } from '@/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' -import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' const logger = createLogger('BYOKKeyManager') @@ -308,31 +310,20 @@ export function BYOKKeyManager(props: BYOKKeyManagerProps) { <>
{showSearch && ( -
- - setSearchTerm(e.target.value)} - disabled={isLoading} - className={cn(CHIP_FIELD_INPUT, 'disabled:cursor-not-allowed disabled:opacity-60')} - /> -
+ setSearchTerm(e.target.value)} + disabled={isLoading} + className='w-full' + /> )} {description &&

{description}

} - {isLoading ? ( -
- {providers.map((p) => ( - - ))} -
- ) : showNoResults ? ( + {isLoading ? null : showNoResults ? ( No providers found matching "{searchTerm}" @@ -346,13 +337,13 @@ export function BYOKKeyManager(props: BYOKKeyManagerProps) { return ( -
{rows.map(renderRow)}
+
{rows.map(renderRow)}
) })}
) : ( -
{filteredProviders.map(renderRow)}
+
{filteredProviders.map(renderRow)}
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton.tsx deleted file mode 100644 index 71d220b9c37..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Skeleton } from '@sim/emcn' - -/** - * Skeleton component for BYOK provider key items. - */ -export function BYOKKeySkeleton() { - return ( -
-
- -
- - -
-
-
- - -
-
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx index c746dd62828..b7cd5655408 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx @@ -2,7 +2,6 @@ import { useMemo, useState } from 'react' import { - Chip, ChipConfirmModal, ChipModal, ChipModalBody, @@ -15,9 +14,14 @@ import { import { createLogger } from '@sim/logger' import { formatDate } from '@sim/utils/formatting' import { Plus } from 'lucide-react' +import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { type CopilotKey, @@ -132,30 +136,33 @@ export function Copilot() { {isLoading ? null : showEmptyState ? ( Click "Create API key" above to get started ) : ( -
+
{filteredKeys.map((key) => ( -
-
-
- - {key.name || 'Unnamed Key'} - - - (last used: {formatLastUsed(key.lastUsed).toLowerCase()}) - -
-

{key.displayKey}

-
- { - setDeleteKey(key) - setShowDeleteDialog(true) - }} - > - Delete - -
+ + {`last used ${formatLastUsed(key.lastUsed).toLowerCase()}`} + + } + trailing={ + { + setDeleteKey(key) + setShowDeleteDialog(true) + }, + }, + ]} + /> + } + /> ))} {showNoResults && ( diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail/custom-tool-detail.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail/custom-tool-detail.tsx index 88788ec0ea7..653bc066b94 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail/custom-tool-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail/custom-tool-detail.tsx @@ -2,15 +2,11 @@ import { useMemo, useState } from 'react' import { ChipConfirmModal, toast } from '@sim/emcn' -import { ArrowLeft, Wrench } from '@sim/emcn/icons' +import { ArrowLeft } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { saveDiscardActions } from '@/components/settings/save-discard-actions' -import { ResourceTile } from '@/app/workspace/[workspaceId]/components' -import { - CredentialDetailHeading, - UnsavedChangesModal, -} from '@/app/workspace/[workspaceId]/components/credential-detail' +import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail' import { CUSTOM_TOOL_DELETE_CONFIRM_TEXT, CustomToolCodeField, @@ -204,6 +200,11 @@ export function CustomToolDetail({ guard.guardBack(onBack) }} title={identity.name || tool?.title || 'New tool'} + description={ + identity.description || + tool?.schema.function.description || + 'Define the JSON schema your agents call, and the code that runs.' + } actions={[ ...(readOnly ? [] @@ -218,6 +219,7 @@ export function CustomToolDetail({ ...(tool && !readOnly ? [ { + id: 'delete', text: deleteTool.isPending ? 'Deleting...' : 'Delete', variant: 'destructive' as const, onSelect: () => setShowDeleteConfirm(true), @@ -228,16 +230,6 @@ export function CustomToolDetail({ ]} >
- } - title={identity.name || tool?.title || 'New tool'} - subtitle={ - identity.description || - tool?.schema.function.description || - 'Define the JSON schema your agents call, and the code that runs.' - } - /> - {error ? ( -
-

- {getErrorMessage(error, 'Failed to load tools')} -

-
+ + {getErrorMessage(error, 'Failed to load tools')} + ) : isLoading ? null : showEmptyState ? ( {canEdit ? 'Click "Add tool" above to get started' : 'No custom tools configured'} ) : ( -
+
{filteredTools.map((tool) => ( - + clickLabel={`Open ${tool.title || 'Unnamed Tool'}`} + navigable + /> ))} {showNoResults && ( diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.tsx index d0c06152363..6fe4b1e29f1 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.tsx @@ -15,7 +15,10 @@ import { getDesktopBridge, getDesktopShellVersion, getDesktopUpdates } from '@/l import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' -import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' function getMounts(response: LocalFilesystemResponse): LocalFilesystemMount[] | null { @@ -297,7 +300,7 @@ export function Desktop() { No folder access granted. Chat can only read folders you add here. ) : ( -
+
{mounts.map((mount) => ( void revealFolder(mount)} clickLabel={`Show ${mount.name} in the file manager`} + badge={ + !mount.remembered ? ( + + Until app restarts + + ) : undefined + } trailing={ -
- {!mount.remembered && ( - - Until app restarts - - )} - setMountToForget(mount), - }, - ]} - /> -
+ setMountToForget(mount), + }, + ]} + /> } /> ))} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx index df0a8c8dc1d..e68485d5739 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx @@ -2,8 +2,9 @@ import { useCallback, useMemo } from 'react' import { Badge, ChipInput, ChipSelect, Search } from '@sim/emcn' +import { ArrowRight } from '@sim/emcn/icons' import { formatRelativeTime } from '@sim/utils/formatting' -import { ArrowRight, Paperclip } from 'lucide-react' +import { Paperclip } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' import { @@ -12,6 +13,7 @@ import { inboxTaskUrlKeys, } from '@/app/workspace/[workspaceId]/settings/components/inbox/search-params' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { RESOURCE_ROW_ARROW_CLASSES } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import type { InboxTaskItem } from '@/hooks/queries/inbox' import { useInboxConfig, useInboxTasks } from '@/hooks/queries/inbox' import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' @@ -119,7 +121,7 @@ export function InboxTaskList() { ) ) : ( -
+
{filteredTasks.map((task) => { const statusBadge = STATUS_BADGES[task.status] || STATUS_BADGES.received const isClickable = @@ -177,9 +179,7 @@ export function InboxTaskList() { )} {statusBadge.label} - {isClickable && ( - - )} + {isClickable && }
) 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 5558e23fb35..0c886ebf438 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx @@ -8,6 +8,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { ChevronDown, Plus } from 'lucide-react' import { useParams } from 'next/navigation' import { useQueryState } from 'nuqs' +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' @@ -25,9 +26,13 @@ import { } from '@/app/workspace/[workspaceId]/settings/[section]/search-params' import { getRefreshActionState } from '@/app/workspace/[workspaceId]/settings/components/mcp/refresh-action-state' import { getServerToolsLabel } from '@/app/workspace/[workspaceId]/settings/components/mcp/server-tools-label' -import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsField } from '@/app/workspace/[workspaceId]/settings/components/settings-field' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' 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' @@ -67,12 +72,10 @@ interface ServerListItemProps { canManage: boolean server: McpServer tools: McpTool[] - isDeleting: boolean isConnecting: boolean isLoadingTools?: boolean isRefreshing?: boolean discoveryError?: string | null - onRemove: () => void onViewDetails: () => void onAuthorize: () => void } @@ -81,12 +84,10 @@ function ServerListItem({ canManage, server, tools, - isDeleting, isConnecting, isLoadingTools = false, isRefreshing = false, discoveryError = null, - onRemove, onViewDetails, onAuthorize, }: ServerListItemProps) { @@ -110,56 +111,46 @@ function ServerListItem({ server.connectionStatus === 'disconnected' || showDiscoveryError + 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 + return ( -
-
-
- - {server.name || 'Unnamed server'} + } + iconFilled + title={serverName} + description={ + <> + {`${transportLabel} · `} + {/* Only the status reddens — the transport is neutral metadata. */} + + {statusText} - ({transportLabel}) -
-

- {isConnecting - ? 'Waiting for authorization...' - : isRefreshing - ? 'Refreshing...' - : isLoadingTools && tools.length === 0 - ? 'Loading...' - : showDiscoveryError - ? discoveryError - : toolsLabel} -

-
-
- {canManage && server.authType === 'oauth' && server.connectionStatus !== 'connected' && ( + + } + onClick={onViewDetails} + clickLabel={`Open ${serverName}`} + navigable + trailing={ + canManage && server.authType === 'oauth' && server.connectionStatus !== 'connected' ? ( {isConnecting ? 'Reopen authorization' : 'Authorize'} - )} - -
-
+ ) : undefined + } + /> ) } @@ -247,6 +238,9 @@ export function MCP() { try { await deleteServerMutation.mutateAsync({ workspaceId, serverId }) + // Deleting from the detail view leaves a dead id in the URL — drop it so Back + // doesn't land on a server that no longer exists. + if (selectedServerId === serverId) handleBackToList() logger.info(`Removed MCP server: ${serverId}`) } catch (error) { logger.error('Failed to remove MCP server:', error) @@ -402,6 +396,28 @@ export function MCP() { const hasServers = servers && servers.length > 0 const showNoResults = searchTerm.trim() && filteredServers.length === 0 && servers.length > 0 + // Delete is reachable from both the list and the detail header, so the confirm + // modal has to render in whichever branch is mounted. + const deleteConfirmModal = canEdit ? ( + { + if (!open) setServerToDeleteId(null) + }} + srTitle='Delete MCP server' + title='Delete MCP server' + text={[ + 'Are you sure you want to delete ', + { + text: servers.find((s) => s.id === serverToDeleteId)?.name || 'this server', + bold: true, + }, + '? This action cannot be undone.', + ]} + confirm={{ label: 'Delete', onClick: confirmDeleteServer }} + /> + ) : null + if (selectedServer) { const { server, tools } = selectedServer const transportLabel = formatTransportLabel(server.transport || 'http') @@ -429,32 +445,31 @@ export function MCP() { text: 'Edit', onSelect: () => setEditingServerId(server.id), }, + { + id: 'delete', + text: deletingServers.has(server.id) ? 'Deleting...' : 'Delete', + variant: 'destructive' as const, + onSelect: () => handleRemoveServer(server.id), + disabled: deletingServers.has(server.id), + }, ] : [] } >
-
- Server name -

{server.name || 'Unnamed server'}

-
+ {server.name || 'Unnamed server'} -
- Transport -

{transportLabel}

-
+ {transportLabel} {server.url && ( -
- URL -

{server.url}

-
+ + {server.url} + )} {server.connectionStatus !== 'connected' && ( -
- Status +

{getServerToolsLabel( [], @@ -463,12 +478,11 @@ export function MCP() { server.authType )}

-
+ )} {canEdit && server.authType === 'oauth' && server.connectionStatus !== 'connected' && ( -
- Authentication +
-
+ )}
@@ -551,7 +565,7 @@ export function MCP() { {isExpanded && hasParams && (
-

+

Parameters

@@ -586,7 +600,7 @@ export function MCP() { )}
{paramDesc && ( -

+

{paramDesc}

)} @@ -628,6 +642,7 @@ export function MCP() { allowedMcpDomains={allowedMcpDomains} /> )} + {deleteConfirmModal} ) } @@ -655,11 +670,9 @@ export function MCP() { } > {listError ? ( -
-

- {getErrorMessage(listError, 'Failed to load MCP servers')} -

-
+ + {getErrorMessage(listError, 'Failed to load MCP servers')} + ) : serversLoading ? ( Loading... ) : !hasServers ? ( @@ -667,7 +680,7 @@ export function MCP() { {canEdit ? 'Click "Add server" above to get started' : 'No MCP servers configured'} ) : ( -
+
{filteredServers.map((server) => { if (!server?.id) return null const tools = toolsByServer[server.id] || [] @@ -682,7 +695,6 @@ export function MCP() { canManage={canEdit} server={server} tools={tools} - isDeleting={deletingServers.has(server.id)} isConnecting={connectingOauthServers.has(server.id)} isLoadingTools={isLoadingTools} isRefreshing={ @@ -692,7 +704,6 @@ export function MCP() { discoveryError={ serverToolsState?.error ? getErrorMessage(serverToolsState.error) : null } - onRemove={() => handleRemoveServer(server.id)} onViewDetails={() => handleViewDetails(server.id)} onAuthorize={() => startOauthForServer(server.id)} /> @@ -727,25 +738,7 @@ export function MCP() { /> )} - {canEdit && ( - { - if (!open) setServerToDeleteId(null) - }} - srTitle='Delete MCP server' - title='Delete MCP server' - text={[ - 'Are you sure you want to delete ', - { - text: servers.find((s) => s.id === serverToDeleteId)?.name || 'this server', - bold: true, - }, - '? This action cannot be undone.', - ]} - confirm={{ label: 'Delete', onClick: confirmDeleteServer }} - /> - )} + {deleteConfirmModal} ) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx index df8930fe4ac..3bf5fafbdad 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx @@ -22,7 +22,10 @@ import { } from '@/app/workspace/[workspaceId]/settings/components/recently-deleted/search-params' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' -import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { useFolders, useRestoreFolder } from '@/hooks/queries/folders' import { useKnowledgeBasesQuery, useRestoreKnowledgeBase } from '@/hooks/queries/kb/knowledge' @@ -526,11 +529,9 @@ export function RecentlyDeleted() { /> {error ? ( -
-

- {toError(error).message || 'Failed to load deleted items'} -

-
+ + {toError(error).message || 'Failed to load deleted items'} + ) : isLoading ? null : filtered.length === 0 ? ( showNoResults ? ( @@ -540,7 +541,7 @@ export function RecentlyDeleted() { No deleted items ) ) : ( -
+
{filtered.map((resource) => { const isRestoring = restoringIds.has(resource.id) const isRestored = restoredItems.has(resource.id) @@ -561,22 +562,24 @@ export function RecentlyDeleted() { Deleted {formatDate(resource.deletedAt)} } + badge={ + canRestore && isRestored ? ( + + {PAUSED_AUTOMATION_TYPES.has(resource.type) + ? 'Restored \u00b7 schedules and webhooks stay paused' + : 'Restored'} + + ) : undefined + } trailing={ !canRestore ? null : isRestoring ? ( Restoring... ) : isRestored ? ( -
- - {PAUSED_AUTOMATION_TYPES.has(resource.type) - ? 'Restored \u00b7 schedules and webhooks stay paused' - : 'Restored'} - - handleView(resource)}> - View - -
+ handleView(resource)}> + View + ) : ( void handleRestore(resource)}> Restore diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/components/sandbox-editor.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/components/sandbox-editor.tsx index c6887f23277..c5cb959405f 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/components/sandbox-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/components/sandbox-editor.tsx @@ -9,6 +9,7 @@ import { type SandboxDraft, type SandboxLanguage, } from '@/app/workspace/[workspaceId]/settings/components/sandboxes/utils' +import { SettingsField } from '@/app/workspace/[workspaceId]/settings/components/settings-field' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import type { Sandbox } from '@/hooks/queries/sandboxes' @@ -41,8 +42,7 @@ export function SandboxEditor({
-
- Name + onChange({ ...draft, name: event.target.value })} @@ -51,9 +51,8 @@ export function SandboxEditor({ maxLength={64} autoComplete='off' /> -
-
- Language + + onChange({ ...draft, language: language as SandboxLanguage })} @@ -63,7 +62,7 @@ export function SandboxEditor({ }))} disabled={disabled} /> -
+
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/sandboxes.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/sandboxes.tsx index 45c2e437303..ed393de09a6 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/sandboxes.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/sandboxes.tsx @@ -1,7 +1,7 @@ 'use client' import { useCallback, useMemo, useState } from 'react' -import { toast } from '@sim/emcn' +import { ChipConfirmModal, toast } from '@sim/emcn' import { ArrowLeft, Plus } from '@sim/emcn/icons' import { getErrorMessage } from '@sim/utils/errors' import { useParams } from 'next/navigation' @@ -12,7 +12,6 @@ import { saveDiscardActions } from '@/components/settings/save-discard-actions' import type { SandboxDependencyIssue } from '@/lib/api/contracts/sandboxes' import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' -import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' import { SandboxEditor, SandboxStatus, @@ -28,12 +27,15 @@ import { SANDBOX_UPGRADE_DESCRIPTION, SANDBOX_UPGRADE_TITLE, type SandboxDraft, + sandboxDeleteConfirmText, toSubmittedLines, } from '@/app/workspace/[workspaceId]/settings/components/sandboxes/utils' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' -import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' -import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { SettingsUpgradeNotice } from '@/app/workspace/[workspaceId]/settings/components/settings-upgrade-notice' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard' @@ -66,6 +68,7 @@ export function Sandboxes() { const [draft, setDraft] = useState(null) const [issues, setIssues] = useState([]) const [isCreating, setIsCreating] = useState(false) + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) // The draft belongs to whatever was open when it was typed. Browser Back // clears `selectedId` without going through `closeEditor`, so without this the @@ -77,6 +80,10 @@ export function Sandboxes() { setDraft(null) setIssues([]) } + // The confirmation belongs to the sandbox that opened it. Browser Back unmounts + // the modal without closing it, so leaving this set would re-open it against + // whichever sandbox is selected next — and delete that one instead. + setShowDeleteConfirm(false) // Creating and having one open are mutually exclusive, and history can land on // a sandbox while create mode is still set — Forward after starting a new one. // Leaving both on renders an empty "New sandbox" form whose Delete still points @@ -140,6 +147,7 @@ export function Sandboxes() { const handleDelete = useCallback( async (sandbox: Sandbox) => { + setShowDeleteConfirm(false) try { await deleteSandbox.mutateAsync({ workspaceId, sandboxId: sandbox.id }) if (selectedId === sandbox.id) closeEditor() @@ -207,9 +215,10 @@ export function Sandboxes() { ...(selected && canAdmin ? [ { - text: 'Delete', - textTone: 'error' as const, - onSelect: () => void handleDelete(selected), + id: 'delete', + text: deleteSandbox.isPending ? 'Deleting...' : 'Delete', + variant: 'destructive' as const, + onSelect: () => setShowDeleteConfirm(true), disabled: deleteSandbox.isPending, }, ] @@ -225,6 +234,17 @@ export function Sandboxes() { /> + {selected && ( + void handleDelete(selected) }} + /> + )} + - - {filtered.length === 0 ? ( - - {searchTerm - ? 'No sandboxes match your search.' - : 'No sandboxes yet. Create one to let Function blocks import packages.'} - - ) : ( -
- {filtered.map((sandbox) => ( - } - title={ - - } - description={`${sandbox.language === 'python' ? 'Python' : 'JavaScript'} · ${sandbox.dependencies.length} ${sandbox.dependencies.length === 1 ? 'package' : 'packages'}`} - trailing={ - canAdmin ? ( - void setSelectedId(sandbox.id) }, - { - label: 'Delete', - destructive: true, - onSelect: () => void handleDelete(sandbox), - }, - ]} - /> - ) : undefined - } - /> - ))} -
- )} -
+ {filtered.length === 0 ? ( + + {searchTerm + ? 'No sandboxes match your search.' + : 'No sandboxes yet. Create one to let Function blocks import packages.'} + + ) : ( +
+ {filtered.map((sandbox) => ( + } + iconFilled + title={sandbox.name} + description={`${sandbox.language === 'python' ? 'Python' : 'JavaScript'} · ${sandbox.dependencies.length} ${sandbox.dependencies.length === 1 ? 'package' : 'packages'}`} + onClick={() => void setSelectedId(sandbox.id)} + clickLabel={`Open ${sandbox.name}`} + navigable + /> + ))} +
+ )} ) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/utils.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/utils.ts index ae5c909621e..8ea2b721667 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/utils.ts @@ -7,6 +7,20 @@ export const SANDBOX_UPGRADE_TITLE = 'Sandboxes require an active Max plan' export const SANDBOX_UPGRADE_DESCRIPTION = 'Upgrade to Max and ensure billing is active to install Python or npm packages that your Function blocks can import.' +/** Delete-confirmation copy, matching the custom tool detail's wording. Names the + * sandbox so the dialog is self-evidently about the one you opened it from. */ +export function sandboxDeleteConfirmText(name: string) { + return [ + 'This will permanently delete ', + { text: name, bold: true }, + { + text: ' and remove it from any Function blocks that are using it.', + error: true, + }, + ' This action cannot be undone.', + ] +} + /** Ordered to match the Function block's own `language` dropdown. */ export const LANGUAGE_OPTIONS = [ { label: 'JavaScript', value: 'javascript' }, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx index 4b6d58ee8ab..2ad13f3d873 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx @@ -19,6 +19,7 @@ import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/component import { SecretValueField } from '@/app/workspace/[workspaceId]/settings/components/secrets/components/secret-value-field' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { isValidEnvVarName } from '@/executor/constants' import { useWorkspaceCredentials, type WorkspaceCredential } from '@/hooks/queries/credentials' @@ -999,9 +1000,7 @@ export function SecretsManager() { {(!searchTerm.trim() || filteredWorkspaceEntries.length > 0 || filteredNewWorkspaceRows.length > 0) && ( -
- Workspace -
+
{(searchTerm.trim() ? filteredWorkspaceEntries @@ -1044,13 +1043,11 @@ export function SecretsManager() { /> ))}
-
+ )} {(!searchTerm.trim() || filteredEnvVars.length > 0) && ( -
- Personal -
+
{filteredEnvVars.map(({ envVar, originalIndex }) => (
@@ -1058,7 +1055,7 @@ export function SecretsManager() {
))}
-
+ )} {searchTerm.trim() && filteredEnvVars.length === 0 && diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-empty-state/settings-empty-state.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-empty-state/settings-empty-state.tsx index c63457a65d6..6e1cc077ce4 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-empty-state/settings-empty-state.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-empty-state/settings-empty-state.tsx @@ -9,6 +9,8 @@ interface SettingsEmptyStateProps { * matched nothing. Defaults to `fill`. */ variant?: 'fill' | 'inline' + /** Renders the message in the error tone, for a failed load. */ + tone?: 'muted' | 'error' } /** @@ -16,11 +18,16 @@ interface SettingsEmptyStateProps { * "no results", and entitlement/loading gates. Centralizes the text token and * spacing so every settings page reads identically. */ -export function SettingsEmptyState({ children, variant = 'fill' }: SettingsEmptyStateProps) { +export function SettingsEmptyState({ + children, + variant = 'fill', + tone = 'muted', +}: SettingsEmptyStateProps) { return (
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-field/index.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-field/index.ts new file mode 100644 index 00000000000..2f151bb85c2 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-field/index.ts @@ -0,0 +1 @@ +export { SettingsField } from './settings-field' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-field/settings-field.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-field/settings-field.tsx new file mode 100644 index 00000000000..3a8072eb3df --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-field/settings-field.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from 'react' +import { cn } from '@sim/emcn' + +interface SettingsFieldProps { + label: ReactNode + /** Wraps long unbroken values (a URL, a key) instead of overflowing. */ + breakAll?: boolean + children: ReactNode +} + +/** + * A read-only label/value pair inside a settings detail body: a muted caption + * over the value. Single source for that pairing — before this, the same field + * was hand-rolled with three different label sizes and three different gaps. + * + * Renders the value paragraph itself, so callers never restate its type tokens. + * Pass a node instead of text only when the value is a control (a chip, a link). + */ +export function SettingsField({ label, breakAll = false, children }: SettingsFieldProps) { + return ( +
+ {label} + {typeof children === 'string' ? ( +

{children}

+ ) : ( + children + )} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/index.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/index.ts index da29f86f11c..335ecd0a0cb 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/index.ts @@ -1 +1,6 @@ -export { SettingsResourceRow } from './settings-resource-row' +export { + RESOURCE_LIST_GRID, + RESOURCE_LIST_STACK, + RESOURCE_ROW_ARROW_CLASSES, + SettingsResourceRow, +} from './settings-resource-row' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx index d5481416753..01f354c27b2 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx @@ -1,8 +1,11 @@ -import type { ReactNode } from 'react' +import { type ReactNode, useId } from 'react' import { cn } from '@sim/emcn' +import { ArrowRight } from '@sim/emcn/icons' +import Link from 'next/link' import { RESOURCE_TILE_BASE, RESOURCE_TILE_FILL, + RESOURCE_TILE_PLAIN, } from '@/app/workspace/[workspaceId]/components/resource-tile' /** @@ -16,15 +19,20 @@ import { * contains to 20px, so callers pass their raw icon node without pre-sizing it. */ interface SettingsResourceRowProps { - /** Icon node centered in the tile; a `` is normalized to 20px, an `` to 20px (or the full tile when `iconFill`). */ - icon: ReactNode + /** + * Icon node centered in the tile; a `` is normalized to 20px, an `` to + * 20px (or the full tile when `iconFill`). Omit it for rows whose resource has no + * identity glyph (an API key, a permission group) — the row then leads with text. + */ + icon?: ReactNode /** * Icon chrome. `tile` (default) is the bordered 36px tile for brand/logo and * resource icons; `plain` drops the tile for a bare 14px glyph in * `--text-icon`, for rows whose icon is a type marker rather than an identity - * (e.g. a folder on disk). + * (e.g. a folder on disk); `custom` renders `icon` verbatim, for callers that + * must supply their own tile (e.g. the brand-tinted `IntegrationTile`). */ - iconVariant?: 'tile' | 'plain' + iconVariant?: 'tile' | 'plain' | 'custom' /** * Let an image icon fill the tile edge-to-edge instead of clamping to 20px. * Use for uploaded image/logo icons (e.g. custom blocks); glyph ``s still @@ -41,20 +49,72 @@ interface SettingsResourceRowProps { /** Secondary muted line — truncates. */ description?: ReactNode /** - * Trailing element pinned to the row's end (chips, actions menu, status). The row - * keeps it at its natural size — callers never need their own `flex-shrink-0`. + * Interactive controls pinned to the row's end (chips, actions menu). These sit + * ABOVE the row's own hit area, so their clicks are theirs. The row keeps them at + * their natural size — callers never need their own `flex-shrink-0`. + * + * Decorative trailing content (a status badge, a tag) belongs in {@link badge}: + * anything placed here swallows clicks meant for the row. */ trailing?: ReactNode /** - * Makes the icon + text cluster activatable. `trailing` stays a sibling, so - * its own controls keep working — never nest an interactive `trailing` inside - * the row's own hit area. + * Decorative trailing content — a status badge or tag. Rendered before + * {@link trailing} and made click-through, so it never turns the row's right + * edge into a dead zone. + */ + badge?: ReactNode + /** + * Makes the whole row activatable via a stretched overlay button. `trailing` + * stacks above it, so interactive trailing controls (menus, chips) keep + * working — never nest an interactive `trailing` inside a caller-supplied + * wrapper `
- {icon} -
+ {icon == null ? null : iconVariant === 'custom' ? ( + icon + ) : ( +
+ {icon} +
+ )}
{title} {description != null && ( - {description} + + {description} + )}
) - const clusterClass = cn('flex min-w-0 items-center', isTile ? 'gap-2.5' : 'gap-2') + const clusterClass = cn( + 'flex min-w-0 items-center', + iconVariant === 'plain' ? 'gap-2' : 'gap-2.5' + ) + const hasEnd = badge != null || trailing != null || navigable + // Decoration and the chevron stay click-through so the row's right edge never + // becomes a dead zone; only `trailing` takes pointer events back. + const end = hasEnd ? ( +
+ {badge} + {trailing != null &&
{trailing}
} + {navigable && } +
+ ) : null + + // Row geometry is identical whether or not the row is activatable, so a list + // mixing clickable and static rows keeps one height and one inset. + const rowClass = cn('flex items-center justify-between gap-2.5', !flush && '-mx-2 rounded-lg p-2') + if (!onClick && !href) { + return ( +
+
{cluster}
+ {end} +
+ ) + } + + // The ring renders on the stretched overlay, which is inset-0 over the row — so a + // keyboard focus outline traces the visible row even though the control is empty. + const overlayClass = + 'absolute inset-0 cursor-pointer rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color-mix(in_srgb,var(--text-muted)_30%,transparent)]' + + // The hit area is a stretched overlay rather than a wrapper around the cluster: + // it lets the hover band span the full row (matching every hand-rolled settings + // list) while `trailing` — which may hold its own buttons — stacks above it. return ( -
- {onClick ? ( +
+ {href ? ( + + ) : ( - ) : ( -
{cluster}
+ aria-describedby={description != null ? describedById : undefined} + className={overlayClass} + /> )} - {trailing ?
{trailing}
: null} +
{cluster}
+ {end}
) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-section/settings-section.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-section/settings-section.tsx index 18fa43c9315..6e80f8b985e 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-section/settings-section.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-section/settings-section.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from 'react' interface SettingsSectionProps { - label: string + label: ReactNode /** Optional node rendered immediately to the right of the label (e.g. an info tooltip). */ headerAccessory?: ReactNode /** Optional control pinned to the far right of the header row (e.g. a Select All chip). */ diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/components/create-workflow-mcp-server-modal/create-workflow-mcp-server-modal.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/components/create-workflow-mcp-server-modal/create-workflow-mcp-server-modal.tsx index d5063475eee..2417477fb83 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/components/create-workflow-mcp-server-modal/create-workflow-mcp-server-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/components/create-workflow-mcp-server-modal/create-workflow-mcp-server-modal.tsx @@ -123,7 +123,9 @@ export function CreateWorkflowMcpServerModal({ Public {formData.isPublic && ( - No authentication required + + No authentication required + )}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx index 69fed161b7e..1a7accd3634 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx @@ -27,6 +27,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { Check, Clipboard, Plus, Server } from 'lucide-react' import { useParams } from 'next/navigation' import { useQueryState } from 'nuqs' +import { McpIcon } from '@/components/icons' import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation' import { getBaseUrl } from '@/lib/core/utils/urls' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' @@ -39,8 +40,13 @@ import { import { CreateApiKeyModal } from '@/app/workspace/[workspaceId]/settings/components/api-keys/components' import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsField } from '@/app/workspace/[workspaceId]/settings/components/settings-field' import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { CreateWorkflowMcpServerModal } from '@/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/components' import { useApiKeys } from '@/hooks/queries/api-keys' @@ -65,11 +71,22 @@ interface ServerDetailViewProps { workspaceId: string serverId: string onBack: () => void + /** Opens the parent's delete confirmation — the modal lives with the mutation. + * Absent until the parent's list resolves, so a deep link never shows an inert Delete. */ + onDelete?: () => void + isDeleting: boolean } type McpClientType = 'sim' | 'cursor' | 'claude-code' | 'claude-desktop' | 'vscode' -function ServerDetailView({ canManage, workspaceId, serverId, onBack }: ServerDetailViewProps) { +function ServerDetailView({ + canManage, + workspaceId, + serverId, + onBack, + onDelete, + isDeleting, +}: ServerDetailViewProps) { const { data, isLoading, error } = useWorkflowMcpServer(workspaceId, serverId) const { data: deployedWorkflows = [], isLoading: isLoadingWorkflows } = useDeployedWorkflows(workspaceId) @@ -363,11 +380,7 @@ function ServerDetailView({ canManage, workspaceId, serverId, onBack }: ServerDe if (error || !data) { return ( -
-

- Failed to load server details -

-
+ Failed to load server details
) } @@ -393,6 +406,17 @@ function ServerDetailView({ canManage, workspaceId, serverId, onBack }: ServerDe ? 'All deployed workflows have been added to this server.' : undefined, }, + ...(onDelete + ? [ + { + id: 'delete', + text: isDeleting ? 'Deleting...' : 'Delete', + variant: 'destructive' as const, + onSelect: onDelete, + disabled: isDeleting, + }, + ] + : []), ] : [] } @@ -410,25 +434,20 @@ function ServerDetailView({ canManage, workspaceId, serverId, onBack }: ServerDe
{activeServerTab === 'workflows' && (
- Workflows - {tools.length === 0 ? (

No workflows added yet. Click "Add Workflow" to add a deployed workflow.

) : ( -
+
{tools.map((tool) => ( -
-
- {tool.toolName} -

- {tool.toolDescription || 'No description'} -

-
- {canManage && ( -
+ -
- )} -
+ ) : undefined + } + /> ))}
)} {deployedWorkflows.length === 0 && !isLoadingWorkflows && ( -

+

Deploy a workflow first to add it to this server.

)} @@ -459,43 +478,24 @@ function ServerDetailView({ canManage, workspaceId, serverId, onBack }: ServerDe {activeServerTab === 'details' && (
-
- - Server Name - -

{server.name}

-
-
- - Transport - -

Streamable-HTTP

-
-
- Access -

- {server.isPublic ? 'Public' : 'API Key'} -

-
+ {server.name} + Streamable-HTTP + + {server.isPublic ? 'Public' : 'API Key'} +
{server.description?.trim() && ( -
- - Description - -

{server.description}

-
+ {server.description} )} -
- URL -

{mcpServerUrl}

-
+ + {mcpServerUrl} +
- + MCP Client
@@ -563,7 +563,7 @@ function ServerDetailView({ canManage, workspaceId, serverId, onBack }: ServerDe )} {addToWorkspaceMutation.isError && ( -

+

{addToWorkspaceMutation.error?.message || 'Failed to add server'}

)} @@ -609,7 +609,7 @@ function ServerDetailView({ canManage, workspaceId, serverId, onBack }: ServerDe )}
{!server.isPublic && ( -

+

Replace $SIM_API_KEY with your API key {canManage && ( <> @@ -632,244 +632,235 @@ function ServerDetailView({ canManage, workspaceId, serverId, onBack }: ServerDe

+ canManage && ( + !open && setToolToDelete(null)} + srTitle='Remove Workflow' + title='Remove Workflow' + text={[ + 'Are you sure you want to remove ', + { text: toolToDelete?.toolName ?? 'this workflow', bold: true }, + ' from this server? The workflow will remain deployed and can be added back later.', + ]} + confirm={{ + label: 'Remove', + onClick: handleDeleteTool, + pending: deleteToolMutation.isPending, + pendingLabel: 'Removing...', + }} + /> + )canManage && ( + { + if (!open) { + setToolToView(null) + setEditingDescription('') + setEditingParameterDescriptions({}) + } + }} + srTitle={toolToView?.toolName ?? 'Edit Tool'} + > + setToolToView(null)}> + {toolToView?.toolName} + + + - {canManage && ( - !open && setToolToDelete(null)} - srTitle='Remove Workflow' - title='Remove Workflow' - text={[ - 'Are you sure you want to remove ', - { text: toolToDelete?.toolName ?? 'this workflow', bold: true }, - ' from this server? The workflow will remain deployed and can be added back later.', - ]} - confirm={{ - label: 'Remove', - onClick: handleDeleteTool, - pending: deleteToolMutation.isPending, - pendingLabel: 'Removing...', - }} - /> - )} - - {canManage && ( - { - if (!open) { - setToolToView(null) - setEditingDescription('') - setEditingParameterDescriptions({}) - } - }} - srTitle={toolToView?.toolName ?? 'Edit Tool'} - > - setToolToView(null)}> - {toolToView?.toolName} - - - - - - {(() => { - const schema = toolToView?.parameterSchema as - | { properties?: Record } - | undefined - const properties = schema?.properties - const hasParams = properties && Object.keys(properties).length > 0 - return hasParams ? ( -
- {Object.entries(properties).map(([name, prop]) => ( -
-
-
- - {name} - - - {prop.type || 'any'} - -
+ + {(() => { + const schema = toolToView?.parameterSchema as + | { properties?: Record } + | undefined + const properties = schema?.properties + const hasParams = properties && Object.keys(properties).length > 0 + return hasParams ? ( +
+ {Object.entries(properties).map(([name, prop]) => ( +
+
+
+ + {name} + + + {prop.type || 'any'} +
-
-
- - - setEditingParameterDescriptions((prev) => ({ - ...prev, - [name]: e.target.value, - })) - } - placeholder={`Enter description for ${name}`} - /> -
+
+
+
+ + + setEditingParameterDescriptions((prev) => ({ + ...prev, + [name]: e.target.value, + })) + } + placeholder={`Enter description for ${name}`} + />
- ))} -
- ) : ( -

- No inputs configured for this workflow. -

- ) - })()} - - - setToolToView(null)} - primaryAction={{ - label: updateToolMutation.isPending ? 'Saving...' : 'Save', - onClick: handleSaveToolEdit, - disabled: isSaveToolDisabled, - }} - /> - - )} - - {canManage && ( - { - if (!open) { - setShowAddWorkflow(false) - setSelectedWorkflowId(null) - } +
+ ))} +
+ ) : ( +

+ No inputs configured for this workflow. +

+ ) + })()} +
+ + setToolToView(null)} + primaryAction={{ + label: updateToolMutation.isPending ? 'Saving...' : 'Save', + onClick: handleSaveToolEdit, + disabled: isSaveToolDisabled, }} - srTitle='Add Workflow' - > - { - setShowAddWorkflow(false) - setSelectedWorkflowId(null) - }} - > - Add Workflow - - -

- Select a deployed workflow to add to this MCP server. The workflow will be available - as a tool. -

- - setSelectedWorkflowId(value)} - placeholder='Select a workflow...' - searchable - searchPlaceholder='Search workflows...' - disabled={addToolMutation.isPending} - fullWidth - dropdownWidth='trigger' - align='start' - displayLabel={selectedWorkflow?.name} - /> - - - {addToolMutation.isError - ? addToolMutation.error?.message || 'Failed to add workflow' - : null} - -
- { - setShowAddWorkflow(false) - setSelectedWorkflowId(null) - }} - primaryAction={{ - label: addToolMutation.isPending ? 'Adding...' : 'Add Workflow', - onClick: handleAddWorkflow, - disabled: !selectedWorkflowId || addToolMutation.isPending, - }} - /> - - )} - - {canManage && ( - { - if (!open) { - setShowEditServer(false) - } + /> + + )canManage && ( + { + if (!open) { + setShowAddWorkflow(false) + setSelectedWorkflowId(null) + } + }} + srTitle='Add Workflow' + > + { + setShowAddWorkflow(false) + setSelectedWorkflowId(null) }} - srTitle='Edit Server' > - setShowEditServer(false)}>Edit Server - - - + +

+ Select a deployed workflow to add to this MCP server. The workflow will be available as + a tool. +

+ + setSelectedWorkflowId(value)} + placeholder='Select a workflow...' + searchable + searchPlaceholder='Search workflows...' + disabled={addToolMutation.isPending} + fullWidth + dropdownWidth='trigger' + align='start' + displayLabel={selectedWorkflow?.name} /> - -
- setEditServerIsPublic(value === 'public')} - > - API Key - Public - -

- {editServerIsPublic - ? 'Anyone with the URL can call this server without authentication' - : 'Requests must include your Sim API key in the X-API-Key header'} -

-
-
-
- setShowEditServer(false)} - primaryAction={{ - label: updateServerMutation.isPending ? 'Saving...' : 'Save', - onClick: handleSaveServerEdit, - disabled: - !editServerName.trim() || - updateServerMutation.isPending || - (editServerName === server.name && - editServerDescription === (server.description || '') && - editServerIsPublic === server.isPublic), - }} +
+ + {addToolMutation.isError + ? addToolMutation.error?.message || 'Failed to add workflow' + : null} + +
+ { + setShowAddWorkflow(false) + setSelectedWorkflowId(null) + }} + primaryAction={{ + label: addToolMutation.isPending ? 'Adding...' : 'Add Workflow', + onClick: handleAddWorkflow, + disabled: !selectedWorkflowId || addToolMutation.isPending, + }} + /> +
+ )canManage && ( + { + if (!open) { + setShowEditServer(false) + } + }} + srTitle='Edit Server' + > + setShowEditServer(false)}>Edit Server + + - - )} - - {canManage && ( - + +
+ setEditServerIsPublic(value === 'public')} + > + API Key + Public + +

+ {editServerIsPublic + ? 'Anyone with the URL can call this server without authentication' + : 'Requests must include your Sim API key in the X-API-Key header'} +

+
+
+ + setShowEditServer(false)} + primaryAction={{ + label: updateServerMutation.isPending ? 'Saving...' : 'Save', + onClick: handleSaveServerEdit, + disabled: + !editServerName.trim() || + updateServerMutation.isPending || + (editServerName === server.name && + editServerDescription === (server.description || '') && + editServerIsPublic === server.isPublic), + }} /> - )} + + )canManage && ( + + ) ) } @@ -926,6 +917,12 @@ export function WorkflowMcpServers() { workspaceId, serverId: serverToDelete.id, }) + // Deleting from the detail view leaves a dead id in the URL; on reload the + // detail branch mounts against a server that no longer exists. + if (selectedServerId === serverToDelete.id) { + void setServerTab(null, { history: 'replace' }) + void setSelectedServerId(null, { history: 'replace' }) + } } catch (err) { logger.error('Failed to delete server:', err) } finally { @@ -951,17 +948,40 @@ export function WorkflowMcpServers() { const selectedServerResolves = selectedServerId !== null && (isLoading || servers.some((s) => s.id === selectedServerId)) + // Delete is reachable from both the list and the detail header, so the confirm + // modal has to render in whichever branch is mounted. + const deleteConfirmModal = canAdmin ? ( + !open && setServerToDelete(null)} + srTitle='Delete MCP Server' + title='Delete MCP Server' + text={[ + 'Are you sure you want to delete ', + { text: serverToDelete?.name ?? 'this server', bold: true }, + '? This action cannot be undone.', + ]} + confirm={{ label: 'Delete', onClick: handleDeleteServer }} + /> + ) : null + if (selectedServerId && selectedServerResolves) { + const selectedServer = servers.find((s) => s.id === selectedServerId) return ( - { - void setServerTab(null, { history: 'replace' }) - void setSelectedServerId(null, { history: 'replace' }) - }} - /> + <> + { + void setServerTab(null, { history: 'replace' }) + void setSelectedServerId(null, { history: 'replace' }) + }} + onDelete={selectedServer ? () => setServerToDelete(selectedServer) : undefined} + isDeleting={deletingServers.has(selectedServerId)} + /> + {deleteConfirmModal} + ) } @@ -989,62 +1009,42 @@ export function WorkflowMcpServers() { >
{error ? ( -
-

- {getErrorMessage(error, 'Failed to load MCP servers')} -

-
+ + {getErrorMessage(error, 'Failed to load MCP servers')} + ) : isLoading ? null : !hasServers ? ( {canAdmin ? 'Click "Add server" above to get started' : 'No MCP servers configured'} ) : ( -
+
{filteredServers.map((server) => { const count = server.toolCount || 0 const toolsLabel = `${count} tool${count !== 1 ? 's' : ''}` - const isDeleting = deletingServers.has(server.id) return ( -
-
-
- - {server.name} - - {server.isPublic && ( - - Public - - )} -
-

{toolsLabel}

-
-
- { - // A lingering ?server-tab= (dead deep link) must not re-target the next open — reset it in the same batched push. - void setServerTab(null) - void setSelectedServerId(server.id) - }, - }, - ...(canAdmin - ? [ - { - label: 'Delete', - destructive: true, - disabled: isDeleting, - onSelect: () => setServerToDelete(server), - }, - ] - : []), - ]} - /> -
-
+ } + iconFilled + title={server.name} + description={toolsLabel} + onClick={() => { + // A lingering ?server-tab= (dead deep link) must not re-target the next open — reset it in the same batched push. + void setServerTab(null) + void setSelectedServerId(server.id) + }} + clickLabel={`Open ${server.name}`} + navigable + // The badge sits at the row's end, not beside the name — the + // title truncates, so a long name would clip it out of view. + badge={ + server.isPublic ? ( + + Public + + ) : undefined + } + /> ) })} {showNoResults && ( @@ -1066,20 +1066,7 @@ export function WorkflowMcpServers() { /> )} - {canAdmin && ( - !open && setServerToDelete(null)} - srTitle='Delete MCP Server' - title='Delete MCP Server' - text={[ - 'Are you sure you want to delete ', - { text: serverToDelete?.name ?? 'this server', bold: true }, - '? This action cannot be undone.', - ]} - confirm={{ label: 'Delete', onClick: handleDeleteServer }} - /> - )} + {deleteConfirmModal} ) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx index b1412d0d7f2..985b35649fa 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx @@ -4,18 +4,19 @@ import { useState } from 'react' import { Chip, ChipCopyInput, ChipLink, Send } from '@sim/emcn' import { ArrowLeft, Key } from '@sim/emcn/icons' import { SaveDiscardChips } from '@/components/settings/save-discard-actions' +import { ResourceTile } from '@/app/workspace/[workspaceId]/components' import { AddPeopleModal, CredentialDetailHeading, CredentialDetailLayout, CredentialMembersSection, - DetailIconTile, DetailSection, UnsavedChangesModal, useUnsavedChangesGuard, } from '@/app/workspace/[workspaceId]/components/credential-detail' import { SecretValueField } from '@/app/workspace/[workspaceId]/settings/components/secrets/components/secret-value-field' import { useSecretValue } from '@/app/workspace/[workspaceId]/settings/components/secrets/hooks/use-secret-value' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { useWorkspaceCredential } from '@/hooks/queries/credentials' interface SecretDetailProps { @@ -65,7 +66,7 @@ export function SecretDetail({ workspaceId, credentialId }: SecretDetailProps) { if (isPending && !credential) { return ( -

Loading…

+ Loading…
) } @@ -73,7 +74,7 @@ export function SecretDetail({ workspaceId, credentialId }: SecretDetailProps) { if (!credential) { return ( -

Secret not found.

+ Secret not found.
) } @@ -82,7 +83,7 @@ export function SecretDetail({ workspaceId, credentialId }: SecretDetailProps) { <> } + leading={} title={credential.envKey || credential.displayName} subtitle={ isPersonal diff --git a/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx b/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx index 9c37ba94fdb..8fe37dcfe52 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx @@ -15,6 +15,7 @@ import { UnsavedChangesModal, useUnsavedChangesGuard, } from '@/app/workspace/[workspaceId]/components/credential-detail' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SkillEditorsCard } from '@/app/workspace/[workspaceId]/skills/[skillId]/components/skill-editors-card' import { type SkillFieldErrors, @@ -199,7 +200,7 @@ export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) { if ((skillsLoading || deleteSkill.isPending || deleteSkill.isSuccess) && !skill) { return ( -

Loading…

+ Loading…
) } @@ -207,7 +208,7 @@ export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) { if (!skill) { return ( -

Skill not found.

+ Skill not found.
) } diff --git a/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx b/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx index 2f2dd9ad2ff..b671fb1e84d 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx +++ b/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx @@ -3,12 +3,18 @@ import { useEffect, useRef } from 'react' import { Chip, ChipInput, Search } from '@sim/emcn' import { getErrorMessage } from '@sim/utils/errors' -import { ArrowRight, Plus } from 'lucide-react' +import { Plus } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' import { useQueryState } from 'nuqs' import { SkillTile } from '@/app/workspace/[workspaceId]/components' import { IntegrationTabsHeader } from '@/app/workspace/[workspaceId]/integrations/components/integration-tabs-header' import { ShowcaseWithExplore } from '@/app/workspace/[workspaceId]/integrations/components/showcase-with-explore' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { + RESOURCE_LIST_GRID, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { skillIdParam, skillIdUrlKeys, @@ -20,48 +26,6 @@ import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' const SKILLS_LABEL = 'Skills' -interface SkillItemProps { - name: string - description: string - onClick: () => void -} - -function SkillItem({ name, description, onClick }: SkillItemProps) { - return ( - - ) -} - -interface SkillSectionProps { - label: string - children: React.ReactNode -} - -function SkillSection({ label, children }: SkillSectionProps) { - return ( -
- {label} -
-
- {children} -
-
- ) -} - export function Skills() { const params = useParams() const router = useRouter() @@ -132,30 +96,36 @@ export function Skills() { value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} disabled={isLoading} - className='flex-1' + className='min-w-0 flex-1' />
{error ? ( -
+ {getErrorMessage(error, 'Failed to load skills')} -
+ ) : filteredSkills.length > 0 ? ( - - {filteredSkills.map((s) => ( - router.push(`${skillsHref}/${s.id}`)} - /> - ))} - + +
+ {filteredSkills.map((s) => ( + } + title={s.name} + description={s.description || undefined} + onClick={() => router.push(`${skillsHref}/${s.id}`)} + clickLabel={`Open ${s.name}`} + navigable + /> + ))} +
+
) : showNoResults ? ( -
+ No skills found matching “{searchTerm}” -
+ ) : null}
diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/components/plan-card/plan-card.tsx b/apps/sim/app/workspace/[workspaceId]/upgrade/components/plan-card/plan-card.tsx index 808be0af4a0..a8b6cbeae41 100644 --- a/apps/sim/app/workspace/[workspaceId]/upgrade/components/plan-card/plan-card.tsx +++ b/apps/sim/app/workspace/[workspaceId]/upgrade/components/plan-card/plan-card.tsx @@ -1,5 +1,6 @@ 'use client' import { Check, ChipTag, Credit, chipVariants, cn, Info, RefreshCw } from '@sim/emcn' +import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' /** * Props for {@link UpgradePlanCard}. @@ -125,10 +126,7 @@ export function UpgradePlanCard({ )}
- {/* Section header + divider matching integrations/skills separator language */} -
- {segmentLabel} -
+
    {features.map((feature) => (
  • @@ -137,7 +135,7 @@ export function UpgradePlanCard({
  • ))}
-
+ ) } diff --git a/apps/sim/ee/access-control/components/access-control.tsx b/apps/sim/ee/access-control/components/access-control.tsx index ce4330347b1..003ba3eb514 100644 --- a/apps/sim/ee/access-control/components/access-control.tsx +++ b/apps/sim/ee/access-control/components/access-control.tsx @@ -14,7 +14,7 @@ import { } from '@sim/emcn' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { ArrowRight, Plus } from 'lucide-react' +import { Plus } from 'lucide-react' import { useParams } from 'next/navigation' import { useQueryState } from 'nuqs' import { isEnterprise } from '@/lib/billing/plan-helpers' @@ -31,6 +31,10 @@ import { } from '@/app/workspace/[workspaceId]/settings/[section]/search-params' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { GroupDetail } from '@/ee/access-control/components/group-detail' @@ -252,37 +256,27 @@ export function AccessControl({ isOrganizationAdmin, organizationId }: AccessCon No groups found matching "{searchTerm}" ) : ( -
+
{filteredGroups.map((group) => ( - + clickLabel={`Open ${group.name}`} + navigable + /> ))}
)} diff --git a/apps/sim/ee/access-control/components/group-detail.tsx b/apps/sim/ee/access-control/components/group-detail.tsx index 5e47ebee4d2..97e289fb7ab 100644 --- a/apps/sim/ee/access-control/components/group-detail.tsx +++ b/apps/sim/ee/access-control/components/group-detail.tsx @@ -436,9 +436,9 @@ function AddMembersModal({
{filteredMembers.length === 0 ? ( -

+ No members found matching "{searchTerm}" -

+ ) : (
{filteredMembers.map((member) => { @@ -451,7 +451,7 @@ function AddMembersModal({ key={member.userId} type='button' onClick={() => handleToggleMember(member.userId)} - className='flex items-center gap-2.5 rounded-sm p-2 text-left hover-hover:bg-[var(--surface-active)]' + className='flex items-center gap-2.5 rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]' > diff --git a/apps/sim/ee/custom-blocks/components/custom-blocks.tsx b/apps/sim/ee/custom-blocks/components/custom-blocks.tsx index 50d0b7ebceb..428b433da5f 100644 --- a/apps/sim/ee/custom-blocks/components/custom-blocks.tsx +++ b/apps/sim/ee/custom-blocks/components/custom-blocks.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from 'react' import { ChipTag } from '@sim/emcn' -import { ArrowRight, Plus } from 'lucide-react' +import { Plus } from 'lucide-react' import { useParams } from 'next/navigation' import { useQueryState } from 'nuqs' import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation' @@ -13,7 +13,10 @@ import { } from '@/app/workspace/[workspaceId]/settings/[section]/search-params' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' -import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { getCustomBlockIcon } from '@/blocks/custom/custom-block-icon' @@ -126,30 +129,21 @@ export function CustomBlocks() { No blocks found matching "{searchTerm}" ) : ( -
+
{filtered.map((cb) => { const Icon = getCustomBlockIcon(cb.iconUrl, fallbackIconUrl) return ( - + icon={} + iconFill + title={cb.name} + description={cb.description || undefined} + onClick={canAdmin ? () => void setSelectedBlockId(cb.id) : undefined} + clickLabel={`Open ${cb.name}`} + navigable={canAdmin} + badge={!cb.enabled ? Disabled : undefined} + /> ) })}
diff --git a/apps/sim/ee/data-drains/components/data-drains-settings.tsx b/apps/sim/ee/data-drains/components/data-drains-settings.tsx index 659c758ffb3..9a0235acdf3 100644 --- a/apps/sim/ee/data-drains/components/data-drains-settings.tsx +++ b/apps/sim/ee/data-drains/components/data-drains-settings.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { ChipTag } from '@sim/emcn' -import { ArrowRight, Database, Plus } from '@sim/emcn/icons' +import { Database, Plus } from '@sim/emcn/icons' import { getErrorMessage } from '@sim/utils/errors' import { useQueryState } from 'nuqs' import { @@ -12,7 +12,10 @@ import { import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' -import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { DataDrainCreate } from '@/ee/data-drains/components/data-drain-create' import { DataDrainDetail } from '@/ee/data-drains/components/data-drain-detail' @@ -105,42 +108,32 @@ export function DataDrainsSettings({ organizationId }: DataDrainsSettingsProps) }} > {error ? ( -
-

- {getErrorMessage(error, "Couldn't load data drains")} -

-
+ + {getErrorMessage(error, "Couldn't load data drains")} + ) : isPending ? null : drains && drains.length > 0 ? ( -
+
{filteredDrains.map((drain) => ( - + clickLabel={`Open ${drain.name}`} + navigable + badge={!drain.enabled ? Disabled : undefined} + /> ))} {filteredDrains.length === 0 && ( diff --git a/apps/sim/ee/data-retention/components/data-retention-settings.tsx b/apps/sim/ee/data-retention/components/data-retention-settings.tsx index 8359777bd59..8076adbd14b 100644 --- a/apps/sim/ee/data-retention/components/data-retention-settings.tsx +++ b/apps/sim/ee/data-retention/components/data-retention-settings.tsx @@ -18,7 +18,7 @@ import { ArrowLeft } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { ArrowRight, Plus } from 'lucide-react' +import { Plus } from 'lucide-react' import { CustomPatternsEditor } from '@/components/pii/custom-patterns-editor' import { saveDiscardActions } from '@/components/settings/save-discard-actions' import type { SettingsAction } from '@/components/settings/settings-header' @@ -45,6 +45,10 @@ import { import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard' import { @@ -994,42 +998,24 @@ export function DataRetentionSettings({ organizationId: orgId }: DataRetentionSe ]} > -
- + clickLabel='Open organization retention policy' + navigable + /> {overrideWorkspaceIds.map((workspaceId) => ( - + clickLabel={`Open ${workspaceName(workspaceId)} retention override`} + navigable + /> ))}
diff --git a/apps/sim/ee/sso/components/sso-auth.tsx b/apps/sim/ee/sso/components/sso-auth.tsx index c4e8ac53fe1..0af36c8de01 100644 --- a/apps/sim/ee/sso/components/sso-auth.tsx +++ b/apps/sim/ee/sso/components/sso-auth.tsx @@ -137,7 +137,7 @@ export default function SSOAuth({ identifier }: SSOAuthProps) { )} /> {showEmailValidationError && emailErrors.length > 0 && ( -
+
{emailErrors.map((error) => (

{error}

))} diff --git a/apps/sim/ee/sso/components/sso-form.tsx b/apps/sim/ee/sso/components/sso-form.tsx index 638a004e216..1c41e429e3e 100644 --- a/apps/sim/ee/sso/components/sso-form.tsx +++ b/apps/sim/ee/sso/components/sso-form.tsx @@ -170,7 +170,7 @@ export default function SSOForm() { )} /> {showEmailValidationError && emailErrors.length > 0 && ( -
+
{emailErrors.map((error) => (

{error}

))} diff --git a/apps/sim/ee/sso/components/verified-domains-section.tsx b/apps/sim/ee/sso/components/verified-domains-section.tsx index e069fd16d53..bf914a9e005 100644 --- a/apps/sim/ee/sso/components/verified-domains-section.tsx +++ b/apps/sim/ee/sso/components/verified-domains-section.tsx @@ -46,16 +46,16 @@ function DomainRow({ organizationId, domain, onRemove }: DomainRowProps) { icon={} title={domain.domain} description={isVerified ? 'Ownership verified' : 'Awaiting DNS verification'} + badge={ + + {isVerified ? 'Verified' : 'Pending'} + + } trailing={ -
- - {isVerified ? 'Verified' : 'Pending'} - - onRemove(domain), destructive: true }]} - /> -
+ onRemove(domain), destructive: true }]} + /> } /> diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx b/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx index e7d7a7d6131..02e22593e73 100644 --- a/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx +++ b/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx @@ -12,7 +12,7 @@ import { Label, Tooltip, } from '@sim/emcn' -import { ArrowRight } from 'lucide-react' +import { ArrowRight } from '@sim/emcn/icons' import type { ForkCopyableUnmapped, ForkDependentReconfig, From 14d9542f2acc71ee05156962e5e6be136a020ff5 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 3 Aug 2026 09:29:26 -0700 Subject: [PATCH 06/11] fix(credentials): capture the correct provider identity on connect and rotate (#6201) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(credentials): capture the correct provider identity on connect and rotate Attio OAuth recorded an arbitrary workspace member instead of the authorizing user, so two members connecting under one Sim user collapsed into a single account row via the stale-sibling dedupe. Notion read `profile.person.email`, which never exists on a bot token. Synthetic connector emails were minted on live third-party domains. Google service-account rotation left the credential labeled with the old key's client_email and skipped audit metadata entirely. Box and Salesforce identity lookups failed silently with no logger in either file. Service-account principals are now a single ServiceAccountPrincipal union (user / tenant / lookup_failed / null) mirrored centrally into both audit and stored metadata, so a principal can no longer be captured and forgotten, and "which account is this credential?" is answerable from SQL. * fix(credentials): keep the provider-reported name when identity lookup degrades Box and Salesforce returned early on a missing user id, discarding a `name` or `login` the response did carry and relabeling the credential to the enterprise or host fallback. Only the principal should degrade; the human label still beats an id-derived string. Also notes the Salesforce `openid` scope in the connect help text. The client credentials minter sends no scope parameter — effective scopes come from the customer's Connected App — so without `openid` the userinfo lookup can 403 and the run-as user silently never reaches the audit record. --- apps/sim/app/api/credentials/route.test.ts | 3 +- apps/sim/app/api/credentials/route.ts | 5 +- apps/sim/lib/auth/auth.ts | 207 +++++++++--- apps/sim/lib/auth/connector-email.test.ts | 79 +++++ apps/sim/lib/auth/connector-email.ts | 61 ++++ .../credentials/atlassian-service-account.ts | 12 +- .../client-credential-accounts/descriptors.ts | 2 +- .../minters/box.test.ts | 38 ++- .../client-credential-accounts/minters/box.ts | 79 +++-- .../minters/salesforce.test.ts | 29 +- .../minters/salesforce.ts | 74 ++++- .../minters/zoho-desk.test.ts | 7 +- .../minters/zoho-desk.ts | 8 +- .../minters/zoom.test.ts | 3 +- .../minters/zoom.ts | 6 +- .../client-credential-accounts/server.ts | 15 +- apps/sim/lib/credentials/display-name.ts | 12 + .../credentials/orchestration/index.test.ts | 298 ++++++++++++++++++ .../lib/credentials/orchestration/index.ts | 174 ++++++---- apps/sim/lib/credentials/principal.ts | 62 ++++ .../service-account-secret.test.ts | 41 ++- .../lib/credentials/service-account-secret.ts | 62 +++- .../token-service-accounts/errors.ts | 14 + .../token-service-accounts/server.ts | 15 +- .../validators/airtable.test.ts | 10 +- .../validators/airtable.ts | 6 +- .../validators/asana.test.ts | 6 +- .../validators/asana.ts | 7 +- .../validators/attio.test.ts | 4 +- .../validators/attio.ts | 15 +- .../validators/calcom.test.ts | 4 +- .../validators/calcom.ts | 8 +- .../validators/claude-platform.ts | 5 + .../validators/clickup.ts | 7 +- .../validators/hubspot.test.ts | 9 +- .../validators/hubspot.ts | 18 +- .../validators/linear.test.ts | 3 +- .../validators/linear.ts | 5 +- .../validators/monday.test.ts | 5 +- .../validators/monday.ts | 5 +- .../validators/notion.test.ts | 13 +- .../validators/notion.ts | 8 +- .../validators/pipedrive.test.ts | 6 +- .../validators/pipedrive.ts | 4 +- .../validators/shopify.test.ts | 26 +- .../validators/shopify.ts | 40 ++- .../validators/trello.test.ts | 4 +- .../validators/trello.ts | 11 +- .../validators/wealthbox.test.ts | 4 +- .../validators/wealthbox.ts | 16 +- .../validators/webflow.test.ts | 6 +- .../validators/webflow.ts | 6 +- 52 files changed, 1294 insertions(+), 273 deletions(-) create mode 100644 apps/sim/lib/auth/connector-email.test.ts create mode 100644 apps/sim/lib/auth/connector-email.ts create mode 100644 apps/sim/lib/credentials/orchestration/index.test.ts create mode 100644 apps/sim/lib/credentials/principal.ts diff --git a/apps/sim/app/api/credentials/route.test.ts b/apps/sim/app/api/credentials/route.test.ts index 6127ba4b162..fb90c407a9c 100644 --- a/apps/sim/app/api/credentials/route.test.ts +++ b/apps/sim/app/api/credentials/route.test.ts @@ -140,7 +140,8 @@ describe('POST /api/credentials', () => { providerId: 'zoom-service-account', encryptedServiceAccountKey: 'encrypted-blob', displayName: 'Zoom account acct_123', - auditMetadata: { zoomAccountId: 'acct_123' }, + auditMetadata: { principalKind: 'tenant', principalId: 'acct_123' }, + principal: { kind: 'tenant', id: 'acct_123' }, }) const req = createMockRequest('POST', { diff --git a/apps/sim/app/api/credentials/route.ts b/apps/sim/app/api/credentials/route.ts index 74b3b6337f1..b8484be2dac 100644 --- a/apps/sim/app/api/credentials/route.ts +++ b/apps/sim/app/api/credentials/route.ts @@ -654,9 +654,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { resourceName: resolvedDisplayName, description: `Created ${type} credential "${resolvedDisplayName}"`, metadata: { + // Provider metadata spreads first so this route's own keys stay + // authoritative and can never be shadowed, matching the update path in + // `lib/credentials/orchestration`. + ...extraAuditMetadata, credentialType: type, providerId: resolvedProviderId, - ...extraAuditMetadata, }, request, }) diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index e7efb1a0e28..a4df054e65c 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -33,6 +33,7 @@ import { } from '@/components/emails' import { getAccessControlConfig, isEmailBlockedByAccessControl } from '@/lib/auth/access-control' import { createAnonymousSession, ensureAnonymousUserExists } from '@/lib/auth/anonymous' +import { syntheticConnectorEmail } from '@/lib/auth/connector-email' import { getRequestedSignInProviderId, isSignInProviderAllowed } from '@/lib/auth/constants' import { getSessionCookieCacheVersion } from '@/lib/auth/security-policy' import { clampExpiryForSession } from '@/lib/auth/session-policy' @@ -113,6 +114,45 @@ import { deriveZohoDeskBaseFromApiDomain } from '@/tools/zoho_desk/host-allowlis const logger = createLogger('Auth') +/** + * Shape of `GET https://api.notion.com/v1/users/me` for an OAuth integration token. + * @see https://developers.notion.com/reference/get-self + */ +interface NotionSelfResponse { + id: string + name?: string | null + bot?: { + owner?: + | { type: 'user'; user?: { id: string; name?: string | null; person?: { email?: string } } } + | { type: 'workspace'; workspace: true } + } +} + +/** + * Shape of `GET https://api.attio.com/v2/self` (the Identify endpoint). + * @see https://docs.attio.com/rest-api/endpoint-reference/meta/identify + */ +interface AttioSelfResponse { + active?: boolean + authorized_by_workspace_member_id?: string | null + workspace_id?: string + workspace_name?: string +} + +/** + * Shape of `GET https://api.attio.com/v2/workspace_members/{id}`. + * @see https://docs.attio.com/rest-api/endpoint-reference/workspace-members/get-a-workspace-member + */ +interface AttioWorkspaceMemberResponse { + data?: { + id: { workspace_id: string; workspace_member_id: string } + first_name?: string | null + last_name?: string | null + email_address?: string | null + avatar_url?: string | null + } +} + /** * Extracts user info from a Microsoft ID token JWT instead of calling Graph API /me. * This avoids 403 errors for external tenant users whose admin hasn't consented to Graph API scopes. @@ -1814,7 +1854,7 @@ export const auth = betterAuth({ const email = data.email && typeof data.email === 'string' ? data.email - : `wealthbox-${userId}@wealthbox.user` + : syntheticConnectorEmail('wealthbox', userId) const name = data.name || data.full_name || data.username || 'Wealthbox User' return { @@ -1845,7 +1885,7 @@ export const auth = betterAuth({ return { id: `wealthbox-${tokenHash}-${generateId()}`, name: 'Wealthbox User', - email: `wealthbox-${tokenHash}@wealthbox.user`, + email: syntheticConnectorEmail('wealthbox', tokenHash), emailVerified: false, createdAt: now, updatedAt: now, @@ -1962,7 +2002,7 @@ export const auth = betterAuth({ return { id: `${(data.user_id || data.hub_id).toString()}-${generateId()}`, name: data.user || 'HubSpot User', - email: data.user || `hubspot-${data.hub_id}@hubspot.com`, + email: data.user || syntheticConnectorEmail('hubspot', data.hub_id), emailVerified: true, image: undefined, createdAt: new Date(), @@ -2016,7 +2056,8 @@ export const auth = betterAuth({ return { id: `${(data.user_id || data.sub).toString()}-${generateId()}`, name: data.name || 'Salesforce User', - email: data.email || `salesforce-${data.user_id}@salesforce.com`, + email: + data.email || syntheticConnectorEmail('salesforce', data.user_id ?? data.sub), emailVerified: data.email_verified === true, image: data.picture || undefined, createdAt: new Date(), @@ -2172,7 +2213,7 @@ export const auth = betterAuth({ return { id: `${zuid}-${generateId()}`, name: profile.Display_Name || 'Zoho User', - email: profile.Email || `zoho-${zuid}@zoho.user`, + email: profile.Email || syntheticConnectorEmail('zoho', zuid), emailVerified: Boolean(profile.Email), createdAt: now, updatedAt: now, @@ -2230,7 +2271,7 @@ export const auth = betterAuth({ return { id: `${profile.data.id.toString()}-${generateId()}`, name: profile.data.name || 'X User', - email: `${profile.data.username}@x.com`, + email: syntheticConnectorEmail('x', profile.data.username ?? profile.data.id), image: profile.data.profile_image_url, emailVerified: profile.data.verified || false, createdAt: now, @@ -2333,7 +2374,7 @@ export const auth = betterAuth({ return { id: `${user.open_id}-${generateId()}`, name: user.display_name || 'TikTok User', - email: `${user.open_id}@tiktok.user`, + email: syntheticConnectorEmail('tiktok', user.open_id), image: user.avatar_url || undefined, emailVerified: false, createdAt: now, @@ -2384,7 +2425,7 @@ export const auth = betterAuth({ return { id: `${profile.account_id.toString()}-${generateId()}`, name: profile.name || profile.display_name || 'Confluence User', - email: profile.email || `${profile.account_id}@atlassian.com`, + email: profile.email || syntheticConnectorEmail('confluence', profile.account_id), image: profile.picture || undefined, emailVerified: true, createdAt: now, @@ -2435,7 +2476,7 @@ export const auth = betterAuth({ return { id: `${profile.account_id.toString()}-${generateId()}`, name: profile.name || profile.display_name || 'Jira User', - email: profile.email || `${profile.account_id}@atlassian.com`, + email: profile.email || syntheticConnectorEmail('jira', profile.account_id), image: profile.picture || undefined, emailVerified: true, createdAt: now, @@ -2485,7 +2526,7 @@ export const auth = betterAuth({ return { id: `${data.id.toString()}-${generateId()}`, name: data.email ? data.email.split('@')[0] : 'Airtable User', - email: data.email || `${data.id}@airtable.user`, + email: data.email || syntheticConnectorEmail('airtable', data.id), emailVerified: !!data.email, createdAt: now, updatedAt: now, @@ -2528,14 +2569,27 @@ export const auth = betterAuth({ return null } - const profile = await response.json() + const profile: NotionSelfResponse = await response.json() const now = new Date() + /** + * An OAuth integration token always resolves to a bot user, so the + * top-level `person` is never present and the top-level `name` is the + * integration's own name ("Sim"), not the human's. The authorizing + * human — and their email — live under `bot.owner.user`, which is + * only populated when `bot.owner.type === 'user'` (a workspace-owned + * internal integration reports `{ type: 'workspace' }` instead). + * @see https://developers.notion.com/reference/get-self + */ + const ownerUser = profile.bot?.owner?.type === 'user' ? profile.bot.owner.user : null + const stableId = ownerUser?.id || profile.id + const ownerEmail = ownerUser?.person?.email + return { - id: `${(profile.bot?.owner?.user?.id || profile.id).toString()}-${generateId()}`, - name: profile.name || profile.bot?.owner?.user?.name || 'Notion User', - email: profile.person?.email || `${profile.id}@notion.user`, - emailVerified: !!profile.person?.email, + id: `${stableId}-${generateId()}`, + name: ownerUser?.name || profile.name || 'Notion User', + email: ownerEmail || syntheticConnectorEmail('notion', stableId), + emailVerified: !!ownerEmail, createdAt: now, updatedAt: now, } @@ -2586,7 +2640,7 @@ export const auth = betterAuth({ return { id: `${user.id.toString()}-${generateId()}`, name: user.name || 'Monday.com User', - email: user.email || `${user.id}@monday.user`, + email: user.email || syntheticConnectorEmail('monday', user.id), emailVerified: !!user.email, createdAt: now, updatedAt: now, @@ -2636,7 +2690,7 @@ export const auth = betterAuth({ return { id: `${data.id.toString()}-${generateId()}`, name: data.name || 'Reddit User', - email: `${data.name}@reddit.user`, + email: syntheticConnectorEmail('reddit', data.name ?? data.id), image: data.icon_img || undefined, emailVerified: false, createdAt: now, @@ -2685,7 +2739,7 @@ export const auth = betterAuth({ return { id: `${user.id.toString()}-${generateId()}`, name: user.username || 'ClickUp User', - email: user.email || `${user.id}@clickup.user`, + email: user.email || syntheticConnectorEmail('clickup', user.id), emailVerified: !!user.email, createdAt: now, updatedAt: now, @@ -2756,7 +2810,7 @@ export const auth = betterAuth({ return { id: `${viewer.id.toString()}-${generateId()}`, - email: viewer.email, + email: viewer.email || syntheticConnectorEmail('linear', viewer.id), name: viewer.name, emailVerified: true, createdAt: new Date(), @@ -2781,44 +2835,90 @@ export const auth = betterAuth({ redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/attio`, getUserInfo: async (tokens) => { try { - const response = await fetch('https://api.attio.com/v2/workspace_members', { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, + /** + * Resolve the *authorizing* member, not an arbitrary one. Listing + * `/v2/workspace_members` returns every member of the workspace in no + * defined order, so taking `data[0]` records a stranger's id as the + * account's stable external id — which then collapses two different + * Attio members into one account row via the stale-sibling dedupe in + * the `account.create.after` hook. + * + * `/v2/self` requires no scope and reports who authorized the token. + * @see https://docs.attio.com/rest-api/endpoint-reference/meta/identify + */ + const selfResponse = await fetch('https://api.attio.com/v2/self', { + headers: { Authorization: `Bearer ${tokens.accessToken}` }, }) - if (!response.ok) { - const errorText = await response.text() - logger.error('Attio API error:', { - status: response.status, - statusText: response.statusText, + if (!selfResponse.ok) { + const errorText = await selfResponse.text().catch(() => '') + logger.error('Attio /v2/self error:', { + status: selfResponse.status, + statusText: selfResponse.statusText, body: errorText, }) - throw new Error(`Attio API error: ${response.status} ${response.statusText}`) + return null } - const { data } = await response.json() + const self: AttioSelfResponse = await selfResponse.json() + const memberId = self.authorized_by_workspace_member_id - if (!data || data.length === 0) { - throw new Error('No workspace members found in Attio response') + if (!memberId) { + logger.error('Attio /v2/self returned no authorizing workspace member', { + active: self.active, + workspaceId: self.workspace_id, + }) + return null } - const member = data[0] + /** + * Fetch that member by id rather than listing and filtering. Requires + * `user_management:read`, which Sim always requests for Attio. + * @see https://docs.attio.com/rest-api/endpoint-reference/workspace-members/get-a-workspace-member + */ + const memberResponse = await fetch( + `https://api.attio.com/v2/workspace_members/${encodeURIComponent(memberId)}`, + { headers: { Authorization: `Bearer ${tokens.accessToken}` } } + ) + + if (!memberResponse.ok) { + const errorText = await memberResponse.text().catch(() => '') + logger.error('Attio workspace member fetch error:', { + status: memberResponse.status, + statusText: memberResponse.statusText, + body: errorText, + }) + return null + } + + const { data: member }: AttioWorkspaceMemberResponse = await memberResponse.json() + + if (!member) { + logger.error('Attio workspace member not found', { memberId }) + return null + } + + const email = member.email_address + const fullName = `${member.first_name ?? ''} ${member.last_name ?? ''}`.trim() return { id: `${member.id.workspace_member_id}-${generateId()}`, - email: member.email_address, - name: - `${member.first_name ?? ''} ${member.last_name ?? ''}`.trim() || - member.email_address, - emailVerified: true, + email: email || syntheticConnectorEmail('attio', member.id.workspace_member_id), + name: fullName || email || 'Attio User', + emailVerified: Boolean(email), createdAt: new Date(), updatedAt: new Date(), image: member.avatar_url || undefined, } } catch (error) { + /** + * Return null rather than rethrowing: Better Auth's `handleUserInfo` + * does not wrap `getUserInfo`, so a throw escapes the callback route + * as a raw 500 with no way back into the app, while null redirects + * with `user_info_is_missing`. + */ logger.error('Error in Attio getUserInfo:', error) - throw error + return null } }, }, @@ -2854,8 +2954,8 @@ export const auth = betterAuth({ return { id: `${data.id}-${generateId()}`, - email: data.login, - name: data.name || data.login, + email: data.login || syntheticConnectorEmail('box', data.id), + name: data.name || data.login || 'Box User', emailVerified: true, createdAt: new Date(), updatedAt: new Date(), @@ -2962,7 +3062,7 @@ export const auth = betterAuth({ return { id: `${profile.gid.toString()}-${generateId()}`, name: profile.name || 'Asana User', - email: profile.email || `${profile.gid}@asana.user`, + email: profile.email || syntheticConnectorEmail('asana', profile.gid), image: profile.photo?.image_128x128 || undefined, emailVerified: !!profile.email, createdAt: now, @@ -3042,7 +3142,7 @@ export const auth = betterAuth({ return { id: `${uniqueId}-${generateId()}`, name: teamName, - email: `${uniqueId}@slack.bot`, + email: syntheticConnectorEmail('slack', uniqueId), emailVerified: false, createdAt: new Date(), updatedAt: new Date(), @@ -3092,7 +3192,7 @@ export const auth = betterAuth({ return { id: `${uniqueId}-${generateId()}`, name: data.user_name || 'Webflow User', - email: `${uniqueId.replace(/[^a-zA-Z0-9]/g, '')}@webflow.user`, + email: syntheticConnectorEmail('webflow', userId), emailVerified: false, createdAt: now, updatedAt: now, @@ -3139,8 +3239,8 @@ export const auth = betterAuth({ return { id: `${profile.sub}-${generateId()}`, name: profile.name || 'LinkedIn User', - email: profile.email || `${profile.sub}@linkedin.user`, - emailVerified: profile.email_verified || true, + email: profile.email || syntheticConnectorEmail('linkedin', profile.sub), + emailVerified: true, image: profile.picture || undefined, createdAt: new Date(), updatedAt: new Date(), @@ -3190,7 +3290,7 @@ export const auth = betterAuth({ id: `${profile.id.toString()}-${generateId()}`, name: `${profile.first_name || ''} ${profile.last_name || ''}`.trim() || 'Zoom User', - email: profile.email || `${profile.id}@zoom.user`, + email: profile.email || syntheticConnectorEmail('zoom', profile.id), emailVerified: profile.verified === 1, image: profile.pic_url || undefined, createdAt: new Date(), @@ -3238,7 +3338,7 @@ export const auth = betterAuth({ return { id: `${profile.id.toString()}-${generateId()}`, name: profile.display_name || 'Spotify User', - email: profile.email || `${profile.id}@spotify.user`, + email: profile.email || syntheticConnectorEmail('spotify', profile.id), emailVerified: true, image: profile.images?.[0]?.url || undefined, createdAt: new Date(), @@ -3286,7 +3386,12 @@ export const auth = betterAuth({ return { id: `${profile.ID?.toString() || profile.id?.toString()}-${generateId()}`, name: profile.display_name || profile.username || 'WordPress User', - email: profile.email || `${profile.username}@wordpress.com`, + email: + profile.email || + syntheticConnectorEmail( + 'wordpress', + profile.username ?? profile.ID ?? profile.id + ), emailVerified: profile.email_verified || false, image: profile.avatar_URL || undefined, createdAt: new Date(), @@ -3344,7 +3449,7 @@ export const auth = betterAuth({ return { id: `${data.sub}-${generateId()}`, name: data.name || accountName, - email: data.email || `${data.sub}@docusign.com`, + email: data.email || syntheticConnectorEmail('docusign', data.sub), emailVerified: true, image: undefined, createdAt: new Date(), @@ -3395,7 +3500,7 @@ export const auth = betterAuth({ return { id: `${profile.id?.toString()}-${generateId()}`, name: profile.name || 'Cal.com User', - email: profile.email || `${profile.id}@cal.com`, + email: profile.email || syntheticConnectorEmail('calcom', profile.id), emailVerified: true, createdAt: new Date(), updatedAt: new Date(), diff --git a/apps/sim/lib/auth/connector-email.test.ts b/apps/sim/lib/auth/connector-email.test.ts new file mode 100644 index 00000000000..a9941c70a40 --- /dev/null +++ b/apps/sim/lib/auth/connector-email.test.ts @@ -0,0 +1,79 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { syntheticConnectorEmail } from '@/lib/auth/connector-email' + +describe('syntheticConnectorEmail', () => { + it('namespaces the address by provider and identity', () => { + expect(syntheticConnectorEmail('attio', 'abc123')).toBe('attio-abc123@connectors.sim.invalid') + }) + + it('always lands on the RFC 2606 reserved .invalid TLD', () => { + const providers: Array<[string, string]> = [ + ['x', 'someuser'], + ['hubspot', '12345'], + ['salesforce', '005xx'], + ['docusign', 'sub-1'], + ['calcom', '77'], + ['atlassian', 'acct'], + ['wordpress', 'blogger'], + ] + for (const [provider, id] of providers) { + const email = syntheticConnectorEmail(provider, id) + expect(email.endsWith('@connectors.sim.invalid')).toBe(true) + } + }) + + it('never emits a live third-party domain', () => { + const email = syntheticConnectorEmail('x', 'jack') + expect(email).not.toMatch(/@(x|hubspot|docusign|cal|salesforce|atlassian|wordpress)\.com$/) + }) + + it('distinguishes the same external id across providers', () => { + expect(syntheticConnectorEmail('zoom', '42')).not.toBe(syntheticConnectorEmail('spotify', '42')) + }) + + it('is deterministic for the same input', () => { + expect(syntheticConnectorEmail('monday', 99)).toBe(syntheticConnectorEmail('monday', 99)) + }) + + it('accepts numeric identifiers', () => { + expect(syntheticConnectorEmail('monday', 99)).toBe('monday-99@connectors.sim.invalid') + }) + + it('strips characters that are illegal in an unquoted local part', () => { + expect(syntheticConnectorEmail('slack', 'T123-usr_U456')).toBe( + 'slack-T123-usr_U456@connectors.sim.invalid' + ) + expect(syntheticConnectorEmail('reddit', 'some user!@#')).toBe( + 'reddit-someuser@connectors.sim.invalid' + ) + }) + + it('keeps the local part inside the RFC 5321 64-character limit', () => { + const email = syntheticConnectorEmail('a'.repeat(100), 'b'.repeat(100)) + const [localPart] = email.split('@') + expect(localPart.length).toBeLessThanOrEqual(64) + }) + + it('does not leave a dot or hyphen at either edge of a truncated segment', () => { + const email = syntheticConnectorEmail('wealthbox', `${'c'.repeat(29)}...tail`) + const [localPart] = email.split('@') + expect(localPart.endsWith('.')).toBe(false) + expect(localPart.startsWith('.')).toBe(false) + }) + + it('falls back to placeholders rather than emitting an empty local part', () => { + expect(syntheticConnectorEmail('notion', undefined)).toBe( + 'notion-unknown@connectors.sim.invalid' + ) + expect(syntheticConnectorEmail('notion', '')).toBe('notion-unknown@connectors.sim.invalid') + expect(syntheticConnectorEmail('', '')).toBe('connector-unknown@connectors.sim.invalid') + expect(syntheticConnectorEmail('!!!', '###')).toBe('connector-unknown@connectors.sim.invalid') + }) + + it('always returns a truthy value, which is what Better Auth 1.6.23 requires', () => { + expect(syntheticConnectorEmail('', undefined)).toBeTruthy() + }) +}) diff --git a/apps/sim/lib/auth/connector-email.ts b/apps/sim/lib/auth/connector-email.ts new file mode 100644 index 00000000000..6f9725a61fe --- /dev/null +++ b/apps/sim/lib/auth/connector-email.ts @@ -0,0 +1,61 @@ +/** RFC 2606 §2 reserved TLD — permanently unregistrable and unroutable. */ +const SYNTHETIC_EMAIL_DOMAIN = 'connectors.sim.invalid' + +/** Longest local-part segment kept, so the address stays under the 64-char RFC 5321 limit. */ +const MAX_SEGMENT_LENGTH = 30 + +/** + * Reduce an arbitrary upstream identifier to characters that are unambiguously + * legal in an unquoted email local part. + */ +function sanitizeLocalPart(value: string): string { + return ( + value + .replace(/[^a-zA-Z0-9._-]/g, '') + .slice(0, MAX_SEGMENT_LENGTH) + // RFC 5321 `dot-string` is `Atom *("." Atom)`, so a run of separators is not + // a legal local part — and stripping illegal characters readily creates one. + .replace(/[._-]{2,}/g, '-') + .replace(/^[._-]+|[._-]+$/g, '') + ) +} + +/** + * Synthetic placeholder email for an OAuth connector identity. + * + * Many connector providers either never expose an email (X, Slack bot tokens, + * TikTok, Reddit, Webflow) or expose one only when an optional scope was + * granted. Better Auth still demands one: in `better-auth@1.6.23`, + * `dist/plugins/generic-oauth/routes.mjs` hard-rejects a falsy `email` returned + * from `getUserInfo` by throwing a redirect to `?error=email_is_missing`. There + * is no option to disable that guard, so every `getUserInfo` must return a + * truthy address or the connect flow dies at the callback. + * + * The value is never persisted. Sim's connectors go through the session-bound + * `oauth2.link` path, the `account` table has no email column, and + * `updateUserInfoOnLink` is unset — so Better Auth reads the address, satisfies + * its own guard, and discards it. It is never shown to a user, never mailed to, + * and never matched against a real account. + * + * The domain is `.invalid`, reserved by RFC 2606 §2 precisely so that it can + * never be registered or routed. Earlier code synthesized addresses on live + * third-party domains (`@x.com`, `@salesforce.com`, `@atlassian.com`, …), which + * are owned by other companies and could in principle resolve to a real + * mailbox. + * + * Delete this helper and return the upstream email directly once Better Auth + * relaxes the guard (tracked in better-auth issue #9124, slated for v2). + * + * @param providerId - Connector provider id, e.g. `'attio'`. Namespaces the + * address so two providers reporting the same external id do not collide. + * @param stableId - Stable external identifier for the connected identity + * (workspace member id, account id, username, …). Falsy or fully-unsupported + * values degrade to `unknown`; uniqueness is best-effort because the address + * is discarded either way. + * @returns An RFC 5321-shaped address on a permanently unroutable domain. + */ +export function syntheticConnectorEmail(providerId: string, stableId?: string | number): string { + const provider = sanitizeLocalPart(providerId) || 'connector' + const identity = sanitizeLocalPart(stableId == null ? '' : String(stableId)) || 'unknown' + return `${provider}-${identity}@${SYNTHETIC_EMAIL_DOMAIN}` +} diff --git a/apps/sim/lib/credentials/atlassian-service-account.ts b/apps/sim/lib/credentials/atlassian-service-account.ts index 1d78cd8bd6b..fa4381d31a9 100644 --- a/apps/sim/lib/credentials/atlassian-service-account.ts +++ b/apps/sim/lib/credentials/atlassian-service-account.ts @@ -80,7 +80,16 @@ async function assertAtlassianResponseOk( export async function validateAtlassianServiceAccount( apiToken: string, domain: string -): Promise<{ accountId: string; displayName: string; cloudId: string }> { +): Promise<{ + accountId: string + displayName: string + cloudId: string + /** + * Only present when the site's profile-visibility settings expose it to the + * calling token; absence is never a validation failure. + */ + emailAddress?: string +}> { assertAtlassianCloudHost(domain) const tenantInfoRes = await fetch(`https://${domain}/_edge/tenant_info`, { @@ -123,5 +132,6 @@ export async function validateAtlassianServiceAccount( accountId: myself.accountId, displayName: myself.displayName || myself.emailAddress || domain, cloudId, + ...(myself.emailAddress ? { emailAddress: myself.emailAddress } : {}), } } diff --git a/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts b/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts index f2439cabd38..ef83609cee2 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts @@ -274,7 +274,7 @@ export const CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS: Record< ], docsUrl: 'https://docs.sim.ai/integrations/salesforce-service-account', helpText: - 'The Connected App must have "Enable Client Credentials Flow" checked with a "Run As" integration user set under Edit Policies — every call executes with that user\'s permissions, and deactivating or freezing the user stops all runs.', + 'The Connected App must have "Enable Client Credentials Flow" checked with a "Run As" integration user set under Edit Policies — every call executes with that user\'s permissions, and deactivating or freezing the user stops all runs. Selecting the "openid" scope lets Sim record which run-as user the credential authenticates as; without it the connection still works but the identity is not captured.', }, [ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID]: { providerId: ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID, diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/box.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/box.test.ts index f380e8d8742..7b09b9b2ad5 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/box.test.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/box.test.ts @@ -67,6 +67,7 @@ describe('mintBoxServiceAccountToken', () => { .mockResolvedValueOnce(jsonResponse(200, { access_token: 'box-access', expires_in: 3600 })) .mockResolvedValueOnce( jsonResponse(200, { + id: '33445566', name: 'Sim Automation', login: 'AutomationUser_123_abc@boxdevedition.com', }) @@ -79,14 +80,13 @@ describe('mintBoxServiceAccountToken', () => { expiresInSeconds: 3600, identity: { displayName: 'Sim Automation', - auditMetadata: { - boxEnterpriseId: '1234567', - boxServiceAccountLogin: 'AutomationUser_123_abc@boxdevedition.com', - }, - storedMetadata: { - enterpriseId: '1234567', - serviceAccountLogin: 'AutomationUser_123_abc@boxdevedition.com', + principal: { + kind: 'user', + id: '33445566', + label: 'AutomationUser_123_abc@boxdevedition.com', }, + auditMetadata: { boxEnterpriseId: '1234567' }, + storedMetadata: { enterpriseId: '1234567' }, }, }) expect(mockFetch).toHaveBeenCalledTimes(2) @@ -94,7 +94,7 @@ describe('mintBoxServiceAccountToken', () => { expectIdentityCall() }) - it('still succeeds with a fallback identity when users/me fails', async () => { + it('marks the principal as lookup_failed when users/me fails', async () => { mockFetch .mockResolvedValueOnce(jsonResponse(200, { access_token: 'box-access', expires_in: 2400 })) .mockResolvedValueOnce(jsonResponse(500, { message: 'boom' })) @@ -105,8 +105,26 @@ describe('mintBoxServiceAccountToken', () => { expect(result.expiresInSeconds).toBe(2400) expect(result.identity).toEqual({ displayName: 'Box enterprise 1234567', + principal: { kind: 'lookup_failed', reason: 'HTTP 500' }, auditMetadata: { boxEnterpriseId: '1234567' }, + storedMetadata: { enterpriseId: '1234567' }, + }) + }) + + it('marks the principal as lookup_failed when users/me omits the user id', async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse(200, { access_token: 'box-access', expires_in: 3600 })) + .mockResolvedValueOnce(jsonResponse(200, { name: 'Sim Automation' })) + + const result = await mintBoxServiceAccountToken(FIELDS) + + expect(result.identity?.principal).toEqual({ + kind: 'lookup_failed', + reason: 'response missing user id', }) + // Only the principal degrades — a name that did come back still beats the + // Enterprise-ID fallback, so the credential does not lose its label. + expect(result.identity?.displayName).toBe('Sim Automation') }) it('still succeeds when the identity request itself throws', async () => { @@ -118,6 +136,10 @@ describe('mintBoxServiceAccountToken', () => { expect(result.accessToken).toBe('box-access') expect(result.identity?.displayName).toBe('Box enterprise 1234567') + expect(result.identity?.principal).toEqual({ + kind: 'lookup_failed', + reason: 'provider_unavailable (HTTP 502)', + }) }) it('throws invalid_credentials on 400 invalid_client', async () => { diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/box.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/box.ts index 720631d072d..25e3b609080 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/box.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/box.ts @@ -1,3 +1,5 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import type { ClientCredentialAccountFields, ClientCredentialAccountIdentity, @@ -8,10 +10,15 @@ import { fetchProvider, isTransientProviderStatus, parseProviderJson, + providerFailureReason, readProviderErrorSnippet, TokenServiceAccountValidationError, } from '@/lib/credentials/token-service-accounts/errors' +const logger = createLogger('BoxServiceAccountMinter') + +const IDENTITY_STEP = 'box_identity' + const BOX_TOKEN_URL = 'https://api.box.com/oauth2/token' const BOX_CURRENT_USER_URL = 'https://api.box.com/2.0/users/me' @@ -20,7 +27,14 @@ interface BoxTokenResponse { expires_in?: number } +/** + * `id`, `name`, and `login` are all in the standard field set `GET /2.0/users/me` + * returns without a `fields` parameter, so capturing the Service Account's user + * id costs no extra request. + * @see https://developer.box.com/reference/get-users-me/ + */ interface BoxCurrentUserResponse { + id?: string name?: string login?: string } @@ -53,40 +67,67 @@ function boxErrorHint(body: string): string | undefined { /** * Best-effort identity lookup for the app's Service Account user. A failure - * never fails the mint — the caller falls back to an Enterprise-ID-derived - * display name. + * never fails the mint — the credential degrades to an Enterprise-ID-derived + * display name with a `lookup_failed` principal, so the audit record shows the + * identity was not captured rather than implying none exists. */ async function fetchBoxServiceAccountIdentity( accessToken: string, orgId: string ): Promise { - const fallback: ClientCredentialAccountIdentity = { - displayName: `Box enterprise ${orgId}`, + /** + * `label` keeps whatever human name the lookup did return. A response can + * carry `name`/`login` but no `id` — the principal is then unusable, but the + * label still beats the Enterprise-ID fallback, so only the principal + * degrades and the credential does not silently lose its name. + */ + const degraded = (reason: string, label?: string): ClientCredentialAccountIdentity => ({ + displayName: label ?? `Box enterprise ${orgId}`, + principal: { kind: 'lookup_failed', reason }, auditMetadata: { boxEnterpriseId: orgId }, - } + storedMetadata: { enterpriseId: orgId }, + }) try { const res = await fetchProvider( BOX_CURRENT_USER_URL, { headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' } }, - 'box_identity' + IDENTITY_STEP ) - if (!res.ok) return fallback - const user = await parseProviderJson(res, 'box_identity') + if (!res.ok) { + logger.warn('Box service-account identity lookup failed', { + step: IDENTITY_STEP, + status: res.status, + enterpriseId: orgId, + }) + return degraded(`HTTP ${res.status}`) + } + const user = await parseProviderJson(res, IDENTITY_STEP) + const id = typeof user.id === 'string' && user.id ? user.id : undefined const login = typeof user.login === 'string' && user.login ? user.login : undefined const name = typeof user.name === 'string' && user.name ? user.name : undefined - return { - displayName: name ?? login ?? fallback.displayName, - auditMetadata: { - boxEnterpriseId: orgId, - ...(login ? { boxServiceAccountLogin: login } : {}), - }, - storedMetadata: { + if (!id) { + logger.warn('Box service-account identity response carried no user id', { + step: IDENTITY_STEP, + status: res.status, enterpriseId: orgId, - ...(login ? { serviceAccountLogin: login } : {}), - }, + }) + return degraded('response missing user id', name ?? login) } - } catch { - return fallback + return { + displayName: name ?? login ?? `Box enterprise ${orgId}`, + // The Service Account is a real Box user; `enterpriseId` is shared by + // every app in the enterprise and so is kept as separate context. + principal: { kind: 'user', id, ...(login ? { label: login } : {}) }, + auditMetadata: { boxEnterpriseId: orgId }, + storedMetadata: { enterpriseId: orgId }, + } + } catch (error) { + logger.warn('Box service-account identity lookup threw', { + step: IDENTITY_STEP, + enterpriseId: orgId, + error: getErrorMessage(error), + }) + return degraded(providerFailureReason(error)) } } diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.test.ts index 6e0aad65f22..0b874321280 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.test.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.test.ts @@ -78,6 +78,7 @@ describe('mintSalesforceServiceAccountToken', () => { name: 'Integration User', preferred_username: 'integration@yourorg.com', organization_id: '00Dxx0000000001EAA', + user_id: '005xx000001Sv6DAAS', }) ) @@ -90,16 +91,19 @@ describe('mintSalesforceServiceAccountToken', () => { grantedScopes: ['api'], identity: { displayName: 'Integration User', + principal: { + kind: 'user', + id: '005xx000001Sv6DAAS', + label: 'integration@yourorg.com', + }, auditMetadata: { salesforceMyDomainHost: HOST, salesforceOrgId: '00Dxx0000000001EAA', - salesforceRunAsUsername: 'integration@yourorg.com', }, storedMetadata: { myDomainHost: HOST, instanceUrl: INSTANCE_URL, orgId: '00Dxx0000000001EAA', - runAsUsername: 'integration@yourorg.com', grantedScopes: 'api', }, }, @@ -259,7 +263,7 @@ describe('mintSalesforceServiceAccountToken', () => { }) }) - it('falls back to a host-derived identity when the userinfo call fails', async () => { + it('marks the principal as lookup_failed when the userinfo call throws', async () => { mockFetch .mockResolvedValueOnce( jsonResponse(200, { access_token: 'sf-access', instance_url: INSTANCE_URL }) @@ -271,11 +275,30 @@ describe('mintSalesforceServiceAccountToken', () => { expect(result.accessToken).toBe('sf-access') expect(result.identity).toEqual({ displayName: `Salesforce ${HOST}`, + principal: { kind: 'lookup_failed', reason: 'provider_unavailable (HTTP 502)' }, auditMetadata: { salesforceMyDomainHost: HOST }, storedMetadata: { myDomainHost: HOST, instanceUrl: INSTANCE_URL }, }) }) + it('marks the principal as lookup_failed when userinfo omits user_id', async () => { + mockFetch + .mockResolvedValueOnce( + jsonResponse(200, { access_token: 'sf-access', instance_url: INSTANCE_URL }) + ) + .mockResolvedValueOnce(jsonResponse(200, { name: 'Integration User' })) + + const result = await mintSalesforceServiceAccountToken(FIELDS) + + expect(result.identity?.principal).toEqual({ + kind: 'lookup_failed', + reason: 'response missing user_id', + }) + // Only the principal degrades — a name that did come back still beats the + // host fallback, so the credential does not lose its label. + expect(result.identity?.displayName).toBe('Integration User') + }) + it('ignores a non-Salesforce instance_url and falls back to the validated host', async () => { mockFetch .mockResolvedValueOnce( diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.ts index 8928cf9d9c6..e8e702c98c7 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.ts @@ -1,3 +1,5 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { normalizeSalesforceMyDomainHost, SALESFORCE_MY_DOMAIN_HOST_REGEX, @@ -8,10 +10,12 @@ import type { ClientCredentialAccountMintOptions, ClientCredentialAccountMintResult, } from '@/lib/credentials/client-credential-accounts/server' +import { userPrincipal } from '@/lib/credentials/principal' import { fetchProvider, isTransientProviderStatus, parseProviderJson, + providerFailureReason, readProviderErrorSnippet, TokenServiceAccountValidationError, } from '@/lib/credentials/token-service-accounts/errors' @@ -24,16 +28,29 @@ import { */ const SALESFORCE_TOKEN_TTL_SECONDS = 600 +const IDENTITY_STEP = 'salesforce_identity' + +const logger = createLogger('SalesforceServiceAccountMinter') + interface SalesforceTokenResponse { access_token?: string instance_url?: string scope?: string } +/** + * `/services/oauth2/userinfo` returns `user_id`, `organization_id`, + * `preferred_username`, and `name` in the same call the display name already + * needs, so capturing the run-as user id costs no extra request. `sub` is + * deliberately unused — Salesforce documents it as the UserInfo endpoint URL, + * not a subject identifier. + * @see https://help.salesforce.com/s/articleView?id=sf.remoteaccess_using_userinfo_endpoint.htm&type=5 + */ interface SalesforceUserinfoResponse { name?: string preferred_username?: string organization_id?: string + user_id?: string } /** @@ -91,27 +108,43 @@ function salesforceTokenTtlSeconds(accessToken: string): number { /** * Best-effort identity lookup for the run-as integration user via the - * standard userinfo endpoint. A failure never fails the mint — the caller - * falls back to a host-derived display name. + * standard userinfo endpoint. A failure never fails the mint — the credential + * degrades to a host-derived display name with a `lookup_failed` principal, so + * the audit record shows the identity was not captured rather than implying + * none exists. */ async function fetchSalesforceIdentity( accessToken: string, instanceUrl: string, host: string ): Promise { - const fallback: ClientCredentialAccountIdentity = { - displayName: `Salesforce ${host}`, + /** + * `label` keeps whatever human name userinfo did return. A response can carry + * `name`/`preferred_username` but no `user_id` — the principal is then + * unusable, but the label still beats the host fallback, so only the + * principal degrades and the credential does not silently lose its name. + */ + const degraded = (reason: string, label?: string): ClientCredentialAccountIdentity => ({ + displayName: label ?? `Salesforce ${host}`, + principal: { kind: 'lookup_failed', reason }, auditMetadata: { salesforceMyDomainHost: host }, storedMetadata: { myDomainHost: host, instanceUrl }, - } + }) try { const res = await fetchProvider( `${instanceUrl}/services/oauth2/userinfo`, { headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' } }, - 'salesforce_identity' + IDENTITY_STEP ) - if (!res.ok) return fallback - const user = await parseProviderJson(res, 'salesforce_identity') + if (!res.ok) { + logger.warn('Salesforce run-as identity lookup failed', { + step: IDENTITY_STEP, + status: res.status, + host, + }) + return degraded(`HTTP ${res.status}`) + } + const user = await parseProviderJson(res, IDENTITY_STEP) const username = typeof user.preferred_username === 'string' && user.preferred_username ? user.preferred_username @@ -121,22 +154,37 @@ async function fetchSalesforceIdentity( typeof user.organization_id === 'string' && user.organization_id ? user.organization_id : undefined + const userId = typeof user.user_id === 'string' && user.user_id ? user.user_id : undefined + if (!userId) { + logger.warn('Salesforce userinfo response carried no user_id', { + step: IDENTITY_STEP, + status: res.status, + host, + }) + return degraded('response missing user_id', name ?? username) + } return { - displayName: name ?? username ?? fallback.displayName, + displayName: name ?? username ?? `Salesforce ${host}`, + // The 18-char user id is immutable; `preferred_username` is renameable, + // so it is only a label. + principal: userPrincipal(userId, username), auditMetadata: { salesforceMyDomainHost: host, ...(orgId ? { salesforceOrgId: orgId } : {}), - ...(username ? { salesforceRunAsUsername: username } : {}), }, storedMetadata: { myDomainHost: host, instanceUrl, ...(orgId ? { orgId } : {}), - ...(username ? { runAsUsername: username } : {}), }, } - } catch { - return fallback + } catch (error) { + logger.warn('Salesforce run-as identity lookup threw', { + step: IDENTITY_STEP, + host, + error: getErrorMessage(error), + }) + return degraded(providerFailureReason(error)) } } diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.test.ts index 839e452202a..6bf48fd65c1 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.test.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.test.ts @@ -130,12 +130,9 @@ describe('mintZohoDeskServiceAccountToken', () => { grantedScopes: ['Desk.tickets.READ', 'Desk.contacts.READ'], identity: { displayName: 'Zoho Desk org 600123456', - auditMetadata: { - zohoDeskSoid: 'ZohoDesk.600123456', - zohoDeskClientId: 'zoho-cid', - }, + principal: { kind: 'tenant', id: 'ZohoDesk.600123456' }, + auditMetadata: { zohoDeskClientId: 'zoho-cid' }, storedMetadata: { - soid: 'ZohoDesk.600123456', apiDomain: 'https://desk.zoho.com', dataCenter: 'us', grantedScopes: 'Desk.tickets.READ Desk.contacts.READ', diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.ts index 35f3f6f7963..7ceff1748a9 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.ts @@ -12,6 +12,7 @@ import type { ClientCredentialAccountMintOptions, ClientCredentialAccountMintResult, } from '@/lib/credentials/client-credential-accounts/server' +import { tenantPrincipal } from '@/lib/credentials/principal' import { fetchProvider, isTransientProviderStatus, @@ -255,7 +256,7 @@ export async function mintZohoDeskServiceAccountToken( return { accessToken: payload.access_token, expiresInSeconds, apiDomain, grantedScopes } } - const storedMetadata: Record = { soid, apiDomain, dataCenter: dataCenter.id } + const storedMetadata: Record = { apiDomain, dataCenter: dataCenter.id } if (grantedScopes?.length) { storedMetadata.grantedScopes = grantedScopes.join(' ') } @@ -267,7 +268,10 @@ export async function mintZohoDeskServiceAccountToken( grantedScopes, identity: { displayName: `Zoho Desk org ${fields.orgId.trim()}`, - auditMetadata: { zohoDeskSoid: soid, zohoDeskClientId: fields.clientId }, + // The Self Client grant is scoped to the organization (`soid`) and never + // hits the Accounts profile endpoint, so no agent identity exists here. + principal: tenantPrincipal(soid), + auditMetadata: { zohoDeskClientId: fields.clientId }, storedMetadata, }, } diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/zoom.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/zoom.test.ts index dcfd2822a14..afa900ed610 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/zoom.test.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/zoom.test.ts @@ -74,7 +74,8 @@ describe('mintZoomServiceAccountToken', () => { grantedScopes: ['meeting:read:meeting:admin', 'user:read:user:admin'], identity: { displayName: 'Zoom account AbCdEf123', - auditMetadata: { zoomAccountId: 'AbCdEf123', zoomClientId: 'zoom-cid' }, + principal: { kind: 'tenant', id: 'AbCdEf123' }, + auditMetadata: { zoomClientId: 'zoom-cid' }, storedMetadata: { apiUrl: 'https://api.zoom.us', grantedScopes: 'meeting:read:meeting:admin user:read:user:admin', diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/zoom.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/zoom.ts index 978409ae0ee..218eee37c1e 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/zoom.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/zoom.ts @@ -3,6 +3,7 @@ import type { ClientCredentialAccountMintOptions, ClientCredentialAccountMintResult, } from '@/lib/credentials/client-credential-accounts/server' +import { tenantPrincipal } from '@/lib/credentials/principal' import { fetchProvider, isTransientProviderStatus, @@ -124,7 +125,10 @@ export async function mintZoomServiceAccountToken( grantedScopes, identity: { displayName: `Zoom account ${fields.orgId}`, - auditMetadata: { zoomAccountId: fields.orgId, zoomClientId: fields.clientId }, + // A Server-to-Server app authenticates as the account, not as a Zoom + // user; the grant exposes no user identifier at all. + principal: tenantPrincipal(fields.orgId), + auditMetadata: { zoomClientId: fields.clientId }, ...(Object.keys(storedMetadata).length > 0 ? { storedMetadata } : {}), }, } diff --git a/apps/sim/lib/credentials/client-credential-accounts/server.ts b/apps/sim/lib/credentials/client-credential-accounts/server.ts index 44b216f8406..2a1f6bd2214 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/server.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/server.ts @@ -11,6 +11,7 @@ import { mintBoxServiceAccountToken } from '@/lib/credentials/client-credential- import { mintSalesforceServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/salesforce' import { mintZohoDeskServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/zoho-desk' import { mintZoomServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/zoom' +import type { ServiceAccountPrincipal } from '@/lib/credentials/principal' /** Raw fields a client-credential minter receives (already trimmed). */ export interface ClientCredentialAccountFields { @@ -33,11 +34,21 @@ export interface ClientCredentialAccountFields { export interface ClientCredentialAccountIdentity { /** Default display name when the user didn't provide one. */ displayName: string - /** Non-secret identifiers recorded in the audit log (e.g. account/enterprise id). */ + /** + * Identity the minted token acts as, or `null` when the provider exposes + * none. Required (never optional) so a new minter cannot be written without + * deciding. `verifyAndBuildServiceAccountSecret` mirrors it into both + * `auditMetadata` and `storedMetadata`, so minters must not repeat it. + */ + principal: ServiceAccountPrincipal | null + /** + * Non-secret identifiers recorded in the audit log that are NOT the + * principal (e.g. the enterprise id behind a service-account user). + */ auditMetadata: Record /** * Non-secret metadata persisted inside the encrypted blob alongside the - * credentials (e.g. regional API host, service-account login) for debugging. + * credentials (e.g. regional API host, granted scopes) for debugging. */ storedMetadata?: Record } diff --git a/apps/sim/lib/credentials/display-name.ts b/apps/sim/lib/credentials/display-name.ts index 9b946f31e56..24f47acb279 100644 --- a/apps/sim/lib/credentials/display-name.ts +++ b/apps/sim/lib/credentials/display-name.ts @@ -47,3 +47,15 @@ export function defaultCredentialDisplayName( } return base } + +/** + * Display name for a custom Slack bot credential. + * + * Lives in this leaf module because two callers must derive it identically — + * the secret builder that sets it at connect time, and the update path that + * compares against it to tell a stale system-derived label from one a user + * typed. A copied literal would silently break that comparison. + */ +export function slackCustomBotDisplayName(teamName?: string | null): string { + return teamName || 'Slack bot' +} diff --git a/apps/sim/lib/credentials/orchestration/index.test.ts b/apps/sim/lib/credentials/orchestration/index.test.ts new file mode 100644 index 00000000000..18e0cfb0d1d --- /dev/null +++ b/apps/sim/lib/credentials/orchestration/index.test.ts @@ -0,0 +1,298 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockRecordAudit, + mockGetCredentialActorContext, + mockDecryptSecret, + mockVerifyAndBuildServiceAccountSecret, + mockIsClientCredentialAccountProviderId, +} = vi.hoisted(() => ({ + mockRecordAudit: vi.fn(), + mockGetCredentialActorContext: vi.fn(), + mockDecryptSecret: vi.fn(), + mockVerifyAndBuildServiceAccountSecret: vi.fn(), + mockIsClientCredentialAccountProviderId: vi.fn(() => false), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { CREDENTIAL_UPDATED: 'credential.updated' }, + AuditResourceType: { CREDENTIAL: 'credential' }, + recordAudit: mockRecordAudit, +})) +vi.mock('@/lib/credentials/access', () => ({ + getCredentialActorContext: mockGetCredentialActorContext, +})) +vi.mock('@/lib/core/security/encryption', () => ({ decryptSecret: mockDecryptSecret })) +vi.mock('@/lib/credentials/service-account-secret', () => ({ + verifyAndBuildServiceAccountSecret: mockVerifyAndBuildServiceAccountSecret, + ServiceAccountSecretError: class ServiceAccountSecretError extends Error {}, +})) +vi.mock('@/lib/credentials/client-credential-accounts/descriptors', () => ({ + isClientCredentialAccountProviderId: mockIsClientCredentialAccountProviderId, +})) +vi.mock('@/lib/credentials/deletion', () => ({ deleteCredential: vi.fn() })) +vi.mock('@/lib/credentials/environment', () => ({ + deleteWorkspaceEnvCredentials: vi.fn(), + syncPersonalEnvCredentialsForUser: vi.fn(), +})) +vi.mock('@/lib/credentials/atlassian-service-account', () => ({ + AtlassianValidationError: class AtlassianValidationError extends Error {}, +})) +vi.mock('@/lib/credentials/token-service-accounts/errors', () => ({ + TokenServiceAccountValidationError: class TokenServiceAccountValidationError extends Error {}, +})) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) + +import { performUpdateCredential } from '@/lib/credentials/orchestration' + +const OLD_EMAIL = 'old-sa@old-project.iam.gserviceaccount.com' +const NEW_EMAIL = 'new-sa@new-project.iam.gserviceaccount.com' + +const NEW_GOOGLE_KEY = JSON.stringify({ + type: 'service_account', + client_email: NEW_EMAIL, + private_key: 'pk', + project_id: 'new-project', +}) + +/** Points `getCredentialActorContext` at an admin-accessible credential row. */ +function mockCredential(overrides: Record = {}) { + mockGetCredentialActorContext.mockResolvedValue({ + credential: { + id: 'cred-1', + workspaceId: 'ws-1', + type: 'service_account', + providerId: 'google-service-account', + displayName: OLD_EMAIL, + ...overrides, + }, + hasWorkspaceAccess: true, + isAdmin: true, + }) +} + +/** Queues the stored (pre-rotation) secret blob for the orchestration's read. */ +function mockStoredBlob(blob: unknown) { + queueTableRows(schemaMock.credential, [{ key: 'stored-cipher' }]) + mockDecryptSecret.mockResolvedValue({ decrypted: JSON.stringify(blob) }) +} + +/** + * The `set(...)` payload of the credential UPDATE — always the first mutation, + * ahead of the Slack bot-user-id propagation to webhooks. + */ +function updatePayload(): Record { + const call = dbChainMockFns.set.mock.calls[0] + return (call?.[0] ?? {}) as Record +} + +/** The metadata recorded on the CREDENTIAL_UPDATED audit entry. */ +function auditMetadata(): Record { + const call = mockRecordAudit.mock.calls.at(-1) + return ((call?.[0] as { metadata?: Record })?.metadata ?? {}) as Record< + string, + unknown + > +} + +describe('performUpdateCredential — service-account secret rotation', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockIsClientCredentialAccountProviderId.mockReturnValue(false) + mockVerifyAndBuildServiceAccountSecret.mockResolvedValue({ + providerId: 'google-service-account', + encryptedServiceAccountKey: 'new-cipher', + displayName: NEW_EMAIL, + auditMetadata: { principalKind: 'user', principalId: NEW_EMAIL }, + }) + }) + + it('re-labels a Google credential whose name is still the previous key identity', async () => { + mockCredential() + mockStoredBlob({ type: 'service_account', client_email: OLD_EMAIL }) + + const result = await performUpdateCredential({ + credentialId: 'cred-1', + userId: 'user-1', + serviceAccountJson: NEW_GOOGLE_KEY, + }) + + expect(result.success).toBe(true) + expect(updatePayload().displayName).toBe(NEW_EMAIL) + expect(updatePayload().encryptedServiceAccountKey).toBe('new-cipher') + expect(result.updatedFields).toContain('displayName') + expect(result.previousDisplayName).toBe(OLD_EMAIL) + }) + + it('keeps a label the user typed instead of the derived identity', async () => { + mockCredential({ displayName: 'Prod billing exporter' }) + mockStoredBlob({ type: 'service_account', client_email: OLD_EMAIL }) + + const result = await performUpdateCredential({ + credentialId: 'cred-1', + userId: 'user-1', + serviceAccountJson: NEW_GOOGLE_KEY, + }) + + expect(result.success).toBe(true) + expect(updatePayload()).not.toHaveProperty('displayName') + expect(result.updatedFields).not.toContain('displayName') + }) + + it('lets an explicit displayName in the same request win over the derived one', async () => { + mockCredential() + mockStoredBlob({ type: 'service_account', client_email: OLD_EMAIL }) + + await performUpdateCredential({ + credentialId: 'cred-1', + userId: 'user-1', + displayName: 'Renamed by admin', + serviceAccountJson: NEW_GOOGLE_KEY, + }) + + expect(updatePayload().displayName).toBe('Renamed by admin') + // The stored blob is never read when the caller already named the credential. + expect(mockDecryptSecret).not.toHaveBeenCalled() + }) + + it('leaves the label alone when the stored blob carries no recoverable identity', async () => { + mockCredential({ providerId: 'atlassian-service-account', displayName: 'Acme Jira' }) + mockVerifyAndBuildServiceAccountSecret.mockResolvedValue({ + providerId: 'atlassian-service-account', + encryptedServiceAccountKey: 'new-cipher', + displayName: 'Other Site', + auditMetadata: { atlassianCloudId: 'cloud-2' }, + }) + + await performUpdateCredential({ + credentialId: 'cred-1', + userId: 'user-1', + apiToken: 'tok', + domain: 'other.atlassian.net', + }) + + expect(updatePayload()).not.toHaveProperty('displayName') + expect(mockDecryptSecret).not.toHaveBeenCalled() + }) + + it('re-labels a Slack custom bot that still carries its previous team name', async () => { + mockCredential({ providerId: 'slack-custom-bot', displayName: 'Old Team' }) + mockStoredBlob({ type: 'slack_custom_bot', teamName: 'Old Team', teamId: 'T1' }) + mockVerifyAndBuildServiceAccountSecret.mockResolvedValue({ + providerId: 'slack-custom-bot', + encryptedServiceAccountKey: 'new-cipher', + displayName: 'New Team', + auditMetadata: { slackTeamId: 'T2' }, + botUserId: 'U2', + }) + + await performUpdateCredential({ + credentialId: 'cred-1', + userId: 'user-1', + botToken: 'xoxb-new', + signingSecret: 'sig', + }) + + expect(updatePayload().displayName).toBe('New Team') + }) + + it('merges the rebuilt secret audit metadata into the CREDENTIAL_UPDATED entry', async () => { + mockCredential() + mockStoredBlob({ type: 'service_account', client_email: OLD_EMAIL }) + + await performUpdateCredential({ + credentialId: 'cred-1', + userId: 'user-1', + serviceAccountJson: NEW_GOOGLE_KEY, + }) + + expect(auditMetadata()).toMatchObject({ + credentialType: 'service_account', + principalKind: 'user', + principalId: NEW_EMAIL, + }) + expect(auditMetadata().updatedFields).toEqual( + expect.arrayContaining(['displayName', 'encryptedServiceAccountKey']) + ) + }) + + it('never lets provider audit metadata shadow the orchestration keys', async () => { + mockCredential({ providerId: 'atlassian-service-account', displayName: 'Acme Jira' }) + mockVerifyAndBuildServiceAccountSecret.mockResolvedValue({ + providerId: 'atlassian-service-account', + encryptedServiceAccountKey: 'new-cipher', + displayName: 'Acme Jira', + auditMetadata: { credentialType: 'spoofed', updatedFields: 'spoofed' }, + }) + + await performUpdateCredential({ credentialId: 'cred-1', userId: 'user-1', apiToken: 'tok' }) + + expect(auditMetadata().credentialType).toBe('service_account') + expect(auditMetadata().updatedFields).toEqual(['encryptedServiceAccountKey']) + }) + + it('omits secret audit metadata on a metadata-only update', async () => { + mockCredential() + + await performUpdateCredential({ + credentialId: 'cred-1', + userId: 'user-1', + description: 'Billing exports', + }) + + expect(mockVerifyAndBuildServiceAccountSecret).not.toHaveBeenCalled() + expect(auditMetadata()).toEqual({ + credentialType: 'service_account', + updatedFields: ['description'], + }) + }) + + it('carries the stored dataCenter forward for a client-credential reconnect', async () => { + mockCredential({ providerId: 'zoho-desk-service-account', displayName: 'Acme Desk' }) + mockIsClientCredentialAccountProviderId.mockReturnValue(true) + mockStoredBlob({ type: 'client_credential_account', dataCenter: 'eu' }) + mockVerifyAndBuildServiceAccountSecret.mockResolvedValue({ + providerId: 'zoho-desk-service-account', + encryptedServiceAccountKey: 'new-cipher', + displayName: 'Acme Desk', + auditMetadata: { zohoOrgId: 'org-1' }, + }) + + await performUpdateCredential({ + credentialId: 'cred-1', + userId: 'user-1', + clientId: 'cid', + clientSecret: 'csec', + orgId: 'org-1', + }) + + expect(mockVerifyAndBuildServiceAccountSecret).toHaveBeenCalledWith( + 'zoho-desk-service-account', + expect.objectContaining({ dataCenter: 'eu' }) + ) + }) + + it('surfaces a rebuild failure as a validation error and writes nothing', async () => { + mockCredential() + mockStoredBlob({ type: 'service_account', client_email: OLD_EMAIL }) + const { ServiceAccountSecretError } = await import('@/lib/credentials/service-account-secret') + mockVerifyAndBuildServiceAccountSecret.mockRejectedValue( + new ServiceAccountSecretError('Invalid service account JSON') + ) + + const result = await performUpdateCredential({ + credentialId: 'cred-1', + userId: 'user-1', + serviceAccountJson: '{}', + }) + + expect(result).toMatchObject({ success: false, errorCode: 'validation' }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(mockRecordAudit).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credentials/orchestration/index.ts b/apps/sim/lib/credentials/orchestration/index.ts index ea69218e38c..b36ca844047 100644 --- a/apps/sim/lib/credentials/orchestration/index.ts +++ b/apps/sim/lib/credentials/orchestration/index.ts @@ -5,11 +5,12 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, sql } from 'drizzle-orm' import type { NextRequest } from 'next/server' -import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' +import { decryptSecret } from '@/lib/core/security/encryption' import { getCredentialActorContext } from '@/lib/credentials/access' import { AtlassianValidationError } from '@/lib/credentials/atlassian-service-account' import { isClientCredentialAccountProviderId } from '@/lib/credentials/client-credential-accounts/descriptors' import { type CredentialDeleteReason, deleteCredential } from '@/lib/credentials/deletion' +import { slackCustomBotDisplayName } from '@/lib/credentials/display-name' import { deleteWorkspaceEnvCredentials, syncPersonalEnvCredentialsForUser, @@ -19,18 +20,40 @@ import { verifyAndBuildServiceAccountSecret, } from '@/lib/credentials/service-account-secret' import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' +import { + GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, + SLACK_CUSTOM_BOT_PROVIDER_ID, + SLACK_CUSTOM_BOT_SECRET_TYPE, +} from '@/lib/oauth/types' import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('CredentialOrchestration') /** - * Read the `dataCenter` already stored in a service-account credential's - * encrypted blob. Used on reconnect so a non-secret regional selector survives a - * secret rotation that does not resubmit it. Returns undefined on any failure - - * a blob that cannot be read must not block the reconnect, and the provider's - * own default then applies. + * Google's stored blob is the raw GCP JSON key, whose own `type` discriminator + * is `service_account`. + */ +const GOOGLE_SERVICE_ACCOUNT_KEY_TYPE = 'service_account' + +/** + * Provider ids whose credential `displayName` is derived from the secret's own + * principal at create time AND whose principal is recoverable from the stored + * blob. Only for these can a reconnect tell a stale derived label apart from a + * name the user typed. An empty provider id is a legacy Google service account + * (the original flow predates multi-provider support). + */ +const IDENTITY_DERIVED_DISPLAY_NAME_PROVIDERS: ReadonlySet = new Set([ + GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, + SLACK_CUSTOM_BOT_PROVIDER_ID, + '', +]) + +/** + * Read and decrypt a service-account credential's stored secret blob. Returns + * null on any failure - a blob that cannot be read must never block a + * reconnect; each caller degrades to the behaviour it had without the blob. */ -async function readStoredDataCenter(credentialId: string): Promise { +async function readStoredSecretBlob(credentialId: string): Promise | null> { try { const rows = await db .select({ key: credential.encryptedServiceAccountKey }) @@ -38,13 +61,41 @@ async function readStoredDataCenter(credentialId: string): Promise) : null } catch { - return undefined + return null + } +} + +/** + * The `dataCenter` already stored in a service-account blob. Used on reconnect + * so a non-secret regional selector survives a secret rotation that does not + * resubmit it; undefined lets the provider's own default apply. + */ +function readStoredDataCenter(blob: Record | null): string | undefined { + const dataCenter = blob?.dataCenter + return typeof dataCenter === 'string' && dataCenter ? dataCenter : undefined +} + +/** + * Recompute the display name that `verifyAndBuildServiceAccountSecret` derived + * from the *stored* secret, so a reconnect can tell whether the current label + * is still the previous principal or a name the user deliberately typed. + * Returns undefined when the blob does not carry its own identity, in which + * case the label must be left alone. + */ +function deriveStoredDisplayName(blob: Record | null): string | undefined { + if (!blob) return undefined + if (blob.type === SLACK_CUSTOM_BOT_SECRET_TYPE) { + return slackCustomBotDisplayName(typeof blob.teamName === 'string' ? blob.teamName : undefined) } + if (blob.type === GOOGLE_SERVICE_ACCOUNT_KEY_TYPE && typeof blob.client_email === 'string') { + return blob.client_email || undefined + } + return undefined } export type CredentialOrchestrationErrorCode = @@ -125,34 +176,13 @@ export async function performUpdateCredential( ) { updates.displayName = params.displayName } - if (params.serviceAccountJson !== undefined && access.credential.type === 'service_account') { - let parsedJson: Record - try { - parsedJson = JSON.parse(params.serviceAccountJson) - } catch { - return { success: false, error: 'Invalid JSON format', errorCode: 'validation' } - } - if ( - parsedJson.type !== 'service_account' || - typeof parsedJson.client_email !== 'string' || - typeof parsedJson.private_key !== 'string' || - typeof parsedJson.project_id !== 'string' - ) { - return { - success: false, - error: 'Invalid service account JSON key', - errorCode: 'validation', - } - } - const { encrypted } = await encryptSecret(params.serviceAccountJson) - updates.encryptedServiceAccountKey = encrypted - } - - // Reconnect: rotate a service-account secret (Slack, Atlassian, or any - // token-paste provider) in place. The - // secret is re-verified against the provider and re-encrypted; the display - // name is preserved (the user may have renamed it). + // Reconnect: rotate a service-account secret (Google JSON key, Slack, + // Atlassian, or any token-paste / client-credential provider) in place. The + // secret is re-verified against the provider and re-encrypted through the + // same builder the create path uses, so the rotation also yields the new + // principal's derived display name and audit metadata. const hasRotationSecret = + params.serviceAccountJson !== undefined || params.signingSecret !== undefined || params.botToken !== undefined || params.apiToken !== undefined || @@ -162,38 +192,61 @@ export async function performUpdateCredential( params.orgId !== undefined || params.dataCenter !== undefined let rotatedSlackBotUserId: string | undefined + let rotatedAuditMetadata: Record | undefined if (hasRotationSecret && access.credential.type === 'service_account') { + const providerId = access.credential.providerId ?? '' + // A reconnect rebuilds the secret blob from the submitted fields only, and // the modal never prefills (secrets are never echoed back). For an actual // secret that is correct - the admin retypes it. But a non-secret selector // like the Zoho data center would be silently dropped, moving an EU/IN/AU // credential back to the US accounts server. Carry the stored value forward // when the caller did not supply one. - // Scoped to the providers that actually have a dataCenter field, so no - // other service-account reconnect (Slack, Atlassian, every token-paste - // provider) pays for a DB read plus a decrypt it can never use. - const carriedDataCenter = - params.dataCenter === undefined && - isClientCredentialAccountProviderId(access.credential.providerId ?? '') - ? await readStoredDataCenter(access.credential.id) - : params.dataCenter + const needsStoredDataCenter = + params.dataCenter === undefined && isClientCredentialAccountProviderId(providerId) + + // Rotating to a key that belongs to a different principal makes an + // identity-derived label (a Google `client_email`, a Slack team name) + // actively wrong about who the credential authenticates as. Re-derive it - + // but only when the stored label is still the previous principal, so a + // name the user deliberately typed always wins. An explicit `displayName` + // in this same request wins outright and skips the read entirely. + const needsStoredIdentity = + params.displayName === undefined && IDENTITY_DERIVED_DISPLAY_NAME_PROVIDERS.has(providerId) + + // One read + decrypt at most, and only for the providers that can use it. + const storedBlob = + needsStoredDataCenter || needsStoredIdentity + ? await readStoredSecretBlob(access.credential.id) + : null try { - const secret = await verifyAndBuildServiceAccountSecret( - access.credential.providerId ?? '', - { - signingSecret: params.signingSecret, - botToken: params.botToken, - apiToken: params.apiToken, - domain: params.domain, - clientId: params.clientId, - clientSecret: params.clientSecret, - orgId: params.orgId, - dataCenter: carriedDataCenter, - } - ) + const secret = await verifyAndBuildServiceAccountSecret(providerId, { + signingSecret: params.signingSecret, + botToken: params.botToken, + apiToken: params.apiToken, + domain: params.domain, + serviceAccountJson: params.serviceAccountJson, + clientId: params.clientId, + clientSecret: params.clientSecret, + orgId: params.orgId, + dataCenter: needsStoredDataCenter ? readStoredDataCenter(storedBlob) : params.dataCenter, + }) updates.encryptedServiceAccountKey = secret.encryptedServiceAccountKey rotatedSlackBotUserId = secret.botUserId + rotatedAuditMetadata = secret.auditMetadata + + if (needsStoredIdentity) { + const previousIdentity = deriveStoredDisplayName(storedBlob) + if ( + previousIdentity !== undefined && + previousIdentity === access.credential.displayName && + secret.displayName && + secret.displayName !== previousIdentity + ) { + updates.displayName = secret.displayName + } + } } catch (error) { if (error instanceof ServiceAccountSecretError) { return { success: false, error: error.message, errorCode: 'validation' } @@ -260,7 +313,10 @@ export async function performUpdateCredential( resourceId: params.credentialId, resourceName: access.credential.displayName, description: `Updated ${access.credential.type} credential "${access.credential.displayName}"`, + // Provider metadata first: the orchestration's own keys stay authoritative + // and can never be shadowed by a builder's audit payload. metadata: { + ...rotatedAuditMetadata, credentialType: access.credential.type, updatedFields, }, diff --git a/apps/sim/lib/credentials/principal.ts b/apps/sim/lib/credentials/principal.ts new file mode 100644 index 00000000000..3339b00ec1a --- /dev/null +++ b/apps/sim/lib/credentials/principal.ts @@ -0,0 +1,62 @@ +/** + * Provider-identity primitives for service-account credentials. + * + * Deliberately a leaf module: the token and client-credential registries both + * need these, and `service-account-secret` imports values from both registries. + * Defining them there would close a runtime import cycle. + */ + +/** + * Provider identity captured while verifying a service-account credential. + * + * `tenant` exists because several providers can only ever report an + * org/workspace/site-level identifier (Attio, Shopify, Webflow, Zoom, Zoho + * Desk) — callers must never present those as the human actor behind the + * credential. `lookup_failed` records that the provider does expose a + * principal but the lookup did not complete, which is distinct from a + * provider that exposes no principal at all (`null`). + */ +export type ServiceAccountPrincipal = + | { kind: 'user'; id: string; label?: string } + | { kind: 'tenant'; id: string; label?: string } + | { kind: 'lookup_failed'; reason: string } + +/** + * The human actor a credential authenticates as. + * + * `label` accepts null/undefined because provider payloads routinely type an + * optional email or username that way, and is dropped when empty so + * {@link serviceAccountPrincipalMetadata} never emits a blank key. + */ +export function userPrincipal(id: string, label?: string | null): ServiceAccountPrincipal { + return { kind: 'user', id, ...(label ? { label } : {}) } +} + +/** + * An org/workspace/site-level identifier, for the providers that expose no + * actor at all. Kept distinct from {@link userPrincipal} so callers can never + * present a tenant id as the person behind the credential. + */ +export function tenantPrincipal(id: string, label?: string | null): ServiceAccountPrincipal { + return { kind: 'tenant', id, ...(label ? { label } : {}) } +} + +/** + * Flattens a principal into the string map mirrored into both `auditMetadata` + * (queryable on `audit_log.metadata`) and `storedMetadata` (inside the + * encrypted blob). Applied centrally by the builders below so no provider can + * capture a principal and forget to surface it. + */ +export function serviceAccountPrincipalMetadata( + principal: ServiceAccountPrincipal | null +): Record { + if (principal === null) return { principalKind: 'none' } + if (principal.kind === 'lookup_failed') { + return { principalKind: 'lookup_failed', principalLookupError: principal.reason } + } + return { + principalKind: principal.kind, + principalId: principal.id, + ...(principal.label ? { principalLabel: principal.label } : {}), + } +} diff --git a/apps/sim/lib/credentials/service-account-secret.test.ts b/apps/sim/lib/credentials/service-account-secret.test.ts index 27fa472f15f..b874432a680 100644 --- a/apps/sim/lib/credentials/service-account-secret.test.ts +++ b/apps/sim/lib/credentials/service-account-secret.test.ts @@ -104,6 +104,7 @@ describe('verifyAndBuildServiceAccountSecret', () => { accountId: 'acc-1', displayName: 'Jira Bot', cloudId: 'cloud-1', + emailAddress: 'bot@acme.com', }) const result = await verifyAndBuildServiceAccountSecret(ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, { apiToken: 'tok', @@ -112,6 +113,9 @@ describe('verifyAndBuildServiceAccountSecret', () => { expect(result.providerId).toBe(ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) expect(result.displayName).toBe('Jira Bot') expect(result.auditMetadata.atlassianCloudId).toBe('cloud-1') + expect(result.principal).toEqual({ kind: 'user', id: 'acc-1', label: 'bot@acme.com' }) + expect(result.auditMetadata.principalId).toBe('acc-1') + expect(result.auditMetadata.principalLabel).toBe('bot@acme.com') const blob = JSON.parse(result.encryptedServiceAccountKey) expect(blob).toMatchObject({ apiToken: 'tok', @@ -127,17 +131,32 @@ describe('verifyAndBuildServiceAccountSecret', () => { }) it('validates and encrypts a Google service-account JSON key', async () => { - const json = JSON.stringify({ type: 'service_account', client_email: 'svc@proj.iam' }) + const json = JSON.stringify({ + type: 'service_account', + client_email: 'svc@proj.iam', + project_id: 'proj', + }) const result = await verifyAndBuildServiceAccountSecret('google-service-account', { serviceAccountJson: json, }) expect(result.providerId).toBe('google-service-account') expect(result.displayName).toBe('svc@proj.iam') expect(result.encryptedServiceAccountKey).toBe(json) + expect(result.principal).toEqual({ kind: 'user', id: 'svc@proj.iam' }) + expect(result.auditMetadata).toEqual({ + googleClientEmail: 'svc@proj.iam', + googleProjectId: 'proj', + principalKind: 'user', + principalId: 'svc@proj.iam', + }) }) it('accepts a legacy Google create with an empty providerId', async () => { - const json = JSON.stringify({ type: 'service_account', client_email: 'svc@proj.iam' }) + const json = JSON.stringify({ + type: 'service_account', + client_email: 'svc@proj.iam', + project_id: 'proj', + }) const result = await verifyAndBuildServiceAccountSecret('', { serviceAccountJson: json }) expect(result.providerId).toBe('google-service-account') }) @@ -157,6 +176,7 @@ describe('verifyAndBuildServiceAccountSecret', () => { expiresInSeconds: 3600, identity: { displayName: 'Zoom account acc-1', + principal: { kind: 'tenant', id: 'acc-1' }, auditMetadata: { zoomAccountId: 'acc-1' }, storedMetadata: { apiUrl: 'https://api.zoom.us' }, }, @@ -168,7 +188,11 @@ describe('verifyAndBuildServiceAccountSecret', () => { }) expect(result.providerId).toBe('zoom-service-account') expect(result.displayName).toBe('Zoom account acc-1') - expect(result.auditMetadata).toEqual({ zoomAccountId: 'acc-1' }) + expect(result.auditMetadata).toEqual({ + zoomAccountId: 'acc-1', + principalKind: 'tenant', + principalId: 'acc-1', + }) expect(mockClientCredentialMinter).toHaveBeenCalledWith({ clientId: 'cid', clientSecret: 'csec', @@ -181,7 +205,11 @@ describe('verifyAndBuildServiceAccountSecret', () => { clientId: 'cid', clientSecret: 'csec', orgId: 'acc-1', - metadata: { apiUrl: 'https://api.zoom.us' }, + metadata: { + apiUrl: 'https://api.zoom.us', + principalKind: 'tenant', + principalId: 'acc-1', + }, }) }) @@ -193,9 +221,10 @@ describe('verifyAndBuildServiceAccountSecret', () => { orgId: '999', }) expect(result.displayName).toBe('Box 999') - expect(result.auditMetadata).toEqual({}) + expect(result.principal).toBeNull() + expect(result.auditMetadata).toEqual({ principalKind: 'none' }) const blob = JSON.parse(result.encryptedServiceAccountKey) - expect(blob.metadata).toBeUndefined() + expect(blob.metadata).toEqual({ principalKind: 'none' }) }) it('throws when client-credential required fields are missing, without minting', async () => { diff --git a/apps/sim/lib/credentials/service-account-secret.ts b/apps/sim/lib/credentials/service-account-secret.ts index 900462b6e27..d6b678d4a17 100644 --- a/apps/sim/lib/credentials/service-account-secret.ts +++ b/apps/sim/lib/credentials/service-account-secret.ts @@ -15,6 +15,11 @@ import { type ClientCredentialAccountSecretBlob, getClientCredentialAccountMinter, } from '@/lib/credentials/client-credential-accounts/server' +import { slackCustomBotDisplayName } from '@/lib/credentials/display-name' +import { + type ServiceAccountPrincipal, + serviceAccountPrincipalMetadata, +} from '@/lib/credentials/principal' import { getTokenServiceAccountDescriptor, isTokenServiceAccountProviderId, @@ -52,6 +57,12 @@ export interface ServiceAccountSecretResult { encryptedServiceAccountKey: string displayName: string auditMetadata: Record + /** + * Provider principal behind the credential, or `null` when the provider + * exposes none. Required (never optional) so a new provider cannot be added + * without deciding what identity it captures. + */ + principal: ServiceAccountPrincipal | null /** Slack custom bot: the derived bot user id (for reaction self-drop). */ botUserId?: string } @@ -78,12 +89,20 @@ async function buildAtlassianServiceAccountSecret( } const normalizedDomain = normalizeAtlassianDomain(domain) const validation = await validateAtlassianServiceAccount(apiToken, normalizedDomain) + const principal: ServiceAccountPrincipal = { + kind: 'user', + id: validation.accountId, + ...(validation.emailAddress ? { label: validation.emailAddress } : {}), + } + // `atlassianAccountId` stays at the blob's top level: `getAtlassianServiceAccountSecret` + // in `app/api/auth/oauth/utils.ts` reads it there on every existing credential. const blob = JSON.stringify({ type: ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE, apiToken, domain: normalizedDomain, cloudId: validation.cloudId, atlassianAccountId: validation.accountId, + metadata: serviceAccountPrincipalMetadata(principal), }) const { encrypted } = await encryptSecret(blob) return { @@ -93,7 +112,9 @@ async function buildAtlassianServiceAccountSecret( auditMetadata: { atlassianDomain: normalizedDomain, atlassianCloudId: validation.cloudId, + ...serviceAccountPrincipalMetadata(principal), }, + principal, } } @@ -123,6 +144,11 @@ async function buildSlackCustomBotSecret( `Could not verify the Slack bot token: ${getErrorMessage(error)}` ) } + // `auth.test` returns the bot user only for bot tokens; a token without one + // is workspace-scoped, so the team is the finest identity available. + const principal: ServiceAccountPrincipal = botUserId + ? { kind: 'user', id: botUserId } + : { kind: 'tenant', id: teamId, ...(teamName ? { label: teamName } : {}) } const blob = JSON.stringify({ type: SLACK_CUSTOM_BOT_SECRET_TYPE, signingSecret, @@ -130,13 +156,15 @@ async function buildSlackCustomBotSecret( teamId, botUserId, teamName, + metadata: serviceAccountPrincipalMetadata(principal), }) const { encrypted } = await encryptSecret(blob) return { providerId: SLACK_CUSTOM_BOT_PROVIDER_ID, encryptedServiceAccountKey: encrypted, - displayName: teamName || 'Slack bot', - auditMetadata: { slackTeamId: teamId }, + displayName: slackCustomBotDisplayName(teamName), + auditMetadata: { slackTeamId: teamId, ...serviceAccountPrincipalMetadata(principal) }, + principal, botUserId, } } @@ -161,12 +189,23 @@ async function buildGoogleServiceAccountSecret( getValidationErrorMessage(jsonParseResult.error, 'Invalid service account JSON') ) } + const { client_email: clientEmail, project_id: projectId } = jsonParseResult.data + // `client_email` is the principal a Google service account authenticates as + // (its `unique_id` is not guaranteed to be present in a downloaded key). + const principal: ServiceAccountPrincipal = { kind: 'user', id: clientEmail } + // The blob stays the verbatim GCP key — every consumer parses it as one — so + // the principal is mirrored into the audit metadata only. const { encrypted } = await encryptSecret(serviceAccountJson) return { providerId: GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, encryptedServiceAccountKey: encrypted, - displayName: jsonParseResult.data.client_email, - auditMetadata: {}, + displayName: clientEmail, + auditMetadata: { + googleClientEmail: clientEmail, + googleProjectId: projectId, + ...serviceAccountPrincipalMetadata(principal), + }, + principal, } } @@ -197,19 +236,21 @@ async function buildTokenServiceAccountSecret( ) } const validation = await validator({ apiToken, domain }) + const principalMetadata = serviceAccountPrincipalMetadata(validation.principal) const blob: TokenServiceAccountSecretBlob = { type: TOKEN_SERVICE_ACCOUNT_SECRET_TYPE, providerId, apiToken, ...(requiresDomain ? { domain: validation.normalizedDomain ?? domain } : {}), - ...(validation.storedMetadata ? { metadata: validation.storedMetadata } : {}), + metadata: { ...validation.storedMetadata, ...principalMetadata }, } const { encrypted } = await encryptSecret(JSON.stringify(blob)) return { providerId, encryptedServiceAccountKey: encrypted, displayName: validation.displayName, - auditMetadata: validation.auditMetadata, + auditMetadata: { ...validation.auditMetadata, ...principalMetadata }, + principal: validation.principal, } } @@ -246,6 +287,10 @@ async function buildClientCredentialAccountSecret( ) } const mint = await minter({ clientId, clientSecret, orgId, dataCenter }) + // `identity` is absent only on the `skipIdentity` execution-time path, which + // never reaches this builder; treat it as "no principal captured". + const principal = mint.identity?.principal ?? null + const principalMetadata = serviceAccountPrincipalMetadata(principal) const blob: ClientCredentialAccountSecretBlob = { type: CLIENT_CREDENTIAL_ACCOUNT_SECRET_TYPE, providerId, @@ -253,14 +298,15 @@ async function buildClientCredentialAccountSecret( clientSecret, orgId, ...(dataCenter ? { dataCenter } : {}), - ...(mint.identity?.storedMetadata ? { metadata: mint.identity.storedMetadata } : {}), + metadata: { ...mint.identity?.storedMetadata, ...principalMetadata }, } const { encrypted } = await encryptSecret(JSON.stringify(blob)) return { providerId, encryptedServiceAccountKey: encrypted, displayName: mint.identity?.displayName ?? `${descriptor.serviceLabel} ${orgId}`, - auditMetadata: mint.identity?.auditMetadata ?? {}, + auditMetadata: { ...mint.identity?.auditMetadata, ...principalMetadata }, + principal, } } diff --git a/apps/sim/lib/credentials/token-service-accounts/errors.ts b/apps/sim/lib/credentials/token-service-accounts/errors.ts index abf0b003b96..3e6ec4cbed6 100644 --- a/apps/sim/lib/credentials/token-service-accounts/errors.ts +++ b/apps/sim/lib/credentials/token-service-accounts/errors.ts @@ -1,3 +1,4 @@ +import { getErrorMessage } from '@sim/utils/errors' import { truncate } from '@sim/utils/string' /** @@ -23,6 +24,19 @@ export class TokenServiceAccountValidationError extends Error { const ERROR_SNIPPET_MAX_LENGTH = 500 +/** + * Short, stable description of a failed best-effort provider call, for callers + * that degrade instead of throwing. `TokenServiceAccountValidationError`'s + * message is only its code, so the status is appended to keep the reason + * diagnosable. + */ +export function providerFailureReason(error: unknown): string { + if (error instanceof TokenServiceAccountValidationError) { + return `${error.code} (HTTP ${error.status})` + } + return getErrorMessage(error, 'request failed') +} + /** * Transient statuses a provider token/verification endpoint can return that * say nothing about the submitted credentials (throttling, request timeout) — diff --git a/apps/sim/lib/credentials/token-service-accounts/server.ts b/apps/sim/lib/credentials/token-service-accounts/server.ts index a7a693b1ca0..4fee7e16e40 100644 --- a/apps/sim/lib/credentials/token-service-accounts/server.ts +++ b/apps/sim/lib/credentials/token-service-accounts/server.ts @@ -1,3 +1,4 @@ +import type { ServiceAccountPrincipal } from '@/lib/credentials/principal' import { AIRTABLE_SERVICE_ACCOUNT_PROVIDER_ID, ASANA_SERVICE_ACCOUNT_PROVIDER_ID, @@ -44,11 +45,21 @@ export interface TokenServiceAccountFields { export interface TokenServiceAccountValidationResult { /** Default display name when the user didn't provide one. */ displayName: string - /** Non-secret identifiers recorded in the audit log (e.g. portal/workspace id). */ + /** + * Identity the token authenticates as, or `null` when the provider exposes + * none. Required (never optional) so a new validator cannot be written + * without deciding. `verifyAndBuildServiceAccountSecret` mirrors it into both + * `auditMetadata` and `storedMetadata`, so validators must not repeat it. + */ + principal: ServiceAccountPrincipal | null + /** + * Non-secret identifiers recorded in the audit log that are NOT the + * principal (e.g. the org id behind a user principal). + */ auditMetadata: Record /** * Non-secret metadata persisted inside the encrypted blob alongside the - * token (e.g. normalized store domain, portal id) for later debugging. + * token (e.g. normalized store domain, granted scopes) for later debugging. */ storedMetadata?: Record /** Normalized domain to persist instead of the raw user input (when collected). */ diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/airtable.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/airtable.test.ts index b1f4797b509..b71becb916b 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/airtable.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/airtable.test.ts @@ -37,8 +37,9 @@ describe('validateAirtableServiceAccount', () => { expect(result).toEqual({ displayName: 'svc@example.com', - auditMetadata: { airtableUserId: 'usrABC123' }, - storedMetadata: { userId: 'usrABC123', scopes: 'data.records:read' }, + principal: { kind: 'user', id: 'usrABC123', label: 'svc@example.com' }, + auditMetadata: {}, + storedMetadata: { scopes: 'data.records:read' }, }) expect(mockFetch).toHaveBeenCalledWith('https://api.airtable.com/v0/meta/whoami', { headers: { @@ -55,8 +56,9 @@ describe('validateAirtableServiceAccount', () => { const result = await validateAirtableServiceAccount({ apiToken: 'pat456.secret' }) expect(result.displayName).toBe('Airtable user usrXYZ789') - expect(result.auditMetadata).toEqual({ airtableUserId: 'usrXYZ789' }) - expect(result.storedMetadata).toEqual({ userId: 'usrXYZ789' }) + expect(result.principal).toEqual({ kind: 'user', id: 'usrXYZ789' }) + expect(result.auditMetadata).toEqual({}) + expect(result.storedMetadata).toEqual({}) }) it('throws invalid_credentials on 401', async () => { diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/airtable.ts b/apps/sim/lib/credentials/token-service-accounts/validators/airtable.ts index ccab65691eb..c70ed3c4b1b 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/airtable.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/airtable.ts @@ -1,3 +1,4 @@ +import { userPrincipal } from '@/lib/credentials/principal' import { fetchProvider, parseProviderJson, @@ -47,14 +48,15 @@ export async function validateAirtableServiceAccount( }) } - const storedMetadata: Record = { userId: whoami.id } + const storedMetadata: Record = {} if (whoami.scopes) { storedMetadata.scopes = whoami.scopes.join(' ') } return { displayName: whoami.email ?? `Airtable user ${whoami.id}`, - auditMetadata: { airtableUserId: whoami.id }, + principal: userPrincipal(whoami.id, whoami.email), + auditMetadata: {}, storedMetadata, } } diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/asana.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/asana.test.ts index 6e950a55002..c1c57c7f815 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/asana.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/asana.test.ts @@ -35,8 +35,8 @@ describe('validateAsanaServiceAccount', () => { expect(result).toEqual({ displayName: 'Sim Integration', - auditMetadata: { asanaUserGid: '12345' }, - storedMetadata: { userGid: '12345', email: 'bot@example.com' }, + principal: { kind: 'user', id: '12345', label: 'bot@example.com' }, + auditMetadata: {}, }) expect(mockFetch).toHaveBeenCalledWith( 'https://app.asana.com/api/1.0/users/me?opt_fields=gid,name,email', @@ -62,7 +62,7 @@ describe('validateAsanaServiceAccount', () => { const gidOnly = await validateAsanaServiceAccount({ apiToken: 'token-2' }) expect(gidOnly.displayName).toBe('Asana user 999') - expect(gidOnly.storedMetadata).toEqual({ userGid: '999' }) + expect(gidOnly.principal).toEqual({ kind: 'user', id: '999' }) }) it('maps 401 to invalid_credentials', async () => { diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/asana.ts b/apps/sim/lib/credentials/token-service-accounts/validators/asana.ts index c138258ee35..e35f0adff37 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/asana.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/asana.ts @@ -1,3 +1,4 @@ +import { userPrincipal } from '@/lib/credentials/principal' import { fetchProvider, parseProviderJson, @@ -51,12 +52,10 @@ export async function validateAsanaServiceAccount( const name = body.data?.name const email = body.data?.email - const storedMetadata: Record = { userGid: gid } - if (email) storedMetadata.email = email return { displayName: name || email || `Asana user ${gid}`, - auditMetadata: { asanaUserGid: gid }, - storedMetadata, + principal: userPrincipal(gid, email), + auditMetadata: {}, } } diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/attio.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/attio.test.ts index 7c193f063e5..92002f1752c 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/attio.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/attio.test.ts @@ -60,8 +60,8 @@ describe('validateAttioServiceAccount', () => { }) expect(result).toEqual({ displayName: 'Acme CRM', - auditMetadata: { attioWorkspaceId: 'ws-123' }, - storedMetadata: { workspaceId: 'ws-123', workspaceSlug: 'acme-crm' }, + principal: { kind: 'tenant', id: 'ws-123', label: 'acme-crm' }, + auditMetadata: {}, }) }) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/attio.ts b/apps/sim/lib/credentials/token-service-accounts/validators/attio.ts index 1969439c4ac..c0b7ccc1d0d 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/attio.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/attio.ts @@ -69,14 +69,15 @@ export async function validateAttioServiceAccount( }) } - const storedMetadata: Record = { workspaceId: self.workspace_id } - if (self.workspace_slug) { - storedMetadata.workspaceSlug = self.workspace_slug - } - + // An Attio workspace access token is not bound to a member, so the workspace + // is the finest identity the token can ever report. return { displayName: self.workspace_name || 'Attio workspace', - auditMetadata: { attioWorkspaceId: self.workspace_id }, - storedMetadata, + principal: { + kind: 'tenant', + id: self.workspace_id, + ...(self.workspace_slug ? { label: self.workspace_slug } : {}), + }, + auditMetadata: {}, } } diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/calcom.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/calcom.test.ts index cbd22a5b1a2..78b0b7bbcd2 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/calcom.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/calcom.test.ts @@ -36,8 +36,8 @@ describe('validateCalcomServiceAccount', () => { expect(result).toEqual({ displayName: 'sim-bot', - auditMetadata: { calcomUserId: '42' }, - storedMetadata: { userId: '42', email: 'bot@example.com' }, + principal: { kind: 'user', id: '42', label: 'sim-bot' }, + auditMetadata: {}, }) expect(mockFetch).toHaveBeenCalledWith('https://api.cal.com/v2/me', { headers: { diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/calcom.ts b/apps/sim/lib/credentials/token-service-accounts/validators/calcom.ts index c536bb43b1e..bd8cc59ab07 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/calcom.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/calcom.ts @@ -1,3 +1,4 @@ +import { userPrincipal } from '@/lib/credentials/principal' import { fetchProvider, parseProviderJson, @@ -54,12 +55,11 @@ export async function validateCalcomServiceAccount( const userId = String(body.data.id) const username = body.data.username const email = body.data.email - const storedMetadata: Record = { userId } - if (email) storedMetadata.email = email + const label = username || email return { displayName: username || email || 'Cal.com account', - auditMetadata: { calcomUserId: userId }, - storedMetadata, + principal: userPrincipal(userId, label), + auditMetadata: {}, } } diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/claude-platform.ts b/apps/sim/lib/credentials/token-service-accounts/validators/claude-platform.ts index f457d6be29d..566834f4fd2 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/claude-platform.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/claude-platform.ts @@ -35,8 +35,13 @@ export async function validateClaudePlatformServiceAccount( await throwForProviderResponse(res, 'agents_list') const suffix = fields.apiToken.slice(-4) + // Explicitly no principal: the Managed Agents API exposes no whoami endpoint + // and no workspace identifier on any response, so nothing about the key's + // owner is knowable at connect time. This is a provider limitation, not a + // failed lookup — see `ServiceAccountPrincipal`. return { displayName: `Claude Platform (…${suffix})`, + principal: null, auditMetadata: {}, } } diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/clickup.ts b/apps/sim/lib/credentials/token-service-accounts/validators/clickup.ts index 129fbb1fb02..6facd5d4b28 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/clickup.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/clickup.ts @@ -1,3 +1,4 @@ +import { userPrincipal } from '@/lib/credentials/principal' import { fetchProvider, parseProviderJson, @@ -65,9 +66,11 @@ export async function validateClickupServiceAccount( }) } + const label = user.username || user.email + return { displayName: user.username || user.email || 'ClickUp account', - auditMetadata: { clickupUserId: String(user.id) }, - storedMetadata: { userId: String(user.id) }, + principal: userPrincipal(String(user.id), label), + auditMetadata: {}, } } diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.test.ts index 1259b11ac0a..c37e2ad1a53 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.test.ts @@ -74,8 +74,9 @@ describe('validateHubspotServiceAccount', () => { expect(result).toEqual({ displayName: 'HubSpot portal 12345', + principal: { kind: 'user', id: '111' }, auditMetadata: { hubspotHubId: '12345' }, - storedMetadata: { hubId: '12345', appId: '222', userId: '111' }, + storedMetadata: { hubId: '12345', appId: '222' }, }) expect(mockFetch).toHaveBeenCalledTimes(1) @@ -91,8 +92,8 @@ describe('validateHubspotServiceAccount', () => { expect(result).toEqual({ displayName: 'HubSpot portal 123', - auditMetadata: { hubspotHubId: '123' }, - storedMetadata: { hubId: '123' }, + principal: { kind: 'tenant', id: '123' }, + auditMetadata: {}, }) expect(mockFetch).toHaveBeenCalledTimes(2) @@ -127,8 +128,8 @@ describe('validateHubspotServiceAccount', () => { expect(result).toEqual({ displayName: 'HubSpot private app', + principal: null, auditMetadata: {}, - storedMetadata: {}, }) expect(mockFetch).toHaveBeenCalledTimes(2) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts b/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts index 457710e32c8..c22452e5b55 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts @@ -1,3 +1,4 @@ +import { tenantPrincipal, userPrincipal } from '@/lib/credentials/principal' import { fetchProvider, parseProviderJson, @@ -41,10 +42,12 @@ async function verifyViaAccountInfo( 'account_info' ) if (res.status === 403) { + // The token is live but the app cannot read account info, so neither the + // portal nor the creating user is knowable on this path. return { displayName: 'HubSpot private app', + principal: null, auditMetadata: {}, - storedMetadata: {}, } } await throwForProviderResponse(res, 'account_info') @@ -53,8 +56,10 @@ async function verifyViaAccountInfo( const hubId = typeof info?.portalId === 'number' ? String(info.portalId) : undefined return { displayName: hubId ? `HubSpot portal ${hubId}` : 'HubSpot private app', - auditMetadata: hubId ? { hubspotHubId: hubId } : {}, - storedMetadata: hubId ? { hubId } : {}, + // This route never reports the private app's creating user, so the portal + // is the finest identity available here. + principal: hubId ? tenantPrincipal(hubId) : null, + auditMetadata: {}, } } @@ -113,10 +118,15 @@ export async function validateHubspotServiceAccount( const storedMetadata: Record = { hubId } if (typeof tokenInfo.appId === 'number') storedMetadata.appId = String(tokenInfo.appId) - if (typeof tokenInfo.userId === 'number') storedMetadata.userId = String(tokenInfo.userId) return { displayName: `HubSpot portal ${hubId}`, + // `userId` is the HubSpot user the private app acts on behalf of; it is the + // actor, while `hubId` is only the portal it lives in. + principal: + typeof tokenInfo.userId === 'number' + ? userPrincipal(String(tokenInfo.userId)) + : tenantPrincipal(hubId), auditMetadata: { hubspotHubId: hubId }, storedMetadata, } diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/linear.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/linear.test.ts index 3007d161370..37ef8fc156c 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/linear.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/linear.test.ts @@ -39,8 +39,9 @@ describe('validateLinearServiceAccount', () => { expect(result).toEqual({ displayName: 'Acme', + principal: { kind: 'user', id: 'viewer-1', label: 'jane@acme.com' }, auditMetadata: { linearOrganizationId: 'org-1' }, - storedMetadata: { viewerId: 'viewer-1', organizationId: 'org-1' }, + storedMetadata: { organizationId: 'org-1' }, }) const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit] diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/linear.ts b/apps/sim/lib/credentials/token-service-accounts/validators/linear.ts index ea4e297f374..46c98aef7b0 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/linear.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/linear.ts @@ -1,3 +1,4 @@ +import { userPrincipal } from '@/lib/credentials/principal' import { fetchProvider, parseProviderJson, @@ -127,15 +128,17 @@ export async function validateLinearServiceAccount( } const organization = payload.data?.organization - const storedMetadata: Record = { viewerId: viewer.id } + const storedMetadata: Record = {} const auditMetadata: Record = {} if (organization?.id) { storedMetadata.organizationId = organization.id auditMetadata.linearOrganizationId = organization.id } + const label = viewer.email || viewer.name || undefined return { displayName: organization?.name || viewer.name || viewer.email || 'Linear workspace', + principal: userPrincipal(viewer.id, label), auditMetadata, storedMetadata, } diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/monday.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/monday.test.ts index e96e7006b72..590d425c79d 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/monday.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/monday.test.ts @@ -38,8 +38,9 @@ describe('validateMondayServiceAccount', () => { expect(result).toEqual({ displayName: 'Acme', + principal: { kind: 'user', id: '12345', label: 'jane@example.com' }, auditMetadata: { mondayAccountId: '987' }, - storedMetadata: { accountId: '987', accountSlug: 'acme', userId: '12345' }, + storedMetadata: { accountId: '987', accountSlug: 'acme' }, }) expect(mockFetch).toHaveBeenCalledWith('https://api.monday.com/v2', { method: 'POST', @@ -159,6 +160,6 @@ describe('validateMondayServiceAccount', () => { ) const result = await validateMondayServiceAccount({ apiToken: 'token' }) expect(result.displayName).toBe('Acme') - expect(result.storedMetadata?.userId).toBe('77') + expect(result.principal).toEqual({ kind: 'user', id: '77', label: 'Bot User' }) }) }) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/monday.ts b/apps/sim/lib/credentials/token-service-accounts/validators/monday.ts index 9ff853d71ff..75fa3fdbe9e 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/monday.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/monday.ts @@ -1,3 +1,4 @@ +import { userPrincipal } from '@/lib/credentials/principal' import { fetchProvider, parseProviderJson, @@ -115,7 +116,7 @@ export async function validateMondayServiceAccount( const userId = String(me.id) const accountId = account?.id != null ? String(account.id) : '' - const storedMetadata: Record = { accountId, userId } + const storedMetadata: Record = { accountId } if (account?.slug) { storedMetadata.accountSlug = account.slug } @@ -123,9 +124,11 @@ export async function validateMondayServiceAccount( if (accountId) { auditMetadata.mondayAccountId = accountId } + const label = me.email || me.name return { displayName: account?.name || me.name || me.email || `monday user ${userId}`, + principal: userPrincipal(userId, label), auditMetadata, storedMetadata, } diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/notion.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/notion.test.ts index fad5f227b30..adffbfb5a1f 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/notion.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/notion.test.ts @@ -39,8 +39,9 @@ describe('validateNotionServiceAccount', () => { expect(result).toEqual({ displayName: 'Ops Integration', - auditMetadata: { notionBotId: 'bot-123' }, - storedMetadata: { botId: 'bot-123', workspaceName: 'Acme Workspace' }, + principal: { kind: 'user', id: 'bot-123', label: 'Ops Integration' }, + auditMetadata: {}, + storedMetadata: { workspaceName: 'Acme Workspace' }, }) expect(mockFetch).toHaveBeenCalledWith('https://api.notion.com/v1/users/me', { headers: { @@ -66,11 +67,9 @@ describe('validateNotionServiceAccount', () => { const result = await validateNotionServiceAccount({ apiToken: 'secret_legacy' }) expect(result.displayName).toBe('Acme Workspace') - expect(result.auditMetadata).toEqual({ notionBotId: 'bot-456' }) - expect(result.storedMetadata).toEqual({ - botId: 'bot-456', - workspaceName: 'Acme Workspace', - }) + expect(result.principal).toEqual({ kind: 'user', id: 'bot-456' }) + expect(result.auditMetadata).toEqual({}) + expect(result.storedMetadata).toEqual({ workspaceName: 'Acme Workspace' }) }) it('throws invalid_credentials on 401', async () => { diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/notion.ts b/apps/sim/lib/credentials/token-service-accounts/validators/notion.ts index 4321ba5f3c4..e2b75762e48 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/notion.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/notion.ts @@ -1,3 +1,4 @@ +import { userPrincipal } from '@/lib/credentials/principal' import { fetchProvider, parseProviderJson, @@ -56,14 +57,17 @@ export async function validateNotionServiceAccount( } const workspaceName = me.bot?.workspace_name || undefined - const storedMetadata: Record = { botId: me.id } + const storedMetadata: Record = {} if (workspaceName) { storedMetadata.workspaceName = workspaceName } return { displayName: me.name || workspaceName || 'Notion integration', - auditMetadata: { notionBotId: me.id }, + // The integration authenticates as its own bot user, which is the actor + // recorded on every page/database change it makes. + principal: userPrincipal(me.id, me.name), + auditMetadata: {}, storedMetadata, } } diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/pipedrive.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/pipedrive.test.ts index 1e73129649b..4e75faeb57f 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/pipedrive.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/pipedrive.test.ts @@ -48,8 +48,9 @@ describe('validatePipedriveServiceAccount', () => { expect(result).toEqual({ displayName: 'Jane Doe (Acme Inc)', + principal: { kind: 'user', id: '42', label: 'Jane Doe' }, auditMetadata: { pipedriveCompanyId: '777' }, - storedMetadata: { userId: '42', companyId: '777', companyDomain: 'acme' }, + storedMetadata: { companyId: '777', companyDomain: 'acme' }, }) expect(mockFetch).toHaveBeenCalledTimes(1) @@ -67,7 +68,8 @@ describe('validatePipedriveServiceAccount', () => { const result = await validatePipedriveServiceAccount(FIELDS) expect(result.displayName).toBe('Pipedrive company 777') - expect(result.storedMetadata).toEqual({ userId: '42', companyId: '777' }) + expect(result.principal).toEqual({ kind: 'user', id: '42' }) + expect(result.storedMetadata).toEqual({ companyId: '777' }) }) it('throws invalid_credentials on 401', async () => { diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/pipedrive.ts b/apps/sim/lib/credentials/token-service-accounts/validators/pipedrive.ts index 3fa20e90ec6..66da8628dfa 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/pipedrive.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/pipedrive.ts @@ -1,3 +1,4 @@ +import { userPrincipal } from '@/lib/credentials/principal' import { fetchProvider, parseProviderJson, @@ -62,7 +63,7 @@ export async function validatePipedriveServiceAccount( const companyDomain = typeof user.company_domain === 'string' && user.company_domain ? user.company_domain : undefined - const storedMetadata: Record = { userId: String(user.id) } + const storedMetadata: Record = {} if (companyId) storedMetadata.companyId = companyId if (companyDomain) storedMetadata.companyDomain = companyDomain @@ -76,6 +77,7 @@ export async function validatePipedriveServiceAccount( return { displayName, + principal: userPrincipal(String(user.id), userName), auditMetadata: companyId ? { pipedriveCompanyId: companyId } : {}, storedMetadata, } diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts index b53fc10cff3..36eff828aac 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts @@ -44,8 +44,8 @@ describe('validateShopifyServiceAccount', () => { expect(result).toEqual({ displayName: 'Acme Store', - auditMetadata: { shopifyShopDomain: 'acme-store.myshopify.com' }, - storedMetadata: { shopDomain: 'acme-store.myshopify.com', shopName: 'Acme Store' }, + principal: { kind: 'tenant', id: 'acme-store.myshopify.com', label: 'Acme Store' }, + auditMetadata: {}, normalizedDomain: 'acme-store.myshopify.com', }) expect(mockFetch).toHaveBeenCalledWith( @@ -159,6 +159,28 @@ describe('validateShopifyServiceAccount', () => { }) }) + it('does not blame the credential when an auth-shaped error accompanies a populated shop', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse(200, { + data: { shop: { name: 'My Store', myshopifyDomain: 'my-store.myshopify.com' } }, + errors: [ + { message: 'Access denied for email field', extensions: { code: 'ACCESS_DENIED' } }, + ], + }) + ) + /** + * A per-field scope denial is not evidence the token is invalid. Reporting + * it as `invalid_credentials` would tell an admin to replace a working + * credential; only a response with no `shop` at all indicts the token. + */ + await expect( + validateShopifyServiceAccount({ apiToken: 'shpat_good', domain: 'my-store.myshopify.com' }) + ).rejects.toMatchObject({ + name: 'TokenServiceAccountValidationError', + code: 'provider_unavailable', + }) + }) + it('normalizes a pasted admin URL down to the bare store host', async () => { mockFetch.mockResolvedValueOnce( jsonResponse(200, { diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts index e0a2f9a605d..4d18a625334 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts @@ -18,6 +18,14 @@ import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' */ const SHOPIFY_HOST_REGEX = /^[a-z0-9][a-z0-9-]*\.myshopify\.com$/ +/** + * Every selected field must stay scope-free. `hasShopifyAuthError` treats an + * auth-shaped GraphQL error as a rejected token, and that is only sound while + * this query cannot partially fail: adding a scoped field (anything guarded by + * `read_*`) makes Shopify answer a token missing that scope with HTTP 200, + * a populated `shop`, AND an `ACCESS_DENIED` error — a working credential that + * must not be rejected. Revisit that check before adding any field here. + */ const SHOP_QUERY = '{ shop { name myshopifyDomain } }' interface ShopifyGraphqlError { @@ -100,19 +108,29 @@ export async function validateShopifyServiceAccount( const payload = await parseProviderJson(res, 'shop_query') + // The auth heuristic only fires when the query returned nothing at all: an + // auth-shaped error alongside a populated `shop` is a partial-scope failure, + // not a rejected token, and blaming the credential there would be wrong. const shop = payload.data?.shop - if (hasShopifyAuthError(payload.errors)) { - throw new TokenServiceAccountValidationError('invalid_credentials', 401, { + if (!shop) { + if (hasShopifyAuthError(payload.errors)) { + throw new TokenServiceAccountValidationError('invalid_credentials', 401, { + step: 'shop_query', + domain, + reason: 'auth-shaped GraphQL error in 200 response', + }) + } + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { step: 'shop_query', domain, - reason: 'auth-shaped GraphQL error in 200 response', + reason: payload.errors ? 'GraphQL errors in response' : 'missing shop in response', }) } - if (payload.errors || !shop) { + if (payload.errors) { throw new TokenServiceAccountValidationError('provider_unavailable', 502, { step: 'shop_query', domain, - reason: payload.errors ? 'GraphQL errors in response' : 'missing shop in response', + reason: 'GraphQL errors in response', }) } @@ -122,13 +140,17 @@ export async function validateShopifyServiceAccount( ? normalizeShopifyDomain(shop.myshopifyDomain) : undefined const canonicalDomain = apiDomain && SHOPIFY_HOST_REGEX.test(apiDomain) ? apiDomain : domain - const storedMetadata: Record = { shopDomain: canonicalDomain } - if (shopName) storedMetadata.shopName = shopName + // A custom-app Admin API token belongs to the app, not to a staff member, so + // the store is the finest identity it can ever report. return { displayName: shopName ?? canonicalDomain, - auditMetadata: { shopifyShopDomain: canonicalDomain }, - storedMetadata, + principal: { + kind: 'tenant', + id: canonicalDomain, + ...(shopName ? { label: shopName } : {}), + }, + auditMetadata: {}, normalizedDomain: canonicalDomain, } } diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/trello.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/trello.test.ts index 314da932186..82c76793363 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/trello.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/trello.test.ts @@ -46,8 +46,8 @@ describe('validateTrelloServiceAccount', () => { expect(result).toEqual({ displayName: 'Sim Bot', - auditMetadata: { trelloMemberId: 'abc123' }, - storedMetadata: { memberId: 'abc123', username: 'simbot' }, + principal: { kind: 'user', id: 'abc123', label: 'simbot' }, + auditMetadata: {}, }) const [url] = mockFetch.mock.calls[0] diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/trello.ts b/apps/sim/lib/credentials/token-service-accounts/validators/trello.ts index 209a43e8b2e..500a8c6b537 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/trello.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/trello.ts @@ -1,4 +1,5 @@ import { env } from '@/lib/core/config/env' +import { userPrincipal } from '@/lib/credentials/principal' import { fetchProvider, parseProviderJson, @@ -79,14 +80,12 @@ export async function validateTrelloServiceAccount( }) } - const storedMetadata: Record = { memberId: member.id } - if (typeof member.username === 'string' && member.username) { - storedMetadata.username = member.username - } + const username = + typeof member.username === 'string' && member.username ? member.username : undefined return { displayName: member.fullName || member.username || `Trello member ${member.id}`, - auditMetadata: { trelloMemberId: member.id }, - storedMetadata, + principal: userPrincipal(member.id, username), + auditMetadata: {}, } } diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.test.ts index 50f8739d879..153faef2706 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.test.ts @@ -43,8 +43,8 @@ describe('validateWealthboxServiceAccount', () => { expect(result).toEqual({ displayName: 'Bill Jones', - auditMetadata: { wealthboxUserId: '42' }, - storedMetadata: { userId: '42', email: 'bill@example.com' }, + principal: { kind: 'user', id: '42', label: 'bill@example.com' }, + auditMetadata: {}, }) expect(mockFetch).toHaveBeenCalledTimes(1) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.ts b/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.ts index 94e85821f33..ac97ca76474 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.ts @@ -1,3 +1,4 @@ +import { userPrincipal } from '@/lib/credentials/principal' import { fetchProvider, parseProviderJson, @@ -94,12 +95,11 @@ export async function validateWealthboxServiceAccount( const userId = typeof me.current_user?.id === 'number' ? String(me.current_user.id) : undefined const email = me.email || me.current_user?.email - const auditMetadata: Record = {} - if (userId) auditMetadata.wealthboxUserId = userId - - const storedMetadata: Record = {} - if (userId) storedMetadata.userId = userId - if (email) storedMetadata.email = email - - return { displayName, auditMetadata, storedMetadata } + // `/v1/me` omits `current_user` for some token types; without it Wealthbox + // reports no identifier of any kind on this response. + return { + displayName, + principal: userId ? userPrincipal(userId, email) : null, + auditMetadata: {}, + } } diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/webflow.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/webflow.test.ts index 1cb3f9018c8..109f862070b 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/webflow.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/webflow.test.ts @@ -35,8 +35,8 @@ describe('validateWebflowServiceAccount', () => { expect(result).toEqual({ displayName: 'Acme Marketing', - auditMetadata: { webflowSiteId: 'site123' }, - storedMetadata: { siteId: 'site123', siteName: 'Acme Marketing' }, + principal: { kind: 'tenant', id: 'site123', label: 'Acme Marketing' }, + auditMetadata: {}, }) expect(mockFetch).toHaveBeenCalledWith('https://api.webflow.com/v2/sites', { headers: { @@ -55,7 +55,7 @@ describe('validateWebflowServiceAccount', () => { const result = await validateWebflowServiceAccount({ apiToken: 'wf-token' }) expect(result.displayName).toBe('acme') - expect(result.storedMetadata).toEqual({ siteId: 'site456', siteName: 'acme' }) + expect(result.principal).toEqual({ kind: 'tenant', id: 'site456', label: 'acme' }) }) it('throws invalid_credentials on 401', async () => { diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/webflow.ts b/apps/sim/lib/credentials/token-service-accounts/validators/webflow.ts index 034558532b2..4da3aeba131 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/webflow.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/webflow.ts @@ -1,3 +1,4 @@ +import { tenantPrincipal } from '@/lib/credentials/principal' import { fetchProvider, parseProviderJson, @@ -51,9 +52,10 @@ export async function validateWebflowServiceAccount( const displayName = site.displayName || site.shortName || 'Webflow site' + // A site API token is bound to a site, never to a Webflow user. return { displayName, - auditMetadata: { webflowSiteId: site.id }, - storedMetadata: { siteId: site.id, siteName: displayName }, + principal: tenantPrincipal(site.id, displayName), + auditMetadata: {}, } } From 030c4e2a5eec430ea1dbfa3d30502a9819657e1b Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 3 Aug 2026 09:35:17 -0700 Subject: [PATCH 07/11] refactor(auth): extract connector definitions out of auth.ts (#6203) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit auth.ts had grown to 3,927 lines, of which ~2,340 were the genericOAuth connector list — the OAuth apps a workspace connects tools to, as distinct from the handful of providers used to sign in to Sim. Adding a connector meant editing the same file that configures sessions, database hooks and Stripe. Moves that list to lib/auth/connectors/providers.ts behind buildConnectorProviders(), and relocates getMicrosoftUserInfoFromIdToken to lib/oauth/microsoft.ts alongside the three Microsoft helpers it already depends on. auth.ts drops to 1,489 lines and reads as auth configuration again. Pure move, verified mechanically: the connector array is token-identical after stripping whitespace, and all 179 template literals emit byte-identical strings (the one apparent diff was reindentation inside a ${} expression, not text). Behavior, evaluation order and log scopes are unchanged; the array is still built once, when betterAuth() runs. The explicit GenericOAuthConfig[] return type is required, not cosmetic — inline, the entries were contextually typed by the config property. Without it prompt: 'consent' widens to string and every getUserInfo parameter becomes implicitly any. --- apps/sim/lib/auth/auth.ts | 2449 +-------------------- apps/sim/lib/auth/connectors/providers.ts | 2410 ++++++++++++++++++++ apps/sim/lib/oauth/microsoft.ts | 60 + 3 files changed, 2476 insertions(+), 2443 deletions(-) create mode 100644 apps/sim/lib/auth/connectors/providers.ts diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index a4df054e65c..87233c9fc40 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -1,13 +1,10 @@ -import { createHash } from 'crypto' import { cache } from 'react' -import { getOAuth2Tokens } from '@better-auth/core/oauth2' import { sso } from '@better-auth/sso' import { stripe } from '@better-auth/stripe' import { db } from '@sim/db' import * as schema from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' import { type BetterAuthOptions, betterAuth, type User } from 'better-auth' import { drizzleAdapter } from 'better-auth/adapters/drizzle' import { APIError, createAuthMiddleware, getSessionFromCtx } from 'better-auth/api' @@ -33,7 +30,7 @@ import { } from '@/components/emails' import { getAccessControlConfig, isEmailBlockedByAccessControl } from '@/lib/auth/access-control' import { createAnonymousSession, ensureAnonymousUserExists } from '@/lib/auth/anonymous' -import { syntheticConnectorEmail } from '@/lib/auth/connector-email' +import { buildConnectorProviders } from '@/lib/auth/connectors/providers' import { getRequestedSignInProviderId, isSignInProviderAllowed } from '@/lib/auth/constants' import { getSessionCookieCacheVersion } from '@/lib/auth/security-policy' import { clampExpiryForSession } from '@/lib/auth/session-policy' @@ -87,10 +84,6 @@ import { isSsoEnabled, } from '@/lib/core/config/env-flags' import { PlatformEvents } from '@/lib/core/telemetry' -import { - readResponseJsonWithLimit, - readResponseTextWithLimit, -} from '@/lib/core/utils/stream-limits' import { getBaseUrl, isLocalhostUrl, parseOriginList } from '@/lib/core/utils/urls' import { processCredentialDraft } from '@/lib/credentials/draft-processor' import { sendEmail } from '@/lib/messaging/email/mailer' @@ -98,11 +91,7 @@ import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email import { quickValidateEmail } from '@/lib/messaging/email/validation' import { validateSignupEmailMx } from '@/lib/messaging/email/validation.server' import { scheduleLifecycleEmail } from '@/lib/messaging/lifecycle' -import { - deriveMicrosoftEmailVerified, - getMicrosoftRefreshTokenExpiry, - isMicrosoftProvider, -} from '@/lib/oauth/microsoft' +import { getMicrosoftRefreshTokenExpiry, isMicrosoftProvider } from '@/lib/oauth/microsoft' import { extractSlackTeamId, fanOutSlackTokenChain } from '@/lib/oauth/slack' import { clearDeadFlag } from '@/lib/oauth/terminal-errors' import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' @@ -110,96 +99,9 @@ import { joinInstanceOrganization } from '@/lib/organizations/instance-org' import { captureServerEvent, getPostHogClient } from '@/lib/posthog/server' import { disableUserResources } from '@/lib/workflows/lifecycle' import { SSO_TRUSTED_PROVIDERS } from '@/ee/sso/constants' -import { deriveZohoDeskBaseFromApiDomain } from '@/tools/zoho_desk/host-allowlist' const logger = createLogger('Auth') -/** - * Shape of `GET https://api.notion.com/v1/users/me` for an OAuth integration token. - * @see https://developers.notion.com/reference/get-self - */ -interface NotionSelfResponse { - id: string - name?: string | null - bot?: { - owner?: - | { type: 'user'; user?: { id: string; name?: string | null; person?: { email?: string } } } - | { type: 'workspace'; workspace: true } - } -} - -/** - * Shape of `GET https://api.attio.com/v2/self` (the Identify endpoint). - * @see https://docs.attio.com/rest-api/endpoint-reference/meta/identify - */ -interface AttioSelfResponse { - active?: boolean - authorized_by_workspace_member_id?: string | null - workspace_id?: string - workspace_name?: string -} - -/** - * Shape of `GET https://api.attio.com/v2/workspace_members/{id}`. - * @see https://docs.attio.com/rest-api/endpoint-reference/workspace-members/get-a-workspace-member - */ -interface AttioWorkspaceMemberResponse { - data?: { - id: { workspace_id: string; workspace_member_id: string } - first_name?: string | null - last_name?: string | null - email_address?: string | null - avatar_url?: string | null - } -} - -/** - * Extracts user info from a Microsoft ID token JWT instead of calling Graph API /me. - * This avoids 403 errors for external tenant users whose admin hasn't consented to Graph API scopes. - * The ID token is always returned when the openid scope is requested. - */ -function getMicrosoftUserInfoFromIdToken(tokens: { accessToken?: string }, providerId: string) { - const idToken = (tokens as Record).idToken as string | undefined - if (!idToken) { - logger.error( - `Microsoft ${providerId} OAuth: no ID token received. Ensure openid scope is requested.` - ) - throw new Error(`Microsoft ${providerId} OAuth requires an ID token (openid scope)`) - } - - const parts = idToken.split('.') - if (parts.length !== 3) { - throw new Error(`Microsoft ${providerId} OAuth: malformed ID token`) - } - - let payload: Record - try { - payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8')) - } catch { - throw new Error(`Microsoft ${providerId} OAuth: failed to decode ID token payload`) - } - - const email = - (payload.email as string) || (payload.preferred_username as string) || (payload.upn as string) - if (!email) { - throw new Error( - `Microsoft ${providerId} OAuth: ID token contains no email, preferred_username, or upn claim` - ) - } - - const emailVerified = deriveMicrosoftEmailVerified(payload, email) - - const now = new Date() - return { - id: `${payload.oid || payload.sub}-${generateId()}`, - name: (payload.name as string) || 'Microsoft User', - email, - emailVerified, - createdAt: now, - updatedAt: now, - } -} - const additionalTrustedOrigins = parseOriginList(env.TRUSTED_ORIGINS, (value) => logger.warn('Ignoring invalid entry in TRUSTED_ORIGINS', { value }) ) @@ -492,8 +394,9 @@ export const auth = betterAuth({ /** * Migrate credentials from stale account rows to the newly created one. * - * Each getUserInfo appends a random UUID to the stable external ID so - * that Better Auth never blocks cross-user connections. This means + * Each `getUserInfo` in `lib/auth/connectors/providers.ts` appends a + * random UUID to the stable external ID so that Better Auth never + * blocks cross-user connections — keep the two in step. This means * re-connecting the same external identity creates a new row. We detect * the stale siblings here by comparing the stable prefix (everything * before the trailing UUID), migrate any credential FKs to the new row, @@ -1171,2347 +1074,7 @@ export const auth = betterAuth({ overrideDefaultEmailVerification: true, }), genericOAuth({ - config: [ - { - providerId: 'google-email', - clientId: env.GOOGLE_CLIENT_ID as string, - clientSecret: env.GOOGLE_CLIENT_SECRET as string, - discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', - accessType: 'offline', - scopes: getCanonicalScopesForProvider('google-email'), - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-email`, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { - headers: { Authorization: `Bearer ${tokens.accessToken}` }, - }) - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Failed to fetch Google user info', { status: response.status }) - throw new Error(`Failed to fetch Google user info: ${response.statusText}`) - } - const profile = await response.json() - const now = new Date() - return { - id: `${profile.sub}-${generateId()}`, - name: profile.name || 'Google User', - email: profile.email, - image: profile.picture || undefined, - emailVerified: profile.email_verified || false, - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error in Google getUserInfo', { error }) - throw error - } - }, - }, - { - providerId: 'google-calendar', - clientId: env.GOOGLE_CLIENT_ID as string, - clientSecret: env.GOOGLE_CLIENT_SECRET as string, - discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', - accessType: 'offline', - scopes: getCanonicalScopesForProvider('google-calendar'), - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-calendar`, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { - headers: { Authorization: `Bearer ${tokens.accessToken}` }, - }) - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Failed to fetch Google user info', { status: response.status }) - throw new Error(`Failed to fetch Google user info: ${response.statusText}`) - } - const profile = await response.json() - const now = new Date() - return { - id: `${profile.sub}-${generateId()}`, - name: profile.name || 'Google User', - email: profile.email, - image: profile.picture || undefined, - emailVerified: profile.email_verified || false, - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error in Google getUserInfo', { error }) - throw error - } - }, - }, - { - providerId: 'google-drive', - clientId: env.GOOGLE_CLIENT_ID as string, - clientSecret: env.GOOGLE_CLIENT_SECRET as string, - discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', - accessType: 'offline', - scopes: getCanonicalScopesForProvider('google-drive'), - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-drive`, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { - headers: { Authorization: `Bearer ${tokens.accessToken}` }, - }) - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Failed to fetch Google user info', { status: response.status }) - throw new Error(`Failed to fetch Google user info: ${response.statusText}`) - } - const profile = await response.json() - const now = new Date() - return { - id: `${profile.sub}-${generateId()}`, - name: profile.name || 'Google User', - email: profile.email, - image: profile.picture || undefined, - emailVerified: profile.email_verified || false, - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error in Google getUserInfo', { error }) - throw error - } - }, - }, - { - providerId: 'google-docs', - clientId: env.GOOGLE_CLIENT_ID as string, - clientSecret: env.GOOGLE_CLIENT_SECRET as string, - discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', - accessType: 'offline', - scopes: getCanonicalScopesForProvider('google-docs'), - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-docs`, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { - headers: { Authorization: `Bearer ${tokens.accessToken}` }, - }) - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Failed to fetch Google user info', { status: response.status }) - throw new Error(`Failed to fetch Google user info: ${response.statusText}`) - } - const profile = await response.json() - const now = new Date() - return { - id: `${profile.sub}-${generateId()}`, - name: profile.name || 'Google User', - email: profile.email, - image: profile.picture || undefined, - emailVerified: profile.email_verified || false, - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error in Google getUserInfo', { error }) - throw error - } - }, - }, - { - providerId: 'google-sheets', - clientId: env.GOOGLE_CLIENT_ID as string, - clientSecret: env.GOOGLE_CLIENT_SECRET as string, - discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', - accessType: 'offline', - scopes: getCanonicalScopesForProvider('google-sheets'), - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-sheets`, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { - headers: { Authorization: `Bearer ${tokens.accessToken}` }, - }) - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Failed to fetch Google user info', { status: response.status }) - throw new Error(`Failed to fetch Google user info: ${response.statusText}`) - } - const profile = await response.json() - const now = new Date() - return { - id: `${profile.sub}-${generateId()}`, - name: profile.name || 'Google User', - email: profile.email, - image: profile.picture || undefined, - emailVerified: profile.email_verified || false, - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error in Google getUserInfo', { error }) - throw error - } - }, - }, - - { - providerId: 'google-contacts', - clientId: env.GOOGLE_CLIENT_ID as string, - clientSecret: env.GOOGLE_CLIENT_SECRET as string, - discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', - accessType: 'offline', - scopes: getCanonicalScopesForProvider('google-contacts'), - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-contacts`, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { - headers: { Authorization: `Bearer ${tokens.accessToken}` }, - }) - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Failed to fetch Google user info', { status: response.status }) - throw new Error(`Failed to fetch Google user info: ${response.statusText}`) - } - const profile = await response.json() - const now = new Date() - return { - id: `${profile.sub}-${generateId()}`, - name: profile.name || 'Google User', - email: profile.email, - image: profile.picture || undefined, - emailVerified: profile.email_verified || false, - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error in Google getUserInfo', { error }) - throw error - } - }, - }, - { - providerId: 'google-forms', - clientId: env.GOOGLE_CLIENT_ID as string, - clientSecret: env.GOOGLE_CLIENT_SECRET as string, - discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', - accessType: 'offline', - scopes: getCanonicalScopesForProvider('google-forms'), - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-forms`, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { - headers: { Authorization: `Bearer ${tokens.accessToken}` }, - }) - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Failed to fetch Google user info', { status: response.status }) - throw new Error(`Failed to fetch Google user info: ${response.statusText}`) - } - const profile = await response.json() - const now = new Date() - return { - id: `${profile.sub}-${generateId()}`, - name: profile.name || 'Google User', - email: profile.email, - image: profile.picture || undefined, - emailVerified: profile.email_verified || false, - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error in Google getUserInfo', { error }) - throw error - } - }, - }, - { - providerId: 'google-ads', - clientId: env.GOOGLE_CLIENT_ID as string, - clientSecret: env.GOOGLE_CLIENT_SECRET as string, - discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', - accessType: 'offline', - scopes: getCanonicalScopesForProvider('google-ads'), - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-ads`, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { - headers: { Authorization: `Bearer ${tokens.accessToken}` }, - }) - if (!response.ok) { - logger.error('Failed to fetch Google user info', { status: response.status }) - throw new Error(`Failed to fetch Google user info: ${response.statusText}`) - } - const profile = await response.json() - const now = new Date() - return { - id: `${profile.sub}-${generateId()}`, - name: profile.name || 'Google User', - email: profile.email, - image: profile.picture || undefined, - emailVerified: profile.email_verified || false, - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error in Google getUserInfo', { error }) - throw error - } - }, - }, - { - providerId: 'google-bigquery', - clientId: env.GOOGLE_CLIENT_ID as string, - clientSecret: env.GOOGLE_CLIENT_SECRET as string, - discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', - accessType: 'offline', - scopes: getCanonicalScopesForProvider('google-bigquery'), - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-bigquery`, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { - headers: { Authorization: `Bearer ${tokens.accessToken}` }, - }) - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Failed to fetch Google user info', { status: response.status }) - throw new Error(`Failed to fetch Google user info: ${response.statusText}`) - } - const profile = await response.json() - const now = new Date() - return { - id: `${profile.sub}-${generateId()}`, - name: profile.name || 'Google User', - email: profile.email, - image: profile.picture || undefined, - emailVerified: profile.email_verified || false, - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error in Google getUserInfo', { error }) - throw error - } - }, - }, - - { - providerId: 'google-vault', - clientId: env.GOOGLE_CLIENT_ID as string, - clientSecret: env.GOOGLE_CLIENT_SECRET as string, - discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', - accessType: 'offline', - scopes: getCanonicalScopesForProvider('google-vault'), - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-vault`, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { - headers: { Authorization: `Bearer ${tokens.accessToken}` }, - }) - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Failed to fetch Google user info', { status: response.status }) - throw new Error(`Failed to fetch Google user info: ${response.statusText}`) - } - const profile = await response.json() - const now = new Date() - return { - id: `${profile.sub}-${generateId()}`, - name: profile.name || 'Google User', - email: profile.email, - image: profile.picture || undefined, - emailVerified: profile.email_verified || false, - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error in Google getUserInfo', { error }) - throw error - } - }, - }, - - { - providerId: 'google-groups', - clientId: env.GOOGLE_CLIENT_ID as string, - clientSecret: env.GOOGLE_CLIENT_SECRET as string, - discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', - accessType: 'offline', - scopes: getCanonicalScopesForProvider('google-groups'), - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-groups`, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { - headers: { Authorization: `Bearer ${tokens.accessToken}` }, - }) - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Failed to fetch Google user info', { status: response.status }) - throw new Error(`Failed to fetch Google user info: ${response.statusText}`) - } - const profile = await response.json() - const now = new Date() - return { - id: `${profile.sub}-${generateId()}`, - name: profile.name || 'Google User', - email: profile.email, - image: profile.picture || undefined, - emailVerified: profile.email_verified || false, - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error in Google getUserInfo', { error }) - throw error - } - }, - }, - - { - providerId: 'google-meet', - clientId: env.GOOGLE_CLIENT_ID as string, - clientSecret: env.GOOGLE_CLIENT_SECRET as string, - discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', - accessType: 'offline', - scopes: getCanonicalScopesForProvider('google-meet'), - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-meet`, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { - headers: { Authorization: `Bearer ${tokens.accessToken}` }, - }) - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Failed to fetch Google user info', { status: response.status }) - throw new Error(`Failed to fetch Google user info: ${response.statusText}`) - } - const profile = await response.json() - const now = new Date() - return { - id: `${profile.sub}-${generateId()}`, - name: profile.name || 'Google User', - email: profile.email, - image: profile.picture || undefined, - emailVerified: profile.email_verified || false, - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error in Google getUserInfo', { error }) - throw error - } - }, - }, - { - providerId: 'google-tasks', - clientId: env.GOOGLE_CLIENT_ID as string, - clientSecret: env.GOOGLE_CLIENT_SECRET as string, - discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', - accessType: 'offline', - scopes: getCanonicalScopesForProvider('google-tasks'), - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-tasks`, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { - headers: { Authorization: `Bearer ${tokens.accessToken}` }, - }) - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Failed to fetch Google user info', { status: response.status }) - throw new Error(`Failed to fetch Google user info: ${response.statusText}`) - } - const profile = await response.json() - const now = new Date() - return { - id: `${profile.sub}-${generateId()}`, - name: profile.name || 'Google User', - email: profile.email, - image: profile.picture || undefined, - emailVerified: profile.email_verified || false, - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error in Google getUserInfo', { error }) - throw error - } - }, - }, - - { - providerId: 'vertex-ai', - clientId: env.GOOGLE_CLIENT_ID as string, - clientSecret: env.GOOGLE_CLIENT_SECRET as string, - discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', - accessType: 'offline', - scopes: getCanonicalScopesForProvider('vertex-ai'), - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/vertex-ai`, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { - headers: { Authorization: `Bearer ${tokens.accessToken}` }, - }) - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Failed to fetch Google user info', { status: response.status }) - throw new Error(`Failed to fetch Google user info: ${response.statusText}`) - } - const profile = await response.json() - const now = new Date() - return { - id: `${profile.sub}-${generateId()}`, - name: profile.name || 'Google User', - email: profile.email, - image: profile.picture || undefined, - emailVerified: profile.email_verified || false, - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error in Google getUserInfo', { error }) - throw error - } - }, - }, - - { - providerId: 'microsoft-ad', - clientId: env.MICROSOFT_CLIENT_ID as string, - clientSecret: env.MICROSOFT_CLIENT_SECRET as string, - authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', - tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', - userInfoUrl: 'https://graph.microsoft.com/v1.0/me', - scopes: getCanonicalScopesForProvider('microsoft-ad'), - responseType: 'code', - accessType: 'offline', - authentication: 'basic', - pkce: true, - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/microsoft-ad`, - getUserInfo: async (tokens) => { - return getMicrosoftUserInfoFromIdToken(tokens, 'microsoft-ad') - }, - }, - - { - providerId: 'microsoft-teams', - clientId: env.MICROSOFT_CLIENT_ID as string, - clientSecret: env.MICROSOFT_CLIENT_SECRET as string, - authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', - tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', - userInfoUrl: 'https://graph.microsoft.com/v1.0/me', - scopes: getCanonicalScopesForProvider('microsoft-teams'), - responseType: 'code', - accessType: 'offline', - authentication: 'basic', - pkce: true, - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/microsoft-teams`, - getUserInfo: async (tokens) => { - return getMicrosoftUserInfoFromIdToken(tokens, 'microsoft-teams') - }, - }, - - { - providerId: 'microsoft-excel', - clientId: env.MICROSOFT_CLIENT_ID as string, - clientSecret: env.MICROSOFT_CLIENT_SECRET as string, - authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', - tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', - userInfoUrl: 'https://graph.microsoft.com/v1.0/me', - scopes: getCanonicalScopesForProvider('microsoft-excel'), - responseType: 'code', - accessType: 'offline', - authentication: 'basic', - pkce: true, - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/microsoft-excel`, - getUserInfo: async (tokens) => { - return getMicrosoftUserInfoFromIdToken(tokens, 'microsoft-excel') - }, - }, - { - providerId: 'microsoft-dataverse', - clientId: env.MICROSOFT_CLIENT_ID as string, - clientSecret: env.MICROSOFT_CLIENT_SECRET as string, - authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', - tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', - userInfoUrl: 'https://graph.microsoft.com/v1.0/me', - scopes: getCanonicalScopesForProvider('microsoft-dataverse'), - responseType: 'code', - accessType: 'offline', - authentication: 'basic', - pkce: true, - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/microsoft-dataverse`, - getUserInfo: async (tokens) => { - return getMicrosoftUserInfoFromIdToken(tokens, 'microsoft-dataverse') - }, - }, - { - providerId: 'microsoft-planner', - clientId: env.MICROSOFT_CLIENT_ID as string, - clientSecret: env.MICROSOFT_CLIENT_SECRET as string, - authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', - tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', - userInfoUrl: 'https://graph.microsoft.com/v1.0/me', - scopes: getCanonicalScopesForProvider('microsoft-planner'), - responseType: 'code', - accessType: 'offline', - authentication: 'basic', - pkce: true, - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/microsoft-planner`, - getUserInfo: async (tokens) => { - return getMicrosoftUserInfoFromIdToken(tokens, 'microsoft-planner') - }, - }, - - { - providerId: 'outlook', - clientId: env.MICROSOFT_CLIENT_ID as string, - clientSecret: env.MICROSOFT_CLIENT_SECRET as string, - authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', - tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', - userInfoUrl: 'https://graph.microsoft.com/v1.0/me', - scopes: getCanonicalScopesForProvider('outlook'), - responseType: 'code', - accessType: 'offline', - authentication: 'basic', - pkce: true, - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/outlook`, - getUserInfo: async (tokens) => { - return getMicrosoftUserInfoFromIdToken(tokens, 'outlook') - }, - }, - - { - providerId: 'onedrive', - clientId: env.MICROSOFT_CLIENT_ID as string, - clientSecret: env.MICROSOFT_CLIENT_SECRET as string, - authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', - tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', - userInfoUrl: 'https://graph.microsoft.com/v1.0/me', - scopes: getCanonicalScopesForProvider('onedrive'), - responseType: 'code', - accessType: 'offline', - authentication: 'basic', - pkce: true, - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/onedrive`, - getUserInfo: async (tokens) => { - return getMicrosoftUserInfoFromIdToken(tokens, 'onedrive') - }, - }, - - { - providerId: 'sharepoint', - clientId: env.MICROSOFT_CLIENT_ID as string, - clientSecret: env.MICROSOFT_CLIENT_SECRET as string, - authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', - tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', - userInfoUrl: 'https://graph.microsoft.com/v1.0/me', - scopes: getCanonicalScopesForProvider('sharepoint'), - responseType: 'code', - accessType: 'offline', - authentication: 'basic', - pkce: true, - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/sharepoint`, - getUserInfo: async (tokens) => { - return getMicrosoftUserInfoFromIdToken(tokens, 'sharepoint') - }, - }, - - { - providerId: 'wealthbox', - clientId: env.WEALTHBOX_CLIENT_ID as string, - clientSecret: env.WEALTHBOX_CLIENT_SECRET as string, - authorizationUrl: 'https://app.crmworkspace.com/oauth/authorize', - tokenUrl: 'https://app.crmworkspace.com/oauth/token', - userInfoUrl: 'https://api.crmworkspace.com/v1/me', - scopes: getCanonicalScopesForProvider('wealthbox'), - responseType: 'code', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/wealthbox`, - getUserInfo: async (tokens) => { - try { - logger.info('Fetching Wealthbox user profile') - - const response = await fetch('https://api.crmworkspace.com/v1/me', { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, - }) - - const now = new Date() - - if (response.ok) { - const data = await response.json() - const userId = data.id?.toString() - if (!userId) { - return null - } - const email = - data.email && typeof data.email === 'string' - ? data.email - : syntheticConnectorEmail('wealthbox', userId) - const name = data.name || data.full_name || data.username || 'Wealthbox User' - - return { - id: `wealthbox-${userId}-${generateId()}`, - name, - email, - emailVerified: false, - createdAt: now, - updatedAt: now, - } - } - - // Fallback: derive a stable identifier from the refresh token (long-lived) - // rather than the access token (rotates every ~2 hours) to avoid creating - // duplicate accounts on token refresh. - logger.warn( - 'Wealthbox user info fetch failed, falling back to token-derived identity', - { - status: response.status, - } - ) - const stableToken = tokens.refreshToken ?? tokens.accessToken - if (!stableToken) { - logger.error('Wealthbox fallback identity: no refresh or access token available') - return null - } - const tokenHash = createHash('sha256').update(stableToken).digest('hex').slice(0, 24) - return { - id: `wealthbox-${tokenHash}-${generateId()}`, - name: 'Wealthbox User', - email: syntheticConnectorEmail('wealthbox', tokenHash), - emailVerified: false, - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error creating Wealthbox user profile:', { - error: toError(error).message, - }) - return null - } - }, - }, - - { - providerId: 'pipedrive', - clientId: env.PIPEDRIVE_CLIENT_ID as string, - clientSecret: env.PIPEDRIVE_CLIENT_SECRET as string, - authorizationUrl: 'https://oauth.pipedrive.com/oauth/authorize', - tokenUrl: 'https://oauth.pipedrive.com/oauth/token', - userInfoUrl: 'https://api.pipedrive.com/v1/users/me', - prompt: 'consent', - scopes: getCanonicalScopesForProvider('pipedrive'), - responseType: 'code', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/pipedrive`, - getUserInfo: async (tokens) => { - try { - logger.info('Fetching Pipedrive user profile') - - const response = await fetch('https://api.pipedrive.com/v1/users/me', { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, - }) - - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Failed to fetch Pipedrive user info', { - status: response.status, - }) - throw new Error('Failed to fetch user info') - } - - const data = await response.json() - const user = data.data - - return { - id: `${user.id.toString()}-${generateId()}`, - name: user.name, - email: user.email, - emailVerified: user.activated, - image: user.icon_url, - createdAt: new Date(), - updatedAt: new Date(), - } - } catch (error) { - logger.error('Error creating Pipedrive user profile:', { error }) - return null - } - }, - }, - - { - providerId: 'hubspot', - clientId: env.HUBSPOT_CLIENT_ID as string, - clientSecret: env.HUBSPOT_CLIENT_SECRET as string, - authorizationUrl: 'https://app.hubspot.com/oauth/authorize', - tokenUrl: 'https://api.hubapi.com/oauth/v1/token', - userInfoUrl: 'https://api.hubapi.com/oauth/v1/access-tokens', - prompt: 'consent', - scopes: getCanonicalScopesForProvider('hubspot'), - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/hubspot`, - getUserInfo: async (tokens) => { - try { - logger.info('Fetching HubSpot user profile') - - const response = await fetch( - `https://api.hubapi.com/oauth/v1/access-tokens/${tokens.accessToken}` - ) - - if (!response.ok) { - let errorBody: string | undefined - try { - errorBody = await response.text() - } catch { - // ignore - } - logger.error('Failed to fetch HubSpot user info', { - status: response.status, - statusText: response.statusText, - body: errorBody?.slice(0, 500), - }) - throw new Error('Failed to fetch user info') - } - - const rawText = await response.text() - const data = JSON.parse(rawText) - - const scopesArray = Array.isArray((data as any)?.scopes) ? (data as any).scopes : [] - if (Array.isArray(scopesArray) && scopesArray.length > 0) { - tokens.scopes = scopesArray - } else if (typeof (data as any)?.scope === 'string') { - tokens.scopes = (data as any).scope.split(/\s+/).filter(Boolean) - } - - logger.info('HubSpot token metadata response:', { - hubId: data.hub_id, - hubDomain: data.hub_domain, - userId: data.user_id, - hasScopes: !!data.scopes, - scopesType: typeof data.scopes, - scopesIsArray: Array.isArray(data.scopes), - }) - - return { - id: `${(data.user_id || data.hub_id).toString()}-${generateId()}`, - name: data.user || 'HubSpot User', - email: data.user || syntheticConnectorEmail('hubspot', data.hub_id), - emailVerified: true, - image: undefined, - createdAt: new Date(), - updatedAt: new Date(), - // Extract scopes from HubSpot's response and convert array to space-delimited string - // Use 'scope' (singular) as that's what better-auth expects for the account table - ...(data.scopes && Array.isArray(data.scopes) - ? { scope: data.scopes.join(' ') } - : {}), - } - } catch (error) { - logger.error('Error creating HubSpot user profile:', { error }) - return null - } - }, - }, - - { - providerId: 'salesforce', - clientId: env.SALESFORCE_CLIENT_ID as string, - clientSecret: env.SALESFORCE_CLIENT_SECRET as string, - authorizationUrl: 'https://login.salesforce.com/services/oauth2/authorize', - tokenUrl: 'https://login.salesforce.com/services/oauth2/token', - userInfoUrl: 'https://login.salesforce.com/services/oauth2/userinfo', - scopes: getCanonicalScopesForProvider('salesforce'), - pkce: true, - prompt: 'consent', - accessType: 'offline', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/salesforce`, - getUserInfo: async (tokens) => { - try { - const response = await fetch( - 'https://login.salesforce.com/services/oauth2/userinfo', - { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, - } - ) - - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Failed to fetch Salesforce user info', { - status: response.status, - }) - throw new Error('Failed to fetch user info') - } - - const data = await response.json() - - return { - id: `${(data.user_id || data.sub).toString()}-${generateId()}`, - name: data.name || 'Salesforce User', - email: - data.email || syntheticConnectorEmail('salesforce', data.user_id ?? data.sub), - emailVerified: data.email_verified === true, - image: data.picture || undefined, - createdAt: new Date(), - updatedAt: new Date(), - } - } catch (error) { - logger.error('Error creating Salesforce user profile:', { error }) - return null - } - }, - }, - - { - providerId: 'zoho-desk', - clientId: env.ZOHO_CLIENT_ID as string, - clientSecret: env.ZOHO_CLIENT_SECRET as string, - authorizationUrl: 'https://accounts.zoho.com/oauth/v2/auth', - tokenUrl: 'https://accounts.zoho.com/oauth/v2/token', - scopes: getCanonicalScopesForProvider('zoho-desk'), - responseType: 'code', - pkce: true, - accessType: 'offline', - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/zoho-desk`, - // Zoho only issues a refresh token when access_type=offline AND - // prompt=consent are present on the authorize request, and it expects - // comma-separated scopes rather than the default space-delimited list. - authorizationUrlParams: { - access_type: 'offline', - prompt: 'consent', - scope: getCanonicalScopesForProvider('zoho-desk').join(','), - }, - getToken: async ({ code, redirectURI, codeVerifier }) => { - const tokenParams = new URLSearchParams({ - client_id: env.ZOHO_CLIENT_ID as string, - client_secret: env.ZOHO_CLIENT_SECRET as string, - code, - grant_type: 'authorization_code', - redirect_uri: redirectURI, - }) - // PKCE is enabled, so better-auth sent a code_challenge on the authorize - // request. The exchange MUST echo the matching code_verifier or Zoho - // rejects the request shape (invalid_request). Verified by isolating - // pkce:false (which connected) then restoring pkce:true + this verifier. - if (codeVerifier) tokenParams.set('code_verifier', codeVerifier) - - const response = await fetch('https://accounts.zoho.com/oauth/v2/token', { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: tokenParams, - }) - const data = await readResponseJsonWithLimit>(response, { - maxBytes: 1024 * 1024, - label: 'Zoho Desk OAuth token response', - }) - - // Zoho signals OAuth failures in the JSON body, usually with HTTP 200, - // e.g. { error: 'invalid_code' } or { error: 'invalid_client', - // error_description: '...' }. The status-only guard therefore never - // fires, so surface the actual error/description instead of collapsing - // every failure into one opaque "no access token" string. - const errorObj = - data && typeof data === 'object' && !Array.isArray(data) - ? (data as { error?: unknown; error_description?: unknown }) - : {} - const zohoError = typeof errorObj.error === 'string' ? errorObj.error : undefined - const zohoErrorDescription = - typeof errorObj.error_description === 'string' - ? errorObj.error_description - : undefined - if ( - !response.ok || - !data || - typeof data !== 'object' || - Array.isArray(data) || - zohoError - ) { - logger.error('Zoho Desk OAuth token exchange failed', { - status: response.status, - zohoError: zohoError ?? null, - zohoErrorDescription: zohoErrorDescription ?? null, - }) - throw new Error( - `Zoho Desk OAuth token exchange failed (HTTP ${response.status}${ - zohoError ? `, ${zohoError}` : '' - }${zohoErrorDescription ? `: ${zohoErrorDescription}` : ''})` - ) - } - - const tokens = getOAuth2Tokens(data) - if (!tokens.accessToken) { - logger.error('Zoho Desk OAuth token response had no access token', { - status: response.status, - bodyKeys: Object.keys(data), - }) - throw new Error('Zoho Desk OAuth token response did not include an access token') - } - - // Persist the data-center-scoped Desk REST base derived from the - // token response api_domain so every API call targets the correct - // host instead of assuming desk.zoho.com. Stored inside the scope - // string (survives refreshes, which never rewrite scope) and read - // back in /api/auth/oauth/token as `apiDomain`. - const deskBase = deriveZohoDeskBaseFromApiDomain( - typeof data.api_domain === 'string' ? data.api_domain : undefined - ) - // Zoho's docs are inconsistent about whether the Desk token response - // carries `scope` (the Mail sample has it; the CRM/Creator samples do - // not). If it is absent, fall back to the scopes we requested and were - // granted by completing the flow - otherwise the stored scope list is - // just the domain marker, and the credential picker would show a - // permanent "needs update / reconnect" badge on every connection. - // Mirrors the existing Box fallback in this file. - const reportedScopes = - typeof data.scope === 'string' ? data.scope.split(/[\s,]+/).filter(Boolean) : [] - const grantedScopes = reportedScopes.length - ? reportedScopes - : getCanonicalScopesForProvider('zoho-desk') - tokens.scopes = [`__zoho_domain__:${deskBase}`, ...grantedScopes] - return tokens - }, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://accounts.zoho.com/oauth/user/info', { - headers: { Authorization: `Zoho-oauthtoken ${tokens.accessToken}` }, - }) - - if (!response.ok) { - await readResponseTextWithLimit(response, { - maxBytes: 1024 * 1024, - label: 'Zoho Desk profile error response', - }).catch(() => {}) - logger.error('Error fetching Zoho Desk user info:', { - status: response.status, - statusText: response.statusText, - }) - return null - } - - const profile = await readResponseJsonWithLimit<{ - ZUID?: number | string - Display_Name?: string - Email?: string - }>(response, { maxBytes: 1024 * 1024, label: 'Zoho Desk profile response' }) - - const zuid = profile.ZUID?.toString() - if (!zuid) { - logger.error('Invalid Zoho Desk profile response:', profile) - return null - } - - const now = new Date() - return { - id: `${zuid}-${generateId()}`, - name: profile.Display_Name || 'Zoho User', - email: profile.Email || syntheticConnectorEmail('zoho', zuid), - emailVerified: Boolean(profile.Email), - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error in Zoho Desk getUserInfo:', { error }) - return null - } - }, - }, - - { - providerId: 'x', - clientId: env.X_CLIENT_ID as string, - clientSecret: env.X_CLIENT_SECRET as string, - authorizationUrl: 'https://x.com/i/oauth2/authorize', - tokenUrl: 'https://api.x.com/2/oauth2/token', - userInfoUrl: 'https://api.x.com/2/users/me', - accessType: 'offline', - scopes: getCanonicalScopesForProvider('x'), - pkce: true, - responseType: 'code', - prompt: 'consent', - authentication: 'basic', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/x`, - getUserInfo: async (tokens) => { - try { - const response = await fetch( - 'https://api.x.com/2/users/me?user.fields=profile_image_url,username,name,verified', - { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, - } - ) - - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Error fetching X user info:', { - status: response.status, - statusText: response.statusText, - }) - return null - } - - const profile = await response.json() - - if (!profile.data) { - logger.error('Invalid X profile response:', profile) - return null - } - - const now = new Date() - - return { - id: `${profile.data.id.toString()}-${generateId()}`, - name: profile.data.name || 'X User', - email: syntheticConnectorEmail('x', profile.data.username ?? profile.data.id), - image: profile.data.profile_image_url, - emailVerified: profile.data.verified || false, - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error in X getUserInfo:', { error }) - return null - } - }, - }, - - { - providerId: 'tiktok', - clientId: env.TIKTOK_CLIENT_ID as string, - clientSecret: env.TIKTOK_CLIENT_SECRET as string, - authorizationUrl: 'https://www.tiktok.com/v2/auth/authorize/', - tokenUrl: 'https://open.tiktokapis.com/v2/oauth/token/', - scopes: getCanonicalScopesForProvider('tiktok'), - responseType: 'code', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/tiktok`, - authorizationUrlParams: { - client_key: env.TIKTOK_CLIENT_ID as string, - scope: getCanonicalScopesForProvider('tiktok').join(','), - }, - getToken: async ({ code, redirectURI }) => { - const response = await fetch('https://open.tiktokapis.com/v2/oauth/token/', { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - client_key: env.TIKTOK_CLIENT_ID as string, - client_secret: env.TIKTOK_CLIENT_SECRET as string, - code, - grant_type: 'authorization_code', - redirect_uri: redirectURI, - }), - }) - const data = await readResponseJsonWithLimit>(response, { - maxBytes: 1024 * 1024, - label: 'TikTok OAuth token response', - }) - - if (!response.ok || !data || typeof data !== 'object' || Array.isArray(data)) { - throw new Error(`TikTok OAuth token exchange failed with HTTP ${response.status}`) - } - - const tokens = getOAuth2Tokens(data) - if (!tokens.accessToken) { - throw new Error('TikTok OAuth token response did not include an access token') - } - if (typeof data.scope === 'string') { - tokens.scopes = data.scope.split(/[\s,]+/).filter(Boolean) - } - return tokens - }, - getUserInfo: async (tokens) => { - try { - const response = await fetch( - 'https://open.tiktokapis.com/v2/user/info/?fields=open_id,display_name,avatar_url', - { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, - } - ) - - if (!response.ok) { - await readResponseTextWithLimit(response, { - maxBytes: 1024 * 1024, - label: 'TikTok profile error response', - }).catch(() => {}) - logger.error('Error fetching TikTok user info:', { - status: response.status, - statusText: response.statusText, - }) - return null - } - - const profile = await readResponseJsonWithLimit<{ - data?: { - user?: { - avatar_url?: string - display_name?: string - open_id?: string - } - } - }>(response, { - maxBytes: 1024 * 1024, - label: 'TikTok profile response', - }) - const user = profile.data?.user - - if (!user?.open_id) { - logger.error('Invalid TikTok profile response:', profile) - return null - } - - const now = new Date() - - return { - id: `${user.open_id}-${generateId()}`, - name: user.display_name || 'TikTok User', - email: syntheticConnectorEmail('tiktok', user.open_id), - image: user.avatar_url || undefined, - emailVerified: false, - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error in TikTok getUserInfo:', { error }) - return null - } - }, - }, - - { - providerId: 'confluence', - clientId: env.CONFLUENCE_CLIENT_ID as string, - clientSecret: env.CONFLUENCE_CLIENT_SECRET as string, - authorizationUrl: 'https://auth.atlassian.com/authorize', - tokenUrl: 'https://auth.atlassian.com/oauth/token', - userInfoUrl: 'https://api.atlassian.com/me', - scopes: getCanonicalScopesForProvider('confluence'), - responseType: 'code', - pkce: true, - accessType: 'offline', - authentication: 'basic', - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/confluence`, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://api.atlassian.com/me', { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, - }) - - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Error fetching Confluence user info:', { - status: response.status, - statusText: response.statusText, - }) - return null - } - - const profile = await response.json() - - const now = new Date() - - return { - id: `${profile.account_id.toString()}-${generateId()}`, - name: profile.name || profile.display_name || 'Confluence User', - email: profile.email || syntheticConnectorEmail('confluence', profile.account_id), - image: profile.picture || undefined, - emailVerified: true, - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error in Confluence getUserInfo:', { error }) - return null - } - }, - }, - - { - providerId: 'jira', - clientId: env.JIRA_CLIENT_ID as string, - clientSecret: env.JIRA_CLIENT_SECRET as string, - authorizationUrl: 'https://auth.atlassian.com/authorize', - tokenUrl: 'https://auth.atlassian.com/oauth/token', - userInfoUrl: 'https://api.atlassian.com/me', - scopes: getCanonicalScopesForProvider('jira'), - responseType: 'code', - pkce: true, - accessType: 'offline', - authentication: 'basic', - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/jira`, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://api.atlassian.com/me', { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, - }) - - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Error fetching Jira user info:', { - status: response.status, - statusText: response.statusText, - }) - return null - } - - const profile = await response.json() - - const now = new Date() - - return { - id: `${profile.account_id.toString()}-${generateId()}`, - name: profile.name || profile.display_name || 'Jira User', - email: profile.email || syntheticConnectorEmail('jira', profile.account_id), - image: profile.picture || undefined, - emailVerified: true, - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error in Jira getUserInfo:', { error }) - return null - } - }, - }, - - { - providerId: 'airtable', - clientId: env.AIRTABLE_CLIENT_ID as string, - clientSecret: env.AIRTABLE_CLIENT_SECRET as string, - authorizationUrl: 'https://airtable.com/oauth2/v1/authorize', - tokenUrl: 'https://airtable.com/oauth2/v1/token', - userInfoUrl: 'https://api.airtable.com/v0/meta/whoami', - scopes: getCanonicalScopesForProvider('airtable'), - responseType: 'code', - pkce: true, - accessType: 'offline', - authentication: 'basic', - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/airtable`, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://api.airtable.com/v0/meta/whoami', { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, - }) - - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Error fetching Airtable user info:', { - status: response.status, - statusText: response.statusText, - }) - return null - } - - const data = await response.json() - const now = new Date() - - return { - id: `${data.id.toString()}-${generateId()}`, - name: data.email ? data.email.split('@')[0] : 'Airtable User', - email: data.email || syntheticConnectorEmail('airtable', data.id), - emailVerified: !!data.email, - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error in Airtable getUserInfo:', { error }) - return null - } - }, - }, - - { - providerId: 'notion', - clientId: env.NOTION_CLIENT_ID as string, - clientSecret: env.NOTION_CLIENT_SECRET as string, - authorizationUrl: 'https://api.notion.com/v1/oauth/authorize', - tokenUrl: 'https://api.notion.com/v1/oauth/token', - userInfoUrl: 'https://api.notion.com/v1/users/me', - responseType: 'code', - pkce: false, - accessType: 'offline', - authentication: 'basic', - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/notion`, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://api.notion.com/v1/users/me', { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - 'Notion-Version': '2022-06-28', - }, - }) - - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Error fetching Notion user info:', { - status: response.status, - statusText: response.statusText, - }) - return null - } - - const profile: NotionSelfResponse = await response.json() - const now = new Date() - - /** - * An OAuth integration token always resolves to a bot user, so the - * top-level `person` is never present and the top-level `name` is the - * integration's own name ("Sim"), not the human's. The authorizing - * human — and their email — live under `bot.owner.user`, which is - * only populated when `bot.owner.type === 'user'` (a workspace-owned - * internal integration reports `{ type: 'workspace' }` instead). - * @see https://developers.notion.com/reference/get-self - */ - const ownerUser = profile.bot?.owner?.type === 'user' ? profile.bot.owner.user : null - const stableId = ownerUser?.id || profile.id - const ownerEmail = ownerUser?.person?.email - - return { - id: `${stableId}-${generateId()}`, - name: ownerUser?.name || profile.name || 'Notion User', - email: ownerEmail || syntheticConnectorEmail('notion', stableId), - emailVerified: !!ownerEmail, - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error in Notion getUserInfo:', { error }) - return null - } - }, - }, - - { - 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', - userInfoUrl: 'https://api.monday.com/v2', - scopes: getCanonicalScopesForProvider('monday'), - responseType: 'code', - pkce: false, - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/monday`, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://api.monday.com/v2', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'API-Version': '2024-10', - Authorization: tokens.accessToken ?? '', - }, - body: JSON.stringify({ query: '{ me { id name email } }' }), - }) - - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Error fetching Monday.com user info:', { - status: response.status, - statusText: response.statusText, - }) - return null - } - - const data = await response.json() - const user = data.data?.me - if (!user) return null - - 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, - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error in Monday.com getUserInfo:', { error }) - return null - } - }, - }, - - { - providerId: 'reddit', - clientId: env.REDDIT_CLIENT_ID as string, - clientSecret: env.REDDIT_CLIENT_SECRET as string, - authorizationUrl: 'https://www.reddit.com/api/v1/authorize?duration=permanent', - tokenUrl: 'https://www.reddit.com/api/v1/access_token', - userInfoUrl: 'https://oauth.reddit.com/api/v1/me', - scopes: getCanonicalScopesForProvider('reddit'), - responseType: 'code', - pkce: false, - accessType: 'offline', - authentication: 'basic', - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/reddit`, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://oauth.reddit.com/api/v1/me', { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - 'User-Agent': 'sim-studio/1.0', - }, - }) - - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Error fetching Reddit user info:', { - status: response.status, - statusText: response.statusText, - }) - return null - } - - const data = await response.json() - const now = new Date() - - return { - id: `${data.id.toString()}-${generateId()}`, - name: data.name || 'Reddit User', - email: syntheticConnectorEmail('reddit', data.name ?? data.id), - image: data.icon_img || undefined, - emailVerified: false, - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error in Reddit getUserInfo:', { error }) - return null - } - }, - }, - - { - providerId: 'clickup', - clientId: env.CLICKUP_CLIENT_ID as string, - clientSecret: env.CLICKUP_CLIENT_SECRET as string, - authorizationUrl: 'https://app.clickup.com/api', - tokenUrl: 'https://api.clickup.com/api/v2/oauth/token', - scopes: getCanonicalScopesForProvider('clickup'), - responseType: 'code', - pkce: false, - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/clickup`, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://api.clickup.com/api/v2/user', { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - 'Content-Type': 'application/json', - }, - }) - - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Error fetching ClickUp user info:', { - status: response.status, - statusText: response.statusText, - }) - return null - } - - const data = await response.json() - const user = data.user - if (!user?.id) return null - - const now = new Date() - return { - id: `${user.id.toString()}-${generateId()}`, - name: user.username || 'ClickUp User', - email: user.email || syntheticConnectorEmail('clickup', user.id), - emailVerified: !!user.email, - createdAt: now, - updatedAt: now, - image: user.profilePicture || undefined, - } - } catch (error) { - logger.error('Error in ClickUp getUserInfo:', { error }) - return null - } - }, - }, - - { - providerId: 'linear', - clientId: env.LINEAR_CLIENT_ID as string, - clientSecret: env.LINEAR_CLIENT_SECRET as string, - authorizationUrl: 'https://linear.app/oauth/authorize', - tokenUrl: 'https://api.linear.app/oauth/token', - scopes: getCanonicalScopesForProvider('linear'), - responseType: 'code', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/linear`, - pkce: true, - prompt: 'consent', - accessType: 'offline', - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://api.linear.app/graphql', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${tokens.accessToken}`, - }, - body: JSON.stringify({ - query: `{ - viewer { - id - email - name - avatarUrl - } - }`, - }), - }) - - if (!response.ok) { - const errorText = await response.text() - logger.error('Linear API error:', { - status: response.status, - statusText: response.statusText, - body: errorText, - }) - throw new Error(`Linear API error: ${response.status} ${response.statusText}`) - } - - const { data, errors } = await response.json() - - if (errors) { - logger.error('GraphQL errors:', errors) - throw new Error(`GraphQL errors: ${JSON.stringify(errors)}`) - } - - if (!data?.viewer) { - logger.error('No viewer data in response:', data) - throw new Error('No viewer data in response') - } - - const viewer = data.viewer - - return { - id: `${viewer.id.toString()}-${generateId()}`, - email: viewer.email || syntheticConnectorEmail('linear', viewer.id), - name: viewer.name, - emailVerified: true, - createdAt: new Date(), - updatedAt: new Date(), - image: viewer.avatarUrl || undefined, - } - } catch (error) { - logger.error('Error in getUserInfo:', error) - throw error - } - }, - }, - - { - providerId: 'attio', - clientId: env.ATTIO_CLIENT_ID as string, - clientSecret: env.ATTIO_CLIENT_SECRET as string, - authorizationUrl: 'https://app.attio.com/authorize', - tokenUrl: 'https://app.attio.com/oauth/token', - scopes: getCanonicalScopesForProvider('attio'), - responseType: 'code', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/attio`, - getUserInfo: async (tokens) => { - try { - /** - * Resolve the *authorizing* member, not an arbitrary one. Listing - * `/v2/workspace_members` returns every member of the workspace in no - * defined order, so taking `data[0]` records a stranger's id as the - * account's stable external id — which then collapses two different - * Attio members into one account row via the stale-sibling dedupe in - * the `account.create.after` hook. - * - * `/v2/self` requires no scope and reports who authorized the token. - * @see https://docs.attio.com/rest-api/endpoint-reference/meta/identify - */ - const selfResponse = await fetch('https://api.attio.com/v2/self', { - headers: { Authorization: `Bearer ${tokens.accessToken}` }, - }) - - if (!selfResponse.ok) { - const errorText = await selfResponse.text().catch(() => '') - logger.error('Attio /v2/self error:', { - status: selfResponse.status, - statusText: selfResponse.statusText, - body: errorText, - }) - return null - } - - const self: AttioSelfResponse = await selfResponse.json() - const memberId = self.authorized_by_workspace_member_id - - if (!memberId) { - logger.error('Attio /v2/self returned no authorizing workspace member', { - active: self.active, - workspaceId: self.workspace_id, - }) - return null - } - - /** - * Fetch that member by id rather than listing and filtering. Requires - * `user_management:read`, which Sim always requests for Attio. - * @see https://docs.attio.com/rest-api/endpoint-reference/workspace-members/get-a-workspace-member - */ - const memberResponse = await fetch( - `https://api.attio.com/v2/workspace_members/${encodeURIComponent(memberId)}`, - { headers: { Authorization: `Bearer ${tokens.accessToken}` } } - ) - - if (!memberResponse.ok) { - const errorText = await memberResponse.text().catch(() => '') - logger.error('Attio workspace member fetch error:', { - status: memberResponse.status, - statusText: memberResponse.statusText, - body: errorText, - }) - return null - } - - const { data: member }: AttioWorkspaceMemberResponse = await memberResponse.json() - - if (!member) { - logger.error('Attio workspace member not found', { memberId }) - return null - } - - const email = member.email_address - const fullName = `${member.first_name ?? ''} ${member.last_name ?? ''}`.trim() - - return { - id: `${member.id.workspace_member_id}-${generateId()}`, - email: email || syntheticConnectorEmail('attio', member.id.workspace_member_id), - name: fullName || email || 'Attio User', - emailVerified: Boolean(email), - createdAt: new Date(), - updatedAt: new Date(), - image: member.avatar_url || undefined, - } - } catch (error) { - /** - * Return null rather than rethrowing: Better Auth's `handleUserInfo` - * does not wrap `getUserInfo`, so a throw escapes the callback route - * as a raw 500 with no way back into the app, while null redirects - * with `user_info_is_missing`. - */ - logger.error('Error in Attio getUserInfo:', error) - return null - } - }, - }, - - { - providerId: 'box', - clientId: env.BOX_CLIENT_ID as string, - clientSecret: env.BOX_CLIENT_SECRET as string, - authorizationUrl: 'https://account.box.com/api/oauth2/authorize', - tokenUrl: 'https://api.box.com/oauth2/token', - scopes: getCanonicalScopesForProvider('box'), - responseType: 'code', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/box`, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://api.box.com/2.0/users/me', { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, - }) - - if (!response.ok) { - const errorText = await response.text() - logger.error('Box API error:', { - status: response.status, - statusText: response.statusText, - body: errorText, - }) - throw new Error(`Box API error: ${response.status} ${response.statusText}`) - } - - const data = await response.json() - - return { - id: `${data.id}-${generateId()}`, - email: data.login || syntheticConnectorEmail('box', data.id), - name: data.name || data.login || 'Box User', - emailVerified: true, - createdAt: new Date(), - updatedAt: new Date(), - image: data.avatar_url || undefined, - } - } catch (error) { - logger.error('Error in Box getUserInfo:', error) - throw error - } - }, - }, - - { - providerId: 'dropbox', - clientId: env.DROPBOX_CLIENT_ID as string, - clientSecret: env.DROPBOX_CLIENT_SECRET as string, - authorizationUrl: 'https://www.dropbox.com/oauth2/authorize', - tokenUrl: 'https://api.dropboxapi.com/oauth2/token', - scopes: getCanonicalScopesForProvider('dropbox'), - responseType: 'code', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/dropbox`, - pkce: true, - accessType: 'offline', - prompt: 'consent', - authorizationUrlParams: { - token_access_type: 'offline', - }, - getUserInfo: async (tokens) => { - try { - const response = await fetch( - 'https://api.dropboxapi.com/2/users/get_current_account', - { - method: 'POST', - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, - } - ) - - if (!response.ok) { - const errorText = await response.text() - logger.error('Dropbox API error:', { - status: response.status, - statusText: response.statusText, - body: errorText, - }) - throw new Error(`Dropbox API error: ${response.status} ${response.statusText}`) - } - - const data = await response.json() - - return { - id: `${data.account_id.toString()}-${generateId()}`, - email: data.email, - name: data.name?.display_name || data.email, - emailVerified: data.email_verified || false, - createdAt: new Date(), - updatedAt: new Date(), - image: data.profile_photo_url || undefined, - } - } catch (error) { - logger.error('Error in getUserInfo:', error) - throw error - } - }, - }, - - { - providerId: 'asana', - clientId: env.ASANA_CLIENT_ID as string, - clientSecret: env.ASANA_CLIENT_SECRET as string, - authorizationUrl: 'https://app.asana.com/-/oauth_authorize', - tokenUrl: 'https://app.asana.com/-/oauth_token', - userInfoUrl: 'https://app.asana.com/api/1.0/users/me', - scopes: getCanonicalScopesForProvider('asana'), - responseType: 'code', - pkce: false, - accessType: 'offline', - authentication: 'basic', - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/asana`, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://app.asana.com/api/1.0/users/me', { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, - }) - - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Error fetching Asana user info:', { - status: response.status, - statusText: response.statusText, - }) - return null - } - - const result = await response.json() - const profile = result.data - - const now = new Date() - - return { - id: `${profile.gid.toString()}-${generateId()}`, - name: profile.name || 'Asana User', - email: profile.email || syntheticConnectorEmail('asana', profile.gid), - image: profile.photo?.image_128x128 || undefined, - emailVerified: !!profile.email, - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error in Asana getUserInfo:', { error }) - return null - } - }, - }, - - { - providerId: 'slack', - clientId: env.SLACK_CLIENT_ID as string, - clientSecret: env.SLACK_CLIENT_SECRET as string, - authorizationUrl: 'https://slack.com/oauth/v2/authorize', - tokenUrl: 'https://slack.com/api/oauth.v2.access', - userInfoUrl: 'https://slack.com/api/users.identity', - scopes: getCanonicalScopesForProvider('slack'), - responseType: 'code', - accessType: 'offline', - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/slack`, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://slack.com/api/auth.test', { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, - }) - - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Slack auth.test failed', { - status: response.status, - statusText: response.statusText, - }) - return null - } - - const data = await response.json() - - if (!data.ok) { - logger.error('Slack auth.test returned error', { error: data.error }) - return null - } - - const teamId = data.team_id || 'unknown' - const teamName = data.team || 'Slack Workspace' - - /** - * Tag the accountId with the installing user's Slack id (from the OAuth - * v2 `authed_user.id`, preserved on `tokens.raw`) behind a `usr_` marker. - * The channels selector uses it to scope private-channel visibility to - * the installer's own Slack membership, per Slack Marketplace rules. The - * marker disambiguates it from a legacy bot id (same `U.../B...` shape); - * absent it, we keep the legacy format and today's behavior. - */ - const rawTokens = (tokens as typeof tokens & { raw?: Record }).raw - const authedUser = rawTokens?.authed_user as { id?: string } | undefined - const installerUserId = authedUser?.id - const userSegment = installerUserId - ? `usr_${installerUserId}` - : data.user_id || data.bot_id || 'bot' - - const uniqueId = `${teamId}-${userSegment}` - - logger.info('Slack credential identifier', { - teamId, - userSegment, - uniqueId, - teamName, - hasInstallerId: !!installerUserId, - }) - - return { - id: `${uniqueId}-${generateId()}`, - name: teamName, - email: syntheticConnectorEmail('slack', uniqueId), - emailVerified: false, - createdAt: new Date(), - updatedAt: new Date(), - } - } catch (error) { - logger.error('Error creating Slack bot profile:', { error }) - return null - } - }, - }, - - { - providerId: 'webflow', - clientId: env.WEBFLOW_CLIENT_ID as string, - clientSecret: env.WEBFLOW_CLIENT_SECRET as string, - authorizationUrl: 'https://webflow.com/oauth/authorize', - tokenUrl: 'https://api.webflow.com/oauth/access_token', - userInfoUrl: 'https://api.webflow.com/v2/token/introspect', - scopes: getCanonicalScopesForProvider('webflow'), - responseType: 'code', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/webflow`, - getUserInfo: async (tokens) => { - try { - logger.info('Fetching Webflow user info') - - const response = await fetch('https://api.webflow.com/v2/token/introspect', { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, - }) - - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Error fetching Webflow user info:', { - status: response.status, - statusText: response.statusText, - }) - return null - } - - const data = await response.json() - const now = new Date() - - const userId = data.user_id || 'user' - const uniqueId = `webflow-${userId}` - - return { - id: `${uniqueId}-${generateId()}`, - name: data.user_name || 'Webflow User', - email: syntheticConnectorEmail('webflow', userId), - emailVerified: false, - createdAt: now, - updatedAt: now, - } - } catch (error) { - logger.error('Error in Webflow getUserInfo:', { error }) - return null - } - }, - }, - { - providerId: 'linkedin', - clientId: env.LINKEDIN_CLIENT_ID as string, - clientSecret: env.LINKEDIN_CLIENT_SECRET as string, - authorizationUrl: 'https://www.linkedin.com/oauth/v2/authorization', - tokenUrl: 'https://www.linkedin.com/oauth/v2/accessToken', - userInfoUrl: 'https://api.linkedin.com/v2/userinfo', - scopes: getCanonicalScopesForProvider('linkedin'), - responseType: 'code', - accessType: 'offline', - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/linkedin`, - getUserInfo: async (tokens) => { - try { - logger.info('Fetching LinkedIn user profile') - - const response = await fetch('https://api.linkedin.com/v2/userinfo', { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, - }) - - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Failed to fetch LinkedIn user info', { - status: response.status, - statusText: response.statusText, - }) - throw new Error('Failed to fetch user info') - } - - const profile = await response.json() - - return { - id: `${profile.sub}-${generateId()}`, - name: profile.name || 'LinkedIn User', - email: profile.email || syntheticConnectorEmail('linkedin', profile.sub), - emailVerified: true, - image: profile.picture || undefined, - createdAt: new Date(), - updatedAt: new Date(), - } - } catch (error) { - logger.error('Error in LinkedIn getUserInfo:', { error }) - return null - } - }, - }, - - { - providerId: 'zoom', - clientId: env.ZOOM_CLIENT_ID as string, - clientSecret: env.ZOOM_CLIENT_SECRET as string, - authorizationUrl: 'https://zoom.us/oauth/authorize', - tokenUrl: 'https://zoom.us/oauth/token', - userInfoUrl: 'https://api.zoom.us/v2/users/me', - scopes: getCanonicalScopesForProvider('zoom'), - responseType: 'code', - accessType: 'offline', - authentication: 'basic', - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/zoom`, - getUserInfo: async (tokens) => { - try { - logger.info('Fetching Zoom user profile') - - const response = await fetch('https://api.zoom.us/v2/users/me', { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, - }) - - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Failed to fetch Zoom user info', { - status: response.status, - statusText: response.statusText, - }) - throw new Error('Failed to fetch user info') - } - - const profile = await response.json() - - return { - id: `${profile.id.toString()}-${generateId()}`, - name: - `${profile.first_name || ''} ${profile.last_name || ''}`.trim() || 'Zoom User', - email: profile.email || syntheticConnectorEmail('zoom', profile.id), - emailVerified: profile.verified === 1, - image: profile.pic_url || undefined, - createdAt: new Date(), - updatedAt: new Date(), - } - } catch (error) { - logger.error('Error in Zoom getUserInfo:', { error }) - return null - } - }, - }, - - { - providerId: 'spotify', - clientId: env.SPOTIFY_CLIENT_ID as string, - clientSecret: env.SPOTIFY_CLIENT_SECRET as string, - authorizationUrl: 'https://accounts.spotify.com/authorize', - tokenUrl: 'https://accounts.spotify.com/api/token', - userInfoUrl: 'https://api.spotify.com/v1/me', - scopes: getCanonicalScopesForProvider('spotify'), - responseType: 'code', - authentication: 'basic', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/spotify`, - getUserInfo: async (tokens) => { - try { - logger.info('Fetching Spotify user profile') - - const response = await fetch('https://api.spotify.com/v1/me', { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, - }) - - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Failed to fetch Spotify user info', { - status: response.status, - statusText: response.statusText, - }) - throw new Error('Failed to fetch user info') - } - - const profile = await response.json() - - return { - id: `${profile.id.toString()}-${generateId()}`, - name: profile.display_name || 'Spotify User', - email: profile.email || syntheticConnectorEmail('spotify', profile.id), - emailVerified: true, - image: profile.images?.[0]?.url || undefined, - createdAt: new Date(), - updatedAt: new Date(), - } - } catch (error) { - logger.error('Error in Spotify getUserInfo:', { error }) - return null - } - }, - }, - - { - providerId: 'wordpress', - clientId: env.WORDPRESS_CLIENT_ID as string, - clientSecret: env.WORDPRESS_CLIENT_SECRET as string, - authorizationUrl: 'https://public-api.wordpress.com/oauth2/authorize', - tokenUrl: 'https://public-api.wordpress.com/oauth2/token', - userInfoUrl: 'https://public-api.wordpress.com/rest/v1.1/me', - scopes: getCanonicalScopesForProvider('wordpress'), - responseType: 'code', - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/wordpress`, - getUserInfo: async (tokens) => { - try { - logger.info('Fetching WordPress.com user profile') - - const response = await fetch('https://public-api.wordpress.com/rest/v1.1/me', { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, - }) - - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Failed to fetch WordPress.com user info', { - status: response.status, - statusText: response.statusText, - }) - throw new Error('Failed to fetch user info') - } - - const profile = await response.json() - - return { - id: `${profile.ID?.toString() || profile.id?.toString()}-${generateId()}`, - name: profile.display_name || profile.username || 'WordPress User', - email: - profile.email || - syntheticConnectorEmail( - 'wordpress', - profile.username ?? profile.ID ?? profile.id - ), - emailVerified: profile.email_verified || false, - image: profile.avatar_URL || undefined, - createdAt: new Date(), - updatedAt: new Date(), - } - } catch (error) { - logger.error('Error in WordPress.com getUserInfo:', { error }) - return null - } - }, - }, - - // DocuSign provider - { - providerId: 'docusign', - clientId: env.DOCUSIGN_CLIENT_ID as string, - clientSecret: env.DOCUSIGN_CLIENT_SECRET as string, - authorizationUrl: 'https://account-d.docusign.com/oauth/auth', - tokenUrl: 'https://account-d.docusign.com/oauth/token', - userInfoUrl: 'https://account-d.docusign.com/oauth/userinfo', - scopes: getCanonicalScopesForProvider('docusign'), - responseType: 'code', - accessType: 'offline', - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/docusign`, - getUserInfo: async (tokens) => { - try { - logger.info('Fetching DocuSign user profile') - - const response = await fetch('https://account-d.docusign.com/oauth/userinfo', { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, - }) - - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Failed to fetch DocuSign user info', { - status: response.status, - statusText: response.statusText, - }) - throw new Error('Failed to fetch user info') - } - - const data = await response.json() - const accounts = data.accounts ?? [] - const defaultAccount = - accounts.find((a: { is_default: boolean }) => a.is_default) ?? accounts[0] - const accountName = defaultAccount?.account_name || 'DocuSign Account' - - if (data.scope) { - tokens.scopes = data.scope.split(/\s+/).filter(Boolean) - } - - return { - id: `${data.sub}-${generateId()}`, - name: data.name || accountName, - email: data.email || syntheticConnectorEmail('docusign', data.sub), - emailVerified: true, - image: undefined, - createdAt: new Date(), - updatedAt: new Date(), - } - } catch (error) { - logger.error('Error in DocuSign getUserInfo:', { error }) - return null - } - }, - }, - - // Cal.com provider - { - providerId: 'calcom', - clientId: env.CALCOM_CLIENT_ID as string, - authorizationUrl: 'https://app.cal.com/auth/oauth2/authorize', - tokenUrl: 'https://app.cal.com/api/auth/oauth/token', - scopes: getCanonicalScopesForProvider('calcom'), - responseType: 'code', - pkce: true, - accessType: 'offline', - prompt: 'consent', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/calcom`, - getUserInfo: async (tokens) => { - try { - logger.info('Fetching Cal.com user profile') - - const response = await fetch('https://api.cal.com/v2/me', { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - 'cal-api-version': '2024-08-13', - }, - }) - - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Failed to fetch Cal.com user info', { - status: response.status, - statusText: response.statusText, - }) - throw new Error('Failed to fetch user info') - } - - const data = await response.json() - const profile = data.data || data - - return { - id: `${profile.id?.toString()}-${generateId()}`, - name: profile.name || 'Cal.com User', - email: profile.email || syntheticConnectorEmail('calcom', profile.id), - emailVerified: true, - createdAt: new Date(), - updatedAt: new Date(), - } - } catch (error) { - logger.error('Error in Cal.com getUserInfo:', { error }) - return null - } - }, - }, - ], + config: buildConnectorProviders(), }), /** * Include SSO plugin when enabled. Resolved through `isSsoEnabled` rather diff --git a/apps/sim/lib/auth/connectors/providers.ts b/apps/sim/lib/auth/connectors/providers.ts new file mode 100644 index 00000000000..af3a1928354 --- /dev/null +++ b/apps/sim/lib/auth/connectors/providers.ts @@ -0,0 +1,2410 @@ +import { createHash } from 'crypto' +import { getOAuth2Tokens } from '@better-auth/core/oauth2' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import type { GenericOAuthConfig } from 'better-auth/plugins' +import { syntheticConnectorEmail } from '@/lib/auth/connector-email' +import { env } from '@/lib/core/config/env' +import { + readResponseJsonWithLimit, + readResponseTextWithLimit, +} from '@/lib/core/utils/stream-limits' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { getMicrosoftUserInfoFromIdToken } from '@/lib/oauth/microsoft' +import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' +import { deriveZohoDeskBaseFromApiDomain } from '@/tools/zoho_desk/host-allowlist' + +/** + * Third-party connector definitions for Better Auth's `genericOAuth` plugin. + * + * These are the OAuth apps a workspace connects *tools* to — Gmail, Jira, + * Slack and the rest — as distinct from the handful of providers used to sign + * in to Sim itself, which stay in `socialProviders` in `lib/auth/auth.ts`. + * + * They live here rather than in `auth.ts` because each entry carries real + * per-provider logic — a `getUserInfo` fetch, its response shape, and its error + * handling — and in aggregate that buried the auth configuration itself. + */ + +/** + * Scoped `'Auth'` rather than something module-specific: these log lines + * predate this file, and renaming the scope would silently break every existing + * log query and alert that matches on it. + */ +const logger = createLogger('Auth') + +/** + * Shape of `GET https://api.notion.com/v1/users/me` for an OAuth integration token. + * @see https://developers.notion.com/reference/get-self + */ +interface NotionSelfResponse { + id: string + name?: string | null + bot?: { + owner?: + | { type: 'user'; user?: { id: string; name?: string | null; person?: { email?: string } } } + | { type: 'workspace'; workspace: true } + } +} + +/** + * Shape of `GET https://api.attio.com/v2/self` (the Identify endpoint). + * @see https://docs.attio.com/rest-api/endpoint-reference/meta/identify + */ +interface AttioSelfResponse { + active?: boolean + authorized_by_workspace_member_id?: string | null + workspace_id?: string + workspace_name?: string +} + +/** + * Shape of `GET https://api.attio.com/v2/workspace_members/{id}`. + * @see https://docs.attio.com/rest-api/endpoint-reference/workspace-members/get-a-workspace-member + */ +interface AttioWorkspaceMemberResponse { + data?: { + id: { workspace_id: string; workspace_member_id: string } + first_name?: string | null + last_name?: string | null + email_address?: string | null + avatar_url?: string | null + } +} + +/** + * Builds the connector list, evaluated once when `betterAuth()` constructs the + * auth instance — the same point the array was built at when it was inline. + * + * A function rather than a module-level constant so that importing this module + * never on its own requires a configured environment: the entries call + * `getBaseUrl()`, which throws when `NEXT_PUBLIC_APP_URL` is unset. That keeps + * the module importable in isolation, by a unit test or a script enumerating + * provider ids, without booting the whole auth configuration. + * + * The explicit `GenericOAuthConfig[]` return type is load-bearing: inline, the + * entries were contextually typed by the `config` property they were assigned + * to. Without the annotation the literals widen (`prompt: string` stops + * matching its union) and every `getUserInfo` parameter becomes implicitly + * `any`. + */ +export function buildConnectorProviders(): GenericOAuthConfig[] { + return [ + { + providerId: 'google-email', + clientId: env.GOOGLE_CLIENT_ID as string, + clientSecret: env.GOOGLE_CLIENT_SECRET as string, + discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', + accessType: 'offline', + scopes: getCanonicalScopesForProvider('google-email'), + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-email`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { + headers: { Authorization: `Bearer ${tokens.accessToken}` }, + }) + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Failed to fetch Google user info', { status: response.status }) + throw new Error(`Failed to fetch Google user info: ${response.statusText}`) + } + const profile = await response.json() + const now = new Date() + return { + id: `${profile.sub}-${generateId()}`, + name: profile.name || 'Google User', + email: profile.email, + image: profile.picture || undefined, + emailVerified: profile.email_verified || false, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in Google getUserInfo', { error }) + throw error + } + }, + }, + { + providerId: 'google-calendar', + clientId: env.GOOGLE_CLIENT_ID as string, + clientSecret: env.GOOGLE_CLIENT_SECRET as string, + discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', + accessType: 'offline', + scopes: getCanonicalScopesForProvider('google-calendar'), + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-calendar`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { + headers: { Authorization: `Bearer ${tokens.accessToken}` }, + }) + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Failed to fetch Google user info', { status: response.status }) + throw new Error(`Failed to fetch Google user info: ${response.statusText}`) + } + const profile = await response.json() + const now = new Date() + return { + id: `${profile.sub}-${generateId()}`, + name: profile.name || 'Google User', + email: profile.email, + image: profile.picture || undefined, + emailVerified: profile.email_verified || false, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in Google getUserInfo', { error }) + throw error + } + }, + }, + { + providerId: 'google-drive', + clientId: env.GOOGLE_CLIENT_ID as string, + clientSecret: env.GOOGLE_CLIENT_SECRET as string, + discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', + accessType: 'offline', + scopes: getCanonicalScopesForProvider('google-drive'), + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-drive`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { + headers: { Authorization: `Bearer ${tokens.accessToken}` }, + }) + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Failed to fetch Google user info', { status: response.status }) + throw new Error(`Failed to fetch Google user info: ${response.statusText}`) + } + const profile = await response.json() + const now = new Date() + return { + id: `${profile.sub}-${generateId()}`, + name: profile.name || 'Google User', + email: profile.email, + image: profile.picture || undefined, + emailVerified: profile.email_verified || false, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in Google getUserInfo', { error }) + throw error + } + }, + }, + { + providerId: 'google-docs', + clientId: env.GOOGLE_CLIENT_ID as string, + clientSecret: env.GOOGLE_CLIENT_SECRET as string, + discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', + accessType: 'offline', + scopes: getCanonicalScopesForProvider('google-docs'), + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-docs`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { + headers: { Authorization: `Bearer ${tokens.accessToken}` }, + }) + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Failed to fetch Google user info', { status: response.status }) + throw new Error(`Failed to fetch Google user info: ${response.statusText}`) + } + const profile = await response.json() + const now = new Date() + return { + id: `${profile.sub}-${generateId()}`, + name: profile.name || 'Google User', + email: profile.email, + image: profile.picture || undefined, + emailVerified: profile.email_verified || false, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in Google getUserInfo', { error }) + throw error + } + }, + }, + { + providerId: 'google-sheets', + clientId: env.GOOGLE_CLIENT_ID as string, + clientSecret: env.GOOGLE_CLIENT_SECRET as string, + discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', + accessType: 'offline', + scopes: getCanonicalScopesForProvider('google-sheets'), + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-sheets`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { + headers: { Authorization: `Bearer ${tokens.accessToken}` }, + }) + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Failed to fetch Google user info', { status: response.status }) + throw new Error(`Failed to fetch Google user info: ${response.statusText}`) + } + const profile = await response.json() + const now = new Date() + return { + id: `${profile.sub}-${generateId()}`, + name: profile.name || 'Google User', + email: profile.email, + image: profile.picture || undefined, + emailVerified: profile.email_verified || false, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in Google getUserInfo', { error }) + throw error + } + }, + }, + + { + providerId: 'google-contacts', + clientId: env.GOOGLE_CLIENT_ID as string, + clientSecret: env.GOOGLE_CLIENT_SECRET as string, + discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', + accessType: 'offline', + scopes: getCanonicalScopesForProvider('google-contacts'), + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-contacts`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { + headers: { Authorization: `Bearer ${tokens.accessToken}` }, + }) + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Failed to fetch Google user info', { status: response.status }) + throw new Error(`Failed to fetch Google user info: ${response.statusText}`) + } + const profile = await response.json() + const now = new Date() + return { + id: `${profile.sub}-${generateId()}`, + name: profile.name || 'Google User', + email: profile.email, + image: profile.picture || undefined, + emailVerified: profile.email_verified || false, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in Google getUserInfo', { error }) + throw error + } + }, + }, + { + providerId: 'google-forms', + clientId: env.GOOGLE_CLIENT_ID as string, + clientSecret: env.GOOGLE_CLIENT_SECRET as string, + discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', + accessType: 'offline', + scopes: getCanonicalScopesForProvider('google-forms'), + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-forms`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { + headers: { Authorization: `Bearer ${tokens.accessToken}` }, + }) + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Failed to fetch Google user info', { status: response.status }) + throw new Error(`Failed to fetch Google user info: ${response.statusText}`) + } + const profile = await response.json() + const now = new Date() + return { + id: `${profile.sub}-${generateId()}`, + name: profile.name || 'Google User', + email: profile.email, + image: profile.picture || undefined, + emailVerified: profile.email_verified || false, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in Google getUserInfo', { error }) + throw error + } + }, + }, + { + providerId: 'google-ads', + clientId: env.GOOGLE_CLIENT_ID as string, + clientSecret: env.GOOGLE_CLIENT_SECRET as string, + discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', + accessType: 'offline', + scopes: getCanonicalScopesForProvider('google-ads'), + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-ads`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { + headers: { Authorization: `Bearer ${tokens.accessToken}` }, + }) + if (!response.ok) { + logger.error('Failed to fetch Google user info', { status: response.status }) + throw new Error(`Failed to fetch Google user info: ${response.statusText}`) + } + const profile = await response.json() + const now = new Date() + return { + id: `${profile.sub}-${generateId()}`, + name: profile.name || 'Google User', + email: profile.email, + image: profile.picture || undefined, + emailVerified: profile.email_verified || false, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in Google getUserInfo', { error }) + throw error + } + }, + }, + { + providerId: 'google-bigquery', + clientId: env.GOOGLE_CLIENT_ID as string, + clientSecret: env.GOOGLE_CLIENT_SECRET as string, + discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', + accessType: 'offline', + scopes: getCanonicalScopesForProvider('google-bigquery'), + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-bigquery`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { + headers: { Authorization: `Bearer ${tokens.accessToken}` }, + }) + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Failed to fetch Google user info', { status: response.status }) + throw new Error(`Failed to fetch Google user info: ${response.statusText}`) + } + const profile = await response.json() + const now = new Date() + return { + id: `${profile.sub}-${generateId()}`, + name: profile.name || 'Google User', + email: profile.email, + image: profile.picture || undefined, + emailVerified: profile.email_verified || false, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in Google getUserInfo', { error }) + throw error + } + }, + }, + + { + providerId: 'google-vault', + clientId: env.GOOGLE_CLIENT_ID as string, + clientSecret: env.GOOGLE_CLIENT_SECRET as string, + discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', + accessType: 'offline', + scopes: getCanonicalScopesForProvider('google-vault'), + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-vault`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { + headers: { Authorization: `Bearer ${tokens.accessToken}` }, + }) + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Failed to fetch Google user info', { status: response.status }) + throw new Error(`Failed to fetch Google user info: ${response.statusText}`) + } + const profile = await response.json() + const now = new Date() + return { + id: `${profile.sub}-${generateId()}`, + name: profile.name || 'Google User', + email: profile.email, + image: profile.picture || undefined, + emailVerified: profile.email_verified || false, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in Google getUserInfo', { error }) + throw error + } + }, + }, + + { + providerId: 'google-groups', + clientId: env.GOOGLE_CLIENT_ID as string, + clientSecret: env.GOOGLE_CLIENT_SECRET as string, + discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', + accessType: 'offline', + scopes: getCanonicalScopesForProvider('google-groups'), + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-groups`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { + headers: { Authorization: `Bearer ${tokens.accessToken}` }, + }) + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Failed to fetch Google user info', { status: response.status }) + throw new Error(`Failed to fetch Google user info: ${response.statusText}`) + } + const profile = await response.json() + const now = new Date() + return { + id: `${profile.sub}-${generateId()}`, + name: profile.name || 'Google User', + email: profile.email, + image: profile.picture || undefined, + emailVerified: profile.email_verified || false, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in Google getUserInfo', { error }) + throw error + } + }, + }, + + { + providerId: 'google-meet', + clientId: env.GOOGLE_CLIENT_ID as string, + clientSecret: env.GOOGLE_CLIENT_SECRET as string, + discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', + accessType: 'offline', + scopes: getCanonicalScopesForProvider('google-meet'), + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-meet`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { + headers: { Authorization: `Bearer ${tokens.accessToken}` }, + }) + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Failed to fetch Google user info', { status: response.status }) + throw new Error(`Failed to fetch Google user info: ${response.statusText}`) + } + const profile = await response.json() + const now = new Date() + return { + id: `${profile.sub}-${generateId()}`, + name: profile.name || 'Google User', + email: profile.email, + image: profile.picture || undefined, + emailVerified: profile.email_verified || false, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in Google getUserInfo', { error }) + throw error + } + }, + }, + { + providerId: 'google-tasks', + clientId: env.GOOGLE_CLIENT_ID as string, + clientSecret: env.GOOGLE_CLIENT_SECRET as string, + discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', + accessType: 'offline', + scopes: getCanonicalScopesForProvider('google-tasks'), + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-tasks`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { + headers: { Authorization: `Bearer ${tokens.accessToken}` }, + }) + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Failed to fetch Google user info', { status: response.status }) + throw new Error(`Failed to fetch Google user info: ${response.statusText}`) + } + const profile = await response.json() + const now = new Date() + return { + id: `${profile.sub}-${generateId()}`, + name: profile.name || 'Google User', + email: profile.email, + image: profile.picture || undefined, + emailVerified: profile.email_verified || false, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in Google getUserInfo', { error }) + throw error + } + }, + }, + + { + providerId: 'vertex-ai', + clientId: env.GOOGLE_CLIENT_ID as string, + clientSecret: env.GOOGLE_CLIENT_SECRET as string, + discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', + accessType: 'offline', + scopes: getCanonicalScopesForProvider('vertex-ai'), + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/vertex-ai`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { + headers: { Authorization: `Bearer ${tokens.accessToken}` }, + }) + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Failed to fetch Google user info', { status: response.status }) + throw new Error(`Failed to fetch Google user info: ${response.statusText}`) + } + const profile = await response.json() + const now = new Date() + return { + id: `${profile.sub}-${generateId()}`, + name: profile.name || 'Google User', + email: profile.email, + image: profile.picture || undefined, + emailVerified: profile.email_verified || false, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in Google getUserInfo', { error }) + throw error + } + }, + }, + + { + providerId: 'microsoft-ad', + clientId: env.MICROSOFT_CLIENT_ID as string, + clientSecret: env.MICROSOFT_CLIENT_SECRET as string, + authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', + tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + userInfoUrl: 'https://graph.microsoft.com/v1.0/me', + scopes: getCanonicalScopesForProvider('microsoft-ad'), + responseType: 'code', + accessType: 'offline', + authentication: 'basic', + pkce: true, + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/microsoft-ad`, + getUserInfo: async (tokens) => { + return getMicrosoftUserInfoFromIdToken(tokens, 'microsoft-ad') + }, + }, + + { + providerId: 'microsoft-teams', + clientId: env.MICROSOFT_CLIENT_ID as string, + clientSecret: env.MICROSOFT_CLIENT_SECRET as string, + authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', + tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + userInfoUrl: 'https://graph.microsoft.com/v1.0/me', + scopes: getCanonicalScopesForProvider('microsoft-teams'), + responseType: 'code', + accessType: 'offline', + authentication: 'basic', + pkce: true, + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/microsoft-teams`, + getUserInfo: async (tokens) => { + return getMicrosoftUserInfoFromIdToken(tokens, 'microsoft-teams') + }, + }, + + { + providerId: 'microsoft-excel', + clientId: env.MICROSOFT_CLIENT_ID as string, + clientSecret: env.MICROSOFT_CLIENT_SECRET as string, + authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', + tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + userInfoUrl: 'https://graph.microsoft.com/v1.0/me', + scopes: getCanonicalScopesForProvider('microsoft-excel'), + responseType: 'code', + accessType: 'offline', + authentication: 'basic', + pkce: true, + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/microsoft-excel`, + getUserInfo: async (tokens) => { + return getMicrosoftUserInfoFromIdToken(tokens, 'microsoft-excel') + }, + }, + { + providerId: 'microsoft-dataverse', + clientId: env.MICROSOFT_CLIENT_ID as string, + clientSecret: env.MICROSOFT_CLIENT_SECRET as string, + authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', + tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + userInfoUrl: 'https://graph.microsoft.com/v1.0/me', + scopes: getCanonicalScopesForProvider('microsoft-dataverse'), + responseType: 'code', + accessType: 'offline', + authentication: 'basic', + pkce: true, + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/microsoft-dataverse`, + getUserInfo: async (tokens) => { + return getMicrosoftUserInfoFromIdToken(tokens, 'microsoft-dataverse') + }, + }, + { + providerId: 'microsoft-planner', + clientId: env.MICROSOFT_CLIENT_ID as string, + clientSecret: env.MICROSOFT_CLIENT_SECRET as string, + authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', + tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + userInfoUrl: 'https://graph.microsoft.com/v1.0/me', + scopes: getCanonicalScopesForProvider('microsoft-planner'), + responseType: 'code', + accessType: 'offline', + authentication: 'basic', + pkce: true, + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/microsoft-planner`, + getUserInfo: async (tokens) => { + return getMicrosoftUserInfoFromIdToken(tokens, 'microsoft-planner') + }, + }, + + { + providerId: 'outlook', + clientId: env.MICROSOFT_CLIENT_ID as string, + clientSecret: env.MICROSOFT_CLIENT_SECRET as string, + authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', + tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + userInfoUrl: 'https://graph.microsoft.com/v1.0/me', + scopes: getCanonicalScopesForProvider('outlook'), + responseType: 'code', + accessType: 'offline', + authentication: 'basic', + pkce: true, + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/outlook`, + getUserInfo: async (tokens) => { + return getMicrosoftUserInfoFromIdToken(tokens, 'outlook') + }, + }, + + { + providerId: 'onedrive', + clientId: env.MICROSOFT_CLIENT_ID as string, + clientSecret: env.MICROSOFT_CLIENT_SECRET as string, + authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', + tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + userInfoUrl: 'https://graph.microsoft.com/v1.0/me', + scopes: getCanonicalScopesForProvider('onedrive'), + responseType: 'code', + accessType: 'offline', + authentication: 'basic', + pkce: true, + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/onedrive`, + getUserInfo: async (tokens) => { + return getMicrosoftUserInfoFromIdToken(tokens, 'onedrive') + }, + }, + + { + providerId: 'sharepoint', + clientId: env.MICROSOFT_CLIENT_ID as string, + clientSecret: env.MICROSOFT_CLIENT_SECRET as string, + authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', + tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + userInfoUrl: 'https://graph.microsoft.com/v1.0/me', + scopes: getCanonicalScopesForProvider('sharepoint'), + responseType: 'code', + accessType: 'offline', + authentication: 'basic', + pkce: true, + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/sharepoint`, + getUserInfo: async (tokens) => { + return getMicrosoftUserInfoFromIdToken(tokens, 'sharepoint') + }, + }, + + { + providerId: 'wealthbox', + clientId: env.WEALTHBOX_CLIENT_ID as string, + clientSecret: env.WEALTHBOX_CLIENT_SECRET as string, + authorizationUrl: 'https://app.crmworkspace.com/oauth/authorize', + tokenUrl: 'https://app.crmworkspace.com/oauth/token', + userInfoUrl: 'https://api.crmworkspace.com/v1/me', + scopes: getCanonicalScopesForProvider('wealthbox'), + responseType: 'code', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/wealthbox`, + getUserInfo: async (tokens) => { + try { + logger.info('Fetching Wealthbox user profile') + + const response = await fetch('https://api.crmworkspace.com/v1/me', { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + }) + + const now = new Date() + + if (response.ok) { + const data = await response.json() + const userId = data.id?.toString() + if (!userId) { + return null + } + const email = + data.email && typeof data.email === 'string' + ? data.email + : syntheticConnectorEmail('wealthbox', userId) + const name = data.name || data.full_name || data.username || 'Wealthbox User' + + return { + id: `wealthbox-${userId}-${generateId()}`, + name, + email, + emailVerified: false, + createdAt: now, + updatedAt: now, + } + } + + // Fallback: derive a stable identifier from the refresh token (long-lived) + // rather than the access token (rotates every ~2 hours) to avoid creating + // duplicate accounts on token refresh. + logger.warn('Wealthbox user info fetch failed, falling back to token-derived identity', { + status: response.status, + }) + const stableToken = tokens.refreshToken ?? tokens.accessToken + if (!stableToken) { + logger.error('Wealthbox fallback identity: no refresh or access token available') + return null + } + const tokenHash = createHash('sha256').update(stableToken).digest('hex').slice(0, 24) + return { + id: `wealthbox-${tokenHash}-${generateId()}`, + name: 'Wealthbox User', + email: syntheticConnectorEmail('wealthbox', tokenHash), + emailVerified: false, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error creating Wealthbox user profile:', { + error: toError(error).message, + }) + return null + } + }, + }, + + { + providerId: 'pipedrive', + clientId: env.PIPEDRIVE_CLIENT_ID as string, + clientSecret: env.PIPEDRIVE_CLIENT_SECRET as string, + authorizationUrl: 'https://oauth.pipedrive.com/oauth/authorize', + tokenUrl: 'https://oauth.pipedrive.com/oauth/token', + userInfoUrl: 'https://api.pipedrive.com/v1/users/me', + prompt: 'consent', + scopes: getCanonicalScopesForProvider('pipedrive'), + responseType: 'code', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/pipedrive`, + getUserInfo: async (tokens) => { + try { + logger.info('Fetching Pipedrive user profile') + + const response = await fetch('https://api.pipedrive.com/v1/users/me', { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + }) + + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Failed to fetch Pipedrive user info', { + status: response.status, + }) + throw new Error('Failed to fetch user info') + } + + const data = await response.json() + const user = data.data + + return { + id: `${user.id.toString()}-${generateId()}`, + name: user.name, + email: user.email, + emailVerified: user.activated, + image: user.icon_url, + createdAt: new Date(), + updatedAt: new Date(), + } + } catch (error) { + logger.error('Error creating Pipedrive user profile:', { error }) + return null + } + }, + }, + + { + providerId: 'hubspot', + clientId: env.HUBSPOT_CLIENT_ID as string, + clientSecret: env.HUBSPOT_CLIENT_SECRET as string, + authorizationUrl: 'https://app.hubspot.com/oauth/authorize', + tokenUrl: 'https://api.hubapi.com/oauth/v1/token', + userInfoUrl: 'https://api.hubapi.com/oauth/v1/access-tokens', + prompt: 'consent', + scopes: getCanonicalScopesForProvider('hubspot'), + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/hubspot`, + getUserInfo: async (tokens) => { + try { + logger.info('Fetching HubSpot user profile') + + const response = await fetch( + `https://api.hubapi.com/oauth/v1/access-tokens/${tokens.accessToken}` + ) + + if (!response.ok) { + let errorBody: string | undefined + try { + errorBody = await response.text() + } catch { + // ignore + } + logger.error('Failed to fetch HubSpot user info', { + status: response.status, + statusText: response.statusText, + body: errorBody?.slice(0, 500), + }) + throw new Error('Failed to fetch user info') + } + + const rawText = await response.text() + const data = JSON.parse(rawText) + + const scopesArray = Array.isArray((data as any)?.scopes) ? (data as any).scopes : [] + if (Array.isArray(scopesArray) && scopesArray.length > 0) { + tokens.scopes = scopesArray + } else if (typeof (data as any)?.scope === 'string') { + tokens.scopes = (data as any).scope.split(/\s+/).filter(Boolean) + } + + logger.info('HubSpot token metadata response:', { + hubId: data.hub_id, + hubDomain: data.hub_domain, + userId: data.user_id, + hasScopes: !!data.scopes, + scopesType: typeof data.scopes, + scopesIsArray: Array.isArray(data.scopes), + }) + + return { + id: `${(data.user_id || data.hub_id).toString()}-${generateId()}`, + name: data.user || 'HubSpot User', + email: data.user || syntheticConnectorEmail('hubspot', data.hub_id), + emailVerified: true, + image: undefined, + createdAt: new Date(), + updatedAt: new Date(), + // Extract scopes from HubSpot's response and convert array to space-delimited string + // Use 'scope' (singular) as that's what better-auth expects for the account table + ...(data.scopes && Array.isArray(data.scopes) ? { scope: data.scopes.join(' ') } : {}), + } + } catch (error) { + logger.error('Error creating HubSpot user profile:', { error }) + return null + } + }, + }, + + { + providerId: 'salesforce', + clientId: env.SALESFORCE_CLIENT_ID as string, + clientSecret: env.SALESFORCE_CLIENT_SECRET as string, + authorizationUrl: 'https://login.salesforce.com/services/oauth2/authorize', + tokenUrl: 'https://login.salesforce.com/services/oauth2/token', + userInfoUrl: 'https://login.salesforce.com/services/oauth2/userinfo', + scopes: getCanonicalScopesForProvider('salesforce'), + pkce: true, + prompt: 'consent', + accessType: 'offline', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/salesforce`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://login.salesforce.com/services/oauth2/userinfo', { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + }) + + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Failed to fetch Salesforce user info', { + status: response.status, + }) + throw new Error('Failed to fetch user info') + } + + const data = await response.json() + + return { + id: `${(data.user_id || data.sub).toString()}-${generateId()}`, + name: data.name || 'Salesforce User', + email: data.email || syntheticConnectorEmail('salesforce', data.user_id ?? data.sub), + emailVerified: data.email_verified === true, + image: data.picture || undefined, + createdAt: new Date(), + updatedAt: new Date(), + } + } catch (error) { + logger.error('Error creating Salesforce user profile:', { error }) + return null + } + }, + }, + + { + providerId: 'zoho-desk', + clientId: env.ZOHO_CLIENT_ID as string, + clientSecret: env.ZOHO_CLIENT_SECRET as string, + authorizationUrl: 'https://accounts.zoho.com/oauth/v2/auth', + tokenUrl: 'https://accounts.zoho.com/oauth/v2/token', + scopes: getCanonicalScopesForProvider('zoho-desk'), + responseType: 'code', + pkce: true, + accessType: 'offline', + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/zoho-desk`, + // Zoho only issues a refresh token when access_type=offline AND + // prompt=consent are present on the authorize request, and it expects + // comma-separated scopes rather than the default space-delimited list. + authorizationUrlParams: { + access_type: 'offline', + prompt: 'consent', + scope: getCanonicalScopesForProvider('zoho-desk').join(','), + }, + getToken: async ({ code, redirectURI, codeVerifier }) => { + const tokenParams = new URLSearchParams({ + client_id: env.ZOHO_CLIENT_ID as string, + client_secret: env.ZOHO_CLIENT_SECRET as string, + code, + grant_type: 'authorization_code', + redirect_uri: redirectURI, + }) + // PKCE is enabled, so better-auth sent a code_challenge on the authorize + // request. The exchange MUST echo the matching code_verifier or Zoho + // rejects the request shape (invalid_request). Verified by isolating + // pkce:false (which connected) then restoring pkce:true + this verifier. + if (codeVerifier) tokenParams.set('code_verifier', codeVerifier) + + const response = await fetch('https://accounts.zoho.com/oauth/v2/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: tokenParams, + }) + const data = await readResponseJsonWithLimit>(response, { + maxBytes: 1024 * 1024, + label: 'Zoho Desk OAuth token response', + }) + + // Zoho signals OAuth failures in the JSON body, usually with HTTP 200, + // e.g. { error: 'invalid_code' } or { error: 'invalid_client', + // error_description: '...' }. The status-only guard therefore never + // fires, so surface the actual error/description instead of collapsing + // every failure into one opaque "no access token" string. + const errorObj = + data && typeof data === 'object' && !Array.isArray(data) + ? (data as { error?: unknown; error_description?: unknown }) + : {} + const zohoError = typeof errorObj.error === 'string' ? errorObj.error : undefined + const zohoErrorDescription = + typeof errorObj.error_description === 'string' ? errorObj.error_description : undefined + if (!response.ok || !data || typeof data !== 'object' || Array.isArray(data) || zohoError) { + logger.error('Zoho Desk OAuth token exchange failed', { + status: response.status, + zohoError: zohoError ?? null, + zohoErrorDescription: zohoErrorDescription ?? null, + }) + throw new Error( + `Zoho Desk OAuth token exchange failed (HTTP ${response.status}${ + zohoError ? `, ${zohoError}` : '' + }${zohoErrorDescription ? `: ${zohoErrorDescription}` : ''})` + ) + } + + const tokens = getOAuth2Tokens(data) + if (!tokens.accessToken) { + logger.error('Zoho Desk OAuth token response had no access token', { + status: response.status, + bodyKeys: Object.keys(data), + }) + throw new Error('Zoho Desk OAuth token response did not include an access token') + } + + // Persist the data-center-scoped Desk REST base derived from the + // token response api_domain so every API call targets the correct + // host instead of assuming desk.zoho.com. Stored inside the scope + // string (survives refreshes, which never rewrite scope) and read + // back in /api/auth/oauth/token as `apiDomain`. + const deskBase = deriveZohoDeskBaseFromApiDomain( + typeof data.api_domain === 'string' ? data.api_domain : undefined + ) + // Zoho's docs are inconsistent about whether the Desk token response + // carries `scope` (the Mail sample has it; the CRM/Creator samples do + // not). If it is absent, fall back to the scopes we requested and were + // granted by completing the flow - otherwise the stored scope list is + // just the domain marker, and the credential picker would show a + // permanent "needs update / reconnect" badge on every connection. + // Mirrors the existing Box fallback in this file. + const reportedScopes = + typeof data.scope === 'string' ? data.scope.split(/[\s,]+/).filter(Boolean) : [] + const grantedScopes = reportedScopes.length + ? reportedScopes + : getCanonicalScopesForProvider('zoho-desk') + tokens.scopes = [`__zoho_domain__:${deskBase}`, ...grantedScopes] + return tokens + }, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://accounts.zoho.com/oauth/user/info', { + headers: { Authorization: `Zoho-oauthtoken ${tokens.accessToken}` }, + }) + + if (!response.ok) { + await readResponseTextWithLimit(response, { + maxBytes: 1024 * 1024, + label: 'Zoho Desk profile error response', + }).catch(() => {}) + logger.error('Error fetching Zoho Desk user info:', { + status: response.status, + statusText: response.statusText, + }) + return null + } + + const profile = await readResponseJsonWithLimit<{ + ZUID?: number | string + Display_Name?: string + Email?: string + }>(response, { maxBytes: 1024 * 1024, label: 'Zoho Desk profile response' }) + + const zuid = profile.ZUID?.toString() + if (!zuid) { + logger.error('Invalid Zoho Desk profile response:', profile) + return null + } + + const now = new Date() + return { + id: `${zuid}-${generateId()}`, + name: profile.Display_Name || 'Zoho User', + email: profile.Email || syntheticConnectorEmail('zoho', zuid), + emailVerified: Boolean(profile.Email), + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in Zoho Desk getUserInfo:', { error }) + return null + } + }, + }, + + { + providerId: 'x', + clientId: env.X_CLIENT_ID as string, + clientSecret: env.X_CLIENT_SECRET as string, + authorizationUrl: 'https://x.com/i/oauth2/authorize', + tokenUrl: 'https://api.x.com/2/oauth2/token', + userInfoUrl: 'https://api.x.com/2/users/me', + accessType: 'offline', + scopes: getCanonicalScopesForProvider('x'), + pkce: true, + responseType: 'code', + prompt: 'consent', + authentication: 'basic', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/x`, + getUserInfo: async (tokens) => { + try { + const response = await fetch( + 'https://api.x.com/2/users/me?user.fields=profile_image_url,username,name,verified', + { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + } + ) + + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Error fetching X user info:', { + status: response.status, + statusText: response.statusText, + }) + return null + } + + const profile = await response.json() + + if (!profile.data) { + logger.error('Invalid X profile response:', profile) + return null + } + + const now = new Date() + + return { + id: `${profile.data.id.toString()}-${generateId()}`, + name: profile.data.name || 'X User', + email: syntheticConnectorEmail('x', profile.data.username ?? profile.data.id), + image: profile.data.profile_image_url, + emailVerified: profile.data.verified || false, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in X getUserInfo:', { error }) + return null + } + }, + }, + + { + providerId: 'tiktok', + clientId: env.TIKTOK_CLIENT_ID as string, + clientSecret: env.TIKTOK_CLIENT_SECRET as string, + authorizationUrl: 'https://www.tiktok.com/v2/auth/authorize/', + tokenUrl: 'https://open.tiktokapis.com/v2/oauth/token/', + scopes: getCanonicalScopesForProvider('tiktok'), + responseType: 'code', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/tiktok`, + authorizationUrlParams: { + client_key: env.TIKTOK_CLIENT_ID as string, + scope: getCanonicalScopesForProvider('tiktok').join(','), + }, + getToken: async ({ code, redirectURI }) => { + const response = await fetch('https://open.tiktokapis.com/v2/oauth/token/', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_key: env.TIKTOK_CLIENT_ID as string, + client_secret: env.TIKTOK_CLIENT_SECRET as string, + code, + grant_type: 'authorization_code', + redirect_uri: redirectURI, + }), + }) + const data = await readResponseJsonWithLimit>(response, { + maxBytes: 1024 * 1024, + label: 'TikTok OAuth token response', + }) + + if (!response.ok || !data || typeof data !== 'object' || Array.isArray(data)) { + throw new Error(`TikTok OAuth token exchange failed with HTTP ${response.status}`) + } + + const tokens = getOAuth2Tokens(data) + if (!tokens.accessToken) { + throw new Error('TikTok OAuth token response did not include an access token') + } + if (typeof data.scope === 'string') { + tokens.scopes = data.scope.split(/[\s,]+/).filter(Boolean) + } + return tokens + }, + getUserInfo: async (tokens) => { + try { + const response = await fetch( + 'https://open.tiktokapis.com/v2/user/info/?fields=open_id,display_name,avatar_url', + { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + } + ) + + if (!response.ok) { + await readResponseTextWithLimit(response, { + maxBytes: 1024 * 1024, + label: 'TikTok profile error response', + }).catch(() => {}) + logger.error('Error fetching TikTok user info:', { + status: response.status, + statusText: response.statusText, + }) + return null + } + + const profile = await readResponseJsonWithLimit<{ + data?: { + user?: { + avatar_url?: string + display_name?: string + open_id?: string + } + } + }>(response, { + maxBytes: 1024 * 1024, + label: 'TikTok profile response', + }) + const user = profile.data?.user + + if (!user?.open_id) { + logger.error('Invalid TikTok profile response:', profile) + return null + } + + const now = new Date() + + return { + id: `${user.open_id}-${generateId()}`, + name: user.display_name || 'TikTok User', + email: syntheticConnectorEmail('tiktok', user.open_id), + image: user.avatar_url || undefined, + emailVerified: false, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in TikTok getUserInfo:', { error }) + return null + } + }, + }, + + { + providerId: 'confluence', + clientId: env.CONFLUENCE_CLIENT_ID as string, + clientSecret: env.CONFLUENCE_CLIENT_SECRET as string, + authorizationUrl: 'https://auth.atlassian.com/authorize', + tokenUrl: 'https://auth.atlassian.com/oauth/token', + userInfoUrl: 'https://api.atlassian.com/me', + scopes: getCanonicalScopesForProvider('confluence'), + responseType: 'code', + pkce: true, + accessType: 'offline', + authentication: 'basic', + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/confluence`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://api.atlassian.com/me', { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + }) + + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Error fetching Confluence user info:', { + status: response.status, + statusText: response.statusText, + }) + return null + } + + const profile = await response.json() + + const now = new Date() + + return { + id: `${profile.account_id.toString()}-${generateId()}`, + name: profile.name || profile.display_name || 'Confluence User', + email: profile.email || syntheticConnectorEmail('confluence', profile.account_id), + image: profile.picture || undefined, + emailVerified: true, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in Confluence getUserInfo:', { error }) + return null + } + }, + }, + + { + providerId: 'jira', + clientId: env.JIRA_CLIENT_ID as string, + clientSecret: env.JIRA_CLIENT_SECRET as string, + authorizationUrl: 'https://auth.atlassian.com/authorize', + tokenUrl: 'https://auth.atlassian.com/oauth/token', + userInfoUrl: 'https://api.atlassian.com/me', + scopes: getCanonicalScopesForProvider('jira'), + responseType: 'code', + pkce: true, + accessType: 'offline', + authentication: 'basic', + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/jira`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://api.atlassian.com/me', { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + }) + + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Error fetching Jira user info:', { + status: response.status, + statusText: response.statusText, + }) + return null + } + + const profile = await response.json() + + const now = new Date() + + return { + id: `${profile.account_id.toString()}-${generateId()}`, + name: profile.name || profile.display_name || 'Jira User', + email: profile.email || syntheticConnectorEmail('jira', profile.account_id), + image: profile.picture || undefined, + emailVerified: true, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in Jira getUserInfo:', { error }) + return null + } + }, + }, + + { + providerId: 'airtable', + clientId: env.AIRTABLE_CLIENT_ID as string, + clientSecret: env.AIRTABLE_CLIENT_SECRET as string, + authorizationUrl: 'https://airtable.com/oauth2/v1/authorize', + tokenUrl: 'https://airtable.com/oauth2/v1/token', + userInfoUrl: 'https://api.airtable.com/v0/meta/whoami', + scopes: getCanonicalScopesForProvider('airtable'), + responseType: 'code', + pkce: true, + accessType: 'offline', + authentication: 'basic', + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/airtable`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://api.airtable.com/v0/meta/whoami', { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + }) + + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Error fetching Airtable user info:', { + status: response.status, + statusText: response.statusText, + }) + return null + } + + const data = await response.json() + const now = new Date() + + return { + id: `${data.id.toString()}-${generateId()}`, + name: data.email ? data.email.split('@')[0] : 'Airtable User', + email: data.email || syntheticConnectorEmail('airtable', data.id), + emailVerified: !!data.email, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in Airtable getUserInfo:', { error }) + return null + } + }, + }, + + { + providerId: 'notion', + clientId: env.NOTION_CLIENT_ID as string, + clientSecret: env.NOTION_CLIENT_SECRET as string, + authorizationUrl: 'https://api.notion.com/v1/oauth/authorize', + tokenUrl: 'https://api.notion.com/v1/oauth/token', + userInfoUrl: 'https://api.notion.com/v1/users/me', + responseType: 'code', + pkce: false, + accessType: 'offline', + authentication: 'basic', + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/notion`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://api.notion.com/v1/users/me', { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + 'Notion-Version': '2022-06-28', + }, + }) + + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Error fetching Notion user info:', { + status: response.status, + statusText: response.statusText, + }) + return null + } + + const profile: NotionSelfResponse = await response.json() + const now = new Date() + + /** + * An OAuth integration token always resolves to a bot user, so the + * top-level `person` is never present and the top-level `name` is the + * integration's own name ("Sim"), not the human's. The authorizing + * human — and their email — live under `bot.owner.user`, which is + * only populated when `bot.owner.type === 'user'` (a workspace-owned + * internal integration reports `{ type: 'workspace' }` instead). + * @see https://developers.notion.com/reference/get-self + */ + const ownerUser = profile.bot?.owner?.type === 'user' ? profile.bot.owner.user : null + const stableId = ownerUser?.id || profile.id + const ownerEmail = ownerUser?.person?.email + + return { + id: `${stableId}-${generateId()}`, + name: ownerUser?.name || profile.name || 'Notion User', + email: ownerEmail || syntheticConnectorEmail('notion', stableId), + emailVerified: !!ownerEmail, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in Notion getUserInfo:', { error }) + return null + } + }, + }, + + { + 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', + userInfoUrl: 'https://api.monday.com/v2', + scopes: getCanonicalScopesForProvider('monday'), + responseType: 'code', + pkce: false, + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/monday`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://api.monday.com/v2', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'API-Version': '2024-10', + Authorization: tokens.accessToken ?? '', + }, + body: JSON.stringify({ query: '{ me { id name email } }' }), + }) + + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Error fetching Monday.com user info:', { + status: response.status, + statusText: response.statusText, + }) + return null + } + + const data = await response.json() + const user = data.data?.me + if (!user) return null + + 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, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in Monday.com getUserInfo:', { error }) + return null + } + }, + }, + + { + providerId: 'reddit', + clientId: env.REDDIT_CLIENT_ID as string, + clientSecret: env.REDDIT_CLIENT_SECRET as string, + authorizationUrl: 'https://www.reddit.com/api/v1/authorize?duration=permanent', + tokenUrl: 'https://www.reddit.com/api/v1/access_token', + userInfoUrl: 'https://oauth.reddit.com/api/v1/me', + scopes: getCanonicalScopesForProvider('reddit'), + responseType: 'code', + pkce: false, + accessType: 'offline', + authentication: 'basic', + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/reddit`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://oauth.reddit.com/api/v1/me', { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + 'User-Agent': 'sim-studio/1.0', + }, + }) + + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Error fetching Reddit user info:', { + status: response.status, + statusText: response.statusText, + }) + return null + } + + const data = await response.json() + const now = new Date() + + return { + id: `${data.id.toString()}-${generateId()}`, + name: data.name || 'Reddit User', + email: syntheticConnectorEmail('reddit', data.name ?? data.id), + image: data.icon_img || undefined, + emailVerified: false, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in Reddit getUserInfo:', { error }) + return null + } + }, + }, + + { + providerId: 'clickup', + clientId: env.CLICKUP_CLIENT_ID as string, + clientSecret: env.CLICKUP_CLIENT_SECRET as string, + authorizationUrl: 'https://app.clickup.com/api', + tokenUrl: 'https://api.clickup.com/api/v2/oauth/token', + scopes: getCanonicalScopesForProvider('clickup'), + responseType: 'code', + pkce: false, + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/clickup`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://api.clickup.com/api/v2/user', { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Error fetching ClickUp user info:', { + status: response.status, + statusText: response.statusText, + }) + return null + } + + const data = await response.json() + const user = data.user + if (!user?.id) return null + + const now = new Date() + return { + id: `${user.id.toString()}-${generateId()}`, + name: user.username || 'ClickUp User', + email: user.email || syntheticConnectorEmail('clickup', user.id), + emailVerified: !!user.email, + createdAt: now, + updatedAt: now, + image: user.profilePicture || undefined, + } + } catch (error) { + logger.error('Error in ClickUp getUserInfo:', { error }) + return null + } + }, + }, + + { + providerId: 'linear', + clientId: env.LINEAR_CLIENT_ID as string, + clientSecret: env.LINEAR_CLIENT_SECRET as string, + authorizationUrl: 'https://linear.app/oauth/authorize', + tokenUrl: 'https://api.linear.app/oauth/token', + scopes: getCanonicalScopesForProvider('linear'), + responseType: 'code', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/linear`, + pkce: true, + prompt: 'consent', + accessType: 'offline', + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://api.linear.app/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${tokens.accessToken}`, + }, + body: JSON.stringify({ + query: `{ + viewer { + id + email + name + avatarUrl + } + }`, + }), + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error('Linear API error:', { + status: response.status, + statusText: response.statusText, + body: errorText, + }) + throw new Error(`Linear API error: ${response.status} ${response.statusText}`) + } + + const { data, errors } = await response.json() + + if (errors) { + logger.error('GraphQL errors:', errors) + throw new Error(`GraphQL errors: ${JSON.stringify(errors)}`) + } + + if (!data?.viewer) { + logger.error('No viewer data in response:', data) + throw new Error('No viewer data in response') + } + + const viewer = data.viewer + + return { + id: `${viewer.id.toString()}-${generateId()}`, + email: viewer.email || syntheticConnectorEmail('linear', viewer.id), + name: viewer.name, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + image: viewer.avatarUrl || undefined, + } + } catch (error) { + logger.error('Error in getUserInfo:', error) + throw error + } + }, + }, + + { + providerId: 'attio', + clientId: env.ATTIO_CLIENT_ID as string, + clientSecret: env.ATTIO_CLIENT_SECRET as string, + authorizationUrl: 'https://app.attio.com/authorize', + tokenUrl: 'https://app.attio.com/oauth/token', + scopes: getCanonicalScopesForProvider('attio'), + responseType: 'code', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/attio`, + getUserInfo: async (tokens) => { + try { + /** + * Resolve the *authorizing* member, not an arbitrary one. Listing + * `/v2/workspace_members` returns every member of the workspace in no + * defined order, so taking `data[0]` records a stranger's id as the + * account's stable external id — which then collapses two different + * Attio members into one account row via the stale-sibling dedupe in + * the `account.create.after` hook. + * + * `/v2/self` requires no scope and reports who authorized the token. + * @see https://docs.attio.com/rest-api/endpoint-reference/meta/identify + */ + const selfResponse = await fetch('https://api.attio.com/v2/self', { + headers: { Authorization: `Bearer ${tokens.accessToken}` }, + }) + + if (!selfResponse.ok) { + const errorText = await selfResponse.text().catch(() => '') + logger.error('Attio /v2/self error:', { + status: selfResponse.status, + statusText: selfResponse.statusText, + body: errorText, + }) + return null + } + + const self: AttioSelfResponse = await selfResponse.json() + const memberId = self.authorized_by_workspace_member_id + + if (!memberId) { + logger.error('Attio /v2/self returned no authorizing workspace member', { + active: self.active, + workspaceId: self.workspace_id, + }) + return null + } + + /** + * Fetch that member by id rather than listing and filtering. Requires + * `user_management:read`, which Sim always requests for Attio. + * @see https://docs.attio.com/rest-api/endpoint-reference/workspace-members/get-a-workspace-member + */ + const memberResponse = await fetch( + `https://api.attio.com/v2/workspace_members/${encodeURIComponent(memberId)}`, + { headers: { Authorization: `Bearer ${tokens.accessToken}` } } + ) + + if (!memberResponse.ok) { + const errorText = await memberResponse.text().catch(() => '') + logger.error('Attio workspace member fetch error:', { + status: memberResponse.status, + statusText: memberResponse.statusText, + body: errorText, + }) + return null + } + + const { data: member }: AttioWorkspaceMemberResponse = await memberResponse.json() + + if (!member) { + logger.error('Attio workspace member not found', { memberId }) + return null + } + + const email = member.email_address + const fullName = `${member.first_name ?? ''} ${member.last_name ?? ''}`.trim() + + return { + id: `${member.id.workspace_member_id}-${generateId()}`, + email: email || syntheticConnectorEmail('attio', member.id.workspace_member_id), + name: fullName || email || 'Attio User', + emailVerified: Boolean(email), + createdAt: new Date(), + updatedAt: new Date(), + image: member.avatar_url || undefined, + } + } catch (error) { + /** + * Return null rather than rethrowing: Better Auth's `handleUserInfo` + * does not wrap `getUserInfo`, so a throw escapes the callback route + * as a raw 500 with no way back into the app, while null redirects + * with `user_info_is_missing`. + */ + logger.error('Error in Attio getUserInfo:', error) + return null + } + }, + }, + + { + providerId: 'box', + clientId: env.BOX_CLIENT_ID as string, + clientSecret: env.BOX_CLIENT_SECRET as string, + authorizationUrl: 'https://account.box.com/api/oauth2/authorize', + tokenUrl: 'https://api.box.com/oauth2/token', + scopes: getCanonicalScopesForProvider('box'), + responseType: 'code', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/box`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://api.box.com/2.0/users/me', { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error('Box API error:', { + status: response.status, + statusText: response.statusText, + body: errorText, + }) + throw new Error(`Box API error: ${response.status} ${response.statusText}`) + } + + const data = await response.json() + + return { + id: `${data.id}-${generateId()}`, + email: data.login || syntheticConnectorEmail('box', data.id), + name: data.name || data.login || 'Box User', + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + image: data.avatar_url || undefined, + } + } catch (error) { + logger.error('Error in Box getUserInfo:', error) + throw error + } + }, + }, + + { + providerId: 'dropbox', + clientId: env.DROPBOX_CLIENT_ID as string, + clientSecret: env.DROPBOX_CLIENT_SECRET as string, + authorizationUrl: 'https://www.dropbox.com/oauth2/authorize', + tokenUrl: 'https://api.dropboxapi.com/oauth2/token', + scopes: getCanonicalScopesForProvider('dropbox'), + responseType: 'code', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/dropbox`, + pkce: true, + accessType: 'offline', + prompt: 'consent', + authorizationUrlParams: { + token_access_type: 'offline', + }, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://api.dropboxapi.com/2/users/get_current_account', { + method: 'POST', + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error('Dropbox API error:', { + status: response.status, + statusText: response.statusText, + body: errorText, + }) + throw new Error(`Dropbox API error: ${response.status} ${response.statusText}`) + } + + const data = await response.json() + + return { + id: `${data.account_id.toString()}-${generateId()}`, + email: data.email, + name: data.name?.display_name || data.email, + emailVerified: data.email_verified || false, + createdAt: new Date(), + updatedAt: new Date(), + image: data.profile_photo_url || undefined, + } + } catch (error) { + logger.error('Error in getUserInfo:', error) + throw error + } + }, + }, + + { + providerId: 'asana', + clientId: env.ASANA_CLIENT_ID as string, + clientSecret: env.ASANA_CLIENT_SECRET as string, + authorizationUrl: 'https://app.asana.com/-/oauth_authorize', + tokenUrl: 'https://app.asana.com/-/oauth_token', + userInfoUrl: 'https://app.asana.com/api/1.0/users/me', + scopes: getCanonicalScopesForProvider('asana'), + responseType: 'code', + pkce: false, + accessType: 'offline', + authentication: 'basic', + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/asana`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://app.asana.com/api/1.0/users/me', { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + }) + + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Error fetching Asana user info:', { + status: response.status, + statusText: response.statusText, + }) + return null + } + + const result = await response.json() + const profile = result.data + + const now = new Date() + + return { + id: `${profile.gid.toString()}-${generateId()}`, + name: profile.name || 'Asana User', + email: profile.email || syntheticConnectorEmail('asana', profile.gid), + image: profile.photo?.image_128x128 || undefined, + emailVerified: !!profile.email, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in Asana getUserInfo:', { error }) + return null + } + }, + }, + + { + providerId: 'slack', + clientId: env.SLACK_CLIENT_ID as string, + clientSecret: env.SLACK_CLIENT_SECRET as string, + authorizationUrl: 'https://slack.com/oauth/v2/authorize', + tokenUrl: 'https://slack.com/api/oauth.v2.access', + userInfoUrl: 'https://slack.com/api/users.identity', + scopes: getCanonicalScopesForProvider('slack'), + responseType: 'code', + accessType: 'offline', + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/slack`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://slack.com/api/auth.test', { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + }) + + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Slack auth.test failed', { + status: response.status, + statusText: response.statusText, + }) + return null + } + + const data = await response.json() + + if (!data.ok) { + logger.error('Slack auth.test returned error', { error: data.error }) + return null + } + + const teamId = data.team_id || 'unknown' + const teamName = data.team || 'Slack Workspace' + + /** + * Tag the accountId with the installing user's Slack id (from the OAuth + * v2 `authed_user.id`, preserved on `tokens.raw`) behind a `usr_` marker. + * The channels selector uses it to scope private-channel visibility to + * the installer's own Slack membership, per Slack Marketplace rules. The + * marker disambiguates it from a legacy bot id (same `U.../B...` shape); + * absent it, we keep the legacy format and today's behavior. + */ + const rawTokens = (tokens as typeof tokens & { raw?: Record }).raw + const authedUser = rawTokens?.authed_user as { id?: string } | undefined + const installerUserId = authedUser?.id + const userSegment = installerUserId + ? `usr_${installerUserId}` + : data.user_id || data.bot_id || 'bot' + + const uniqueId = `${teamId}-${userSegment}` + + logger.info('Slack credential identifier', { + teamId, + userSegment, + uniqueId, + teamName, + hasInstallerId: !!installerUserId, + }) + + return { + id: `${uniqueId}-${generateId()}`, + name: teamName, + email: syntheticConnectorEmail('slack', uniqueId), + emailVerified: false, + createdAt: new Date(), + updatedAt: new Date(), + } + } catch (error) { + logger.error('Error creating Slack bot profile:', { error }) + return null + } + }, + }, + + { + providerId: 'webflow', + clientId: env.WEBFLOW_CLIENT_ID as string, + clientSecret: env.WEBFLOW_CLIENT_SECRET as string, + authorizationUrl: 'https://webflow.com/oauth/authorize', + tokenUrl: 'https://api.webflow.com/oauth/access_token', + userInfoUrl: 'https://api.webflow.com/v2/token/introspect', + scopes: getCanonicalScopesForProvider('webflow'), + responseType: 'code', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/webflow`, + getUserInfo: async (tokens) => { + try { + logger.info('Fetching Webflow user info') + + const response = await fetch('https://api.webflow.com/v2/token/introspect', { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + }) + + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Error fetching Webflow user info:', { + status: response.status, + statusText: response.statusText, + }) + return null + } + + const data = await response.json() + const now = new Date() + + const userId = data.user_id || 'user' + const uniqueId = `webflow-${userId}` + + return { + id: `${uniqueId}-${generateId()}`, + name: data.user_name || 'Webflow User', + email: syntheticConnectorEmail('webflow', userId), + emailVerified: false, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in Webflow getUserInfo:', { error }) + return null + } + }, + }, + { + providerId: 'linkedin', + clientId: env.LINKEDIN_CLIENT_ID as string, + clientSecret: env.LINKEDIN_CLIENT_SECRET as string, + authorizationUrl: 'https://www.linkedin.com/oauth/v2/authorization', + tokenUrl: 'https://www.linkedin.com/oauth/v2/accessToken', + userInfoUrl: 'https://api.linkedin.com/v2/userinfo', + scopes: getCanonicalScopesForProvider('linkedin'), + responseType: 'code', + accessType: 'offline', + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/linkedin`, + getUserInfo: async (tokens) => { + try { + logger.info('Fetching LinkedIn user profile') + + const response = await fetch('https://api.linkedin.com/v2/userinfo', { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + }) + + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Failed to fetch LinkedIn user info', { + status: response.status, + statusText: response.statusText, + }) + throw new Error('Failed to fetch user info') + } + + const profile = await response.json() + + return { + id: `${profile.sub}-${generateId()}`, + name: profile.name || 'LinkedIn User', + email: profile.email || syntheticConnectorEmail('linkedin', profile.sub), + emailVerified: true, + image: profile.picture || undefined, + createdAt: new Date(), + updatedAt: new Date(), + } + } catch (error) { + logger.error('Error in LinkedIn getUserInfo:', { error }) + return null + } + }, + }, + + { + providerId: 'zoom', + clientId: env.ZOOM_CLIENT_ID as string, + clientSecret: env.ZOOM_CLIENT_SECRET as string, + authorizationUrl: 'https://zoom.us/oauth/authorize', + tokenUrl: 'https://zoom.us/oauth/token', + userInfoUrl: 'https://api.zoom.us/v2/users/me', + scopes: getCanonicalScopesForProvider('zoom'), + responseType: 'code', + accessType: 'offline', + authentication: 'basic', + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/zoom`, + getUserInfo: async (tokens) => { + try { + logger.info('Fetching Zoom user profile') + + const response = await fetch('https://api.zoom.us/v2/users/me', { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + }) + + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Failed to fetch Zoom user info', { + status: response.status, + statusText: response.statusText, + }) + throw new Error('Failed to fetch user info') + } + + const profile = await response.json() + + return { + id: `${profile.id.toString()}-${generateId()}`, + name: `${profile.first_name || ''} ${profile.last_name || ''}`.trim() || 'Zoom User', + email: profile.email || syntheticConnectorEmail('zoom', profile.id), + emailVerified: profile.verified === 1, + image: profile.pic_url || undefined, + createdAt: new Date(), + updatedAt: new Date(), + } + } catch (error) { + logger.error('Error in Zoom getUserInfo:', { error }) + return null + } + }, + }, + + { + providerId: 'spotify', + clientId: env.SPOTIFY_CLIENT_ID as string, + clientSecret: env.SPOTIFY_CLIENT_SECRET as string, + authorizationUrl: 'https://accounts.spotify.com/authorize', + tokenUrl: 'https://accounts.spotify.com/api/token', + userInfoUrl: 'https://api.spotify.com/v1/me', + scopes: getCanonicalScopesForProvider('spotify'), + responseType: 'code', + authentication: 'basic', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/spotify`, + getUserInfo: async (tokens) => { + try { + logger.info('Fetching Spotify user profile') + + const response = await fetch('https://api.spotify.com/v1/me', { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + }) + + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Failed to fetch Spotify user info', { + status: response.status, + statusText: response.statusText, + }) + throw new Error('Failed to fetch user info') + } + + const profile = await response.json() + + return { + id: `${profile.id.toString()}-${generateId()}`, + name: profile.display_name || 'Spotify User', + email: profile.email || syntheticConnectorEmail('spotify', profile.id), + emailVerified: true, + image: profile.images?.[0]?.url || undefined, + createdAt: new Date(), + updatedAt: new Date(), + } + } catch (error) { + logger.error('Error in Spotify getUserInfo:', { error }) + return null + } + }, + }, + + { + providerId: 'wordpress', + clientId: env.WORDPRESS_CLIENT_ID as string, + clientSecret: env.WORDPRESS_CLIENT_SECRET as string, + authorizationUrl: 'https://public-api.wordpress.com/oauth2/authorize', + tokenUrl: 'https://public-api.wordpress.com/oauth2/token', + userInfoUrl: 'https://public-api.wordpress.com/rest/v1.1/me', + scopes: getCanonicalScopesForProvider('wordpress'), + responseType: 'code', + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/wordpress`, + getUserInfo: async (tokens) => { + try { + logger.info('Fetching WordPress.com user profile') + + const response = await fetch('https://public-api.wordpress.com/rest/v1.1/me', { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + }) + + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Failed to fetch WordPress.com user info', { + status: response.status, + statusText: response.statusText, + }) + throw new Error('Failed to fetch user info') + } + + const profile = await response.json() + + return { + id: `${profile.ID?.toString() || profile.id?.toString()}-${generateId()}`, + name: profile.display_name || profile.username || 'WordPress User', + email: + profile.email || + syntheticConnectorEmail('wordpress', profile.username ?? profile.ID ?? profile.id), + emailVerified: profile.email_verified || false, + image: profile.avatar_URL || undefined, + createdAt: new Date(), + updatedAt: new Date(), + } + } catch (error) { + logger.error('Error in WordPress.com getUserInfo:', { error }) + return null + } + }, + }, + + // DocuSign provider + { + providerId: 'docusign', + clientId: env.DOCUSIGN_CLIENT_ID as string, + clientSecret: env.DOCUSIGN_CLIENT_SECRET as string, + authorizationUrl: 'https://account-d.docusign.com/oauth/auth', + tokenUrl: 'https://account-d.docusign.com/oauth/token', + userInfoUrl: 'https://account-d.docusign.com/oauth/userinfo', + scopes: getCanonicalScopesForProvider('docusign'), + responseType: 'code', + accessType: 'offline', + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/docusign`, + getUserInfo: async (tokens) => { + try { + logger.info('Fetching DocuSign user profile') + + const response = await fetch('https://account-d.docusign.com/oauth/userinfo', { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + }) + + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Failed to fetch DocuSign user info', { + status: response.status, + statusText: response.statusText, + }) + throw new Error('Failed to fetch user info') + } + + const data = await response.json() + const accounts = data.accounts ?? [] + const defaultAccount = + accounts.find((a: { is_default: boolean }) => a.is_default) ?? accounts[0] + const accountName = defaultAccount?.account_name || 'DocuSign Account' + + if (data.scope) { + tokens.scopes = data.scope.split(/\s+/).filter(Boolean) + } + + return { + id: `${data.sub}-${generateId()}`, + name: data.name || accountName, + email: data.email || syntheticConnectorEmail('docusign', data.sub), + emailVerified: true, + image: undefined, + createdAt: new Date(), + updatedAt: new Date(), + } + } catch (error) { + logger.error('Error in DocuSign getUserInfo:', { error }) + return null + } + }, + }, + + // Cal.com provider + { + providerId: 'calcom', + clientId: env.CALCOM_CLIENT_ID as string, + authorizationUrl: 'https://app.cal.com/auth/oauth2/authorize', + tokenUrl: 'https://app.cal.com/api/auth/oauth/token', + scopes: getCanonicalScopesForProvider('calcom'), + responseType: 'code', + pkce: true, + accessType: 'offline', + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/calcom`, + getUserInfo: async (tokens) => { + try { + logger.info('Fetching Cal.com user profile') + + const response = await fetch('https://api.cal.com/v2/me', { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + 'cal-api-version': '2024-08-13', + }, + }) + + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Failed to fetch Cal.com user info', { + status: response.status, + statusText: response.statusText, + }) + throw new Error('Failed to fetch user info') + } + + const data = await response.json() + const profile = data.data || data + + return { + id: `${profile.id?.toString()}-${generateId()}`, + name: profile.name || 'Cal.com User', + email: profile.email || syntheticConnectorEmail('calcom', profile.id), + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + } + } catch (error) { + logger.error('Error in Cal.com getUserInfo:', { error }) + return null + } + }, + }, + ] +} diff --git a/apps/sim/lib/oauth/microsoft.ts b/apps/sim/lib/oauth/microsoft.ts index 1e9be406f2a..8da533eee72 100644 --- a/apps/sim/lib/oauth/microsoft.ts +++ b/apps/sim/lib/oauth/microsoft.ts @@ -1,3 +1,13 @@ +import { createLogger } from '@sim/logger' +import { generateId } from '@sim/utils/id' + +/** + * Scoped `'Auth'` because these lines are emitted from the OAuth callback path + * and were logged under that scope before this helper moved here; renaming the + * scope would break existing log queries and alerts. + */ +const logger = createLogger('Auth') + const MICROSOFT_REFRESH_TOKEN_LIFETIME_DAYS = 90 export const PROACTIVE_REFRESH_THRESHOLD_DAYS = 7 @@ -43,3 +53,53 @@ export function deriveMicrosoftEmailVerified( (Array.isArray(verifiedSecondary) && verifiedSecondary.includes(email)) ) } + +/** + * Extracts user info from a Microsoft ID token JWT instead of calling Graph API /me. + * This avoids 403 errors for external tenant users whose admin hasn't consented to Graph API scopes. + * The ID token is always returned when the openid scope is requested. + */ +export function getMicrosoftUserInfoFromIdToken( + tokens: { accessToken?: string }, + providerId: string +) { + const idToken = (tokens as Record).idToken as string | undefined + if (!idToken) { + logger.error( + `Microsoft ${providerId} OAuth: no ID token received. Ensure openid scope is requested.` + ) + throw new Error(`Microsoft ${providerId} OAuth requires an ID token (openid scope)`) + } + + const parts = idToken.split('.') + if (parts.length !== 3) { + throw new Error(`Microsoft ${providerId} OAuth: malformed ID token`) + } + + let payload: Record + try { + payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8')) + } catch { + throw new Error(`Microsoft ${providerId} OAuth: failed to decode ID token payload`) + } + + const email = + (payload.email as string) || (payload.preferred_username as string) || (payload.upn as string) + if (!email) { + throw new Error( + `Microsoft ${providerId} OAuth: ID token contains no email, preferred_username, or upn claim` + ) + } + + const emailVerified = deriveMicrosoftEmailVerified(payload, email) + + const now = new Date() + return { + id: `${payload.oid || payload.sub}-${generateId()}`, + name: (payload.name as string) || 'Microsoft User', + email, + emailVerified, + createdAt: now, + updatedAt: now, + } +} From f07160aec74e9c84e21919b7dc2b77a2ef285449 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 3 Aug 2026 09:39:04 -0700 Subject: [PATCH 08/11] fix(trigger): deps to include e2b and daytona (#6205) --- apps/sim/trigger.config.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/sim/trigger.config.ts b/apps/sim/trigger.config.ts index 2cc7c6e28dc..5f1a45cca2e 100644 --- a/apps/sim/trigger.config.ts +++ b/apps/sim/trigger.config.ts @@ -65,6 +65,12 @@ export default defineConfig({ '@earendil-works/pi-ai', '@earendil-works/pi-coding-agent', 'cpu-features', + // `@e2b/code-interpreter` copies `e2b`'s members onto its exports at runtime, so + // bundling drops every name a static analyzer cannot see — `Template` among them. + // Same reason `next.config.ts` keeps these in `serverExternalPackages`. + 'e2b', + '@e2b/code-interpreter', + '@daytona/sdk', ], extensions: [ syncEnvVars(() => [{ name: 'DB_APP_NAME', value: 'sim-trigger' }]), @@ -84,6 +90,8 @@ export default defineConfig({ '@react-email/render', '@earendil-works/pi-ai', '@earendil-works/pi-coding-agent', + '@e2b/code-interpreter', + '@daytona/sdk', ], }), ], From 03333ce30da73ba75294edc0f3e5fd228adfafa6 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 3 Aug 2026 09:44:37 -0700 Subject: [PATCH 09/11] feat(library): AEO vs GEO: What Answer Engine and Generative Engine Optimization Actually Mean (#6204) * feat(library): AEO vs GEO: What Answer Engine and Generative Engine Optimization Actually Mean * feat(library): add generated cover for AEO vs GEO post --------- Co-authored-by: Sim Pi Agent Co-authored-by: Waleed Latif --- .../index.mdx | 94 ++++++++++++++++++ .../cover.jpg | Bin 0 -> 32369 bytes 2 files changed, 94 insertions(+) create mode 100644 apps/sim/content/library/aeo-vs-geo-what-answer-engine-and-generative-engine-optimization-actually-mean/index.mdx create mode 100644 apps/sim/public/library/aeo-vs-geo-what-answer-engine-and-generative-engine-optimization-actually-mean/cover.jpg diff --git a/apps/sim/content/library/aeo-vs-geo-what-answer-engine-and-generative-engine-optimization-actually-mean/index.mdx b/apps/sim/content/library/aeo-vs-geo-what-answer-engine-and-generative-engine-optimization-actually-mean/index.mdx new file mode 100644 index 00000000000..a692fb2422f --- /dev/null +++ b/apps/sim/content/library/aeo-vs-geo-what-answer-engine-and-generative-engine-optimization-actually-mean/index.mdx @@ -0,0 +1,94 @@ +--- +slug: aeo-vs-geo-what-answer-engine-and-generative-engine-optimization-actually-mean +title: 'AEO vs GEO: What Answer Engine and Generative Engine Optimization Actually Mean' +description: 'Understand AEO vs GEO, how answer engine and generative engine optimization differ from traditional SEO, and the content practices that make answers easier to cite.' +date: 2026-08-03 +updated: 2026-08-03 +authors: + - andrew +readingTime: 6 +tags: [SEO, Generative AI, Content Strategy, Sim] +ogImage: /library/aeo-vs-geo-what-answer-engine-and-generative-engine-optimization-actually-mean/cover.jpg +canonical: https://www.sim.ai/library/aeo-vs-geo-what-answer-engine-and-generative-engine-optimization-actually-mean +draft: false +faq: + - q: "What is the difference between AEO and GEO?" + a: "AEO emerged around direct-answer surfaces such as featured snippets, while GEO focuses on being selected and cited in synthesized generative answers. In practice, both require clear, self-contained, verifiable content, so their tactics substantially overlap." + - q: "Is GEO replacing SEO?" + a: "No. SEO still helps people and search systems discover pages. GEO adds an emphasis on writing passages that can be quoted, verified, and used as evidence in a generated answer." + - q: "What content is most useful for answer engines?" + a: "Content that answers a specific question early, defines its terms, gives concrete evidence, and clearly states tradeoffs is easier for both readers and answer systems to use." + - q: "Do comparison tables help with AEO and GEO?" + a: "A comparison table can make distinctions between options explicit. It works best when its rows use clear criteria and its surrounding text explains the practical tradeoffs." +--- + +## TL;DR + +- **AEO (Answer Engine Optimization)** means structuring content so a machine can lift it whole into a direct answer, from [featured snippets](https://developers.google.com/search/docs/appearance/featured-snippets) to AI chat replies. +- **GEO (Generative Engine Optimization)** means optimizing content so generative systems select and cite it when they synthesize an answer. +- The terms now overlap heavily, and the label matters less than the mechanics both require. +- The comparison below shows how traditional SEO, AEO, and GEO differ across focus, surface, and tactics. +- The mechanics section covers the concrete moves that make content easier to quote and verify, whichever label you use. + +## What is AEO (Answer Engine Optimization)? + +Answer Engine Optimization (AEO) is the practice of structuring content so a machine can lift a complete answer out of it and present that answer directly to a user. The term grew out of the answer-box era: [Google describes featured snippets as excerpts from web pages that it automatically determines can answer a searcher's question](https://developers.google.com/search/docs/appearance/featured-snippets). + +That origin shaped the core mechanic. To win a snippet or a voice-style answer, a passage needs to make sense on its own, answer a specific question in its first sentence or two, and need no surrounding context to be understood. A page that buries its answer three paragraphs down is less useful than a page that leads with it. + +AEO can also describe AI chat surfaces, but the mechanic does not change with the surface. The goal is still to write an answer a machine can quote without editing. The vocabulary expanded to include AI answers; the discipline stayed focused on self-contained, question-first structure. + +## What is GEO (Generative Engine Optimization)? + +GEO (Generative Engine Optimization) is the practice of structuring content so generative AI systems select and cite it when they synthesize an answer. The term comes from the 2023 paper by researchers at Princeton, Georgia Tech, and the Allen Institute for AI, ["GEO: Generative Engine Optimization"](https://arxiv.org/abs/2311.09735), which examined how content changes affect visibility in generative-engine responses. + +The paper framed visibility and citation rate inside generative outputs as useful measures: how often and how prominently a source appears in an answer a model produces. That is a newer framing than AEO. AEO grew around direct-answer surfaces; GEO starts from a different question: when a model composes an answer from many sources, what makes it choose yours? + +GEO targets surfaces where an AI reads across documents and writes a synthesized response rather than lifting one boxed answer. [ChatGPT Search presents answers with source citations](https://help.openai.com/en/articles/9237897-chatgpt-search), [Perplexity explains how its citations support answer claims](https://www.perplexity.ai/help-center/en/articles/10352895-what-are-citations), [Gemini is Google's generative AI assistant](https://gemini.google.com/), and [Google AI Overviews link to supporting web results](https://blog.google/products/search/generative-ai-search/). Winning in these surfaces means becoming one of the useful inputs to a response, not only pursuing a top-ranked document. + +## AEO vs GEO: where they actually diverge + +The difference between AEO and GEO is emphasis and origin, not a separate set of writing mechanics. AEO grew out of direct-answer results, where success meant supplying a concise response. GEO grew out of research into how generative models select sources, where success means appearing alongside other pages in a synthesized answer. Both describe the task of writing content a machine can lift and reuse. + +The single-answer framing is also less clean than it once was. [Google's description of AI Overviews](https://blog.google/products/search/generative-ai-search/) presents them as AI-generated overviews with links to explore supporting information, blending direct answers with cited sources. When one surface combines both patterns, drawing a hard line between the two disciplines becomes less useful. + +Arguing over which label is correct matters less than optimizing for machine extraction. Whether you call it AEO or GEO, answer the question early, write sections that stand alone when quoted, and support concrete specifics with sources a reader can check. + +## Traditional SEO vs AEO vs GEO + +The three approaches emphasize different output surfaces, even though their content mechanics overlap in practice. + +| Dimension | Traditional SEO | AEO | GEO | +| --- | --- | --- | --- | +| Primary focus | Making a page discoverable in ranked results, including the [ranking systems Google documents](https://developers.google.com/search/docs/fundamentals/ranking-systems) | Providing a concise, direct response that can be surfaced on its own | Supplying clear, verifiable material that can support a synthesized answer | +| Output surface | Ranked search results | Featured snippets and direct-answer experiences | Cited passages in generative answers | +| Useful tactics | Helpful information architecture, internal linking, and technical accessibility | Question-based headings, concise lead answers, and [structured data where it applies](https://developers.google.com/search/docs/appearance/structured-data/intro-structured-data) | First-sentence answers, self-contained sections, sourced specifics, and honest tradeoffs | + +The row that matters most is tactics, and the overlap there is the point. Both AEO and GEO benefit from answering the question early and structuring content so a machine can lift it cleanly. Traditional SEO remains important for discovery, but it does not replace the need for passages that are understandable when quoted on their own. + +## The mechanics that make content easier to cite, regardless of label + +Six practices make material easier to extract, quote, and verify. None depends on whether you call the work AEO or GEO. + +1. **Answer the question in the first one or two sentences of a section.** A lead that opens with backstory makes a reader or system hunt for the point. Put the direct answer first, then supply the reasoning and context. +2. **Write sections that stand alone when quoted.** A paragraph can be separated from the surrounding page in a summary or answer. Give each section enough context to make sense without the sentence before it. +3. **Use concrete, sourced specifics instead of adjectives.** A verifiable number, date, or named source gives a reader something to check. For example, "reduced latency by 40 percent" is more informative than "blazing fast" only when the measurement and its source are available. +4. **Build comparison tables where the content compares options.** A table makes the relationship between items explicit. Use clear criteria in its rows, and use the surrounding prose to explain what the comparison means in practice. +5. **Define terms on first use.** Defining an acronym in context gives readers and systems a complete answer to a definitional question rather than forcing them to infer the meaning from nearby text. +6. **Admit tradeoffs and drop promotional framing.** A useful source explains where an approach works well and where it does not. Writing "this works well for X but struggles with Y" gives readers a balanced comparison they can evaluate instead of an unsupported promise. + +For teams building AI-powered workflows, the same discipline also improves the material an agent works from. [What is an AI agent?](https://www.sim.ai/library/what-is-an-ai-agent-definition-how-it-works-and-examples) explains how agents use models, tools, memory, and goals; clear source material helps those systems and their users assess an answer. + +## How Sim applies this in practice + +For a team using Sim, AEO and GEO do not need separate checklists. Start with the editorial work: make the target question explicit, state the answer before its lead-up, define terms, and connect important claims to the evidence behind them. Those choices make an article more useful to a person reading it and more portable when a system needs a focused passage. + +The same approach is useful when documenting an AI workflow. A guide to [building AI agents with Sim](https://www.sim.ai/library/how-to-create-an-ai-agent) can state the outcome and constraints before its implementation detail, while an [AI agent observability](https://www.sim.ai/library/ai-agent-observability) plan can record the evidence needed to evaluate an answer or decision. Neither example requires choosing an AEO label over a GEO label first. + +Question-based H2s can mirror the language people use when they ask an assistant for help. Comparison tables can clarify choices. Honest tradeoffs can prevent a generated summary from turning a conditional recommendation into a blanket claim. These are practical writing decisions, not competing optimization programs. + +## The bottom line + +AEO and GEO describe closely related work: writing content so machines can extract, reuse, and, where the surface supports it, cite it rather than only rank it. The label you pick changes little about the work. Answer the question in the first sentence, write sections that hold up when quoted alone, support concrete specifics with sources, and admit tradeoffs. + +Do that and your content is more useful in direct-answer and generative-search experiences. Spend the effort on the mechanics, not the terminology. diff --git a/apps/sim/public/library/aeo-vs-geo-what-answer-engine-and-generative-engine-optimization-actually-mean/cover.jpg b/apps/sim/public/library/aeo-vs-geo-what-answer-engine-and-generative-engine-optimization-actually-mean/cover.jpg new file mode 100644 index 0000000000000000000000000000000000000000..2122a0d6261afc4d8cf0d7f1d1c5c4f8b03e1362 GIT binary patch literal 32369 zcmeFXWmH_jwl3OOaCZsr?h-V(ySuwvfCP7Ux8UyX!2?aB!7X_3;K5&$z0bM(oO|yW zZ@>5V)ao8R_Fe-mk57A zEKQA##X=CUhOq%8$n@_vf*w4cr%$f^)-lPOtB=*oA(CQQq*qQ~XU^Znd%+3({I|Ny z#czU_9Pn=y`Jc@HBZ2>s!2d|#|BD2WF!{8AV{phwoXyx?&DdXlub``{R|o-3h!O+- z*7qaZLMKA+yZF*G8T7hV^hd3SG@X4PhGz(;D*TIj03?BY!g2xw`-JUnU`ZqnzgPhc zxZJypwQk=407G}7`~R&|Fm9&Pl9d|d)sxA_RtY|xGj%em)7W3GY_>l#C`AX%c&|X^ zFm>Cm{n9%4CB|8jWs(`Cy2ddt6w#x>m17YsB{|kK1tvEkUctPoL-NG+j|1pmamU<$I+ij@Jy+q@5-aE@wbxR`xAB zZ`ym<%gELVt!oAOkLXv4-4X{VkEyixh6a}e^b0&cZ9V4$4W5C`!+Xh4dGBhgnLRWL zSU9=O(H{PEtLFyDVaNT+5T+~Kw{wYIDQt~zF~MiiP_QBX)hy7eA&`*uUnM}%+&n68 z@FV4c+VMme;hFSCut7Jo3Qw|h!y5*6R)2LSOqr|u&R>~E>|om>%ZH?xHt9Tc_hUR= zdg;%qniUFLSsJv@q+_uBs~Qr;@>^=U#cPF$n8kF*mZhBh$T<_W_D1Vlu<>K2q1!ON z#*36fdbr)Pkgos$-{o`+lJAZ87cAV5DzlgEeKyR&q&E_E?`RSclW;!P(u>~HR`F|~ zad$4g?6z8;MH~zaXC9f8k_g;6%u!91friMm>Nqz?a}j-2N>U@Oj>ahpz3WQioZ64q z=^Sl1)vi7gHQ!l35#ts=Dr~dcR^s0HuhQ^(6qPOV z4fadPIw9Kp^vyRC`Ll`ofi!J_&(lm=hk{~pO2}@+$Fooh>Du5j18SHH}xzU*k`&}S~3gfeV->W z*!K!uZj18aQ0{nA|#w3slX=}paskgrhLi_qYS@+DLH$n?1AY(@GP$Z|^C?Lf{= z=_Bd~W^IS6(^tTu$JL$4ygB<}sSnygJku*+*=0`4+G^`N61RU*pJ2SgI+M|#MJ`4Rwt+aEOt#M@f#$aMmU4GSr@xbSr8 zl&|KT#5L&AjVeT-6biB{`k82vSi|ofnUHAp-L#8cAntOT`cs zsPSI#w@Lbz!ibGyWWJ$B43!((ltAp9kT-rEh?y9Y)c8g4958(ztn%0ju-!>^`MMd_ zXF&EZ{d{|T5;hc@-xV|>F$PCqzdgRC{S6TJ`#u2>K~}6B-~O2bzLCJL?wq_uzvL^C z9bFRFO-+L%P{5Oix5WPl{geHCYri=1-;*OMQjidWd_%&>CH`;Xt}JvUUL2U6*d=vs7Xo8na%}B@b{> zcc6GA7`S)1>}z;_<)psHOUw&PSg+?CDyUu#MgvNC?oSM{ugDJy)-2_hpZ8OJF-6!+w2y|=;s4aNt?Ip;2FU4^sHRXvC+rLDH6xXtUU zqulx~!m4A1jBpiu@qQ*b=U1=4PBO{f__5#Avh}sj$sVH7O*3%)IQfFMiaFb zguF;8u8G1324de-$e%`waTg}zl<{9n@#lAF>JJ?#1{5&ujOTC2OP8h>6u?+bf4QHw z;$OQSo)jaU%fVDCJEYG^l091Af}HAaLg=+h)p<{R{~wA15u6+3)+g@PGo*m7LSIoW zdotEc|0q)V)bk~#$NnMdgYZ678R=aKqA&KDCyER8n?d-+dmSdAhAY7`O(UT6wY8y0 zk^XXG!_HGGmBdkbH`>KaJgTYKyR9&ad!*Lh)JAFp%bO!?qGjLxC8|xy&!^!yxFqmk z(w9s#DN+#1o)9l4tW=;D!S9K0+ZVb8MBt*8W+CQ2I~>ay3PKQafz3gScer-mq)FJB zv#40bF1ZoiluCW?IQ_RJSzr<0U?GB^9KkY9je><{Sxtq;N+^`6@uJkZ*u3V<$1~U8 zv6a41+-~iRzeLLF$x(f#xeMqe#H(`8kD%L&XENDDR#FYhH5P=@sEt(g@z8_+MSc*r z8n>Z_#wT1?s&y#?wMjThqS0S9|TA@Gk`FCm9{cFzrE^JWI#FfWkAJgAZp5El%L=< zdGYMVSJ=^|8nX1?1yl>xrH;mN8jD}Q(rD7EZ}3Rx~>s;18){vUv5?!Te9@to@VFLdO5-EN6@F4^IA{ zWsG!iBenG{mOy?FNLC-nn&v3Y>2-t~6XOBp8~qz@5X z=SpK!JSEIhJ%A!Blt183qDsgXBx@(=_&2lb%sycxxNS9$w%iW!V+cE>5Rc+)abq*> zP3~=V$4kqKPL(munW-o=j9+S4D33~L&X!kHH&kWg71Yu56peFV9ss{2skHiv>aT82 z&AcIS>KB(i3>Jcc$*)N9_(Mc}MHnbwI4)||E07!b7B1M1Y#e`99k4$T9qKlYRZIyI z`_`(rF|n~QCj5)A;5ZKeMV(ODr;WBI3EydQCQ?=JB#7LT5n0Qdb~wav?%2pcb;dlN{r zW(TcnR2z%)tm3LtCRKkqHSZz7clo>M?CSHRFaBi799gvVIKp} zy^9J)>jmw3P>k|A1z1Nkc{Q1^d|3(d5*reZ-RRtqpalJ3|5 z`}8=QDelpUgg7l9#P;$Q1CmKtaShKBO^1`tSs=ZAj(yU9E=6zhg61yzbz&O%t+PpX zVCx3`i2JexX|tOeP{94s(VPWZ@%T$xpV^iWV!qwwQqjiDWTguFcfIK&G>2rgG zVdsD#=jeCZ`v}|&MYEf_>?MmE%N7RNe7E$ggfxr?%!xl7hVk8I-)Zc?<^cH0N}6|cXdd_) zUja|6i3kI?gWRdUU-^zroJ_%5dHPHF74Ru8rh;qn6)=Mkw~|kZ0UVs5x6ekr2K|cy zfgd3X%bn`~Ytnyl+;^xxk7e~i8{f?Q|-NJvNsNNDJH;2R8>HiU)&^M~&M zXk4%um{{o8INZ3TtQ?#Y%Epu;WaJd=&H>-Rlp!3LNQ8g`yaI03kk@NMr^ra{(iv>& zjh^9Kx!HKd^l28(y>GfUtUmdI%&e?G%R+Hn<=n`w?Nga~e9Zj9j)|r%#(^2b$xzMg z2LGW`El@et^vEs83?s#aXx!B=4dZe@`xiN0V;shev+HFNzlVh!TG8{(tnT|64cr%! z>iwZicJ1w~j^a;8uK=O#%RSQ{ykm((568X?0)k!G2V7SBa2Y^62pBy&%UsFGD} zjmiT)k+uLG1Vx0gX4^SSZVeUB*2}0Xi|9$5KG-+6VP+=5h_|$ZmW9k(; z?R!!#m)SM zl`Lu%AvqJcO8aIl)tUx{XzdU9;VNd!8lS{i+gw%22g?&WVVaV5=qLX#HT(S^YJO(g zqFNY7hLvbaOX4WQyg=xqd09Tl`}3q@*;+=ope2#b;%f!cI=$(kd(P_gihLFp;D6b9 zzO(X830jQm+s#wrsnie)RM)Wbwr56!>#fpZLWDD5Cij z`iX0C#z*khs&rsqdZ0Yxh0I`Th@k_VE{s;YZOLcbY-Ta?=sN70%GTv?!+0=LllO-qN6ZfXDDmk6wFw!;KWaPJ z#$GG(>pZTgDrwV@aBJP?|CKVX48?yH1KbIC`q%`DA?WzZWOF0_)b(!0hl8Pr;C3}E2%Tg)Lu(e5;%&sQufR}I1AU@+dMe{TFFAeRF-r!6saY`O*q3U0_%^bIg2-ky?1^_ zgR32XK7XQ}b}T(|*e$^uWlsavuk7Y5>bk>r=DCOq2oj=?=8k0etd#j8E@9PIj^WFF z(1Vh3e^Gc6Rz^o0X*X*0?A?f$yOmionj5@)8YCw6-iG%l#0`(7RB}GUIq*N(aZ`GX z{dD-@9J!^r!5`Jp1Z zdEQ9|?ONKS+Xi6NQY7(cjw?)Brdl71tZ;1HbD>T4-eK&=KPP zPBQntLdpUegxzN~Ox;cV^?M&)m!7h`p6dZ29rHPT^{_ zL%$XABj%G>6sGTS_Qzd{=mI5leb>3wmtFg2D-5FpYm0+Ay6#r_wl?oblHK{qN*ddt znZj{cTSDFg5T%>lSAX=I33GiwUoPJkO$G&+8!j$ldKv17A)~~L-5sHl;7z*ZSRqFB ze|x9Nv;9mp5FI7fdJ#kYLC0@-k9CT>@OfvhBoCFyvopw=}mM{-jPbD)|qRjqat8$(jwLeO|ovT!`c!DCT3i=veYHnk^`Rs!^3G>3T}-ftLvFL zm0G!V#*==0Iz71xiNWZRUppCkV_Pz#G-tdbZ*SAth~G7AqO^`l_~onz+s2ReZMCM6 zFw3iN!zJ_fht_6Q6;@W%*%@8liEn>^LKUQ{%jbES>Ed{g($#X#YpDF%ZeJ(Zt;-G;zo^0%x1X zYxuicG?KtS0@3?VKwI!S25sS=v*+7MzQ6ncVI}W{@j?oC^oEMcn1%-C!D!!v#VdLqGjw0OSQV{-z+|Al{jO~$& zU*jMK#(jwKka1EK5d(1dfPsc@49{;^1nTVrb2d3;jFKv5!dKuNg>mrNlWF=0d00I0 zXt_5wGs*+2+DB|tN5VgOG5S@B9`pkLrR&)u|0m(r6UcouNT zx{*08y>O>iiyf-X`}?7={b4eqA!4 zitRG>=)?rpdo$y)sW)}OM3ezR5}X&JyZs8$H>#@CR!1N-E#oP&LUP6+*EU5bj{hDp zrn8&}r6>jc!EMgU=<E)5 zxm}@tx)x>jMT?I55tf%thdD&=mZh-{5|2T7A_CS$P)cFd5@yBnbs0llITbv}OEM6k z=Otr=0@)kgFyXiRSrLAQ?vIc(x(`$ntxgq1$5CT$aseFwt|eJvY`Kx26wVvPvKD=H zCJxX?+|shAgB3LZio1W}<5Pk%@KKd>h0nOw-+u~Qd)oos^WYEOK)2s6J4yiP=XrrvQgS~lN=fw@bXan$RoQy6m3N( zo@{-XDhc2Ee0>lf5Sd_!ApdEuC=nM~TjtrlUGYfq)NUT*1_ z?sJ+sMVPThR_H_OIg;b|hawf9m@DK^|F`+#sw{AuPssn{%2I91+&c(gKN~?ketoyF zXps28Vtg?38J4ARrmi#EBaDPio+)Ybk#}vO>u73>cElyceBFJ%fl}sB>1bG6T}xr+ zMq(T>bU{-l+od3{3%nauXNwQ3-bO&8_8h<^Qa1@02@s9rvdCb?k`J=Yx`UYaM3|dS zzL@0Z%#gIF3lDEw&fy>t6TDO0`X@3yz zzvicq=%`Ce9ULZvvJ`Y<<8~M!@OU`tew;Pj z;HR*2_QJOk-SS7OP2LV1_CI|FYN+bOvg{#R9&|C(Lr-*wj)yzV-l2(CIgVV4tdSAK%O zHte4&yhUMG_KBQ_;>01Ai#8HWZ{YZ8G=W)EJGolph_mJ!eM|>w#*wpjCl75b%gX9h zZzkT`D}cCn8b=6AV)HWhL%9{(ZU@TvcBLX!(+aQsiY*RV9ELdXcx^c0TsqHPbhMis zGQUU(KdVQ&cemsuh3$_tnCote1!Z9De{a{T>W!~#Me+To8J#gFN+5?{=6YCo4d5c) zM8q^XRthC5)@T*rrxz7GKSQpjj{bG%%VDcbI*&&zjU&>)sJyxoJLUyMcyf3>SBGB* zL6RL-Am8Rp(!~m_buq8Bt=*Nuaej*y32B>Sd`);d6p~xe6`qTggAr)6cljGZz0T32 z@IQLxY*26$iVW{&JI|EcT5q6m7wPU$N|^me44AyqV8tjsL@?v%5V9wMjjW)qwoP;W zB)5U}A{H81fW=vYFRB{T3?Z;bWa-@jf0nq(=q81_T6%K@`YWKfZ<42X4CQUt!=0MO zrQ)C-C4)F#9_5dQ=Pg9Ux!`!#9R2OXUa7JHf_yeif4b#3RbZD1e)aMyeG$}5Wed~5 z<9CK|Ty2OXjkHyZJ3WU8z`bq4=#;{Ye~%J>Vf}KgF`3I!86wv{N5DVlyeLtg%>@hA zL86X^wOR16{$?Aubv2W#(9B9*#*O2s_sizMB^G*vB1xY$Tn`W0bJ46@EGvuWSrrGU z0CzHIv;+}Z(yJd>ZzVCb;K z(9R^zvBZp^8CMQd_^GythoQURGN~eDom2~j+m4~8SqWIv^$HU+FaD^Sq~_Ue&8n-_ z&{s@}h_qCx6O)5CV@G!;oy&p&SH7nq8dX`@bTiEOeT>TYf3;huB&NS*?!zillIOhULRab@4y>q-awy#_ zym>(qBuACLzDWAEs)B%ssIAyu8ATYQ88Q6@MZ2?7Iifs^cT`__*1JB!x_pZzPFx=j zYd_KH2bz(<6nV6?*bgq)YKUB~>WN>!npom+s*t5Gi4=(e6JZ%ci)96s<0tWY452NP z9dE*75Knpqq+9j~BD;kq?p-e^;Ho&yfY)m=EyxjK(bK;&lbBVB7m(G^FyI>LY#%mu zu#SBPQB$rfFGCoU$mH~))O091#quP4Cdi_OEe4kfp4%;F%d;`p{=!1Qyq^1qXwcyN z96&)l{uQt%aDX>DD%2uyQ*ltEBxvi6Kj2ToayUM|l^;@M4hD9kFAHulW_(jMO8Y%o zGbI_DavC94=w&b0)wTG4WVuECpZrzTV%RRy|CT;ho6^lQ<4t4B@yoAKVq=se!FvS+ zTCHmP8NFfSXN6ZlH}Vu}U&i3eD%dJr<~Rex>XYclED?Gp2SHP<1Q)k`kvA^=>J}yZ3 zba?Y;=Iz3J95qGFx=CLG?_YD@2hjf9E4nr~BvgOXtF_2>#@EwDx1!%g8OaRIsEiB6 zq1CXQwdy#&NFM}n>rdf-_g+z!;v10ygS^@Yg49}Naq^jua4fl-^o;!hh2ofs<8hC+ zrKKpTRey{-+n{l#(DppF>?tgK5tox-1Qs3mgAhw1E~{*N+n2TU5Aa6Zl01var&x0d zT@m*pw!0Ww(Hncw8>n!OX}dj*aX5jcpi!e*?|6V~L)j>#J73{V!-)LILEXL)3bA!K zxt()gva1uvvb?tK!`);=Zh8!D|2%;tsR^)BJ+Ya5K+WveFLQhyo|zv#nWSnU&$)Ke zPVIR5)~e&!ROi@8+BO}DohCp-sR6g+hiA75UZ~WV7ooWVt9LMd1*&T|PjXuU`c)SB z0XeKQT65XsB&M<&^D`f-7i&k@b#ITe9yWk{LoPYHxixTe6I7CGLHDM-wd1ED_SO3k zHI+QLEm;ijuDT7_2IdR1p#auU0y+t+n6)#$Ue?9Aevixph1`?_Mi#h1L)03R(|=UV zLdCyR6MkT@Lf9JA0d(>v`s*COIE6!4ccgJ7Je)r_Guti=<=2e-owzB8;dkJp-^Kn|&3N905_o(?g}T5#UmXuCO4hlTyNXY!GS1^YWWVK=64#XI*HMA;rLW zQO#G3!FgNLNTv#8V9CHD=VBd03X`+ysVCh#-(M{<)5}4o^dlESzAg)QsDz$x*i&?6 z%fgo%6gT(189nXzRCkaY0cd1fidSDFGY?#v=InSQyh2A36-YJSNHsT)ly&RdMGxA{ zFU!WmR%RX{UoDsPTxif4n93CW9lY|HY6|6QOE*f^14F5w##M7l=%rQ}-@;b$5h3o9-57O(8*g4Q$ z0Oq8sscw@m2f0Y1p+I}|mJ`oBi2|fHBijZ1dQII&4c6gMTV_XySaP6~uAM})GX6K5 z)~7h#>zI{+vCz^Adu@@OxHNpZzY2 z#-0I@i>!;_)y!8wjP<#jQGRmu9`zTR8venr0=Mp)kJkF41^^zoB%$WT`ZA^gs}GP zwJx?9{M`P-bEGPqi$ZQ$4w6r$_L}2MRV*)ELMwwMK382Ph-BAu8yYn|!z~8_n~w?l zGarl7coLm>W~wT_-VF}kTfyd{cm>H9iEg=+*HuJk6@5ZQNFz(2nu{}`6V+Wb)d6oo z-mI>_cNvdT+xSnYp{5`L^*QAmEd~CJdNOnz3r#m1yQXlMU2Lmlyn74Y`;pa~e%N9k zzc#TEb{s2QHqLExS#%fX(8CDnD@$R%#kqLtHSczMPRM~`Z%axQMmLM|>ie(3iqMoN zR~7{I8*HUDkUHaqJD}YP{R1pFQ9Xl2EZ>}!^dGLKSIo68Ic0V;mM&CPS2(IExFobs zpmZTiU=&_`wuN(c;VweaSO^=w4wAOeW-Yvg*xXGgFN2u3T&MPQ)XEAx40fL43E==FXu&&Fm)SWZAa3}I>X51VcOGcoZ z6|geZ4>)h$l#A$dR40}ScnWnr?o;)(G{108;1)BtG%laL3lISTVz8S5L)gvuSX>R`KP;P^p8#bW1)64es zZo0!3-(Fxef6|KsTgo*1kVF}DiXi@zU|0S>SWZ$E%CEaRu8&p3*-kABBW}@F7e?hB zCUAN|&Hkp{17~G-6^CyYYlffl!I8wJVLxzHVSk5c$5fc__lwK;Rn@NzuNpPJgPM8C zqnb)_6*e#y!;AlKGiT{!dPpVb;OWtK{I*iKd(GdDRCa53@Ok+s>sp z7yApUhv+o4&~^r?TZRedpM4oL3Sh(lR*JB}(*HC(Grvg~?Twd|udt7}lM_bjcVG#y z=CrTp6+UB8!N#&tK7_0WswDhFI4;b&wrOJ7d0;T_fS6UchUm{Av^$vPzZwlbh;Z;O zdG2N5mfcfZNKA`%r%uz@$Gh${3ql{~-wG6knvj%~WFQzYEzE30X1w^0szD)D? z3tXUJTvg_yl>i5r3x)raXCAi$sm~kwA9I{me}HN${7QP%bgbZ?uWR?DefZhmX!-p4 zJG0m4N6-ha;HTlIY5v6ST@01c=SWd!%S9 z=j-(C)q2xN?z*;R7b{nz0qa1q4Z(0&pB)yQU4CatR1F%OXI>9WAl#asMeJ@&angh# z8_bnS$bMq6iTsYiyrNl)t^#XoZj~i0U<1xW4!yC%+%=53PuVxUw`o6enP$%UF*cqP zWyOhSZ}uu`gkW`|n1@@j?2x$*N6KzRlGd9A>eBI$2C>&p)N_xT1zH*BXF|If2aAI^ zB~=c;t5^}cM8)TUJS+i3yucGqxy5v^HnP<~PM;&*w!^_HZf(i!RYpfj3(7RziwKrC z(^cpxbG;_CV+Mk1N(SBNUnMSURSVyfsSVG6Q1SI@B;;OKr28IqqYw%t|IPH+XWH~e zzs&vXh+1_O*g9*fETNV*0A`9@=n_d^6UXa*ka(Rp?Wb({Jj8x&z_0t@R&DT~W~KC{ zZwIg>3bP5}i17iBxC;@K&eM`yys|W?zVtH>_>P`geX?68Hrr|FnJwi=Nsl{sK6TFj zw7>=49k50&{V->Au#g2leQRF)gYQnvB_21`!MdM+yL}e_xN2zQ?-UI>6~C}hpcnrg zVm`id(OVAkv+rAy_I4Md1Is4nF&V|S&#C4hLU_XHubC^0FzT+$g7u=w_8Dtb6Zlb) zIa?VpnG<6559Uzi?Okwd2n=ixcYTL5m7Be!oMxk^* z%k=9A2`|EYGNel8(h3Cff~4QCGD_0A=ggQR*v#>cxa3&uCuw|Ayzy7+y7Km_k`jof z;HEi@lj!s!k=M8V318jTd0d#C?@JH?Q>#IbxDTvr=UXmDF0JLVrqVQ_P&L4b|Li~) zktS=rMVJFb|3)Cwh*gO6pk@4&9Kskbs6{IpOQF#Xe;6p1xl_n~z~3 zPZ{K~aPiX4(0Rd_StWp?Gh!=pcDRi$R{Ve?l9uziwv(>oZl*jycTe?euYKoZaBh~! zit80lb#KyXWz|t*mA>_`8lP^X^xA+8d6czS@usDeL|r1+ z_l$9HOCcKjn0m^|a$CBz^O`sJbAYE1WTHP$SDNjm+)N-?l&~mm%(=(Po;bYnx;`>8$jT9ggWTS@{lMyrc(J*O#=}o&8ErGqpWOOk;cRult_T&AhMQ zQBH=>k`dnxQX^f(EWWoYR|Ry=W8@=P7?3D2iBjz)iXLJH_X?z#9GvFi29~n&Tg{=+nuRMYL)=;g}i?I%h6V z6Ljw)MQPZryJ~Squ%07dIEf}iPYm6kT`C41lBz!jn=OYhZ5Hl0+e2|klxfhBKkRKEBQ=b&G3D>opgivQR!{4m-LK;}TW7Bx~%p>lh z^I;cXJ%@{O?%}i9oa$#(yDIx?{rYy-tYl+5Me54CbhXCzgKMF#PeZHOkjoK#xpEts zKMPseYsNZa0tq%R*;CAj#`P#xi82{KqO|IDSb9}S$iSIl+7)bLkIyHjZf{=1HJ_sd zTuohhg+9{X7k=uv_RBs9{K12BNWv_j`U;RqOT5AyXNkc462mX~45t3Td(SuUe^`I) zFO<=^&7`y℘grn(+nH5VC#5T!gj14j*2P4w$;iej2#1@ubSHc;nXImNx%og3J7k zvg;x`E!uGg`Dg$%v%oUSy^op0DB7nMqun+ra{_jOpm_E%BA-Ew1c|S@ADp7eK8odQ zrrC#n($HUKX*UmFmmoy+T9>bOXloW4`{~tU1@B!L?Ll2-_r55*Ow|)tRSh$4h*fzw z>3v@LN|C2U7eNva`53ae1EHSlk91=-e<8o~a7RW*ES1pvB4OeDh&Dazp$gkYCtKg7 zVKm#>j&0}pTVQlAfNlT#UisuyX)I@>yb6QN>M95F#1Y}a+5Oesm;1+FTNpG`YhA_R zM%AQlEy!D3NhS8i(o^d+D#Mnx6&>uLQ777fImMYM)*}1iP`Gc?zDjn*>;l_yk$OO= zkM~9`vsr9h71*huV7KqBOXRETJk&a=MZ&I>PxE%!1`d0HGXHEoiicuE>vYG?vrUPk zL33r^w|=-VStfW^LD2IbXKw)T*_%Yz?@DxibWS@)A>Ca$w>i@l8}P1>IOvw)HB z8}sfHzOdE^n%$nto91R@}E@WLoGTsUJzZ+P^DC3pZpUk2_ZN(J8@y|49ek z%R}36AX5iXHs?5}GKVoVCC&jolCZr9?uXCF3?$N2)??x~X!&umHxCd_l;29+t!lb? zhYJADdR7;D-9*<;G7XfQdfiy3B=&Z_9okABB&~Ob+;#2vZmNs6P7E^4>o)J3_p6)V z$!X&dyUShK+AQXE?V##EK!%0%&2t8qIw$b5OEeypNi57+b`S2$LiI7qNBu@**3xtN zwCoV!p{yCNKPE`filvzCQq|fLNjm$%rIEGXSlJjUZj_Dr@DXj@vz5Uc)X2Hp^K zQSI;5x6N@!^Kq;`w!yXChxh`92z%8Mag`6}A0iM;+{M&eKiM5y2%`&^?FVka@4S3p zOnJExNHOCbXSYX6YhaW=qIuj6Hr2D)7$!4<{%MiSI<(Igv4c0N8Sn_A^KJMsdCNTf z=VE!2&DMt39TnKzAUTe5TNRQBA=w>`NPkPMFHwGzpb@X>z$bdkIi7@qVN4+1DDowf z7B2IIpsEx$$-!r#MufujYL!ae_><@vjC~!FTuIMeyPK}mRg)DWMNMI>VH!7=vf^z2 z@$cLFea2r?f8O85ER{yr$CQVydt4s(rQ$)NbuW~Up=yc|YOS8Mx8WtDQRHP`Q-tjR ziHQ${nxwCWSeCUwm_1XacgtjNr!76Iw;lpKe=M%SaPA`C!DO0cD-u+7_D=r|Q`sw3 zO4E#DYnqugz!fWrD(^^q`P`x0x(b6NKnF4zQl||&wU5T7S zUk%Y&K~=|dW{fCRO!^4L7$25W6a)Fh6ggUazX7vo%<~ln&$tDq`E9@lKn>-ck8;$5 z2&CEQr$y985Nv-!m56fXsBtqDg9hg)ztVIZ@Gb&J&E9A_Ik@RSWLJSAZk)!9P28@d_}v8~69g z%YJwTgbbP>BfSDXhHlCE^3U@c7ky>x?KdI zWCAhcaXjarHXhgB+(h2Lae!|mUjEN4sCOb*MCXMwwXM-D4r$4w<>c))1GU8FSQkBU zO7)r6wk0telj~KdxB8XKY7UzH-apteg32ddh^+XQmp>MLKC2SJ&+Ot1uti%_ooF2vr#EF3+XFALidiGz5Vwt zt;NC|C*2peUNaWn2rIrcH#%C`4(lI2$Ya$K;RaS4{|0;Y^JZ!Y+ z>4U?{6G1N~UlzI$CSy#pmfj;B4$b?Zn?S-9>oWE3z}-n=NpZdc+HhS?Z!z|A(`*j4 zOtcS_-e~8&o|}}J&>I`DCe;3Knh+1T&nklXMkSB=jv5Ie8)}?^PGn%%B>G<2Q$l~E z{~CW*-*V&;N4e_*fCP@%?kzG6Y98wnvqMQS=ZL2|{V>gzC>Sb*G zyv5F9C0;3rl1H6asRQG*y<0V*KkVw(@b61F5^a>1j`dgZH+8xiMo`dKTmX} z4TqIktuTvBwgzsJGEsOfOyE#J_s zBOMFUV_b#hw;6dV?XnX^-NEVh`H|XG6&7hrr=Fmb{}>6$tzm^JH#`0Qa-jJHf(%MXriVI}^Xw zDTWSB77{1X#xEgcLe{!bot@&OAccgYMRTdGA^W0 zQU8j6So|x4iS=#-Tk3=Obf)scz!4vq>#A+n&}XPsiP@nA?!xoIiMETP<&c@vz&{1j zP~Z))#W=~AXIpvfz_EHfh@lmjc#dxii6rNf%1k9t{v1Gh@0YL`|B-9I>Zv%xI4v+cAvdZ zOGNyNFS@oEMLSoJdKPO4LyQRu8td?4*7wQ!aP})_6G@b6Rn8)ODYn$k(mwYqKFA86 zr2~kiM8Q@?AT4pAZ78QKTZ$XLUNu>#cg~=4J<@$BsmXZV9H|B(WE&5X?(ewk=jqBt z7)yJcm8#y3c}KJoF#dO40H{QcuZNa_vJA!DHJ0%mN|i3Z1k%t~b~1!&D55vAhir#3 zx<#6WMDe9s7TU@dMw2-hAc^MOTbgTB+TF-u#Cj~sQyA#~J#b!U(t+OF|86G&h5s!S z61{x{0ofYy>18@qPQGcLQeq40=(8bKgLhNRIJPtCzBlfZBv7HP$V7vj#tCCMrEe~~ z%8L7>33|m2iaBJmwq4D@L+cf=Zv=J#mjye3LqI}8A;QDFgMoT;0Ed8t0-!@L5i{_KYo(q^Am8xi@d8`@x8O2wX}!lW1aFu~ol~ zGOmOfqyDCOWKuUz%Ec}nZbtErc&6w*v;xQq@W|lKdNyhrj(h9hSCR-RlY`ZAZs)SV zgZT=GpKWjhM%kf-)FimJgvE2vtlBqivkVfdz_6;Tc~kkO3+}h6;NKIF7p=gTTD!2e zq2E087nmLo8rmU?Yf`JLv9)WF672KD%rsJ7G!cv7VXBPJKz=Gm!!#LR*r@@f)2`a_ zgq6?_jvEaOCWi9G$rOP&kQ**6ume(4TlP(Hd9)`aj*%%9hBF*axad55mAeR29c_fq zw@L7JlvgN-_N%XyIBuKRin6g8e+ssejw`AdS}L{jB10(q%vWl}No9W#9qF$Hv|P)5 z;EkbKPXz?7Aj>cmxZWiMIerGz3D{+9P!s^t#8Ek1(u5V@uuPSkAf^mTQ#ifcQBzJB zJ*`Fb$gHOb+6f|x%!s0PTENh2vsj32q@Y=n52Ul7XLV|6CIXH(HA|vy=^hHx)q#Zs zZP&@W5<5;$78xH|w0f+jW?Da*v7GX`v`A-X#{z;#J#Z({v9$Z5*32s|&h*au3G9I0 zM{xjlNKg1PN)R@K2lmU)^3HF`j0J$>lQVe2q3&|z(8;`Xb{h*j)$(Wk2+OAq{th&o z7>o1)acDi38=G8Bh{W(MlC@>F_0R+&ksmP32(fAiE0sKsCK#r`QxNfMpT#qN>Ym|J z$p7vFHO8gf0X@!5)4MG5Q72;>9Irk_5B{TdXRQql{~`7(a=Z%TG>0WyvMH)c4bcAC z)%L7}g<+W@_Fez?!kRvQe5&hsZ0{ZYk&E|4!)G)1uK+4Psi~J|xhWwNbbegsSv!Hb ziRbDGYuFLq1<#27Cx*v0>e~Z5aaX@=x(kIL`u^Xl$;tSQ#?6jD&^VcFlH+h%=5PujA-`=2*qe(S`3&BD z9z0(&4(#*2`(c~xpL2wf3QY9|bi4u{=!b`-r3slmA&$8^Y%Y{$3(#dSL(P%1RhHbp zQ8Dqx)7f`@ljAo;Ph*1t^fnEl3O9?LCMHt! z0{)a&HO~9Rdk(T^Ikk+kD^&-fNHEPx!2=gvZCA5euL((Is3YU{pj-b(lQu0SsBQsV8oQGc!j|a%=x)7@_dj>f@XhN7_SZzhJ z*jMV^=@nsgvT3S%qS;GG6Z7C|5~8krV~oTKh00|5(h{uw>6>f)=idQ6eM_-5laDmU zKbp(v6X3U@Pl~KBD13y{=I7=xzs-}1G3-r9oa&Y5!#&%1U~ZArI2L}PxTz2i6L?}} z`ZA%LH`};jMW~^)_1?0Og(6o(^Ow%|OEbB^W3D)q!QavyPRCgBSPiZ99%we_k)e)+ zapHTYBz+JiZb?{sj%q*XC6jarc5}^>)na(bw*@-#7svF9xBfJJB`<1n&?J}-r{%np zg!M8TlKMHbZ&s}w?LF4wW+eEM+8OyfexXf82>oH+m$Ys*(n^=o4OCs@W zS-z0_Bsuu&QiQaT%pZ!6my|lV4a4!C!40~a@4L~(g2N;87rZEXJ59X^`gk549&0m& zV;0%ErB1SW<)jW<3uJYcmLRH=te1B@JOpi>48oj7K(@&|&p)WsfssET{+f%{Yjp*cZ1;oi#a4=BZ`wi$4C^jj z|4za)XXxh4pg5?iJZiM{o-kY^L>sclp=e^%DDmn!tZAD_;+bE!yTV)@HtESdEsm@H zA+M>#budDWJg`CU0z%D zX%aXPrtwa#6Ma4N7jS+cB|Xj$*&DKxP@>RG+@j5P^!H@}n2EBAasu4Wv!NgOx2&kC zJ^T&XV^M1thwRqeRw%NXkyGlwfDuHnQKc5Zd|%;*i`rUSS(YX;59DKBa$+XP{h=T_ zs&yq#Og_>tp}fg(rPmYFp3^ z(Y#?_fA^=UaB}=iw3RLs!rDc&fgt5D#g&cP~IvfIfwThpfJ<5U_nX z&HRE6mSK!@UvS8UZB`3lDd1}FmDqjgtlQ<}T|?5@Qx2mor>#9%lU9K1u)0^8J;wdc z3_;@VqKHxXPBEcouJ3C(TQbQnzQ7HyKH=~;4MjWwS_&v%SeUuzo1T1%)5342a{yFF zs{Lt+-o6m=$-b^83IL6>EFIut+o57iiQt2CaXK{1wAH#MOUbA$gf+Pe`LI9#Y^|YY zrY2>TU*WhBk3=VdyoDF*@Ac2e9(RFb;f-euJNlqT*NY+>9(M+9PKcxW^!U$_o6zUh z-tNy9t|_Z-jdpg2M01j!Hv+e80psC%=82> z9kn%!y(HL%HhwO8;4fecDs22mikKGMfU|^0HeZ;A;uSevJi>Wz_X76Me6g!lcUCjj z(7b~mDJ+?$_j_06bp(E9!aHA7>myh8)Yc)chrv3Pe80nErmA1!q{bxv7+PQ?x#xOj z)Oy2z*gyL5z=yh`me?ZPyRWPd{fKNK`s3A3ORQJZFs|K>nx{5#+}Vf=Q|9T)wKc^o9L@}_z3*;mnvM&v{{l9V@3v}ph!pD+ z&NB7$a>h@1u{x#JjoF%q%Da?>s$5 zVzmB%@NJZoRieU~+4hSBLj!>LZWewK7pNZjL35sYfCM?&go0Wo^jVuqA;edk#I#njNHi;)jg0sYcL6?fSYGsvS^e$QwjDm}eW0SM2!;3tNDO>n5U z|8wH{v1rVa)?~{Zn!^dK?wc7W!9VJnUJWn5^~Z7JPS&8lO&$R9n71l-;I;0g6WG32 zC8nK-GT+y5Psbmv>BxBDHfzYsw{pkYD)vPFbQS=qUc9%HJ>{*fZD5RN!Yj{1ijk42 z-o4CaTc^!Z14R?8Z9!YT*I2qI7-Rf|`TtB;F8?W>4SYK)MaKLYet!;6Z%Ll=`cC;- zP>=`dUw}T0-lyO(7kh9T&Q^p0kbZIFO!qBh; zt%+!c!1Xxzm@HYfvy(C?$L88tR&@@17~}i5^J#cep2;>#DCJywWr8O#Pf%KujA?MD z*awAaxfAEzE~A*!ALdWO^wNBj)cW3ikuSPB_FQNLabN&x2QpMeDnY>T7{t3_ZHHDb z^{?T25mR$YmdZI#y_z)&LLiv#3pb$!qgqCZzS_0i)~TjK{YICUfD5)OK?1`&gAfW+0dMKbqv{5uLj^SU(Gmq3%mMu!?hES zr+juKNA?>k>|HSZf_R4t3T#6QvHMA{h{yDF$~UyREk&Un>Jos9WC^35i9F@GR)GX+ zf}d5gJ>~9psan}HM%48vy~eLEKH2J_ofila!bMj3)JsxBUJPo-Bqn8F<;U``I^r_? zDekE!QwD)EF1#29IUx(R1kOB3WQ>AcRqaNeXk9@IW^@hGuX<&2FsT15JBu)>7ey97DKGPUK`m;hjdn|`SUjg)FU?3uXvr1B% zCwQvZfZ*6!JCfXoh)b48?`Dll)p>Z2|JC9b9G5?)GSYIcj*oj+sGrK++axq4oJ&tF zW@i;6ZoJ2i#q<{hJ2tw^J1$j%dmnO6q6H$00d(4ED*gk7yU;DQKxft1Q;^v}bWY?} zND9LK2u4F6-01a{@nXB%8F@6O3ec&{is;>N+<9;=i~q^0uQO)9#%x{v@O^^M@TE23 zn^g2_wU6T7&63_J4(2n}Wmzq;*L?x0rvu-mBYZEGwRMfQ?1Rjx9~FSt;7L+D3?st* z?iNfk2z^_Z*5g&VK~i#iuTz3*LzT> z9wxC7cko^`tU+Jjsg#xu&bAL$JbNnto5<)cYk*DeH^6D~%AT&G6p=b>x*fq=jA%Q&1vX&V)hD$ zXfKqbVN#-nKSuU=n=hE6k_;n?Jf&7xK1qgn+`<|oQ%zaRZRt+quZ=VR05r>emChc$ zoUX?AZq7rGK*?3SD&JR*xQph zUF~GQjEFh%%9Cv0=5u`AT*2Tt46e|B+Ry8I=&fI_{erk+_|9HY$*AFy3El`+l>bJJw*a ztF`8Fq1EDrYzLWUx{3;|4F)VY*G&m)3kCS*yDhEq{d}u)+o-u-(=hz<-3>ms`;n)1Py4ewR~c%cQmM;AS=%`N*|+WoBsmzzJt@S8~0xhNMHpvNy?Nb zON;K>Y{w6!kkBm;O@Svk;#(3?l`IEYmY)_(p-ANuo#JpK15$tacUPVDn^9>E*O9fGyA$uAbh zNZT7`QT=4WYGQ#0JTo-$QT^}OQy)MfrJh;$fPt^c5^~(mz->2DWWoo{*uMO$WF}h$ zsWsdGlG*?rrke3g9{4CkgRU*Dzgs<&CKyq_2(_Bl*f!P??tp_hz zqx%iw3)CEK^Dm|RV8Re+P?;xEH-7<|sQnd}WJ)<+j{MpL{*dzx-eT@LZ7YY9dI@bnIFLxws2&$K=+lOWJa&>*wC2t}W38h}J(_s+2S)tv%gYG5Yop0#+D`FiA+i|A zM-pYM*s#%P>Qu>=y0wFg9HFIgV)8gvG<1Bp*-8fDv|!g079{i_je8r&8h87>{~orI zNbYYy_fIiqTz>%&ANVmtBAT)kOCtzD&F)dkzmVN;iUg1Wq%lNP(XCZy)9OR`7s8+0ksHyMhl)db#bns@Gx4W-f@1|BDd|K9BIiY zH+2jMwyQiwu&5hOXbaxYsnllOF)=(6BG1A>78@uMW3bX+hOUkUS-z~(01Du4>0S`6 z>eP;Uy9fn$iaQ1~r=%m>ADANK)eu6zEi0W9)&{U9xWo1UArxMU zp+Azj0ZF`hj8GItWtkUZdreL(&40$}m3*%;TVoIm?JG8R?D@v7b|~}4Y{B=B*MfP? zH>%e3esuqLY#8-PV_7&6t~LJk(ZE3PheZ;t?9n!vE}(gX#ILE`}9@w zm@m!Emy(F9-|Jsxzl*}fV)v3%&Zhk+-K(=#8E8YxGqI4$3B%_)hJ(Kn66cg>#!PTT zjJ)59WjoyA`swa-(7AOIq&5M`(fAwcOg^C(mLe|@;PVt+VNr8W$0Z85PnInQEL@-; zy?L-wo=@r~5&iB+enP)Qk<~!l6)#LetNR6e{;VK`O;1%=O)*sHDNTtsaf}d5*o$x! z4U%9Y79BsbM(6Osrz77y05g$SeNHQv+M0U1R84jX17?(jh%Bws4{|K|?pX&wzX)B0+R06D3S2Ni)DM{XUnO}(>|5Mx@eZB?tUHaJj;Se3x;js9KXEmwM_@$d8j%v1Py#2-chfkR!fid31h=#h<~}rChAiC^qsXOn z=1PCC*SC?gLNlvn1XAC?qc&<1^R^Jrx<^aiOM|c!aC(%h7ftdOuNH-C5=)2#%^z3x z6&i%m3CmB|oG#34#KaN&tZr}ibwK!6CVGupv|4hxYI#*gYNa=7mdj-=7M#DZ+!Jtf zsKsq-3b&T<8w`(1YhI8k8tm~oJ5NcoA1Odo6n?n4CR;1tw<)z=e81WN960W%STNkWo7Kb}w1;;gw}(b<-z z+bAhi_6A;8$Q0ZVX3qtl1lE-;yP5exJ>J|+^Pubt=SAEb7_fC?O?Q05ORk0_a_XUB z@4PF7OWV|WocVUB0{Z+NAzEABv`m*95-1_#RbKdla{MRCk7iV1KZd*7mD`aBs^B6N z2TyKIzeFfo_uDHjhdD98sN6@|0=V+dx#YYwVqFy(UYIymOtMNU_{QbY&4yumUEzMrcdy&1MDGtSY_V*homnn!kPf;4y2K z4u?*WtQl&0FdcFY1SPGnGFABN@|msFQE1(nq7N2r^{nl3KoG+*o$Ym?A+kTxzsbgBhO%lUWT5;7+O!xRV~S=-*aVfY?Eu)?DXOn{ zFu*W#PKk*+r*M!ERd*q)MC67dqoa zw)#b{!bMUnIPtt*^=>;sVYSm{D&RMEeeXYhzEff9aO;Q`&hxIN{HS| zpIWR{2VwS`_jc4QRtj1VGJCQr7`*3J*VR;ug>ZH^(+(_H;%cRms+*J$4WtN+YKB2F3xylCjYr9S47 zm`K;HDZ_s?OG2CJ_rA?E+=3t%o;$m1X3B^k-d|k49`kz$oZt7<$7|3^LjccRx+B|i z*U({08~h8-@4L7~fb&ISxzwsECIMrZ{d$>Ic&$Qz@m zrIIQdH$^Z0F%-JY+&6${oid7A3Nalm;>vqb@q|vvzSKvm?>h%{<)`$Z z^9C6$K50UjLO0ngosO$|Spk7b5Zs$qfMb;-XeLHT&&h1K7H6uIGeaEDVWOzf_Y#NXj9_O1Hau zWx!g@wAkVcoL{o5N<5fBRe>I#mJr~@GAsRb=(`99CC6RHKaVz8#d=z^6nVYshD;5= z?{$<2_>kRwiw~m@&|{T30(G4|^_m&2rs8#m&8nGGJUWrsaV}?yJm!_XKkf&;j2?)> zeM5e5CT3!^YmRioF=sEGFEP5|a8vm4akb*#HvSGRyKuvUB`)fh2&cgZz0|>K6;I*q zl}CCu%6EVK1*jib%j)0`yf1rlRU~$8&s?7<+>!!*vUkwVT0V zZfVttNg>gsCN9C`?!h!q`Dr&+gKVpJF%5-8sqXudmvAh90~>1(=XXp5*alBX_a%bjf?Ji*gq6cQxhCC>`4X*%|071#&l<-@w94faTKypS z*g`?!0w+UllZtwR)2Slx1p{n;br`B6(M$aX{wslGMXThK^uil7!lDY}Pv(2HOQdHW zzIFfIcwCQ-#wJT3?u{sWlL5l}shu+9!dS$ql&}5U%z437+RYa=msZnxR0?X2=)j43{?Ud_vtxH0dLRn1H7!~YP_jPCMU zhJElq#>Gm-SvURKJI}pgRVq>Lo{ltk$VrC~uCDwhuP*@qp|;geY11fsl}n1*hba8$ z*RbMNKMKZ8Dax$qUymX!w*2E`W`dAFzV$G&N&E1`rmV0$?j`7MT5{;7GSB%4Vq#|Nq^y{Iq@G_3Pfo}{u@^_>tP#0gNzq%Z6(6@Cs12A`+djhCEWX7-nlF>WJ5dg zv9@3ii?%BY#ey2*|47#ty0XtDL#?z~-# z&;5t2cfi-DVhzjDG7a`p`FQ3L#s|LJUrAF(aoiRKeRFAeJ52M}n!a@Aw!xNtU7z|E z%u9;?sKA;n_Dhql>Ect-CYfZXtbw0csSRpnY9d|G>ncq)#1=iH^9RBfNx|Xr;5L=O z-eh~(rGESBDhl3n+3|`|H-eyxKvY~c^~wEr!NfHfHoZuaCoU04*i$a0tsqJ8}3f0+q`UGrJZ6JpPd|b{uv2&ooPvqlLVAuES&T2Pw-o zc(|THJ4WUXb6?<3^S`pH2wlVM_;%>qO=W8oXJ_)FO&BeJAq4@+s|MmkN!!HXaA)IU zTlj@tbpQ9pMSb)IUDTzGgC`2#T4!R&df*!sIx8jOoe(px4^JW^Z@f7qPYyj`y`K>@ z7)*;EUQHEIpV7Qf`L1P@v1x9V^0NBI0kfy>B`qiyct6s7kjn|T_ri5lkRjQM37d9A zD$30N$8NxSL@q(5ko>wLce?nZK2@0-AhYGZV^ii3r>UYELd`>;{6`tmgyN_wmp93z zc?@g`8Eoo03-}Trse4nLUJaA#$hFgDD`g#0HA(s9aJvx2Zh4Nmg%s0FZEeEILrB&V zc{4W`hyJrh33exZtyMdHFX@ytOI0#_S)?=;OWM1Jo>3$OXLoxX(>HM=5A~Pd=f@=I zi&b4Qecd%_2xgBcZ@d<+59_zTSZVDHvhAv}U`X`K?N>Uiz4#5v2A_Tkn^ z79b!fVb%9@Ae8aBNEvC5U8Q^};|7*r~XQS9wHN8AdPTu0`j36Bp;cCeg!sXimyLWaOzkb)LmDe>T@PS=_Jg*;r9vsoy(fCaa(=L6p zbA@3ZeD5nSs`lP?9!mz~GLlzD6QnsqEfW>cjN8D45<_OoTvIFr#vLf>?32=&a>dW^ zc|yUw_ldQx$nfjLoBBuum^#gNOxe2FO`wmay2WXw^0+=OiJ|@Csq*)3M^nh_0Uu&^du9zWi7%glm-earB_A7IUJZ)dZ(&G07mTh7oE3K)0mcn{zDbSD+NJ5%cvp}hzxF3!q=puRL<--O>+0N?U4ARUDPi@{4<|K9)SMw$-XXs6ysEP9`G3 zDce=3P`Vb(m(I$d>f#C4S%xS+wY3v|;jFaX0|eP}-T~{OKT7p4K+2VfI`SEO=Pp$8kbxx4@Mc+n^MKR6w|U@t&5}UtDAJsePp;}Q2@%AC&4T&q zV+{c&d{)IX&FpRooOxt%YX`K)Tv)pWyB0y0Yj*FC#cVoGU!E4lj&S8tnyAhS0rpw- z$Ru26r2uM-oKv&|x{7m0`?LQnWduu|8Wk%pIou>-nqdN)R<}c?agvs4C+L6ZRaZe0 z;r@%QMAj=K>E(ErFya%Tfiiu+7Z{q?S}_8gQO*SzhVlnV>RD8=7}Hsf8s= ztXJlL<{#P={W-mXJ$bnHu>K!=t{&A(#)=GfN%fQd=}M_@Bo7JRd4jU4eia%stQ0d7 zxvxEQ_4$;{ccb7=Plo0?mazezqabhNh6DUiS?qYXJEoyPvz zx|}O9a}}gBu~vA{C|Kr4C^(6dA%j*8V`UUw!wqcVGtC}-liV&uYkbP(cWi^f)focn z$s+H!EPuTlSETO=8Sd#_QlFA=OVVvFTdwv76-~U&Vr+~d6BKM zt_$g+NzC=@{_SM-xSKy8<}ZGo{v_R-T$6Ho_OhAep35YX z24?Gz+5fWkO2%+^{(M)Zb5-fZ;qFM++FDm1O7X%YwXn%$3DDXQtnzH?6MF8~9pzM? zAKip^Hom;~g(4zuf(j+SS;@T8`3uO9RsZFD#$-|{^d8Kke^RsH{=GD8e^2GB(vP3> zg?GCC(LI2dm`0{^&%8HsrP8bdz zxb>?=kfOc5%sARqBoXE4%`R^nsM1-FmE`l+kTz`;XU?8`a$-cSq92L|^3K<+@wGN{ zp-_mhQ?RLiojm8F=d+~00C%-Q3V5P)Cta7z%&L7qazrPyM($o?)s8*3HelI(FOOQ^nj5Y-<}- z*=WqV8m~1{Cw#mBh^N!MX9qG1X9-0Mjm^3#bqjQ~f zR6Fow{&NwZG#;8?same0yVC{)4J)aFkCJ}QvSch^xON_QXvlZ8En5mgpDfFOg+eVQ zt<(o9iJZKAp*4<^J#NDT_KPr}RJC~O2fHLD%1+$on&yds$Y6TQeJ6^`*jOru`k7a# zh0bf%vlK?brTXoW#fqnVI&<@abs0qX=IOiwl}?Xxc89xE?c23!jc?F3>oi20x)kG` z1)s2G8su-PZ{l0B;0Ma{SES3r$@$wv9+k+xCAvA5S%G?B@qw|{S!q7K*6FLeI9r~p zERnnD<)v#z?4zh|(4gd1c-CW)#>_$Isw9<18oA6-{)V)=JXt{__0t0&{-)Xv0BLRs z7(6!SV@!Ei+$>o#|4piB3SJYz!}DU?ZM-H`W3ysq%LID zok=+FIVeSQqD?Y#1$Vpsef+sKW8dkVwjX~wG$2Ld(;#CWzB;{yIP>7_&W^)%vk`ja zl1&+Bu3dS%1`UrsC?D*vA;w(sSnETD1HnlBREb;>m_K#vK`Muil9 z`;y+}${ipT(EIDtbU4un;iF@Py!gec@%QDxO@iv4X}h>LCYj z=KXTx#i<=Ir){?BNMeZwbL{9~u1FZVU7K2WlhQ)l!G;vAR1@wusvmQ_XdUC}s%Q~E z{hQ43L=`La>&hnl%-$#jU@ zsrQWaOMC2oO1MW51Ra)ZZz@EbF-h+_g_evT6*Y%bCsh_|l*?w-1_owB&Ewsvhb76& zasx87fRf^_O0mG?g;C(L?fLiQeX$iGocP>huSr_eMW#)5%q7WL{n&?yInW^p|N6Dc zDa>S<`vi14;|eE85ZK{E%w|lu_^VRnMsBhR_MtD^Szf-S%FfhMBR8$|njQvd6l(Q$ z10|6TA%l)vnaqU42W+7fj+=3mG`i3!&i8@a2%dYcYyRI7Ip)Wk&SRo!`v!(s%alj| z%V!nMjB4rx-}9E<8QoCZ-jR-1?|oe9K!)1kElpF>F?G(>g8yP&)}qCxWo)&hFfUYe ze`b97{np8c#C0!~40AYTCeAyM)vXN`LR8R-@ckny!D5H$zW_4Y14Uwr3m`TDXsZ_++oz!E^QrQWkz7+3R1zWngb*n-%m?S~c4#kzf(+ zkdUc{;Bm;TnO5&Sk1=6fs~mMxVm@vGV(w5Rm&oO^K1zbVe4h&d|%lv!6R=S|(Kn+_Jvew1&Bb$LnVymqtfWRZ^NPXYKnC!E|L-NNMxM~-!$!`RrMk-_M%V~JHmq*>XPUKESFLK=xkwS zUN`-HS3$)ipFyU&mS)}1z$4sJT|Z6SQ*jFC;nuj+XlZePjk2mEMF)XjB9mAJT*vWj-aGjrg#kEA!lx z?D;rFHi_~b%8ryxdQnPVPHh;4q78BT&!RT^#GmuZ_8WFA*XXpYVl=j5LLr2W1IOO= zy5*^R7wA^>s~CF|@MOMEsIuqrwA;Wiagwt0tdP{(r0t9BmZD}IFV}4NvZlJn?Ba-w zCf3|J_NX?JM^>Q*=B2m>sR*rZ-sv&>gIm_~cls;sFTZoA*3m5iEIi7+{4i)G_oBC= zif8t@RG>a5$I_R^*avzM8x^NLZLKpAkjh1FaNvDCF?-vld#|kOC!TqYece>OCsF_L zd9reDf&__|6-f^%hWCYeMOgZbT`T$o?^`kx5yPF7qhDXXHA+eafBE)LH^6NL%PzJ( zsm9=K0+UWcCm)C;Lulc;?eE);dZPOLP(y`D@?3VsO!<5)R{vsS5rz zza8GU^u4OiAJey1(UE)~K|m-9B_P8Vo|$9okpuNo?tC4Ave`&18Tc4#o*)PA=YBAQ zk)|3a0kw{5mZ7^z#v6(F?Fbk`M%4c~3wi&Vf`XJijU9{PIJR`?mc_&pbgw>J0uQLE zxG5a2;9OSRPKY;&$r+-8;`EFvn^8a93n5JT9!imZMFb|~C_ZTdktjm`2xnU?nWx0_ zxW5JX2{(%$TV+p#fBLzfalcB?^o-ft`TJdb#u_ob zLSsFF|5U);a^k@`UmHR$wXhrteF!#i`Ec^*=Yes|{rm-!P5k^DseTcOyPuZ#4<9bo zV&kJM4~Ct*5a%AvGpCUKNL-@U%MnIAZHdsjTYbrc`VQl52L*P?-EB8^$-^0EVZX%( zA5Qx@c|@7w-~*#6*KTYD0p?{J%ODICP&Ch4)-*o9KNriO; zhj_@~KVl_NnfiW3rqVk~Ny??L>0!~TfoJnrP5Os|Oan+Y+`uE=UB4nt!(U5>R&KUg zViPy6eX+ziYzMOlYa${*)OhlFg@MZ~s6`MS+N~J|uWm`Jp z`G_6GZG z{fL(#)i<+Gx+<>XBFkD5&APEN5jJ99F?nz~yw96f$5!#c)3!;M)>P-Sf8 z)Uw;n!$&JYg`tSi~6&ahmRno!qbouw>y^%H8 zsdD(g`QpC-ywQSIM%h^&yu|ZaG5VBCC=?58a8H@|-lJ&sRvHG=4|S$C0#^=#@6+he zN@2@}9B{E-hM{$V|5#f1t%ur1*+Mw@Gv@vxg)Mtu9k9>b_lx_t9%JLdWQk8RO7ZaPHfKP?oHEKVSbV(umFA&! zY!{`zE-T!xq--2vHW*$CGh3n zL2fW+F+fK8SI>%P>Pw~Lm&o6gfiY%LEVBH);@wV0U`}61waMDJ8}gVX6R-aALyd3z z2zM-U7WU(h-k$H4_1?_02lqqI>MIJqjVxS0x3(FgT30zcx`~(vr+3D!Q}#g?q-CPq zpdYY8UC}?@ut!sCdc>m~Qf~|-9aMU@BvXSes2emnCN3j6PgY0a%8n#{uKo+i)^)Ei zC^co@SSWD8Cig9toXqnh4@?$4J$UcFr7lJGIL&JD*p<$9Z1oQ^CqivW|F7BN_YVo4 zD#X`MaMZ9pV_mtlh(~iPQrSE-mIzbtI(>#=l=ns=F^UvEkI#p{bj=E|QY0tSM-)9% zAarhx-r;LW?$yi^%UaesFN(UWzb=%LlDu+Bt95E+f-B`?3EW6ltxjez_XJR=k^-WM zIZtA2a^o+Zas;b!R2KmMDNdMR568hvBwe}W0G}3S(0bx0iG@`Qi^6d5vw(JmIJ|Qm zP~Ui=e`Jgu$M?+Dv^?FXrO1wIQ%yySS#d0jV>UZR6z=Gh$lgJZ8ww*Luk;d)%!Imc zQg|(DHF`1;wo8%~cYuv4>WJ8k9tgOy9z&s3BVir0(v47lC;tw8P^5P$E-{J-D&2vK z`5jmBoyMk^+7a2hk{)sOOqnRNtI7qxC<>&%E=WdJ%uLLaSzow_!`nBPkapMEpBhT{ z#{c_3ab;V zGegMq9}V;nO&K@euD$>+2*99<1eiu6h&hiqF*_SkgrC1TYYGTIBl_pXA1NqMTSNoY zSRd2eV@KeC@4xo8DT>9`VZ2Lsri0XiQV1XpihyZa1(`VqV&w{(7wlIlI7fuYOs(+O zmID_>eS`eKd!?{Idt~O|i%~&g!#X_jMwnXw{(>bVKQ;;0A`5l@&@aS~6fMmVBB8%~ z`~nme`kW+bJ5Z~MQ`nf;3%6Z}8IViD%d~TZJyPXspg_##t_z5lE9!}pAaDQ12Z9*S zzysV2K~S6p5WpIV?YfY?J#C!^+fDnwK9K($orH^nqXN)<_5Zm7dw}~tS1kTNDmRJm literal 0 HcmV?d00001 From b741176d4bac11052913d8a39692d071ba4586a8 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 3 Aug 2026 10:17:04 -0700 Subject: [PATCH 10/11] improvement(settings): one header action order across detail pages (#6206) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detail headers disagreed on where Delete sits. The skills page reads `Share → Delete → Discard → Save`, but every SettingsPanel page spread saveDiscardActions() first and appended Delete, rendering it to the RIGHT of the primary chip: sandboxes, custom tools, custom blocks, permission groups and data retention all did this. Fixed in the shell rather than at nine callsites. orderHeaderActions() ranks actions — secondary, then `id:'discard'`, then `variant:'primary'` — stably within each band, so writing the array the natural way now produces the right header and a new detail page cannot get it wrong. This generalizes past Save: workflow MCP servers' `Add workflows` primary is now right-most with Delete before it, instead of the reverse. The ranking has to survive one trap. The shell routes onSelect through configRef.current.actions[index] to avoid stale closures, so reordering the render without preserving the source index would bind every chip to the wrong handler — clicking Delete would Save. orderHeaderActions carries {action,index} pairs; settings-header-shell.test.tsx pins that at the render level, including the conditional-Discard case where a missing action shifts every index. Delete is also now a plain chip on the nine resource-detail headers, matching skills, each with a stable `id:'delete'` (three lacked one, so the chip remounted when its label flipped to Deleting...). `variant:'destructive'` is kept for actions destructive at scale — Delete all passwords, Clear all browsing data, Sign out all members — which the confirm modal does not cover the way it covers removing the single resource you are looking at. --- .claude/rules/sim-settings-pages.md | 44 +++++- .../password-detail/password-detail.tsx | 2 +- .../custom-tool-detail/custom-tool-detail.tsx | 1 - .../settings/components/mcp/mcp.tsx | 1 - .../components/sandboxes/sandboxes.tsx | 1 - .../workflow-mcp-servers.tsx | 1 - .../settings/settings-header-order.test.ts | 129 ++++++++++++++++++ .../settings/settings-header-shell.test.tsx | 116 ++++++++++++++++ .../components/settings/settings-header.tsx | 34 ++++- .../components/group-detail.tsx | 2 +- .../components/custom-block-detail.tsx | 2 +- .../components/data-drain-detail.tsx | 2 +- .../components/data-retention-settings.tsx | 2 +- 13 files changed, 321 insertions(+), 16 deletions(-) create mode 100644 apps/sim/components/settings/settings-header-order.test.ts create mode 100644 apps/sim/components/settings/settings-header-shell.test.tsx diff --git a/.claude/rules/sim-settings-pages.md b/.claude/rules/sim-settings-pages.md index 114bc1f30a8..7438f5aaf5e 100644 --- a/.claude/rules/sim-settings-pages.md +++ b/.claude/rules/sim-settings-pages.md @@ -247,12 +247,46 @@ the email is the primary content; `components/permissions/member-row.tsx` render a 36px `getUserColor`-hashed avatar for member *management* rows that carry a name, an email, and a role control. Same shape, different job — do not merge them. +## Header action order + +Every detail header reads left→right: + +``` +← Back [secondary actions] → Delete → Discard → Save +``` + +You do not have to get the array order right — `orderHeaderActions()` ranks them +(secondary → `id:'discard'` → `variant:'primary'`), order-stable within each +band, so spreading `saveDiscardActions()` first still renders Save last. Both +action stacks apply it: `SettingsHeaderShell` and `SettingsActionChips`. +Covered by `settings-header-order.test.ts` and `settings-header-shell.test.tsx` +— the latter pins that a reordered chip still routes to its own handler. + +Two consequences worth knowing: + +- **The primary chip is always right-most**, and it is not always Save — on a + page with no save state it is whatever the primary action is (`Add workflows`, + `Import`). Delete still precedes it. +- `CredentialDetailLayout` takes a `ReactNode`, so the chips you write directly + are in your order — only what you route through `SettingsActionChips` / + `SaveDiscardChips` is ranked. Put `` last (skills, secrets, + connected credentials already do). + ## Deleting a resource -Delete lives in the **detail header**, as -`{ text: 'Delete', variant: 'destructive', onSelect: … }` behind a -`ChipConfirmModal` — never `textTone: 'error'`, never a bare `Chip`, and never -unconfirmed. A list row does not carry Delete when the resource has a detail page. +Delete lives in the **detail header**, as `{ id: 'delete', text: 'Delete', +onSelect: … }` behind a `ChipConfirmModal` — a **plain chip**, never +`textTone: 'error'`, and never unconfirmed. In a `SettingsPanel` header it is +action *data*, never a hand-rolled ``; only `CredentialDetailLayout` +surfaces, which take a `ReactNode`, render one directly. Always set `id: 'delete'`; without a +stable id the chip remounts when the label flips to `Deleting...`. + +`variant: 'destructive'` is reserved for actions that are destructive at +**scale** — `Delete all` passwords, `Clear all` browsing data, `Sign out all +members`. Removing the single resource you are already looking at is confirmed +by the modal, so it does not also need a red chip. + +A list row does not carry Delete when the resource has a detail page. ## Save / Discard + unsaved-changes guard @@ -344,5 +378,5 @@ A settings page is design-system-clean when: - [ ] Rows that open a detail page use `navigable` + `clickLabel`; flat records use `RowActionsMenu`. Not both. - [ ] Decorative trailing content is in `badge`, not `trailing`. - [ ] Labeled sections use `SettingsSection`; read-only fields use `SettingsField`; empty/loading/error use `SettingsEmptyState`. -- [ ] Delete is a `destructive` header action behind a `ChipConfirmModal`. +- [ ] Delete is a plain `id:'delete'` header action behind a `ChipConfirmModal`; `destructive` is reserved for bulk actions. - [ ] `tsc`, `biome`, and the page's tests pass. diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.tsx index 90091fb5f00..7a805605904 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.tsx @@ -133,8 +133,8 @@ export function PasswordDetail({ credential, onBack, onForgotten }: PasswordDeta description='Saved on this device, encrypted. Chat can never read, choose, or type it.' actions={[ { + id: 'delete', text: 'Forget', - variant: 'destructive' as const, onSelect: () => setConfirmingForget(true), disabled: busy, }, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail/custom-tool-detail.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail/custom-tool-detail.tsx index 653bc066b94..809feeeb408 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail/custom-tool-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail/custom-tool-detail.tsx @@ -221,7 +221,6 @@ export function CustomToolDetail({ { id: 'delete', text: deleteTool.isPending ? 'Deleting...' : 'Delete', - variant: 'destructive' as const, onSelect: () => setShowDeleteConfirm(true), disabled: deleteTool.isPending, }, 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 0c886ebf438..04158338a79 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx @@ -448,7 +448,6 @@ export function MCP() { { id: 'delete', text: deletingServers.has(server.id) ? 'Deleting...' : 'Delete', - variant: 'destructive' as const, onSelect: () => handleRemoveServer(server.id), disabled: deletingServers.has(server.id), }, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/sandboxes.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/sandboxes.tsx index ed393de09a6..c8ab16b44ff 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/sandboxes.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/sandboxes.tsx @@ -217,7 +217,6 @@ export function Sandboxes() { { id: 'delete', text: deleteSandbox.isPending ? 'Deleting...' : 'Delete', - variant: 'destructive' as const, onSelect: () => setShowDeleteConfirm(true), disabled: deleteSandbox.isPending, }, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx index 1a7accd3634..53e4a100ec0 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx @@ -411,7 +411,6 @@ function ServerDetailView({ { id: 'delete', text: isDeleting ? 'Deleting...' : 'Delete', - variant: 'destructive' as const, onSelect: onDelete, disabled: isDeleting, }, diff --git a/apps/sim/components/settings/settings-header-order.test.ts b/apps/sim/components/settings/settings-header-order.test.ts new file mode 100644 index 00000000000..a648c32d42a --- /dev/null +++ b/apps/sim/components/settings/settings-header-order.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest' +import { saveDiscardActions } from '@/components/settings/save-discard-actions' +import type { SettingsAction } from '@/components/settings/settings-header' +import { orderHeaderActions } from '@/components/settings/settings-header' + +const noop = () => {} + +/** Labels in the order the header renders them. */ +function rendered(actions: SettingsAction[]): string[] { + return orderHeaderActions(actions).map(({ action }) => action.text) +} + +const save = (dirty: boolean) => + saveDiscardActions({ dirty, saving: false, onSave: noop, onDiscard: noop }) + +describe('orderHeaderActions', () => { + it('puts Delete before Discard and Save no matter how the caller ordered them', () => { + // The natural way to write this array — Save/Discard first, then Delete — + // is what every settings detail page did, and it rendered Delete to the + // right of the primary chip. + const actions: SettingsAction[] = [ + ...save(true), + { id: 'delete', text: 'Delete', onSelect: noop }, + ] + + expect(rendered(actions)).toEqual(['Delete', 'Discard', 'Save']) + }) + + it('matches the skills detail header: secondary actions, then Delete, then Save', () => { + const actions: SettingsAction[] = [ + { text: 'Share', onSelect: noop }, + { id: 'delete', text: 'Delete', onSelect: noop }, + ...save(true), + ] + + expect(rendered(actions)).toEqual(['Share', 'Delete', 'Discard', 'Save']) + }) + + it('keeps Save right-most when there is nothing to discard', () => { + const actions: SettingsAction[] = [ + ...save(false), + { id: 'delete', text: 'Delete', onSelect: noop }, + ] + + expect(rendered(actions)).toEqual(['Delete', 'Save']) + }) + + it('sends any primary action to the end, not just Save', () => { + // Workflow MCP servers: Add workflows is the primary, Delete must precede it. + const actions: SettingsAction[] = [ + { text: 'Edit server', onSelect: noop }, + { text: 'Add workflows', variant: 'primary', onSelect: noop }, + { id: 'delete', text: 'Delete', onSelect: noop }, + ] + + expect(rendered(actions)).toEqual(['Edit server', 'Delete', 'Add workflows']) + }) + + it('preserves caller order within a band', () => { + const actions: SettingsAction[] = [ + { text: 'Refresh', onSelect: noop }, + { text: 'Edit', onSelect: noop }, + { id: 'delete', text: 'Delete', onSelect: noop }, + ] + + expect(rendered(actions)).toEqual(['Refresh', 'Edit', 'Delete']) + }) + + it('leaves a destructive bulk action left of the primary', () => { + // Passwords: `Delete all` is destructive but must not outrank `Import`. + // This is what keeps a red chip from becoming the right-most control. + const actions: SettingsAction[] = [ + { text: 'Delete all', variant: 'destructive', onSelect: noop }, + { text: 'Import', variant: 'primary', onSelect: noop }, + ] + + expect(rendered(actions)).toEqual(['Delete all', 'Import']) + }) + + it('ranks a destructive action alongside secondary ones, not after Discard', () => { + const actions: SettingsAction[] = [ + ...save(true), + { text: 'Sign out all members', variant: 'destructive', onSelect: noop }, + ] + + expect(rendered(actions)).toEqual(['Sign out all members', 'Discard', 'Save']) + }) + + it('treats primary as the stronger signal when an action is both', () => { + const actions: SettingsAction[] = [ + { text: 'Other', onSelect: noop }, + { id: 'discard', text: 'Odd', variant: 'primary', onSelect: noop }, + ] + + expect(rendered(actions)).toEqual(['Other', 'Odd']) + }) + + it('carries each action original index so ref-routed handlers stay bound', () => { + const actions: SettingsAction[] = [ + ...save(true), // indices 0 (Discard), 1 (Save) + { id: 'delete', text: 'Delete', onSelect: noop }, // index 2 + ] + + expect(orderHeaderActions(actions).map(({ action, index }) => [action.text, index])).toEqual([ + ['Delete', 2], + ['Discard', 0], + ['Save', 1], + ]) + }) + + it('tolerates an absent or empty action list', () => { + expect(orderHeaderActions(undefined)).toEqual([]) + expect(orderHeaderActions([])).toEqual([]) + }) + + it('does not mutate the caller array', () => { + // The shell sorts a prop read off a live ref; reordering it in place would + // renumber the indices the handlers are routed through. + const actions: SettingsAction[] = [ + ...save(true), + { id: 'delete', text: 'Delete', onSelect: noop }, + ] + const before = actions.map((a) => a.text) + + orderHeaderActions(actions) + + expect(actions.map((a) => a.text)).toEqual(before) + }) +}) diff --git a/apps/sim/components/settings/settings-header-shell.test.tsx b/apps/sim/components/settings/settings-header-shell.test.tsx new file mode 100644 index 00000000000..bca2da37694 --- /dev/null +++ b/apps/sim/components/settings/settings-header-shell.test.tsx @@ -0,0 +1,116 @@ +/** + * @vitest-environment jsdom + * + * The shell renders header actions in ranked order but routes every handler + * through `configRef.current.actions[index]` to dodge stale closures. Those two + * facts fight each other: if the reordered render ever renumbered the indices, + * clicking Delete would invoke Save. These tests pin the pairing at the render + * level, which the pure-function tests cannot reach. + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { saveDiscardActions } from '@/components/settings/save-discard-actions' +import type { SettingsAction } from '@/components/settings/settings-header' +import { SettingsHeaderProvider, SettingsHeaderShell } from '@/components/settings/settings-header' +import { SettingsPanel } from '@/components/settings/settings-panel' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.clearAllMocks() +}) + +function renderHeader(actions: SettingsAction[]) { + act(() => { + root.render( + + + +
+ + + + ) + }) +} + +/** Header chips in rendered (left→right) order. */ +function chipLabels(): string[] { + return [...container.querySelectorAll('header button, div button')] + .map((node) => node.textContent?.trim() ?? '') + .filter(Boolean) +} + +function clickChip(label: string) { + const chip = [...container.querySelectorAll('button')].find( + (node) => node.textContent?.trim() === label + ) + if (!chip) throw new Error(`no chip labelled "${label}" (have: ${chipLabels().join(', ')})`) + act(() => { + chip.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) +} + +describe('SettingsHeaderShell action routing', () => { + it('renders Delete before Discard and Save even though the array lists it last', () => { + const actions: SettingsAction[] = [ + ...saveDiscardActions({ dirty: true, saving: false, onSave: vi.fn(), onDiscard: vi.fn() }), + { id: 'delete', text: 'Delete', onSelect: vi.fn() }, + ] + + renderHeader(actions) + + const labels = chipLabels() + expect(labels.indexOf('Delete')).toBeLessThan(labels.indexOf('Discard')) + expect(labels.indexOf('Discard')).toBeLessThan(labels.indexOf('Save')) + }) + + it('invokes the action that was clicked, not the one at that render position', () => { + const onSave = vi.fn() + const onDiscard = vi.fn() + const onDelete = vi.fn() + + renderHeader([ + ...saveDiscardActions({ dirty: true, saving: false, onSave, onDiscard }), + { id: 'delete', text: 'Delete', onSelect: onDelete }, + ]) + + // Delete renders first but lives at source index 2. + clickChip('Delete') + expect(onDelete).toHaveBeenCalledTimes(1) + expect(onSave).not.toHaveBeenCalled() + expect(onDiscard).not.toHaveBeenCalled() + + clickChip('Save') + expect(onSave).toHaveBeenCalledTimes(1) + expect(onDelete).toHaveBeenCalledTimes(1) + }) + + it('stays correctly bound when a conditional action shifts every index', () => { + // Sandboxes: Discard only exists while dirty, so Delete moves 2 -> 1. + const onSave = vi.fn() + const onDelete = vi.fn() + + renderHeader([ + ...saveDiscardActions({ dirty: false, saving: false, onSave, onDiscard: vi.fn() }), + { id: 'delete', text: 'Delete', onSelect: onDelete }, + ]) + + clickChip('Delete') + + expect(onDelete).toHaveBeenCalledTimes(1) + expect(onSave).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/components/settings/settings-header.tsx b/apps/sim/components/settings/settings-header.tsx index 9093b4d70b4..c194d435b23 100644 --- a/apps/sim/components/settings/settings-header.tsx +++ b/apps/sim/components/settings/settings-header.tsx @@ -73,6 +73,9 @@ function computeSignature(config: SettingsHeaderConfig): string { back: config.back ? [config.back.text, config.back.icon ? 1 : 0] : null, actions: config.actions?.map((action) => [ action.text, + // `id` participates in ordering, so a config that changes only the id + // must still re-render — the sort key cannot be wider than the signature. + action.id ?? '', action.textTone ?? '', action.variant ?? '', action.active ?? false, @@ -180,13 +183,40 @@ export function SettingsActionChip({ export function SettingsActionChips({ actions }: { actions: SettingsAction[] }) { return ( <> - {actions.map((action) => ( + {orderHeaderActions(actions).map(({ action }) => ( ))} ) } +/** + * Every detail header reads left→right as + * `[secondary actions] → [Delete] → [Discard] → [Save]`. + * + * The shell enforces it rather than trusting callsites, because the natural way + * to write the array — spreading {@link saveDiscardActions} first, then adding a + * Delete — produces the opposite order and puts a destructive chip to the right + * of the primary one. Ranking is stable, so an action's position within its own + * band is still the caller's to choose. + * + * Pairs each action with its ORIGINAL index: the shell dereferences + * `actions[index]` on a live ref to dodge stale closures, so a reordered render + * must not renumber them. + */ +export function orderHeaderActions( + actions: SettingsAction[] | undefined +): { action: SettingsAction; index: number }[] { + const rank = (action: SettingsAction) => { + if (action.variant === 'primary') return 2 + if (action.id === 'discard') return 1 + return 0 + } + return (actions ?? []) + .map((action, index) => ({ action, index })) + .sort((a, b) => rank(a.action) - rank(b.action)) +} + export function SettingsHeaderShell({ children }: { children: ReactNode }) { const read = useContext(ReadContext) const configRef = read?.configRef @@ -209,7 +239,7 @@ export function SettingsHeaderShell({ children }: { children: ReactNode }) { Docs )} - {actions?.map((action, index) => ( + {orderHeaderActions(actions).map(({ action, index }) => ( setShowDeleteConfirm(true), disabled: deletePermissionGroup.isPending, }, diff --git a/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx b/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx index 94bd6bc3b73..d4d22822858 100644 --- a/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx +++ b/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx @@ -439,8 +439,8 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD ...(existing && canManageBlock ? [ { + id: 'delete', text: remove.isPending ? 'Deleting...' : 'Delete', - variant: 'destructive' as const, onSelect: () => { setShowDelete(true) // The warning must reflect the org's CURRENT usage, not a diff --git a/apps/sim/ee/data-drains/components/data-drain-detail.tsx b/apps/sim/ee/data-drains/components/data-drain-detail.tsx index dc7c65c291d..e8d5bcf68e0 100644 --- a/apps/sim/ee/data-drains/components/data-drain-detail.tsx +++ b/apps/sim/ee/data-drains/components/data-drain-detail.tsx @@ -150,8 +150,8 @@ export function DataDrainDetail({ organizationId, drain, onBack }: DataDrainDeta }, { text: 'Test connection', onSelect: handleTest, disabled: testDrain.isPending }, { + id: 'delete', text: 'Delete', - variant: 'destructive', onSelect: () => setShowDeleteConfirm(true), disabled: deleteDrain.isPending, }, diff --git a/apps/sim/ee/data-retention/components/data-retention-settings.tsx b/apps/sim/ee/data-retention/components/data-retention-settings.tsx index 8076adbd14b..81e49ceb12e 100644 --- a/apps/sim/ee/data-retention/components/data-retention-settings.tsx +++ b/apps/sim/ee/data-retention/components/data-retention-settings.tsx @@ -516,8 +516,8 @@ function PolicyDetail({ ...(canRemove ? [ { + id: 'delete', text: 'Remove override', - variant: 'destructive', onSelect: () => setShowRemoveConfirm(true), disabled: isSaving, } satisfies SettingsAction, From feaddc437a5b067576dc06db8e6820790ea4be86 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 3 Aug 2026 10:31:52 -0700 Subject: [PATCH 11/11] improvement(headers): one action cluster and one ordering app-wide (#6210) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the settings-header ordering past detail pages. Five headers hand-rolled their own action wrapper. `resource-header` used `flex shrink-0 items-center` — no height, no gap, so chips on tables, files, knowledge, logs and scheduled tasks sat flush against each other; the integrations tab strip and the integration block detail each used `ml-auto flex items-center`. Only the settings shell and credential detail wore the intended `flex h-[30px] items-center gap-1`. That string is now HEADER_ACTION_CLUSTER, next to PAGE_HEADER_BAR, and all five compose it. `Resource.Header` now ranks its actions through orderHeaderActions too, so the resource pages inherit the same order as settings rather than rendering their array verbatim. `ResourceAction` gains `id`, which was the only field keeping it from being a subset of `SettingsAction`. Delete is now ranked by its `id` rather than by where the caller put it. That matters for a header with no primary action: the file detail listed `Download → Share → Delete`, leaving a destructive chip in the slot a primary would occupy. Tagging it `id:'delete'` fixes that without inventing a primary. --- .claude/rules/sim-settings-pages.md | 17 ++++++++++++++--- .../components/credential-detail-layout.tsx | 4 ++-- .../resource-header/resource-header.tsx | 16 ++++++++++++---- .../app/workspace/[workspaceId]/files/files.tsx | 1 + .../[block]/integration-block-detail.tsx | 4 ++-- .../integration-tabs-header.tsx | 6 +++--- apps/sim/components/page-header-bar.ts | 9 +++++++++ .../settings/settings-header-order.test.ts | 12 ++++++++++++ .../sim/components/settings/settings-header.tsx | 15 ++++++++++----- 9 files changed, 65 insertions(+), 19 deletions(-) diff --git a/.claude/rules/sim-settings-pages.md b/.claude/rules/sim-settings-pages.md index 7438f5aaf5e..2cff0545957 100644 --- a/.claude/rules/sim-settings-pages.md +++ b/.claude/rules/sim-settings-pages.md @@ -256,9 +256,20 @@ Every detail header reads left→right: ``` You do not have to get the array order right — `orderHeaderActions()` ranks them -(secondary → `id:'discard'` → `variant:'primary'`), order-stable within each -band, so spreading `saveDiscardActions()` first still renders Save last. Both -action stacks apply it: `SettingsHeaderShell` and `SettingsActionChips`. +(secondary → `id:'delete'` → `id:'discard'` → `variant:'primary'`), order-stable +within each band, so spreading `saveDiscardActions()` first still renders Save +last. Three stacks apply it: `SettingsHeaderShell`, `SettingsActionChips`, and +`Resource.Header` — so tables, files, knowledge and logs get the same ordering +as settings. + +Delete is placed by its **`id`**, not by position, which is why `id:'delete'` is +required rather than cosmetic: a page with no primary action still must not +leave a destructive chip in the slot a primary would occupy. + +The bar geometry and the action cluster are both single-sourced in +`@/components/page-header-bar` — `PAGE_HEADER_BAR` (or `Resource.Header`'s +bordered variant) and `HEADER_ACTION_CLUSTER`. Never re-derive `h-[30px]`, +`gap-1`, or the lane padding per header. Covered by `settings-header-order.test.ts` and `settings-header-shell.test.tsx` — the latter pins that a reordered chip still routes to its own handler. diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-detail-layout.tsx b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-detail-layout.tsx index b6edcb5801d..8efb140820d 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-detail-layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-detail-layout.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from 'react' import { cn } from '@sim/emcn' -import { PAGE_HEADER_BAR } from '@/components/page-header-bar' +import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar' interface CredentialDetailLayoutProps { /** Back link rendered at the start of the fixed action bar. */ @@ -21,7 +21,7 @@ export function CredentialDetailLayout({ back, actions, children }: CredentialDe
{back} - {actions ?
{actions}
: null} + {actions ?
{actions}
: null}
{children}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx index df0960385c5..9f295fe229a 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx @@ -31,7 +31,8 @@ import { } from '@sim/emcn' import { ArrowUpLeft } from 'lucide-react' import { createPortal } from 'react-dom' -import { TITLE_BAR_LANE_PT } from '@/components/page-header-bar' +import { HEADER_ACTION_CLUSTER, TITLE_BAR_LANE_PT } from '@/components/page-header-bar' +import { orderHeaderActions } from '@/components/settings/settings-header' import { InlineRenameInput } from '@/app/workspace/[workspaceId]/components/inline-rename-input' export interface DropdownOption { @@ -77,6 +78,13 @@ export interface BreadcrumbItem { * a selected/toggle state with `active` (e.g. the Logs/Dashboard view toggle). */ export interface ResourceAction { + /** + * Stable render identity, and the action's slot in the row. `'delete'` and + * `'discard'` are ordered by {@link orderHeaderActions} rather than by where the + * caller listed them; any other id is just a key. Falls back to `text`, which + * remounts the chip whenever the label flips (Delete → Deleting...). + */ + id?: string icon?: ComponentType<{ className?: string }> text: string variant?: 'primary' | 'destructive' @@ -202,11 +210,11 @@ export const ResourceHeader = memo(function ResourceHeader({ )}
{(aside || (actions && actions.length > 0)) && ( -
+
{aside} - {actions?.map((action) => ( + {orderHeaderActions(actions).map(({ action }) => ( Integrations -
+
{oauthService ? ( hasServiceAccount ? ( Skills - {rightSlot &&
{rightSlot}
} + {rightSlot &&
{rightSlot}
}
) } diff --git a/apps/sim/components/page-header-bar.ts b/apps/sim/components/page-header-bar.ts index 1c1fb8e5ce9..23436cad2e2 100644 --- a/apps/sim/components/page-header-bar.ts +++ b/apps/sim/components/page-header-bar.ts @@ -23,3 +23,12 @@ export const TITLE_BAR_LANE_PT = 'pt-[calc(8.5px+var(--workspace-content-title-b * Single source of truth for this geometry — never re-derive it per page. */ export const PAGE_HEADER_BAR = `flex flex-shrink-0 items-center bg-[var(--bg)] px-4 ${TITLE_BAR_LANE_PT} pb-[8.5px]` + +/** + * The right-hand action cluster inside a top bar. Every header — settings, + * credential detail, `Resource` pages, the integrations tab strip — wears this, + * so a chip row is the same height and rhythm wherever it appears. + * + * Single source of truth: never re-derive `h-[30px]`/`gap-1` per header. + */ +export const HEADER_ACTION_CLUSTER = 'flex h-[30px] items-center gap-1' diff --git a/apps/sim/components/settings/settings-header-order.test.ts b/apps/sim/components/settings/settings-header-order.test.ts index a648c32d42a..d2700d7b146 100644 --- a/apps/sim/components/settings/settings-header-order.test.ts +++ b/apps/sim/components/settings/settings-header-order.test.ts @@ -56,6 +56,18 @@ describe('orderHeaderActions', () => { expect(rendered(actions)).toEqual(['Edit server', 'Delete', 'Add workflows']) }) + it('places Delete by its id, not by where the caller listed it', () => { + // A page with no primary action still must not leave Delete in the slot a + // primary would occupy — files detail is exactly this shape. + const actions: SettingsAction[] = [ + { id: 'delete', text: 'Delete', onSelect: noop }, + { text: 'Download', onSelect: noop }, + { text: 'Share', onSelect: noop }, + ] + + expect(rendered(actions)).toEqual(['Download', 'Share', 'Delete']) + }) + it('preserves caller order within a band', () => { const actions: SettingsAction[] = [ { text: 'Refresh', onSelect: noop }, diff --git a/apps/sim/components/settings/settings-header.tsx b/apps/sim/components/settings/settings-header.tsx index c194d435b23..91747443095 100644 --- a/apps/sim/components/settings/settings-header.tsx +++ b/apps/sim/components/settings/settings-header.tsx @@ -14,7 +14,7 @@ import { useState, } from 'react' import { Chip, ChipInput, ChipLink, cn, Search, Tooltip } from '@sim/emcn' -import { PAGE_HEADER_BAR } from '@/components/page-header-bar' +import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar' const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect @@ -191,9 +191,13 @@ export function SettingsActionChips({ actions }: { actions: SettingsAction[] }) } /** - * Every detail header reads left→right as + * Every header reads left→right as * `[secondary actions] → [Delete] → [Discard] → [Save]`. * + * Delete is placed by its `id`, not by where the caller happened to put it, so a + * page with no primary action still can't leave a destructive chip in the slot a + * primary would occupy. + * * The shell enforces it rather than trusting callsites, because the natural way * to write the array — spreading {@link saveDiscardActions} first, then adding a * Delete — produces the opposite order and puts a destructive chip to the right @@ -208,8 +212,9 @@ export function orderHeaderActions( actions: SettingsAction[] | undefined ): { action: SettingsAction; index: number }[] { const rank = (action: SettingsAction) => { - if (action.variant === 'primary') return 2 - if (action.id === 'discard') return 1 + if (action.variant === 'primary') return 3 + if (action.id === 'discard') return 2 + if (action.id === 'delete') return 1 return 0 } return (actions ?? []) @@ -233,7 +238,7 @@ export function SettingsHeaderShell({ children }: { children: ReactNode }) { ) : (
)} -
+
{docsLink && ( Docs