diff --git a/apps/docs/content/docs/platform/connected-accounts.mdx b/apps/docs/content/docs/platform/connected-accounts.mdx new file mode 100644 index 00000000000..1840650f209 --- /dev/null +++ b/apps/docs/content/docs/platform/connected-accounts.mdx @@ -0,0 +1,117 @@ +--- +title: Connected accounts +description: Collect accounts in one organization credential group and control which workspaces can use them. +--- + +import { Callout } from 'fumadocs-ui/components/callout' + +**Connected accounts** collects people's accounts in one shared pool for your organization, also called a **credential group**. An organization admin chooses the providers, invites people to connect, and allows specific workspaces to use the pool. Workflows in those workspaces can find an account by email through the [Credential block](/workflows/blocks/credential). + +Each organization has at most one pool. Creating it does not give any workspace access; the workspace allowlist starts empty. + +## Availability + +Connected accounts must be enabled for your organization. Sim Cloud also requires an active Enterprise plan. Organization owners and admins manage the pool, subject to the organization's permission settings. A workspace admin who is not an organization admin cannot change the pool or its workspace access. + +For self-hosted deployments using environment-based feature flags, set `CREDENTIAL_GROUPS=true`. Availability is organization-scoped; personal workspaces cannot use an organization pool. + +The setup instructions below describe **Settings → Connected accounts**, shown when Knowledge Member Access is disabled. When `KNOWLEDGE_MEMBER_ACCESS=true` and connected accounts is available, organization settings shows the Search **Integrations** page instead. Only the selected page is available, including through direct links. Switching pages does not remove existing connections or workspace access. + +## Set up connected accounts + +Open your organization’s **Settings → Connected accounts**. Select **Set up connected accounts** if this is your first time. + +The page has three tabs: + +| Tab | What you manage | +| --- | --- | +| **Providers** | Services people can connect and their shared configuration | +| **People** | Connection requests, each person's connected accounts, and revocation | +| **Workspace access** | Workspaces allowed to use the organization's accounts | + +### 1. Add providers + +In **Providers**, select **Add provider** and search the catalog. Complete any required configuration before adding the provider. Added providers appear in the list; use **Configure** to edit their settings or **Remove** in the row menu to remove them. + +| Provider | Organization setup | +| --- | --- | +| OAuth providers, such as Gmail or Google Drive | Add the provider from the available catalog. Each invited person authorizes their account. | +| Slack | Supply the Slack App ID, Slack workspace ID, OAuth client ID, and client secret, then complete app verification. The organization uses one app and Slack workspace. Existing workspace Slack bots remain separate. | +| Fireflies and Granola | Add the provider. Sim supplies the MCP endpoint and handles OAuth client registration. People only complete their own authorization. | +| Databricks | Enter a name, the tenant's MCP URL, a registered OAuth client ID, and a client secret if required by that client. | + +For Databricks, **Add** validates and saves the configuration before the provider appears in the list. Cancelling the form leaves nothing added. Use an official Databricks HTTPS MCP endpoint, such as `https://your-workspace.cloud.databricks.com/api/2.0/mcp/sql`, and the OAuth client registered for your deployment. People connecting later use this organization configuration. + +When editing Databricks, leaving the client secret blank preserves the saved secret. Changing the MCP URL or OAuth client requires people to reconnect. + +Adding a provider makes account connections available without enabling indexing. Search source setup is managed on **Settings → Integrations** when Search is enabled. Connected accounts has no indexing controls or status indicators. Managed MCP providers currently support live tool calls only. + +### 2. Invite people + +For Search-enabled organizations, open **Settings → Integrations → People**. Otherwise, use **Settings → Connected accounts → People**. Set up a provider for personal account connections before inviting people; approving a Search integration alone is not enough. + +1. Select **Request connections**. +2. Enter their email addresses and select **Send requests**. +3. Each person opens the invitation, signs in to Sim with the verified invitation email, and authorizes the providers they want to connect. +4. They select **Submit** to finish the connection form. + +Invitees can contribute accounts without joining your organization. The invitation grants access to their connection form; it does not grant access to your workspaces or workflows. + +Use the invitation email in workflow lookups. For example, if you invite `alex@example.com`, **Find Organization Account** with that email and **Gmail** finds Alex's active Gmail contribution. + +#### How the email is associated with a Sim user + +On first use, the invitation email must match the signed-in user's verified Sim email. Sim then binds the invitation to that user's account. Another person cannot take it over by opening the link, and a later email change does not transfer it to a different Sim user. The invitation email remains the workflow lookup key. + +OAuth account providers also verify that the provider email matches the invitation email. Managed MCP connections are associated with the verified Sim user who completes authorization; they do not independently verify the MCP provider's account email. Keep using the invitation email when looking up those connections. + +In **People**, open a person's actions menu and select **Resend** when they need another invitation. **Revoke** stops the organization from using that person's contributions. The person cannot undo an administrator's revocation by reconnecting on their own. + +### 3. Allow workspaces + +Open **Workspace access** and select **Add workspaces** to choose one or more workspaces. The list shows workspaces that have access; use a row's **Remove** action to withdraw it. Adding or removing a workspace applies immediately. Only workspaces in this organization can be allowed. + + +An allowed workspace gives every authorized manual and deployed workflow in that workspace access to **every active account in the pool**. This includes scheduled, webhook, and public deployments that pass their normal workflow authorization. There is no separate workflow allowlist or restriction to the running user's own contributions. + + +For example, allowing the Support workspace lets its authorized workflows use Alex's Gmail contribution even when someone else runs the workflow. Allowing the workspace does not grant anyone permission to edit or run a workflow they could not otherwise access. + +Removing a workspace stops subsequent use of the pool. It does not recall provider requests already in flight or remove data a workflow has already received. Chat continues to use each person's own connections; workspace access does not give Chat access to other people's accounts. + +## Use accounts in workflows + +Use the **Credential** block's organization operations in an allowed workspace: + +- **Find Organization Account** selects an OAuth account by invitation email and provider. +- **List Organization Accounts** returns a page of OAuth accounts, optionally filtered by email and providers. +- **Find Organization MCP Connection** selects a person's managed MCP connection by invitation email and MCP provider. +- **List Organization MCP Connections** returns a page of managed MCP connections, optionally filtered by email and provider. + +The organization is determined by the workflow's workspace. You do not enter a credential group ID or organization ID in the block. + +The outputs are account references, without tokens. Use an OAuth `credentialId` in the corresponding integration block's credential field. For managed MCP, `credentialId` identifies the person's connection; `mcpServerId` identifies shared configuration and cannot select that person's authorization by itself. + +See the [Credential block reference](/workflows/blocks/credential#organization-accounts) for inputs, outputs, pagination, and connection-event triggers. + +## Reconnect or stop sharing + +People can open **Settings → Account → Connected accounts** to view accounts they contributed, including contributions to organizations they have not joined. **Reconnect** starts authorization again. **Disconnect** stops the organization from using that account in subsequent calls. + +For administrators, the controls have different scopes: + +| Action | Effect | +| --- | --- | +| Remove a provider | Stops use of that provider's contributions in the pool | +| Revoke a person | Stops use of all that person's contributions to the organization | +| Remove a workspace from the allowlist | Stops that workspace's workflows from using the pool | + +## Current limits and troubleshooting + +- **One pool per organization:** there are no additional groups, per-workflow grants, or per-account workspace allowlists. +- **One Databricks configuration:** multiple tenant endpoints cannot be added as separate Databricks providers in the same pool. +- **Organization operations are missing:** confirm the feature is enabled for the organization and the workflow's workspace is allowed. +- **An invitation rejects your sign-in:** use the verified Sim account matching the invitation email. For OAuth providers, connect the provider account with that same email. +- **A Find operation fails:** it requires exactly one active matching connection. Check the invitation email, provider, connection status, and workspace access. It never chooses an arbitrary account when there are zero or multiple matches. +- **A list seems incomplete:** organization list operations return up to 100 connections per page. Follow `nextCursor` while `hasMore` is true. +- **A legacy Credential Group block fails:** replace it with the appropriate Credential block operation or trigger, update its output references, and redeploy the workflow. diff --git a/apps/docs/content/docs/platform/meta.json b/apps/docs/content/docs/platform/meta.json index c4d59da4bdc..4e7ffeec952 100644 --- a/apps/docs/content/docs/platform/meta.json +++ b/apps/docs/content/docs/platform/meta.json @@ -1,4 +1,11 @@ { "title": "Platform", - "pages": ["workspaces", "organization", "permissions", "credentials", "costs"] + "pages": [ + "workspaces", + "organization", + "permissions", + "connected-accounts", + "credentials", + "costs" + ] } diff --git a/apps/docs/content/docs/workflows/blocks/credential.mdx b/apps/docs/content/docs/workflows/blocks/credential.mdx index ae28df764a2..a6044fea0a0 100644 --- a/apps/docs/content/docs/workflows/blocks/credential.mdx +++ b/apps/docs/content/docs/workflows/blocks/credential.mdx @@ -1,6 +1,6 @@ --- title: Credential -description: The Credential block outputs an OAuth credential's ID reference for downstream blocks to use. +description: Select workspace OAuth credentials or find organization OAuth and MCP account references for downstream blocks. --- import { Callout } from 'fumadocs-ui/components/callout' @@ -14,7 +14,7 @@ import { } from '@/components/workflow-preview' import { FAQ } from '@/components/ui/faq' -The **Credential block** hands a downstream block an OAuth credential to use, without exposing the secret. It has two operations: **Select Credential** outputs one credential's ID reference for other blocks to use; **List Credentials** returns all the workspace's OAuth credentials (optionally filtered by provider) as an array to iterate over. +The **Credential block** passes account references to downstream blocks without exposing tokens. **Select Credential** and **List Credentials** use workspace OAuth credentials. When [organization connected accounts](/platform/connected-accounts) is enabled and shared with the workflow's workspace, the organization operations find or list contributed OAuth accounts and managed MCP connections. @@ -30,6 +30,10 @@ The **Credential block** hands a downstream block an OAuth credential to use, wi |---|---| | **Select Credential** | Pick one OAuth credential and output its reference — use this to wire a single credential into downstream blocks | | **List Credentials** | Return all OAuth credentials in the workspace as an array — use this with a ForEach loop | +| **Find Organization Account** | Find exactly one active OAuth contribution by invitation email and provider | +| **List Organization Accounts** | Return a page of active OAuth contributions, optionally filtered by email and providers | +| **Find Organization MCP Connection** | Find exactly one active managed MCP connection by invitation email and MCP provider | +| **List Organization MCP Connections** | Return a page of active managed MCP connections, optionally filtered by email and provider | ### Credential (Select operation) @@ -73,6 +77,68 @@ Filter the returned OAuth credentials by provider. Select one or more providers +## Organization accounts + +An organization owner or admin must first [set up connected accounts](/platform/connected-accounts) and allow this workflow's workspace. The block uses the organization that owns the workspace; there is no credential group or organization selector. + +Every authorized workflow in an allowed workspace can use every active contribution in the organization's pool. Results are not restricted to the running user's own accounts, and no separate per-workflow grant is required. Normal workflow permissions still apply. + +### Inputs + +| Operation | Required fields | Optional fields | +| --- | --- | --- | +| **Find Organization Account** | Email, Provider | — | +| **List Organization Accounts** | — | Email, Providers, Limit, Cursor | +| **Find Organization MCP Connection** | Email, MCP provider | — | +| **List Organization MCP Connections** | — | Email, MCP provider, Limit, Cursor | + +For list operations, **Limit** accepts 1–100 and defaults to 100. **Cursor** accepts the previous page's `nextCursor`. + +**Email** refers to the address used for the person's invitation. Sim associates that invitation with their verified Sim user. See [email association](/platform/connected-accounts#how-the-email-is-associated-with-a-sim-user) for how OAuth and managed MCP identity checks differ. + +Find operations fail unless there is exactly one active matching connection. List operations return an empty array when there are no matches and omit inactive or revoked connections. + +### OAuth outputs + +**Find Organization Account** returns `credentialId`, `displayName`, `providerId`, and the invitation `email`. Pass `credentialId` into the corresponding integration block's credential field in advanced mode. + +**List Organization Accounts** returns these account references in `credentials`, along with `count`, `hasMore`, and `nextCursor`. `count` is the number returned on this page. Feed `credentials` into a ForEach loop and use `` inside the loop. To process additional pages, pass `nextCursor` into another call with the same filters while `hasMore` is true; the block does not fetch all pages automatically. + +For example, name a Credential block **account**, choose **Find Organization Account**, set **Email** to `alex@example.com`, and select **Gmail**. Reference `` in a Gmail block to act using Alex's contribution. + +### Managed MCP outputs + +**Find Organization MCP Connection** returns: + +| Output | Type | Description | +| --- | --- | --- | +| `credentialId` | `string` | The person's managed MCP connection ID; use this connection for MCP calls | +| `email` | `string` | Invitation email used to find the connection | +| `displayName` | `string` | Connection display name | +| `mcpServerId` | `string` | Shared MCP configuration ID | +| `mcpServerName` | `string` | Configured MCP server name | +| `toolNames` | `json` | Tool names available to this connection | + +**List Organization MCP Connections** returns these objects in `mcpConnections`, plus `count`, `hasMore`, and `nextCursor`. Pagination works the same way as for organization OAuth accounts; `nextCursor` is `null` on the last page. + + +For a managed MCP account, use the returned **`credentialId`** to select the person's connection in the MCP Tool block. **`mcpServerId`** identifies the shared provider configuration; it does not identify a person's authorization. No OAuth token or client secret is returned by the Credential block. + + +## Connection-event triggers + +Switch the Credential block to trigger mode to start a workflow when an account connects or a connection form is submitted. Select an **Event** and deploy the workflow in an allowed workspace. + +| Event | When it runs | +| --- | --- | +| **Credential Added** | A person adds a new account contribution | +| **Credential Reconnected** | A person reconnects an existing contribution | +| **Account Connections Submitted** | A person submits the connection form | + +Events include `event`, `timestamp`, `email`, `enrollmentId`, `enrollmentStatus`, `credentialGroupId`, and `credentialGroupName`. Added and reconnected events also include account details such as `credentialId`, `provider`, and `displayName`; `mcpServerId` identifies shared configuration for an MCP connection and is `null` for an OAuth account. + +Each deployed workflow that selects the event in an allowed workspace can receive it. Removing workspace access stops subsequent event delivery. Legacy **Credential Group** blocks must be replaced with the Credential block; they are not automatically converted. + ## Examples ### Share one credential across blocks @@ -123,17 +189,19 @@ The same reference works for any OAuth block. In a Gmail or Slack block's creden ## Best Practices - **Define once, reference many times**: When five blocks use the same Google account, use one Credential block and wire all five to `` instead of selecting the account five times -- **Outputs are safe to log**: The `credentialId` output is a UUID reference, not a secret. It is safe to inspect in execution logs +- **Inspect references, not tokens**: The block returns credential IDs and account metadata rather than tokens. Organization outputs can include people's email addresses - **Use for environment switching**: Pair with a Condition block to route to a production or staging OAuth credential based on a workflow variable - **Advanced mode is required**: Downstream blocks must be in advanced mode on their credential field to accept a dynamic reference - **Use List + ForEach for fan-out**: When you need to run the same action across all accounts of a provider, List Credentials feeds naturally into a ForEach loop - **Narrow by provider**: Use the Provider multiselect to filter to specific services — only providers you have credentials for are shown in your Function block's code. Note that the function will receive the raw UUID string — if you need the resolved token, the downstream block must handle the resolution (as integration blocks do). The Function block does not automatically resolve credential IDs." }, + { question: "Can I use a Credential block output in a Function block?", answer: "Yes. Reference in your Function block's code. The function receives the ID string. It does not automatically resolve credential IDs to tokens; use a compatible integration or MCP block to perform an authenticated action." }, { question: "What happens if the credential is deleted?", answer: "The Select operation will throw an error at execution time: 'Credential not found'. The List operation will simply omit the deleted credential from the results. Update the Credential block to select a valid credential before re-running." }, ]} /> diff --git a/apps/sim/app/(auth)/oauth/sign-in/route.test.ts b/apps/sim/app/(auth)/oauth/sign-in/route.test.ts index ae2b7dea886..1157b335229 100644 --- a/apps/sim/app/(auth)/oauth/sign-in/route.test.ts +++ b/apps/sim/app/(auth)/oauth/sign-in/route.test.ts @@ -151,7 +151,7 @@ describe('OAuth login bridge', () => { }) ) expect(response.status).toBe(307) - expect(response.headers.get('location')).toBe('https://sim.test/workspace') + expect(response.headers.get('location')).toBe('https://sim.test/home') } }) diff --git a/apps/sim/app/api/credential-groups/oauth-callback.ts b/apps/sim/app/api/credential-groups/oauth-callback.ts index 45a47a2565c..67d998aa631 100644 --- a/apps/sim/app/api/credential-groups/oauth-callback.ts +++ b/apps/sim/app/api/credential-groups/oauth-callback.ts @@ -5,6 +5,7 @@ import { NextResponse } from 'next/server' import type { CredentialGroupOAuthCallbackQuery } from '@/lib/api/contracts/credential-groups' import { credentialGroupOAuthAttemptPrincipal } from '@/lib/credential-groups/application/enrollment-auth' import { completePublicCredentialGroupOAuth } from '@/lib/credential-groups/application/public-enrollment' +import { CredentialGroupOAuthStateVersionError } from '@/lib/credential-groups/oauth-attempt-version' import { consumeCredentialGroupOAuthAttempt } from '@/lib/credential-groups/oauth-state' import { CredentialGroupInvitationUnavailableError, @@ -38,6 +39,12 @@ export async function handleCredentialGroupOAuthCallback({ try { attempt = await consumeCredentialGroupOAuthAttempt(state) } catch (error) { + if (error instanceof CredentialGroupOAuthStateVersionError) { + return NextResponse.json( + { error: error.message }, + { status: 400, headers: { 'Cache-Control': 'no-store' } } + ) + } logger.error('Failed to consume credential group OAuth state', { error: getErrorMessage(error), }) @@ -70,9 +77,8 @@ export async function handleCredentialGroupOAuthCallback({ return failureRedirect('failed') } - const principal = credentialGroupOAuthAttemptPrincipal(attempt) - try { + const principal = await credentialGroupOAuthAttemptPrincipal(attempt) await completePublicCredentialGroupOAuth.execute({ principal, input: { attempt, code }, diff --git a/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.test.ts b/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.test.ts index 3413974291f..7056e109ba9 100644 --- a/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.test.ts +++ b/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.test.ts @@ -3,6 +3,7 @@ */ import { NextRequest, NextResponse } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { CredentialGroupOAuthStateVersionError } from '@/lib/credential-groups/oauth-attempt-version' const mocks = vi.hoisted(() => ({ authenticate: vi.fn(), @@ -249,4 +250,14 @@ describe('credential group OAuth callback', () => { expect(response.headers.get('location')).toBe('/credential-groups/complete?oauth=rate_limited') expect(mocks.completeOAuth).not.toHaveBeenCalled() }) + it('reports a state protocol change as an explicit restart without exchanging a code', async () => { + mocks.consumeAttempt.mockRejectedValue(new CredentialGroupOAuthStateVersionError()) + const response = await GET(request('state=state-1&code=code-1'), context) + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ + error: expect.stringContaining('Reopen your invitation and connect again'), + }) + expect(mocks.authenticate).not.toHaveBeenCalled() + expect(mocks.completeOAuth).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/app/api/mcp/oauth/callback/route.test.ts b/apps/sim/app/api/mcp/oauth/callback/route.test.ts index 7d72946d574..d1af1e73161 100644 --- a/apps/sim/app/api/mcp/oauth/callback/route.test.ts +++ b/apps/sim/app/api/mcp/oauth/callback/route.test.ts @@ -10,6 +10,7 @@ import { } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { CredentialGroupOAuthStateVersionError } from '@/lib/credential-groups/oauth-attempt-version' const { mockAuthenticateEnrollment, @@ -195,4 +196,13 @@ describe('MCP OAuth callback route', () => { expect(mockConsumeManagedAttempt).not.toHaveBeenCalled() expect(mockCompleteManagedMcpOAuth).not.toHaveBeenCalled() }) + it('reports a state protocol change without exchanging a code or loading an enrollment', async () => { + mockConsumeManagedAttempt.mockRejectedValue(new CredentialGroupOAuthStateVersionError()) + const response = await GET( + new NextRequest('http://localhost:3000/api/mcp/oauth/callback?state=mcp_cg_old&code=code') + ) + expect(await response.text()).toContain('Reopen your invitation and connect again') + expect(mockAuthenticateEnrollment).not.toHaveBeenCalled() + expect(mockCompleteManagedMcpOAuth).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/app/api/mcp/oauth/callback/route.ts b/apps/sim/app/api/mcp/oauth/callback/route.ts index 8259cc38dc8..dd31f4a24b2 100644 --- a/apps/sim/app/api/mcp/oauth/callback/route.ts +++ b/apps/sim/app/api/mcp/oauth/callback/route.ts @@ -15,6 +15,7 @@ import { consumeCredentialGroupMcpOAuthAttempt, isCredentialGroupMcpOAuthState, } from '@/lib/credential-groups/mcp-oauth-state' +import { CredentialGroupOAuthStateVersionError } from '@/lib/credential-groups/oauth-attempt-version' import { enforcePublicCredentialGroupIpRateLimit } from '@/lib/credential-groups/rate-limit' import { assertSafeOauthServerUrl, @@ -84,7 +85,15 @@ async function completeManagedMcpCallback(params: { code?: string error?: string }): Promise { - const attempt = await consumeCredentialGroupMcpOAuthAttempt(params.state) + let attempt + try { + attempt = await consumeCredentialGroupMcpOAuthAttempt(params.state) + } catch (error) { + if (error instanceof CredentialGroupOAuthStateVersionError) { + return htmlClose(error.message, false, 'invalid_state', undefined, params.state) + } + throw error + } if (!attempt) { return htmlClose('Invalid or expired authorization state.', false, 'invalid_state') } @@ -97,7 +106,7 @@ async function completeManagedMcpCallback(params: { }) } try { - const principal = credentialGroupOAuthAttemptPrincipal(attempt) + const principal = await credentialGroupOAuthAttemptPrincipal(attempt) const result = await completePublicCredentialGroupMcpOAuth.execute({ principal, input: { attempt, code: params.code }, @@ -194,7 +203,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { .where(and(eq(mcpServers.id, row.mcpServerId), isNull(mcpServers.deletedAt))) .limit(1) ) - if (!server || !server.url) { + if (!server || !server.url || !server.workspaceId) { return respond('Server no longer exists.', false, 'server_gone', serverId) } if (server.workspaceId !== row.workspaceId) { @@ -206,6 +215,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) } const serverUrl = server.url + const serverWorkspaceId = server.workspaceId try { assertSafeOauthServerUrl(serverUrl) } catch { @@ -257,7 +267,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { try { // forceRefresh: skip any stale cache from before re-auth. await timedStep('discoverServerTools', 60_000, () => - mcpService.discoverServerTools(session.user.id, server.id, server.workspaceId, 'force') + mcpService.discoverServerTools(session.user.id, server.id, serverWorkspaceId, 'force') ) } catch (e) { logger.warn('Post-auth tools refresh failed', toError(e).message) diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/[groupId]/slack-managed-users/route.test.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/[groupId]/slack-managed-users/route.test.ts index 25cc53fab41..146a97011f1 100644 --- a/apps/sim/app/api/organizations/[id]/connected-accounts/[groupId]/slack-managed-users/route.test.ts +++ b/apps/sim/app/api/organizations/[id]/connected-accounts/[groupId]/slack-managed-users/route.test.ts @@ -18,7 +18,8 @@ import { credentialGroupOperations } from '@/lib/credential-groups/application/o import { POST } from '@/app/api/organizations/[id]/connected-accounts/[groupId]/slack-managed-users/route' const body = { - slackBotCredentialId: '11111111-1111-4111-8111-111111111111', + appId: 'A123', + teamId: 'T123', clientId: 'fixture-client-id', clientSecret: 'fixture-client-secret', } diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/databricks/route.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/databricks/route.ts new file mode 100644 index 00000000000..ef9d7e49571 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/connected-accounts/databricks/route.ts @@ -0,0 +1,40 @@ +import { + configureOrganizationMcpContract, + getOrganizationDatabricksSetupContract, +} from '@/lib/api/contracts/organization-accounts' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + configureOrganizationMcp, + configureOrganizationMcpOperation, +} from '@/lib/credential-groups/application/configure-organization-mcp' +import { + getOrganizationDatabricksSetup, + getOrganizationDatabricksSetupOperation, +} from '@/lib/credential-groups/application/organization-databricks-setup' + +export const GET = defineInternalJsonRoute({ + contract: getOrganizationDatabricksSetupContract, + auth: internalSessionAuth, + operation: getOrganizationDatabricksSetupOperation, + rateLimit: internalRateLimits.user({ bucketName: 'organization-databricks-setup' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ organizationId: params.id }), + useCase: getOrganizationDatabricksSetup, + present: ({ server }) => ({ server }), +}) + +export const PUT = defineInternalJsonRoute({ + contract: configureOrganizationMcpContract, + auth: internalSessionAuth, + operation: configureOrganizationMcpOperation, + rateLimit: internalRateLimits.user({ bucketName: 'organization-databricks-setup' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ organizationId: params.id, ...body }), + useCase: configureOrganizationMcp, + present: ({ mcpServer }) => ({ mcpServer }), +}) diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/indexing/route.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/indexing/route.ts new file mode 100644 index 00000000000..d79a286c00f --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/connected-accounts/indexing/route.ts @@ -0,0 +1,22 @@ +import { updateOrganizationAccountIndexingContract } from '@/lib/api/contracts/organization-accounts' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + updateOrganizationAccountIndexing, + updateOrganizationAccountIndexingOperation, +} from '@/lib/credential-groups/application/organization-account-indexing' + +export const PUT = defineInternalJsonRoute({ + contract: updateOrganizationAccountIndexingContract, + auth: internalSessionAuth, + operation: updateOrganizationAccountIndexingOperation, + rateLimit: internalRateLimits.user({ bucketName: 'organization-account-indexing' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ organizationId: params.id, ...body }), + useCase: updateOrganizationAccountIndexing, + present: ({ enabled, knowledgeBaseIds }) => ({ enabled, knowledgeBaseIds }), +}) diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/mcp-providers/[connectorId]/route.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/mcp-providers/[connectorId]/route.ts new file mode 100644 index 00000000000..54055821f39 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/connected-accounts/mcp-providers/[connectorId]/route.ts @@ -0,0 +1,22 @@ +import { removeOrganizationAccountMcpProviderContract } from '@/lib/api/contracts/organization-accounts' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + organizationAccountManagementOperations, + removeOrganizationAccountMcpProvider, +} from '@/lib/credential-groups/application/organization-account-management' + +export const DELETE = defineInternalJsonRoute({ + contract: removeOrganizationAccountMcpProviderContract, + auth: internalSessionAuth, + operation: organizationAccountManagementOperations.removeMcp, + rateLimit: internalRateLimits.user({ bucketName: 'organization-connected-accounts' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ organizationId: params.id, connectorId: params.connectorId }), + useCase: removeOrganizationAccountMcpProvider, + present: () => ({ success: true as const }), +}) diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/mcp-providers/route.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/mcp-providers/route.ts new file mode 100644 index 00000000000..101a8e6c3f7 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/connected-accounts/mcp-providers/route.ts @@ -0,0 +1,22 @@ +import { addOrganizationAccountMcpProviderContract } from '@/lib/api/contracts/organization-accounts' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + addOrganizationAccountMcpProvider, + organizationAccountManagementOperations, +} from '@/lib/credential-groups/application/organization-account-management' + +export const POST = defineInternalJsonRoute({ + contract: addOrganizationAccountMcpProviderContract, + auth: internalSessionAuth, + operation: organizationAccountManagementOperations.addMcp, + rateLimit: internalRateLimits.user({ bucketName: 'organization-connected-accounts' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ organizationId: params.id, ...body }), + useCase: addOrganizationAccountMcpProvider, + present: ({ mcpServer }) => ({ mcpServer }), +}) diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/people/[enrollmentId]/resend/route.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/people/[enrollmentId]/resend/route.ts new file mode 100644 index 00000000000..838defa8935 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/connected-accounts/people/[enrollmentId]/resend/route.ts @@ -0,0 +1,22 @@ +import { resendOrganizationAccountInvitationContract } from '@/lib/api/contracts/organization-accounts' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + organizationAccountManagementOperations, + resendOrganizationAccountInvitation, +} from '@/lib/credential-groups/application/organization-account-management' + +export const POST = defineInternalJsonRoute({ + contract: resendOrganizationAccountInvitationContract, + auth: internalSessionAuth, + operation: organizationAccountManagementOperations.resend, + rateLimit: internalRateLimits.user({ bucketName: 'organization-connected-accounts' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ organizationId: params.id, enrollmentId: params.enrollmentId }), + useCase: resendOrganizationAccountInvitation, + present: ({ credentialGroupEnrollment }) => ({ credentialGroupEnrollment }), +}) diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/people/[enrollmentId]/route.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/people/[enrollmentId]/route.ts new file mode 100644 index 00000000000..936ad572ccb --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/connected-accounts/people/[enrollmentId]/route.ts @@ -0,0 +1,22 @@ +import { revokeOrganizationAccountEnrollmentContract } from '@/lib/api/contracts/organization-accounts' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + organizationAccountManagementOperations, + revokeOrganizationAccountEnrollment, +} from '@/lib/credential-groups/application/organization-account-management' + +export const DELETE = defineInternalJsonRoute({ + contract: revokeOrganizationAccountEnrollmentContract, + auth: internalSessionAuth, + operation: organizationAccountManagementOperations.revoke, + rateLimit: internalRateLimits.user({ bucketName: 'organization-connected-accounts' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ organizationId: params.id, enrollmentId: params.enrollmentId }), + useCase: revokeOrganizationAccountEnrollment, + present: ({ credentialGroupEnrollment }) => ({ credentialGroupEnrollment }), +}) diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/people/route.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/people/route.ts new file mode 100644 index 00000000000..61e8cd3fd0e --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/connected-accounts/people/route.ts @@ -0,0 +1,36 @@ +import { + inviteOrganizationAccountPeopleContract, + listOrganizationAccountPeopleContract, +} from '@/lib/api/contracts/organization-accounts' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + inviteOrganizationAccountPeople, + listOrganizationAccountPeople, + organizationAccountManagementOperations, +} from '@/lib/credential-groups/application/organization-account-management' + +export const GET = defineInternalJsonRoute({ + contract: listOrganizationAccountPeopleContract, + auth: internalSessionAuth, + operation: organizationAccountManagementOperations.people, + rateLimit: internalRateLimits.user({ bucketName: 'organization-connected-accounts' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, query }) => ({ organizationId: params.id, ...query }), + useCase: listOrganizationAccountPeople, +}) + +export const POST = defineInternalJsonRoute({ + contract: inviteOrganizationAccountPeopleContract, + auth: internalSessionAuth, + operation: organizationAccountManagementOperations.invite, + rateLimit: internalRateLimits.user({ bucketName: 'organization-connected-accounts' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ organizationId: params.id, ...body }), + useCase: inviteOrganizationAccountPeople, + present: ({ results, sentCount, failedCount }) => ({ results, sentCount, failedCount }), +}) diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/workspace-access/route.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/workspace-access/route.ts new file mode 100644 index 00000000000..8ae3315a131 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/connected-accounts/workspace-access/route.ts @@ -0,0 +1,40 @@ +import { + getOrganizationAccountWorkspaceAccessContract, + updateOrganizationAccountWorkspaceAccessContract, +} from '@/lib/api/contracts/organization-accounts' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + getOrganizationAccountWorkspaceAccess, + organizationAccountAccessOperations, + updateOrganizationAccountWorkspaceAccess, +} from '@/lib/credential-groups/application/organization-access' + +export const GET = defineInternalJsonRoute({ + contract: getOrganizationAccountWorkspaceAccessContract, + auth: internalSessionAuth, + operation: organizationAccountAccessOperations.read, + rateLimit: internalRateLimits.none({ + reason: 'Bounded administrator workspace access settings read', + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ organizationId: params.id }), + useCase: getOrganizationAccountWorkspaceAccess, +}) + +export const PUT = defineInternalJsonRoute({ + contract: updateOrganizationAccountWorkspaceAccessContract, + auth: internalSessionAuth, + operation: organizationAccountAccessOperations.update, + rateLimit: internalRateLimits.none({ + reason: 'Administrator policy revision protects bounded workspace access updates', + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ organizationId: params.id, ...body }), + useCase: updateOrganizationAccountWorkspaceAccess, + present: ({ revision, workspaceIds }) => ({ revision, workspaceIds }), +}) diff --git a/apps/sim/app/api/users/me/organization-accounts/[credentialId]/reconnect/route.ts b/apps/sim/app/api/users/me/organization-accounts/[credentialId]/reconnect/route.ts new file mode 100644 index 00000000000..c9d66c778e7 --- /dev/null +++ b/apps/sim/app/api/users/me/organization-accounts/[credentialId]/reconnect/route.ts @@ -0,0 +1,18 @@ +import { reconnectPersonalOrganizationAccountContract } from '@/lib/api/contracts/organization-accounts' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { reconnectPersonalOrganizationAccount } from '@/lib/credential-groups/application/personal-organization-accounts' + +export const POST = defineInternalJsonRoute({ + contract: reconnectPersonalOrganizationAccountContract, + auth: internalSessionAuth, + operation: reconnectPersonalOrganizationAccount.operation, + rateLimit: internalRateLimits.none({ reason: 'Current-user connected account management' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => params, + useCase: reconnectPersonalOrganizationAccount, +}) diff --git a/apps/sim/app/api/users/me/organization-accounts/[credentialId]/route.ts b/apps/sim/app/api/users/me/organization-accounts/[credentialId]/route.ts new file mode 100644 index 00000000000..56076c3e7ed --- /dev/null +++ b/apps/sim/app/api/users/me/organization-accounts/[credentialId]/route.ts @@ -0,0 +1,19 @@ +import { disconnectPersonalOrganizationAccountContract } from '@/lib/api/contracts/organization-accounts' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { disconnectPersonalOrganizationAccount } from '@/lib/credential-groups/application/personal-organization-accounts' + +export const DELETE = defineInternalJsonRoute({ + contract: disconnectPersonalOrganizationAccountContract, + auth: internalSessionAuth, + operation: disconnectPersonalOrganizationAccount.operation, + rateLimit: internalRateLimits.none({ reason: 'Current-user connected account management' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => params, + useCase: disconnectPersonalOrganizationAccount, + present: () => ({ success: true as const }), +}) diff --git a/apps/sim/app/api/users/me/organization-accounts/route.ts b/apps/sim/app/api/users/me/organization-accounts/route.ts new file mode 100644 index 00000000000..64d1129abeb --- /dev/null +++ b/apps/sim/app/api/users/me/organization-accounts/route.ts @@ -0,0 +1,18 @@ +import { listPersonalOrganizationAccountsContract } from '@/lib/api/contracts/organization-accounts' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { listPersonalOrganizationAccounts } from '@/lib/credential-groups/application/personal-organization-accounts' + +export const GET = defineInternalJsonRoute({ + contract: listPersonalOrganizationAccountsContract, + auth: internalSessionAuth, + operation: listPersonalOrganizationAccounts.operation, + rateLimit: internalRateLimits.none({ reason: 'Current-user connected account management' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ query }) => query, + useCase: listPersonalOrganizationAccounts, +}) diff --git a/apps/sim/app/api/workspaces/[id]/organization-accounts/route.ts b/apps/sim/app/api/workspaces/[id]/organization-accounts/route.ts new file mode 100644 index 00000000000..16ec78bb6c8 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/organization-accounts/route.ts @@ -0,0 +1,18 @@ +import { getWorkspaceOrganizationAccountsContract } from '@/lib/api/contracts/organization-accounts' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { getWorkspaceOrganizationAccounts } from '@/lib/credential-groups/application/workspace-organization-accounts' + +export const GET = defineInternalJsonRoute({ + contract: getWorkspaceOrganizationAccountsContract, + auth: internalSessionAuth, + operation: getWorkspaceOrganizationAccounts.operation, + rateLimit: internalRateLimits.none({ reason: 'Read-only workspace organization account status' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ workspaceId: params.id }), + useCase: getWorkspaceOrganizationAccounts, +}) diff --git a/apps/sim/app/api/workspaces/invitations/route.test.ts b/apps/sim/app/api/workspaces/invitations/route.test.ts index 8460eba4129..fe52275f533 100644 --- a/apps/sim/app/api/workspaces/invitations/route.test.ts +++ b/apps/sim/app/api/workspaces/invitations/route.test.ts @@ -119,7 +119,9 @@ describe('POST /api/workspaces/invitations/batch', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() + queueTableRows(schemaMock.user, [{ id: 'user-1', name: 'Owner User', email: 'owner@test.com' }]) mockGetSession.mockResolvedValue({ + session: { id: 'session-1' }, user: { id: 'user-1', email: 'owner@test.com', name: 'Owner User' }, }) mockGetWorkspaceWithOwner.mockResolvedValue({ @@ -171,6 +173,7 @@ describe('POST /api/workspaces/invitations/batch', () => { afterAll(() => { resetDbChainMock() + queueTableRows(schemaMock.user, [{ id: 'user-1', name: 'Owner User', email: 'owner@test.com' }]) }) it('blocks invites for personal workspaces with an upgrade prompt', async () => { diff --git a/apps/sim/app/credential-groups/enroll/[token]/page.test.tsx b/apps/sim/app/credential-groups/enroll/[token]/page.test.tsx index 7063e295305..7b5d08d9439 100644 --- a/apps/sim/app/credential-groups/enroll/[token]/page.test.tsx +++ b/apps/sim/app/credential-groups/enroll/[token]/page.test.tsx @@ -1,5 +1,6 @@ /** @vitest-environment jsdom */ import type { ReactNode } from 'react' +import { authMockFns } from '@sim/testing' import { renderToStaticMarkup } from 'react-dom/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { PublicCredentialGroupEnrollment } from '@/lib/credential-groups/enrollments' @@ -21,6 +22,8 @@ vi.mock('@/lib/credential-groups/rate-limit', () => ({ enforcePublicCredentialGroupIpRateLimit: mocks.rateLimit, })) vi.mock('@/lib/credential-groups/providers', () => ({ + CREDENTIAL_GROUP_PROVIDER_IDS: ['confluence', 'slack'], + CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS: ['confluence'], getCredentialGroupProviderService: (provider: string) => ({ providerId: provider, name: provider === 'confluence' ? 'Confluence' : 'Slack', @@ -77,6 +80,10 @@ function oauthLinks() { beforeEach(() => { vi.clearAllMocks() + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'member', email: 'member@example.test', emailVerified: true }, + session: { id: 'session-1' }, + }) mocks.authenticate.mockResolvedValue(principal) mocks.rateLimit.mockResolvedValue(null) enrollment = { diff --git a/apps/sim/app/credential-groups/enroll/[token]/page.tsx b/apps/sim/app/credential-groups/enroll/[token]/page.tsx index 31fbba0249b..790319a44f2 100644 --- a/apps/sim/app/credential-groups/enroll/[token]/page.tsx +++ b/apps/sim/app/credential-groups/enroll/[token]/page.tsx @@ -2,11 +2,14 @@ import { type ReactNode, Suspense } from 'react' import { Chip, ChipLink } from '@sim/emcn' import type { Metadata } from 'next' import { headers } from 'next/headers' +import { redirect } from 'next/navigation' +import { getSession } from '@/lib/auth' import { asOrchestrationError } from '@/lib/core/orchestration/types' import type { ResourceOwner } from '@/lib/core/resource-scope' import { resourceScopeFromOwner } from '@/lib/core/resource-scope' import { authenticateCredentialGroupEnrollment } from '@/lib/credential-groups/application/enrollment-auth' import { readPublicCredentialGroupEnrollment } from '@/lib/credential-groups/application/public-enrollment' +import { CredentialGroupEnrollmentError } from '@/lib/credential-groups/enrollments' import { getManagedMcpConnectorIcon } from '@/lib/credential-groups/managed-mcp-connector-icons' import { CredentialGroupProviderConfigurationError } from '@/lib/credential-groups/provider-adapter' import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers' @@ -51,18 +54,20 @@ function PageShell({ children }: PageShellProps) { interface UnavailableInvitationProps { rateLimited?: boolean + message?: string } -function UnavailableInvitation({ rateLimited = false }: UnavailableInvitationProps) { +function UnavailableInvitation({ rateLimited = false, message }: UnavailableInvitationProps) { return (
@@ -119,10 +124,23 @@ export default async function CredentialGroupEnrollmentPage({ const { token } = await params if (!token || token.length > 128) return - + const resolvedSearchParams = await searchParams + const session = await getSession() + if (!session?.user) { + const callback = new URLSearchParams() + for (const key of ['returnTo', 'optionId']) { + const value = getSearchParam(resolvedSearchParams, key) + if (value) callback.set(key, value) + } + const callbackUrl = `/credential-groups/enroll/${encodeURIComponent(token)}${callback.size ? `?${callback}` : ''}` + redirect(`/login?callbackUrl=${encodeURIComponent(callbackUrl)}`) + } + if (!session.user.emailVerified) + return ( + + ) const principal = await authenticateCredentialGroupEnrollment(token) if (!principal) return - const resolvedSearchParams = await searchParams const returnToSearch = resolvedSearchParams.returnTo === 'search' const requestedOptionId = resolvedSearchParams.optionId const focusedOptionId = @@ -132,12 +150,16 @@ export default async function CredentialGroupEnrollmentPage({ const enrollmentResult = await readPublicCredentialGroupEnrollment .execute({ principal, input: returnToSearch ? { optionId: focusedOptionId } : {} }) .catch((error: unknown) => { + if (error instanceof CredentialGroupEnrollmentError) + return { enrollment: null, enrollmentError: error.message } if (asOrchestrationError(error)?.code === 'not_found') return null if (returnToSearch && error instanceof CredentialGroupProviderConfigurationError) return { enrollment: null } throw error }) if (!enrollmentResult) return + if ('enrollmentError' in enrollmentResult) + return const { enrollment } = enrollmentResult if (!enrollment) return diff --git a/apps/sim/app/o/[organizationId]/chat/[chatId]/page.tsx b/apps/sim/app/o/[organizationId]/chat/[chatId]/page.tsx index a13ec7d6af0..d05f2568244 100644 --- a/apps/sim/app/o/[organizationId]/chat/[chatId]/page.tsx +++ b/apps/sim/app/o/[organizationId]/chat/[chatId]/page.tsx @@ -1,7 +1,9 @@ import type { Metadata } from 'next' -import { notFound } from 'next/navigation' +import { notFound, redirect } from 'next/navigation' import { getSession } from '@/lib/auth' import { getAccessibleCopilotChatAuth } from '@/lib/copilot/chat/lifecycle' +import { WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths' +import { getOrganizationSurfaceContext } from '@/lib/organizations/surface' import { OrganizationHome } from '@/app/o/[organizationId]/home/organization-home' export const metadata: Metadata = { title: 'Chat' } @@ -14,6 +16,9 @@ export default async function OrganizationChatPage({ const { organizationId, chatId } = await params const session = await getSession() if (!session?.user?.id) notFound() + const context = await getOrganizationSurfaceContext(organizationId, session.user.id) + if (!context) notFound() + if (!context.searchAccess.memberScoped) redirect(WORKSPACE_SETTINGS_PATH) const chat = await getAccessibleCopilotChatAuth(chatId, session.user.id, { principal: { kind: 'session', userId: session.user.id, sessionId: session.session.id }, }) diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/navigation.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/navigation.ts index 0263ab1986d..9b3b0a6a6c6 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/navigation.ts +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/navigation.ts @@ -21,9 +21,12 @@ const ORGANIZATION_NAV_ENTRIES: readonly OrganizationNavEntry[] = [ { id: 'integrations', label: 'Integrations', icon: Integration, route: 'integrations' }, ] -export function buildOrganizationNavItems(organizationId: string): SidebarNavItemData[] { +export function buildOrganizationNavItems( + organizationId: string, + searchAvailable: boolean +): SidebarNavItemData[] { const routes = organizationRoutes(organizationId) - return ORGANIZATION_NAV_ENTRIES.map(({ route, ...entry }) => ({ + return ORGANIZATION_NAV_ENTRIES.filter(() => searchAvailable).map(({ route, ...entry }) => ({ ...entry, href: routes[route], })) diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx index b84188bd745..8be529333e1 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx @@ -1,6 +1,6 @@ 'use client' -import { memo, useCallback, useRef, useState } from 'react' +import { type ComponentProps, memo, useCallback, useRef, useState } from 'react' import { Chip, cn, scrollFadeAttributes, scrollFadeClass, useScrollEdges } from '@sim/emcn' import { PanelLeft } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' @@ -51,6 +51,16 @@ const logger = createLogger('OrganizationSidebar') */ const DRAG_EXEMPT_CLASS = '[-webkit-app-region:no-drag]' +interface OrganizationChatsProps + extends Omit, 'chats' | 'isLoading'> { + organizationId: string +} + +function OrganizationChats({ organizationId, ...props }: OrganizationChatsProps) { + const { chats, isLoading } = useOrganizationChats(organizationId) + return +} + /** * The organization surface's rail: the same chrome as the workspace sidebar — * header row, pinned nav block, a divided scroll region of sections, and the @@ -68,7 +78,7 @@ export const OrganizationSidebar = memo(function OrganizationSidebar() { const pathname = usePathname() const posthog = usePostHog() - const { organization } = useOrganizationContext() + const { organization, searchAccess } = useOrganizationContext() const toggleCollapsed = useSidebarStore((state) => state.toggleCollapsed) const { handlePointerDown } = useSidebarResize() const showCollapsedTooltips = useCollapsedTooltips(isCollapsed) @@ -76,10 +86,9 @@ export const OrganizationSidebar = memo(function OrganizationSidebar() { contentRef: scrollContentRef, enabled: !isCollapsed, }) - const { chats, isLoading: chatsLoading } = useOrganizationChats(organization.id) const isMac = isMacPlatform() - const navItems = buildOrganizationNavItems(organization.id) + const navItems = buildOrganizationNavItems(organization.id, searchAccess.memberScoped) const settingsPath = organizationRoutes(organization.id).settings const isSettings = pathname === settingsPath || pathname?.startsWith(`${settingsPath}/`) @@ -276,15 +285,16 @@ export const OrganizationSidebar = memo(function OrganizationSidebar() { pathname={pathname} onContextMenu={handleHrefContextMenu} /> - + {searchAccess.memberScoped && ( + + )} diff --git a/apps/sim/app/o/[organizationId]/home/organization-home.test.tsx b/apps/sim/app/o/[organizationId]/home/organization-home.test.tsx index 07b6636b39d..68027c13cf6 100644 --- a/apps/sim/app/o/[organizationId]/home/organization-home.test.tsx +++ b/apps/sim/app/o/[organizationId]/home/organization-home.test.tsx @@ -41,7 +41,10 @@ let container: HTMLDivElement beforeEach(() => { vi.clearAllMocks() vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) - mocks.context.mockReturnValue({ organization: { id: 'organization-a' } }) + mocks.context.mockReturnValue({ + organization: { id: 'organization-a' }, + searchAccess: { memberScoped: true }, + }) mocks.chat.mockReturnValue({ messages: [], isChatHistoryPending: true, sendMessage: mocks.send }) mocks.composer.mockReturnValue(
Question composer
) mocks.renderer.mockReturnValue(
Chat history
) @@ -59,6 +62,19 @@ function composerProps(): ComponentProps { } describe('organization home', () => { + it.each([undefined, 'chat-a'])( + 'does not mount Home or chat %s when Search is disabled', + async (chatId) => { + mocks.context.mockReturnValue({ searchAccess: { memberScoped: false } }) + await act(async () => root.render()) + expect(container.textContent).toBe('') + expect(mocks.composer).not.toHaveBeenCalled() + expect(mocks.chat).not.toHaveBeenCalled() + expect(mocks.consume).not.toHaveBeenCalled() + expect(mocks.renderer).not.toHaveBeenCalled() + } + ) + it('greets the viewer over the composer and steps while the history query is pending', async () => { await act(async () => root.render()) expect(container.textContent).toContain('What should we get done, Ada?') diff --git a/apps/sim/app/o/[organizationId]/home/organization-home.tsx b/apps/sim/app/o/[organizationId]/home/organization-home.tsx index f00d075b1d1..aaee012dc96 100644 --- a/apps/sim/app/o/[organizationId]/home/organization-home.tsx +++ b/apps/sim/app/o/[organizationId]/home/organization-home.tsx @@ -15,13 +15,14 @@ interface OrganizationHomeProps { chatId?: string } -/** - * The organization home: a private Assistant conversation with the routed - * organization's sources. Empty, it is the greeting over the composer with the - * onboarding steps beneath; once a turn is sent, or a routed chat is opened, the - * transcript takes the surface and the same composer moves to its foot. - */ -export function OrganizationHome({ userName, chatId }: OrganizationHomeProps) { +/** Search and private Assistant chats for the routed organization. */ +export function OrganizationHome(props: OrganizationHomeProps) { + const { searchAccess } = useOrganizationContext() + if (!searchAccess.memberScoped) return null + return +} + +function OrganizationHomeContent({ userName, chatId }: OrganizationHomeProps) { const { organization } = useOrganizationContext() const { data: session } = useSession() const [draft, setDraft] = useState('') diff --git a/apps/sim/app/o/[organizationId]/home/page.test.tsx b/apps/sim/app/o/[organizationId]/home/page.test.tsx new file mode 100644 index 00000000000..dcb449256f8 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/home/page.test.tsx @@ -0,0 +1,97 @@ +/** @vitest-environment node */ +import { authMockFns } from '@sim/testing' +import { renderToStaticMarkup } from 'react-dom/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ context: vi.fn(), chat: vi.fn() })) + +vi.mock('next/navigation', () => ({ + redirect: (path: string) => { + throw new Error(`redirect:${path}`) + }, + notFound: () => { + throw new Error('not-found') + }, +})) +vi.mock('@/lib/organizations/surface', () => ({ getOrganizationSurfaceContext: mocks.context })) +vi.mock('@/lib/copilot/chat/lifecycle', () => ({ getAccessibleCopilotChatAuth: mocks.chat })) +vi.mock('@/app/o/[organizationId]/search/search', () => ({ + OrganizationSearch: () =>
Organization Search
, +})) +vi.mock('@/app/o/[organizationId]/integrations/integrations', () => ({ + OrganizationIntegrations: () =>
Integrations
, +})) +vi.mock('@/app/o/[organizationId]/home/organization-home', () => ({ + OrganizationHome: () =>
Organization Assistant
, +})) + +import OrganizationChatPage from '@/app/o/[organizationId]/chat/[chatId]/page' +import OrganizationHomePage from '@/app/o/[organizationId]/home/page' +import OrganizationIntegrationsPage from '@/app/o/[organizationId]/integrations/page' +import OrganizationPage from '@/app/o/[organizationId]/page' +import OrganizationSearchPage from '@/app/o/[organizationId]/search/page' + +const params = Promise.resolve({ organizationId: 'org-1', chatId: 'chat-1' }) +const session = { user: { id: 'viewer', name: 'Taylor' }, session: { id: 'session-1' } } + +describe('organization Search page gates', () => { + beforeEach(() => { + vi.clearAllMocks() + authMockFns.mockGetSession.mockResolvedValue(session) + mocks.context.mockResolvedValue({ searchAccess: { memberScoped: true } }) + mocks.chat.mockResolvedValue({ type: 'mothership', organizationId: 'org-1' }) + }) + + it.each([ + ['Home', () => OrganizationHomePage({ params })], + ['Search', () => OrganizationSearchPage({ params })], + ['chat', () => OrganizationChatPage({ params })], + ['organization entry', () => OrganizationPage({ params })], + ] as const)('redirects %s to workspace settings when Search is disabled', async (_name, open) => { + mocks.context.mockResolvedValue({ searchAccess: { memberScoped: false, sourceMirrored: true } }) + await expect(open()).rejects.toThrow('redirect:/workspace?redirect=settings') + expect(mocks.context).toHaveBeenCalledWith('org-1', 'viewer') + expect(mocks.chat).not.toHaveBeenCalled() + }) + + it.each([ + ['Home', () => OrganizationHomePage({ params })], + ['Search', () => OrganizationSearchPage({ params })], + ['chat', () => OrganizationChatPage({ params })], + ] as const)('denies %s to nonmembers before loading content', async (_name, open) => { + mocks.context.mockResolvedValue(null) + await expect(open()).rejects.toThrow('not-found') + expect(mocks.chat).not.toHaveBeenCalled() + }) + + it('hides Integrations when Search is disabled', async () => { + mocks.context.mockResolvedValue({ searchAccess: { memberScoped: false } }) + await expect(OrganizationIntegrationsPage({ params })).rejects.toThrow('not-found') + }) + + it('renders Home when the organization gate is enabled', async () => { + expect(renderToStaticMarkup(await OrganizationHomePage({ params }))).toContain( + 'Organization Assistant' + ) + }) + + it('retains chat authorization and asserted organization checks when enabled', async () => { + expect(renderToStaticMarkup(await OrganizationChatPage({ params }))).toContain( + 'Organization Assistant' + ) + expect(mocks.chat).toHaveBeenCalledWith('chat-1', 'viewer', { + principal: { kind: 'session', userId: 'viewer', sessionId: 'session-1' }, + }) + mocks.chat.mockResolvedValue({ type: 'mothership', organizationId: 'another-org' }) + await expect(OrganizationChatPage({ params })).rejects.toThrow('not-found') + }) + + it('lands enabled organizations on Home', async () => { + await expect(OrganizationPage({ params })).rejects.toThrow('redirect:/o/org-1/home') + }) + + it('propagates availability failures instead of rendering the Assistant', async () => { + mocks.context.mockRejectedValue(new Error('Availability unavailable')) + await expect(OrganizationHomePage({ params })).rejects.toThrow('Availability unavailable') + }) +}) diff --git a/apps/sim/app/o/[organizationId]/home/page.tsx b/apps/sim/app/o/[organizationId]/home/page.tsx index 0468e4e12d7..addddff1e9a 100644 --- a/apps/sim/app/o/[organizationId]/home/page.tsx +++ b/apps/sim/app/o/[organizationId]/home/page.tsx @@ -1,13 +1,25 @@ import type { Metadata } from 'next' +import { notFound, redirect } from 'next/navigation' import { getSession } from '@/lib/auth' +import { WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths' +import { getOrganizationSurfaceContext } from '@/lib/organizations/surface' import { OrganizationHome } from '@/app/o/[organizationId]/home/organization-home' export const metadata: Metadata = { title: 'Home', } -export default async function OrganizationHomePage() { +export default async function OrganizationHomePage({ + params, +}: { + params: Promise<{ organizationId: string }> +}) { + const { organizationId } = await params const session = await getSession() + if (!session?.user?.id) notFound() + const context = await getOrganizationSurfaceContext(organizationId, session.user.id) + if (!context) notFound() + if (!context.searchAccess.memberScoped) redirect(WORKSPACE_SETTINGS_PATH) - return + return } diff --git a/apps/sim/app/o/[organizationId]/integrations/page.tsx b/apps/sim/app/o/[organizationId]/integrations/page.tsx index 3db4edf7741..b3075409e06 100644 --- a/apps/sim/app/o/[organizationId]/integrations/page.tsx +++ b/apps/sim/app/o/[organizationId]/integrations/page.tsx @@ -1,10 +1,30 @@ import type { Metadata } from 'next' +import { notFound, redirect } from 'next/navigation' +import { getSession } from '@/lib/auth' +import { organizationRoutes } from '@/lib/navigation/paths' +import { getOrganizationSurfaceContext } from '@/lib/organizations/surface' +import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect' import { OrganizationIntegrations } from '@/app/o/[organizationId]/integrations/integrations' export const metadata: Metadata = { title: 'Integrations', } -export default function OrganizationIntegrationsPage() { +export default async function OrganizationIntegrationsPage({ + params, +}: { + params: Promise<{ organizationId: string }> +}) { + const { organizationId } = await params + const session = await getSession() + if (!session?.user) + redirect( + buildAuthCrossLink('/login', { + callbackUrl: organizationRoutes(organizationId).integrations, + isInviteFlow: false, + }) + ) + const context = await getOrganizationSurfaceContext(organizationId, session.user.id) + if (!context?.searchAccess.memberScoped) notFound() return } diff --git a/apps/sim/app/o/[organizationId]/page.tsx b/apps/sim/app/o/[organizationId]/page.tsx index ad93fa4fcc2..5ea08a88e4e 100644 --- a/apps/sim/app/o/[organizationId]/page.tsx +++ b/apps/sim/app/o/[organizationId]/page.tsx @@ -1,5 +1,7 @@ -import { redirect } from 'next/navigation' -import { organizationRoutes } from '@/lib/navigation/paths' +import { notFound, redirect } from 'next/navigation' +import { getSession } from '@/lib/auth' +import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths' +import { getOrganizationSurfaceContext } from '@/lib/organizations/surface' export default async function OrganizationPage({ params, @@ -7,5 +9,10 @@ export default async function OrganizationPage({ params: Promise<{ organizationId: string }> }) { const { organizationId } = await params - redirect(organizationRoutes(organizationId).home) + const session = await getSession() + if (!session?.user?.id) notFound() + const context = await getOrganizationSurfaceContext(organizationId, session.user.id) + if (!context) notFound() + const routes = organizationRoutes(organizationId) + redirect(context.searchAccess.memberScoped ? routes.home : WORKSPACE_SETTINGS_PATH) } diff --git a/apps/sim/app/o/[organizationId]/search/page.tsx b/apps/sim/app/o/[organizationId]/search/page.tsx index fdce0f0caa7..74ae2f0234b 100644 --- a/apps/sim/app/o/[organizationId]/search/page.tsx +++ b/apps/sim/app/o/[organizationId]/search/page.tsx @@ -1,10 +1,22 @@ import type { Metadata } from 'next' +import { notFound, redirect } from 'next/navigation' +import { getSession } from '@/lib/auth' +import { WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths' +import { getOrganizationSurfaceContext } from '@/lib/organizations/surface' import { OrganizationSearch } from '@/app/o/[organizationId]/search/search' -export const metadata: Metadata = { - title: 'Search', -} +export const metadata: Metadata = { title: 'Search' } -export default function OrganizationSearchPage() { +export default async function OrganizationSearchPage({ + params, +}: { + params: Promise<{ organizationId: string }> +}) { + const { organizationId } = await params + const session = await getSession() + if (!session?.user?.id) notFound() + const context = await getOrganizationSurfaceContext(organizationId, session.user.id) + if (!context) notFound() + if (!context.searchAccess.memberScoped) redirect(WORKSPACE_SETTINGS_PATH) return } diff --git a/apps/sim/app/o/[organizationId]/search/search.tsx b/apps/sim/app/o/[organizationId]/search/search.tsx index 34cb262b914..63cc1a91d04 100644 --- a/apps/sim/app/o/[organizationId]/search/search.tsx +++ b/apps/sim/app/o/[organizationId]/search/search.tsx @@ -108,6 +108,12 @@ function SearchField({ * Summarizing a document hands the turn to the Assistant on Home. */ export function OrganizationSearch() { + const { searchAccess } = useOrganizationContext() + if (!searchAccess.memberScoped) return null + return +} + +function OrganizationSearchContent() { const { organization } = useOrganizationContext() const router = useRouter() const [{ q }, setParams] = useQueryStates(organizationSearchParsers, organizationSearchUrlKeys) diff --git a/apps/sim/app/o/[organizationId]/settings/[section]/settings.tsx b/apps/sim/app/o/[organizationId]/settings/[section]/settings.tsx index 2e6283a5b37..28c56368897 100644 --- a/apps/sim/app/o/[organizationId]/settings/[section]/settings.tsx +++ b/apps/sim/app/o/[organizationId]/settings/[section]/settings.tsx @@ -8,8 +8,9 @@ import { } from '@/components/settings/navigation' import { SettingsSectionProvider } from '@/components/settings/settings-panel' import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' -import { OrganizationIntegrationsSetup } from '@/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup' +import { OrganizationIntegrationsSettings } from '@/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings' import { OrganizationSearchMcp } from '@/app/o/[organizationId]/settings/components/organization-search-mcp' +import { OrganizationConnectedAccounts } from '@/ee/credential-groups/components/organization-connected-accounts' const TeamManagement = dynamic(() => import('@/app/workspace/[workspaceId]/settings/components/team-management/team-management').then( @@ -59,7 +60,10 @@ export function OrganizationSettings({ section }: OrganizationSettingsProps) { return ( - {section === 'integrations' && } + {section === 'integrations' && } + {section === 'connected-accounts' && ( + + )} {section === 'search-mcp' && } {section === 'members' && ( ({ + context: vi.fn(), + accounts: vi.fn(), + people: vi.fn(), + invite: vi.fn(), + refetch: vi.fn(), +})) +vi.mock('@/app/o/[organizationId]/providers/organization-provider', () => ({ + useOrganizationContext: mocks.context, +})) +vi.mock( + '@/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup', + () => ({ OrganizationIntegrationsSetup: () =>
Provider setup
}) +) +vi.mock('@/hooks/queries/organization-accounts', () => ({ + useOrganizationAccounts: mocks.accounts, + useOrganizationAccountPeople: mocks.people, + useInviteOrganizationAccountPeople: () => ({ mutateAsync: mocks.invite, reset: vi.fn() }), + useResendOrganizationAccountInvitation: () => ({}), + useRevokeOrganizationAccountEnrollment: () => ({}), +})) + +import { OrganizationIntegrationsSettings } from '@/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings' + +describe('organization integration invitations', () => { + let root: Root + let container: HTMLDivElement + + beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(toast, 'success').mockReturnValue('toast-id') + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + mocks.context.mockReturnValue({ organization: { id: 'org-a' }, viewer: { isAdmin: true } }) + mocks.accounts.mockReturnValue({ + data: { credentialGroup: { id: 'group-a' } }, + error: null, + refetch: mocks.refetch, + }) + mocks.people.mockReturnValue({ data: { pages: [{ enrollments: [] }] } }) + mocks.invite.mockResolvedValue({ + sentCount: 2, + results: [ + { email: 'one@example.com', success: true }, + { email: 'two@example.com', success: true }, + ], + }) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + async function render(searchParams = '') { + await act(async () => + root.render( + + + + ) + ) + } + + async function click(label: string) { + const button = Array.from(document.querySelectorAll('button')).find( + (element) => element.textContent === label + ) + if (!button) throw new Error(`Missing ${label} button`) + await act(async () => button.click()) + } + + it('keeps provider setup as the default and sends manual invitations from People to this org', async () => { + await render() + expect(container.textContent).toContain('Provider setup') + expect(mocks.accounts).toHaveBeenLastCalledWith(undefined) + expect(mocks.people).not.toHaveBeenCalled() + + await click('People') + expect(container.textContent).not.toContain('Provider setup') + expect(mocks.accounts).toHaveBeenLastCalledWith('org-a') + expect(mocks.people).toHaveBeenLastCalledWith('org-a') + expect(container.querySelector('[aria-label="Search people"]')).not.toBeNull() + expect(mocks.invite).not.toHaveBeenCalled() + + await click('Request connections') + const input = document.querySelector('input[placeholder="Enter emails"]') + if (!input) throw new Error('Missing invitation email input') + const paste = new Event('paste', { bubbles: true, cancelable: true }) + Object.defineProperty(paste, 'clipboardData', { + value: { getData: () => 'one@example.com two@example.com' }, + }) + await act(async () => input.dispatchEvent(paste)) + await click('Send requests') + expect(mocks.invite).toHaveBeenCalledExactlyOnceWith({ + organizationId: 'org-a', + emails: ['one@example.com', 'two@example.com'], + }) + expect(document.querySelector('[role="dialog"]')).toBeNull() + }) + + it('opens People directly from the saved URL', async () => { + await render('?tab=people') + expect(container.textContent).toContain('Request connections') + expect(container.textContent).not.toContain('Provider setup') + expect(mocks.people).toHaveBeenLastCalledWith('org-a') + }) + + it('sends an org without a credential group back to provider setup before invitations', async () => { + mocks.accounts.mockReturnValue({ data: { credentialGroup: null }, error: null }) + await render('?tab=people') + expect(container.textContent).toContain('before inviting people') + expect(mocks.people).not.toHaveBeenCalled() + expect(container.textContent).not.toContain('Request connections') + await click('Set up providers') + expect(container.textContent).toContain('Provider setup') + expect(mocks.invite).not.toHaveBeenCalled() + }) + + it('surfaces account lookup errors instead of treating them as missing setup', async () => { + mocks.accounts.mockReturnValue({ + error: new Error('Account access denied'), + refetch: mocks.refetch, + }) + await render('?tab=people') + expect(container.textContent).toContain('Account access denied') + expect(container.textContent).not.toContain('Set up providers') + expect(mocks.people).not.toHaveBeenCalled() + await click('Try again') + expect(mocks.refetch).toHaveBeenCalledOnce() + }) + + it('does not load admin account data or expose invitations to an ordinary member', async () => { + mocks.context.mockReturnValue({ organization: { id: 'org-a' }, viewer: { isAdmin: false } }) + await render('?tab=people') + expect(container.textContent).toBe('') + expect(mocks.accounts).toHaveBeenLastCalledWith(undefined) + expect(mocks.people).not.toHaveBeenCalled() + expect(mocks.invite).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.tsx new file mode 100644 index 00000000000..ea16315bafe --- /dev/null +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.tsx @@ -0,0 +1,63 @@ +'use client' + +import { Chip, ChipSwitch } from '@sim/emcn' +import { useQueryState } from 'nuqs' +import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' +import { OrganizationIntegrationsSetup } from '@/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup' +import { organizationIntegrationsTabParam } from '@/app/o/[organizationId]/settings/components/integrations/search-params' +import { + SettingsEmptyState, + SettingsQueryErrorState, +} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { OrganizationAccountPeople } from '@/ee/credential-groups/components/organization-account-people' +import { useOrganizationAccounts } from '@/hooks/queries/organization-accounts' + +export function OrganizationIntegrationsSettings() { + const { organization, viewer } = useOrganizationContext() + const [tab, setTab] = useQueryState( + organizationIntegrationsTabParam.key, + organizationIntegrationsTabParam.parser + ) + const accounts = useOrganizationAccounts( + viewer.isAdmin && tab === 'people' ? organization.id : undefined + ) + if (!viewer.isAdmin) return null + + return ( +
+
+ void setTab(value)} + options={[ + { value: 'providers', label: 'Providers' }, + { value: 'people', label: 'People' }, + ]} + /> +
+ {tab === 'providers' && } + {tab === 'people' && + (accounts.error ? ( + void accounts.refetch()} + variant='inline' + /> + ) : !accounts.data ? ( + Loading connected accounts… + ) : !accounts.data.credentialGroup ? ( +
+ + Set up a provider for personal account connections before inviting people. + + void setTab('providers')}>Set up providers +
+ ) : ( + + ))} +
+ ) +} diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/search-params.ts b/apps/sim/app/o/[organizationId]/settings/components/integrations/search-params.ts index 3399bce228b..f0eda4192ce 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/search-params.ts +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/search-params.ts @@ -1,5 +1,10 @@ import { parseAsStringLiteral } from 'nuqs/server' +export const organizationIntegrationsTabParam = { + key: 'tab', + parser: parseAsStringLiteral(['providers', 'people']).withDefault('providers'), +} as const + export const connectedAccountsParam = { key: 'connectedAccounts', parser: parseAsStringLiteral(['slack']), diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/slack-account-setup.test.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/slack-account-setup.test.tsx index f9d192b2f70..1cc0c9b77b9 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/slack-account-setup.test.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/slack-account-setup.test.tsx @@ -8,7 +8,6 @@ const mocks = vi.hoisted(() => ({ accounts: vi.fn(), ensure: vi.fn(), prepare: vi.fn(), - credentials: vi.fn(), modal: vi.fn(), setProvider: vi.fn(), setReturnSource: vi.fn(), @@ -29,7 +28,6 @@ vi.mock('@/hooks/queries/organization-accounts', () => ({ useOrganizationAccounts: mocks.accounts, useEnsureOrganizationAccounts: mocks.prepare, })) -vi.mock('@/hooks/queries/scoped-credentials', () => ({ useScopedCredentials: mocks.credentials })) vi.mock('@/ee/credential-groups/components/slack-managed-users-modal', () => ({ SlackManagedUsersModal: mocks.modal, })) @@ -55,7 +53,6 @@ describe('organization Slack setup continuation', () => { isPending: false, error: null, }) - mocks.credentials.mockReturnValue({ data: [], isPending: false, error: null }) mocks.modal.mockReturnValue(null) container = document.createElement('div') document.body.appendChild(container) @@ -77,12 +74,6 @@ describe('organization Slack setup continuation', () => { expect(mocks.ensure).toHaveBeenCalledExactlyOnceWith({ organizationId: 'org-a' }) expect(document.body.textContent).toContain('Loading Slack setup') expect(document.body.textContent).not.toContain('Continue') - expect(mocks.credentials).toHaveBeenCalledWith({ - organizationId: 'org-a', - type: 'service_account', - providerId: 'slack-custom-bot', - enabled: true, - }) }) it('does not prepare accounts or open admin setup for an ordinary member', async () => { @@ -105,6 +96,7 @@ describe('organization Slack setup continuation', () => { const props = mocks.modal.mock.calls[0][0] expect(props).toMatchObject({ organizationId: 'org-a', credentialGroupId: 'group-a' }) expect(props).not.toHaveProperty('workspaceId') + expect(props.bots).toEqual([]) props.onOpenChange(false) expect(mocks.setProvider).toHaveBeenCalledExactlyOnceWith(null) expect(mocks.setReturnSource).toHaveBeenCalledExactlyOnceWith(null, { history: 'replace' }) diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/slack-account-setup.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/slack-account-setup.tsx index 5f203f644f2..9184cb4b6ef 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/slack-account-setup.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/slack-account-setup.tsx @@ -24,7 +24,6 @@ import { useEnsureOrganizationAccounts, useOrganizationAccounts, } from '@/hooks/queries/organization-accounts' -import { useScopedCredentials } from '@/hooks/queries/scoped-credentials' /** Slack's provider verification returns to the source form that started setup. */ export function OrganizationSlackAccountSetup() { @@ -47,12 +46,6 @@ export function OrganizationSlackAccountSetup() { isIdle: setupIdle, isPending: setupPending, } = useEnsureOrganizationAccounts() - const bots = useScopedCredentials({ - organizationId: organization.id, - type: 'service_account', - providerId: 'slack-custom-bot', - enabled: open, - }) const prepared = preparedAccounts?.credentialGroup const group = accounts.data?.credentialGroup ?? @@ -75,9 +68,9 @@ export function OrganizationSlackAccountSetup() { open organizationId={organization.id} credentialGroupId={group.id} - bots={bots.data ?? []} - isLoading={bots.isPending} - error={bots.error} + bots={[]} + isLoading={false} + error={null} initialCredentialId={ group.options.find((option) => option.provider === 'slack')?.slackBotCredentialId ?? undefined diff --git a/apps/sim/app/o/[organizationId]/settings/navigation.test.ts b/apps/sim/app/o/[organizationId]/settings/navigation.test.ts index 4f6d275ea2c..1f347662b4a 100644 --- a/apps/sim/app/o/[organizationId]/settings/navigation.test.ts +++ b/apps/sim/app/o/[organizationId]/settings/navigation.test.ts @@ -22,34 +22,43 @@ const enterprise: OrganizationSettingsFeatures = { selfHosted: {}, } +const available = { connectedAccounts: true, search: true } + describe('organization settings navigation', () => { it('exposes MCP setup and the read-only roster to an ordinary organization member', () => { - expect(organizationSettingsNavigation(false, enterprise).map(({ id }) => id)).toEqual([ - 'members', - 'search-mcp', - ]) + expect( + organizationSettingsNavigation(false, enterprise, available).map(({ id }) => id) + ).toEqual(['members', 'search-mcp']) }) - it('offers every org settings section to an entitled organization administrator', () => { - expect(organizationSettingsNavigation(true, enterprise)).toEqual(ORGANIZATION_SETTINGS_ITEMS) + it('uses Integrations instead of Connected accounts when Search is available', () => { + expect(organizationSettingsNavigation(true, enterprise, available)).toEqual( + ORGANIZATION_SETTINGS_ITEMS.filter(({ id }) => id !== 'connected-accounts') + ) }) it('keeps members and billing reachable without an enterprise plan', () => { expect( - organizationSettingsNavigation(true, { ...enterprise, hasEnterprisePlan: false }).map( - ({ id }) => id - ) + organizationSettingsNavigation( + true, + { ...enterprise, hasEnterprisePlan: false }, + available + ).map(({ id }) => id) ).toEqual(['billing', 'members', 'search-mcp']) }) it('honors individual self-hosted feature flags and hides billing when disabled', () => { expect( - organizationSettingsNavigation(true, { - ...enterprise, - hosted: false, - billingEnabled: false, - selfHosted: { sso: true }, - }).map(({ id }) => id) + organizationSettingsNavigation( + true, + { + ...enterprise, + hosted: false, + billingEnabled: false, + selfHosted: { sso: true }, + }, + available + ).map(({ id }) => id) ).toEqual(['members', 'sso', 'integrations', 'search-mcp']) }) @@ -60,7 +69,7 @@ describe('organization settings navigation', () => { expect(resolveOrganizationSettingsSection('subscription')).toBe('billing') expect(resolveOrganizationSettingsSection('domains')).toBe('sso') expect(resolveOrganizationSettingsSection('skills')).toBeNull() - expect(buildOrganizationNavItems('org').map(({ id }) => id)).toEqual([ + expect(buildOrganizationNavItems('org', true).map(({ id }) => id)).toEqual([ 'home', 'search', 'integrations', @@ -71,6 +80,7 @@ describe('organization settings navigation', () => { expect(ORGANIZATION_SETTINGS_ITEMS.map(({ id, group }) => `${group}:${id}`)).toEqual([ 'account:billing', 'organization:members', + 'organization:connected-accounts', 'organization:usage', 'organization:whitelabeling', 'governance:audit-logs', @@ -85,11 +95,9 @@ describe('organization settings navigation', () => { }) it('hosts the account General section ahead of the organization sections', () => { - expect(organizationSurfaceSettingsNavigation(false, enterprise).map(({ id }) => id)).toEqual([ - 'general', - 'members', - 'search-mcp', - ]) + expect( + organizationSurfaceSettingsNavigation(false, enterprise, available).map(({ id }) => id) + ).toEqual(['general', 'members', 'search-mcp']) expect(ORGANIZATION_SETTINGS_GROUPS.map(({ key }) => key)).toEqual([ 'account', 'organization', @@ -110,4 +118,33 @@ describe('organization settings navigation', () => { }) expect(resolveOrganizationSurfaceSection('skills')).toBeNull() }) + it('hides gated sections while preserving ordinary organization navigation', () => { + const sections = organizationSurfaceSettingsNavigation(true, enterprise, { + connectedAccounts: false, + search: false, + }).map(({ id }) => id) + expect(sections).not.toContain('connected-accounts') + expect(sections).not.toContain('search-mcp') + expect(sections).not.toContain('integrations') + expect(sections).toContain('members') + expect(sections).toContain('general') + expect(buildOrganizationNavItems('org', false)).toEqual([]) + }) + it('exposes Connected accounts before Search is enabled', () => { + const sections = organizationSettingsNavigation(true, enterprise, { + connectedAccounts: true, + search: false, + }).map(({ id }) => id) + expect(sections).toContain('connected-accounts') + expect(sections).not.toContain('search-mcp') + expect(sections).not.toContain('integrations') + }) + it('keeps both setup pages hidden from non-admins when Search is disabled', () => { + const sections = organizationSettingsNavigation(false, enterprise, { + connectedAccounts: true, + search: false, + }).map(({ id }) => id) + expect(sections).not.toContain('connected-accounts') + expect(sections).not.toContain('integrations') + }) }) diff --git a/apps/sim/app/o/[organizationId]/settings/navigation.ts b/apps/sim/app/o/[organizationId]/settings/navigation.ts index 330057e1204..254e03d0546 100644 --- a/apps/sim/app/o/[organizationId]/settings/navigation.ts +++ b/apps/sim/app/o/[organizationId]/settings/navigation.ts @@ -67,15 +67,20 @@ export function resolveOrganizationSurfaceSection( export function organizationSettingsNavigation( isAdmin: boolean, - features: OrganizationSettingsFeatures + features: OrganizationSettingsFeatures, + availability: { connectedAccounts: boolean; search: boolean } ) { return ORGANIZATION_SETTINGS_ITEMS.filter( (item) => + (item.id !== 'connected-accounts' || + (availability.connectedAccounts && !availability.search)) && + ((item.id !== 'search-mcp' && item.id !== 'integrations') || availability.search) && resolveOrganizationSectionAccess({ section: item.id, isTargetOrganizationMember: true, isTargetOrganizationAdmin: isAdmin, - }) !== 'unavailable' && isOrganizationSettingsSectionAvailable(item.id, features) + }) !== 'unavailable' && + isOrganizationSettingsSectionAvailable(item.id, features) ) } @@ -87,10 +92,11 @@ export function organizationSettingsNavigation( */ export function organizationSurfaceSettingsNavigation( isAdmin: boolean, - features: OrganizationSettingsFeatures + features: OrganizationSettingsFeatures, + availability: { connectedAccounts: boolean; search: boolean } ): SettingsNavigationItem[] { return [ ...ORGANIZATION_SURFACE_ACCOUNT_ITEMS, - ...organizationSettingsNavigation(isAdmin, features), + ...organizationSettingsNavigation(isAdmin, features, availability), ] } diff --git a/apps/sim/app/o/[organizationId]/settings/organization-settings-sidebar.tsx b/apps/sim/app/o/[organizationId]/settings/organization-settings-sidebar.tsx index f60a2a609d0..cf23fa19c7f 100644 --- a/apps/sim/app/o/[organizationId]/settings/organization-settings-sidebar.tsx +++ b/apps/sim/app/o/[organizationId]/settings/organization-settings-sidebar.tsx @@ -8,7 +8,7 @@ import { import { SettingsSidebar } from '@/components/settings/settings-sidebar' import { isEnterprise } from '@/lib/billing/plan-helpers' import { useDeploymentShape } from '@/lib/core/config/deployment-shape' -import { organizationRoutes } from '@/lib/navigation/paths' +import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths' import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' import { ORGANIZATION_SETTINGS_OUTBOUND_LINKS, @@ -23,7 +23,8 @@ interface OrganizationSettingsSidebarProps { } export function OrganizationSettingsSidebar(props: OrganizationSettingsSidebarProps) { - const { organization, viewer } = useOrganizationContext() + const { organization, viewer, connectedAccountsAvailable, searchAccess } = + useOrganizationContext() const pathname = usePathname() const deployment = useDeploymentShape() const { data: billing } = useOrganizationBilling(organization.id, { @@ -42,10 +43,13 @@ export function OrganizationSettingsSidebar(props: OrganizationSettingsSidebarPr plane='organization' activeSection={resolveOrganizationSurfaceSection(pathname ?? '')?.section ?? 'general'} groups={ORGANIZATION_SETTINGS_GROUPS} - items={organizationSurfaceSettingsNavigation(viewer.isAdmin, features)} - outboundLinks={ORGANIZATION_SETTINGS_OUTBOUND_LINKS} + items={organizationSurfaceSettingsNavigation(viewer.isAdmin, features, { + connectedAccounts: connectedAccountsAvailable, + search: searchAccess.memberScoped, + })} + outboundLinks={searchAccess.memberScoped ? ORGANIZATION_SETTINGS_OUTBOUND_LINKS : []} hrefForSection={(section) => routes.settingsSection(section)} - backHref={routes.home} + backHref={searchAccess.memberScoped ? routes.home : WORKSPACE_SETTINGS_PATH} /> ) } diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.test.tsx index da7048562a3..3eadd3aeb1f 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.test.tsx @@ -219,7 +219,7 @@ describe('Slack member setup readiness', () => { (link) => link.textContent?.trim() === 'Set up Slack' ) expect(setup?.getAttribute('href')).toBe( - '/o/org-1/integrations?search-setup=slack&connectedAccounts=slack' + '/o/org-1/settings/integrations?search-setup=slack&connectedAccounts=slack' ) expect(document.body.textContent).not.toContain('Create & Invite') }) @@ -286,6 +286,25 @@ describe('Slack member setup readiness', () => { }) describe('Search methods requiring member identity', () => { + it('keeps organization connected-account setup in members mode', async () => { + await render({ + scope: { kind: 'organization', organizationId: 'org-1' }, + initialConnectorType: 'confluence', + initialAccessMode: 'admin', + membersOnly: true, + }) + expect(document.body.textContent).not.toContain('Connection method') + expect(document.body.textContent).not.toContain('Choose another source') + await act(async () => button('Add source').click()) + expect(mocks.create).toHaveBeenCalledWith( + expect.objectContaining({ + connectorType: 'confluence', + accessMode: 'members', + }), + expect.any(Object) + ) + }) + it('blocks a new Confluence admin connection when member identity is unavailable', async () => { mocks.memberAccess = false await render({ initialConnectorType: 'confluence', initialAccessMode: 'admin' }) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx index 625c299b9f9..3647fff9111 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx @@ -82,6 +82,7 @@ interface AddConnectorModalProps { isSearchIndex?: boolean initialConnectorType?: string | null initialAccessMode?: ConnectorAccessSelection['accessMode'] + membersOnly?: boolean initialSyncIntervalMinutes?: number onCreated?: (connectorType: string) => void setupDraftKey?: string @@ -97,6 +98,7 @@ export function AddConnectorModal({ isSearchIndex = false, initialConnectorType, initialAccessMode = 'workspace', + membersOnly = false, initialSyncIntervalMinutes = 1440, onCreated, setupDraftKey, @@ -127,7 +129,7 @@ export function AddConnectorModal({ ) const [access, setAccess] = useState(() => ({ accessMode: - draft?.accessMode ?? + (membersOnly ? 'members' : draft?.accessMode) ?? (isSearchIndex && initialAccessMode === 'workspace' ? initialType && CONNECTOR_META_REGISTRY[initialType]?.auth.mode === 'apiKey' ? 'admin' @@ -449,15 +451,17 @@ export function AddConnectorModal({ closeSetup(false)}> {step === 'configure' ? ( - { - if (setupDraftKey) useConnectorSetupStore.getState().clearDraft(setupDraftKey) - setStep('select-type') - onConnectorTypeChange?.('') - }} - /> + {!membersOnly && ( + { + if (setupDraftKey) useConnectorSetupStore.getState().clearDraft(setupDraftKey) + setStep('select-type') + onConnectorTypeChange?.('') + }} + /> + )} {`Configure ${connectorConfig?.name}`} ) : ( @@ -515,23 +519,24 @@ export function AddConnectorModal({ /> )} - {(memberAccessAvailable || mirroredAccessAvailable || slackSetupRequired) && ( - - )} + {!membersOnly && + (memberAccessAvailable || mirroredAccessAvailable || slackSetupRequired) && ( + + )} {!slackSetupRequired && ( <> diff --git a/apps/sim/app/workspace/[workspaceId]/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/prefetch.ts index 315a8cf5fd3..95a2735c120 100644 --- a/apps/sim/app/workspace/[workspaceId]/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/prefetch.ts @@ -10,7 +10,6 @@ import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' import { listWorkspacesForViewer } from '@/lib/workspaces/list' import { getWorkspacePermissionsForAuthorizedViewer } from '@/lib/workspaces/permissions/utils' import { prefetchResourceFolders } from '@/app/workspace/[workspaceId]/lib/prefetch-resource-folders' -import {} from '@/hooks/queries/current-user-data' import { MOTHERSHIP_CHAT_LIST_STALE_TIME, mapChat, diff --git a/apps/sim/app/workspace/[workspaceId]/search/components/search-source-setup.test.tsx b/apps/sim/app/workspace/[workspaceId]/search/components/search-source-setup.test.tsx index f98833556ec..06c1511820b 100644 --- a/apps/sim/app/workspace/[workspaceId]/search/components/search-source-setup.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/search/components/search-source-setup.test.tsx @@ -520,6 +520,25 @@ describe('Search source setup with real connector dialogs', () => { expect(mocks.connectorsQuery).toHaveBeenLastCalledWith(undefined) }) + it('prepares organization connected-account indexing in members mode even when central access is available', async () => { + mocks.bases = [] + await render( + , + '?addConnector=slack' + ) + await click(button('Continue setup')) + expect(mocks.prepare).toHaveBeenCalledWith( + { organizationId: 'org-1', connectorType: 'slack', accessMode: 'members' }, + expect.any(Object) + ) + }) + it('does not reuse mutation data after the current index has been removed', async () => { mocks.prepareData = { knowledgeBaseId: 'kb-search' } mocks.bases = [] @@ -1160,8 +1179,9 @@ describe('administrator source prerequisites in real connector dialogs', () => { initialAccessMode={accessMode} /> ) - expect(document.body.textContent).toContain('Crawl as') - expect(document.body.textContent).not.toContain('Crawl as*') + const subjectLabel = accessMode === 'members' ? 'Sync documents with' : 'Crawl as' + expect(document.body.textContent).toContain(subjectLabel) + expect(document.body.textContent).not.toContain(`${subjectLabel}*`) const submit = button(accessMode === 'members' ? 'Create & Invite' : 'Connect & Sync') expect(submit).toBeEnabled() await click(submit) diff --git a/apps/sim/app/workspace/[workspaceId]/search/components/search-source-setup.tsx b/apps/sim/app/workspace/[workspaceId]/search/components/search-source-setup.tsx index 18c1b38db13..d8f4d4926f3 100644 --- a/apps/sim/app/workspace/[workspaceId]/search/components/search-source-setup.tsx +++ b/apps/sim/app/workspace/[workspaceId]/search/components/search-source-setup.tsx @@ -63,6 +63,7 @@ interface SearchSourceSetupProps { canAdmin: boolean memberAccessAvailable: boolean mirroredAccessAvailable: boolean + membersOnly?: boolean } /** Owns admin setup and existing source management, including bookmarked OAuth return URLs. */ @@ -72,6 +73,7 @@ export function SearchSourceSetup({ canAdmin, memberAccessAvailable, mirroredAccessAvailable, + membersOnly = false, }: SearchSourceSetupProps) { const scope = explicitScope ?? resourceScopeFromOwner({ workspaceId }) const { data: session } = useSession() @@ -120,6 +122,7 @@ export function SearchSourceSetup({ managedConnectors[0]?.connectorType ?? (managedSource && CONNECTOR_META_REGISTRY[managedSource] ? managedSource : undefined) const initialMode = (type: string) => { + if (membersOnly) return 'members' as const const meta = CONNECTOR_META_REGISTRY[type] if ( meta && @@ -153,6 +156,7 @@ export function SearchSourceSetup({ isSearchIndex initialConnectorType={selectedType} initialAccessMode={initialMode(selectedType)} + membersOnly={membersOnly} setupDraftKey={`${session.user.id}:${resourceScopeKey(scope)}:${knowledgeBaseId}:${selectedType}`} onConnectorTypeChange={(type) => void setSelectedType(type !== null ? searchSetupParam.parser.parse(type) : null) @@ -249,7 +253,7 @@ export function SearchSourceSetup({ isIntegrationAvailabilityReady, } ) - const available = central || members + const available = membersOnly ? members : central || members return ( diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.test.ts b/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.test.ts index 9d6ef47d288..397ffde3dde 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.test.ts @@ -4,9 +4,8 @@ import { QueryClient } from '@tanstack/react-query' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetCurrentUserSettings, mockExecute, mockAuthenticate } = vi.hoisted(() => ({ +const { mockGetCurrentUserSettings, mockAuthenticate } = vi.hoisted(() => ({ mockGetCurrentUserSettings: vi.fn(), - mockExecute: vi.fn(), mockAuthenticate: vi.fn(), })) @@ -14,10 +13,6 @@ vi.mock('@/lib/users/application/read-current-user', () => ({ getCurrentUserSettingsUseCase: { execute: mockGetCurrentUserSettings }, })) -vi.mock('@/lib/credential-groups/application/manage-groups', () => ({ - getWorkspaceAccountsSettings: { execute: mockExecute }, -})) - vi.mock('@/lib/api/server/routes/internal-json-route', () => ({ internalSessionAuth: { authenticate: mockAuthenticate }, })) @@ -27,7 +22,6 @@ vi.mock('@/lib/api/server/routes', () => ({ import { SECTION_PREFETCHERS } from '@/app/workspace/[workspaceId]/settings/[section]/prefetch' import { generalSettingsKeys } from '@/hooks/queries/current-user-data' -import { credentialGroupKeys } from '@/hooks/queries/utils/credential-group-queries' describe('general settings prefetch', () => { beforeEach(() => { @@ -64,65 +58,3 @@ describe('general settings prefetch', () => { }) }) }) - -describe('credential-groups prefetch', () => { - beforeEach(() => { - vi.clearAllMocks() - mockAuthenticate.mockResolvedValue({ kind: 'session', userId: 'u1', sessionId: 's1' }) - }) - - it('hydrates the key the panel subscribes to, through the authorized use case', async () => { - const credentialGroup = { - id: 'g1', - workspaceId: 'w1', - name: 'Engineering', - description: null, - options: [], - mcpServers: [], - status: 'active', - createdAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-01T00:00:00.000Z', - } - mockExecute.mockResolvedValue({ - credentialGroup: { ...credentialGroup, internal: true }, - availableProviders: ['gmail'], - }) - const queryClient = new QueryClient() - - await SECTION_PREFETCHERS['credential-groups']?.(queryClient, { - workspaceId: 'w1', - }) - - expect(mockExecute).toHaveBeenCalledWith({ - principal: { kind: 'session', userId: 'u1', sessionId: 's1' }, - input: { workspaceId: 'w1' }, - }) - expect(queryClient.getQueryData(credentialGroupKeys.workspace('w1'))).toEqual({ - credentialGroup, - availableProviders: ['gmail'], - }) - }) - - it('leaves the cache empty when the use case denies the viewer', async () => { - mockExecute.mockRejectedValue(Object.assign(new Error('forbidden'), { code: 'forbidden' })) - const queryClient = new QueryClient() - - await SECTION_PREFETCHERS['credential-groups']?.(queryClient, { - workspaceId: 'w1', - }) - - expect(queryClient.getQueryData(credentialGroupKeys.workspace('w1'))).toBeUndefined() - }) - - it('leaves the cache empty when session authentication fails', async () => { - mockAuthenticate.mockRejectedValue(new Error('unauthenticated')) - const queryClient = new QueryClient() - - await SECTION_PREFETCHERS['credential-groups']?.(queryClient, { - workspaceId: 'w1', - }) - - expect(mockExecute).not.toHaveBeenCalled() - expect(queryClient.getQueryData(credentialGroupKeys.workspace('w1'))).toBeUndefined() - }) -}) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts index 861683c3267..89889c87cb3 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts @@ -1,32 +1,6 @@ import type { QueryClient } from '@tanstack/react-query' -import { getWorkspaceAccountsContract } from '@/lib/api/contracts/credential-groups' -import { internalSessionAuth } from '@/lib/api/server/routes/internal-json-route' -import { getWorkspaceAccountsSettings } from '@/lib/credential-groups/application/manage-groups' import { prefetchCurrentUserSettings } from '@/lib/settings/prefetch-current-user-settings' import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation' -import { - credentialGroupKeys, - WORKSPACE_ACCOUNTS_STALE_TIME, -} from '@/hooks/queries/utils/credential-group-queries' - -/** Prefetches workspace accounts through the route's authorization and response boundaries. */ -async function prefetchWorkspaceAccounts( - queryClient: QueryClient, - { workspaceId }: SettingsSectionPrefetchContext -) { - return queryClient.prefetchQuery({ - queryKey: credentialGroupKeys.workspace(workspaceId), - queryFn: async () => { - const principal = await internalSessionAuth.authenticate() - const result = await getWorkspaceAccountsSettings.execute({ - principal, - input: { workspaceId }, - }) - return getWorkspaceAccountsContract.response.schema.parse(result) - }, - staleTime: WORKSPACE_ACCOUNTS_STALE_TIME, - }) -} export interface SettingsSectionPrefetchContext { workspaceId: string @@ -46,5 +20,4 @@ export const SECTION_PREFETCHERS: Partial< general: (queryClient) => prefetchCurrentUserSettings(queryClient), billing: (queryClient) => prefetchCurrentUserSettings(queryClient), admin: (queryClient) => prefetchCurrentUserSettings(queryClient), - 'credential-groups': prefetchWorkspaceAccounts, } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx index 57f81dd07dd..00db817fd3b 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx @@ -84,9 +84,6 @@ const AccessControl = dynamic(() => const CustomBlocks = dynamic(() => import('@/ee/custom-blocks/components/custom-blocks').then((m) => m.CustomBlocks) ) -const CredentialGroups = dynamic(() => - import('@/ee/credential-groups/components').then((m) => m.CredentialGroupsSettings) -) const AuditLogs = dynamic(() => import('@/ee/audit-logs/components/audit-logs').then((m) => m.AuditLogs) ) @@ -163,9 +160,6 @@ export function SettingsPage({ section }: SettingsPageProps) { {effectiveSection === 'browser' && } {effectiveSection === 'terminal' && } {effectiveSection === 'secrets' && } - {effectiveSection === 'credential-groups' && ( - - )} {effectiveSection === 'access-control' && organizationId && ( { { 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: 'forks', label: 'Workspace forks', section: 'workspace' }, { id: 'billing', label: 'Subscription', section: 'account' }, { id: 'teammates', label: 'Teammates', section: 'workspace' }, { id: 'organization', label: 'Members', section: 'organization' }, { id: 'usage', label: 'Usage tracking', section: 'organization' }, { id: 'secrets', label: 'Secrets', section: 'workspace' }, - { id: 'credential-groups', label: 'Connected accounts', 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' }, @@ -48,7 +47,7 @@ describe('unified settings navigation', () => { { 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: 'custom-blocks', label: 'Custom blocks', section: 'workspace' }, { id: 'admin', label: 'Admin', section: 'platform' }, { id: 'mothership', label: 'Mothership', section: 'platform' }, ]) @@ -72,20 +71,19 @@ describe('unified settings navigation', () => { 'teammates', 'secrets', 'mcp', + 'custom-blocks', + 'forks', 'custom-tools', 'byok', 'inbox', 'workflow-mcp-servers', 'apikeys', 'sandboxes', - 'credential-groups', 'recently-deleted', ]) expect(idsForSection('organization')).toEqual([ 'organization', 'usage', - 'custom-blocks', - 'forks', 'access-control', 'audit-logs', 'whitelabeling', @@ -146,6 +144,11 @@ describe('resolveSettingsSection', () => { expect(resolveSettingsSection('')).toBeNull() }) + it('does not expose credential group management through workspace settings', () => { + expect(resolveSettingsSection('credential-groups')).toBeNull() + expect(resolveSettingsSection('connected-accounts')).toBeNull() + }) + it('carries the catalog label through as the header title', () => { // `billing` is the case where id and label visibly differ, and the title feeds both the // shell heading and the document title via generateMetadata. diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.test.tsx index 0b335cdc0a6..0a430a7e6e4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.test.tsx @@ -76,6 +76,9 @@ vi.mock( '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight', () => ({ getWorkflowSearchLabelHighlight: () => undefined }) ) +vi.mock('@/hooks/queries/organization-accounts', () => ({ + useWorkspaceOrganizationAccounts: () => ({ data: { allowed: false } }), +})) vi.mock('@/hooks/use-operation-access', () => ({ useOperationAccess: () => ({ getDeniedOperations: () => new Set(), @@ -89,7 +92,7 @@ vi.mock('@/stores/workflows/workflow/store', () => ({ })) vi.mock('@/stores/workflows/registry/store', () => ({ useWorkflowRegistry: (selector: (state: unknown) => unknown) => - selector({ activeWorkflowId: 'wf-1' }), + selector({ activeWorkflowId: 'wf-1', hydration: { workspaceId: 'workspace-1' } }), })) vi.mock('@/stores/workflows/subblock/store', () => ({ useSubBlockStore: (selector: (state: unknown) => unknown) => selector({ workflowValues: {} }), diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx index 081d329bc3c..3af408c28f3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx @@ -18,6 +18,7 @@ import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflow import { getBlock } from '@/blocks/registry' import type { SubBlockConfig } from '@/blocks/types' import { ResponseBlockHandler } from '@/executor/handlers/response/response-handler' +import { useWorkspaceOrganizationAccounts } from '@/hooks/queries/organization-accounts' import { useDebounce } from '@/hooks/use-debounce' import { useOperationAccess } from '@/hooks/use-operation-access' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -123,6 +124,15 @@ export const Dropdown = memo(function Dropdown({ const dependsOnFields = useMemo(() => getDependsOnFields(dependsOn), [dependsOn]) const blockType = useWorkflowStore((state) => state.blocks[blockId]?.type) + const workspaceId = useWorkflowRegistry((state) => state.hydration.workspaceId) + const organizationAccounts = useWorkspaceOrganizationAccounts( + workspaceId ?? undefined, + blockType === 'credential' && subBlockId === OPERATION_SUBBLOCK_ID + ) + const hideOrganizationOperations = + blockType === 'credential' && + subBlockId === OPERATION_SUBBLOCK_ID && + organizationAccounts.data?.allowed !== true const blockConfig = blockType ? getBlock(blockType) : null const previousModeRef = useRef(null) @@ -274,10 +284,19 @@ export const Dropdown = memo(function Dropdown({ label: toLabel(opt.label), value: opt.id, icon: 'icon' in opt ? opt.icon : undefined, - hidden: opt.hidden || deniedOperationIds.has(opt.id), + hidden: + opt.hidden || + deniedOperationIds.has(opt.id) || + (hideOrganizationOperations && + [ + 'find_organization_account', + 'list_organization_accounts', + 'find_organization_mcp_connection', + 'list_organization_mcp_connections', + ].includes(opt.id)), } }) - }, [allOptions, deniedOperationIds, preserveLabelCase]) + }, [allOptions, deniedOperationIds, preserveLabelCase, hideOrganizationOperations]) const optionMap = useMemo(() => { return new Map(comboboxOptions.map((opt) => [opt.value, opt.label])) 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 6b44090bd7a..e30ead93cb6 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 @@ -189,12 +189,6 @@ export function SettingsSidebar({ if (item.id === 'forks' && !(forkingAvailable && canAdminWorkspace)) { return false } - if ( - item.id === 'credential-groups' && - (!hostContext.features?.credentialGroups || !canAdminWorkspace) - ) { - return false - } if (item.id === 'custom-blocks' && !hostContext.hostOrganizationId) { return false } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx index abe3c75c474..f6d35471dfb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx @@ -583,9 +583,7 @@ function WorkspaceHeaderImpl({ }} /> )} - {/* The gaps to the search field above and the rule below are the list's - own padding, so at rest rows sit where they always did, and while - scrolling they run through the gap beneath the edge fade. */} + {/** The list owns the gap below search, when shown, and above the separator. */}
{ workspaceListRef.current = node @@ -593,7 +591,8 @@ function WorkspaceHeaderImpl({ }} className={cn( scrollFadeClass, - '-mx-1.5 flex max-h-[200px] flex-col gap-0.5 overflow-y-auto px-1.5 pt-1.5 pb-1' + '-mx-1.5 flex max-h-[200px] flex-col gap-0.5 overflow-y-auto px-1.5 pb-1', + showSearch && 'pt-1.5' )} {...scrollFadeAttributes(listEdges)} > diff --git a/apps/sim/app/workspace/page.test.tsx b/apps/sim/app/workspace/page.test.tsx new file mode 100644 index 00000000000..4ef877bcd5f --- /dev/null +++ b/apps/sim/app/workspace/page.test.tsx @@ -0,0 +1,95 @@ +/** @vitest-environment jsdom */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + replace: vi.fn(), + workspaces: vi.fn(), + recentWorkspace: vi.fn(), + request: vi.fn(), +})) + +vi.mock('next/navigation', () => ({ useRouter: () => ({ replace: mocks.replace }) })) +vi.mock('@/lib/auth/auth-client', () => ({ + useSession: () => ({ data: { user: { id: 'viewer' } }, isPending: false }), +})) +vi.mock('@/lib/auth/stale-session-recovery', () => ({ recoverFromStaleSession: vi.fn() })) +vi.mock('@/lib/api/client/request', () => ({ requestJson: mocks.request })) +vi.mock('@/lib/core/utils/browser-storage', () => ({ + WorkspaceRecencyStorage: { getMostRecent: mocks.recentWorkspace }, +})) +vi.mock('@/app/_shell/desktop-title-bar', () => ({ DesktopTitleBarLane: () => null })) +vi.mock('@/hooks/queries/workspace', () => ({ useWorkspacesWithMetadata: mocks.workspaces })) + +import WorkspacePage from '@/app/workspace/page' + +describe('workspace settings destination', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + window.history.replaceState(null, '', '/workspace?redirect=settings') + mocks.recentWorkspace.mockReturnValue('workspace-2') + mocks.workspaces.mockReturnValue({ + data: { + workspaces: [{ id: 'workspace-1' }, { id: 'workspace-2' }], + lastActiveWorkspaceId: 'workspace-1', + }, + isLoading: false, + }) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + vi.unstubAllGlobals() + }) + + it('opens full settings in the most recent accessible workspace', async () => { + await act(async () => root.render()) + expect(mocks.replace).toHaveBeenCalledWith('/workspace/workspace-2/settings/general') + expect(mocks.request).not.toHaveBeenCalled() + }) + + it('uses an accessible workspace when the locally remembered workspace is unavailable', async () => { + mocks.recentWorkspace.mockReturnValue('removed-workspace') + await act(async () => root.render()) + expect(mocks.replace).toHaveBeenCalledWith('/workspace/workspace-1/settings/general') + }) + + it('preserves the settings destination after creating a permitted first workspace', async () => { + mocks.workspaces.mockReturnValue({ + data: { workspaces: [], creationPolicy: { canCreate: true } }, + isLoading: false, + }) + mocks.request.mockResolvedValue({ workspace: { id: 'new-workspace' } }) + await act(async () => root.render()) + expect(mocks.replace).toHaveBeenCalledWith('/workspace/new-workspace/settings/general') + }) + + it('keeps the access-denied state when workspace creation is blocked', async () => { + mocks.workspaces.mockReturnValue({ + data: { + workspaces: [], + creationPolicy: { canCreate: false, workspaceMode: 'organization' }, + }, + isLoading: false, + }) + await act(async () => root.render()) + expect(container.textContent).toContain('No workspace access yet') + expect(mocks.replace).not.toHaveBeenCalled() + expect(mocks.request).not.toHaveBeenCalled() + }) + + it('preserves normal workspace navigation without a settings destination', async () => { + window.history.replaceState(null, '', '/workspace') + await act(async () => root.render()) + expect(mocks.replace).toHaveBeenCalledWith('/workspace/workspace-2') + }) +}) diff --git a/apps/sim/app/workspace/page.tsx b/apps/sim/app/workspace/page.tsx index 72ed6873e6b..e53c894085a 100644 --- a/apps/sim/app/workspace/page.tsx +++ b/apps/sim/app/workspace/page.tsx @@ -125,14 +125,13 @@ export default function WorkspacePage() { const redirectTarget = urlParams.get('redirect') const rawReason = urlParams.get(UPGRADE_REASON_PARAM) - // `?redirect=upgrade` is how a caller that cannot know a workspace id — a - // self-hosted deployment, an email — reaches the plan picker. It has to - // survive workspace creation too: a first-time visitor has no workspace to - // resolve, and dropping the intent lands them on home with no explanation. + /** Preserve settings and upgrade destinations when selecting or creating a workspace. */ const destinationFor = (id: string) => redirectTarget === 'upgrade' ? buildUpgradeHref(id, isUpgradeReason(rawReason) ? rawReason : undefined) - : `/workspace/${id}` + : redirectTarget === 'settings' + ? `/workspace/${id}/settings/general` + : `/workspace/${id}` const { workspaces, lastActiveWorkspaceId, creationPolicy } = data diff --git a/apps/sim/blocks/blocks/credential-group.test.ts b/apps/sim/blocks/blocks/credential-group.test.ts index 38f56e14cca..703ce2b342c 100644 --- a/apps/sim/blocks/blocks/credential-group.test.ts +++ b/apps/sim/blocks/blocks/credential-group.test.ts @@ -9,7 +9,8 @@ import { CredentialGroupBlock } from '@/blocks/blocks/credential-group' describe('Connected Accounts block', () => { it('uses the workspace container without selectable or manual group inputs', () => { - expect(CredentialGroupBlock.name).toBe('Connected Accounts') + expect(CredentialGroupBlock.name).toBe('Connected Accounts (Legacy)') + expect(CredentialGroupBlock.hideFromToolbar).toBe(true) const operation = CredentialGroupBlock.subBlocks.find((field) => field.id === 'operation') expect(operation?.options).toEqual([ { label: 'List Credentials', id: 'list_credentials' }, diff --git a/apps/sim/blocks/blocks/credential-group.ts b/apps/sim/blocks/blocks/credential-group.ts index 3e962ae15d3..ad9f45df962 100644 --- a/apps/sim/blocks/blocks/credential-group.ts +++ b/apps/sim/blocks/blocks/credential-group.ts @@ -47,7 +47,8 @@ const LIST_OPERATIONS = ['list_credentials', 'list_mcp_connections', 'list_peopl export const CredentialGroupBlock: BlockConfig = { type: 'credential_group', - name: 'Connected Accounts', + name: 'Connected Accounts (Legacy)', + hideFromToolbar: true, description: 'Invite people and use connected accounts in this workspace', longDescription: 'List usable managed credentials or MCP connections, inspect invited people, send an invitation, or generate an account-connection link in the current workspace. The block returns credential IDs and account metadata without exposing OAuth tokens.', diff --git a/apps/sim/blocks/blocks/credential.ts b/apps/sim/blocks/blocks/credential.ts index 1ef89687e18..4dd13a27dc1 100644 --- a/apps/sim/blocks/blocks/credential.ts +++ b/apps/sim/blocks/blocks/credential.ts @@ -1,27 +1,27 @@ import { CredentialIcon } from '@/components/icons' +import { CREDENTIAL_GROUP_EVENT_TRIGGER_ID } from '@/lib/credential-groups/trigger-constants' import type { BlockConfig } from '@/blocks/types' +import { getTrigger } from '@/triggers' -interface CredentialBlockOutput { - success: boolean - output: { - credentialId: string - displayName: string - providerId: string - credentials: Array<{ - credentialId: string - displayName: string - providerId: string - }> - count: number - } -} +const ORGANIZATION_OPERATIONS = [ + 'find_organization_account', + 'list_organization_accounts', + 'find_organization_mcp_connection', + 'list_organization_mcp_connections', +] +const ORGANIZATION_LIST_OPERATIONS = [ + 'list_organization_accounts', + 'list_organization_mcp_connections', +] +const OAUTH_FIND_OPERATIONS = ['select', 'find_organization_account'] +const MCP_OPERATIONS = ['find_organization_mcp_connection', 'list_organization_mcp_connections'] -export const CredentialBlock: BlockConfig = { +export const CredentialBlock: BlockConfig = { type: 'credential', name: 'Credential', - description: 'Select or list OAuth credentials', + description: 'Select credentials or find organization accounts and MCP connections', longDescription: - 'Select an OAuth credential once and pipe its ID into any downstream block that requires authentication, or list all OAuth credentials in the workspace for iteration. No secrets are ever exposed — only credential IDs and metadata.', + 'Select workspace OAuth credentials or find and list organization accounts in an allowlisted workspace. Organization accounts are shared with every authorized workflow in that workspace. Returns credential references and account metadata. Manage invitations in organization settings.', bestPractices: ` - Use "Select Credential" to define an OAuth credential once and reference in multiple downstream blocks instead of repeating credential IDs. - Use "List Credentials" with a ForEach loop to iterate over all OAuth accounts (e.g. all Gmail accounts). @@ -38,6 +38,16 @@ export const CredentialBlock: BlockConfig = { byOperation: { select: ['Select an OAuth credential'], list: ['List OAuth credentials', { text: 'for', field: 'providerFilter' }], + find_organization_account: ['Find organization account', { text: 'for', field: 'email' }], + list_organization_accounts: ['List organization accounts', { text: 'for', field: 'email' }], + find_organization_mcp_connection: [ + 'Find organization MCP connection', + { text: 'for', field: 'email' }, + ], + list_organization_mcp_connections: [ + 'List organization MCP connections', + { text: 'for', field: 'email' }, + ], }, }, }, @@ -50,6 +60,10 @@ export const CredentialBlock: BlockConfig = { options: [ { label: 'Select Credential', id: 'select' }, { label: 'List Credentials', id: 'list' }, + { label: 'Find Organization Account', id: 'find_organization_account' }, + { label: 'List Organization Accounts', id: 'list_organization_accounts' }, + { label: 'Find Organization MCP Connection', id: 'find_organization_mcp_connection' }, + { label: 'List Organization MCP Connections', id: 'list_organization_mcp_connections' }, ], value: () => 'select', }, @@ -81,12 +95,75 @@ export const CredentialBlock: BlockConfig = { canonicalParamId: 'credentialId', condition: { field: 'operation', value: 'select' }, }, + { + id: 'email', + title: 'Email', + type: 'short-input', + placeholder: 'person@example.com', + condition: { field: 'operation', value: ORGANIZATION_OPERATIONS }, + required: { + field: 'operation', + value: ['find_organization_account', 'find_organization_mcp_connection'], + }, + }, + { + id: 'organizationProvider', + title: 'Provider', + type: 'dropdown', + selectorKey: 'workspace.credentialGroupProviders', + condition: { field: 'operation', value: 'find_organization_account' }, + required: true, + }, + { + id: 'organizationProviders', + title: 'Providers', + type: 'dropdown', + multiSelect: true, + selectorKey: 'workspace.credentialGroupProviders', + condition: { field: 'operation', value: 'list_organization_accounts' }, + }, + { + id: 'mcpProvider', + title: 'MCP provider', + type: 'dropdown', + selectorKey: 'workspace.organizationMcpProviders', + condition: { field: 'operation', value: MCP_OPERATIONS }, + required: { field: 'operation', value: 'find_organization_mcp_connection' }, + }, + { + id: 'limit', + title: 'Limit', + type: 'short-input', + value: () => '100', + condition: { field: 'operation', value: ORGANIZATION_LIST_OPERATIONS }, + }, + { + id: 'cursor', + title: 'Cursor', + type: 'short-input', + placeholder: 'Previous page nextCursor', + condition: { field: 'operation', value: ORGANIZATION_LIST_OPERATIONS }, + }, + ...getTrigger(CREDENTIAL_GROUP_EVENT_TRIGGER_ID).subBlocks, ], + triggers: { enabled: true, available: [CREDENTIAL_GROUP_EVENT_TRIGGER_ID] }, tools: { access: [], }, inputs: { - operation: { type: 'string', description: "'select' or 'list'" }, + operation: { type: 'string', description: 'Credential operation' }, + email: { type: 'string', description: 'Enrollment email' }, + organizationProvider: { + type: 'string', + description: 'Organization OAuth provider ID for an exact match', + }, + organizationProviders: { + type: 'json', + description: 'Optional organization OAuth provider IDs', + }, + mcpProvider: { type: 'string', description: 'Managed MCP provider ID' }, + limit: { type: 'number', description: 'Page size from 1 to 100' }, + cursor: { type: 'string', description: 'Previous page nextCursor' }, credentialId: { type: 'string', description: 'The OAuth credential ID to resolve (select operation)', @@ -101,28 +178,73 @@ export const CredentialBlock: BlockConfig = { credentialId: { type: 'string', description: "Credential ID — pipe into other blocks' credential fields", - condition: { field: 'operation', value: 'select' }, + condition: { + field: 'operation', + value: [...OAUTH_FIND_OPERATIONS, 'find_organization_mcp_connection'], + }, }, displayName: { type: 'string', description: 'Human-readable name of the credential', - condition: { field: 'operation', value: 'select' }, + condition: { + field: 'operation', + value: [...OAUTH_FIND_OPERATIONS, 'find_organization_mcp_connection'], + }, }, providerId: { type: 'string', description: 'OAuth provider ID (e.g. google-email, slack)', - condition: { field: 'operation', value: 'select' }, + condition: { field: 'operation', value: OAUTH_FIND_OPERATIONS }, }, credentials: { type: 'json', description: 'Array of OAuth credential objects, each with credentialId, displayName, and providerId', - condition: { field: 'operation', value: 'list' }, + condition: { field: 'operation', value: ['list', 'list_organization_accounts'] }, }, count: { type: 'number', - description: 'Number of credentials returned', - condition: { field: 'operation', value: 'list' }, + description: 'Number of connections returned', + condition: { field: 'operation', value: ['list', ...ORGANIZATION_LIST_OPERATIONS] }, + }, + email: { + type: 'string', + description: 'Enrollment email', + condition: { + field: 'operation', + value: ['find_organization_account', 'find_organization_mcp_connection'], + }, + }, + mcpServerId: { + type: 'string', + description: 'Shared MCP server configuration ID; use credentialId to select the account', + condition: { field: 'operation', value: 'find_organization_mcp_connection' }, + }, + mcpServerName: { + type: 'string', + description: 'MCP server name', + condition: { field: 'operation', value: 'find_organization_mcp_connection' }, + }, + toolNames: { + type: 'json', + description: 'Tools available to this connection', + condition: { field: 'operation', value: 'find_organization_mcp_connection' }, + }, + mcpConnections: { + type: 'json', + description: + 'Managed MCP connections with credentialId, email, mcpServerId, mcpServerName, displayName, and toolNames', + condition: { field: 'operation', value: 'list_organization_mcp_connections' }, + }, + hasMore: { + type: 'boolean', + description: 'Whether another page is available', + condition: { field: 'operation', value: ORGANIZATION_LIST_OPERATIONS }, + }, + nextCursor: { + type: 'string', + description: 'Next page cursor, or null', + condition: { field: 'operation', value: ORGANIZATION_LIST_OPERATIONS }, }, }, } diff --git a/apps/sim/components/settings/account-settings-renderer.tsx b/apps/sim/components/settings/account-settings-renderer.tsx index 7cefeba5d47..2112668c818 100644 --- a/apps/sim/components/settings/account-settings-renderer.tsx +++ b/apps/sim/components/settings/account-settings-renderer.tsx @@ -6,6 +6,7 @@ import { usePostHog } from 'posthog-js/react' import type { AccountSettingsSection } from '@/components/settings/navigation' import { captureEvent } from '@/lib/posthog/client' import { General } from '@/app/workspace/[workspaceId]/settings/components/general/general' +import { PersonalOrganizationAccounts } from '@/ee/credential-groups/components/personal-organization-accounts' const Billing = dynamic(() => import('@/app/workspace/[workspaceId]/settings/components/billing/billing').then( @@ -39,6 +40,7 @@ export function AccountSettingsRenderer({ section }: AccountSettingsRendererProp captureEvent(posthog, 'settings_tab_viewed', { plane: 'account', section }) }, [posthog, section]) + if (section === 'connected-accounts') return if (section === 'general') return if (section === 'billing') return if (section === 'api-keys') return diff --git a/apps/sim/components/settings/navigation.test.ts b/apps/sim/components/settings/navigation.test.ts index 270209f733d..8af92604619 100644 --- a/apps/sim/components/settings/navigation.test.ts +++ b/apps/sim/components/settings/navigation.test.ts @@ -62,7 +62,6 @@ const SELF_HOSTED_WORKSPACE_SECTIONS = WORKSPACE_SETTINGS_ITEMS.map(({ id }) => ) const ALL_ENTITLEMENTS = { - credentialGroups: true, customBlocks: true, forks: true, inbox: true, @@ -106,7 +105,6 @@ describe('settings navigation boundaries', () => { 'organization', 'usage', 'secrets', - 'credential-groups', 'custom-tools', 'mcp', 'apikeys', @@ -129,6 +127,7 @@ describe('settings navigation boundaries', () => { 'general', 'billing', 'api-keys', + 'connected-accounts', 'admin', 'mothership', ]) @@ -138,7 +137,6 @@ describe('settings navigation boundaries', () => { 'secrets', 'byok', 'sandboxes', - 'credential-groups', 'custom-tools', 'mcp', 'workflow-mcp-servers', @@ -235,6 +233,7 @@ describe('settings navigation boundaries', () => { hasEnterprisePlan: true, hosted: false, selfHosted: { + 'connected-accounts': true, 'access-control': false, 'audit-logs': false, sso: true, @@ -335,7 +334,6 @@ describe('settings navigation boundaries', () => { secrets: 'secrets', byok: 'byok', sandboxes: 'sandboxes', - 'credential-groups': 'credential-groups', 'custom-tools': 'custom-tools', mcp: 'mcp', 'workflow-mcp-servers': 'workflow-mcp-servers', @@ -560,7 +558,6 @@ describe('settings navigation boundaries', () => { expect(items.map(({ id }) => id)).toEqual([ 'teammates', - 'credential-groups', 'workflow-mcp-servers', 'recently-deleted', 'forks', diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts index 5cb06f29536..09720683acd 100644 --- a/apps/sim/components/settings/navigation.ts +++ b/apps/sim/components/settings/navigation.ts @@ -35,7 +35,13 @@ import { organizationRoutes } from '@/lib/navigation/paths' export type SettingsPlane = 'account' | 'selfhost' | 'workspace' -export type AccountSettingsSection = 'general' | 'billing' | 'api-keys' | 'admin' | 'mothership' +export type AccountSettingsSection = + | 'connected-accounts' + | 'general' + | 'billing' + | 'api-keys' + | 'admin' + | 'mothership' /** * Settings a self-hoster needs from the managed service: their profile, what @@ -45,6 +51,7 @@ export type SelfHostSettingsSection = 'general' | 'billing' | 'chat-keys' export type OrganizationSettingsSection = | 'integrations' + | 'connected-accounts' | 'search-mcp' | 'members' | 'billing' @@ -60,7 +67,6 @@ export type OrganizationSettingsSection = export type WorkspaceSettingsSection = | 'teammates' | 'secrets' - | 'credential-groups' | 'byok' | 'sandboxes' | 'custom-tools' @@ -94,7 +100,6 @@ export type UnifiedSettingsSection = | 'browser' | 'terminal' | 'secrets' - | 'credential-groups' | 'access-control' | 'custom-blocks' | 'audit-logs' @@ -525,17 +530,13 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] { label: 'Connected accounts', icon: GridOffset, - unified: { - id: 'credential-groups', - description: 'Manage the accounts people connect for Search and workflows.', - group: 'workspace', - order: 9, - requiresEnterprise: true, - allowNonOrgAdmin: true, - selfHostedOverride: 'always', - }, planes: { - workspace: { id: 'credential-groups', group: 'workspace', order: 4 }, + account: { + id: 'connected-accounts', + group: 'account', + order: 3, + description: 'Manage accounts you have contributed to organizations.', + }, }, }, { @@ -901,6 +902,7 @@ const ORGANIZATION_SECTION_GROUPS: Record { const group = ORGANIZATION_SECTION_GROUPS[id] + if (id === 'connected-accounts') { + return { + id, + label: 'Connected accounts', + description: 'Manage accounts shared with your organization’s workflows.', + icon: GridOffset, + group, + } + } if (id === 'integrations') { return { id, @@ -1016,6 +1027,7 @@ export function getOrganizationSettingsFeatures( hasEnterprisePlan, hosted: deployment.hosted, selfHosted: { + 'connected-accounts': true, 'access-control': features.accessControl, 'audit-logs': features.auditLogs, sso: features.sso, @@ -1069,7 +1081,6 @@ export function workspaceSectionUsesPermissionConfig(section: WorkspaceSettingsS } export interface WorkspaceSettingsEntitlements { - credentialGroups: boolean customBlocks: boolean forks: boolean inbox: boolean @@ -1138,7 +1149,6 @@ export interface ResolvedWorkspaceNavigationItem const WORKSPACE_MUTATION_PERMISSION: Record = { teammates: 'admin', secrets: 'write', - 'credential-groups': 'admin', byok: 'admin', sandboxes: 'admin', 'custom-tools': 'write', @@ -1177,12 +1187,6 @@ export function resolveWorkspaceNavigation({ const permissionConfigKey = WORKSPACE_PERMISSION_CONFIG_KEYS[item.id] if (permissionConfigKey && permissionConfig[permissionConfigKey]) return [] if (item.id === 'forks' && (permission !== 'admin' || !entitlements.forks)) return [] - if ( - item.id === 'credential-groups' && - (permission !== 'admin' || !entitlements.credentialGroups) - ) { - return [] - } if (item.id === 'custom-blocks' && !entitlements.customBlocks) return [] const lockedBy = LOCKABLE_WORKSPACE_SECTIONS[item.id] diff --git a/apps/sim/ee/credential-groups/components/credential-group-access.test.tsx b/apps/sim/ee/credential-groups/components/credential-group-access.test.tsx index 27fe230e9a9..06d41d69cc3 100644 --- a/apps/sim/ee/credential-groups/components/credential-group-access.test.tsx +++ b/apps/sim/ee/credential-groups/components/credential-group-access.test.tsx @@ -9,8 +9,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useSettingsDirtyStore } from '@/stores/settings/dirty/store' const mocks = vi.hoisted(() => ({ - addWorkflowModalProps: null as { - workflows: Array<{ id: string; name: string }> + addResourceModalProps: null as { + resources: Array<{ id: string; name: string }> onAdd: (workflowId: string) => void onClose: () => void } | null, @@ -107,13 +107,13 @@ vi.mock( }) ) -vi.mock('@/ee/credential-groups/components/credential-group-add-workflow-modal', () => ({ - CredentialGroupAddWorkflowModal: (props: { - workflows: Array<{ id: string; name: string }> +vi.mock('@/ee/credential-groups/components/credential-group-add-resource-modal', () => ({ + CredentialGroupAddResourceModal: (props: { + resources: Array<{ id: string; name: string }> onAdd: (workflowId: string) => void onClose: () => void }) => { - mocks.addWorkflowModalProps = props + mocks.addResourceModalProps = props return
Add workflow modal
}, })) @@ -194,7 +194,7 @@ function renderAccess(overrides: Partial { vi.clearAllMocks() useSettingsDirtyStore.getState().reset() - mocks.addWorkflowModalProps = null + mocks.addResourceModalProps = null mocks.mutationError = null mocks.useAccess.mockReturnValue({ data: { @@ -346,10 +346,10 @@ describe('CredentialGroupAccess', () => { act(() => access.button('Add workflow').click()) - expect(mocks.addWorkflowModalProps?.workflows).toEqual([ + expect(mocks.addResourceModalProps?.resources).toEqual([ { id: 'workflow-2', name: 'Support workflow' }, ]) - act(() => mocks.addWorkflowModalProps?.onAdd('workflow-2')) + act(() => mocks.addResourceModalProps?.onAdd('workflow-2')) expect(access.onAllowedWorkflowIdsChange).toHaveBeenCalledWith(['workflow-1', 'workflow-2'], 7) }) diff --git a/apps/sim/ee/credential-groups/components/credential-group-access.tsx b/apps/sim/ee/credential-groups/components/credential-group-access.tsx index 972a0948f8f..cf524f5e196 100644 --- a/apps/sim/ee/credential-groups/components/credential-group-access.tsx +++ b/apps/sim/ee/credential-groups/components/credential-group-access.tsx @@ -13,7 +13,7 @@ import { SettingsResourceRow, } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' -import { CredentialGroupAddWorkflowModal } from '@/ee/credential-groups/components/credential-group-add-workflow-modal' +import { CredentialGroupAddResourceModal } from '@/ee/credential-groups/components/credential-group-add-resource-modal' import { useCredentialGroupAccess, useUpdateCredentialGroupAccess, @@ -274,10 +274,14 @@ export function CredentialGroupAccess({ {showAddWorkflow && ( - { + addWorkflow(id) + setShowAddWorkflow(false) + }} onClose={() => setShowAddWorkflow(false)} /> )} diff --git a/apps/sim/ee/credential-groups/components/credential-group-add-resource-modal.test.tsx b/apps/sim/ee/credential-groups/components/credential-group-add-resource-modal.test.tsx new file mode 100644 index 00000000000..80b36c8841e --- /dev/null +++ b/apps/sim/ee/credential-groups/components/credential-group-add-resource-modal.test.tsx @@ -0,0 +1,174 @@ +/** + * @vitest-environment jsdom + */ + +import type { ReactNode } from 'react' +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +vi.mock('@sim/emcn', () => ({ + ChipModal: ({ children }: { children: ReactNode }) =>
{children}
, + ChipModalBody: ({ children }: { children: ReactNode }) =>
{children}
, + ChipModalError: ({ children }: { children: ReactNode }) =>
{children}
, + ChipModalField: ({ + children, + }: { + children: ReactNode | ((aria: { 'aria-required'?: boolean }) => ReactNode) + }) => ( +
{typeof children === 'function' ? children({ 'aria-required': true }) : children}
+ ), + ChipModalHeader: ({ children }: { children: ReactNode }) =>

{children}

, + ChipModalFooter: ({ + onCancel, + primaryAction, + }: { + onCancel: () => void + primaryAction: { label: string; onClick: () => void; disabled?: boolean } + }) => ( +
+ + +
+ ), + ChipDropdown: ({ + options, + value, + onChange, + }: { + options: Array<{ value: string; label: string }> + value: string[] + onChange: (value: string[]) => void + }) => ( +
+ {options.map((option) => ( + + ))} +
+ ), + ChipSelect: ({ + options, + onChange, + }: { + options: Array<{ value: string; label: string }> + onChange: (value: string) => void + }) => ( +
+ {options.map((option) => ( + + ))} +
+ ), +})) + +import { CredentialGroupAddResourceModal } from '@/ee/credential-groups/components/credential-group-add-resource-modal' + +const mountedRoots: Root[] = [] + +beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true +}) + +afterEach(() => { + act(() => { + for (const root of mountedRoots.splice(0)) root.unmount() + }) +}) + +it('requires one workflow and returns the canonical selected ID', () => { + const container = document.createElement('div') + const root = createRoot(container) + mountedRoots.push(root) + const onAdd = vi.fn() + const onClose = vi.fn() + act(() => + root.render( + + ) + ) + const button = (label: string) => { + const match = [...container.querySelectorAll('button')].find( + (candidate) => candidate.textContent === label + ) + if (!(match instanceof HTMLButtonElement)) throw new Error(`Button ${label} not found`) + return match + } + + expect(button('Add workflow').disabled).toBe(true) + act(() => button('Finance workflow').click()) + expect(button('Add workflow').disabled).toBe(false) + act(() => button('Add workflow').click()) + + expect(onAdd).toHaveBeenCalledWith('workflow-1') + expect(onClose).not.toHaveBeenCalled() +}) + +it('adds multiple selected workspaces together and excludes deselected workspaces', () => { + const container = document.createElement('div') + const root = createRoot(container) + mountedRoots.push(root) + const onAdd = vi.fn() + const onClose = vi.fn() + act(() => + root.render( + + ) + ) + const button = (label: string) => { + const match = [...container.querySelectorAll('button')].find( + (candidate) => candidate.textContent === label + ) + if (!match) throw new Error(`Button ${label} not found`) + return match + } + + expect(button('Add workspaces').disabled).toBe(true) + act(() => button('Finance').click()) + act(() => button('Support').click()) + act(() => button('Sales').click()) + act(() => button('Support').click()) + expect(onAdd).not.toHaveBeenCalled() + expect(onClose).not.toHaveBeenCalled() + expect(button('Finance').getAttribute('aria-pressed')).toBe('true') + expect(button('Sales').getAttribute('aria-pressed')).toBe('true') + expect(button('Support').getAttribute('aria-pressed')).toBe('false') + + act(() => button('Add workspaces').click()) + expect(onAdd).toHaveBeenCalledExactlyOnceWith(['workspace-1', 'workspace-3']) + expect(onClose).not.toHaveBeenCalled() +}) diff --git a/apps/sim/ee/credential-groups/components/credential-group-add-resource-modal.tsx b/apps/sim/ee/credential-groups/components/credential-group-add-resource-modal.tsx new file mode 100644 index 00000000000..e19d8efba7b --- /dev/null +++ b/apps/sim/ee/credential-groups/components/credential-group-add-resource-modal.tsx @@ -0,0 +1,110 @@ +'use client' + +import { useState } from 'react' +import { + ChipDropdown, + ChipModal, + ChipModalBody, + ChipModalError, + ChipModalField, + ChipModalFooter, + ChipModalHeader, + ChipSelect, +} from '@sim/emcn' + +interface CredentialGroupAddResourceModalBaseProps { + resources: readonly { id: string; name: string }[] + disabled: boolean + error?: string + onClose: () => void +} + +type CredentialGroupAddResourceModalProps = CredentialGroupAddResourceModalBaseProps & + ( + | { resourceType: 'workflow'; onAdd: (resourceId: string) => void } + | { resourceType: 'workspace'; onAdd: (resourceIds: string[]) => void } + ) + +export function CredentialGroupAddResourceModal(props: CredentialGroupAddResourceModalProps) { + const { resources, resourceType, disabled, error, onClose } = props + const [selectedResourceIds, setSelectedResourceIds] = useState([]) + const label = resourceType === 'workspace' ? 'Workspaces' : 'Workflow' + const title = resourceType === 'workspace' ? 'Add workspaces' : 'Add workflow' + const options = resources.map((resource) => ({ value: resource.id, label: resource.name })) + + const handleAdd = () => { + if (!selectedResourceIds.length) + throw new Error(`Select a ${resourceType} before granting access`) + if (new Set(selectedResourceIds).size !== selectedResourceIds.length) { + throw new Error('Access selection contains duplicate resources') + } + const available = new Set(resources.map((resource) => resource.id)) + for (const id of selectedResourceIds) { + if (!available.has(id)) throw new Error(`Selected ${resourceType} ${id} is unavailable`) + } + if (props.resourceType === 'workspace') props.onAdd(selectedResourceIds) + else props.onAdd(selectedResourceIds[0]) + } + + return ( + !open && !disabled && onClose()} + srTitle={title} + size='sm' + dismissDisabled={disabled} + > + + {title} + + + + {(aria) => + resourceType === 'workspace' ? ( + + ) : ( + setSelectedResourceIds([id])} + placeholder={`Select ${resourceType}`} + searchPlaceholder={`Search ${resourceType}s`} + searchable + aria-label={label} + disabled={disabled} + fullWidth + dropdownWidth='trigger' + align='start' + {...aria} + /> + ) + } + + {error} + + + + ) +} diff --git a/apps/sim/ee/credential-groups/components/credential-group-add-workflow-modal.test.tsx b/apps/sim/ee/credential-groups/components/credential-group-add-workflow-modal.test.tsx deleted file mode 100644 index 82b186c2112..00000000000 --- a/apps/sim/ee/credential-groups/components/credential-group-add-workflow-modal.test.tsx +++ /dev/null @@ -1,96 +0,0 @@ -/** - * @vitest-environment jsdom - */ - -import type { ReactNode } from 'react' -import { act } from 'react' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, expect, it, vi } from 'vitest' - -vi.mock('@sim/emcn', () => ({ - ChipModal: ({ children }: { children: ReactNode }) =>
{children}
, - ChipModalBody: ({ children }: { children: ReactNode }) =>
{children}
, - ChipModalField: ({ - children, - }: { - children: ReactNode | ((aria: { 'aria-required'?: boolean }) => ReactNode) - }) => ( -
{typeof children === 'function' ? children({ 'aria-required': true }) : children}
- ), - ChipModalHeader: ({ children }: { children: ReactNode }) =>

{children}

, - ChipModalFooter: ({ - onCancel, - primaryAction, - }: { - onCancel: () => void - primaryAction: { label: string; onClick: () => void; disabled?: boolean } - }) => ( -
- - -
- ), - ChipSelect: ({ - options, - onChange, - }: { - options: Array<{ value: string; label: string }> - onChange: (value: string) => void - }) => ( -
- {options.map((option) => ( - - ))} -
- ), -})) - -import { CredentialGroupAddWorkflowModal } from '@/ee/credential-groups/components/credential-group-add-workflow-modal' - -const mountedRoots: Root[] = [] - -afterEach(() => { - act(() => { - for (const root of mountedRoots.splice(0)) root.unmount() - }) -}) - -it('requires one workflow and returns the canonical selected ID', () => { - ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true - const container = document.createElement('div') - const root = createRoot(container) - mountedRoots.push(root) - const onAdd = vi.fn() - const onClose = vi.fn() - act(() => - root.render( - - ) - ) - const button = (label: string) => { - const match = [...container.querySelectorAll('button')].find( - (candidate) => candidate.textContent === label - ) - if (!(match instanceof HTMLButtonElement)) throw new Error(`Button ${label} not found`) - return match - } - - expect(button('Add workflow').disabled).toBe(true) - act(() => button('Finance workflow').click()) - expect(button('Add workflow').disabled).toBe(false) - act(() => button('Add workflow').click()) - - expect(onAdd).toHaveBeenCalledWith('workflow-1') - expect(onClose).toHaveBeenCalledOnce() -}) diff --git a/apps/sim/ee/credential-groups/components/credential-group-add-workflow-modal.tsx b/apps/sim/ee/credential-groups/components/credential-group-add-workflow-modal.tsx deleted file mode 100644 index db566560d32..00000000000 --- a/apps/sim/ee/credential-groups/components/credential-group-add-workflow-modal.tsx +++ /dev/null @@ -1,76 +0,0 @@ -'use client' - -import { useState } from 'react' -import { - ChipModal, - ChipModalBody, - ChipModalField, - ChipModalFooter, - ChipModalHeader, - ChipSelect, -} from '@sim/emcn' -import type { CredentialGroupAccessResponse } from '@/lib/api/contracts/credential-groups' - -type CredentialGroupWorkflow = CredentialGroupAccessResponse['workflows'][number] - -interface CredentialGroupAddWorkflowModalProps { - workflows: readonly CredentialGroupWorkflow[] - disabled: boolean - onAdd: (workflowId: string) => void - onClose: () => void -} - -export function CredentialGroupAddWorkflowModal({ - workflows, - disabled, - onAdd, - onClose, -}: CredentialGroupAddWorkflowModalProps) { - const [selectedWorkflowId, setSelectedWorkflowId] = useState('') - - const handleAdd = () => { - if (!selectedWorkflowId) throw new Error('Select a workflow before granting access') - if (!workflows.some((workflow) => workflow.id === selectedWorkflowId)) { - throw new Error(`Workflow ${selectedWorkflowId} is unavailable`) - } - onAdd(selectedWorkflowId) - onClose() - } - - return ( - !open && onClose()} srTitle='Add workflow' size='sm'> - Add workflow - - - {(aria) => ( - ({ - value: workflow.id, - label: workflow.name, - }))} - value={selectedWorkflowId} - onChange={setSelectedWorkflowId} - placeholder='Select workflow' - searchPlaceholder='Search workflows' - searchable - aria-label='Workflow' - disabled={disabled} - fullWidth - dropdownWidth='trigger' - align='start' - {...aria} - /> - )} - - - - - ) -} diff --git a/apps/sim/ee/credential-groups/components/credential-group-detail.tsx b/apps/sim/ee/credential-groups/components/credential-group-detail.tsx index 6e154278751..bad80ee1f8b 100644 --- a/apps/sim/ee/credential-groups/components/credential-group-detail.tsx +++ b/apps/sim/ee/credential-groups/components/credential-group-detail.tsx @@ -5,15 +5,8 @@ import { Chip, ChipConfirmModal, ChipModalTabs, toast } from '@sim/emcn' import { Plus } from '@sim/emcn/icons' import { getErrorMessage } from '@sim/utils/errors' import { useQueryState } from 'nuqs' -import { McpIcon } from '@/components/icons' import { saveDiscardActions } from '@/components/settings/save-discard-actions' -import type { - CredentialGroupEnrollment, - CredentialGroupEnrollmentConnection, - CredentialGroupEnrollmentMcpConnection, -} from '@/lib/api/contracts/credential-groups' -import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers' -import { resolveCredentialDisplay } from '@/lib/integrations/credential-display' +import type { CredentialGroupEnrollment } from '@/lib/api/contracts/credential-groups' import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail' import { SearchSetupReturn } from '@/app/workspace/[workspaceId]/search/components/search-setup-return' @@ -41,6 +34,7 @@ import { useCredentialGroupAccessEditor, } from '@/ee/credential-groups/components/credential-group-access' import { CredentialGroupDetails } from '@/ee/credential-groups/components/credential-group-details' +import { EnrollmentConnections } from '@/ee/credential-groups/components/credential-group-enrollment-connections' import { CredentialGroupInviteModal } from '@/ee/credential-groups/components/credential-group-invite-modal' import { useCredentialGroupDetail, @@ -64,49 +58,6 @@ const CREDENTIAL_GROUP_TABS = [ { value: 'access', label: 'Workflow access' }, ] as const -interface EnrollmentConnectionsProps { - connections: CredentialGroupEnrollmentConnection[] - mcpConnections: CredentialGroupEnrollmentMcpConnection[] -} - -interface CredentialProviderIconProps { - provider: CredentialGroupEnrollmentConnection['provider'] -} - -function CredentialProviderIcon({ provider }: CredentialProviderIconProps) { - if (provider === 'gitlab') { - const display = resolveCredentialDisplay({ - type: 'personal_token', - providerId: provider, - displayName: provider, - }) - const Icon = display.icon - return Icon ? : null - } - const ProviderIcon = getCredentialGroupProviderService(provider).icon - return -} - -function EnrollmentConnections({ connections, mcpConnections }: EnrollmentConnectionsProps) { - const connected = connections.filter((connection) => connection.status === 'active') - const connectedMcp = mcpConnections.filter((connection) => connection.status === 'active') - const count = - connected.reduce((total, connection) => total + connection.count, 0) + connectedMcp.length - const providers = [...new Set(connected.map((connection) => connection.provider))] - - return ( - - {providers.map((provider) => { - return - })} - {connectedMcp.length > 0 ? : null} - - {count} {count === 1 ? 'account' : 'accounts'} connected - - - ) -} - export function CredentialGroupDetail({ workspaceId, groupId }: CredentialGroupDetailProps) { const detail = useCredentialGroupDetail(workspaceId, groupId) const slackBots = useWorkspaceCredentials({ diff --git a/apps/sim/ee/credential-groups/components/credential-group-details.tsx b/apps/sim/ee/credential-groups/components/credential-group-details.tsx index bfd587d6089..07e54e01afe 100644 --- a/apps/sim/ee/credential-groups/components/credential-group-details.tsx +++ b/apps/sim/ee/credential-groups/components/credential-group-details.tsx @@ -32,7 +32,6 @@ import { SettingsResourceRow, } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' -import { DatabricksMcpConnectorModal } from '@/ee/credential-groups/components/databricks-mcp-connector-modal' import { SlackManagedUsersModal } from '@/ee/credential-groups/components/slack-managed-users-modal' import { useCreateCredentialGroupMcpConnector, @@ -41,7 +40,6 @@ import { useWorkspaceAccounts, } from '@/hooks/queries/credential-groups' import { useWorkspaceCredentials } from '@/hooks/queries/credentials' -import { useMcpServers } from '@/hooks/queries/mcp' /** Stable identity so a pending/errored credentials query cannot churn the modal's `bots` prop. */ const EMPTY_SLACK_BOTS: WorkspaceCredential[] = [] @@ -83,7 +81,6 @@ export function CredentialGroupDetails({ */ const accounts = useWorkspaceAccounts(workspaceId) const availableProviders = accounts.data?.availableProviders - const mcpServers = useMcpServers(workspaceId) const slackBots = useWorkspaceCredentials({ workspaceId, type: 'service_account', @@ -91,7 +88,6 @@ export function CredentialGroupDetails({ }) const [slackSetup, setSlackSetup] = useState<{ credentialId?: string } | null>(null) const [removingProvider, setRemovingProvider] = useState(null) - const [databricksSetupOpen, setDatabricksSetupOpen] = useState(false) const [removingMcpConnector, setRemovingMcpConnector] = useState( null ) @@ -210,12 +206,6 @@ export function CredentialGroupDetails({ connector.description.toLowerCase().includes(providerQuery) ) }) - const databricksServerSummary = credentialGroup.mcpServers.find( - (server) => server.managedConnectorId === 'databricks' - ) - const databricksServer = databricksServerSummary - ? mcpServers.data?.find((server) => server.id === databricksServerSummary.id) - : undefined return ( <> @@ -330,22 +320,17 @@ export function CredentialGroupDetails({ key={connectorId} icon={} title={server?.name ?? connector.name} - description={connector.description} + description={ + connectorId === 'databricks' + ? 'Configure Databricks in organization Connected accounts' + : connector.description + } badge={server ? Added : undefined} trailing={ server ? ( setDatabricksSetupOpen(true), - disabled: isUpdating || !databricksServer, - }, - ] - : []), { label: 'Remove', destructive: true, @@ -354,17 +339,11 @@ export function CredentialGroupDetails({ }, ]} /> - ) : ( - { - if (connectorId === 'databricks') setDatabricksSetupOpen(true) - else void addMcpConnector(connectorId) - }} - > - {connectorId === 'databricks' ? 'Set up' : 'Add'} + ) : connectorId !== 'databricks' ? ( + void addMcpConnector(connectorId)}> + Add - ) + ) : undefined } /> ) @@ -391,14 +370,6 @@ export function CredentialGroupDetails({ } /> - - !open && !isUpdating && setRemovingProvider(null)} diff --git a/apps/sim/ee/credential-groups/components/credential-group-enrollment-connections.tsx b/apps/sim/ee/credential-groups/components/credential-group-enrollment-connections.tsx new file mode 100644 index 00000000000..500a9205461 --- /dev/null +++ b/apps/sim/ee/credential-groups/components/credential-group-enrollment-connections.tsx @@ -0,0 +1,50 @@ +import { McpIcon } from '@/components/icons' +import type { + CredentialGroupEnrollmentConnection, + CredentialGroupEnrollmentMcpConnection, +} from '@/lib/api/contracts/credential-groups' +import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers' +import { resolveCredentialDisplay } from '@/lib/integrations/credential-display' + +interface EnrollmentConnectionsProps { + connections: CredentialGroupEnrollmentConnection[] + mcpConnections: CredentialGroupEnrollmentMcpConnection[] +} + +interface CredentialProviderIconProps { + provider: CredentialGroupEnrollmentConnection['provider'] +} + +function CredentialProviderIcon({ provider }: CredentialProviderIconProps) { + if (provider === 'gitlab') { + const display = resolveCredentialDisplay({ + type: 'personal_token', + providerId: provider, + displayName: provider, + }) + const Icon = display.icon + return Icon ? : null + } + const ProviderIcon = getCredentialGroupProviderService(provider).icon + return +} + +export function EnrollmentConnections({ connections, mcpConnections }: EnrollmentConnectionsProps) { + const connected = connections.filter((connection) => connection.status === 'active') + const connectedMcp = mcpConnections.filter((connection) => connection.status === 'active') + const count = + connected.reduce((total, connection) => total + connection.count, 0) + connectedMcp.length + const providers = [...new Set(connected.map((connection) => connection.provider))] + + return ( + + {providers.map((provider) => { + return + })} + {connectedMcp.length > 0 ? : null} + + {count} {count === 1 ? 'account' : 'accounts'} connected + + + ) +} diff --git a/apps/sim/ee/credential-groups/components/credential-groups-settings.test.tsx b/apps/sim/ee/credential-groups/components/credential-groups-settings.test.tsx deleted file mode 100644 index 4345fdcb35e..00000000000 --- a/apps/sim/ee/credential-groups/components/credential-groups-settings.test.tsx +++ /dev/null @@ -1,255 +0,0 @@ -/** @vitest-environment jsdom */ -import { act, type ReactNode } from 'react' -import { toast } from '@sim/emcn' -import { NuqsTestingAdapter } from 'nuqs/adapters/testing' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { CredentialGroup } from '@/lib/api/contracts/credential-groups' - -const mocks = vi.hoisted(() => ({ - groups: [] as CredentialGroup[], - accountsPending: false, - ensure: vi.fn(), - ensured: undefined as { credentialGroup: CredentialGroup } | undefined, - setupError: null as Error | null, - setupIdle: true, - setupPending: false, - push: vi.fn(), - update: vi.fn(), - panel: null as { - title?: string - back?: { onSelect: () => void } - actions?: Array<{ text: string; onSelect: () => void }> - } | null, -})) - -vi.mock('next/navigation', () => ({ - usePathname: () => '/workspace/workspace-1/settings/credential-groups', - useRouter: () => ({ push: mocks.push }), -})) -vi.mock('@/hooks/queries/credential-groups', () => ({ - useWorkspaceAccounts: (workspaceId: string) => ({ - data: mocks.accountsPending - ? undefined - : { - credentialGroup: mocks.groups.find((group) => group.workspaceId === workspaceId) ?? null, - availableProviders: [], - }, - isSuccess: !mocks.accountsPending, - isPending: mocks.accountsPending, - }), - useEnsureWorkspaceAccounts: () => ({ - mutate: mocks.ensure, - data: mocks.ensured, - error: mocks.setupError, - isIdle: mocks.setupIdle, - isPending: mocks.setupPending, - }), - useCredentialGroupDetail: (_workspaceId: string, groupId: string) => ({ - data: { - pages: [ - { - credentialGroup: - mocks.groups.find((group) => group.id === groupId) ?? mocks.ensured?.credentialGroup, - enrollments: [], - }, - ], - }, - isPending: false, - hasNextPage: false, - }), - useResendCredentialGroupEnrollment: () => ({ isPending: false }), - useDeleteCredentialGroupEnrollment: () => ({ isPending: false }), - useUpdateCredentialGroup: () => ({ isPending: false, mutateAsync: mocks.update }), -})) -vi.mock('@/hooks/queries/credentials', () => ({ useWorkspaceCredentials: () => ({ data: [] }) })) -vi.mock('@/hooks/use-debounced-search-setter', () => ({ - useDebouncedSearchSetter: (setter: unknown) => setter, -})) -vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-panel', () => ({ - SettingsPanel: ({ - children, - ...props - }: { - children: ReactNode - title?: string - actions?: Array<{ text: string; onSelect: () => void }> - }) => { - mocks.panel = props - return <>{children} - }, -})) -vi.mock('@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard', () => ({ - useSettingsUnsavedGuard: () => ({ - guardBack: (action: () => void) => action(), - showUnsavedModal: false, - }), -})) -vi.mock('@/app/workspace/[workspaceId]/components/credential-detail', () => ({ - UnsavedChangesModal: () => null, -})) -vi.mock('@/ee/credential-groups/components/credential-group-details', () => ({ - CredentialGroupDetails: ({ credentialGroup }: { credentialGroup: CredentialGroup }) => ( -
Accounts people can connect
- ), -})) -vi.mock('@/ee/credential-groups/components/credential-group-access', () => ({ - useCredentialGroupAccessEditor: () => ({ dirty: false, saving: false, discard: vi.fn() }), - CredentialGroupAccess: () => null, -})) -vi.mock('@/ee/credential-groups/components/credential-group-invite-modal', () => ({ - CredentialGroupInviteModal: () => null, -})) - -import { CredentialGroupsSettings } from '@/ee/credential-groups/components/credential-groups-settings' - -const canonical: CredentialGroup = { - id: 'canonical-group', - workspaceId: 'workspace-1', - name: 'Workspace accounts', - description: null, - options: [], - mcpServers: [], - status: 'active', - createdAt: '2026-09-04T00:00:00Z', - updatedAt: '2026-09-04T00:00:00Z', -} - -describe('Connected accounts settings', () => { - let root: Root - let container: HTMLDivElement - beforeEach(() => { - vi.clearAllMocks() - vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) - vi.spyOn(toast, 'success').mockImplementation(() => '') - mocks.groups = [canonical] - mocks.accountsPending = false - mocks.ensured = undefined - mocks.setupError = null - mocks.setupIdle = true - mocks.setupPending = false - mocks.panel = null - container = document.createElement('div') - document.body.appendChild(container) - root = createRoot(container) - }) - afterEach(async () => { - await act(async () => root.unmount()) - container.remove() - vi.restoreAllMocks() - vi.unstubAllGlobals() - }) - async function render(searchParams = '', workspaceId = 'workspace-1') { - await act(async () => - root.render( - - - - ) - ) - } - - it('opens the canonical accounts directly without create, group search, name, or delete controls', async () => { - await render() - expect(container.querySelector('[data-accounts-id]')?.getAttribute('data-accounts-id')).toBe( - canonical.id - ) - expect(container.textContent).toContain('Accounts') - expect(container.textContent).toContain('People') - expect(container.textContent).toContain('Workflow access') - expect(container.textContent).not.toContain('Existing connections') - expect(container.textContent).not.toContain('Create group') - expect(container.querySelector('input')).toBeNull() - expect(mocks.panel?.back).toBeUndefined() - expect(mocks.panel?.actions).toEqual([]) - expect(mocks.ensure).not.toHaveBeenCalled() - }) - - it('prepares a missing workspace container automatically and opens the returned accounts', async () => { - mocks.groups = [] - await render() - expect(mocks.ensure).toHaveBeenCalledWith({ workspaceId: 'workspace-1' }) - mocks.setupIdle = false - mocks.ensured = { credentialGroup: canonical } - await render() - expect(container.querySelector('[data-accounts-id]')?.getAttribute('data-accounts-id')).toBe( - canonical.id - ) - expect(mocks.ensure).toHaveBeenCalledTimes(1) - }) - - it('opens the People tab on the workspace account record', async () => { - await render('?credential-group-tab=people') - expect(container.textContent).toContain('No people invited yet') - expect(mocks.panel?.title).toBeUndefined() - expect(mocks.panel?.back).toBeUndefined() - expect(mocks.ensure).not.toHaveBeenCalled() - }) - - it('does not show a previous workspace response after switching workspaces', async () => { - mocks.groups = [] - mocks.ensured = { credentialGroup: canonical } - mocks.setupIdle = false - await render() - expect(container.querySelector('[data-accounts-id]')).not.toBeNull() - mocks.setupIdle = true - await render('', 'workspace-2') - expect(container.querySelector('[data-accounts-id]')).toBeNull() - expect(mocks.ensure).toHaveBeenCalledWith({ workspaceId: 'workspace-2' }) - }) - - it('shows retry after failed setup instead of repeatedly creating a group', async () => { - mocks.groups = [] - mocks.setupIdle = false - mocks.setupError = new Error('Temporary setup failure') - await render() - expect(mocks.ensure).not.toHaveBeenCalled() - expect(container.textContent).toContain('Temporary setup failure') - const retry = Array.from(container.querySelectorAll('button')).find( - (button) => button.textContent === 'Try again' - ) - await act(async () => retry?.click()) - expect(mocks.ensure).toHaveBeenCalledWith({ workspaceId: 'workspace-1' }) - }) - - it.each([ - ['loading', 'search', '/workspace/workspace-1/search'], - ['loading', 'slack', '/workspace/workspace-1/search?addConnector=slack'], - ['preparing', 'search', '/workspace/workspace-1/search'], - ['preparing', 'slack', '/workspace/workspace-1/search?addConnector=slack'], - ['failed', 'search', '/workspace/workspace-1/search'], - ['failed', 'slack', '/workspace/workspace-1/search?addConnector=slack'], - ] as const)('returns from %s accounts to %s setup', async (state, source, href) => { - mocks.groups = [] - mocks.accountsPending = state === 'loading' - mocks.setupIdle = state === 'loading' - mocks.setupPending = state === 'preparing' - mocks.setupError = state === 'failed' ? new Error('Temporary setup failure') : null - - await render(`?search-setup=${source}`) - - expect(container.querySelector('[data-accounts-id]')).toBeNull() - if (state === 'failed') expect(container.textContent).toContain('Temporary setup failure') - const continueSetup = Array.from(container.querySelectorAll('button')).find( - (button) => button.textContent === 'Continue Search setup' - ) - expect(continueSetup).toBeDefined() - await act(async () => continueSetup?.click()) - expect(mocks.push).toHaveBeenCalledExactlyOnceWith(href) - expect(mocks.ensure).not.toHaveBeenCalled() - }) - - it('offers to enable a disabled canonical container without replacing it', async () => { - mocks.groups = [{ ...canonical, status: 'disabled' }] - await render() - const enable = mocks.panel?.actions?.find((action) => action.text === 'Enable accounts') - expect(enable).toBeDefined() - await act(async () => enable?.onSelect()) - expect(mocks.update).toHaveBeenCalledWith({ - workspaceId: 'workspace-1', - groupId: canonical.id, - body: { status: 'active' }, - }) - expect(mocks.ensure).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/ee/credential-groups/components/credential-groups-settings.tsx b/apps/sim/ee/credential-groups/components/credential-groups-settings.tsx deleted file mode 100644 index 032a867dc7b..00000000000 --- a/apps/sim/ee/credential-groups/components/credential-groups-settings.tsx +++ /dev/null @@ -1,57 +0,0 @@ -'use client' - -import { useEffect } from 'react' -import { SearchSetupReturn } from '@/app/workspace/[workspaceId]/search/components/search-setup-return' -import { SettingsQueryErrorState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' -import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' -import { CredentialGroupDetail } from '@/ee/credential-groups/components/credential-group-detail' -import { useEnsureWorkspaceAccounts, useWorkspaceAccounts } from '@/hooks/queries/credential-groups' - -interface CredentialGroupsSettingsProps { - workspaceId: string -} - -export function CredentialGroupsSettings({ workspaceId }: CredentialGroupsSettingsProps) { - return -} - -function WorkspaceAccounts({ workspaceId }: CredentialGroupsSettingsProps) { - const accounts = useWorkspaceAccounts(workspaceId) - const { - mutate: ensureAccounts, - data: preparedAccounts, - error: setupError, - isIdle: setupIdle, - isPending: setupPending, - } = useEnsureWorkspaceAccounts() - const prepared = preparedAccounts?.credentialGroup - const credentialGroup = - accounts.data?.credentialGroup ?? (prepared?.workspaceId === workspaceId ? prepared : undefined) - const needsSetup = accounts.isSuccess && !credentialGroup - - /** Existing workspaces prepare their account container on first use. */ - useEffect(() => { - if (needsSetup && setupIdle) ensureAccounts({ workspaceId }) - }, [ensureAccounts, needsSetup, setupIdle, workspaceId]) - - if (credentialGroup) { - return - } - - const error = accounts.error ?? setupError - return ( - - - {error ? ( - - accounts.error ? void accounts.refetch() : ensureAccounts({ workspaceId }) - } - /> - ) : null} - - ) -} diff --git a/apps/sim/ee/credential-groups/components/databricks-mcp-connector-modal.tsx b/apps/sim/ee/credential-groups/components/databricks-mcp-connector-modal.tsx index 7908646500d..a39136e7d1a 100644 --- a/apps/sim/ee/credential-groups/components/databricks-mcp-connector-modal.tsx +++ b/apps/sim/ee/credential-groups/components/databricks-mcp-connector-modal.tsx @@ -12,84 +12,66 @@ import { } from '@sim/emcn' import { getErrorMessage } from '@sim/utils/errors' import { DatabricksIcon } from '@/components/icons' -import type { McpServer } from '@/lib/api/contracts/mcp' +import type { OrganizationAccountsSettings } from '@/lib/api/contracts/organization-accounts' import { - useCreateCredentialGroupMcpConnector, - useUpdateCredentialGroupMcpConnector, -} from '@/hooks/queries/credential-groups' + useAddOrganizationAccountMcpProvider, + useConfigureOrganizationMcp, + useOrganizationDatabricksSetup, +} from '@/hooks/queries/organization-accounts' interface DatabricksMcpConnectorModalProps { - credentialGroupId: string + organizationId: string onOpenChange: (open: boolean) => void open: boolean - server?: McpServer - workspaceId: string + existingServer?: NonNullable< + OrganizationAccountsSettings['credentialGroup'] + >['mcpServers'][number] } export function DatabricksMcpConnectorModal({ - credentialGroupId, + organizationId, onOpenChange, open, - server, - workspaceId, + existingServer, }: DatabricksMcpConnectorModalProps) { - const createConnector = useCreateCredentialGroupMcpConnector() - const updateConnector = useUpdateCredentialGroupMcpConnector() + const setup = useOrganizationDatabricksSetup(organizationId, open && Boolean(existingServer)) + const add = useAddOrganizationAccountMcpProvider() + const configure = useConfigureOrganizationMcp() const [nameInput, setNameInput] = useState(null) const [urlInput, setUrlInput] = useState(null) const [clientIdInput, setClientIdInput] = useState(null) const [clientSecret, setClientSecret] = useState('') + const server = existingServer ? setup.data?.server : undefined + const adding = !existingServer?.enabled const name = nameInput ?? server?.name ?? 'Databricks' const url = urlInput ?? server?.url ?? '' const clientId = clientIdInput ?? server?.oauthClientId ?? '' - const pending = createConnector.isPending || updateConnector.isPending - const error = createConnector.error ?? updateConnector.error - - const reset = () => { - setNameInput(null) - setUrlInput(null) - setClientIdInput(null) - setClientSecret('') - createConnector.reset() - updateConnector.reset() - } + const pending = configure.isPending || add.isPending + const setupError = existingServer ? setup.error : null + const error = add.error ?? configure.error ?? setupError + const fieldsAvailable = !existingServer || Boolean(server && !setupError) + const canSave = fieldsAvailable && Boolean(name.trim() && url.trim() && clientId.trim()) + const title = adding ? 'Add Databricks' : 'Configure Databricks' const handleOpenChange = (nextOpen: boolean) => { if (pending && !nextOpen) return onOpenChange(nextOpen) - if (!nextOpen) reset() } const handleSubmit = async () => { - if (!name.trim() || !url.trim() || !clientId.trim() || pending) return + if (!canSave || pending) return try { - if (server) { - await updateConnector.mutateAsync({ - workspaceId, - groupId: credentialGroupId, - connectorId: 'databricks', - body: { - name: name.trim(), - url: url.trim(), - oauthClientId: clientId.trim(), - ...(clientSecret.trim() ? { oauthClientSecret: clientSecret.trim() } : {}), - }, - }) - } else { - await createConnector.mutateAsync({ - workspaceId, - groupId: credentialGroupId, - body: { - connectorId: 'databricks', - name: name.trim(), - url: url.trim(), - oauthClientId: clientId.trim(), - ...(clientSecret.trim() ? { oauthClientSecret: clientSecret.trim() } : {}), - }, - }) + const configuration = { + organizationId, + name: name.trim(), + url: url.trim(), + oauthClientId: clientId.trim(), + ...(clientSecret.trim() ? { oauthClientSecret: clientSecret.trim() } : {}), } - toast.success(server ? 'Databricks updated' : 'Databricks added') - handleOpenChange(false) + if (existingServer) await configure.mutateAsync(configuration) + else await add.mutateAsync({ ...configuration, connectorId: 'databricks' }) + toast.success(adding ? 'Databricks added' : 'Databricks configuration saved') + onOpenChange(false) } catch (submitError) { toast.error(getErrorMessage(submitError, 'Could not save Databricks')) } @@ -100,7 +82,7 @@ export function DatabricksMcpConnectorModal({ open={open} onOpenChange={handleOpenChange} dismissDisabled={pending} - srTitle={server ? 'Edit Databricks MCP' : 'Add Databricks MCP'} + srTitle={title} size='md' > handleOpenChange(false)} closeDisabled={pending} > - {server ? 'Edit Databricks MCP' : 'Add Databricks MCP'} + {title} - - - - + {existingServer && setup.isPending && ( +

+ Loading Databricks configuration... +

+ )} + {fieldsAvailable && ( + <> + + + + + {server?.enabled && ( +

+ Changing the URL or OAuth client requires people to reconnect their Databricks + accounts. +

+ )} + + )} {error ? getErrorMessage(error) : null}
handleOpenChange(false)} cancelDisabled={pending} primaryAction={{ - label: pending ? 'Saving...' : 'Save', + label: pending ? (adding ? 'Adding...' : 'Saving...') : adding ? 'Add' : 'Save', onClick: () => void handleSubmit(), - disabled: pending || !name.trim() || !url.trim() || !clientId.trim(), + disabled: pending || !canSave, }} /> diff --git a/apps/sim/ee/credential-groups/components/index.ts b/apps/sim/ee/credential-groups/components/index.ts deleted file mode 100644 index d8b5132e3d7..00000000000 --- a/apps/sim/ee/credential-groups/components/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { CredentialGroupsSettings } from '@/ee/credential-groups/components/credential-groups-settings' diff --git a/apps/sim/ee/credential-groups/components/organization-account-invite-modal.tsx b/apps/sim/ee/credential-groups/components/organization-account-invite-modal.tsx new file mode 100644 index 00000000000..ce0c2e9eb6c --- /dev/null +++ b/apps/sim/ee/credential-groups/components/organization-account-invite-modal.tsx @@ -0,0 +1,118 @@ +'use client' + +import { useCallback, useState } from 'react' +import { + ChipModal, + ChipModalBody, + ChipModalError, + ChipModalField, + ChipModalFooter, + ChipModalHeader, + toast, +} from '@sim/emcn' +import { getErrorMessage } from '@sim/utils/errors' +import { quickValidateEmail } from '@/lib/messaging/email/validation' +import { useInviteOrganizationAccountPeople } from '@/hooks/queries/organization-accounts' + +interface OrganizationAccountInviteModalProps { + organizationId: string + onClose: () => void +} + +function validateEmail(email: string): string | null { + const result = quickValidateEmail(email) + return result.isValid ? null : (result.reason ?? 'Invalid email') +} + +export function OrganizationAccountInviteModal({ + organizationId, + onClose, +}: OrganizationAccountInviteModalProps) { + const invite = useInviteOrganizationAccountPeople() + const [emails, setEmails] = useState([]) + const [deliveryError, setDeliveryError] = useState(null) + const canSubmit = emails.length > 0 && !invite.isPending + + const handleEmailsChange = useCallback((next: string[]) => { + setEmails(next) + setDeliveryError(null) + }, []) + + const handleOpenChange = (nextOpen: boolean) => { + if (invite.isPending) return + if (!nextOpen) onClose() + if (!nextOpen) { + setEmails([]) + setDeliveryError(null) + invite.reset() + } + } + + const handleSubmit = async () => { + if (!canSubmit) return + setDeliveryError(null) + try { + const result = await invite.mutateAsync({ + organizationId, + emails, + }) + const failures = result.results.filter((item) => !item.success) + if (failures.length === 0) { + toast.success( + result.sentCount === 1 + ? 'Connection request sent' + : `${result.sentCount} connection requests sent` + ) + handleOpenChange(false) + return + } + + setEmails(failures.map((item) => item.email)) + setDeliveryError( + result.sentCount > 0 + ? `${result.sentCount} sent. ${failures.length} failed: ${failures.map((item) => item.email).join(', ')}` + : `No invitations were sent: ${failures.map((item) => `${item.email} (${item.error})`).join(', ')}` + ) + } catch { + return + } + } + + return ( + + handleOpenChange(false)}> + Request account connections + + + + + {deliveryError ?? + (invite.error ? getErrorMessage(invite.error, 'Failed to send invitations') : null)} + + + handleOpenChange(false)} + cancelDisabled={invite.isPending} + primaryAction={{ + label: invite.isPending ? 'Sending...' : 'Send requests', + onClick: handleSubmit, + disabled: !canSubmit, + }} + /> + + ) +} diff --git a/apps/sim/ee/credential-groups/components/organization-account-people.test.tsx b/apps/sim/ee/credential-groups/components/organization-account-people.test.tsx new file mode 100644 index 00000000000..7febf7f91f0 --- /dev/null +++ b/apps/sim/ee/credential-groups/components/organization-account-people.test.tsx @@ -0,0 +1,168 @@ +/** @vitest-environment jsdom */ +import { act } from 'react' +import { NuqsTestingAdapter } from 'nuqs/adapters/testing' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + resend: vi.fn(), + revoke: vi.fn(), + disconnect: vi.fn(), + reset: vi.fn(), +})) +vi.mock('@/hooks/queries/organization-accounts', () => ({ + useOrganizationAccountPeople: () => ({ + data: { + pages: [ + { + enrollments: [ + { + id: 'enrollment-1', + email: 'person@example.com', + status: 'active', + connections: [{ provider: 'gmail', status: 'active', count: 2 }], + mcpConnections: [], + }, + ], + }, + ], + }, + }), + usePersonalOrganizationAccounts: () => ({ + data: { + pages: [ + { + accounts: [ + { + credentialId: 'credential-1', + displayName: 'Personal Gmail', + organizationName: 'Example organization', + providerId: 'gmail', + status: 'active', + canReconnect: true, + }, + ], + }, + ], + }, + }), + useResendOrganizationAccountInvitation: () => ({ mutate: mocks.resend }), + useRevokeOrganizationAccountEnrollment: () => ({ mutate: mocks.revoke, reset: mocks.reset }), + useReconnectPersonalOrganizationAccount: () => ({}), + useDisconnectPersonalOrganizationAccount: () => ({ + mutate: mocks.disconnect, + reset: mocks.reset, + }), +})) +vi.mock('@/ee/credential-groups/components/organization-account-invite-modal', () => ({ + OrganizationAccountInviteModal: () => null, +})) + +import { OrganizationAccountPeople } from '@/ee/credential-groups/components/organization-account-people' +import { PersonalOrganizationAccounts } from '@/ee/credential-groups/components/personal-organization-accounts' + +let root: Root +let container: HTMLDivElement +beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) +afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + vi.unstubAllGlobals() +}) + +function button(parent: ParentNode, label: string): HTMLButtonElement { + const found = Array.from(parent.querySelectorAll('button')).find( + (node) => node.textContent === label + ) + if (!found) throw new Error(`Missing ${label} button`) + return found +} + +async function selectPersonAction(label: string) { + const trigger = container.querySelector('[aria-label="person@example.com actions"]') + if (!trigger) throw new Error('Missing person actions menu') + await act(async () => + trigger.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 })) + ) + const action = Array.from(document.querySelectorAll('[role="menuitem"]')).find( + (item) => item.textContent === label + ) + if (!action) throw new Error(`Missing ${label} action`) + await act(async () => action.click()) +} + +async function openConfirmation(label: string) { + if (label === 'Revoke') await selectPersonAction(label) + else await act(async () => button(container, label).click()) +} + +it('keeps the compact People rows and resends from the actions menu', async () => { + await act(async () => + root.render( + + + + ) + ) + expect(container.textContent).toContain('2 accounts connected') + expect(container.textContent).not.toContain('Copy new link') + expect(container.textContent).not.toContain('gmail: active') + await selectPersonAction('Resend') + expect(mocks.resend).toHaveBeenCalledExactlyOnceWith( + { organizationId: 'organization-1', enrollmentId: 'enrollment-1' }, + expect.objectContaining({ onSuccess: expect.any(Function) }) + ) +}) + +const cases = [ + { + label: 'Revoke', + component: , + mutation: mocks.revoke, + target: 'person@example.com', + input: { organizationId: 'organization-1', enrollmentId: 'enrollment-1' }, + }, + { + label: 'Disconnect', + component: , + mutation: mocks.disconnect, + target: 'Personal Gmail', + input: 'credential-1', + }, +] as const + +describe.each(cases)( + '$label organization account access', + ({ label, component, mutation, target, input }) => { + it('requires confirmation, allows cancellation, and never submits from an unfocused Enter', async () => { + await act(async () => + root.render({component}) + ) + await openConfirmation(label) + let dialog = document.querySelector('[role="dialog"]') + expect(dialog?.textContent).toContain(target) + expect(mutation).not.toHaveBeenCalled() + await act(async () => + dialog?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + ) + expect(mutation).not.toHaveBeenCalled() + if (!dialog) throw new Error('Missing confirmation dialog') + await act(async () => button(dialog, 'Cancel').click()) + expect(mutation).not.toHaveBeenCalled() + await openConfirmation(label) + dialog = document.querySelector('[role="dialog"]') + if (!dialog) throw new Error('Missing confirmation dialog') + await act(async () => button(dialog, label).click()) + expect(mutation).toHaveBeenCalledExactlyOnceWith( + input, + expect.objectContaining({ onSuccess: expect.any(Function) }) + ) + }) + } +) diff --git a/apps/sim/ee/credential-groups/components/organization-account-people.tsx b/apps/sim/ee/credential-groups/components/organization-account-people.tsx new file mode 100644 index 00000000000..f5272a91737 --- /dev/null +++ b/apps/sim/ee/credential-groups/components/organization-account-people.tsx @@ -0,0 +1,197 @@ +'use client' + +import { useState } from 'react' +import { Chip, ChipConfirmModal, ChipInput, ChipModalError, toast } from '@sim/emcn' +import { Plus, Search } from '@sim/emcn/icons' +import { useQueryState } from 'nuqs' +import { + credentialGroupPeopleSearchParam, + credentialGroupPeopleSearchUrlKeys, +} from '@/app/workspace/[workspaceId]/settings/[section]/search-params' +import { MemberAvatar } from '@/app/workspace/[workspaceId]/settings/components/member-list' +import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' +import { + SettingsEmptyState, + SettingsQueryErrorState, +} 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 { EnrollmentConnections } from '@/ee/credential-groups/components/credential-group-enrollment-connections' +import { OrganizationAccountInviteModal } from '@/ee/credential-groups/components/organization-account-invite-modal' +import { + useOrganizationAccountPeople, + useResendOrganizationAccountInvitation, + useRevokeOrganizationAccountEnrollment, +} from '@/hooks/queries/organization-accounts' +import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' + +interface OrganizationAccountPeopleProps { + organizationId: string +} +export function OrganizationAccountPeople({ organizationId }: OrganizationAccountPeopleProps) { + const people = useOrganizationAccountPeople(organizationId) + const resend = useResendOrganizationAccountInvitation() + const revoke = useRevokeOrganizationAccountEnrollment() + const [peopleSearch, setPeopleSearchParam] = useQueryState(credentialGroupPeopleSearchParam.key, { + ...credentialGroupPeopleSearchParam.parser, + ...credentialGroupPeopleSearchUrlKeys, + }) + const setPeopleSearch = useDebouncedSearchSetter(setPeopleSearchParam) + const [inviteOpen, setInviteOpen] = useState(false) + const [revokingPerson, setRevokingPerson] = useState<{ id: string; email: string } | null>(null) + const error = resend.error ?? revoke.error + const pending = resend.isPending || revoke.isPending + const enrollments = people.data?.pages.flatMap((page) => page.enrollments) ?? [] + const filter = peopleSearch.trim().toLowerCase() + const visibleEnrollments = filter + ? enrollments.filter((person) => person.email.toLowerCase().includes(filter)) + : enrollments + const loadedTotal = `${enrollments.length}${people.hasNextPage ? '+' : ''}` + const searchLabel = people.hasNextPage ? 'Search loaded people' : 'Search people' + const peopleLabel = filter + ? `People (${visibleEnrollments.length} of ${loadedTotal})` + : `People (${loadedTotal})` + if (people.error) + return ( + void people.refetch()} + /> + ) + return ( + + setPeopleSearch(event.target.value)} + disabled={people.isPending} + autoComplete='off' + className='w-full' + /> + {error && ( +

+ {error.message} +

+ )} + {!people.isPending && ( + + {people.hasNextPage && ( + void people.fetchNextPage()} + > + {people.isFetchingNextPage ? 'Loading...' : 'Load more'} + + )} + } + disabled={pending} + onClick={() => setInviteOpen(true)} + > + Request connections + +
+ } + > + {visibleEnrollments.length === 0 ? ( + + {filter + ? people.hasNextPage + ? 'No loaded people match your search. Load more to search additional people.' + : 'No people match your search' + : 'No people invited yet'} + + ) : ( +
+ {visibleEnrollments.map((person) => ( + } + iconVariant='custom' + title={person.email} + description={ + + } + trailing={ + + resend.mutate( + { organizationId, enrollmentId: person.id }, + { onSuccess: () => toast.success('Invitation sent') } + ), + }, + { + label: 'Revoke', + destructive: true, + disabled: pending || person.status === 'revoked', + onSelect: () => { + revoke.reset() + setRevokingPerson({ id: person.id, email: person.email }) + }, + }, + ]} + /> + } + /> + ))} +
+ )} + + )} + {inviteOpen && ( + setInviteOpen(false)} + /> + )} + {revokingPerson && ( + { + if (!open && !revoke.isPending) setRevokingPerson(null) + }} + title={`Revoke ${revokingPerson.email}`} + text='Organization workflows will no longer be able to use accounts contributed by this person.' + defaultAction='none' + confirm={{ + label: 'Revoke', + pendingLabel: 'Revoking…', + pending: revoke.isPending, + variant: 'destructive', + onClick: () => + revoke.mutate( + { organizationId, enrollmentId: revokingPerson.id }, + { + onSuccess: () => { + setRevokingPerson(null) + toast.success('Access revoked') + }, + } + ), + }} + > + {revoke.error?.message} + + )} + + ) +} diff --git a/apps/sim/ee/credential-groups/components/organization-account-provider-catalog.tsx b/apps/sim/ee/credential-groups/components/organization-account-provider-catalog.tsx new file mode 100644 index 00000000000..d9d42c05d6f --- /dev/null +++ b/apps/sim/ee/credential-groups/components/organization-account-provider-catalog.tsx @@ -0,0 +1,133 @@ +'use client' + +import { useState } from 'react' +import { + Chip, + ChipInput, + ChipModal, + ChipModalBody, + ChipModalError, + ChipModalField, + ChipModalHeader, +} from '@sim/emcn' +import { Search } from '@sim/emcn/icons' +import type { OrganizationAccountsSettings } from '@/lib/api/contracts/organization-accounts' +import { getManagedMcpConnectorIcon } from '@/lib/credential-groups/managed-mcp-connector-icons' +import { + MANAGED_MCP_CONNECTOR_IDS, + MANAGED_MCP_CONNECTORS, + type ManagedMcpConnectorId, +} from '@/lib/credential-groups/managed-mcp-connectors' +import { + type CredentialGroupProvider, + getCredentialGroupProviderService, +} from '@/lib/credential-groups/providers' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' + +export type OrganizationAccountProviderChoice = + | { kind: 'oauth'; provider: CredentialGroupProvider } + | { kind: 'mcp'; connectorId: ManagedMcpConnectorId } + +interface OrganizationAccountProviderCatalogProps { + group: NonNullable + availableProviders: CredentialGroupProvider[] + pending: boolean + error: string | undefined + onClose: () => void + onAdd: (choice: OrganizationAccountProviderChoice) => void +} + +export function OrganizationAccountProviderCatalog({ + group, + availableProviders, + pending, + error, + onClose, + onAdd, +}: OrganizationAccountProviderCatalogProps) { + const [search, setSearch] = useState('') + const query = search.trim().toLowerCase() + const providers = [ + ...availableProviders + .filter((provider) => !group.options.some((option) => option.provider === provider)) + .map((provider) => ({ + ...getCredentialGroupProviderService(provider), + choice: { kind: 'oauth', provider } as const, + })), + ...MANAGED_MCP_CONNECTOR_IDS.filter( + (id) => + !group.mcpServers.some( + (server) => server.managedConnectorId === id && (id !== 'databricks' || server.enabled) + ) + ).map((connectorId) => ({ + name: MANAGED_MCP_CONNECTORS[connectorId].name, + icon: getManagedMcpConnectorIcon(connectorId), + choice: { kind: 'mcp', connectorId } as const, + })), + ] + .filter((provider) => provider.name.toLowerCase().includes(query)) + .sort((left, right) => left.name.localeCompare(right.name)) + + return ( + { + if (!open && !pending) onClose() + }} + > + + Add provider + + + + setSearch(event.target.value)} + /> + + +
+
+ {providers.map(({ name, icon: Icon, choice }) => ( + } + title={name} + trailing={ + onAdd(choice)} + > + Add + + } + /> + ))} + {!providers.length && ( + + {query ? 'No matching providers.' : 'All available providers have been added.'} + + )} +
+
+
+ {error} +
+
+ ) +} diff --git a/apps/sim/ee/credential-groups/components/organization-account-providers.test.tsx b/apps/sim/ee/credential-groups/components/organization-account-providers.test.tsx new file mode 100644 index 00000000000..e3961080663 --- /dev/null +++ b/apps/sim/ee/credential-groups/components/organization-account-providers.test.tsx @@ -0,0 +1,387 @@ +/** @vitest-environment jsdom */ +import { act } from 'react' +import { toast } from '@sim/emcn' +import { NuqsTestingAdapter } from 'nuqs/adapters/testing' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + OrganizationAccountsSettings, + OrganizationDatabricksSetup, +} from '@/lib/api/contracts/organization-accounts' + +const mocks = vi.hoisted(() => ({ + add: vi.fn(), + addAsync: vi.fn(), + configure: vi.fn(), + setup: vi.fn(), + update: vi.fn(), + remove: vi.fn(), + reset: vi.fn(), + slack: vi.fn<(props: unknown) => null>(() => null), + addError: null as Error | null, +})) +vi.mock('@/hooks/queries/organization-accounts', () => ({ + useUpdateOrganizationAccounts: () => ({ + isPending: false, + mutate: mocks.update, + reset: mocks.reset, + }), + useAddOrganizationAccountMcpProvider: () => ({ + isPending: false, + mutate: mocks.add, + mutateAsync: mocks.addAsync, + reset: mocks.reset, + error: mocks.addError, + }), + useRemoveOrganizationAccountMcpProvider: () => ({ + isPending: false, + mutate: mocks.remove, + reset: mocks.reset, + }), + useConfigureOrganizationMcp: () => ({ isPending: false, mutateAsync: mocks.configure }), + useOrganizationDatabricksSetup: mocks.setup, +})) +vi.mock('@/ee/credential-groups/components/slack-managed-users-modal', () => ({ + SlackManagedUsersModal: mocks.slack, +})) + +import { OrganizationAccountProviders } from '@/ee/credential-groups/components/organization-account-providers' + +const group: NonNullable = { + id: 'group-1', + organizationId: 'org-1', + workspaceId: null, + name: 'Connected accounts', + description: null, + options: [], + mcpServers: [], + status: 'active', + createdAt: '2026-09-07T00:00:00Z', + updatedAt: '2026-09-07T00:00:00Z', +} +const setupServer: OrganizationDatabricksSetup['server'] = { + id: 'server-1', + name: 'Databricks', + url: null, + oauthClientId: null, + hasOauthClientSecret: false, + enabled: false, +} +const provider = { + id: 'server-1', + name: 'Databricks', + description: null, + authType: 'oauth', + enabled: false, + managedConnectorId: 'databricks' as const, +} +const gmail: NonNullable['options'][number] = { + id: 'gmail-option', + provider: 'gmail', + label: 'Gmail', + required: false, + status: 'active', + configurationStatus: 'ready', +} + +describe('organization provider configuration UI', () => { + let root: Root + let container: HTMLDivElement + + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + vi.spyOn(toast, 'success').mockImplementation(() => '') + vi.spyOn(toast, 'error').mockImplementation(() => '') + mocks.setup.mockReturnValue({ data: { server: setupServer }, isPending: false, error: null }) + mocks.configure.mockResolvedValue({ mcpServer: { ...provider, enabled: true } }) + mocks.addAsync.mockResolvedValue({ mcpServer: { ...provider, enabled: true } }) + mocks.add.mockImplementation((_input, { onSuccess }) => onSuccess()) + mocks.update.mockImplementation((_input, { onSuccess }) => onSuccess()) + mocks.addError = null + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + async function render( + mcpServers: NonNullable['mcpServers'] = [ + { ...provider, enabled: true }, + ], + options: NonNullable['options'] = [] + ) { + await act(async () => + root.render( + + + + ) + ) + } + async function clickButton(text: string) { + const button = Array.from(document.querySelectorAll('button')).find( + (node) => node.textContent === text || node.getAttribute('aria-label') === text + ) + expect(button).toBeDefined() + expect(button?.disabled).toBe(false) + await act(async () => button?.click()) + } + async function fill(labelText: string, value: string) { + const label = Array.from(document.querySelectorAll('label')).find((node) => + node.textContent?.startsWith(labelText) + ) + const input = label?.control + expect(input).toBeInstanceOf(HTMLInputElement) + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, value) + input?.dispatchEvent(new Event('input', { bubbles: true })) + }) + } + + it('shows only added providers and searches the remaining catalog', async () => { + await render([], [gmail]) + expect(container.textContent).toContain('Gmail') + expect(container.textContent).not.toContain('Indexing') + expect(container.textContent).not.toContain('Configure') + expect(container.textContent).not.toMatch(/Ready|Setup required/) + expect(container.textContent).not.toContain('Fireflies') + expect(container.querySelector('[role="radio"]')).toBeNull() + await clickButton('Add provider') + expect(document.querySelector('[aria-label="Add Gmail"]')).toBeNull() + const search = document.querySelector('[aria-label="Search providers"]') + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call( + search, + 'fire' + ) + search?.dispatchEvent(new Event('input', { bubbles: true })) + }) + expect(document.querySelector('[aria-label="Add Fireflies"]')).not.toBeNull() + expect(document.querySelector('[aria-label="Add Databricks"]')).toBeNull() + }) + + it('adds Fireflies directly without an empty configuration modal', async () => { + await render([]) + await clickButton('Add provider') + await clickButton('Add Fireflies') + expect(mocks.add).toHaveBeenCalledWith( + { organizationId: 'org-1', connectorId: 'fireflies' }, + expect.any(Object) + ) + expect(document.querySelector('[role="dialog"]')).toBeNull() + await render([ + { ...provider, managedConnectorId: 'fireflies', name: 'Fireflies', enabled: true }, + ]) + expect(container.textContent).toContain('Fireflies') + expect(container.textContent).not.toContain('Configure') + expect(container.textContent).not.toMatch(/Ready|Setup required/) + }) + + it('adds Gmail directly without opening indexing configuration', async () => { + await render([]) + await clickButton('Add provider') + await clickButton('Add Gmail') + expect(mocks.update).toHaveBeenCalledWith( + { + organizationId: 'org-1', + groupId: 'group-1', + update: { options: [{ provider: 'gmail', label: 'Gmail', required: false }] }, + }, + expect.any(Object) + ) + await render([], [gmail]) + expect(document.querySelector('[role="dialog"]')).toBeNull() + expect(container.textContent).not.toContain('Configure') + expect(container.textContent).not.toContain('Indexing') + }) + + it('opens Slack app configuration directly with the existing scopes', async () => { + await render( + [], + [ + { + ...gmail, + id: 'slack-option', + provider: 'slack', + label: 'Slack', + requiredScopes: ['search:read'], + }, + ] + ) + expect(mocks.slack).not.toHaveBeenCalled() + await clickButton('Configure') + expect(mocks.slack.mock.lastCall?.[0]).toMatchObject({ + open: true, + organizationId: 'org-1', + credentialGroupId: 'group-1', + initialRequiredScopes: ['search:read'], + }) + }) + + it('surfaces an add failure in the catalog and does not open configuration', async () => { + mocks.add.mockImplementation(() => {}) + await render([]) + await clickButton('Add provider') + await clickButton('Add Fireflies') + mocks.addError = new Error('Could not add Fireflies') + await render([]) + expect(document.querySelector('[role="dialog"]')?.textContent).toContain( + 'Could not add Fireflies' + ) + expect(mocks.setup).not.toHaveBeenCalled() + }) + + it('removes a provider through the row menu using organization scope', async () => { + mocks.remove.mockImplementation((_input, { onSuccess }) => onSuccess()) + await render() + const trigger = document.querySelector('[aria-label="Databricks actions"]') + await act(async () => + trigger?.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 })) + ) + const remove = document.querySelector('[role="menuitem"]') + expect(remove?.textContent).toBe('Remove') + await act(async () => remove?.click()) + expect(mocks.remove).not.toHaveBeenCalled() + await clickButton('Remove') + expect(mocks.remove).toHaveBeenCalledWith( + { organizationId: 'org-1', connectorId: 'databricks' }, + expect.any(Object) + ) + expect(document.querySelector('[role="dialog"]')).toBeNull() + }) + + it('returns an unfinished Databricks entry to the catalog until its configuration is saved', async () => { + await render([provider]) + expect(container.textContent).not.toContain('Databricks') + await clickButton('Add provider') + await clickButton('Add Databricks') + expect(mocks.add).not.toHaveBeenCalled() + expect(mocks.addAsync).not.toHaveBeenCalled() + expect(mocks.setup).toHaveBeenCalledWith('org-1', true) + expect(document.querySelector('[role="dialog"]')?.textContent).toContain('Add Databricks') + await fill('MCP URL', 'https://tenant.cloud.databricks.com/api/2.0/mcp/sql') + await fill('OAuth Client ID', 'client-1') + await clickButton('Add') + expect(mocks.configure).toHaveBeenCalledWith({ + organizationId: 'org-1', + name: 'Databricks', + url: 'https://tenant.cloud.databricks.com/api/2.0/mcp/sql', + oauthClientId: 'client-1', + }) + expect(document.querySelector('[role="dialog"]')).toBeNull() + }) + + it('cancels Databricks setup without adding a provider', async () => { + await render([]) + await clickButton('Add provider') + await clickButton('Add Databricks') + expect(mocks.setup).toHaveBeenCalledWith('org-1', false) + expect(document.querySelector('[role="dialog"]')?.textContent).toContain('Add Databricks') + const add = Array.from(document.querySelectorAll('button')).find( + (node) => node.textContent === 'Add' + ) + expect(add?.disabled).toBe(true) + await fill('MCP URL', 'https://tenant.cloud.databricks.com/api/2.0/mcp/sql') + await fill('OAuth Client ID', 'client-1') + await clickButton('Cancel') + expect(mocks.add).not.toHaveBeenCalled() + expect(mocks.addAsync).not.toHaveBeenCalled() + expect(mocks.configure).not.toHaveBeenCalled() + expect(document.querySelector('[role="dialog"]')).toBeNull() + expect(container.textContent).not.toContain('Databricks') + }) + + it('adds Databricks with its complete configuration in one organization-scoped request', async () => { + await render([]) + await clickButton('Add provider') + await clickButton('Add Databricks') + expect(mocks.addAsync).not.toHaveBeenCalled() + await fill('Name', ' Analytics ') + await fill('MCP URL', 'https://tenant.cloud.databricks.com/api/2.0/mcp/sql') + await fill('OAuth Client ID', ' client-1 ') + await fill('OAuth Client Secret', 'secret-1') + await clickButton('Add') + expect(mocks.addAsync).toHaveBeenCalledExactlyOnceWith({ + organizationId: 'org-1', + connectorId: 'databricks', + name: 'Analytics', + url: 'https://tenant.cloud.databricks.com/api/2.0/mcp/sql', + oauthClientId: 'client-1', + oauthClientSecret: 'secret-1', + }) + expect(mocks.add).not.toHaveBeenCalled() + expect(mocks.configure).not.toHaveBeenCalled() + expect(document.querySelector('[role="dialog"]')).toBeNull() + }) + + it('keeps Databricks configuration open after validation fails and allows correction', async () => { + mocks.addAsync.mockRejectedValueOnce(new Error('Enter a valid Databricks MCP URL')) + await render([]) + await clickButton('Add provider') + await clickButton('Add Databricks') + await fill('MCP URL', 'https://invalid.example.com/mcp') + await fill('OAuth Client ID', 'client-1') + await clickButton('Add') + expect(toast.error).toHaveBeenCalledWith('Enter a valid Databricks MCP URL') + expect(document.querySelector('[role="dialog"]')?.textContent).toContain('Add Databricks') + expect(container.textContent).not.toContain('Databricks') + await fill('MCP URL', 'https://tenant.cloud.databricks.com/api/2.0/mcp/sql') + await clickButton('Add') + expect(mocks.addAsync).toHaveBeenCalledTimes(2) + expect(document.querySelector('[role="dialog"]')).toBeNull() + }) + + it('loads existing settings for Configure and preserves a saved secret left blank', async () => { + mocks.setup.mockReturnValue({ + data: { + server: { + ...setupServer, + enabled: true, + url: 'https://tenant.cloud.databricks.com/api/2.0/mcp/sql', + oauthClientId: 'existing-client', + hasOauthClientSecret: true, + }, + }, + isPending: false, + error: null, + }) + await render([{ ...provider, enabled: true }]) + await clickButton('Configure') + const secret = document.querySelector( + 'input[placeholder="Leave blank to keep the current secret"]' + ) + expect(secret).not.toBeNull() + expect(secret?.value).toBe('') + await clickButton('Save') + expect(mocks.configure.mock.calls[0][0]).not.toHaveProperty('oauthClientSecret') + expect(mocks.configure.mock.calls[0][0]).not.toHaveProperty('workspaceId') + }) + + it('shows setup read failures and disables saving', async () => { + mocks.setup.mockReturnValue({ + data: undefined, + isPending: false, + error: new Error('Organization administrator access is required'), + }) + await render() + await clickButton('Configure') + expect(document.querySelector('[role="dialog"]')?.textContent).toContain( + 'Organization administrator access is required' + ) + const save = Array.from(document.querySelectorAll('button')).find( + (node) => node.textContent === 'Save' + ) + expect(save?.disabled).toBe(true) + expect(mocks.configure).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/ee/credential-groups/components/organization-account-providers.tsx b/apps/sim/ee/credential-groups/components/organization-account-providers.tsx new file mode 100644 index 00000000000..f5cce773c30 --- /dev/null +++ b/apps/sim/ee/credential-groups/components/organization-account-providers.tsx @@ -0,0 +1,286 @@ +'use client' + +import { useState } from 'react' +import { + Chip, + ChipModal, + ChipModalBody, + ChipModalError, + ChipModalFooter, + ChipModalHeader, + toast, +} from '@sim/emcn' +import { Plus } from '@sim/emcn/icons' +import { getErrorMessage } from '@sim/utils/errors' +import type { + OrganizationAccountsSettings, + UpdateOrganizationAccountsBody, +} from '@/lib/api/contracts/organization-accounts' +import { getManagedMcpConnectorIcon } from '@/lib/credential-groups/managed-mcp-connector-icons' +import { MANAGED_MCP_CONNECTORS } from '@/lib/credential-groups/managed-mcp-connectors' +import { + type CredentialGroupProvider, + getCredentialGroupProviderService, +} from '@/lib/credential-groups/providers' +import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +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 { DatabricksMcpConnectorModal } from '@/ee/credential-groups/components/databricks-mcp-connector-modal' +import { + OrganizationAccountProviderCatalog, + type OrganizationAccountProviderChoice, +} from '@/ee/credential-groups/components/organization-account-provider-catalog' +import { SlackManagedUsersModal } from '@/ee/credential-groups/components/slack-managed-users-modal' +import { + useAddOrganizationAccountMcpProvider, + useRemoveOrganizationAccountMcpProvider, + useUpdateOrganizationAccounts, +} from '@/hooks/queries/organization-accounts' + +interface OrganizationAccountProvidersProps { + organizationId: string + group: NonNullable + availableProviders: CredentialGroupProvider[] +} + +export function OrganizationAccountProviders({ + organizationId, + group, + availableProviders, +}: OrganizationAccountProvidersProps) { + const [catalogOpen, setCatalogOpen] = useState(false) + const [removing, setRemoving] = useState(null) + const [slackOpen, setSlackOpen] = useState(false) + const [databricksOpen, setDatabricksOpen] = useState(false) + const update = useUpdateOrganizationAccounts() + const addMcp = useAddOrganizationAccountMcpProvider() + const removeMcp = useRemoveOrganizationAccountMcpProvider() + const pending = update.isPending || addMcp.isPending || removeMcp.isPending + const options: NonNullable = group.options.map( + (option) => { + const common = { id: option.id, label: option.label, required: option.required } + return option.provider === 'slack' + ? { ...common, provider: 'slack', requiredScopes: option.requiredScopes } + : { ...common, provider: option.provider } + } + ) + const addProvider = (choice: OrganizationAccountProviderChoice) => { + if (choice.kind === 'mcp') { + if (choice.connectorId === 'databricks') { + setCatalogOpen(false) + setDatabricksOpen(true) + return + } + addMcp.mutate( + { organizationId, connectorId: choice.connectorId }, + { + onSuccess: () => { + setCatalogOpen(false) + toast.success(`${MANAGED_MCP_CONNECTORS[choice.connectorId].name} added`) + }, + } + ) + return + } + const { provider } = choice + if (provider === 'slack') { + setCatalogOpen(false) + setSlackOpen(true) + return + } + const service = getCredentialGroupProviderService(provider) + update.mutate( + { + organizationId, + groupId: group.id, + update: { options: [...options, { provider, label: service.name, required: false }] }, + }, + { + onSuccess: () => { + setCatalogOpen(false) + toast.success(`${service.name} added`) + }, + } + ) + } + const removeProvider = () => { + if (!removing || pending) return + const onSuccess = () => { + setRemoving(null) + toast.success('Provider removed') + } + if (removing.kind === 'mcp') { + removeMcp.mutate({ organizationId, connectorId: removing.connectorId }, { onSuccess }) + } else { + update.mutate( + { + organizationId, + groupId: group.id, + update: { options: options.filter((option) => option.provider !== removing.provider) }, + }, + { onSuccess } + ) + } + } + const rows = [ + ...group.options.map((option) => { + const service = getCredentialGroupProviderService(option.provider) + return { + id: option.id, + name: service.name, + icon: service.icon, + configure: option.provider === 'slack' ? () => setSlackOpen(true) : undefined, + choice: { kind: 'oauth', provider: option.provider } as const, + } + }), + ...group.mcpServers + .filter((server) => server.managedConnectorId !== 'databricks' || server.enabled) + .map((server) => ({ + id: server.id, + name: MANAGED_MCP_CONNECTORS[server.managedConnectorId].name, + icon: getManagedMcpConnectorIcon(server.managedConnectorId), + configure: + server.managedConnectorId === 'databricks' ? () => setDatabricksOpen(true) : undefined, + choice: { kind: 'mcp', connectorId: server.managedConnectorId } as const, + })), + ].sort((left, right) => left.name.localeCompare(right.name)) + const error = update.error ?? addMcp.error ?? removeMcp.error + const removingName = removing + ? removing.kind === 'oauth' + ? getCredentialGroupProviderService(removing.provider).name + : MANAGED_MCP_CONNECTORS[removing.connectorId].name + : '' + + return ( +
+ } + disabled={pending} + onClick={() => { + update.reset() + addMcp.reset() + removeMcp.reset() + setCatalogOpen(true) + }} + > + Add provider + + } + > +
+ {rows.map(({ id, name, icon: Icon, configure, choice }) => ( + } + title={name} + trailing={ +
+ {configure && ( + + Configure + + )} + { + update.reset() + addMcp.reset() + removeMcp.reset() + setRemoving(choice) + }, + }, + ]} + /> +
+ } + /> + ))} + {!rows.length && ( + + Add a provider to start connecting accounts. + + )} +
+
+ {catalogOpen && ( + setCatalogOpen(false)} + onAdd={addProvider} + /> + )} + {databricksOpen && ( + server.managedConnectorId === 'databricks' + )} + onOpenChange={setDatabricksOpen} + /> + )} + {slackOpen && ( + option.provider === 'slack')?.requiredScopes + } + /> + )} + {removing && ( + { + if (!open && !pending) setRemoving(null) + }} + > + setRemoving(null)} closeDisabled={pending}> + Remove {removingName} + + +

+ Remove this provider and its connected accounts from the organization? +

+ {error ? getErrorMessage(error) : null} +
+ setRemoving(null)} + cancelDisabled={pending} + primaryAction={{ + label: pending ? 'Removing…' : 'Remove', + variant: 'destructive', + disabled: pending, + onClick: removeProvider, + }} + /> +
+ )} +
+ ) +} diff --git a/apps/sim/ee/credential-groups/components/organization-account-workspace-access.test.tsx b/apps/sim/ee/credential-groups/components/organization-account-workspace-access.test.tsx new file mode 100644 index 00000000000..c8d8823f6c6 --- /dev/null +++ b/apps/sim/ee/credential-groups/components/organization-account-workspace-access.test.tsx @@ -0,0 +1,271 @@ +/** + * @vitest-environment jsdom + */ + +import { act, type ComponentProps, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { SettingsAction } from '@/components/settings/settings-header' +import type { + UpdateOrganizationAccountWorkspaceAccessBody, + OrganizationAccountWorkspaceAccess as WorkspaceAccess, +} from '@/lib/api/contracts/organization-accounts' +import type { CredentialGroupAddResourceModal } from '@/ee/credential-groups/components/credential-group-add-resource-modal' + +const mocks = vi.hoisted(() => ({ + useAccess: vi.fn(), + mutateAsync: vi.fn(), + reset: vi.fn(), + mutationError: null as Error | null, + isPending: false, + modal: null as ComponentProps | null, + toastError: vi.fn(), + toastSuccess: vi.fn(), +})) + +vi.mock('@sim/emcn', () => ({ + Chip: ({ children, onClick, disabled }: ComponentProps<'button'>) => ( + + ), + toast: { error: mocks.toastError, success: mocks.toastSuccess }, +})) +vi.mock('@sim/emcn/icons', () => ({ Workspaces: () => null })) +vi.mock('@/hooks/queries/organization-accounts', () => ({ + useOrganizationAccountWorkspaceAccess: mocks.useAccess, + useUpdateOrganizationAccountWorkspaceAccess: () => ({ + mutateAsync: mocks.mutateAsync, + reset: mocks.reset, + error: mocks.mutationError, + isPending: mocks.isPending, + }), +})) +vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-panel', () => ({ + SettingsPanel: ({ + actions = [], + children, + }: { + actions?: SettingsAction[] + children: ReactNode + }) => ( +
+ {actions.map((action) => ( + + ))} + {children} +
+ ), +})) +vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-resource-row', () => ({ + RESOURCE_LIST_STACK: '', + SettingsResourceRow: ({ title, trailing }: { title: string; trailing: ReactNode }) => ( +
+ {title} + {trailing} +
+ ), +})) +vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-empty-state', () => ({ + SettingsEmptyState: ({ children }: { children: ReactNode }) =>
{children}
, + SettingsQueryErrorState: ({ error }: { error: Error }) =>
{error.message}
, +})) +vi.mock( + '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section', + () => ({ + SettingsSection: ({ + label, + action, + children, + }: { + label: string + action: ReactNode + children: ReactNode + }) => ( +
+

{label}

+ {action} + {children} +
+ ), + }) +) +vi.mock('@/app/workspace/[workspaceId]/settings/components/row-actions-menu', () => ({ + RowActionsMenu: ({ actions }: { actions: Array<{ label: string; onSelect: () => void }> }) => ( +
+ {actions.map((action) => ( + + ))} +
+ ), +})) +vi.mock('@/ee/credential-groups/components/credential-group-add-resource-modal', () => ({ + CredentialGroupAddResourceModal: ( + props: ComponentProps + ) => { + mocks.modal = props + return
Add workspaces modal
+ }, +})) + +import { OrganizationAccountWorkspaceAccess } from '@/ee/credential-groups/components/organization-account-workspace-access' + +const WORKSPACES = [ + { id: 'workspace-1', name: 'Finance' }, + { id: 'workspace-2', name: 'Support' }, + { id: 'workspace-3', name: 'Sales' }, +] +const mountedRoots: Root[] = [] + +function setAccess(workspaceIds = ['workspace-1'], revision = 3) { + mocks.useAccess.mockReturnValue({ + data: { workspaceIds, revision, workspaces: WORKSPACES } satisfies WorkspaceAccess, + error: null, + }) +} + +function renderAccess() { + const container = document.createElement('div') + const root = createRoot(container) + mountedRoots.push(root) + const rerender = () => + act(() => root.render()) + const button = (label: string, scope: ParentNode = container) => { + const match = [...scope.querySelectorAll('button')].find( + (candidate) => candidate.textContent === label + ) + if (!match) throw new Error(`Button ${label} not found`) + return match + } + const add = async (ids: string[]) => { + act(() => button('Add workspaces').click()) + await act(async () => { + if (mocks.modal?.resourceType !== 'workspace') + throw new Error('Workspace picker is unavailable') + mocks.modal.onAdd(ids) + }) + } + const rows = () => + [...container.querySelectorAll('[data-workspace]')].map((row) => + row.getAttribute('data-workspace') + ) + rerender() + return { container, rerender, button, add, rows } +} + +beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + vi.clearAllMocks() + mocks.mutationError = null + mocks.isPending = false + mocks.modal = null + setAccess() + mocks.mutateAsync.mockImplementation( + async (input: UpdateOrganizationAccountWorkspaceAccessBody) => { + setAccess(input.workspaceIds, input.revision + 1) + return { workspaceIds: input.workspaceIds, revision: input.revision + 1 } + } + ) +}) + +afterEach(() => { + act(() => { + for (const root of mountedRoots.splice(0)) root.unmount() + }) +}) + +it('adds multiple workspaces immediately without Save or Discard actions', async () => { + const editor = renderAccess() + expect(editor.rows()).toEqual(['Finance']) + expect(editor.container.textContent).not.toMatch(/Save|Discard/) + + await editor.add(['workspace-3', 'workspace-2']) + editor.rerender() + + expect(mocks.modal?.resources).toEqual(WORKSPACES.slice(1)) + expect(mocks.mutateAsync).toHaveBeenCalledExactlyOnceWith({ + organizationId: 'org-1', + revision: 3, + workspaceIds: ['workspace-1', 'workspace-3', 'workspace-2'], + }) + expect(editor.rows()).toEqual(['Finance', 'Support', 'Sales']) + expect(editor.container.textContent).not.toContain('Add workspaces modal') + expect(editor.container.textContent).not.toMatch(/Save|Discard/) +}) + +it('removes workspace access directly from the row action', async () => { + setAccess(['workspace-1', 'workspace-2']) + const editor = renderAccess() + const finance = editor.container.querySelector('[data-workspace="Finance"]') + if (!finance) throw new Error('Finance row not found') + await act(async () => editor.button('Remove', finance).click()) + editor.rerender() + + expect(mocks.mutateAsync).toHaveBeenCalledExactlyOnceWith({ + organizationId: 'org-1', + revision: 3, + workspaceIds: ['workspace-2'], + }) + expect(editor.rows()).toEqual(['Support']) +}) + +it('does not change access when the add picker is cancelled', () => { + const editor = renderAccess() + act(() => editor.button('Add workspaces').click()) + act(() => mocks.modal?.onClose()) + + expect(mocks.mutateAsync).not.toHaveBeenCalled() + expect(editor.rows()).toEqual(['Finance']) + expect(editor.container.textContent).not.toContain('Add workspaces modal') +}) + +it('keeps the picker open and reports failed additions without changing the list', async () => { + const conflict = new Error('Workspace access changed while it was edited') + mocks.mutateAsync.mockImplementation(async () => { + mocks.mutationError = conflict + throw conflict + }) + const editor = renderAccess() + await editor.add(['workspace-2', 'workspace-3']) + editor.rerender() + + expect(mocks.mutateAsync).toHaveBeenCalledOnce() + expect(editor.rows()).toEqual(['Finance']) + expect(editor.container.textContent).toContain('Add workspaces modal') + expect(mocks.modal?.error).toBe(conflict.message) + expect(mocks.toastError).toHaveBeenCalledWith(conflict.message) + expect(mocks.toastSuccess).not.toHaveBeenCalled() +}) + +it('disables access changes while a removal is in flight and preserves the row on failure', async () => { + let fail: ((error: Error) => void) | undefined + mocks.mutateAsync.mockImplementation(() => { + mocks.isPending = true + return new Promise((_, reject) => { + fail = reject + }) + }) + const editor = renderAccess() + act(() => editor.button('Remove').click()) + editor.rerender() + + expect(editor.button('Add workspaces').disabled).toBe(true) + expect(editor.rows()).toEqual(['Finance']) + expect(editor.container.textContent).not.toContain('Remove') + + const error = new Error('Could not remove workspace access') + await act(async () => { + if (!fail) throw new Error('Request rejection is unavailable') + mocks.isPending = false + mocks.mutationError = error + fail(error) + }) + editor.rerender() + expect(editor.rows()).toEqual(['Finance']) + expect(editor.button('Remove').disabled).toBe(false) + expect(editor.container.textContent).toContain(error.message) +}) diff --git a/apps/sim/ee/credential-groups/components/organization-account-workspace-access.tsx b/apps/sim/ee/credential-groups/components/organization-account-workspace-access.tsx new file mode 100644 index 00000000000..ac14e9c7570 --- /dev/null +++ b/apps/sim/ee/credential-groups/components/organization-account-workspace-access.tsx @@ -0,0 +1,167 @@ +'use client' + +import { useState } from 'react' +import { Chip, toast } from '@sim/emcn' +import { Workspaces } from '@sim/emcn/icons' +import { getErrorMessage } from '@sim/utils/errors' +import type { OrganizationAccountWorkspaceAccess as WorkspaceAccess } from '@/lib/api/contracts/organization-accounts' +import { ORGANIZATION_ACCOUNT_WORKSPACE_LIMIT } from '@/lib/credential-groups/limits' +import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' +import { + SettingsEmptyState, + SettingsQueryErrorState, +} 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 { CredentialGroupAddResourceModal } from '@/ee/credential-groups/components/credential-group-add-resource-modal' +import { + useOrganizationAccountWorkspaceAccess, + useUpdateOrganizationAccountWorkspaceAccess, +} from '@/hooks/queries/organization-accounts' + +interface OrganizationAccountWorkspaceAccessProps { + organizationId: string +} + +export function OrganizationAccountWorkspaceAccess({ + organizationId, +}: OrganizationAccountWorkspaceAccessProps) { + const access = useOrganizationAccountWorkspaceAccess(organizationId) + if (access.error) + return ( + void access.refetch()} + /> + ) + if (!access.data) return null + return ( + + ) +} + +interface WorkspaceAccessFormProps extends OrganizationAccountWorkspaceAccessProps { + access: WorkspaceAccess +} + +function WorkspaceAccessForm({ organizationId, access }: WorkspaceAccessFormProps) { + const update = useUpdateOrganizationAccountWorkspaceAccess() + const [showAddWorkspace, setShowAddWorkspace] = useState(false) + + const selectedIds = access.workspaceIds + const selected = new Set(selectedIds) + const workspacesById = new Map(access.workspaces.map((workspace) => [workspace.id, workspace])) + if (selected.size !== selectedIds.length) + throw new Error('Workspace access contains duplicate workspaces') + for (const id of selectedIds) { + if (!workspacesById.has(id)) + throw new Error(`Workspace access references unavailable workspace ${id}`) + } + const allowedWorkspaces = access.workspaces.filter((workspace) => selected.has(workspace.id)) + const availableWorkspaces = access.workspaces.filter((workspace) => !selected.has(workspace.id)) + + const updateAccess = async (workspaceIds: string[]) => { + try { + await update.mutateAsync({ + organizationId, + revision: access.revision, + workspaceIds, + }) + setShowAddWorkspace(false) + toast.success('Workspace access updated') + } catch (error) { + toast.error(getErrorMessage(error, 'Could not update workspace access')) + } + } + + return ( + + { + update.reset() + setShowAddWorkspace(true) + }} + disabled={ + update.isPending || + availableWorkspaces.length === 0 || + selectedIds.length >= ORGANIZATION_ACCOUNT_WORKSPACE_LIMIT + } + > + Add workspaces + + } + > + {update.error && ( +

+ {update.error.message} +

+ )} + {allowedWorkspaces.length === 0 ? ( + No workspaces have access + ) : ( +
+ {allowedWorkspaces.map((workspace) => ( + } + iconFilled + title={workspace.name} + description='Authorized workflows can use every connected account in this organization' + disabled={update.isPending} + trailing={ + update.isPending ? undefined : ( + + void updateAccess(selectedIds.filter((id) => id !== workspace.id)), + }, + ]} + /> + ) + } + /> + ))} +
+ )} +
+ {showAddWorkspace && ( + { + for (const id of ids) { + if (!workspacesById.has(id)) throw new Error(`Workspace ${id} is unavailable`) + if (selected.has(id)) throw new Error(`Workspace ${id} already has access`) + } + if (selectedIds.length + ids.length > ORGANIZATION_ACCOUNT_WORKSPACE_LIMIT) { + throw new Error( + `Workspace access cannot exceed ${ORGANIZATION_ACCOUNT_WORKSPACE_LIMIT} workspaces` + ) + } + void updateAccess([...selectedIds, ...ids]) + }} + onClose={() => setShowAddWorkspace(false)} + /> + )} +
+ ) +} diff --git a/apps/sim/ee/credential-groups/components/organization-connected-accounts.tsx b/apps/sim/ee/credential-groups/components/organization-connected-accounts.tsx new file mode 100644 index 00000000000..da3eef33092 --- /dev/null +++ b/apps/sim/ee/credential-groups/components/organization-connected-accounts.tsx @@ -0,0 +1,87 @@ +'use client' + +import { Chip, ChipSwitch } from '@sim/emcn' +import { parseAsStringLiteral, useQueryState } from 'nuqs' +import { SettingsQueryErrorState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { OrganizationAccountPeople } from '@/ee/credential-groups/components/organization-account-people' +import { OrganizationAccountProviders } from '@/ee/credential-groups/components/organization-account-providers' +import { OrganizationAccountWorkspaceAccess } from '@/ee/credential-groups/components/organization-account-workspace-access' +import { + useEnsureOrganizationAccounts, + useOrganizationAccounts, +} from '@/hooks/queries/organization-accounts' + +const TABS = ['providers', 'people', 'workspace-access'] as const +interface OrganizationConnectedAccountsProps { + organizationId: string +} + +export function OrganizationConnectedAccounts({ + organizationId, +}: OrganizationConnectedAccountsProps) { + const accounts = useOrganizationAccounts(organizationId) + const ensure = useEnsureOrganizationAccounts() + const [tab, setTab] = useQueryState('tab', parseAsStringLiteral(TABS).withDefault('providers')) + const error = accounts.error ?? ensure.error + if (error) + return ( + { + ensure.reset() + void accounts.refetch() + }} + /> + ) + if (!accounts.data) + return

Loading connected accounts…

+ if (!accounts.data.canManage) + return

An organization admin manages connected accounts.

+ const group = accounts.data.credentialGroup + if (!group) + return ( +
+

+ Set up one shared account pool for your organization. Workspaces have no access until you + allow them. +

+
+ ensure.mutate({ organizationId })} + > + Set up connected accounts + +
+
+ ) + return ( +
+
+ void setTab(value)} + options={[ + { value: 'providers', label: 'Providers' }, + { value: 'people', label: 'People' }, + { value: 'workspace-access', label: 'Workspace access' }, + ]} + /> +
+ {tab === 'providers' && ( + + )} + {tab === 'people' && } + {tab === 'workspace-access' && ( + + )} +
+ ) +} diff --git a/apps/sim/ee/credential-groups/components/personal-organization-accounts.tsx b/apps/sim/ee/credential-groups/components/personal-organization-accounts.tsx new file mode 100644 index 00000000000..0d21c7c54b1 --- /dev/null +++ b/apps/sim/ee/credential-groups/components/personal-organization-accounts.tsx @@ -0,0 +1,133 @@ +'use client' + +import { useState } from 'react' +import { Chip, ChipConfirmModal, ChipModalError, ChipTag, toast } from '@sim/emcn' +import { SettingsQueryErrorState } 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 { + useDisconnectPersonalOrganizationAccount, + usePersonalOrganizationAccounts, + useReconnectPersonalOrganizationAccount, +} from '@/hooks/queries/organization-accounts' + +export function PersonalOrganizationAccounts() { + const accounts = usePersonalOrganizationAccounts() + const reconnect = useReconnectPersonalOrganizationAccount() + const disconnect = useDisconnectPersonalOrganizationAccount() + const [disconnectingId, setDisconnectingId] = useState(null) + const disconnectingAccount = accounts.data?.pages + .flatMap((page) => page.accounts) + .find((account) => account.credentialId === disconnectingId) + const pending = reconnect.isPending || disconnect.isPending + const error = reconnect.error ?? disconnect.error + return ( + +
+

+ These are accounts you contributed to organizations. Disconnecting stops their workflows + from using that account. Organization admins control which workspaces can use the shared + pool. +

+ {error && ( +

+ {error.message} +

+ )} + {accounts.error ? ( + void accounts.refetch()} + /> + ) : accounts.isPending ? ( +

Loading your accounts…

+ ) : ( + accounts.data?.pages + .flatMap((page) => page.accounts) + .map((account) => ( + + {account.enrollmentStatus === 'revoked' ? 'Access revoked' : account.status} + + } + trailing={ +
+ + reconnect.mutate(account.credentialId, { + onSuccess: ({ invitationLink }) => { + window.location.assign(invitationLink) + }, + }) + } + > + Reconnect + + { + disconnect.reset() + setDisconnectingId(account.credentialId) + }} + > + Disconnect + +
+ } + /> + )) + )} + {accounts.data?.pages[0]?.accounts.length === 0 && ( +

+ You haven’t contributed accounts to an organization yet. Use your invitation link to get + started. +

+ )} + {accounts.hasNextPage && ( +
+ void accounts.fetchNextPage()} + > + Load more + +
+ )} + {disconnectingAccount && ( + { + if (!open && !disconnect.isPending) setDisconnectingId(null) + }} + title={`Disconnect ${disconnectingAccount.displayName}`} + text={`${disconnectingAccount.organizationName} workflows will no longer be able to use this account.`} + defaultAction='none' + confirm={{ + label: 'Disconnect', + pendingLabel: 'Disconnecting…', + pending: disconnect.isPending, + variant: 'destructive', + onClick: () => + disconnect.mutate(disconnectingAccount.credentialId, { + onSuccess: () => { + setDisconnectingId(null) + toast.success('Account disconnected') + }, + }), + }} + > + {disconnect.error?.message} + + )} +
+
+ ) +} diff --git a/apps/sim/ee/credential-groups/components/slack-managed-users-access.test.tsx b/apps/sim/ee/credential-groups/components/slack-managed-users-access.test.tsx index bc4d0678c66..bf4826e6ab4 100644 --- a/apps/sim/ee/credential-groups/components/slack-managed-users-access.test.tsx +++ b/apps/sim/ee/credential-groups/components/slack-managed-users-access.test.tsx @@ -167,48 +167,13 @@ describe('Slack member access selection', () => { expect(window.open).not.toHaveBeenCalled() }) - it('keeps organization Slack app setup focused on search and returns to member verification', async () => { + it('keeps organization personal-account configuration separate from workspace bots', async () => { await render(undefined, [], 'org-1') - await clickButton('Set up Slack app') - const dialog = appSetupDialog(true) - expect(dialog).toBeDefined() - expect(dialog?.textContent).toContain('App name') - expect(dialog?.textContent).not.toContain('Additional permissions') - expect(dialog?.textContent).not.toContain('Member access') - expect(dialog?.textContent).not.toContain('Slash commands') - expect(dialog?.textContent).not.toContain('Workflow tools') - - await fill('Sim Bot', 'Organization search') - await clickButton('Next') - const manifestText = Array.from(appSetupDialog(true)?.querySelectorAll('pre') ?? []) - .map((node) => node.textContent) - .join('\n') - const manifest = JSON.parse(manifestText) - expect(manifest.oauth_config.scopes.user).toEqual([...SLACK_SEARCH_USER_SCOPES].sort()) - expect(manifest.oauth_config.scopes.bot).toContain('users:read') - expect(manifest.features).not.toHaveProperty('slash_commands') - - await clickButton('Next') - await fill('Paste your signing secret', 'fixture-signing-secret') - await clickButton('Next') - await fill('xoxb-...', 'xoxb-fixture-token') - await clickButton('Next') - - expect(mocks.create).toHaveBeenCalledExactlyOnceWith({ - organizationId: 'org-1', - type: 'service_account', - providerId: 'slack-custom-bot', - id: expect.any(String), - signingSecret: 'fixture-signing-secret', - botToken: 'xoxb-fixture-token', - displayName: 'Organization search', - description: undefined, - }) - expect(appSetupDialog(true)?.textContent).toContain('Click Done to verify member access.') - expect(appSetupDialog(true)?.textContent).not.toContain('Slack triggers and actions') - await clickButton('Done') - expect(appSetupDialog(true)).toBeUndefined() - expect(mocks.onOpenChange).not.toHaveBeenCalled() + expect(document.body.textContent).toContain('Slack App ID') + expect(document.body.textContent).toContain('Slack workspace ID') + expect(document.body.textContent).not.toContain('Set up Slack app') + expect(document.body.textContent).not.toContain('Signing secret') + expect(mocks.create).not.toHaveBeenCalled() expect(mocks.start).not.toHaveBeenCalled() }) @@ -274,19 +239,35 @@ describe('Slack member access selection', () => { ) }) - it('org setup requests search scopes without exposing workflow access', async () => { + it('org setup uses the personal app identity and workflow scopes', async () => { await render(SLACK_MANAGED_USER_SCOPES, [bot], 'org-1') expect(document.body.textContent).not.toContain('Workflow tools') expect(document.body.textContent).not.toContain('Search documents') - await submit() + await fill('A…', 'A_APP') + await fill('T…', 'T_TEAM') + const inputs = document.querySelectorAll('input') + for (const [index, value] of [ + [2, 'fixture-client'], + [3, 'fixture-secret'], + ] as const) { + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call( + inputs[index], + value + ) + inputs[index].dispatchEvent(new Event('input', { bubbles: true })) + }) + } + await clickButton('Verify and add') expect(mocks.start).toHaveBeenCalledExactlyOnceWith({ organizationId: 'org-1', credentialGroupId: 'group-1', body: { - slackBotCredentialId: bot.id, + appId: 'A_APP', + teamId: 'T_TEAM', clientId: 'fixture-client', clientSecret: 'fixture-secret', - requiredScopes: [...SLACK_SEARCH_USER_SCOPES], + requiredScopes: [...SLACK_MANAGED_USER_SCOPES], }, }) }) diff --git a/apps/sim/ee/credential-groups/components/slack-managed-users-modal.tsx b/apps/sim/ee/credential-groups/components/slack-managed-users-modal.tsx index f312bdd35cd..eabf2c2219d 100644 --- a/apps/sim/ee/credential-groups/components/slack-managed-users-modal.tsx +++ b/apps/sim/ee/credential-groups/components/slack-managed-users-modal.tsx @@ -92,6 +92,9 @@ export function SlackManagedUsersModal({ const startAuthorization = useStartSlackCredentialGroupConfiguration() const [appSetupOpen, setAppSetupOpen] = useState(false) const [selectedCredentialId, setSelectedCredentialId] = useState(null) + const [appId, setAppId] = useState('') + const [teamId, setTeamId] = useState('') + const organizationSetup = scope.kind === 'organization' const [clientId, setClientId] = useState('') const [clientSecret, setClientSecret] = useState('') const [pending, setPending] = useState(false) @@ -122,7 +125,7 @@ export function SlackManagedUsersModal({ : 'search') const requiredScopes = scope.kind === 'organization' - ? [...SLACK_SEARCH_USER_SCOPES] + ? [...SLACK_MANAGED_USER_SCOPES] : access === null ? currentScopes : [...(access === 'search' ? SLACK_SEARCH_USER_SCOPES : SLACK_MANAGED_USER_SCOPES)] @@ -137,6 +140,8 @@ export function SlackManagedUsersModal({ setSelectedCredentialId(null) setClientId('') setClientSecret('') + setAppId('') + setTeamId('') setPending(false) setAccess(null) startAuthorization.reset() @@ -160,13 +165,13 @@ export function SlackManagedUsersModal({ } if ( message.credentialGroupId !== credentialGroupId || - !verifiedCredentialId || - message.slackBotCredentialId !== verifiedCredentialId + (!organizationSetup && + (!verifiedCredentialId || message.slackBotCredentialId !== verifiedCredentialId)) ) { toast.error('Slack app verification failed. Please try again.') return } - if (!bots.some((bot) => bot.id === verifiedCredentialId)) { + if (!organizationSetup && !bots.some((bot) => bot.id === verifiedCredentialId)) { toast.error('The verified Slack app is no longer available.') return } @@ -230,7 +235,8 @@ export function SlackManagedUsersModal({ } const handleSubmit = async () => { - if (!selectedBot || pending) return + if (pending || (!organizationSetup && !selectedBot)) return + if (organizationSetup && (!appId.trim() || !teamId.trim())) return if (!clientId.trim() || !clientSecret.trim()) return const opened = window.open('about:blank', 'slack-managed-users', 'width=720,height=760') @@ -245,14 +251,16 @@ export function SlackManagedUsersModal({ ...resourceScopeFields(scope), credentialGroupId, body: { - slackBotCredentialId: selectedBot.id, + ...(organizationSetup + ? { appId: appId.trim(), teamId: teamId.trim() } + : { slackBotCredentialId: selectedBot?.id }), clientId: clientId.trim(), clientSecret: clientSecret.trim(), requiredScopes, }, }) expectedState.current = result.state - expectedCredentialId.current = selectedBot.id + expectedCredentialId.current = selectedBot?.id ?? null opened.location.href = result.authorizationUrl const startedAt = Date.now() popupWatcher.current = window.setInterval(() => { @@ -274,14 +282,20 @@ export function SlackManagedUsersModal({ } } - const noBots = !isLoading && bots.length === 0 + const noBots = !organizationSetup && !isLoading && bots.length === 0 const primaryLabel = isLoading ? 'Loading...' : pending ? 'Waiting for Slack...' : 'Verify and add' const primaryDisabled = - isLoading || noBots || !selectedBot || pending || !clientId.trim() || !clientSecret.trim() + isLoading || + noBots || + (!organizationSetup && !selectedBot) || + pending || + !clientId.trim() || + !clientSecret.trim() || + (organizationSetup && (!appId.trim() || !teamId.trim())) return ( <> @@ -300,7 +314,49 @@ export function SlackManagedUsersModal({ Set up Slack
- {isLoading ? ( + {organizationSetup ? ( + <> + + + + + + ) : isLoading ? (
diff --git a/apps/sim/executor/handlers/credential-group/credential-group-handler.test.ts b/apps/sim/executor/handlers/credential-group/credential-group-handler.test.ts index a9fb1241d5a..ea28a9e9068 100644 --- a/apps/sim/executor/handlers/credential-group/credential-group-handler.test.ts +++ b/apps/sim/executor/handlers/credential-group/credential-group-handler.test.ts @@ -1,339 +1,11 @@ -/** - * @vitest-environment node - */ -import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { BlockType } from '@/executor/constants' -import type { ExecutionContext } from '@/executor/types' -import type { SerializedBlock } from '@/serializer/types' - -const mocks = vi.hoisted(() => ({ - createPrincipal: vi.fn(), - createInviteLink: vi.fn(), - enforceInviteRateLimit: vi.fn(), - listCredentials: vi.fn(), - listMcpConnections: vi.fn(), - listPeople: vi.fn(), - sendInvite: vi.fn(), -})) - -vi.mock('@/lib/credential-groups/application/create-invite-link', () => ({ - createCredentialGroupInviteLink: { execute: mocks.createInviteLink }, -})) - -vi.mock('@/lib/credential-groups/application/list-credentials', () => ({ - listCredentialGroupCredentials: { execute: mocks.listCredentials }, -})) - -vi.mock('@/lib/credential-groups/application/list-mcp-connections', () => ({ - listCredentialGroupMcpConnections: { execute: mocks.listMcpConnections }, -})) - -vi.mock('@/lib/credential-groups/application/list-people', () => ({ - CREDENTIAL_GROUP_PEOPLE_STATUSES: [ - 'invited', - 'delivery_failed', - 'in_progress', - 'completed', - 'revoked', - ], - listCredentialGroupPeople: { execute: mocks.listPeople }, -})) - -vi.mock('@/lib/credential-groups/application/send-invite', () => ({ - sendCredentialGroupInvite: { execute: mocks.sendInvite }, -})) - -vi.mock('@/lib/credential-groups/rate-limit', () => ({ - enforceCredentialGroupInvitationExecutionRateLimit: mocks.enforceInviteRateLimit, -})) - -vi.mock('@/lib/internal/principals/executor', () => ({ - createExecutorPrincipalFromExecutionContext: mocks.createPrincipal, -})) - +/** @vitest-environment node */ +import { describe, expect, it } from 'vitest' import { CredentialGroupBlockHandler } from '@/executor/handlers/credential-group/credential-group-handler' -const principal: WorkflowExecutionDelegatedPrincipal = { - kind: 'delegated', - serviceId: 'executor', - subjectUserId: 'user-1', - workspaceId: 'workspace-1', - delegationId: 'delegation-1', - audience: 'sim:credential-groups', - issuedAt: new Date(Date.now() - 1_000), - expiresAt: new Date(Date.now() + 60_000), - delegationContext: { - kind: 'workflow_execution', - workflowId: 'workflow-1', - principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, - }, -} - -const context = { - workspaceId: 'workspace-1', - workflowId: 'workflow-1', - userId: 'user-1', - principal: principal.delegationContext.principal, - executorDelegationOrigin: { - subjectUserId: 'user-1', - workflowId: 'workflow-1', - principal: principal.delegationContext.principal, - }, -} as ExecutionContext - -const block = { metadata: { id: BlockType.CREDENTIAL_GROUP } } as SerializedBlock - -describe('CredentialGroupBlockHandler', () => { - beforeEach(() => { - vi.clearAllMocks() - mocks.createPrincipal.mockResolvedValue(principal) - }) - - it('recognizes only Credential Group blocks', () => { - const handler = new CredentialGroupBlockHandler() - - expect(handler.canHandle(block)).toBe(true) - expect(handler.canHandle({ metadata: { id: BlockType.CREDENTIAL } } as SerializedBlock)).toBe( - false +describe('legacy Connected Accounts block', () => { + it('requires explicit replacement instead of changing credential scope silently', async () => { + await expect(new CredentialGroupBlockHandler().execute()).rejects.toThrow( + 'Replace this legacy Connected Accounts block with a Credential block' ) }) - - it('lists credentials with an optional email selector', async () => { - mocks.listCredentials.mockResolvedValue({ - credentials: [], - count: 0, - hasMore: false, - nextCursor: null, - }) - - const result = await new CredentialGroupBlockHandler().execute(context, block, { - operation: 'list_credentials', - workspaceId: 'workspace-forged', - email: ' person@example.com ', - credentialProviderIds: '["google-email", "google-email"]', - limit: '25', - cursor: ' credential-1 ', - }) - - expect(mocks.createPrincipal).toHaveBeenCalledWith({ - context, - audience: 'sim:credential-groups', - }) - expect(mocks.listCredentials).toHaveBeenCalledWith({ - principal, - input: { - workspaceId: 'workspace-1', - email: 'person@example.com', - credentialProviderIds: ['google-email'], - limit: 25, - cursor: 'credential-1', - }, - }) - expect(result).toEqual({ credentials: [], count: 0, hasMore: false, nextCursor: null }) - }) - - it('lists credentials for an actorless workflow execution', async () => { - const executionPrincipal = { - kind: 'system' as const, - serviceId: 'schedule' as const, - workspaceId: 'workspace-1', - workflowId: 'workflow-1', - } - const actorlessPrincipal: WorkflowExecutionDelegatedPrincipal = { - kind: 'delegated', - serviceId: 'executor', - workspaceId: 'workspace-1', - delegationId: 'delegation-actorless', - audience: 'sim:credential-groups', - issuedAt: new Date(Date.now() - 1_000), - expiresAt: new Date(Date.now() + 60_000), - delegationContext: { - kind: 'workflow_execution', - workflowId: 'workflow-1', - principal: executionPrincipal, - currentWorkflow: { - workflowId: 'workflow-1', - mode: 'deployment', - deploymentVersionId: 'deployment-version-1', - }, - }, - } - const actorlessContext = { - ...context, - userId: undefined, - principal: executionPrincipal, - executorDelegationOrigin: { - workflowId: 'workflow-1', - principal: executionPrincipal, - currentWorkflow: actorlessPrincipal.delegationContext.currentWorkflow, - }, - } as ExecutionContext - mocks.createPrincipal.mockResolvedValueOnce(actorlessPrincipal) - mocks.listCredentials.mockResolvedValue({ - credentials: [], - count: 0, - hasMore: false, - nextCursor: null, - }) - - await new CredentialGroupBlockHandler().execute(actorlessContext, block, { - operation: 'list_credentials', - }) - - expect(mocks.createPrincipal).toHaveBeenCalledWith({ - context: actorlessContext, - audience: 'sim:credential-groups', - }) - expect(mocks.listCredentials).toHaveBeenCalledWith({ - principal: actorlessPrincipal, - input: { - workspaceId: 'workspace-1', - limit: 100, - cursor: undefined, - email: undefined, - credentialProviderIds: undefined, - }, - }) - }) - - it('lists explicit MCP connection references for an advanced MCP tool', async () => { - mocks.listMcpConnections.mockResolvedValue({ - mcpConnections: [], - count: 0, - hasMore: false, - nextCursor: null, - }) - - const result = await new CredentialGroupBlockHandler().execute(context, block, { - operation: 'list_mcp_connections', - email: ' person@example.com ', - mcpServerId: ' mcp-server-1 ', - limit: '25', - cursor: ' mcp-cg-connection-1 ', - }) - - expect(mocks.listMcpConnections).toHaveBeenCalledWith({ - principal, - input: { - workspaceId: 'workspace-1', - email: 'person@example.com', - mcpServerId: 'mcp-server-1', - limit: 25, - cursor: 'mcp-cg-connection-1', - }, - }) - expect(result).toEqual({ mcpConnections: [], count: 0, hasMore: false, nextCursor: null }) - }) - - it('rejects removed group discovery before delegation', async () => { - await expect( - new CredentialGroupBlockHandler().execute(context, block, { operation: 'list_groups' }) - ).rejects.toThrow('Unsupported Credential Group operation: list_groups') - expect(mocks.createPrincipal).not.toHaveBeenCalled() - }) - - it('applies the shared workspace invitation budget before sending', async () => { - mocks.sendInvite.mockResolvedValue({ - enrollment: { - id: 'enrollment-1', - email: 'person@example.com', - status: 'invited', - invitedAt: '2026-08-13T12:00:00.000Z', - expiresAt: '2026-08-20T12:00:00.000Z', - }, - }) - - await new CredentialGroupBlockHandler().execute(context, block, { - operation: 'send_invite', - email: ' person@example.com ', - }) - - expect(mocks.enforceInviteRateLimit).toHaveBeenCalledWith('workspace-1') - expect(mocks.enforceInviteRateLimit.mock.invocationCallOrder[0]).toBeLessThan( - mocks.sendInvite.mock.invocationCallOrder[0]! - ) - expect(mocks.sendInvite).toHaveBeenCalledWith({ - principal, - input: { workspaceId: 'workspace-1', email: 'person@example.com' }, - }) - }) - - it('issues a fresh invitation link without routing through email delivery', async () => { - mocks.createInviteLink.mockResolvedValue({ - enrollment: { - id: 'enrollment-1', - email: 'person@example.com', - status: 'invited', - invitedAt: '2026-08-13T12:00:00.000Z', - expiresAt: '2026-08-20T12:00:00.000Z', - }, - invitationLink: 'https://sim.ai/credential-groups/enroll/token-1', - }) - - const result = await new CredentialGroupBlockHandler().execute(context, block, { - operation: 'get_invite_link', - email: ' person@example.com ', - }) - - expect(mocks.createPrincipal).toHaveBeenCalledWith({ - context, - audience: 'sim:credential-groups', - }) - expect(mocks.enforceInviteRateLimit).toHaveBeenCalledWith('workspace-1') - expect(mocks.enforceInviteRateLimit.mock.invocationCallOrder[0]).toBeLessThan( - mocks.createInviteLink.mock.invocationCallOrder[0]! - ) - expect(mocks.createInviteLink).toHaveBeenCalledWith({ - principal, - input: { workspaceId: 'workspace-1', email: 'person@example.com' }, - }) - expect(mocks.sendInvite).not.toHaveBeenCalled() - expect(result).toEqual({ - enrollmentId: 'enrollment-1', - email: 'person@example.com', - status: 'invited', - invitedAt: '2026-08-13T12:00:00.000Z', - expiresAt: '2026-08-20T12:00:00.000Z', - invitationLink: 'https://sim.ai/credential-groups/enroll/token-1', - }) - }) - - it('fails fast on unsupported people statuses', async () => { - await expect( - new CredentialGroupBlockHandler().execute(context, block, { - operation: 'list_people', - peopleStatuses: ['unknown'], - }) - ).rejects.toThrow('People statuses contain an unsupported value') - expect(mocks.listPeople).not.toHaveBeenCalled() - }) - - it('lists people from the execution workspace without a group input', async () => { - const page = { people: [], count: 0, hasMore: false, nextCursor: null } - mocks.listPeople.mockResolvedValue(page) - await expect( - new CredentialGroupBlockHandler().execute(context, block, { - operation: 'list_people', - peopleStatuses: ['completed'], - }) - ).resolves.toEqual(page) - expect(mocks.listPeople).toHaveBeenCalledWith({ - principal, - input: { - workspaceId: 'workspace-1', - limit: 100, - cursor: undefined, - email: undefined, - statuses: ['completed'], - }, - }) - }) - - it('rejects unsupported operations before delegation', async () => { - await expect( - new CredentialGroupBlockHandler().execute(context, block, { operation: 'unknown' }) - ).rejects.toThrow('Unsupported Credential Group operation: unknown') - expect(mocks.createPrincipal).not.toHaveBeenCalled() - }) }) diff --git a/apps/sim/executor/handlers/credential-group/credential-group-handler.ts b/apps/sim/executor/handlers/credential-group/credential-group-handler.ts index aef1e1d1af0..9855e1ae59e 100644 --- a/apps/sim/executor/handlers/credential-group/credential-group-handler.ts +++ b/apps/sim/executor/handlers/credential-group/credential-group-handler.ts @@ -1,216 +1,16 @@ -import { createLogger } from '@sim/logger' -import { CREDENTIAL_GROUP_DELEGATION_AUDIENCE } from '@/lib/credential-groups/application/authorization' -import { createCredentialGroupInviteLink } from '@/lib/credential-groups/application/create-invite-link' -import { listCredentialGroupCredentials } from '@/lib/credential-groups/application/list-credentials' -import { listCredentialGroupMcpConnections } from '@/lib/credential-groups/application/list-mcp-connections' -import { - CREDENTIAL_GROUP_PEOPLE_STATUSES, - listCredentialGroupPeople, -} from '@/lib/credential-groups/application/list-people' -import { sendCredentialGroupInvite } from '@/lib/credential-groups/application/send-invite' -import { MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE } from '@/lib/credential-groups/credentials' -import type { CredentialGroupEnrollmentStatus } from '@/lib/credential-groups/enrollments' -import { enforceCredentialGroupInvitationExecutionRateLimit } from '@/lib/credential-groups/rate-limit' -import { createExecutorPrincipalFromExecutionContext } from '@/lib/internal/principals/executor' import type { BlockOutput } from '@/blocks/types' import { BlockType } from '@/executor/constants' -import type { BlockHandler, ExecutionContext } from '@/executor/types' +import type { BlockHandler } from '@/executor/types' import type { SerializedBlock } from '@/serializer/types' -const logger = createLogger('CredentialGroupBlockHandler') - -const CREDENTIAL_GROUP_OPERATION_IDS = [ - 'list_credentials', - 'list_mcp_connections', - 'send_invite', - 'get_invite_link', - 'list_people', -] as const - -type CredentialGroupOperation = (typeof CREDENTIAL_GROUP_OPERATION_IDS)[number] - -function parseOperation(value: unknown): CredentialGroupOperation { - const operation = typeof value === 'string' ? value : 'list_credentials' - const supported = CREDENTIAL_GROUP_OPERATION_IDS.find((candidate) => candidate === operation) - if (!supported) throw new Error(`Unsupported Credential Group operation: ${operation}`) - return supported -} - -function parseStringList(value: unknown, label: string): string[] | undefined { - if (value === undefined || value === null || value === '') return undefined - - let parsed: unknown = value - if (typeof value === 'string') { - const trimmed = value.trim() - if (!trimmed) return undefined - if (!trimmed.startsWith('[')) return [trimmed] - try { - parsed = JSON.parse(trimmed) - } catch { - throw new Error(`${label} must be a valid JSON array of strings`) - } - } - - if (!Array.isArray(parsed) || !parsed.every((item) => typeof item === 'string' && item.trim())) { - throw new Error(`${label} must be an array of non-empty strings`) - } - - const values = [...new Set(parsed.map((item) => item.trim()))] - return values.length > 0 ? values : undefined -} - -function parseLimit(value: unknown): number { - const raw = value ?? MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE - const limit = - typeof raw === 'number' ? raw : typeof raw === 'string' && raw.trim() ? Number(raw) : Number.NaN - if (!Number.isInteger(limit) || limit < 1 || limit > MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE) { - throw new Error( - `Limit must be an integer between 1 and ${MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE}` - ) - } - return limit -} - -function parseOptionalString(value: unknown, label: string): string | undefined { - if (value === undefined || value === null || value === '') return undefined - if (typeof value !== 'string' || !value.trim()) - throw new Error(`${label} must be a non-empty string`) - return value.trim() -} - -function requireString(value: unknown, label: string): string { - const parsed = parseOptionalString(value, label) - if (!parsed) throw new Error(`${label} is required`) - return parsed -} - +/** Legacy blocks must be explicitly replaced because organization access has different scope. */ export class CredentialGroupBlockHandler implements BlockHandler { canHandle(block: SerializedBlock): boolean { return block.metadata?.id === BlockType.CREDENTIAL_GROUP } - - async execute( - ctx: ExecutionContext, - _block: SerializedBlock, - inputs: Record - ): Promise { - if (!ctx.workspaceId) throw new Error('workspaceId is required for Credential Group operations') - const operation = parseOperation(inputs.operation) - if (!ctx.executorDelegationOrigin) { - throw new Error('Credential Group operations require an authenticated workflow execution') - } - const principal = await createExecutorPrincipalFromExecutionContext({ - context: ctx, - audience: CREDENTIAL_GROUP_DELEGATION_AUDIENCE, - }) - - switch (operation) { - case 'list_credentials': { - const credentialProviderIds = parseStringList( - inputs.credentialProviderIds, - 'Credential provider IDs' - ) - const result = await listCredentialGroupCredentials.execute({ - principal, - input: { - workspaceId: ctx.workspaceId, - limit: parseLimit(inputs.limit), - cursor: parseOptionalString(inputs.cursor, 'Cursor'), - email: parseOptionalString(inputs.email, 'Email'), - credentialProviderIds, - }, - }) - logger.info('Listed Credential Group credentials', { - workspaceId: ctx.workspaceId, - count: result.count, - hasMore: result.hasMore, - }) - return result - } - case 'list_mcp_connections': { - const result = await listCredentialGroupMcpConnections.execute({ - principal, - input: { - workspaceId: ctx.workspaceId, - limit: parseLimit(inputs.limit), - cursor: parseOptionalString(inputs.cursor, 'Cursor'), - email: parseOptionalString(inputs.email, 'Email'), - mcpServerId: parseOptionalString(inputs.mcpServerId, 'MCP Server ID'), - }, - }) - logger.info('Listed Credential Group MCP connections', { - workspaceId: ctx.workspaceId, - count: result.count, - hasMore: result.hasMore, - }) - return result - } - case 'send_invite': { - await enforceCredentialGroupInvitationExecutionRateLimit(principal.workspaceId) - const result = await sendCredentialGroupInvite.execute({ - principal, - input: { - workspaceId: ctx.workspaceId, - email: requireString(inputs.email, 'Email'), - }, - }) - logger.info('Sent Credential Group invitation', { - workspaceId: ctx.workspaceId, - enrollmentId: result.enrollment.id, - }) - return { - enrollmentId: result.enrollment.id, - email: result.enrollment.email, - status: result.enrollment.status, - invitedAt: result.enrollment.invitedAt, - expiresAt: result.enrollment.expiresAt, - } - } - case 'get_invite_link': { - await enforceCredentialGroupInvitationExecutionRateLimit(principal.workspaceId) - const result = await createCredentialGroupInviteLink.execute({ - principal, - input: { - workspaceId: ctx.workspaceId, - email: requireString(inputs.email, 'Email'), - }, - }) - logger.info('Generated Credential Group invitation link', { - workspaceId: ctx.workspaceId, - enrollmentId: result.enrollment.id, - }) - return { - enrollmentId: result.enrollment.id, - email: result.enrollment.email, - status: result.enrollment.status, - invitedAt: result.enrollment.invitedAt, - expiresAt: result.enrollment.expiresAt, - invitationLink: result.invitationLink, - } - } - case 'list_people': { - const statuses = parseStringList(inputs.peopleStatuses, 'People statuses') - const allowedStatuses = new Set(CREDENTIAL_GROUP_PEOPLE_STATUSES) - if (statuses?.some((status) => !allowedStatuses.has(status))) { - throw new Error('People statuses contain an unsupported value') - } - const result = await listCredentialGroupPeople.execute({ - principal, - input: { - workspaceId: ctx.workspaceId, - limit: parseLimit(inputs.limit), - cursor: parseOptionalString(inputs.cursor, 'Cursor'), - email: parseOptionalString(inputs.email, 'Email'), - statuses: statuses as CredentialGroupEnrollmentStatus[] | undefined, - }, - }) - logger.info('Listed Credential Group people', { - workspaceId: ctx.workspaceId, - count: result.count, - hasMore: result.hasMore, - }) - return result - } - } + async execute(): Promise { + throw new Error( + 'Replace this legacy Connected Accounts block with a Credential block and configure its organization operation. Invitations are managed in organization settings.' + ) } } diff --git a/apps/sim/executor/handlers/credential/credential-handler.test.ts b/apps/sim/executor/handlers/credential/credential-handler.test.ts new file mode 100644 index 00000000000..417616009af --- /dev/null +++ b/apps/sim/executor/handlers/credential/credential-handler.test.ts @@ -0,0 +1,153 @@ +/** @vitest-environment node */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ExecutionContext } from '@/executor/types' +import type { SerializedBlock } from '@/serializer/types' + +const mocks = vi.hoisted(() => ({ + principal: vi.fn(), + oauth: vi.fn(), + mcp: vi.fn(), + workspace: vi.fn(), +})) +vi.mock('@/lib/internal/principals/executor', () => ({ + createExecutorPrincipalFromExecutionContext: mocks.principal, +})) +vi.mock('@/lib/credential-groups/application/list-credentials', () => ({ + listCredentialGroupCredentials: { execute: mocks.oauth }, +})) +vi.mock('@/lib/credential-groups/application/list-mcp-connections', () => ({ + listCredentialGroupMcpConnections: { execute: mocks.mcp }, +})) +vi.mock('@/lib/credentials/application/resolve-workflow-credentials', () => ({ + resolveWorkflowCredentials: { execute: mocks.workspace }, +})) + +import { CredentialBlockHandler } from '@/executor/handlers/credential/credential-handler' + +const ctx = { + workspaceId: 'child-workspace', + executorDelegationOrigin: { workflowId: 'child-workflow' }, +} as ExecutionContext +const block = { metadata: { id: 'credential' } } as SerializedBlock +const handler = new CredentialBlockHandler() +const account = { + credentialId: 'credential-1', + email: 'person@example.com', + providerId: 'google-email', + displayName: 'Person', +} + +describe('Credential organization operations', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.principal.mockResolvedValue({ delegationId: 'current-run' }) + mocks.oauth.mockResolvedValue({ + credentials: [account], + count: 1, + hasMore: false, + nextCursor: null, + }) + }) + it('uses the actual executing workspace for an exact account lookup', async () => { + expect( + await handler.execute(ctx, block, { + operation: 'find_organization_account', + email: account.email, + organizationProvider: 'google-email', + }) + ).toEqual(account) + expect(mocks.oauth).toHaveBeenCalledWith({ + principal: { delegationId: 'current-run' }, + input: { + workspaceId: 'child-workspace', + email: account.email, + credentialProviderIds: ['google-email'], + limit: 2, + cursor: undefined, + }, + }) + }) + it.each([0, 2])('rejects an ambiguous or missing account (%i matches)', async (count) => { + mocks.oauth.mockResolvedValue({ + credentials: Array.from({ length: count }, () => account), + hasMore: false, + }) + await expect( + handler.execute(ctx, block, { + operation: 'find_organization_account', + email: account.email, + organizationProvider: 'google-email', + }) + ).rejects.toThrow('Expected exactly one') + }) + it('requires both email and provider for find', async () => { + await expect( + handler.execute(ctx, block, { operation: 'find_organization_account', email: account.email }) + ).rejects.toThrow('Provider is required') + expect(mocks.oauth).not.toHaveBeenCalled() + }) + it('preserves pagination for lists', async () => { + await handler.execute(ctx, block, { + operation: 'list_organization_accounts', + limit: '25', + cursor: 'previous', + organizationProviders: '["google-email"]', + }) + expect(mocks.oauth).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + limit: 25, + cursor: 'previous', + credentialProviderIds: ['google-email'], + }), + }) + ) + }) + it('returns the person’s MCP credential separately from the shared server', async () => { + const connection = { + credentialId: 'mcp-cg-person', + mcpServerId: 'server', + toolNames: ['search'], + } + mocks.mcp.mockResolvedValue({ mcpConnections: [connection], hasMore: false }) + expect( + await handler.execute(ctx, block, { + operation: 'find_organization_mcp_connection', + email: account.email, + mcpProvider: 'fireflies', + }) + ).toEqual(connection) + expect(mocks.mcp).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + connectorId: 'fireflies', + email: account.email, + limit: 2, + }), + }) + ) + }) + it('preserves workspace credential selection through its authorized use case', async () => { + mocks.workspace.mockResolvedValue([account]) + expect( + await handler.execute(ctx, block, { operation: 'select', credentialId: 'credential-1' }) + ).toEqual(account) + expect(mocks.oauth).not.toHaveBeenCalled() + }) + it.each(['send_invite', 'get_invite_link', 'list_people'])( + 'does not expose the removed %s operation', + async (operation) => { + await expect(handler.execute(ctx, block, { operation })).rejects.toThrow( + 'Unsupported Credential operation' + ) + } + ) + it('requires trusted workflow execution', async () => { + await expect( + handler.execute({ workspaceId: 'workspace' } as ExecutionContext, block, { + operation: 'list_organization_accounts', + }) + ).rejects.toThrow('authenticated workflow execution') + expect(mocks.principal).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/executor/handlers/credential/credential-handler.ts b/apps/sim/executor/handlers/credential/credential-handler.ts index ff01b77ff95..f570e00aa73 100644 --- a/apps/sim/executor/handlers/credential/credential-handler.ts +++ b/apps/sim/executor/handlers/credential/credential-handler.ts @@ -1,13 +1,61 @@ -import { db } from '@sim/db' -import { credential } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { and, asc, eq, inArray } from 'drizzle-orm' +import { CREDENTIAL_GROUP_DELEGATION_AUDIENCE } from '@/lib/credential-groups/application/authorization' +import { listCredentialGroupCredentials } from '@/lib/credential-groups/application/list-credentials' +import { listCredentialGroupMcpConnections } from '@/lib/credential-groups/application/list-mcp-connections' +import { MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE } from '@/lib/credential-groups/credentials' +import { resolveWorkflowCredentials } from '@/lib/credentials/application/resolve-workflow-credentials' +import { createExecutorPrincipalFromExecutionContext } from '@/lib/internal/principals/executor' import type { BlockOutput } from '@/blocks/types' import { BlockType } from '@/executor/constants' import type { BlockHandler, ExecutionContext } from '@/executor/types' import type { SerializedBlock } from '@/serializer/types' -const logger = createLogger('CredentialBlockHandler') +function parseStringList(value: unknown, label: string): string[] | undefined { + if (value === undefined || value === null || value === '') return undefined + + let parsed: unknown = value + if (typeof value === 'string') { + const trimmed = value.trim() + if (!trimmed) return undefined + if (!trimmed.startsWith('[')) return [trimmed] + try { + parsed = JSON.parse(trimmed) + } catch { + throw new Error(`${label} must be a valid JSON array of strings`) + } + } + + if (!Array.isArray(parsed) || !parsed.every((item) => typeof item === 'string' && item.trim())) { + throw new Error(`${label} must be an array of non-empty strings`) + } + + const values = [...new Set(parsed.map((item) => item.trim()))] + return values.length > 0 ? values : undefined +} + +function parseLimit(value: unknown): number { + const raw = value ?? MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE + const limit = + typeof raw === 'number' ? raw : typeof raw === 'string' && raw.trim() ? Number(raw) : Number.NaN + if (!Number.isInteger(limit) || limit < 1 || limit > MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE) { + throw new Error( + `Limit must be an integer between 1 and ${MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE}` + ) + } + return limit +} + +function parseOptionalString(value: unknown, label: string): string | undefined { + if (value === undefined || value === null || value === '') return undefined + if (typeof value !== 'string' || !value.trim()) + throw new Error(`${label} must be a non-empty string`) + return value.trim() +} + +function requireString(value: unknown, label: string): string { + const parsed = parseOptionalString(value, label) + if (!parsed) throw new Error(`${label} is required`) + return parsed +} export class CredentialBlockHandler implements BlockHandler { canHandle(block: SerializedBlock): boolean { @@ -19,96 +67,86 @@ export class CredentialBlockHandler implements BlockHandler { _block: SerializedBlock, inputs: Record ): Promise { - if (!ctx.workspaceId) { - throw new Error('workspaceId is required for credential resolution') - } - - const operation = typeof inputs.operation === 'string' ? inputs.operation : 'select' - + if (!ctx.workspaceId || !ctx.executorDelegationOrigin) + throw new Error('Credential operations require an authenticated workflow execution') + const principal = await createExecutorPrincipalFromExecutionContext({ + context: ctx, + audience: CREDENTIAL_GROUP_DELEGATION_AUDIENCE, + }) + const operation = parseOptionalString(inputs.operation, 'Operation') ?? 'select' switch (operation) { - case 'select': - return this.selectCredential(ctx.workspaceId, inputs) - case 'list': - return this.listCredentials(ctx.workspaceId, inputs) + case 'select': { + const credentials = await resolveWorkflowCredentials.execute({ + principal, + input: { + workspaceId: ctx.workspaceId, + credentialId: requireString(inputs.credentialId, 'Credential ID'), + }, + }) + const selected = credentials[0] + if (!selected) throw new Error('Credential not found') + return selected + } + case 'list': { + const credentials = await resolveWorkflowCredentials.execute({ + principal, + input: { + workspaceId: ctx.workspaceId, + providerIds: parseStringList(inputs.providerFilter, 'Providers'), + }, + }) + return { credentials, count: credentials.length } + } + case 'find_organization_account': + case 'list_organization_accounts': { + const find = operation === 'find_organization_account' + const result = await listCredentialGroupCredentials.execute({ + principal, + input: { + workspaceId: ctx.workspaceId, + email: find + ? requireString(inputs.email, 'Email') + : parseOptionalString(inputs.email, 'Email'), + credentialProviderIds: find + ? [requireString(inputs.organizationProvider, 'Provider')] + : parseStringList(inputs.organizationProviders, 'Providers'), + limit: find ? 2 : parseLimit(inputs.limit), + cursor: find ? undefined : parseOptionalString(inputs.cursor, 'Cursor'), + }, + }) + if (!find) return result + if (result.credentials.length !== 1 || result.hasMore) + throw new Error( + `Expected exactly one organization account; found ${result.credentials.length}${result.hasMore ? '+' : ''}. Check the email, provider, and connection status.` + ) + return result.credentials[0]! + } + case 'find_organization_mcp_connection': + case 'list_organization_mcp_connections': { + const find = operation === 'find_organization_mcp_connection' + const result = await listCredentialGroupMcpConnections.execute({ + principal, + input: { + workspaceId: ctx.workspaceId, + email: find + ? requireString(inputs.email, 'Email') + : parseOptionalString(inputs.email, 'Email'), + connectorId: find + ? requireString(inputs.mcpProvider, 'MCP provider') + : parseOptionalString(inputs.mcpProvider, 'MCP provider'), + limit: find ? 2 : parseLimit(inputs.limit), + cursor: find ? undefined : parseOptionalString(inputs.cursor, 'Cursor'), + }, + }) + if (!find) return result + if (result.mcpConnections.length !== 1 || result.hasMore) + throw new Error( + `Expected exactly one organization MCP connection; found ${result.mcpConnections.length}${result.hasMore ? '+' : ''}. Check the email, provider, and connection status.` + ) + return result.mcpConnections[0]! + } default: throw new Error(`Unsupported Credential operation: ${operation}`) } } - - private async selectCredential( - workspaceId: string, - inputs: Record - ): Promise { - const credentialId = typeof inputs.credentialId === 'string' ? inputs.credentialId.trim() : '' - - if (!credentialId) { - throw new Error('No credential selected') - } - - const record = await db.query.credential.findFirst({ - where: and( - eq(credential.id, credentialId), - eq(credential.workspaceId, workspaceId), - eq(credential.type, 'oauth') - ), - columns: { - id: true, - displayName: true, - providerId: true, - }, - }) - - if (!record) { - throw new Error(`Credential not found: ${credentialId}`) - } - - logger.info('Credential block resolved', { credentialId: record.id }) - - return { - credentialId: record.id, - displayName: record.displayName, - providerId: record.providerId ?? '', - } - } - - private async listCredentials( - workspaceId: string, - inputs: Record - ): Promise { - const providerFilter = Array.isArray(inputs.providerFilter) - ? (inputs.providerFilter as string[]).filter(Boolean) - : [] - - const conditions = [eq(credential.workspaceId, workspaceId), eq(credential.type, 'oauth')] - - if (providerFilter.length > 0) { - conditions.push(inArray(credential.providerId, providerFilter)) - } - - const records = await db.query.credential.findMany({ - where: and(...conditions), - columns: { - id: true, - displayName: true, - providerId: true, - }, - orderBy: [asc(credential.displayName)], - }) - - const credentials = records.map((r) => ({ - credentialId: r.id, - displayName: r.displayName, - providerId: r.providerId ?? '', - })) - - logger.info('Credential block listed credentials', { - count: credentials.length, - providerFilter: providerFilter.length > 0 ? providerFilter : undefined, - }) - - return { - credentials, - count: credentials.length, - } - } } diff --git a/apps/sim/executor/variables/resolvers/block.test.ts b/apps/sim/executor/variables/resolvers/block.test.ts index de4cb9da017..c972df2afc7 100644 --- a/apps/sim/executor/variables/resolvers/block.test.ts +++ b/apps/sim/executor/variables/resolvers/block.test.ts @@ -8,6 +8,7 @@ import { BlockResolver } from './block' import { RESOLVED_EMPTY, type ResolutionContext } from './reference' vi.mock('@/lib/uploads/server/metadata', () => ({ + insertImmutableFileMetadata: vi.fn().mockResolvedValue({ id: 'execution-payload-file' }), insertFileMetadata: vi.fn().mockResolvedValue({ id: 'execution-payload-file' }), deleteFileMetadata: vi.fn().mockResolvedValue(undefined), })) diff --git a/apps/sim/executor/variables/resolvers/workflow.test.ts b/apps/sim/executor/variables/resolvers/workflow.test.ts index 4d884b532a3..0055aaed708 100644 --- a/apps/sim/executor/variables/resolvers/workflow.test.ts +++ b/apps/sim/executor/variables/resolvers/workflow.test.ts @@ -16,6 +16,7 @@ vi.mock('@/lib/workflows/variables/variable-manager', () => ({ })) vi.mock('@/lib/uploads/server/metadata', () => ({ + insertImmutableFileMetadata: vi.fn().mockResolvedValue({ id: 'execution-payload-file' }), insertFileMetadata: vi.fn().mockResolvedValue({ id: 'execution-payload-file' }), deleteFileMetadata: vi.fn().mockResolvedValue(undefined), })) diff --git a/apps/sim/hooks/queries/credential-groups.ts b/apps/sim/hooks/queries/credential-groups.ts index 3bcb06e41f4..949564cec16 100644 --- a/apps/sim/hooks/queries/credential-groups.ts +++ b/apps/sim/hooks/queries/credential-groups.ts @@ -282,15 +282,24 @@ export function useStartSlackCredentialGroupConfiguration() { | { organizationId: string; workspaceId?: never } )) => { const scope = resourceScopeFromOwner({ workspaceId, organizationId }) - return scope.kind === 'organization' - ? requestJson(startOrganizationSlackConfigurationContract, { - params: { id: scope.organizationId, groupId: credentialGroupId }, - body, - }) - : requestJson(startSlackCredentialGroupConfigurationContract, { - params: { id: scope.workspaceId, groupId: credentialGroupId }, - body, - }) + if (scope.kind === 'organization') { + if (!body.appId || !body.teamId) + throw new Error('Slack App ID and workspace ID are required') + return requestJson(startOrganizationSlackConfigurationContract, { + params: { id: scope.organizationId, groupId: credentialGroupId }, + body: { + clientId: body.clientId, + clientSecret: body.clientSecret, + appId: body.appId, + teamId: body.teamId, + requiredScopes: body.requiredScopes, + }, + }) + } + return requestJson(startSlackCredentialGroupConfigurationContract, { + params: { id: scope.workspaceId, groupId: credentialGroupId }, + body, + }) }, }) } diff --git a/apps/sim/hooks/queries/organization-accounts.ts b/apps/sim/hooks/queries/organization-accounts.ts index 3e5ce4f3e2f..6ef7a263930 100644 --- a/apps/sim/hooks/queries/organization-accounts.ts +++ b/apps/sim/hooks/queries/organization-accounts.ts @@ -1,20 +1,46 @@ 'use client' -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { requestJson } from '@/lib/api/client/request' import { + type AddOrganizationAccountMcpProviderBody, + addOrganizationAccountMcpProviderContract, + type ConfigureOrganizationMcpBody, + configureOrganizationMcpContract, + disconnectPersonalOrganizationAccountContract, type EnsureOrganizationAccountsBody, ensureOrganizationAccountsContract, getOrganizationAccountsContract, + getOrganizationAccountWorkspaceAccessContract, + getOrganizationDatabricksSetupContract, + getWorkspaceOrganizationAccountsContract, + type InviteOrganizationAccountPeopleBody, + inviteOrganizationAccountPeopleContract, + listOrganizationAccountPeopleContract, + listPersonalOrganizationAccountsContract, + type RemoveOrganizationAccountMcpProviderParams, + reconnectPersonalOrganizationAccountContract, + removeOrganizationAccountMcpProviderContract, + resendOrganizationAccountInvitationContract, + revokeOrganizationAccountEnrollmentContract, startOrganizationAccountConnectionContract, type UpdateOrganizationAccountsBody, + type UpdateOrganizationAccountWorkspaceAccessBody, updateOrganizationAccountsContract, + updateOrganizationAccountWorkspaceAccessContract, } from '@/lib/api/contracts/organization-accounts' export const ORGANIZATION_ACCOUNTS_STALE_TIME = 30_000 export const organizationAccountsKeys = { all: ['organization-accounts'] as const, + personal: () => [...organizationAccountsKeys.all, 'personal'] as const, + workspaces: () => [...organizationAccountsKeys.all, 'workspace'] as const, + workspace: (workspaceId?: string) => + [...organizationAccountsKeys.workspaces(), workspaceId ?? ''] as const, + access: (id?: string) => [...organizationAccountsKeys.detail(id), 'access'] as const, + people: (id?: string) => [...organizationAccountsKeys.detail(id), 'people'] as const, + databricks: (id?: string) => [...organizationAccountsKeys.detail(id), 'databricks'] as const, details: () => [...organizationAccountsKeys.all, 'detail'] as const, detail: (organizationId?: string) => [...organizationAccountsKeys.details(), organizationId ?? ''] as const, @@ -48,6 +74,38 @@ export function useEnsureOrganizationAccounts() { }) } +export function useOrganizationDatabricksSetup(organizationId: string, enabled: boolean) { + return useQuery({ + queryKey: organizationAccountsKeys.databricks(organizationId), + enabled, + staleTime: ORGANIZATION_ACCOUNTS_STALE_TIME, + queryFn: ({ signal }) => + requestJson(getOrganizationDatabricksSetupContract, { + params: { id: organizationId }, + signal, + }), + }) +} + +export function useConfigureOrganizationMcp() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ + organizationId, + ...body + }: { organizationId: string } & ConfigureOrganizationMcpBody) => + requestJson(configureOrganizationMcpContract, { params: { id: organizationId }, body }), + onSuccess: (_, { organizationId }) => + Promise.all([ + queryClient.invalidateQueries({ + queryKey: organizationAccountsKeys.detail(organizationId), + }), + queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.workspaces() }), + queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.personal() }), + ]), + }) +} + export function useUpdateOrganizationAccounts() { const queryClient = useQueryClient() return useMutation({ @@ -65,7 +123,13 @@ export function useUpdateOrganizationAccounts() { body: update, }), onSuccess: (_, { organizationId }) => - queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.detail(organizationId) }), + Promise.all([ + queryClient.invalidateQueries({ + queryKey: organizationAccountsKeys.detail(organizationId), + }), + queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.workspaces() }), + queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.personal() }), + ]), }) } @@ -78,3 +142,192 @@ export function useConnectOrganizationAccount() { }), }) } + +export function useWorkspaceOrganizationAccounts(workspaceId?: string, enabled = true) { + return useQuery({ + queryKey: organizationAccountsKeys.workspace(workspaceId), + enabled: Boolean(workspaceId) && enabled, + staleTime: ORGANIZATION_ACCOUNTS_STALE_TIME, + queryFn: ({ signal }) => { + if (!workspaceId) throw new Error('Workspace is required') + return requestJson(getWorkspaceOrganizationAccountsContract, { + params: { id: workspaceId }, + signal, + }) + }, + }) +} +export function useOrganizationAccountWorkspaceAccess(organizationId: string) { + return useQuery({ + queryKey: organizationAccountsKeys.access(organizationId), + staleTime: ORGANIZATION_ACCOUNTS_STALE_TIME, + queryFn: ({ signal }) => + requestJson(getOrganizationAccountWorkspaceAccessContract, { + params: { id: organizationId }, + signal, + }), + }) +} +export function useUpdateOrganizationAccountWorkspaceAccess() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ + organizationId, + ...body + }: { organizationId: string } & UpdateOrganizationAccountWorkspaceAccessBody) => + requestJson(updateOrganizationAccountWorkspaceAccessContract, { + params: { id: organizationId }, + body, + }), + onSuccess: (_, { organizationId }) => + Promise.all([ + queryClient.invalidateQueries({ + queryKey: organizationAccountsKeys.access(organizationId), + }), + queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.workspaces() }), + ]), + }) +} +export function useOrganizationAccountPeople(organizationId: string) { + return useInfiniteQuery({ + queryKey: organizationAccountsKeys.people(organizationId), + staleTime: ORGANIZATION_ACCOUNTS_STALE_TIME, + initialPageParam: undefined as string | undefined, + queryFn: ({ signal, pageParam }) => + requestJson(listOrganizationAccountPeopleContract, { + params: { id: organizationId }, + query: { limit: 50, cursor: pageParam }, + signal, + }), + getNextPageParam: (page) => page.nextCursor ?? undefined, + }) +} +export function useInviteOrganizationAccountPeople() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ + organizationId, + ...body + }: { organizationId: string } & InviteOrganizationAccountPeopleBody) => + requestJson(inviteOrganizationAccountPeopleContract, { + params: { id: organizationId }, + body, + }), + onSuccess: (_, { organizationId }) => + queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.people(organizationId) }), + }) +} +export function useResendOrganizationAccountInvitation() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ + organizationId, + enrollmentId, + }: { + organizationId: string + enrollmentId: string + }) => + requestJson(resendOrganizationAccountInvitationContract, { + params: { id: organizationId, enrollmentId }, + }), + onSuccess: (_, { organizationId }) => + queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.people(organizationId) }), + }) +} +export function useRevokeOrganizationAccountEnrollment() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ + organizationId, + enrollmentId, + }: { + organizationId: string + enrollmentId: string + }) => + requestJson(revokeOrganizationAccountEnrollmentContract, { + params: { id: organizationId, enrollmentId }, + }), + onSuccess: (_, { organizationId }) => + Promise.all([ + queryClient.invalidateQueries({ + queryKey: organizationAccountsKeys.people(organizationId), + }), + queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.personal() }), + ]), + }) +} +export function useAddOrganizationAccountMcpProvider() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ + organizationId, + ...body + }: { organizationId: string } & AddOrganizationAccountMcpProviderBody) => + requestJson(addOrganizationAccountMcpProviderContract, { + params: { id: organizationId }, + body, + }), + onSuccess: (_, { organizationId }) => + Promise.all([ + queryClient.invalidateQueries({ + queryKey: organizationAccountsKeys.detail(organizationId), + }), + queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.workspaces() }), + ]), + }) +} +export function useRemoveOrganizationAccountMcpProvider() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ + organizationId, + connectorId, + }: { organizationId: string } & Pick< + RemoveOrganizationAccountMcpProviderParams, + 'connectorId' + >) => + requestJson(removeOrganizationAccountMcpProviderContract, { + params: { id: organizationId, connectorId }, + }), + onSuccess: (_, { organizationId }) => + Promise.all([ + queryClient.invalidateQueries({ + queryKey: organizationAccountsKeys.detail(organizationId), + }), + queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.workspaces() }), + queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.personal() }), + ]), + }) +} + +export function usePersonalOrganizationAccounts() { + return useInfiniteQuery({ + queryKey: organizationAccountsKeys.personal(), + staleTime: ORGANIZATION_ACCOUNTS_STALE_TIME, + initialPageParam: undefined as string | undefined, + queryFn: ({ signal, pageParam }) => + requestJson(listPersonalOrganizationAccountsContract, { + query: { cursor: pageParam }, + signal, + }), + getNextPageParam: (page) => page.nextCursor ?? undefined, + }) +} +export function useReconnectPersonalOrganizationAccount() { + return useMutation({ + mutationFn: (credentialId: string) => + requestJson(reconnectPersonalOrganizationAccountContract, { params: { credentialId } }), + }) +} +export function useDisconnectPersonalOrganizationAccount() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (credentialId: string) => + requestJson(disconnectPersonalOrganizationAccountContract, { params: { credentialId } }), + onSuccess: () => + Promise.all([ + queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.personal() }), + queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.details() }), + ]), + }) +} diff --git a/apps/sim/lib/api/contracts/credential-groups.test.ts b/apps/sim/lib/api/contracts/credential-groups.test.ts index d90fd8918d7..1e339a10915 100644 --- a/apps/sim/lib/api/contracts/credential-groups.test.ts +++ b/apps/sim/lib/api/contracts/credential-groups.test.ts @@ -118,7 +118,7 @@ describe('credential group contracts', () => { expect(result.success).toBe(false) }) - it('requires a custom bot for Slack option updates', () => { + it('allows Slack options without a workspace bot for organization personal authorization', () => { const missingApp = updateCredentialGroupBodySchema.safeParse({ options: [ { @@ -139,7 +139,7 @@ describe('credential group contracts', () => { ], }) - expect(missingApp.success).toBe(false) + expect(missingApp.success).toBe(true) expect(withApp.success).toBe(true) }) diff --git a/apps/sim/lib/api/contracts/credential-groups.ts b/apps/sim/lib/api/contracts/credential-groups.ts index 5dec48b3c49..7fd305e57f5 100644 --- a/apps/sim/lib/api/contracts/credential-groups.ts +++ b/apps/sim/lib/api/contracts/credential-groups.ts @@ -49,7 +49,7 @@ const slackCredentialGroupOptionInputSchema = z .object({ provider: z.literal('slack'), ...credentialGroupOptionFields, - slackBotCredentialId: z.string().uuid('Select a custom Slack bot'), + slackBotCredentialId: z.string().uuid('Select a custom Slack bot').optional(), }) .strict() @@ -267,7 +267,17 @@ export type CredentialGroupOAuthCallbackQuery = z.output< export const startSlackCredentialGroupConfigurationBodySchema = z .object({ - slackBotCredentialId: z.string().uuid('Select a custom Slack bot'), + slackBotCredentialId: z.string().uuid('Select a custom Slack bot').optional(), + appId: z + .string() + .regex(/^A[A-Z0-9]+$/, 'Enter the Slack App ID') + .max(64) + .optional(), + teamId: z + .string() + .regex(/^T[A-Z0-9]+$/, 'Enter the Slack workspace ID') + .max(64) + .optional(), clientId: z.string().trim().min(1, 'Slack Client ID is required').max(256), clientSecret: z.string().trim().min(1, 'Slack Client Secret is required').max(512), requiredScopes: z.array(z.string().trim().min(1).max(255)).min(1).max(100).optional(), diff --git a/apps/sim/lib/api/contracts/organization-accounts.test.ts b/apps/sim/lib/api/contracts/organization-accounts.test.ts new file mode 100644 index 00000000000..0a964866eb1 --- /dev/null +++ b/apps/sim/lib/api/contracts/organization-accounts.test.ts @@ -0,0 +1,39 @@ +/** @vitest-environment node */ +import { describe, expect, it } from 'vitest' +import { addOrganizationAccountMcpProviderContract } from '@/lib/api/contracts/organization-accounts' + +describe('organization MCP provider creation contract', () => { + const schema = addOrganizationAccountMcpProviderContract.body + if (!schema) throw new Error('MCP provider creation requires a body contract') + const databricks = { + connectorId: 'databricks', + name: 'Databricks', + url: 'https://tenant.cloud.databricks.com/api/2.0/mcp/sql', + oauthClientId: 'client-1', + } + + it.each(['fireflies', 'granola'])( + 'allows adding %s without tenant configuration', + (connectorId) => { + expect(schema.parse({ connectorId })).toEqual({ connectorId }) + } + ) + + it('requires configuration when adding Databricks', () => { + expect(schema.safeParse({ connectorId: 'databricks' }).success).toBe(false) + expect(schema.parse(databricks)).toEqual(databricks) + }) + + it.each(['name', 'url', 'oauthClientId'])('rejects missing or blank %s', (field) => { + expect(schema.safeParse({ ...databricks, [field]: undefined }).success).toBe(false) + expect(schema.safeParse({ ...databricks, [field]: ' ' }).success).toBe(false) + }) + + it('accepts an optional client secret while rejecting a caller-supplied workspace scope', () => { + expect(schema.parse({ ...databricks, oauthClientSecret: 'secret-1' })).toHaveProperty( + 'oauthClientSecret', + 'secret-1' + ) + expect(schema.safeParse({ ...databricks, workspaceId: 'workspace-1' }).success).toBe(false) + }) +}) diff --git a/apps/sim/lib/api/contracts/organization-accounts.ts b/apps/sim/lib/api/contracts/organization-accounts.ts index 2afed8334b2..5b03d3ca021 100644 --- a/apps/sim/lib/api/contracts/organization-accounts.ts +++ b/apps/sim/lib/api/contracts/organization-accounts.ts @@ -1,14 +1,25 @@ import { z } from 'zod' import { + createCredentialGroupMcpConnectorBodySchema, + credentialGroupEnrollmentDetailSchema, + credentialGroupEnrollmentSchema, + credentialGroupMcpServerSchema, credentialGroupOptionInputSchema, credentialGroupProviderSchema, credentialGroupSchema, + inviteCredentialGroupEnrollmentsBodySchema, + inviteCredentialGroupEnrollmentsContract, + managedMcpConnectorIdSchema, startSlackCredentialGroupConfigurationBodySchema, startSlackCredentialGroupConfigurationContract, updateCredentialGroupBodySchema, } from '@/lib/api/contracts/credential-groups' -import { organizationIdSchema } from '@/lib/api/contracts/primitives' +import { organizationIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' +import { + ORGANIZATION_ACCOUNT_INDEXING_SOURCE_LIMIT, + ORGANIZATION_ACCOUNT_WORKSPACE_LIMIT, +} from '@/lib/credential-groups/limits' const organizationAccountsParamsSchema = z.object({ id: organizationIdSchema }) const organizationCredentialGroupSchema = credentialGroupSchema.extend({ @@ -29,6 +40,7 @@ export const getOrganizationAccountsContract = defineRouteContract({ credentialGroup: organizationCredentialGroupSchema.nullable(), availableProviders: z.array(credentialGroupProviderSchema), canManage: z.boolean(), + indexingAvailable: z.boolean(), }), }, }) @@ -58,11 +70,245 @@ export const startOrganizationSlackConfigurationContract = defineRouteContract({ method: 'POST', path: '/api/organizations/[id]/connected-accounts/[groupId]/slack-managed-users', params: organizationAccountsParamsSchema.extend({ groupId: z.string().min(1).max(128) }), - body: startSlackCredentialGroupConfigurationBodySchema, + body: startSlackCredentialGroupConfigurationBodySchema + .omit({ slackBotCredentialId: true }) + .required({ appId: true, teamId: true }), response: startSlackCredentialGroupConfigurationContract.response, }) export type OrganizationAccountsSettings = z.output< typeof getOrganizationAccountsContract.response.schema > -export type EnsureOrganizationAccountsBody = z.input + +export const updateOrganizationAccountIndexingContract = defineRouteContract({ + method: 'PUT', + path: '/api/organizations/[id]/connected-accounts/indexing', + params: organizationAccountsParamsSchema, + body: z + .object({ + optionId: z.string().min(1, 'Provider option is required').max(128), + enabled: z.boolean(), + }) + .strict(), + response: { + mode: 'json', + schema: z.object({ + enabled: z.boolean(), + knowledgeBaseIds: z + .array(z.string().min(1).max(128)) + .max(ORGANIZATION_ACCOUNT_INDEXING_SOURCE_LIMIT), + }), + }, +}) +export type UpdateOrganizationAccountIndexingBody = z.input< + NonNullable +> +export type EnsureOrganizationAccountsBody = z.input< + NonNullable +> export type UpdateOrganizationAccountsBody = z.input + +export const organizationAccountWorkspaceAccessSchema = z.object({ + revision: z.number().int().positive(), + workspaceIds: z + .array(workspaceIdSchema) + .max(ORGANIZATION_ACCOUNT_WORKSPACE_LIMIT) + .refine((ids) => new Set(ids).size === ids.length, 'Workspace IDs must be unique'), +}) +export const getOrganizationAccountWorkspaceAccessContract = defineRouteContract({ + method: 'GET', + path: '/api/organizations/[id]/connected-accounts/workspace-access', + params: organizationAccountsParamsSchema, + response: { + mode: 'json', + schema: organizationAccountWorkspaceAccessSchema.extend({ + workspaces: z + .array(z.object({ id: workspaceIdSchema, name: z.string().max(256) })) + .max(ORGANIZATION_ACCOUNT_WORKSPACE_LIMIT), + }), + }, +}) +export const updateOrganizationAccountWorkspaceAccessContract = defineRouteContract({ + method: 'PUT', + path: '/api/organizations/[id]/connected-accounts/workspace-access', + params: organizationAccountsParamsSchema, + body: organizationAccountWorkspaceAccessSchema.strict(), + response: { mode: 'json', schema: organizationAccountWorkspaceAccessSchema }, +}) +export type OrganizationAccountWorkspaceAccess = z.output< + typeof getOrganizationAccountWorkspaceAccessContract.response.schema +> +export type UpdateOrganizationAccountWorkspaceAccessBody = z.input< + NonNullable +> + +export const listOrganizationAccountPeopleContract = defineRouteContract({ + method: 'GET', + path: '/api/organizations/[id]/connected-accounts/people', + params: organizationAccountsParamsSchema, + query: z.object({ + limit: z.coerce.number().int().min(1).max(100).default(50), + cursor: z.string().min(1).max(512).optional(), + email: z.string().trim().max(320).optional(), + }), + response: { + mode: 'json', + schema: z.object({ + enrollments: z.array(credentialGroupEnrollmentDetailSchema).max(100), + nextCursor: z.string().nullable(), + }), + }, +}) +export const inviteOrganizationAccountPeopleContract = defineRouteContract({ + method: 'POST', + path: '/api/organizations/[id]/connected-accounts/people', + params: organizationAccountsParamsSchema, + body: inviteCredentialGroupEnrollmentsBodySchema, + response: inviteCredentialGroupEnrollmentsContract.response, +}) +const organizationAccountEnrollmentParamsSchema = organizationAccountsParamsSchema.extend({ + enrollmentId: z.string().min(1, 'Enrollment ID is required').max(128), +}) +export const resendOrganizationAccountInvitationContract = defineRouteContract({ + method: 'POST', + path: '/api/organizations/[id]/connected-accounts/people/[enrollmentId]/resend', + params: organizationAccountEnrollmentParamsSchema, + response: { + mode: 'json', + schema: z.object({ credentialGroupEnrollment: credentialGroupEnrollmentSchema }), + }, +}) +export const revokeOrganizationAccountEnrollmentContract = defineRouteContract({ + method: 'DELETE', + path: '/api/organizations/[id]/connected-accounts/people/[enrollmentId]', + params: organizationAccountEnrollmentParamsSchema, + response: { + mode: 'json', + schema: z.object({ credentialGroupEnrollment: credentialGroupEnrollmentSchema }), + }, +}) +export const addOrganizationAccountMcpProviderContract = defineRouteContract({ + method: 'POST', + path: '/api/organizations/[id]/connected-accounts/mcp-providers', + params: organizationAccountsParamsSchema, + body: createCredentialGroupMcpConnectorBodySchema, + response: { mode: 'json', schema: z.object({ mcpServer: credentialGroupMcpServerSchema }) }, +}) +export const removeOrganizationAccountMcpProviderContract = defineRouteContract({ + method: 'DELETE', + path: '/api/organizations/[id]/connected-accounts/mcp-providers/[connectorId]', + params: organizationAccountsParamsSchema.extend({ connectorId: managedMcpConnectorIdSchema }), + response: { mode: 'json', schema: z.object({ success: z.literal(true) }) }, +}) +export type OrganizationAccountPeoplePage = z.output< + typeof listOrganizationAccountPeopleContract.response.schema +> +export type OrganizationAccountPeopleQuery = z.input< + NonNullable +> +export type InviteOrganizationAccountPeopleBody = z.input< + NonNullable +> +export type AddOrganizationAccountMcpProviderBody = z.input< + NonNullable +> +export type RemoveOrganizationAccountMcpProviderParams = z.input< + NonNullable +> + +export const configureOrganizationMcpContract = defineRouteContract({ + method: 'PUT', + path: '/api/organizations/[id]/connected-accounts/databricks', + params: organizationAccountsParamsSchema, + body: z + .object({ + url: z.string().trim().min(1, 'Databricks MCP endpoint is required').max(2048).url(), + oauthClientId: z.string().trim().min(1, 'OAuth client ID is required').max(2048), + oauthClientSecret: z.string().min(1).max(8192).nullable().optional(), + name: z.string().trim().min(1).max(256).optional(), + }) + .strict(), + response: { mode: 'json', schema: z.object({ mcpServer: credentialGroupMcpServerSchema }) }, +}) +export type ConfigureOrganizationMcpBody = z.input< + NonNullable +> + +export const getOrganizationDatabricksSetupContract = defineRouteContract({ + method: 'GET', + path: '/api/organizations/[id]/connected-accounts/databricks', + params: organizationAccountsParamsSchema, + response: { + mode: 'json', + schema: z.object({ + server: z.object({ + id: z.string().min(1).max(128), + name: z.string().min(1).max(256), + url: z.string().max(2048).nullable(), + oauthClientId: z.string().max(2048).nullable(), + hasOauthClientSecret: z.boolean(), + enabled: z.boolean(), + }), + }), + }, +}) +export type OrganizationDatabricksSetup = z.output< + typeof getOrganizationDatabricksSetupContract.response.schema +> + +export const getWorkspaceOrganizationAccountsContract = defineRouteContract({ + method: 'GET', + path: '/api/workspaces/[id]/organization-accounts', + params: z.object({ id: workspaceIdSchema }), + response: { + mode: 'json', + schema: z.object({ + organizationId: organizationIdSchema.nullable(), + organizationName: z.string().nullable(), + available: z.boolean(), + allowed: z.boolean(), + canManage: z.boolean(), + providers: z.array(z.object({ id: z.string(), label: z.string() })), + mcpProviders: z.array(z.object({ id: z.string(), label: z.string() })), + }), + }, +}) +export type WorkspaceOrganizationAccounts = z.output< + typeof getWorkspaceOrganizationAccountsContract.response.schema +> + +export const personalOrganizationAccountSchema = z.object({ + credentialId: z.string().min(1).max(128), + displayName: z.string(), + providerId: z.string().min(1), + kind: z.enum(['oauth', 'mcp']), + status: z.enum(['active', 'needs_reauth', 'revoked']), + organizationId: organizationIdSchema, + organizationName: z.string(), + enrollmentStatus: z.enum(['invited', 'delivery_failed', 'in_progress', 'completed', 'revoked']), + canReconnect: z.boolean(), +}) +export const listPersonalOrganizationAccountsContract = defineRouteContract({ + method: 'GET', + path: '/api/users/me/organization-accounts', + query: z.object({ cursor: z.string().min(1).max(128).optional() }), + response: { + mode: 'json', + schema: z.object({ + accounts: z.array(personalOrganizationAccountSchema).max(50), + nextCursor: z.string().nullable(), + }), + }, +}) +export const reconnectPersonalOrganizationAccountContract = defineRouteContract({ + method: 'POST', + path: '/api/users/me/organization-accounts/[credentialId]/reconnect', + params: z.object({ credentialId: z.string().min(1).max(128) }), + response: { mode: 'json', schema: z.object({ invitationLink: z.string().url() }) }, +}) +export const disconnectPersonalOrganizationAccountContract = defineRouteContract({ + method: 'DELETE', + path: '/api/users/me/organization-accounts/[credentialId]', + params: z.object({ credentialId: z.string().min(1).max(128) }), + response: { mode: 'json', schema: z.object({ success: z.literal(true) }) }, +}) +export type PersonalOrganizationAccount = z.output diff --git a/apps/sim/lib/copilot/generated/docs-manifest.ts b/apps/sim/lib/copilot/generated/docs-manifest.ts index ee11d415c70..805780ac4e0 100644 --- a/apps/sim/lib/copilot/generated/docs-manifest.ts +++ b/apps/sim/lib/copilot/generated/docs-manifest.ts @@ -366,6 +366,7 @@ export const DOCS_MANIFEST: readonly string[] = [ 'logs-debugging.mdx', 'logs-debugging/alerts.mdx', 'logs-debugging/logging.mdx', + 'platform/connected-accounts.mdx', 'platform/costs.mdx', 'platform/credentials.mdx', 'platform/enterprise.mdx', diff --git a/apps/sim/lib/core/config/feature-flags.test.ts b/apps/sim/lib/core/config/feature-flags.test.ts index 4aa4597245e..16dc6902a84 100644 --- a/apps/sim/lib/core/config/feature-flags.test.ts +++ b/apps/sim/lib/core/config/feature-flags.test.ts @@ -202,12 +202,17 @@ describe('isFeatureEnabled', () => { expect(await isFeatureEnabled('credential-groups')).toBe(true) }) - it('opens for an allowlisted workspace only', async () => { - withAppConfig({ 'credential-groups': { workspaceIds: ['ws-1'] } }) - expect(await isFeatureEnabled('credential-groups', { workspaceId: 'ws-1' })).toBe(true) - expect(await isFeatureEnabled('credential-groups', { workspaceId: 'ws-2' })).toBe(false) + it('opens for an allowlisted organization only', async () => { + withAppConfig({ 'credential-groups': { orgIds: ['org-1'] } }) + expect(await isFeatureEnabled('credential-groups', { orgId: 'org-1' })).toBe(true) + expect(await isFeatureEnabled('credential-groups', { orgId: 'org-2' })).toBe(false) expect(await isFeatureEnabled('credential-groups')).toBe(false) }) + + it('a legacy workspace allowlist does not enable the organization gate', async () => { + withAppConfig({ 'credential-groups': { workspaceIds: ['ws-1'] } }) + expect(await isFeatureEnabled('credential-groups', { orgId: 'org-1' })).toBe(false) + }) }) it('matches the workspaceIds clause', async () => { diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index fb9ce87bb17..4ef2852cbe3 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -76,20 +76,20 @@ const FEATURE_FLAGS = { }, 'credential-groups': { description: - 'Workspace-owned collections that gather managed OAuth credentials from external users. ' + - 'Gated by workspaceId via AppConfig (or globally); hosted workspaces must also have an ' + - 'Enterprise subscription. Off-AppConfig falls back to CREDENTIAL_GROUPS.', + 'Managed connected accounts, including organization account pools and their settings UI. ' + + 'Uses orgId targeting only; workspace callers resolve their canonical organization. Hosted ' + + 'owners also require an active Enterprise subscription. Organization Search additionally ' + + 'requires knowledge-member-access. Off-AppConfig falls back to CREDENTIAL_GROUPS.', fallback: 'CREDENTIAL_GROUPS', }, 'knowledge-member-access': { description: - 'Permission-aware knowledge bases: lets a workspace admin sync a connector once per ' + - 'Credential Group member so each person sees only what their own account can read, and ' + - 'makes hybrid retrieval with a source-recency boost the default for searches in that ' + - 'workspace. Gated by workspaceId via AppConfig for members mode, which is judged by the ' + - 'workspace alone; the adminEnabled clause additionally opens the retrieval default to a ' + - 'platform admin anywhere. Off-AppConfig falls back to KNOWLEDGE_MEMBER_ACCESS. Requires ' + - 'the credential-groups flag for the connector side to do anything.', + 'Permission-aware indexing and retrieval. Organization Search UI, MCP, and search APIs ' + + 'require this flag and credential-groups for the canonical orgId; user/admin/workspace ' + + 'targeting cannot enable another organization. Workspace member sync uses workspaceId; ' + + 'workspace retrieval defaults may additionally use user/admin targeting. Source ACL ' + + 'mirroring remains independent of managed identities. Off-AppConfig falls back to ' + + 'KNOWLEDGE_MEMBER_ACCESS.', fallback: 'KNOWLEDGE_MEMBER_ACCESS', }, } satisfies Record diff --git a/apps/sim/lib/core/resource-scope.server.ts b/apps/sim/lib/core/resource-scope.server.ts index 292ab1dc502..aa7423d045d 100644 --- a/apps/sim/lib/core/resource-scope.server.ts +++ b/apps/sim/lib/core/resource-scope.server.ts @@ -1,4 +1,4 @@ -import { and, eq, isNull } from 'drizzle-orm' +import { and, eq, isNull, or } from 'drizzle-orm' import type { PgColumn } from 'drizzle-orm/pg-core' import type { ResourceScope } from '@/lib/core/resource-scope' @@ -11,3 +11,22 @@ export function resourceScopeCondition( ? and(eq(table.workspaceId, scope.workspaceId), isNull(table.organizationId))! : and(eq(table.organizationId, scope.organizationId), isNull(table.workspaceId))! } + +/** Joins resources only when both have the same unambiguous owner. */ +export function sameResourceScopeCondition( + left: { workspaceId: PgColumn; organizationId: PgColumn }, + right: { workspaceId: PgColumn; organizationId: PgColumn } +) { + return or( + and( + eq(left.workspaceId, right.workspaceId), + isNull(left.organizationId), + isNull(right.organizationId) + ), + and( + eq(left.organizationId, right.organizationId), + isNull(left.workspaceId), + isNull(right.workspaceId) + ) + )! +} diff --git a/apps/sim/lib/credential-groups/README.md b/apps/sim/lib/credential-groups/README.md new file mode 100644 index 00000000000..69e29c08454 --- /dev/null +++ b/apps/sim/lib/credential-groups/README.md @@ -0,0 +1,99 @@ +# Organization connected accounts + +An organization has at most one account pool. Owners and admins manage it in **Organization settings → Connected accounts**, with Providers, People, and Workspace access tabs. The initial workspace allowlist is empty. Personal account settings show the signed-in person’s own contributions, including contributions to organizations they have not joined. + +Organization settings selects one setup page: **Connected accounts** when `credential-groups` is available and `knowledge-member-access` is off, or the Search **Integrations** page when both are available. The two pages remain separate; the inactive page is hidden from navigation and returns not found on direct access. This switches the settings UI without changing credential-group API access or stored workspace policies. + +Both pages include **People → Request connections**, with the existing manual email invitation, Resend, and Revoke actions. Search-enabled orgs open **Settings → Integrations → People**; Providers remains the default tab. If no org credential group exists, People directs the admin to provider setup first. Search approval alone does not configure a provider for account invitations. + +## Permissions + +An allowed workspace grants every normally authorized manual and deployed workflow access to every active contribution in this pool. There is no per-workflow resource-policy grant and no per-person filtering for workflow execution. Keep ordinary workspace/workflow authorization and deployment authority: an allowlist entry alone cannot authorize running a workflow. Nested workflows use their actual execution workspace. A workspace move, revocation, inactive enrollment, removed provider, disabled group, or unavailable org entitlement blocks subsequent use. + +Standalone Chat uses the signed-in person’s own connections. Invited contributors do not need organization membership. Redemption requires a verified matching Sim email; the enrollment is then bound permanently to that user ID. An email change cannot transfer an enrollment. OAuth callbacks require the same verified signed-in user who started authorization. Search requires current organization membership and applies document permissions using the viewer's own verified provider identities; workspace access to the shared credential pool does not grant access to other people's indexed documents. + +Disconnect revokes the local grant and invalidates pending invitation-based authorization. Administrators can revoke an enrollment; the person cannot restore it themselves. Removing workspace access stops future authorized calls, but cannot recall a provider request already in flight or erase data already returned to a workflow. Full-pool sharing includes public, scheduled, and webhook deployments that otherwise pass workflow authorization. + +## Credential block + +| Operation | Inputs | Output | +| --- | --- | --- | +| Select Credential | Workspace OAuth credential | Existing credential reference | +| List Credentials | Optional workspace provider filter | Existing reference list | +| Find Organization Account | Email and OAuth provider | Exactly one reference, otherwise an error | +| List Organization Accounts | Optional email/providers, limit/cursor | Bounded reference page | +| Find Organization MCP Connection | Email and MCP provider | Exactly one personal MCP reference, otherwise an error | +| List Organization MCP Connections | Optional email/provider, limit/cursor | Bounded MCP reference page | + +MCP `credentialId` identifies the person’s connection (`mcp-cg-…`) and is used to select the connection in the MCP block. `mcpServerId` identifies shared configuration and does not grant access to a person’s token. The block never returns secrets or invitation links. Org operations are hidden in ineligible workspaces; saved invalid configurations fail during execution. + +Credential trigger mode supports `credential_added`, `credential_reconnected`, and `form_submitted`. Events go to opted-in, currently deployed Credential triggers in allowed workspaces. Legacy Credential Group blocks are hidden and fail with an explicit replacement instruction; they are not automatically rebound. + +## Providers + +The Providers tab lists only added providers. **Add provider** opens a searchable catalog with the remaining providers. Required configuration is completed before adding a provider; **Configure** reopens Slack or Databricks settings directly. Providers without required setup are added directly. Providers have **Remove** in their actions menu. Individual account connections remain in People. Connected accounts does not display indexing status, load Search sources, or open indexing setup. + +- Fireflies and Granola: an admin adds the provider; Sim supplies the fixed hosted MCP endpoint and dynamic client registration. Each person completes their own OAuth authorization. +- Databricks: **Add** collects and validates the organization's tenant MCP URL and registered OAuth client before creating an enabled provider in one transaction. Cancelling leaves nothing added. Organization owners and admins create it through `POST /api/organizations/[id]/connected-accounts/mcp-providers` and read or edit settings through `GET` / `PUT /api/organizations/[id]/connected-accounts/databricks`; no workspace configuration is used. Unfinished entries from the earlier flow appear in the Add catalog until their configuration is saved. The form never reads stored client secrets, and leaving the secret blank when editing preserves it. Endpoint/client identity changes invalidate affected grants and pending attempts, requiring people to reconnect. +- Slack personal OAuth: the org admin supplies App ID, Slack workspace ID, client ID, and client secret, then verifies authorization. The org has one configured app/workspace. Existing workspace bots and their triggers remain separate. + +Search approval and source setup are managed through **Organization settings → Integrations**. Approval alone does not create a credential or start indexing. Support for indexing connected accounts comes from the existing Search connector registry's permission-scoped OAuth ingestion capabilities: Gmail, Google Drive, Google Calendar, GitHub repositories, Jira, Confluence, and Slack. Source setup collects any required repository, domain, space, or other settings. Reconnecting an OAuth account queues the existing active sources for that option; it never enables paused indexing. + +Both `CREDENTIAL_GROUPS` and `KNOWLEDGE_MEMBER_ACCESS` must be enabled locally. Hosted deployments additionally enforce the routed org's feature rules and Enterprise availability. Owners and admins manage indexing; existing Knowledge permission-group rules still apply. Managed MCP account connections remain available for live tool calls only and have no indexing switch. Separate API-key KB connectors for Fireflies, Granola, and Databricks do not consume these managed MCP connections. + +### Feature gates + +| Surface or behavior | Required organization flags | +| --- | --- | +| Connected accounts settings page | `credential-groups` enabled, `knowledge-member-access` disabled | +| Provider setup APIs, personal contributions, workspace access to the pool | `credential-groups` | +| Indexing On, member sync, organization Home/Assistant and chat pages, Integrations source setup, Search MCP settings | `credential-groups` and `knowledge-member-access` | +| Organization Search MCP endpoint and organization knowledge search through internal/public APIs or trusted tools | `credential-groups` and `knowledge-member-access`, checked after current authorization | + +The Search gate uses the persisted knowledge base owner or the authenticated route's target organization. User, platform-admin, and workspace targeting cannot opt a different organization into Search. Disabled organizations receive `403 Search is not enabled for this organization` before index lookup or model execution; hiding navigation is not the authorization boundary. Organization Home, Search, and chat URLs open full settings in the viewer's most recent accessible workspace when Search is disabled. Default app entry uses that same destination, and Home, Integrations, chat history, and Assistant loading UI are hidden. Connected accounts settings and Workspaces remain available. Settings and source-setup URLs also enforce their gates. Ordinary workspace knowledge search keeps its existing behavior. Pausing configured indexing remains available to authorized org admins when the Search flag is off. + +For a targeted hosted rollout, configure both existing flags in AppConfig's `feature-flags` document: + +```json +{ + "credential-groups": { "enabled": false, "orgIds": ["org-to-enable"] }, + "knowledge-member-access": { "enabled": false, "orgIds": ["org-to-enable"] } +} +``` + +`enabled: true` enables a flag globally; it is not needed alongside an org allowlist. Off AppConfig, `CREDENTIAL_GROUPS=true` and `KNOWLEDGE_MEMBER_ACCESS=true` are deployment-wide switches and cannot target individual organizations. Both flag checks still apply the organization's hosted Enterprise/billing requirements and normal membership, permission-group, and document access checks. These examples document configuration only; this change does not update a deployed AppConfig document. + +Credential-groups rollout never evaluates `workspaceIds`. Existing workspace-scoped callers resolve their owning organization and use its `orgId`; personal workspaces cannot enable connected accounts. This flag rollout is separate from the organization's workspace access allowlist, which still controls which workflows may use the pool. Normal settings no longer prefetch the legacy workspace-owned account container. + +Current bounds: 100 entries per discovery page, 1,000 workspace allowlist entries, and 1,000 deployed event subscriptions per organization. Event delivery is synchronous after enrollment commits; a delivery failure surfaces as an error and does not roll back the saved connection. An outbox/retry mechanism is not included. + +## Rollout + +OAuth attempt state changes at this release boundary (OAuth v5 and managed MCP v3). Older attempts lack a verified Sim user binding; older MCP attempts also lack the configuration version. They are deliberately rejected before token exchange, with an explicit instruction to reopen the invitation and connect again. Existing saved credentials are not invalidated by the state version change. Mixed application versions cannot complete each other's in-flight attempts: pause enrollment starts, allow the ten-minute state lifetime to drain, replace the application instances together, and only then reopen enrollment and enable the org rollout. Do not run enrollment OAuth across mixed versions or roll back with active attempts. + +1. Apply `0327_organization_connected_accounts.sql` before deploying code that reads the new columns. It expands ownership columns and checks, adds stable enrollment identity and MCP configuration versions, and builds indexes concurrently. No grants, enrollments, or Search data are moved or deleted. Constraints are added `NOT VALID` to avoid scanning existing tables while holding the DDL lock; validate them separately after auditing existing rows. +2. Inventory existing groups and their Search dependencies before enabling the feature. The queries below read IDs/counts only. Review archived/deleted sources too because a reset must account for retained documents and cleanup work. +3. Existing org groups without the new v2 workspace policy stop with a migration-review error. Do not insert a v2 policy over legacy contributions. Resolve Search dependencies explicitly, retire the old group through an audited maintenance procedure, create a fresh org pool, and invite people to reconnect. No reset command is supplied or run by this change. +4. Enable the existing `credential-groups` feature flag for the target org (`orgIds`), then set up providers and allow specific same-org workspaces. A previous workspace-only feature-flag allowlist does not enable the org surface. Sim Cloud also requires an active Enterprise entitlement. +5. Replace legacy workflow blocks, reconfigure credential references, and redeploy event subscribers. Verify one manual run, one deployed run, and one revocation before widening the workspace allowlist. + +```sql +SELECT cg.id, cg.organization_id, cg.workspace_id, + (SELECT count(*) FROM credential_group_enrollment e + WHERE e.credential_group_id = cg.id) AS enrollments, + (SELECT count(*) FROM credential c + JOIN credential_group_enrollment e ON e.id = c.credential_group_enrollment_id + WHERE e.credential_group_id = cg.id) AS credentials, + (SELECT count(*) FROM knowledge_connector kc + WHERE kc.credential_group_id = cg.id) AS search_dependencies +FROM credential_group cg +ORDER BY cg.id; + +SELECT kc.id AS connector_id, kc.credential_group_id, kc.knowledge_base_id, + kc.access_mode, kc.deleted_at +FROM knowledge_connector kc +WHERE kc.credential_group_id IS NOT NULL +ORDER BY kc.credential_group_id, kc.id; +``` + +The migration is rerunnable after partial completion. If a concurrent index build leaves an invalid index, the final check fails with its name; repair that index explicitly before retrying. Keep the flag off during rollback. Earlier app versions do not understand the org sharing policy or stable enrollment identity; rollback does not transfer new org grants back to workspaces. diff --git a/apps/sim/lib/credential-groups/application/authorization.test.ts b/apps/sim/lib/credential-groups/application/authorization.test.ts index 5522e1402b9..25b2d806c86 100644 --- a/apps/sim/lib/credential-groups/application/authorization.test.ts +++ b/apps/sim/lib/credential-groups/application/authorization.test.ts @@ -4,13 +4,14 @@ import type { DelegatedPrincipal, WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { compileCredentialGroupWorkflowAccessPolicy } from '@/lib/credential-groups/application/workflow-access-policy' +import { buildOrganizationAccountAccessPolicy } from '@/lib/credential-groups/application/workspace-access-policy' import { credentialOperations } from '@/lib/credentials/application/operations' const mocks = vi.hoisted(() => ({ loadEnrollmentAccess: vi.fn(), loadBinding: vi.fn(), requirePolicy: vi.fn(), + isAvailable: vi.fn(), })) vi.mock('@/lib/credential-groups/credentials', () => ({ @@ -28,6 +29,10 @@ vi.mock('@/lib/credential-groups/credentials', () => ({ binding.optionStatus === 'active', })) +vi.mock('@/lib/credential-groups/scoped-availability', () => ({ + isScopedCredentialGroupsAvailable: mocks.isAvailable, +})) + vi.mock('@/lib/resource-policies/repository', () => ({ requireResourcePolicy: mocks.requirePolicy, })) @@ -39,7 +44,8 @@ import { const context = { workspaceId: 'workspace-1', - workspaceOrganizationId: null, + workspaceOrganizationId: 'org-1', + organizationId: 'org-1', allowPersonalApiKeys: true, credentialId: 'credential-1', credentialGroupId: 'group-1', @@ -58,17 +64,12 @@ const liveBinding = { optionStatus: 'active', } -function storedPolicy(allowedWorkflowIds: string[] = []) { +function storedPolicy(workspaceIds: string[] = ['workspace-1']) { return { id: 'policy-1', - workspaceId: 'workspace-1', + organizationId: 'org-1', revision: 1, - document: compileCredentialGroupWorkflowAccessPolicy({ - credentialGroupId: 'group-1', - allowedWorkflowIds, - }), - createdAt: new Date('2026-08-20T00:00:00.000Z'), - updatedAt: new Date('2026-08-20T00:00:00.000Z'), + document: buildOrganizationAccountAccessPolicy('group-1', workspaceIds), } } @@ -132,6 +133,7 @@ function requireAccess(principal: DelegatedPrincipal, accessContext = context): describe('requireCredentialGroupCredentialAccess', () => { beforeEach(() => { vi.clearAllMocks() + mocks.isAvailable.mockResolvedValue(true) mocks.requirePolicy.mockResolvedValue(storedPolicy()) mocks.loadEnrollmentAccess.mockResolvedValue({ enrollmentId: 'enrollment-1', @@ -172,8 +174,8 @@ describe('requireCredentialGroupCredentialAccess', () => { ).rejects.toMatchObject({ code: 'forbidden' }) }) - it('denies a Chat turn whose user holds no live enrollment, even for an allowlisted workflow', async () => { - mocks.requirePolicy.mockResolvedValue(storedPolicy(['workflow-1'])) + it('denies a Chat turn whose user holds no live enrollment, even in an allowlisted workspace', async () => { + mocks.requirePolicy.mockResolvedValue(storedPolicy()) mocks.loadEnrollmentAccess.mockResolvedValue(null) await expect(requireAccess(copilotPrincipal())).rejects.toMatchObject({ code: 'forbidden' }) @@ -187,32 +189,20 @@ describe('requireCredentialGroupCredentialAccess', () => { expect(mocks.loadEnrollmentAccess).not.toHaveBeenCalled() }) - it('allows an external actor to use only their own enrollment', async () => { - const principal = executorPrincipal() - - await expect(requireAccess(principal)).resolves.toBeUndefined() - expect(mocks.requirePolicy).toHaveBeenCalledWith({ - workspaceId: 'workspace-1', - resourceType: 'credential_group', - resourceId: 'group-1', - codec: expect.objectContaining({ resourceType: 'credential_group' }), - }) - expect(mocks.loadEnrollmentAccess).toHaveBeenCalledWith('group-1', { - kind: 'external_user', - provider: 'slack', - tenantId: 'T123', - subjectId: 'U123', - }) - + it('allows an external actor to use any live contributed credential in an allowed workspace', async () => { await expect( - requireAccess(principal, { + requireAccess(executorPrincipal(), { ...context, - credentialGroupEnrollmentId: 'enrollment-2', + credentialGroupEnrollmentId: 'someone-else', }) - ).rejects.toMatchObject({ code: 'forbidden' }) + ).resolves.toBeUndefined() + expect(mocks.loadEnrollmentAccess).not.toHaveBeenCalled() + expect(mocks.requirePolicy).toHaveBeenCalledWith( + expect.objectContaining({ organizationId: 'org-1', resourceId: 'group-1' }) + ) }) - it('allows a Sim actor to use their own enrollment', async () => { + it('allows a Sim actor without a personal enrollment', async () => { const principal = executorPrincipal() principal.subjectUserId = 'user-1' principal.delegationContext!.principal = { @@ -220,15 +210,12 @@ describe('requireCredentialGroupCredentialAccess', () => { userId: 'user-1', sessionId: 'session-1', } - + mocks.loadEnrollmentAccess.mockResolvedValue(null) await expect(requireAccess(principal)).resolves.toBeUndefined() - expect(mocks.loadEnrollmentAccess).toHaveBeenCalledWith('group-1', { - kind: 'sim_user', - userId: 'user-1', - }) + expect(mocks.loadEnrollmentAccess).not.toHaveBeenCalled() }) - it('allows an actorless deployed workflow only when its current workflow is allowlisted', async () => { + it('allows an actorless deployed workflow without a per-workflow allowlist', async () => { const principal = executorPrincipal() principal.delegationContext!.principal = { kind: 'system', @@ -236,35 +223,36 @@ describe('requireCredentialGroupCredentialAccess', () => { workspaceId: 'workspace-1', workflowId: 'root-workflow', } - mocks.requirePolicy.mockResolvedValue(storedPolicy(['workflow-1'])) - await expect(requireAccess(principal)).resolves.toBeUndefined() expect(mocks.loadEnrollmentAccess).not.toHaveBeenCalled() - - principal.delegationContext!.currentWorkflow = { workflowId: 'workflow-1', mode: 'draft' } - await expect(requireAccess(principal)).rejects.toMatchObject({ - code: 'forbidden', - }) + mocks.requirePolicy.mockResolvedValue(storedPolicy([])) + await expect(requireAccess(principal)).rejects.toMatchObject({ code: 'forbidden' }) }) - it('uses the current child workflow rather than the root workflow grant', async () => { + it('denies a child workflow in a different or non-allowlisted workspace', async () => { const principal = executorPrincipal() - principal.delegationContext!.principal = { - kind: 'system', - serviceId: 'schedule', - workspaceId: 'workspace-1', - workflowId: 'root-workflow', - } principal.delegationContext!.currentWorkflow = { workflowId: 'child-workflow', mode: 'deployment', deploymentVersionId: 'child-version', } - mocks.requirePolicy.mockResolvedValue(storedPolicy(['root-workflow'])) + await expect( + requireAccess(principal, { ...context, workspaceId: 'child-workspace' }) + ).rejects.toMatchObject({ code: 'forbidden' }) + await expect( + requireAccess(principal, { ...context, workspaceOrganizationId: 'other-org' }) + ).rejects.toMatchObject({ code: 'forbidden' }) + }) - await expect(requireAccess(principal)).rejects.toMatchObject({ - code: 'forbidden', - }) + it('rejects workspace-owned legacy workflow credentials with a reconnect instruction', async () => { + await expect( + requireAccess(executorPrincipal(), { ...context, organizationId: undefined }) + ).rejects.toThrow('Reconnect this account') + }) + + it('rechecks the org feature flag before credential use', async () => { + mocks.isAvailable.mockResolvedValue(false) + await expect(requireAccess(executorPrincipal())).rejects.toMatchObject({ code: 'not_found' }) }) it('rejects inconsistent Sim and external subject assertions before loading policy', async () => { diff --git a/apps/sim/lib/credential-groups/application/authorization.ts b/apps/sim/lib/credential-groups/application/authorization.ts index 33df6dd3e72..13a51696fd6 100644 --- a/apps/sim/lib/credential-groups/application/authorization.ts +++ b/apps/sim/lib/credential-groups/application/authorization.ts @@ -10,10 +10,10 @@ import type { WorkspaceDelegationPolicy, } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { requireOrganizationAccountsWorkspaceAccess } from '@/lib/credential-groups/application/organization-workspace-access' import { credentialGroupWorkflowAccessPolicyCodec, evaluateCredentialGroupActorCredentialAccess, - evaluateCredentialGroupWorkflowAccess, } from '@/lib/credential-groups/application/workflow-access-policy' import type { CredentialGroupCredentialListContext, @@ -41,6 +41,7 @@ export const workspaceAccountsSettingsDelegationPolicy = { export interface CredentialGroupAuthorizationContext extends WorkspaceAuthorizationContext { credentialGroupId: string + organizationId?: string } export interface CredentialGroupApplicationContext @@ -98,6 +99,7 @@ function requireConsistentWorkflowSubject( * user simply records none. */ export function requireCredentialGroupWorkflowActor(principal: Principal): PrincipalSubject | null { + requireCurrentWorkflow(principal) return requireConsistentWorkflowSubject(principal, requireWorkflowExecutionPrincipal(principal)) } @@ -122,6 +124,19 @@ async function requireCredentialGroupActorCredentialAccess( if (!binding) { throw new OrchestrationError('forbidden', 'Credential Group credential access denied') } + if (context.organizationId) { + const actorAccess = await loadCredentialGroupEnrollmentAccessForSubject( + context.credentialGroupId, + subject + ) + if (actorAccess?.enrollmentId !== context.credentialGroupEnrollmentId) { + throw new OrchestrationError( + 'forbidden', + 'Only your own organization connections can be used in Chat' + ) + } + return + } const [policy, actorAccess] = await Promise.all([ requireResourcePolicy({ workspaceId: context.workspaceId, @@ -167,29 +182,18 @@ export async function requireCredentialGroupCredentialAccess( if (principal.kind === 'delegated' && principal.serviceId === 'copilot') { return requireCredentialGroupActorCredentialAccess(principal, context, binding, resourcePolicy) } - const executionPrincipal = requireWorkflowExecutionPrincipal(principal) - const currentWorkflow = requireCurrentWorkflow(principal) - const subject = requireConsistentWorkflowSubject(principal, executionPrincipal) - const policy = await requireResourcePolicy({ - workspaceId: context.workspaceId, - resourceType: 'credential_group', - resourceId: context.credentialGroupId, - codec: credentialGroupWorkflowAccessPolicyCodec, - }) - const actorAccess = subject - ? await loadCredentialGroupEnrollmentAccessForSubject(context.credentialGroupId, subject) - : null - const decision = evaluateCredentialGroupWorkflowAccess({ - document: policy.document, - credentialGroupId: context.credentialGroupId, - selectedEnrollmentId: context.credentialGroupEnrollmentId, - ...(actorAccess ? { actorEnrollmentId: actorAccess.enrollmentId } : {}), - currentWorkflow, - resourcePolicy, - }) - if (decision.decision !== 'allow') { - throw new OrchestrationError('forbidden', 'Credential Group credential access denied') + requireCredentialGroupWorkflowActor(principal) + requireCurrentWorkflow(principal) + if (!context.organizationId) { + throw new OrchestrationError( + 'forbidden', + 'Reconnect this account in organization settings and replace the legacy Connected Accounts block' + ) } + await requireOrganizationAccountsWorkspaceAccess({ + ...context, + organizationId: context.organizationId, + }) } export const credentialGroupDelegationPolicy = { diff --git a/apps/sim/lib/credential-groups/application/configure-organization-mcp.test.ts b/apps/sim/lib/credential-groups/application/configure-organization-mcp.test.ts new file mode 100644 index 00000000000..5dd78c484fc --- /dev/null +++ b/apps/sim/lib/credential-groups/application/configure-organization-mcp.test.ts @@ -0,0 +1,241 @@ +/** @vitest-environment node */ +import * as audit from '@sim/audit' +import type { SessionPrincipal } from '@sim/auth/principal' +import { queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { eq } from 'drizzle-orm' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + available: vi.fn(), + group: vi.fn(), + setup: vi.fn(), + read: vi.fn(), + create: vi.fn(), + update: vi.fn(), + clear: vi.fn(), + evict: vi.fn(), +})) +vi.mock('@/lib/credential-groups/scoped-availability', () => ({ + isScopedCredentialGroupsAvailable: mocks.available, +})) +vi.mock('@/lib/credential-groups/credentials', () => ({ + loadScopedAccountsCredentialListContext: mocks.group, +})) +vi.mock('@/lib/credential-groups/organization-setup', () => ({ + requireOrganizationAccountsSetup: mocks.setup, +})) +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + getUserPermissionConfigForOrganization: vi.fn().mockResolvedValue(null), +})) +vi.mock('@/lib/credential-groups/service', () => ({ + ensureWorkspaceAccountsGroup: vi.fn(), + getOrganizationAccountsGroup: vi.fn(), + updateCredentialGroup: vi.fn(), +})) +vi.mock('@/lib/credential-groups/provider-availability', () => ({ + listConfiguredCredentialGroupProviders: vi.fn(), +})) +vi.mock('@/lib/credential-groups/self-enrollment', () => ({ + createViewerCredentialGroupEnrollment: vi.fn(), +})) +vi.mock('@/lib/credential-groups/managed-mcp-service', () => ({ + loadOrganizationDatabricksSetup: mocks.read, + createManagedMcpConnector: mocks.create, + updateManagedMcpConnector: mocks.update, + ManagedMcpConnectorError: class extends Error { + constructor( + message: string, + public code: string + ) { + super(message) + } + }, +})) +vi.mock('@/lib/credential-groups/mcp-oauth-state', () => ({ + clearCredentialGroupMcpOAuthAttempts: mocks.clear, +})) +vi.mock('@/lib/mcp/connection-pool', () => ({ evictMcpServerConnections: mocks.evict })) + +import { configureOrganizationMcp } from '@/lib/credential-groups/application/configure-organization-mcp' +import { addOrganizationAccountMcpProvider } from '@/lib/credential-groups/application/organization-account-management' +import { getOrganizationDatabricksSetup } from '@/lib/credential-groups/application/organization-databricks-setup' +import { ManagedMcpConnectorError } from '@/lib/credential-groups/managed-mcp-service' + +const principal: SessionPrincipal = { + kind: 'session', + userId: 'admin-user', + sessionId: 'session-1', +} +const input = { + organizationId: 'customer-org', + connectorId: 'databricks' as const, + name: 'Databricks', + url: 'https://tenant.cloud.databricks.com/api/2.0/mcp/sql', + oauthClientId: 'registered-client', +} +const server = { + id: 'server-new', + name: 'Databricks', + url: input.url, + oauthClientId: input.oauthClientId, + hasOauthClientSecret: true, + enabled: true, +} + +describe('organization Databricks setup', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(audit, 'recordAudit').mockImplementation(() => {}) + resetDbChainMock() + mocks.available.mockResolvedValue(true) + mocks.group.mockResolvedValue({ credentialGroupId: 'customer-group' }) + mocks.setup.mockResolvedValue(undefined) + mocks.read.mockResolvedValue(server) + mocks.create.mockResolvedValue({ + mcpServer: { id: 'server-new', name: 'Databricks', enabled: true }, + resetMcpServerIds: [], + retiredMcpConnectionIds: [], + }) + mocks.update.mockResolvedValue({ + mcpServer: { id: 'server-new', name: 'Databricks' }, + resetMcpServerIds: ['server-old'], + retiredMcpConnectionIds: ['mcp-cg-old'], + }) + }) + + describe.each([ + ['read', getOrganizationDatabricksSetup], + ['configure', configureOrganizationMcp], + ['add', addOrganizationAccountMcpProvider], + ] as const)('%s authorization', (_name, useCase) => { + it.each(['member', null])('refuses a %s before reading configuration', async (role) => { + queueTableRows(schemaMock.member, role ? [{ role }] : []) + await expect(useCase.execute({ principal, input })).rejects.toThrow() + expect(mocks.group).not.toHaveBeenCalled() + expect(mocks.read).not.toHaveBeenCalled() + expect(mocks.update).not.toHaveBeenCalled() + expect(mocks.create).not.toHaveBeenCalled() + }) + + it.each(['admin', 'owner'])( + 'allows an organization %s using the routed organization', + async (role) => { + queueTableRows(schemaMock.member, [{ role }]) + await useCase.execute({ principal, input }) + expect(eq).toHaveBeenCalledWith(schemaMock.member.userId, 'admin-user') + expect(eq).toHaveBeenCalledWith(schemaMock.member.organizationId, 'customer-org') + expect(mocks.group).toHaveBeenCalledWith({ + kind: 'organization', + organizationId: 'customer-org', + }) + } + ) + + it('refuses setup when the feature is unavailable', async () => { + queueTableRows(schemaMock.member, [{ role: 'admin' }]) + mocks.available.mockResolvedValue(false) + await expect(useCase.execute({ principal, input })).rejects.toMatchObject({ + code: 'not_found', + }) + expect(mocks.read).not.toHaveBeenCalled() + expect(mocks.update).not.toHaveBeenCalled() + expect(mocks.create).not.toHaveBeenCalled() + }) + + it('requires the organization connected accounts group', async () => { + queueTableRows(schemaMock.member, [{ role: 'admin' }]) + mocks.group.mockResolvedValue(null) + await expect(useCase.execute({ principal, input })).rejects.toMatchObject({ + code: 'not_found', + }) + expect(mocks.read).not.toHaveBeenCalled() + expect(mocks.update).not.toHaveBeenCalled() + expect(mocks.create).not.toHaveBeenCalled() + }) + }) + + it('loads setup metadata for the organization without returning a client secret', async () => { + queueTableRows(schemaMock.member, [{ role: 'admin' }]) + const result = await getOrganizationDatabricksSetup.execute({ principal, input }) + expect(mocks.read).toHaveBeenCalledWith('customer-org', 'customer-group') + expect(result).toEqual({ server }) + expect(result.server).not.toHaveProperty('oauthClientSecret') + }) + + it('updates only the organization provider and invalidates affected connections', async () => { + queueTableRows(schemaMock.member, [{ role: 'admin' }]) + await configureOrganizationMcp.execute({ principal, input }) + expect(mocks.update).toHaveBeenCalledWith({ + organizationId: 'customer-org', + credentialGroupId: 'customer-group', + connectorId: 'databricks', + input: { + url: input.url, + oauthClientId: input.oauthClientId, + oauthClientSecret: undefined, + name: 'Databricks', + }, + }) + expect(audit.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: 'admin-user', + resourceId: 'customer-group', + metadata: { organizationId: 'customer-org' }, + }) + ) + expect(mocks.clear).toHaveBeenCalledWith(['server-old']) + expect(mocks.evict).toHaveBeenCalledWith('server-old', expect.any(String)) + expect(mocks.evict).toHaveBeenCalledWith('mcp-cg-old', expect.any(String)) + }) + + it('surfaces configuration failures without auditing success or evicting connections', async () => { + queueTableRows(schemaMock.member, [{ role: 'admin' }]) + mocks.update.mockRejectedValue( + new ManagedMcpConnectorError('Databricks has not been added', 'not_found') + ) + await expect(configureOrganizationMcp.execute({ principal, input })).rejects.toMatchObject({ + code: 'not_found', + }) + expect(audit.recordAudit).not.toHaveBeenCalled() + expect(mocks.clear).not.toHaveBeenCalled() + expect(mocks.evict).not.toHaveBeenCalled() + }) + + it('creates the fully configured provider with canonical organization scope in one mutation', async () => { + queueTableRows(schemaMock.member, [{ role: 'admin' }]) + const result = await addOrganizationAccountMcpProvider.execute({ principal, input }) + expect(mocks.create).toHaveBeenCalledExactlyOnceWith({ + organizationId: 'customer-org', + credentialGroupId: 'customer-group', + userId: 'admin-user', + input: { + connectorId: 'databricks', + name: 'Databricks', + url: input.url, + oauthClientId: input.oauthClientId, + }, + }) + expect(result.mcpServer.enabled).toBe(true) + expect(mocks.update).not.toHaveBeenCalled() + expect(audit.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: 'admin-user', + resourceId: 'customer-group', + metadata: { organizationId: 'customer-org' }, + }) + ) + }) + + it('rejects invalid Databricks creation without auditing success or trying a second mutation', async () => { + queueTableRows(schemaMock.member, [{ role: 'admin' }]) + mocks.create.mockRejectedValue(new ManagedMcpConnectorError('Invalid MCP URL', 'validation')) + await expect( + addOrganizationAccountMcpProvider.execute({ principal, input }) + ).rejects.toMatchObject({ + code: 'validation', + message: 'Invalid MCP URL', + }) + expect(audit.recordAudit).not.toHaveBeenCalled() + expect(mocks.update).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credential-groups/application/configure-organization-mcp.ts b/apps/sim/lib/credential-groups/application/configure-organization-mcp.ts new file mode 100644 index 00000000000..5251a0e8f6b --- /dev/null +++ b/apps/sim/lib/credential-groups/application/configure-organization-mcp.ts @@ -0,0 +1,69 @@ +import type { OrganizationMembershipContext } from '@/lib/core/application/organization-authorization' +import { defineOrganizationOperation } from '@/lib/core/application/organization-operation' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { defineOrganizationAccountsUseCase } from '@/lib/credential-groups/application/organization-accounts' +import { loadScopedAccountsCredentialListContext } from '@/lib/credential-groups/credentials' +import { updateManagedMcpConnector } from '@/lib/credential-groups/managed-mcp-service' +import { clearCredentialGroupMcpOAuthAttempts } from '@/lib/credential-groups/mcp-oauth-state' +import { evictMcpServerConnections } from '@/lib/mcp/connection-pool' + +export const configureOrganizationMcpOperation = defineOrganizationOperation({ + id: 'organization_accounts.mcp.configure', + minimumRole: 'admin', + principalKinds: ['session'], + capability: 'integrations.manage', +}) + +export interface ConfigureOrganizationMcpInput { + organizationId: string + url: string + oauthClientId: string + oauthClientSecret?: string | null + name?: string +} + +export const configureOrganizationMcp = defineOrganizationAccountsUseCase({ + operation: configureOrganizationMcpOperation, + async execute({ + input, + context, + }: { + input: ConfigureOrganizationMcpInput + context: OrganizationMembershipContext + }) { + const group = await loadScopedAccountsCredentialListContext({ + kind: 'organization', + organizationId: context.organizationId, + }) + if (!group) + throw new OrchestrationError( + 'not_found', + 'Organization connected accounts are not configured' + ) + const result = await updateManagedMcpConnector({ + organizationId: context.organizationId, + credentialGroupId: group.credentialGroupId, + connectorId: 'databricks', + input: { + url: input.url, + oauthClientId: input.oauthClientId, + oauthClientSecret: input.oauthClientSecret, + name: input.name, + }, + }) + return { ...result, credentialGroupId: group.credentialGroupId } + }, + projectAudit: ({ credentialGroupId }) => ({ + resourceId: credentialGroupId, + resourceName: 'Connected accounts', + description: 'Configured the organization Databricks provider', + }), + async afterSuccess({ result }) { + await clearCredentialGroupMcpOAuthAttempts(result.resetMcpServerIds) + await Promise.all( + [...result.resetMcpServerIds, ...result.retiredMcpConnectionIds].map((id) => + evictMcpServerConnections(id, 'organization MCP configuration changed') + ) + ) + }, +}) diff --git a/apps/sim/lib/credential-groups/application/context.ts b/apps/sim/lib/credential-groups/application/context.ts index cd0092050f1..a5f8a97a262 100644 --- a/apps/sim/lib/credential-groups/application/context.ts +++ b/apps/sim/lib/credential-groups/application/context.ts @@ -13,7 +13,10 @@ import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/applicat export async function requireCredentialGroupsAvailable(workspaceId: string): Promise { const ownerBilling = await getWorkspaceOwnerSubscriptionAccess(workspaceId) - const availability = await resolveCredentialGroupsAvailability({ workspaceId, ownerBilling }) + const availability = await resolveCredentialGroupsAvailability({ + organizationId: ownerBilling.organizationId, + ownerBilling, + }) if (!availability.available) { const message = availability.reason === 'enterprise_plan_required' @@ -25,7 +28,12 @@ export async function requireCredentialGroupsAvailable(workspaceId: string): Pro export async function requireCredentialGroupSettingsAvailable(workspaceId: string): Promise { const ownerBilling = await getWorkspaceOwnerSubscriptionAccess(workspaceId) - if (!(await isCredentialGroupsAvailable({ workspaceId, ownerBilling }))) { + if ( + !(await isCredentialGroupsAvailable({ + organizationId: ownerBilling.organizationId, + ownerBilling, + })) + ) { throw new OrchestrationError('not_found', 'Credential Groups are not available') } } diff --git a/apps/sim/lib/credential-groups/application/enrollment-auth.test.ts b/apps/sim/lib/credential-groups/application/enrollment-auth.test.ts index 79db9327bbb..0ef34067003 100644 --- a/apps/sim/lib/credential-groups/application/enrollment-auth.test.ts +++ b/apps/sim/lib/credential-groups/application/enrollment-auth.test.ts @@ -1,8 +1,9 @@ /** @vitest-environment node */ import { sha256Hex } from '@sim/security/hash' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' -const mocks = vi.hoisted(() => ({ authenticate: vi.fn() })) +const mocks = vi.hoisted(() => ({ authenticate: vi.fn(), getSession: vi.fn() })) +vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession })) vi.mock('@/lib/credential-groups/enrollments', () => ({ authenticatePublicCredentialGroupEnrollment: mocks.authenticate, })) @@ -14,17 +15,23 @@ import { import type { CredentialGroupOAuthAttempt } from '@/lib/credential-groups/oauth-state' describe('consumed OAuth attempt identity', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getSession.mockResolvedValue({ user: { id: 'user-1', emailVerified: true } }) + }) it('retains the old invitation identity without reauthenticating a rotated bearer', async () => { const attempt = { + userId: 'user-1', workspaceId: 'workspace', credentialGroupId: 'group', enrollmentId: 'enrollment', email: 'person@example.com', invitationToken: 'old-invitation', } as CredentialGroupOAuthAttempt - const principal = credentialGroupOAuthAttemptPrincipal(attempt) + const principal = await credentialGroupOAuthAttemptPrincipal(attempt) expect(principal).toEqual({ kind: 'credential_group_enrollment', + userId: 'user-1', workspaceId: 'workspace', credentialGroupId: 'group', enrollmentId: 'enrollment', @@ -37,4 +44,21 @@ describe('consumed OAuth attempt identity', () => { expect(await authenticateCredentialGroupEnrollment('old-invitation')).toBeNull() expect(mocks.authenticate).toHaveBeenCalledWith('old-invitation') }) + it('rejects completion from a different signed-in user', async () => { + await expect( + credentialGroupOAuthAttemptPrincipal({ + userId: 'other-user', + organizationId: 'org-1', + credentialGroupId: 'group', + enrollmentId: 'enrollment', + email: 'person@example.com', + invitationToken: 'token', + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + }) + it('requires a verified signed-in user before reading an invitation', async () => { + mocks.getSession.mockResolvedValue(null) + expect(await authenticateCredentialGroupEnrollment('token')).toBeNull() + expect(mocks.authenticate).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/lib/credential-groups/application/enrollment-auth.ts b/apps/sim/lib/credential-groups/application/enrollment-auth.ts index d4a8af1b0f5..b39ac84ea1e 100644 --- a/apps/sim/lib/credential-groups/application/enrollment-auth.ts +++ b/apps/sim/lib/credential-groups/application/enrollment-auth.ts @@ -1,5 +1,7 @@ import type { CredentialGroupEnrollmentPrincipal } from '@sim/auth/principal' import { sha256Hex } from '@sim/security/hash' +import { getSession } from '@/lib/auth' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { resourceScopeFields, resourceScopeFromOwner } from '@/lib/core/resource-scope' import { authenticatePublicCredentialGroupEnrollment } from '@/lib/credential-groups/enrollments' import type { CredentialGroupOAuthAttempt } from '@/lib/credential-groups/oauth-state' @@ -9,10 +11,14 @@ export async function authenticateCredentialGroupEnrollment( invitationToken: string ): Promise { if (!invitationToken.trim() || invitationToken.length > 128) return null + const session = await getSession() + if (!session?.user?.id || !session.user.emailVerified) return null const identity = await authenticatePublicCredentialGroupEnrollment(invitationToken) if (!identity) return null + if (identity.userId && identity.userId !== session.user.id) return null return Object.freeze({ kind: 'credential_group_enrollment' as const, + userId: session.user.id, ...resourceScopeFields(resourceScopeFromOwner(identity)), credentialGroupId: identity.credentialGroupId, enrollmentId: identity.enrollmentId, @@ -22,7 +28,7 @@ export async function authenticateCredentialGroupEnrollment( } /** A consumed one-time attempt retains only its original enrollment authority, never a rotated invitation. */ -export function credentialGroupOAuthAttemptPrincipal( +export async function credentialGroupOAuthAttemptPrincipal( attempt: Pick< CredentialGroupOAuthAttempt, | 'workspaceId' @@ -31,10 +37,19 @@ export function credentialGroupOAuthAttemptPrincipal( | 'enrollmentId' | 'email' | 'invitationToken' + | 'userId' > -): CredentialGroupEnrollmentPrincipal { +): Promise { + const session = await getSession() + if (!attempt.userId || !session?.user?.emailVerified || session.user.id !== attempt.userId) { + throw new OrchestrationError( + 'forbidden', + 'Complete authorization using the same signed-in account that started it' + ) + } return Object.freeze({ kind: 'credential_group_enrollment', + userId: session.user.id, ...resourceScopeFields(resourceScopeFromOwner(attempt)), credentialGroupId: attempt.credentialGroupId, enrollmentId: attempt.enrollmentId, diff --git a/apps/sim/lib/credential-groups/application/list-credentials.test.ts b/apps/sim/lib/credential-groups/application/list-credentials.test.ts index 58a5dd13087..b940d915701 100644 --- a/apps/sim/lib/credential-groups/application/list-credentials.test.ts +++ b/apps/sim/lib/credential-groups/application/list-credentials.test.ts @@ -3,9 +3,11 @@ */ import type { SessionPrincipal, WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { buildOrganizationAccountAccessPolicy } from '@/lib/credential-groups/application/workspace-access-policy' const mocks = vi.hoisted(() => ({ getWorkspaceOwnerSubscriptionAccess: vi.fn(), + requirePolicy: vi.fn(), listCredentials: vi.fn(), loadEnrollmentAccess: vi.fn(), loadGroup: vi.fn(), @@ -18,8 +20,12 @@ vi.mock('@/lib/billing/core/workspace-access', () => ({ getWorkspaceOwnerSubscriptionAccess: mocks.getWorkspaceOwnerSubscriptionAccess, })) -vi.mock('@/lib/credential-groups/availability', () => ({ - resolveCredentialGroupsAvailability: mocks.resolveCredentialGroupsAvailability, +vi.mock('@/lib/credential-groups/scoped-availability', () => ({ + isScopedCredentialGroupsAvailable: async () => + (await mocks.resolveCredentialGroupsAvailability()).available, +})) +vi.mock('@/lib/resource-policies/repository', () => ({ + requireResourcePolicy: mocks.requirePolicy, })) vi.mock('@/lib/credential-groups/credentials', () => ({ @@ -31,7 +37,7 @@ vi.mock('@/lib/credential-groups/credentials', () => ({ }, listCredentialGroupCredentialReferences: mocks.listCredentials, loadCredentialGroupEnrollmentAccessForSubject: mocks.loadEnrollmentAccess, - loadWorkspaceAccountsCredentialListContext: mocks.loadGroup, + loadScopedAccountsCredentialListContext: mocks.loadGroup, MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE: 100, })) @@ -78,7 +84,7 @@ const groupContext = { } const workspaceContext = { workspaceId: 'workspace-1', - workspaceOrganizationId: null, + workspaceOrganizationId: 'org-1', allowPersonalApiKeys: true, billedAccountUserId: 'billing-owner-1', } @@ -110,6 +116,9 @@ function executorPrincipal(workspaceId = 'workspace-1'): WorkflowExecutionDelega describe('listCredentialGroupCredentials', () => { beforeEach(() => { vi.clearAllMocks() + mocks.requirePolicy.mockResolvedValue({ + document: buildOrganizationAccountAccessPolicy('group-1', ['workspace-1']), + }) mocks.loadGroup.mockResolvedValue(groupContext) mocks.loadWorkspace.mockResolvedValue(workspaceContext) mocks.resolvePermission.mockResolvedValue('read') @@ -152,7 +161,7 @@ describe('listCredentialGroupCredentials', () => { await expect( listCredentialGroupCredentials.execute({ principal: executorPrincipal(), input }) ).rejects.toMatchObject({ code: 'not_found' }) - expect(mocks.loadGroup).toHaveBeenCalledWith('workspace-1') + expect(mocks.loadGroup).toHaveBeenCalledWith({ kind: 'organization', organizationId: 'org-1' }) expect(mocks.listCredentials).not.toHaveBeenCalled() }) @@ -192,14 +201,25 @@ describe('listCredentialGroupCredentials', () => { expect(mocks.listCredentials).toHaveBeenCalled() }) - it('does not use the executor subject to filter credential references', async () => { + it('rejects inconsistent execution attribution', async () => { const principal = executorPrincipal() principal.subjectUserId = 'different-user' + await expect( + listCredentialGroupCredentials.execute({ principal, input }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.listCredentials).not.toHaveBeenCalled() + }) - await listCredentialGroupCredentials.execute({ principal, input }) - - expect(mocks.loadEnrollmentAccess).not.toHaveBeenCalled() - expect(mocks.listCredentials).toHaveBeenCalled() + it('rechecks workspace revocation for the next discovery', async () => { + await listCredentialGroupCredentials.execute({ principal: executorPrincipal(), input }) + mocks.listCredentials.mockClear() + mocks.requirePolicy.mockResolvedValue({ + document: buildOrganizationAccountAccessPolicy('group-1', []), + }) + await expect( + listCredentialGroupCredentials.execute({ principal: executorPrincipal(), input }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.listCredentials).not.toHaveBeenCalled() }) it('returns a bounded page after current workspace and entitlement checks', async () => { @@ -208,11 +228,17 @@ describe('listCredentialGroupCredentials', () => { input, }) - expect(mocks.resolvePermission).toHaveBeenCalledWith('user-1', 'workspace-1', null, undefined, { - forUpdate: undefined, - }) + expect(mocks.resolvePermission).toHaveBeenCalledWith( + 'user-1', + 'workspace-1', + 'org-1', + undefined, + { + forUpdate: undefined, + } + ) expect(mocks.listCredentials).toHaveBeenCalledWith({ - workspaceId: 'workspace-1', + organizationId: 'org-1', credentialGroupId: 'group-1', limit: 50, cursor: undefined, @@ -299,8 +325,8 @@ describe('listCredentialGroupCredentials', () => { await expect( listCredentialGroupCredentials.execute({ principal: executorPrincipal(), input }) ).rejects.toMatchObject({ - code: 'forbidden', - message: 'Credential Groups are not available', + code: 'not_found', + message: 'Organization connected accounts are not available', }) expect(mocks.listCredentials).not.toHaveBeenCalled() }) @@ -315,8 +341,8 @@ describe('listCredentialGroupCredentials', () => { await expect( listCredentialGroupCredentials.execute({ principal: executorPrincipal(), input }) ).rejects.toMatchObject({ - code: 'forbidden', - message: 'Credential Groups are not available. Enterprise plan required.', + code: 'not_found', + message: 'Organization connected accounts are not available', }) expect(mocks.listCredentials).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/credential-groups/application/list-credentials.ts b/apps/sim/lib/credential-groups/application/list-credentials.ts index 8b54994d73b..a81af811f5c 100644 --- a/apps/sim/lib/credential-groups/application/list-credentials.ts +++ b/apps/sim/lib/credential-groups/application/list-credentials.ts @@ -1,12 +1,15 @@ import { isValidEmailSyntax, normalizeEmail } from '@sim/utils/string' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' -import { credentialGroupDelegationPolicy } from '@/lib/credential-groups/application/authorization' import { - requireCredentialGroupsAvailable, - resolveWorkspaceAccountsContext, -} from '@/lib/credential-groups/application/context' + credentialGroupDelegationPolicy, + requireCredentialGroupWorkflowActor, +} from '@/lib/credential-groups/application/authorization' import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' +import { + requireOrganizationAccountsWorkspaceAccess, + resolveOrganizationAccountsWorkspaceContext, +} from '@/lib/credential-groups/application/organization-workspace-access' import { CredentialGroupCredentialCursorNotFoundError, type CredentialGroupCredentialReference, @@ -36,8 +39,12 @@ export interface ListCredentialGroupCredentialsResult { export const listCredentialGroupCredentials = defineAuthorizedWorkspaceUseCase({ operation: credentialGroupOperations.listCredentials, resolveContext: ({ input }: { input: ListCredentialGroupCredentialsInput }) => - resolveWorkspaceAccountsContext(input.workspaceId), + resolveOrganizationAccountsWorkspaceContext(input.workspaceId), authorizationOptions: { delegation: credentialGroupDelegationPolicy }, + async authorizeResource({ principal, context }) { + requireCredentialGroupWorkflowActor(principal) + await requireOrganizationAccountsWorkspaceAccess(context) + }, execute: async ({ input, context }): Promise => { if ( !Number.isInteger(input.limit) || @@ -81,12 +88,10 @@ export const listCredentialGroupCredentials = defineAuthorizedWorkspaceUseCase({ ) } - await requireCredentialGroupsAvailable(context.workspaceId) - let page try { page = await listCredentialGroupCredentialReferences({ - workspaceId: context.workspaceId, + organizationId: context.organizationId, credentialGroupId: context.credentialGroupId, credentialGroupOptionIds: activeOptions.map((option) => option.id), limit: input.limit, diff --git a/apps/sim/lib/credential-groups/application/list-mcp-connections.test.ts b/apps/sim/lib/credential-groups/application/list-mcp-connections.test.ts index 8a1a4d775b6..25487113610 100644 --- a/apps/sim/lib/credential-groups/application/list-mcp-connections.test.ts +++ b/apps/sim/lib/credential-groups/application/list-mcp-connections.test.ts @@ -3,9 +3,11 @@ */ import type { SessionPrincipal, WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { buildOrganizationAccountAccessPolicy } from '@/lib/credential-groups/application/workspace-access-policy' const mocks = vi.hoisted(() => ({ getWorkspaceOwnerSubscriptionAccess: vi.fn(), + requirePolicy: vi.fn(), listMcpConnections: vi.fn(), loadGroup: vi.fn(), loadWorkspace: vi.fn(), @@ -17,12 +19,16 @@ vi.mock('@/lib/billing/core/workspace-access', () => ({ getWorkspaceOwnerSubscriptionAccess: mocks.getWorkspaceOwnerSubscriptionAccess, })) -vi.mock('@/lib/credential-groups/availability', () => ({ - resolveCredentialGroupsAvailability: mocks.resolveCredentialGroupsAvailability, +vi.mock('@/lib/credential-groups/scoped-availability', () => ({ + isScopedCredentialGroupsAvailable: async () => + (await mocks.resolveCredentialGroupsAvailability()).available, +})) +vi.mock('@/lib/resource-policies/repository', () => ({ + requireResourcePolicy: mocks.requirePolicy, })) vi.mock('@/lib/credential-groups/credentials', () => ({ - loadWorkspaceAccountsCredentialListContext: mocks.loadGroup, + loadScopedAccountsCredentialListContext: mocks.loadGroup, })) vi.mock('@/lib/credential-groups/mcp-connections', () => ({ @@ -58,7 +64,7 @@ const groupContext = { } const workspaceContext = { workspaceId: 'workspace-1', - workspaceOrganizationId: null, + workspaceOrganizationId: 'org-1', allowPersonalApiKeys: true, billedAccountUserId: 'billing-owner-1', } @@ -90,6 +96,9 @@ function executorPrincipal(workspaceId = 'workspace-1'): WorkflowExecutionDelega describe('listCredentialGroupMcpConnections', () => { beforeEach(() => { vi.clearAllMocks() + mocks.requirePolicy.mockResolvedValue({ + document: buildOrganizationAccountAccessPolicy('group-1', ['workspace-1']), + }) mocks.loadGroup.mockResolvedValue(groupContext) mocks.loadWorkspace.mockResolvedValue(workspaceContext) mocks.resolvePermission.mockResolvedValue('read') @@ -144,12 +153,13 @@ describe('listCredentialGroupMcpConnections', () => { }) expect(mocks.listMcpConnections).toHaveBeenCalledWith({ - workspaceId: 'workspace-1', + organizationId: 'org-1', credentialGroupId: 'group-1', limit: 50, cursor: undefined, email: 'person@example.com', mcpServerId: 'mcp-server-1', + connectorId: undefined, }) expect(result).toEqual({ mcpConnections: [ diff --git a/apps/sim/lib/credential-groups/application/list-mcp-connections.ts b/apps/sim/lib/credential-groups/application/list-mcp-connections.ts index f3af6657ecf..576b86d27dd 100644 --- a/apps/sim/lib/credential-groups/application/list-mcp-connections.ts +++ b/apps/sim/lib/credential-groups/application/list-mcp-connections.ts @@ -1,12 +1,16 @@ import { isValidEmailSyntax, normalizeEmail } from '@sim/utils/string' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' -import { credentialGroupDelegationPolicy } from '@/lib/credential-groups/application/authorization' import { - requireCredentialGroupsAvailable, - resolveWorkspaceAccountsContext, -} from '@/lib/credential-groups/application/context' + credentialGroupDelegationPolicy, + requireCredentialGroupWorkflowActor, +} from '@/lib/credential-groups/application/authorization' import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' +import { + requireOrganizationAccountsWorkspaceAccess, + resolveOrganizationAccountsWorkspaceContext, +} from '@/lib/credential-groups/application/organization-workspace-access' +import { getManagedMcpConnector } from '@/lib/credential-groups/managed-mcp-connectors' import { CredentialGroupMcpConnectionCursorNotFoundError, type CredentialGroupMcpConnectionReference, @@ -20,6 +24,7 @@ export interface ListCredentialGroupMcpConnectionsInput { cursor?: string email?: string mcpServerId?: string + connectorId?: string } export interface ListCredentialGroupMcpConnectionsResult { @@ -32,8 +37,12 @@ export interface ListCredentialGroupMcpConnectionsResult { export const listCredentialGroupMcpConnections = defineAuthorizedWorkspaceUseCase({ operation: credentialGroupOperations.listMcpConnections, resolveContext: ({ input }: { input: ListCredentialGroupMcpConnectionsInput }) => - resolveWorkspaceAccountsContext(input.workspaceId), + resolveOrganizationAccountsWorkspaceContext(input.workspaceId), authorizationOptions: { delegation: credentialGroupDelegationPolicy }, + async authorizeResource({ principal, context }) { + requireCredentialGroupWorkflowActor(principal) + await requireOrganizationAccountsWorkspaceAccess(context) + }, execute: async ({ input, context }): Promise => { if ( !Number.isInteger(input.limit) || @@ -54,21 +63,21 @@ export const listCredentialGroupMcpConnections = defineAuthorizedWorkspaceUseCas throw new OrchestrationError('validation', 'Email must be a valid address') } const mcpServerId = input.mcpServerId?.trim() + if (input.connectorId !== undefined) getManagedMcpConnector(input.connectorId) if (input.mcpServerId !== undefined && !mcpServerId) { throw new OrchestrationError('validation', 'MCP server ID must not be empty') } - await requireCredentialGroupsAvailable(context.workspaceId) - let page try { page = await listCredentialGroupMcpConnectionReferences({ - workspaceId: context.workspaceId, + organizationId: context.organizationId, credentialGroupId: context.credentialGroupId, limit: input.limit, cursor: input.cursor, email, mcpServerId, + connectorId: input.connectorId, }) } catch (error) { if (error instanceof CredentialGroupMcpConnectionCursorNotFoundError) { diff --git a/apps/sim/lib/credential-groups/application/organization-access.test.ts b/apps/sim/lib/credential-groups/application/organization-access.test.ts new file mode 100644 index 00000000000..f86ea9f7790 --- /dev/null +++ b/apps/sim/lib/credential-groups/application/organization-access.test.ts @@ -0,0 +1,153 @@ +/** @vitest-environment node */ +import type { SessionPrincipal } from '@sim/auth/principal' +import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { eq } from 'drizzle-orm' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + available: vi.fn(), + group: vi.fn(), + setup: vi.fn(), + write: vi.fn(), + policy: vi.fn(), +})) +vi.mock('@/lib/credential-groups/scoped-availability', () => ({ + isScopedCredentialGroupsAvailable: mocks.available, +})) +vi.mock('@/lib/credential-groups/credentials', () => ({ + loadScopedAccountsCredentialListContext: mocks.group, +})) +vi.mock('@/lib/credential-groups/organization-setup', () => ({ + requireOrganizationAccountsSetup: mocks.setup, +})) +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + getUserPermissionConfigForOrganization: vi.fn().mockResolvedValue(null), +})) +vi.mock('@/lib/credential-groups/service', () => ({ + ensureWorkspaceAccountsGroup: vi.fn(), + getOrganizationAccountsGroup: vi.fn(), + updateCredentialGroup: vi.fn(), +})) +vi.mock('@/lib/credential-groups/provider-availability', () => ({ + listConfiguredCredentialGroupProviders: vi.fn(), +})) +vi.mock('@/lib/credential-groups/self-enrollment', () => ({ + createViewerCredentialGroupEnrollment: vi.fn(), +})) +vi.mock('@/lib/resource-policies/repository', () => ({ + requireResourcePolicy: mocks.policy, + writeResourcePolicy: mocks.write, + ResourcePolicyRevisionConflictError: class extends Error {}, +})) + +import { + getOrganizationAccountWorkspaceAccess, + updateOrganizationAccountWorkspaceAccess, +} from '@/lib/credential-groups/application/organization-access' +import { buildOrganizationAccountAccessPolicy } from '@/lib/credential-groups/application/workspace-access-policy' +import { ResourcePolicyRevisionConflictError } from '@/lib/resource-policies/repository' + +const principal: SessionPrincipal = { + kind: 'session', + userId: 'admin-user', + sessionId: 'session-1', +} +const input = { organizationId: 'org-1', revision: 3, workspaceIds: ['workspace-1'] } + +describe('organization workspace sharing administration', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.available.mockResolvedValue(true) + mocks.group.mockResolvedValue({ + credentialGroupId: 'group-1', + name: 'Accounts', + status: 'active', + }) + mocks.setup.mockResolvedValue(undefined) + mocks.policy.mockResolvedValue({ + revision: 3, + document: buildOrganizationAccountAccessPolicy('group-1', []), + }) + mocks.write.mockImplementation(async ({ document }) => ({ revision: 4, document })) + }) + + it.each(['member', null])( + 'denies management by a %s before loading account data', + async (role) => { + queueTableRows(schemaMock.member, role ? [{ role }] : []) + await expect( + updateOrganizationAccountWorkspaceAccess.execute({ principal, input }) + ).rejects.toThrow() + expect(mocks.group).not.toHaveBeenCalled() + expect(mocks.write).not.toHaveBeenCalled() + } + ) + + it('uses the routed org and checks every workspace before granting access', async () => { + queueTableRows(schemaMock.member, [{ role: 'admin' }]) + queueTableRows(schemaMock.workspace, [{ id: 'workspace-1' }]) + await expect( + updateOrganizationAccountWorkspaceAccess.execute({ principal, input }) + ).resolves.toMatchObject({ revision: 4, workspaceIds: ['workspace-1'] }) + expect(eq).toHaveBeenCalledWith(schemaMock.member.userId, 'admin-user') + expect(eq).toHaveBeenCalledWith(schemaMock.member.organizationId, 'org-1') + expect(eq).toHaveBeenCalledWith(schemaMock.workspace.organizationId, 'org-1') + expect(mocks.write).toHaveBeenCalledWith( + expect.objectContaining({ + organizationId: 'org-1', + actorUserId: 'admin-user', + expectedRevision: 3, + }) + ) + }) + + it('refuses foreign or archived workspaces without writing a policy', async () => { + queueTableRows(schemaMock.member, [{ role: 'owner' }]) + queueTableRows(schemaMock.workspace, []) + await expect( + updateOrganizationAccountWorkspaceAccess.execute({ principal, input }) + ).rejects.toThrow('Every allowed workspace') + expect(mocks.write).not.toHaveBeenCalled() + }) + + it('supports revoking every workspace without a replacement workflow grant', async () => { + queueTableRows(schemaMock.member, [{ role: 'admin' }]) + await expect( + updateOrganizationAccountWorkspaceAccess.execute({ + principal, + input: { ...input, workspaceIds: [] }, + }) + ).resolves.toMatchObject({ workspaceIds: [] }) + expect(mocks.write).toHaveBeenCalledWith( + expect.objectContaining({ document: buildOrganizationAccountAccessPolicy('group-1', []) }) + ) + }) + + it('rejects a stale revision rather than overwriting another admin', async () => { + queueTableRows(schemaMock.member, [{ role: 'admin' }]) + mocks.write.mockRejectedValue(new ResourcePolicyRevisionConflictError()) + await expect( + updateOrganizationAccountWorkspaceAccess.execute({ + principal, + input: { ...input, workspaceIds: [] }, + }) + ).rejects.toMatchObject({ code: 'conflict' }) + }) + + it('fails closed when the flag is disabled or legacy setup is unresolved', async () => { + queueTableRows(schemaMock.member, [{ role: 'admin' }]) + mocks.available.mockResolvedValue(false) + await expect( + getOrganizationAccountWorkspaceAccess.execute({ principal, input }) + ).rejects.toMatchObject({ code: 'not_found' }) + expect(mocks.group).not.toHaveBeenCalled() + queueTableRows(schemaMock.member, [{ role: 'admin' }]) + mocks.available.mockResolvedValue(true) + mocks.setup.mockRejectedValue(new Error('Migration review required')) + await expect( + getOrganizationAccountWorkspaceAccess.execute({ principal, input }) + ).rejects.toThrow('Migration review required') + expect(dbChainMockFns.from).not.toHaveBeenCalledWith(schemaMock.workspace) + }) +}) diff --git a/apps/sim/lib/credential-groups/application/organization-access.ts b/apps/sim/lib/credential-groups/application/organization-access.ts new file mode 100644 index 00000000000..7300e2bbfb8 --- /dev/null +++ b/apps/sim/lib/credential-groups/application/organization-access.ts @@ -0,0 +1,143 @@ +import { db } from '@sim/db' +import { workspace } from '@sim/db/schema' +import { and, asc, eq, inArray, isNull } from 'drizzle-orm' +import type { OrganizationMembershipContext } from '@/lib/core/application/organization-authorization' +import { defineOrganizationOperation } from '@/lib/core/application/organization-operation' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { defineOrganizationAccountsUseCase } from '@/lib/credential-groups/application/organization-accounts' +import { + buildOrganizationAccountAccessPolicy, + listOrganizationAccountWorkspaceIds, + organizationAccountAccessPolicyCodec, + organizationAccountWorkspaceIdsSchema, +} from '@/lib/credential-groups/application/workspace-access-policy' +import { loadScopedAccountsCredentialListContext } from '@/lib/credential-groups/credentials' +import { ORGANIZATION_ACCOUNT_WORKSPACE_LIMIT } from '@/lib/credential-groups/limits' +import { + ResourcePolicyRevisionConflictError, + requireResourcePolicy, + writeResourcePolicy, +} from '@/lib/resource-policies/repository' + +export const organizationAccountAccessOperations = { + read: defineOrganizationOperation({ + id: 'organization_accounts.workspace_access.read', + minimumRole: 'admin', + principalKinds: ['session'], + capability: 'integrations.manage', + }), + update: defineOrganizationOperation({ + id: 'organization_accounts.workspace_access.update', + minimumRole: 'admin', + principalKinds: ['session'], + capability: 'integrations.manage', + }), +} as const + +async function requireGroup(organizationId: string) { + const group = await loadScopedAccountsCredentialListContext({ + kind: 'organization', + organizationId, + }) + if (!group) + throw new OrchestrationError('not_found', 'Organization connected accounts are not configured') + return group +} + +export const getOrganizationAccountWorkspaceAccess = defineOrganizationAccountsUseCase({ + operation: organizationAccountAccessOperations.read, + async execute({ context }) { + const group = await requireGroup(context.organizationId) + const [policy, workspaces] = await Promise.all([ + requireResourcePolicy({ + organizationId: context.organizationId, + resourceType: 'credential_group', + resourceId: group.credentialGroupId, + codec: organizationAccountAccessPolicyCodec, + }), + db + .select({ id: workspace.id, name: workspace.name }) + .from(workspace) + .where( + and(eq(workspace.organizationId, context.organizationId), isNull(workspace.archivedAt)) + ) + .orderBy(asc(workspace.name), asc(workspace.id)) + .limit(ORGANIZATION_ACCOUNT_WORKSPACE_LIMIT + 1), + ]) + if (workspaces.length > ORGANIZATION_ACCOUNT_WORKSPACE_LIMIT) + throw new OrchestrationError( + 'validation', + `Workspace access supports at most ${ORGANIZATION_ACCOUNT_WORKSPACE_LIMIT} workspaces` + ) + return { + revision: policy.revision, + workspaceIds: listOrganizationAccountWorkspaceIds(policy.document), + workspaces, + } + }, +}) + +export const updateOrganizationAccountWorkspaceAccess = defineOrganizationAccountsUseCase({ + operation: organizationAccountAccessOperations.update, + async execute({ + input, + context, + }: { + input: { organizationId: string; revision: number; workspaceIds: string[] } + context: OrganizationMembershipContext + }) { + const parsed = organizationAccountWorkspaceIdsSchema.safeParse(input.workspaceIds) + if (!parsed.success) + throw new OrchestrationError( + 'validation', + 'Workspace IDs must be unique, valid identifiers within the supported limit' + ) + const group = await requireGroup(context.organizationId) + if (parsed.data.length) { + const rows = await db + .select({ id: workspace.id }) + .from(workspace) + .where( + and( + eq(workspace.organizationId, context.organizationId), + inArray(workspace.id, parsed.data), + isNull(workspace.archivedAt) + ) + ) + if (rows.length !== parsed.data.length) + throw new OrchestrationError( + 'validation', + 'Every allowed workspace must be active and belong to this organization' + ) + } + try { + const policy = await writeResourcePolicy({ + organizationId: context.organizationId, + resourceType: 'credential_group', + resourceId: group.credentialGroupId, + codec: organizationAccountAccessPolicyCodec, + expectedRevision: input.revision, + document: buildOrganizationAccountAccessPolicy(group.credentialGroupId, parsed.data), + actorUserId: context.userId, + }) + return { + credentialGroupId: group.credentialGroupId, + name: group.name, + revision: policy.revision, + workspaceIds: listOrganizationAccountWorkspaceIds(policy.document), + } + } catch (error) { + if (error instanceof ResourcePolicyRevisionConflictError) + throw new OrchestrationError( + 'conflict', + 'Workspace access changed. Reload before saving again.' + ) + throw error + } + }, + projectAudit: (result) => ({ + resourceId: result.credentialGroupId, + resourceName: result.name, + description: `Allowed ${result.workspaceIds.length} workspaces to use organization connected accounts`, + }), +}) diff --git a/apps/sim/lib/credential-groups/application/organization-account-indexing.test.ts b/apps/sim/lib/credential-groups/application/organization-account-indexing.test.ts new file mode 100644 index 00000000000..0917a7d64ae --- /dev/null +++ b/apps/sim/lib/credential-groups/application/organization-account-indexing.test.ts @@ -0,0 +1,136 @@ +/** @vitest-environment node */ +import { auditMock, auditMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + available: vi.fn(), + group: vi.fn(), + readiness: vi.fn(), + feature: vi.fn(), + setIndexing: vi.fn(), + dispatch: vi.fn(), +})) +vi.mock('@sim/audit', () => auditMock) +vi.mock('@/lib/credential-groups/scoped-availability', () => ({ + isScopedCredentialGroupsAvailable: mocks.available, +})) +vi.mock('@/lib/credential-groups/credentials', () => ({ + loadScopedAccountsCredentialListContext: mocks.group, +})) +vi.mock('@/lib/credential-groups/organization-setup', () => ({ + requireOrganizationAccountsSetup: mocks.readiness, +})) +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + getUserPermissionConfigForOrganization: vi.fn().mockResolvedValue(null), +})) +vi.mock('@/lib/credential-groups/service', () => ({ + ensureWorkspaceAccountsGroup: vi.fn(), + getOrganizationAccountsGroup: vi.fn(), + updateCredentialGroup: vi.fn(), +})) +vi.mock('@/lib/credential-groups/provider-availability', () => ({ + listConfiguredCredentialGroupProviders: vi.fn(), +})) +vi.mock('@/lib/credential-groups/self-enrollment', () => ({ + createViewerCredentialGroupEnrollment: vi.fn(), +})) +vi.mock('@/lib/credential-groups/managed-mcp-service', () => ({ + ManagedMcpConnectorError: class extends Error {}, +})) +vi.mock('@/lib/knowledge/access/availability', () => ({ + requireKnowledgeMemberAccessAvailable: mocks.feature, + isKnowledgeMemberAccessAvailable: vi.fn(), +})) +vi.mock('@/lib/knowledge/connectors/organization-account-indexing', () => ({ + setOrganizationAccountIndexing: mocks.setIndexing, +})) +vi.mock('@/lib/knowledge/connectors/member-queue', () => ({ + dispatchMemberSyncsForCredentialOption: mocks.dispatch, +})) + +import { updateOrganizationAccountIndexing } from '@/lib/credential-groups/application/organization-account-indexing' + +const principal = { kind: 'session' as const, userId: 'admin-1', sessionId: 'session-1' } +const input = { organizationId: 'org-1', optionId: 'option-1', enabled: true } + +describe('organization account indexing authorization', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.available.mockResolvedValue(true) + mocks.group.mockResolvedValue({ credentialGroupId: 'group-1' }) + mocks.feature.mockResolvedValue(undefined) + mocks.setIndexing.mockResolvedValue({ + enabled: true, + changed: true, + providerName: 'Gmail', + knowledgeBaseIds: ['kb-1'], + }) + }) + it.each(['member', null])('denies a %s before reading account data', async (role) => { + queueTableRows(schemaMock.member, role ? [{ role }] : []) + await expect(updateOrganizationAccountIndexing.execute({ principal, input })).rejects.toThrow() + expect(mocks.group).not.toHaveBeenCalled() + expect(mocks.setIndexing).not.toHaveBeenCalled() + }) + it.each(['owner', 'admin'])( + 'allows an org %s and dispatches only that organization option', + async (role) => { + queueTableRows(schemaMock.member, [{ role }]) + await updateOrganizationAccountIndexing.execute({ principal, input }) + expect(mocks.setIndexing).toHaveBeenCalledWith({ ...input, credentialGroupId: 'group-1' }) + expect(mocks.dispatch).toHaveBeenCalledWith({ + organizationId: 'org-1', + credentialGroupOptionId: 'option-1', + }) + expect(auditMockFns.mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ actorId: 'admin-1', metadata: { organizationId: 'org-1' } }) + ) + } + ) + it('requires indexing availability to enable a source', async () => { + queueTableRows(schemaMock.member, [{ role: 'admin' }]) + mocks.feature.mockRejectedValue(new Error('Search is not enabled')) + await expect(updateOrganizationAccountIndexing.execute({ principal, input })).rejects.toThrow( + 'Search is not enabled' + ) + expect(mocks.setIndexing).not.toHaveBeenCalled() + expect(mocks.dispatch).not.toHaveBeenCalled() + }) + it('allows pausing when the Search feature is off and does not dispatch', async () => { + queueTableRows(schemaMock.member, [{ role: 'admin' }]) + mocks.setIndexing.mockResolvedValue({ + enabled: false, + changed: true, + providerName: 'Gmail', + knowledgeBaseIds: ['kb-1'], + }) + await updateOrganizationAccountIndexing.execute({ + principal, + input: { ...input, enabled: false }, + }) + expect(mocks.feature).not.toHaveBeenCalled() + expect(mocks.dispatch).not.toHaveBeenCalled() + }) + it('does not audit or redispatch an unchanged setting', async () => { + queueTableRows(schemaMock.member, [{ role: 'admin' }]) + mocks.setIndexing.mockResolvedValue({ + enabled: true, + changed: false, + providerName: 'Gmail', + knowledgeBaseIds: ['kb-1'], + }) + await updateOrganizationAccountIndexing.execute({ principal, input }) + expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled() + expect(mocks.dispatch).not.toHaveBeenCalled() + }) + it('does not audit or dispatch a failed mutation', async () => { + queueTableRows(schemaMock.member, [{ role: 'admin' }]) + mocks.setIndexing.mockRejectedValue(new Error('Sync already in progress')) + await expect(updateOrganizationAccountIndexing.execute({ principal, input })).rejects.toThrow( + 'Sync already in progress' + ) + expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled() + expect(mocks.dispatch).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credential-groups/application/organization-account-indexing.ts b/apps/sim/lib/credential-groups/application/organization-account-indexing.ts new file mode 100644 index 00000000000..1c6791ea61e --- /dev/null +++ b/apps/sim/lib/credential-groups/application/organization-account-indexing.ts @@ -0,0 +1,60 @@ +import type { OrganizationMembershipContext } from '@/lib/core/application/organization-authorization' +import { defineOrganizationOperation } from '@/lib/core/application/organization-operation' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { defineOrganizationAccountsUseCase } from '@/lib/credential-groups/application/organization-accounts' +import { loadScopedAccountsCredentialListContext } from '@/lib/credential-groups/credentials' +import { requireKnowledgeMemberAccessAvailable } from '@/lib/knowledge/access/availability' +import { dispatchMemberSyncsForCredentialOption } from '@/lib/knowledge/connectors/member-queue' +import { setOrganizationAccountIndexing } from '@/lib/knowledge/connectors/organization-account-indexing' + +export const updateOrganizationAccountIndexingOperation = defineOrganizationOperation({ + id: 'organization_accounts.indexing.update', + minimumRole: 'admin', + principalKinds: ['session'], + capability: 'knowledge.use', +}) + +export const updateOrganizationAccountIndexing = defineOrganizationAccountsUseCase({ + operation: updateOrganizationAccountIndexingOperation, + async execute({ + input, + context, + }: { + input: { organizationId: string; optionId: string; enabled: boolean } + context: OrganizationMembershipContext + }) { + if (input.enabled) + await requireKnowledgeMemberAccessAvailable({ organizationId: context.organizationId }) + const group = await loadScopedAccountsCredentialListContext({ + kind: 'organization', + organizationId: context.organizationId, + }) + if (!group) + throw new OrchestrationError( + 'not_found', + 'Organization connected accounts are not configured' + ) + const result = await setOrganizationAccountIndexing({ + organizationId: context.organizationId, + credentialGroupId: group.credentialGroupId, + optionId: input.optionId, + enabled: input.enabled, + }) + return { ...result, credentialGroupId: group.credentialGroupId, optionId: input.optionId } + }, + projectAudit: (result) => + result.changed + ? { + resourceId: result.credentialGroupId, + resourceName: 'Connected accounts', + description: `${result.enabled ? 'Enabled' : 'Paused'} ${result.providerName} indexing`, + } + : null, + async afterSuccess({ context, result }) { + if (result.enabled && result.changed) + await dispatchMemberSyncsForCredentialOption({ + organizationId: context.organizationId, + credentialGroupOptionId: result.optionId, + }) + }, +}) diff --git a/apps/sim/lib/credential-groups/application/organization-account-management.ts b/apps/sim/lib/credential-groups/application/organization-account-management.ts new file mode 100644 index 00000000000..905feb0831b --- /dev/null +++ b/apps/sim/lib/credential-groups/application/organization-account-management.ts @@ -0,0 +1,235 @@ +import type { OrganizationMembershipContext } from '@/lib/core/application/organization-authorization' +import { defineOrganizationOperation } from '@/lib/core/application/organization-operation' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { defineOrganizationAccountsUseCase } from '@/lib/credential-groups/application/organization-accounts' +import { validateCredentialGroupInvitationEmails } from '@/lib/credential-groups/application/validation' +import { loadScopedAccountsCredentialListContext } from '@/lib/credential-groups/credentials' +import { + inviteCredentialGroupEnrollments, + listCredentialGroupEnrollments, + loadCredentialGroupInviterIdentity, + resendCredentialGroupEnrollment, + revokeCredentialGroupEnrollment, +} from '@/lib/credential-groups/enrollments' +import type { ManagedMcpConnectorId } from '@/lib/credential-groups/managed-mcp-connectors' +import { + type CreateManagedMcpConnectorInput, + createManagedMcpConnector, + deleteManagedMcpConnector, +} from '@/lib/credential-groups/managed-mcp-service' +import { clearCredentialGroupMcpOAuthAttempts } from '@/lib/credential-groups/mcp-oauth-state' +import { evictMcpServerConnections } from '@/lib/mcp/connection-pool' + +export const organizationAccountManagementOperations = { + people: defineOrganizationOperation({ + id: 'organization_accounts.people.list', + minimumRole: 'admin', + principalKinds: ['session'], + capability: 'integrations.manage', + }), + invite: defineOrganizationOperation({ + id: 'organization_accounts.people.invite', + minimumRole: 'admin', + principalKinds: ['session'], + capability: 'integrations.manage', + }), + resend: defineOrganizationOperation({ + id: 'organization_accounts.people.resend', + minimumRole: 'admin', + principalKinds: ['session'], + capability: 'integrations.manage', + }), + revoke: defineOrganizationOperation({ + id: 'organization_accounts.people.revoke', + minimumRole: 'admin', + principalKinds: ['session'], + capability: 'integrations.manage', + }), + addMcp: defineOrganizationOperation({ + id: 'organization_accounts.mcp.add', + minimumRole: 'admin', + principalKinds: ['session'], + capability: 'integrations.manage', + }), + removeMcp: defineOrganizationOperation({ + id: 'organization_accounts.mcp.remove', + minimumRole: 'admin', + principalKinds: ['session'], + capability: 'integrations.manage', + }), +} as const + +interface OrganizationInput { + organizationId: string +} +interface OrganizationExecution { + input: I + context: OrganizationMembershipContext +} + +async function requireGroup(organizationId: string) { + const scope = { kind: 'organization' as const, organizationId } + const group = await loadScopedAccountsCredentialListContext(scope) + if (!group) + throw new OrchestrationError('not_found', 'Organization connected accounts are not configured') + return { scope, group } +} + +async function requireInviterName(userId: string) { + const inviter = await loadCredentialGroupInviterIdentity(userId) + const name = inviter?.name?.trim() || inviter?.email + if (!name) throw new OrchestrationError('conflict', 'Inviting user has no display identity') + return name +} + +function groupAudit(result: { credentialGroupId: string; description: string }) { + return { + resourceId: result.credentialGroupId, + resourceName: 'Connected accounts', + description: result.description, + } +} + +async function evictConnections(connectionIds: string[]) { + await Promise.all( + connectionIds.map((id) => + evictMcpServerConnections(id, 'organization connected accounts changed') + ) + ) +} + +export const listOrganizationAccountPeople = defineOrganizationAccountsUseCase({ + operation: organizationAccountManagementOperations.people, + async execute({ + input, + context, + }: OrganizationExecution< + OrganizationInput & { limit: number; cursor?: string; email?: string } + >) { + const { scope, group } = await requireGroup(context.organizationId) + return listCredentialGroupEnrollments( + scope, + group.credentialGroupId, + input.limit, + input.cursor, + { email: input.email } + ) + }, +}) + +export const inviteOrganizationAccountPeople = defineOrganizationAccountsUseCase({ + operation: organizationAccountManagementOperations.invite, + async execute({ + input, + context, + }: OrganizationExecution) { + const emails = validateCredentialGroupInvitationEmails(input.emails) + const { scope, group } = await requireGroup(context.organizationId) + const result = await inviteCredentialGroupEnrollments( + scope, + group.credentialGroupId, + context.userId, + await requireInviterName(context.userId), + { emails } + ) + return { + ...result, + credentialGroupId: group.credentialGroupId, + description: `Sent ${result.sentCount} connected account invitations`, + } + }, + projectAudit: groupAudit, +}) + +export const resendOrganizationAccountInvitation = defineOrganizationAccountsUseCase({ + operation: organizationAccountManagementOperations.resend, + async execute({ + input, + context, + }: OrganizationExecution) { + const { scope, group } = await requireGroup(context.organizationId) + const enrollment = await resendCredentialGroupEnrollment( + scope, + group.credentialGroupId, + input.enrollmentId, + context.userId, + await requireInviterName(context.userId) + ) + return { + credentialGroupEnrollment: enrollment, + credentialGroupId: group.credentialGroupId, + description: 'Resent a connected account invitation', + } + }, + projectAudit: groupAudit, +}) + +export const revokeOrganizationAccountEnrollment = defineOrganizationAccountsUseCase({ + operation: organizationAccountManagementOperations.revoke, + async execute({ + input, + context, + }: OrganizationExecution) { + const { scope, group } = await requireGroup(context.organizationId) + const result = await revokeCredentialGroupEnrollment( + scope, + group.credentialGroupId, + input.enrollmentId + ) + return { + ...result, + credentialGroupId: group.credentialGroupId, + description: 'Revoked a person’s connected accounts', + } + }, + projectAudit: groupAudit, + afterSuccess: ({ result }) => evictConnections(result.retiredMcpConnectionIds), +}) + +export const addOrganizationAccountMcpProvider = defineOrganizationAccountsUseCase({ + operation: organizationAccountManagementOperations.addMcp, + async execute({ + input, + context, + }: OrganizationExecution) { + const { group } = await requireGroup(context.organizationId) + const { organizationId: _organizationId, ...connectorInput } = input + const result = await createManagedMcpConnector({ + organizationId: context.organizationId, + credentialGroupId: group.credentialGroupId, + userId: context.userId, + input: connectorInput, + }) + return { + ...result, + credentialGroupId: group.credentialGroupId, + description: `Added ${result.mcpServer.name} to connected accounts`, + } + }, + projectAudit: groupAudit, +}) + +export const removeOrganizationAccountMcpProvider = defineOrganizationAccountsUseCase({ + operation: organizationAccountManagementOperations.removeMcp, + async execute({ + input, + context, + }: OrganizationExecution) { + const { group } = await requireGroup(context.organizationId) + const result = await deleteManagedMcpConnector({ + organizationId: context.organizationId, + credentialGroupId: group.credentialGroupId, + connectorId: input.connectorId, + }) + return { + ...result, + credentialGroupId: group.credentialGroupId, + description: `Removed ${result.mcpServer.name} from connected accounts`, + } + }, + projectAudit: groupAudit, + async afterSuccess({ result }) { + await clearCredentialGroupMcpOAuthAttempts(result.serverIds) + await evictConnections([...result.serverIds, ...result.retiredMcpConnectionIds]) + }, +}) diff --git a/apps/sim/lib/credential-groups/application/organization-accounts.ts b/apps/sim/lib/credential-groups/application/organization-accounts.ts index b95265fce6d..ccf1782f170 100644 --- a/apps/sim/lib/credential-groups/application/organization-accounts.ts +++ b/apps/sim/lib/credential-groups/application/organization-accounts.ts @@ -10,6 +10,10 @@ import { } from '@/lib/core/application/organization-operation' import { OrchestrationError } from '@/lib/core/orchestration/types' import { validateUpdateCredentialGroupInput } from '@/lib/credential-groups/application/validation' +import { loadScopedAccountsCredentialListContext } from '@/lib/credential-groups/credentials' +import { CredentialGroupEnrollmentError } from '@/lib/credential-groups/enrollments' +import { ManagedMcpConnectorError } from '@/lib/credential-groups/managed-mcp-service' +import { requireOrganizationAccountsSetup } from '@/lib/credential-groups/organization-setup' import { listConfiguredCredentialGroupProviders } from '@/lib/credential-groups/provider-availability' import { isScopedCredentialGroupsAvailable } from '@/lib/credential-groups/scoped-availability' import { createViewerCredentialGroupEnrollment } from '@/lib/credential-groups/self-enrollment' @@ -22,6 +26,7 @@ import type { CredentialGroupOptionInput, UpdateCredentialGroupInput, } from '@/lib/credential-groups/types' +import { isKnowledgeMemberAccessAvailable } from '@/lib/knowledge/access/availability' export const organizationAccountOperations = { read: defineOrganizationOperation({ @@ -54,14 +59,17 @@ interface OrganizationAccountsInput { organizationId: string } -function defineOrganizationAccountsUseCase< +export function defineOrganizationAccountsUseCase< const O extends OrganizationOperation, I extends OrganizationAccountsInput, R, >(definition: { operation: O execute(args: { input: I; context: OrganizationMembershipContext }): Promise - projectAudit?(result: R): { resourceId: string; resourceName: string; description: string } | null + projectAudit?( + result: NoInfer + ): { resourceId: string; resourceName: string; description: string } | null + afterSuccess?(args: { result: NoInfer; context: OrganizationMembershipContext }): Promise }): OperationUseCase { return { operation: definition.operation, @@ -75,7 +83,33 @@ function defineOrganizationAccountsUseCase< ) { throw new OrchestrationError('not_found', 'Connected accounts are not available') } - const result = await definition.execute({ input, context }) + if (definition.operation.id !== organizationAccountOperations.ensure.id) { + const group = await loadScopedAccountsCredentialListContext({ + kind: 'organization', + organizationId: context.organizationId, + }) + if (group) + await requireOrganizationAccountsSetup(context.organizationId, group.credentialGroupId) + } + const result = await definition.execute({ input, context }).catch((error: unknown) => { + if (error instanceof ManagedMcpConnectorError) + throw new OrchestrationError( + error.code === 'bad_gateway' ? 'internal' : error.code, + error.message + ) + if (error instanceof CredentialGroupEnrollmentError) + throw new OrchestrationError( + error.status === 404 + ? 'not_found' + : error.status === 409 + ? 'conflict' + : error.status === 400 + ? 'validation' + : 'internal', + error.message + ) + throw error + }) const audit = definition.projectAudit?.(result) if (audit) recordAudit({ @@ -86,6 +120,7 @@ function defineOrganizationAccountsUseCase< metadata: { organizationId: context.organizationId }, request, }) + await definition.afterSuccess?.({ result, context }) return result }, } @@ -98,6 +133,9 @@ export const getOrganizationAccountsSettings = defineOrganizationAccountsUseCase credentialGroup: await getOrganizationAccountsGroup(context.organizationId), availableProviders: listConfiguredCredentialGroupProviders(), canManage: context.role === 'owner' || context.role === 'admin', + indexingAvailable: await isKnowledgeMemberAccessAvailable({ + organizationId: context.organizationId, + }), } }, }) diff --git a/apps/sim/lib/credential-groups/application/organization-databricks-setup.ts b/apps/sim/lib/credential-groups/application/organization-databricks-setup.ts new file mode 100644 index 00000000000..9fafe3301fd --- /dev/null +++ b/apps/sim/lib/credential-groups/application/organization-databricks-setup.ts @@ -0,0 +1,32 @@ +import { defineOrganizationOperation } from '@/lib/core/application/organization-operation' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { defineOrganizationAccountsUseCase } from '@/lib/credential-groups/application/organization-accounts' +import { loadScopedAccountsCredentialListContext } from '@/lib/credential-groups/credentials' +import { loadOrganizationDatabricksSetup } from '@/lib/credential-groups/managed-mcp-service' + +export const getOrganizationDatabricksSetupOperation = defineOrganizationOperation({ + id: 'organization_accounts.mcp.setup.read', + minimumRole: 'admin', + principalKinds: ['session'], + capability: 'integrations.manage', +}) + +export const getOrganizationDatabricksSetup = defineOrganizationAccountsUseCase({ + operation: getOrganizationDatabricksSetupOperation, + async execute({ context }) { + const group = await loadScopedAccountsCredentialListContext({ + kind: 'organization', + organizationId: context.organizationId, + }) + if (!group) + throw new OrchestrationError( + 'not_found', + 'Organization connected accounts are not configured' + ) + const server = await loadOrganizationDatabricksSetup( + context.organizationId, + group.credentialGroupId + ) + return { server } + }, +}) diff --git a/apps/sim/lib/credential-groups/application/organization-workspace-access.ts b/apps/sim/lib/credential-groups/application/organization-workspace-access.ts new file mode 100644 index 00000000000..76ed97340cf --- /dev/null +++ b/apps/sim/lib/credential-groups/application/organization-workspace-access.ts @@ -0,0 +1,63 @@ +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { CredentialGroupApplicationContext } from '@/lib/credential-groups/application/authorization' +import { resolveCredentialGroupWorkspaceContext } from '@/lib/credential-groups/application/context' +import { + organizationAccountAccessPolicyCodec, + organizationAccountPolicyAllowsWorkspace, +} from '@/lib/credential-groups/application/workspace-access-policy' +import { loadScopedAccountsCredentialListContext } from '@/lib/credential-groups/credentials' +import { isScopedCredentialGroupsAvailable } from '@/lib/credential-groups/scoped-availability' +import { requireResourcePolicy } from '@/lib/resource-policies/repository' + +export interface OrganizationAccountsWorkspaceContext extends CredentialGroupApplicationContext { + organizationId: string +} + +/** Resolves the singleton using the executing workspace's current organization. */ +export async function resolveOrganizationAccountsWorkspaceContext( + workspaceId: string +): Promise { + const workspace = await resolveCredentialGroupWorkspaceContext(workspaceId) + if (!workspace.workspaceOrganizationId) { + throw new OrchestrationError('forbidden', 'This workspace does not belong to an organization') + } + const group = await loadScopedAccountsCredentialListContext({ + kind: 'organization', + organizationId: workspace.workspaceOrganizationId, + }) + if (!group) + throw new OrchestrationError('not_found', 'Organization connected accounts are not configured') + return { ...group, ...workspace, organizationId: workspace.workspaceOrganizationId } +} + +/** Uses live policy and ownership; cached selections and deployment snapshots never grant access. */ +export async function requireOrganizationAccountsWorkspaceAccess(context: { + workspaceId: string + workspaceOrganizationId: string | null + organizationId: string + credentialGroupId: string +}): Promise { + if (context.organizationId !== context.workspaceOrganizationId) { + throw new OrchestrationError('forbidden', 'Connected accounts belong to another organization') + } + if ( + !(await isScopedCredentialGroupsAvailable({ + kind: 'organization', + organizationId: context.organizationId, + })) + ) { + throw new OrchestrationError('not_found', 'Organization connected accounts are not available') + } + const policy = await requireResourcePolicy({ + organizationId: context.organizationId, + resourceType: 'credential_group', + resourceId: context.credentialGroupId, + codec: organizationAccountAccessPolicyCodec, + }) + if (!organizationAccountPolicyAllowsWorkspace(policy.document, context.workspaceId)) { + throw new OrchestrationError( + 'forbidden', + 'An organization admin must allow this workspace to use connected accounts' + ) + } +} diff --git a/apps/sim/lib/credential-groups/application/personal-organization-accounts.test.ts b/apps/sim/lib/credential-groups/application/personal-organization-accounts.test.ts new file mode 100644 index 00000000000..e4708a87ab4 --- /dev/null +++ b/apps/sim/lib/credential-groups/application/personal-organization-accounts.test.ts @@ -0,0 +1,114 @@ +/** @vitest-environment node */ +import type { SessionPrincipal } from '@sim/auth/principal' +import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { eq } from 'drizzle-orm' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + lock: vi.fn(), + evict: vi.fn(), + invite: vi.fn(), + available: vi.fn(), +})) +vi.mock('@/lib/credential-groups/enrollments', () => ({ + lockCredentialGroupEnrollmentLifecycle: mocks.lock, +})) +vi.mock('@/lib/mcp/connection-pool', () => ({ evictMcpServerConnections: mocks.evict })) +vi.mock('@/lib/credential-groups/self-enrollment', () => ({ + createViewerCredentialGroupEnrollment: mocks.invite, +})) +vi.mock('@/lib/credential-groups/scoped-availability', () => ({ + isScopedCredentialGroupsAvailable: mocks.available, +})) + +import { + disconnectPersonalOrganizationAccount, + listPersonalOrganizationAccounts, + reconnectPersonalOrganizationAccount, +} from '@/lib/credential-groups/application/personal-organization-accounts' + +const principal: SessionPrincipal = { + kind: 'session', + userId: 'contributor', + sessionId: 'session-1', +} +const input = { credentialId: 'mcp-cg-person' } +const row = { + ...input, + displayName: 'Fireflies', + providerId: null, + type: 'managed_mcp', + status: 'active', + organizationId: 'org-1', + organizationName: 'Acme', + groupId: 'group-1', + groupStatus: 'active', + enrollmentId: 'enrollment-1', + enrollmentStatus: 'completed', + optionId: null, + mcpProvider: 'fireflies', +} + +describe('personal organization contributions', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.available.mockResolvedValue(true) + mocks.invite.mockResolvedValue({ invitationLink: 'https://sim.test/enroll/token' }) + }) + + it('lists only the stable signed-in identity without requiring org membership', async () => { + queueTableRows(schemaMock.credential, [row]) + const result = await listPersonalOrganizationAccounts.execute({ principal, input: {} }) + expect(result.accounts).toEqual([ + expect.objectContaining({ + credentialId: 'mcp-cg-person', + providerId: 'fireflies', + canReconnect: true, + }), + ]) + expect(eq).toHaveBeenCalledWith(schemaMock.credentialGroupEnrollment.userId, 'contributor') + expect(dbChainMockFns.from).not.toHaveBeenCalledWith(schemaMock.member) + }) + + it('refuses disconnect of another contributor’s account', async () => { + queueTableRows(schemaMock.credential, []) + await expect( + disconnectPersonalOrganizationAccount.execute({ principal, input }) + ).rejects.toMatchObject({ code: 'not_found' }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('rechecks ownership under the lifecycle lock before revoking', async () => { + queueTableRows(schemaMock.credential, [row]) + queueTableRows(schemaMock.credential, []) + await expect( + disconnectPersonalOrganizationAccount.execute({ principal, input }) + ).rejects.toMatchObject({ code: 'not_found' }) + expect(mocks.lock).toHaveBeenCalledWith(expect.any(Object), 'enrollment-1') + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('revokes locally and invalidates pending attempts even with the flag off', async () => { + queueTableRows(schemaMock.credential, [row]) + queueTableRows(schemaMock.credential, [row]) + mocks.available.mockResolvedValue(false) + await disconnectPersonalOrganizationAccount.execute({ principal, input }) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ managedOauthStatus: 'revoked', revokedAt: expect.any(Date) }) + ) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ invitationTokenHash: expect.any(String) }) + ) + expect(mocks.evict).toHaveBeenCalledWith('mcp-cg-person', expect.any(String)) + expect(mocks.available).not.toHaveBeenCalled() + }) + + it('does not restore an enrollment revoked by its administrator', async () => { + queueTableRows(schemaMock.credential, [{ ...row, enrollmentStatus: 'revoked' }]) + await expect( + reconnectPersonalOrganizationAccount.execute({ principal, input }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.invite).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credential-groups/application/personal-organization-accounts.ts b/apps/sim/lib/credential-groups/application/personal-organization-accounts.ts new file mode 100644 index 00000000000..243643c45d6 --- /dev/null +++ b/apps/sim/lib/credential-groups/application/personal-organization-accounts.ts @@ -0,0 +1,203 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import type { SessionPrincipal } from '@sim/auth/principal' +import { db } from '@sim/db' +import { + credential, + credentialGroup, + credentialGroupEnrollment, + mcpServers, + organization, +} from '@sim/db/schema' +import { sha256Hex } from '@sim/security/hash' +import { generateId } from '@sim/utils/id' +import { and, asc, eq, gt, inArray, isNotNull } from 'drizzle-orm' +import { defineOperation } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { sameResourceScopeCondition } from '@/lib/core/resource-scope.server' +import { lockCredentialGroupEnrollmentLifecycle } from '@/lib/credential-groups/enrollments' +import { isScopedCredentialGroupsAvailable } from '@/lib/credential-groups/scoped-availability' +import { createViewerCredentialGroupEnrollment } from '@/lib/credential-groups/self-enrollment' +import { defineAuthorizedCredentialUserUseCase } from '@/lib/credentials/application/authorized-user-use-case' +import type { DbOrTx } from '@/lib/db/types' +import { evictMcpServerConnections } from '@/lib/mcp/connection-pool' + +/** Contributors retain control of their own grants even without organization or workspace membership. */ +export const personalOrganizationAccountOperations = { + /** + * permission-group-exempt: Contributors retain control of their own grants after organization membership ends. + */ + list: defineOperation({ + id: 'organization_accounts.personal.list', + principalKinds: ['session'], + capability: 'none', + }), + /** + * permission-group-exempt: Contributors retain control of their own grants after organization membership ends. + */ + reconnect: defineOperation({ + id: 'organization_accounts.personal.reconnect', + principalKinds: ['session'], + capability: 'none', + }), + /** + * permission-group-exempt: Contributors retain control of their own grants after organization membership ends. + */ + disconnect: defineOperation({ + id: 'organization_accounts.personal.disconnect', + principalKinds: ['session'], + capability: 'none', + }), +} as const + +function ownAccounts( + userId: string, + input: { credentialId?: string; cursor?: string }, + executor: DbOrTx = db +) { + return executor + .select({ + credentialId: credential.id, + displayName: credential.displayName, + providerId: credential.providerId, + type: credential.type, + status: credential.managedOauthStatus, + organizationId: organization.id, + organizationName: organization.name, + groupId: credentialGroup.id, + groupStatus: credentialGroup.status, + enrollmentId: credentialGroupEnrollment.id, + enrollmentStatus: credentialGroupEnrollment.status, + optionId: credential.credentialGroupOptionId, + mcpProvider: mcpServers.managedConnectorId, + }) + .from(credential) + .innerJoin( + credentialGroupEnrollment, + eq(credentialGroupEnrollment.id, credential.credentialGroupEnrollmentId) + ) + .innerJoin(credentialGroup, eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId)) + .innerJoin(organization, eq(organization.id, credentialGroup.organizationId)) + .leftJoin(mcpServers, eq(mcpServers.id, credential.mcpServerId)) + .where( + and( + eq(credentialGroupEnrollment.userId, userId), + sameResourceScopeCondition(credential, credentialGroup), + isNotNull(credential.organizationId), + inArray(credential.type, ['managed_oauth', 'managed_mcp']), + input.credentialId ? eq(credential.id, input.credentialId) : undefined, + input.cursor ? gt(credential.id, input.cursor) : undefined + ) + ) + .orderBy(asc(credential.id)) + .limit(input.credentialId ? 1 : 51) +} + +export const listPersonalOrganizationAccounts = defineAuthorizedCredentialUserUseCase({ + operation: personalOrganizationAccountOperations.list, + async execute({ principal, input }: { principal: SessionPrincipal; input: { cursor?: string } }) { + const rows = await ownAccounts(principal.userId, input) + const page = rows.slice(0, 50) + return { + accounts: page.map((row) => { + const providerId = row.type === 'managed_mcp' ? row.mcpProvider : row.providerId + if (!providerId || !row.status) + throw new Error('Organization account identity is incomplete') + return { + credentialId: row.credentialId, + displayName: row.displayName, + providerId, + kind: row.type === 'managed_mcp' ? ('mcp' as const) : ('oauth' as const), + status: row.status, + organizationId: row.organizationId, + organizationName: row.organizationName, + enrollmentStatus: row.enrollmentStatus, + canReconnect: row.groupStatus === 'active' && row.enrollmentStatus !== 'revoked', + } + }), + nextCursor: rows.length > 50 ? page.at(-1)!.credentialId : null, + } + }, +}) + +export const reconnectPersonalOrganizationAccount = defineAuthorizedCredentialUserUseCase({ + operation: personalOrganizationAccountOperations.reconnect, + async execute({ + principal, + input, + }: { + principal: SessionPrincipal + input: { credentialId: string } + }) { + const [account] = await ownAccounts(principal.userId, input) + if (!account) throw new OrchestrationError('not_found', 'Connected account not found') + if (account.groupStatus !== 'active' || account.enrollmentStatus === 'revoked') + throw new OrchestrationError( + 'forbidden', + 'An organization admin must restore your access before you reconnect' + ) + if ( + !(await isScopedCredentialGroupsAvailable({ + kind: 'organization', + organizationId: account.organizationId, + })) + ) + throw new OrchestrationError('forbidden', 'Organization connected accounts are unavailable') + const { invitationLink } = await createViewerCredentialGroupEnrollment({ + organizationId: account.organizationId, + credentialGroupId: account.groupId, + userId: principal.userId, + }) + const url = new URL(invitationLink) + if (account.optionId) url.searchParams.set('optionId', account.optionId) + return { invitationLink: url.toString() } + }, +}) + +export const disconnectPersonalOrganizationAccount = defineAuthorizedCredentialUserUseCase({ + operation: personalOrganizationAccountOperations.disconnect, + async execute({ + principal, + input, + }: { + principal: SessionPrincipal + input: { credentialId: string } + }) { + const [account] = await ownAccounts(principal.userId, input) + if (!account) throw new OrchestrationError('not_found', 'Connected account not found') + await db.transaction(async (tx) => { + await lockCredentialGroupEnrollmentLifecycle(tx, account.enrollmentId) + const [current] = await ownAccounts(principal.userId, input, tx) + if (!current || current.enrollmentId !== account.enrollmentId) + throw new OrchestrationError('not_found', 'Connected account not found') + await tx + .update(credential) + .set({ managedOauthStatus: 'revoked', revokedAt: new Date(), updatedAt: new Date() }) + .where(eq(credential.id, current.credentialId)) + await tx + .update(credentialGroupEnrollment) + .set({ invitationTokenHash: sha256Hex(generateId()), updatedAt: new Date() }) + .where(eq(credentialGroupEnrollment.id, current.enrollmentId)) + }) + return { + success: true as const, + credentialId: account.credentialId, + organizationId: account.organizationId, + managedMcp: account.type === 'managed_mcp', + } + }, + projectAudit: ({ input, result }) => ({ + workspaceId: null, + action: AuditAction.CREDENTIAL_UPDATED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: input.credentialId, + description: 'Disconnected own organization account', + metadata: { organizationId: result.organizationId }, + }), + afterSuccess: async ({ result }) => { + if (result.managedMcp) + await evictMcpServerConnections( + result.credentialId, + 'contributor disconnected organization account' + ) + }, +}) diff --git a/apps/sim/lib/credential-groups/application/public-enrollment.test.ts b/apps/sim/lib/credential-groups/application/public-enrollment.test.ts index 32ff083b2ae..97b50175865 100644 --- a/apps/sim/lib/credential-groups/application/public-enrollment.test.ts +++ b/apps/sim/lib/credential-groups/application/public-enrollment.test.ts @@ -6,6 +6,7 @@ import { sha256Hex } from '@sim/security/hash' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ + bind: vi.fn(), completeEnrollment: vi.fn(), completeOAuth: vi.fn(), fireTrigger: vi.fn(), @@ -20,6 +21,7 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('@/lib/credential-groups/enrollments', () => ({ + bindCredentialGroupEnrollmentUser: mocks.bind, completeAuthorizedCredentialGroupEnrollment: mocks.completeEnrollment, getAuthorizedCredentialGroupMcpOAuthContext: mocks.getMcpOAuthContext, getAuthorizedCredentialGroupOAuthContext: mocks.getOAuthContext, @@ -54,21 +56,24 @@ import { const invitationToken = 'invitation-token' const principal: CredentialGroupEnrollmentPrincipal = { kind: 'credential_group_enrollment', - workspaceId: 'workspace-1', + userId: 'user-1', + organizationId: 'org-1', credentialGroupId: 'group-1', enrollmentId: 'enrollment-1', email: 'person@example.com', invitationTokenHash: sha256Hex(invitationToken), } const identity = { - workspaceId: principal.workspaceId, + organizationId: principal.organizationId, + userId: principal.userId, credentialGroupId: principal.credentialGroupId, enrollmentId: principal.enrollmentId, email: principal.email, invitationTokenHash: principal.invitationTokenHash, } const oauthAttempt = { - workspaceId: principal.workspaceId, + organizationId: principal.organizationId, + userId: principal.userId, email: principal.email, state: 'state-1', provider: 'gmail' as const, @@ -87,6 +92,7 @@ const oauthAttempt = { describe('public Credential Group enrollment application operations', () => { beforeEach(() => { vi.clearAllMocks() + mocks.bind.mockResolvedValue(undefined) mocks.getEnrollment.mockResolvedValue({ status: 'invited', credentialGroupName: 'Credential Group', @@ -235,7 +241,7 @@ describe('public Credential Group enrollment application operations', () => { expect(result).toEqual({ completed: true }) expect(mocks.fireTrigger).toHaveBeenCalledWith({ event: 'form_submitted', - workspaceId: 'workspace-1', + organizationId: 'org-1', credentialGroupId: 'group-1', credentialGroupName: 'Credential Group', enrollmentId: 'enrollment-1', @@ -297,30 +303,19 @@ describe('public Credential Group enrollment application operations', () => { expect.objectContaining({ event: 'credential_reconnected', enrollmentStatus: 'completed' }) ) }) - it('completes a consumed attempt after invitation rotation without granting new public read authority', async () => { - mocks.getOAuthContext.mockResolvedValue(null) - mocks.getEnrollment.mockResolvedValue(null) + it('rejects a consumed attempt after invitation rotation before exchanging its code', async () => { + mocks.bind.mockRejectedValue(new Error('Invitation is invalid or expired')) await expect( completePublicCredentialGroupOAuth.execute({ principal, input: { attempt: oauthAttempt, code: 'code' }, }) - ).resolves.toEqual({ connectedOptionId: 'option-1' }) - expect(mocks.getAttemptContext).toHaveBeenCalledWith(identity, 'option-1') - expect(mocks.getOAuthContext).not.toHaveBeenCalled() - await expect( - readPublicCredentialGroupEnrollment.execute({ principal, input: {} }) - ).rejects.toMatchObject({ code: 'not_found' }) - await expect( - startPublicCredentialGroupOAuth.execute({ - principal, - input: { invitationToken, optionId: 'option-1' }, - }) - ).rejects.toMatchObject({ code: 'not_found' }) - expect(mocks.startOAuth).not.toHaveBeenCalled() + ).rejects.toThrow('Invitation is invalid or expired') + expect(mocks.completeOAuth).not.toHaveBeenCalled() }) it.each([ - { workspaceId: 'other' }, + { organizationId: 'other' }, + { userId: 'other-user' }, { credentialGroupId: 'other' }, { enrollmentId: 'other' }, { email: 'other@example.com' }, @@ -346,30 +341,90 @@ describe('public Credential Group enrollment application operations', () => { expect(mocks.completeOAuth).not.toHaveBeenCalled() expect(mocks.fireTrigger).not.toHaveBeenCalled() }) - it('completes a pinned MCP attempt after another account rotates its invitation, but cannot restart', async () => { - const attempt = { ...oauthAttempt, mcpServerId: 'mcp-server-1', codeVerifier: 'verifier' } - mocks.getMcpOAuthContext.mockResolvedValue(null) - mocks.getMcpAttemptContext.mockResolvedValue({ server: { id: 'mcp-server-1' } }) + it('emits the personal MCP connection ID after completing the current configuration', async () => { + const attempt = { + ...oauthAttempt, + mcpServerId: 'mcp-server-1', + oauthConfigVersion: 2, + codeVerifier: 'verifier', + } + mocks.getMcpAttemptContext.mockResolvedValue({ + credentialGroupName: 'Accounts', + server: { + id: 'mcp-server-1', + connectorId: 'fireflies', + name: 'Fireflies', + oauthConfigVersion: 2, + }, + }) mocks.completeMcpOAuth.mockResolvedValue({ - connectionId: 'connection', + connectionId: 'mcp-cg-person', mcpServerId: 'mcp-server-1', + created: true, + enrollmentStatus: 'in_progress', }) await expect( completePublicCredentialGroupMcpOAuth.execute({ principal, input: { attempt, code: 'code' } }) - ).resolves.toEqual({ connectionId: 'connection', mcpServerId: 'mcp-server-1' }) - expect(mocks.getMcpAttemptContext).toHaveBeenCalledWith(identity, 'mcp-server-1') - expect(mocks.getMcpOAuthContext).not.toHaveBeenCalled() + ).resolves.toEqual({ connectionId: 'mcp-cg-person', mcpServerId: 'mcp-server-1' }) + expect(mocks.completeMcpOAuth).toHaveBeenCalledWith( + expect.any(Object), + 'verifier', + 'code', + invitationToken + ) + expect(mocks.fireTrigger).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'credential_added', + organizationId: 'org-1', + credential: expect.objectContaining({ + credentialId: 'mcp-cg-person', + mcpServerId: 'mcp-server-1', + }), + }) + ) + }) + + it('rejects a changed MCP configuration before exchanging the code', async () => { + mocks.getMcpAttemptContext.mockResolvedValue({ server: { oauthConfigVersion: 3 } }) await expect( - startPublicCredentialGroupMcpOAuth.execute({ + completePublicCredentialGroupMcpOAuth.execute({ principal, - input: { invitationToken, mcpServerId: 'mcp-server-1' }, + input: { + attempt: { + ...oauthAttempt, + mcpServerId: 'mcp-server-1', + oauthConfigVersion: 2, + codeVerifier: 'verifier', + }, + code: 'code', + }, }) - ).rejects.toMatchObject({ code: 'not_found' }) - expect(mocks.startMcpOAuth).not.toHaveBeenCalled() + ).rejects.toMatchObject({ code: 'conflict' }) + expect(mocks.completeMcpOAuth).not.toHaveBeenCalled() + }) + + it('rejects a pending MCP attempt after disconnect rotates its invitation', async () => { + mocks.bind.mockRejectedValue(new Error('Invitation is invalid or expired')) + await expect( + completePublicCredentialGroupMcpOAuth.execute({ + principal, + input: { + attempt: { + ...oauthAttempt, + mcpServerId: 'mcp-server-1', + oauthConfigVersion: 2, + codeVerifier: 'verifier', + }, + code: 'code', + }, + }) + ).rejects.toThrow('Invitation is invalid or expired') + expect(mocks.completeMcpOAuth).not.toHaveBeenCalled() }) it.each([ - 'workspaceId', + 'organizationId', + 'userId', 'credentialGroupId', 'enrollmentId', 'email', diff --git a/apps/sim/lib/credential-groups/application/public-enrollment.ts b/apps/sim/lib/credential-groups/application/public-enrollment.ts index 08b00747838..a132e443115 100644 --- a/apps/sim/lib/credential-groups/application/public-enrollment.ts +++ b/apps/sim/lib/credential-groups/application/public-enrollment.ts @@ -10,6 +10,7 @@ import { } from '@/lib/core/resource-scope' import { credentialGroupEnrollmentOperations } from '@/lib/credential-groups/application/enrollment-operations' import { + bindCredentialGroupEnrollmentUser, completeAuthorizedCredentialGroupEnrollment, getAuthorizedCredentialGroupMcpOAuthContext, getAuthorizedCredentialGroupOAuthContext, @@ -40,7 +41,7 @@ interface AuthorizedCredentialGroupEnrollmentUseCaseDefinition { function requireCredentialGroupEnrollmentPrincipal( principal: Principal ): asserts principal is CredentialGroupEnrollmentPrincipal { - if (principal.kind !== 'credential_group_enrollment') { + if (principal.kind !== 'credential_group_enrollment' || !principal.userId?.trim()) { throw new OrchestrationError( 'forbidden', 'This operation requires a Credential Group invitation' @@ -57,6 +58,7 @@ function requireMatchingContext( context.credentialGroupId !== principal.credentialGroupId || context.enrollmentId !== principal.enrollmentId || context.email !== principal.email || + context.userId !== principal.userId || !safeCompare(context.invitationTokenHash, principal.invitationTokenHash) ) { throw new OrchestrationError('not_found', 'Invitation is invalid or expired') @@ -74,6 +76,7 @@ function defineAuthorizedCredentialGroupEnrollmentUseCase< ): OperationUseCase { async function authorize(principal: Principal, input: I) { requireCredentialGroupEnrollmentPrincipal(principal) + await bindCredentialGroupEnrollmentUser(identityFromPrincipal(principal), principal.userId) const context = await definition.resolveContext({ principal, input }) requireMatchingContext(principal, context) return { principal, input, context } @@ -100,6 +103,7 @@ function identityFromPrincipal( enrollmentId: principal.enrollmentId, email: principal.email, invitationTokenHash: principal.invitationTokenHash, + userId: principal.userId, } } @@ -151,10 +155,10 @@ export const completePublicCredentialGroupEnrollment = resolveContext: ({ principal }) => resolvePublicEnrollmentContext(principal), async execute({ context }) { const completion = await completeAuthorizedCredentialGroupEnrollment(context) - if (completion?.transitioned && context.workspaceId) { + if (completion?.transitioned && context.organizationId) { await fireCredentialGroupTrigger({ event: 'form_submitted', - workspaceId: context.workspaceId, + organizationId: context.organizationId, credentialGroupId: context.credentialGroupId, credentialGroupName: context.enrollment.credentialGroupName, enrollmentId: context.enrollmentId, @@ -220,6 +224,7 @@ function identityForOAuthAttempt( | 'enrollmentId' | 'email' | 'invitationToken' + | 'userId' > ): PublicCredentialGroupEnrollmentIdentity { requireInvitationToken(principal, attempt.invitationToken) @@ -227,7 +232,8 @@ function identityForOAuthAttempt( !sameResourceScope(resourceScopeFromOwner(attempt), resourceScopeFromOwner(principal)) || attempt.email !== principal.email || attempt.credentialGroupId !== principal.credentialGroupId || - attempt.enrollmentId !== principal.enrollmentId + attempt.enrollmentId !== principal.enrollmentId || + attempt.userId !== principal.userId ) { throw new OrchestrationError('not_found', 'Authorization state does not match this enrollment') } @@ -256,10 +262,10 @@ export const completePublicCredentialGroupOAuth = defineAuthorizedCredentialGrou async execute({ principal, input, context }) { requireInvitationToken(principal, input.attempt.invitationToken) const completion = await completeCredentialGroupOAuth(context.oauth, input.attempt, input.code) - if (context.workspaceId) + if (context.organizationId) await fireCredentialGroupTrigger({ event: completion.created ? 'credential_added' : 'credential_reconnected', - workspaceId: context.workspaceId, + organizationId: context.organizationId, credentialGroupId: context.credentialGroupId, credentialGroupName: context.oauth.credentialGroupName, enrollmentId: context.enrollmentId, @@ -334,10 +340,36 @@ export const completePublicCredentialGroupMcpOAuth = input.attempt.mcpServerId ) if (!oauth) throw new CredentialGroupInvitationUnavailableError() + if (oauth.server.oauthConfigVersion !== input.attempt.oauthConfigVersion) + throw new OrchestrationError('conflict', 'MCP setup changed. Start authorization again.') return { ...identity, oauth } }, async execute({ principal, input, context }) { requireInvitationToken(principal, input.attempt.invitationToken) - return completeCredentialGroupMcpOAuth(context.oauth, input.attempt.codeVerifier, input.code) + const completion = await completeCredentialGroupMcpOAuth( + context.oauth, + input.attempt.codeVerifier, + input.code, + input.attempt.invitationToken + ) + if (context.organizationId) + await fireCredentialGroupTrigger({ + event: completion.created ? 'credential_added' : 'credential_reconnected', + organizationId: context.organizationId, + credentialGroupId: context.credentialGroupId, + credentialGroupName: context.oauth.credentialGroupName, + enrollmentId: context.enrollmentId, + email: context.email, + enrollmentStatus: completion.enrollmentStatus, + credential: { + credentialId: completion.connectionId, + credentialGroupOptionId: null, + mcpServerId: completion.mcpServerId, + provider: context.oauth.server.connectorId, + providerId: context.oauth.server.connectorId, + displayName: context.oauth.server.name, + }, + }) + return { connectionId: completion.connectionId, mcpServerId: completion.mcpServerId } }, }) diff --git a/apps/sim/lib/credential-groups/application/slack-managed-users.test.ts b/apps/sim/lib/credential-groups/application/slack-managed-users.test.ts index fe6c49ec391..75514610292 100644 --- a/apps/sim/lib/credential-groups/application/slack-managed-users.test.ts +++ b/apps/sim/lib/credential-groups/application/slack-managed-users.test.ts @@ -15,6 +15,10 @@ const mocks = vi.hoisted(() => ({ consume: vi.fn(), exchange: vi.fn(), })) +vi.mock('@/lib/credential-groups/organization-setup', () => ({ + requireOrganizationAccountsSetup: vi.fn().mockResolvedValue(undefined), +})) + vi.mock('@sim/audit', () => auditMock) vi.mock('@/lib/core/application/organization-authorization', () => ({ requireOrganizationMembership: mocks.organizationAccess, diff --git a/apps/sim/lib/credential-groups/application/slack-managed-users.ts b/apps/sim/lib/credential-groups/application/slack-managed-users.ts index 83f9af9c991..a18f42c8e82 100644 --- a/apps/sim/lib/credential-groups/application/slack-managed-users.ts +++ b/apps/sim/lib/credential-groups/application/slack-managed-users.ts @@ -15,6 +15,7 @@ import { sameResourceScope, } from '@/lib/core/resource-scope' import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' +import { requireOrganizationAccountsSetup } from '@/lib/credential-groups/organization-setup' import { credentialGroupScopePolicyVersion } from '@/lib/credential-groups/provider-adapter' import { isScopedCredentialGroupsAvailable } from '@/lib/credential-groups/scoped-availability' import { @@ -70,7 +71,9 @@ export interface StartSlackCredentialGroupConfigurationInput { assertedWorkspaceId?: string organizationId?: string credentialGroupId: string - slackBotCredentialId: string + slackBotCredentialId?: string + appId?: string + teamId?: string clientId: string clientSecret: string requiredScopes?: string[] @@ -96,11 +99,15 @@ export const startSlackCredentialGroupConfiguration: OperationUseCase< credentialGroupOperations.startSlackConfiguration, scope ) + if (scope.kind === 'organization') + await requireOrganizationAccountsSetup(scope.organizationId, input.credentialGroupId) return createSlackManagedUsersAttempt({ ...resourceScopeFields(scope), userId: principal.userId, credentialGroupId: input.credentialGroupId, slackBotCredentialId: input.slackBotCredentialId, + appId: input.appId, + teamId: input.teamId, clientId: input.clientId, clientSecret: input.clientSecret, requiredScopes: input.requiredScopes, @@ -148,6 +155,8 @@ export const completeSlackCredentialGroupConfiguration: OperationUseCase< credentialGroupOperations.completeSlackConfiguration, scope ) + if (scope.kind === 'organization') + await requireOrganizationAccountsSetup(scope.organizationId, pending.credentialGroupId) const attempt = await consumeSlackManagedUsersAttempt(input.state) if ( !attempt || diff --git a/apps/sim/lib/credential-groups/application/validation.ts b/apps/sim/lib/credential-groups/application/validation.ts index bec8f12898b..ffb0d74e664 100644 --- a/apps/sim/lib/credential-groups/application/validation.ts +++ b/apps/sim/lib/credential-groups/application/validation.ts @@ -23,7 +23,11 @@ function validateOption( `Credential option ${index + 1} requires a label of at most 100 characters` ) } - if (option.provider === 'slack' && !option.slackBotCredentialId.trim()) { + if ( + option.provider === 'slack' && + option.slackBotCredentialId !== undefined && + !option.slackBotCredentialId.trim() + ) { throw new OrchestrationError('validation', 'Select a custom Slack bot') } } diff --git a/apps/sim/lib/credential-groups/application/workspace-access-policy.test.ts b/apps/sim/lib/credential-groups/application/workspace-access-policy.test.ts new file mode 100644 index 00000000000..c29a825eab1 --- /dev/null +++ b/apps/sim/lib/credential-groups/application/workspace-access-policy.test.ts @@ -0,0 +1,54 @@ +/** @vitest-environment node */ +import { describe, expect, it } from 'vitest' +import { + buildOrganizationAccountAccessPolicy, + listOrganizationAccountWorkspaceIds, + organizationAccountAccessPolicyCodec, + organizationAccountPolicyAllowsWorkspace, +} from '@/lib/credential-groups/application/workspace-access-policy' + +describe('organization account workspace policy', () => { + it('denies every workspace by default', () => { + const policy = buildOrganizationAccountAccessPolicy('group-1', []) + expect(organizationAccountPolicyAllowsWorkspace(policy, 'workspace-1')).toBe(false) + }) + + it('grants only selected workspaces without a workflow or deployment condition', () => { + const policy = buildOrganizationAccountAccessPolicy('group-1', ['workspace-2', 'workspace-1']) + expect(listOrganizationAccountWorkspaceIds(policy)).toEqual(['workspace-1', 'workspace-2']) + expect(organizationAccountPolicyAllowsWorkspace(policy, 'workspace-1')).toBe(true) + expect(organizationAccountPolicyAllowsWorkspace(policy, 'workspace-3')).toBe(false) + expect(policy.statements[0]).not.toHaveProperty('condition') + }) + + it('rejects policies naming a different group', () => { + expect(() => + organizationAccountAccessPolicyCodec.parse( + buildOrganizationAccountAccessPolicy('group-2', []), + { type: 'credential_group', id: 'group-1' } + ) + ).toThrow('canonical group') + }) + + it('rejects workflow grants and indexing grants', () => { + for (const principal of [ + { type: 'workflow', workflowId: 'workflow-1' }, + { type: 'knowledge_connector', connectorId: 'connector-1' }, + ]) { + const policy = buildOrganizationAccountAccessPolicy('group-1', ['workspace-1']) + expect(() => + organizationAccountAccessPolicyCodec.parse( + { ...policy, statements: [{ ...policy.statements[0], principals: [principal] }] }, + { type: 'credential_group', id: 'group-1' } + ) + ).toThrow() + } + }) + + it('rejects duplicate selections and malformed IDs', () => { + expect(() => + buildOrganizationAccountAccessPolicy('group-1', ['workspace-1', 'workspace-1']) + ).toThrow() + expect(() => buildOrganizationAccountAccessPolicy('group-1', [' workspace-1 '])).toThrow() + }) +}) diff --git a/apps/sim/lib/credential-groups/application/workspace-access-policy.ts b/apps/sim/lib/credential-groups/application/workspace-access-policy.ts new file mode 100644 index 00000000000..dd9af53ba90 --- /dev/null +++ b/apps/sim/lib/credential-groups/application/workspace-access-policy.ts @@ -0,0 +1,99 @@ +import { z } from 'zod' +import { ORGANIZATION_ACCOUNT_WORKSPACE_LIMIT } from '@/lib/credential-groups/limits' +import { evaluateResourcePolicy } from '@/lib/resource-policies/evaluator' +import { workspaceResourcePolicyPrincipalSchema } from '@/lib/resource-policies/principals/workspace' +import { CREDENTIAL_GROUP_CREDENTIAL_USE_ACTION } from '@/lib/resource-policies/registry' +import type { ResourcePolicyCodec } from '@/lib/resource-policies/types' + +export const organizationAccountWorkspaceIdsSchema = z + .array(workspaceResourcePolicyPrincipalSchema.shape.workspaceId) + .max(ORGANIZATION_ACCOUNT_WORKSPACE_LIMIT) + .refine((ids) => new Set(ids).size === ids.length, 'Workspace IDs must be unique') + +const workspaceAccessStatementSchema = z + .object({ + sid: z.literal('WorkspaceCredentialAccess'), + effect: z.literal('allow'), + actions: z.tuple([z.literal(CREDENTIAL_GROUP_CREDENTIAL_USE_ACTION)]), + principals: z + .array(workspaceResourcePolicyPrincipalSchema) + .min(1) + .max(ORGANIZATION_ACCOUNT_WORKSPACE_LIMIT), + }) + .strict() + .refine( + ({ principals }) => + principals.every( + (principal, index) => + index === 0 || principals[index - 1].workspaceId < principal.workspaceId + ), + 'Workspace principals must be sorted and unique' + ) + +export const organizationAccountAccessPolicySchema = z + .object({ + version: z.literal(2), + resource: z + .object({ type: z.literal('credential_group'), id: z.string().min(1).max(128) }) + .strict(), + statements: z.array(workspaceAccessStatementSchema).max(1), + }) + .strict() + +export type OrganizationAccountAccessPolicy = z.output + +export const organizationAccountAccessPolicyCodec: ResourcePolicyCodec< + 'credential_group', + OrganizationAccountAccessPolicy +> = { + resourceType: 'credential_group', + parse(value, expected) { + const document = organizationAccountAccessPolicySchema.parse(value) + if (document.resource.type !== expected.type || document.resource.id !== expected.id) { + throw new Error('Connected accounts policy does not match its canonical group') + } + return document + }, +} + +export function buildOrganizationAccountAccessPolicy( + credentialGroupId: string, + workspaceIds: string[] +): OrganizationAccountAccessPolicy { + const ids = organizationAccountWorkspaceIdsSchema.parse(workspaceIds).sort() + return organizationAccountAccessPolicySchema.parse({ + version: 2, + resource: { type: 'credential_group', id: credentialGroupId }, + statements: ids.length + ? [ + { + sid: 'WorkspaceCredentialAccess', + effect: 'allow', + actions: [CREDENTIAL_GROUP_CREDENTIAL_USE_ACTION], + principals: ids.map((workspaceId) => ({ type: 'workspace', workspaceId })), + }, + ] + : [], + }) +} + +export function listOrganizationAccountWorkspaceIds( + document: OrganizationAccountAccessPolicy +): string[] { + return document.statements.flatMap((statement) => + statement.principals.map((principal) => principal.workspaceId) + ) +} + +export function organizationAccountPolicyAllowsWorkspace( + document: OrganizationAccountAccessPolicy, + workspaceId: string +): boolean { + return ( + evaluateResourcePolicy({ + document, + action: CREDENTIAL_GROUP_CREDENTIAL_USE_ACTION, + facts: { currentWorkspaceId: workspaceId }, + }).decision === 'allow' + ) +} diff --git a/apps/sim/lib/credential-groups/application/workspace-organization-accounts.ts b/apps/sim/lib/credential-groups/application/workspace-organization-accounts.ts new file mode 100644 index 00000000000..c33cf518f70 --- /dev/null +++ b/apps/sim/lib/credential-groups/application/workspace-organization-accounts.ts @@ -0,0 +1,106 @@ +import { db } from '@sim/db' +import { mcpServers, member, organization } from '@sim/db/schema' +import { and, eq, isNull } from 'drizzle-orm' +import { defineAuthorizedWorkspaceUseCase, defineWorkspaceOperation } from '@/lib/core/application' +import { resolveCredentialGroupWorkspaceContext } from '@/lib/credential-groups/application/context' +import { + organizationAccountAccessPolicyCodec, + organizationAccountPolicyAllowsWorkspace, +} from '@/lib/credential-groups/application/workspace-access-policy' +import { loadScopedAccountsCredentialListContext } from '@/lib/credential-groups/credentials' +import { getManagedMcpConnector } from '@/lib/credential-groups/managed-mcp-connectors' +import { + getCredentialGroupProviderService, + isCredentialGroupProvider, +} from '@/lib/credential-groups/providers' +import { isScopedCredentialGroupsAvailable } from '@/lib/credential-groups/scoped-availability' +import { requireResourcePolicy } from '@/lib/resource-policies/repository' + +/** + * permission-group-exempt: Workspace readers can see sharing status and provider labels; credential use is authorized separately. + */ +export const workspaceOrganizationAccountsOperation = defineWorkspaceOperation({ + id: 'organization_accounts.workspace_status.read', + minimumRole: 'read', + workspaceApiKey: 'deny', + capability: 'none', + principalKinds: ['session'], +}) + +/** Read-only workspace projection; provider configuration and people remain organization resources. */ +export const getWorkspaceOrganizationAccounts = defineAuthorizedWorkspaceUseCase({ + operation: workspaceOrganizationAccountsOperation, + resolveContext: ({ input }: { input: { workspaceId: string } }) => + resolveCredentialGroupWorkspaceContext(input.workspaceId), + authorizationOptions: {}, + execute: async ({ principal, context }) => { + const organizationId = context.workspaceOrganizationId + const result = { + organizationId, + organizationName: null as string | null, + available: false, + allowed: false, + canManage: false, + providers: [] as Array<{ id: string; label: string }>, + mcpProviders: [] as Array<{ id: string; label: string }>, + } + if (!organizationId) return result + const [org] = await db + .select({ name: organization.name }) + .from(organization) + .where(eq(organization.id, organizationId)) + .limit(1) + if (!org) throw new Error('Workspace organization no longer exists') + result.organizationName = org.name + const [membership] = await db + .select({ role: member.role }) + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, principal.userId))) + .limit(1) + result.canManage = membership?.role === 'admin' || membership?.role === 'owner' + result.available = await isScopedCredentialGroupsAvailable({ + kind: 'organization', + organizationId, + }) + if (!result.available) return result + const group = await loadScopedAccountsCredentialListContext({ + kind: 'organization', + organizationId, + }) + if (!group || group.status !== 'active') return result + const policy = await requireResourcePolicy({ + organizationId, + resourceType: 'credential_group', + resourceId: group.credentialGroupId, + codec: organizationAccountAccessPolicyCodec, + }) + result.allowed = organizationAccountPolicyAllowsWorkspace(policy.document, context.workspaceId) + if (!result.allowed) return result + result.providers = group.options + .filter((option) => option.status === 'active') + .map((option) => { + if (!isCredentialGroupProvider(option.provider)) + throw new Error(`Unsupported organization provider: ${option.provider}`) + const service = getCredentialGroupProviderService(option.provider) + return { id: service.providerId, label: service.name } + }) + const servers = await db + .select({ connectorId: mcpServers.managedConnectorId }) + .from(mcpServers) + .where( + and( + eq(mcpServers.organizationId, organizationId), + eq(mcpServers.credentialGroupId, group.credentialGroupId), + eq(mcpServers.enabled, true), + isNull(mcpServers.deletedAt) + ) + ) + result.mcpProviders = servers.map((server) => { + if (!server.connectorId) + throw new Error('Organization MCP provider is missing its connector ID') + const connector = getManagedMcpConnector(server.connectorId) + return { id: connector.id, label: connector.name } + }) + return result + }, +}) diff --git a/apps/sim/lib/credential-groups/availability.test.ts b/apps/sim/lib/credential-groups/availability.test.ts index 2a51f7c6225..c0edbe98b86 100644 --- a/apps/sim/lib/credential-groups/availability.test.ts +++ b/apps/sim/lib/credential-groups/availability.test.ts @@ -22,12 +22,23 @@ describe('resolveCredentialGroupsAvailability', () => { vi.clearAllMocks() }) + it('does not expose organization accounts in a personal workspace even with the global flag enabled', async () => { + mockIsFeatureEnabled.mockResolvedValue(true) + await expect( + resolveCredentialGroupsAvailability({ + organizationId: null, + ownerBilling: { isEnterprise: true }, + }) + ).resolves.toEqual({ available: false, reason: 'feature_disabled' }) + expect(mockIsFeatureEnabled).not.toHaveBeenCalled() + }) + it('attributes a disabled feature flag before considering the plan', async () => { mockIsFeatureEnabled.mockResolvedValue(false) await expect( resolveCredentialGroupsAvailability({ - workspaceId: 'ws-1', + organizationId: 'org-1', ownerBilling: { isEnterprise: false }, }) ).resolves.toEqual({ @@ -41,7 +52,7 @@ describe('resolveCredentialGroupsAvailability', () => { await expect( resolveCredentialGroupsAvailability({ - workspaceId: 'ws-1', + organizationId: 'org-1', ownerBilling: { isEnterprise: false }, }) ).resolves.toEqual({ @@ -50,23 +61,23 @@ describe('resolveCredentialGroupsAvailability', () => { }) }) - it('evaluates the flag against the workspace id', async () => { + it('evaluates the flag against the organization id', async () => { mockIsFeatureEnabled.mockResolvedValue(true) await resolveCredentialGroupsAvailability({ - workspaceId: 'ws-1', + organizationId: 'org-1', ownerBilling: { isEnterprise: true }, }) - expect(mockIsFeatureEnabled).toHaveBeenCalledWith('credential-groups', { workspaceId: 'ws-1' }) + expect(mockIsFeatureEnabled).toHaveBeenCalledWith('credential-groups', { orgId: 'org-1' }) }) - it('allows Enterprise workspaces when the hosted feature is enabled', async () => { + it('allows Enterprise organizations when the hosted feature is enabled', async () => { mockIsFeatureEnabled.mockResolvedValue(true) await expect( resolveCredentialGroupsAvailability({ - workspaceId: 'ws-1', + organizationId: 'org-1', ownerBilling: { isEnterprise: true }, }) ).resolves.toEqual({ diff --git a/apps/sim/lib/credential-groups/availability.ts b/apps/sim/lib/credential-groups/availability.ts index 76852800476..2010afe622b 100644 --- a/apps/sim/lib/credential-groups/availability.ts +++ b/apps/sim/lib/credential-groups/availability.ts @@ -6,20 +6,22 @@ export type CredentialGroupsAvailability = | { available: false; reason: 'feature_disabled' | 'enterprise_plan_required' } /** - * The workspace the gate is evaluated for. `workspaceId` is required so no call - * site can silently fall back to the global clause and reveal the feature to a - * workspace the AppConfig `credential-groups` allowlist does not name. + * The canonical organization and its billing entitlement. Personal workspaces + * have no organization and cannot enable connected accounts. */ export interface CredentialGroupsAvailabilityInput { - workspaceId: string + organizationId: string | null ownerBilling: { isEnterprise: boolean } } export async function resolveCredentialGroupsAvailability({ - workspaceId, + organizationId, ownerBilling, }: CredentialGroupsAvailabilityInput): Promise { - if (!(await isFeatureEnabled('credential-groups', { workspaceId }))) { + if ( + !organizationId || + !(await isFeatureEnabled('credential-groups', { orgId: organizationId })) + ) { return { available: false, reason: 'feature_disabled' } } if (isHosted && !ownerBilling.isEnterprise) { @@ -29,8 +31,8 @@ export async function resolveCredentialGroupsAvailability({ } /** - * Credential Groups are gated per workspace (globally or by the AppConfig - * `workspaceIds` allowlist) and restricted to Enterprise workspaces on Sim Cloud. + * Credential Groups use organization rollout targeting and require an active + * Enterprise entitlement on Sim Cloud. Workspace flag targeting is not consulted. */ export async function isCredentialGroupsAvailable( input: CredentialGroupsAvailabilityInput diff --git a/apps/sim/lib/credential-groups/credentials.ts b/apps/sim/lib/credential-groups/credentials.ts index 3a386692052..ec0fe7889af 100644 --- a/apps/sim/lib/credential-groups/credentials.ts +++ b/apps/sim/lib/credential-groups/credentials.ts @@ -5,11 +5,9 @@ import { credential, credentialGroup, credentialGroupEnrollment, - foldedEmail, - member, user, } from '@sim/db/schema' -import { and, asc, eq, gt, inArray, isNull, or, type SQL, sql } from 'drizzle-orm' +import { and, asc, eq, gt, inArray, isNotNull, isNull, or, type SQL, sql } from 'drizzle-orm' import { type ResourceScope, resourceScopeFromOwner } from '@/lib/core/resource-scope' import { resourceScopeCondition } from '@/lib/core/resource-scope.server' import { @@ -132,7 +130,7 @@ export async function loadCredentialGroupEnrollmentAccess( email: credentialGroupEnrollment.email, }) .from(credentialGroupEnrollment) - .innerJoin(user, eq(foldedEmail(user.email), credentialGroupEnrollment.email)) + .innerJoin(user, eq(user.id, credentialGroupEnrollment.userId)) .where( and( eq(user.id, userId), @@ -220,6 +218,7 @@ export async function loadManagedCredentialGroupBinding( .select({ credentialId: credential.id, createdBy: credential.createdBy, + enrollmentUserId: credentialGroupEnrollment.userId, workspaceId: credential.workspaceId, organizationId: credential.organizationId, providerId: credential.providerId, @@ -257,13 +256,9 @@ export async function loadManagedCredentialGroupBinding( .limit(1) if (!row) return null if (row.organizationId) { - if (!row.createdBy) return null - const [membership] = await db - .select({ id: member.id }) - .from(member) - .where(and(eq(member.organizationId, row.organizationId), eq(member.userId, row.createdBy))) - .limit(1) - if (!membership) return null + if (!row.enrollmentUserId || row.enrollmentUserId !== row.createdBy) { + throw new Error('Organization credential is not bound to its enrolled user') + } } if (!row.providerId) throw new Error(`Managed credential ${row.credentialId} has no provider ID`) if (!row.credentialGroupOptionId) { @@ -415,6 +410,12 @@ export async function listCredentialGroupCredentialReferences({ eq(credential.type, 'managed_oauth'), eq(credential.managedOauthStatus, 'active'), eq(credentialGroupEnrollment.credentialGroupId, credentialGroupId), + organizationId + ? and( + isNotNull(credentialGroupEnrollment.userId), + eq(credential.createdBy, credentialGroupEnrollment.userId) + ) + : undefined, email ? eq(credentialGroupEnrollment.email, email) : undefined, inArray(credential.credentialGroupOptionId, credentialGroupOptionIds), credentialProviderIds?.length diff --git a/apps/sim/lib/credential-groups/enrollments.test.ts b/apps/sim/lib/credential-groups/enrollments.test.ts index 1c583e48525..e0380fadb13 100644 --- a/apps/sim/lib/credential-groups/enrollments.test.ts +++ b/apps/sim/lib/credential-groups/enrollments.test.ts @@ -40,6 +40,7 @@ vi.mock('@/lib/credential-groups/provider-registry', () => ({ import { getOrganizationSubscriptionUsable } from '@/lib/billing/core/subscription' import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { + bindCredentialGroupEnrollmentUser, completeCredentialGroupEnrollment, createCredentialGroupInvitationLink, createCredentialGroupSelfEnrollmentLink, @@ -610,6 +611,7 @@ describe('enrollment context for session-authorized or consumed-attempt OAuth', const row = { enrollment: { ...ENROLLMENT, + userId: 'person-1', invitationTokenHash: 'rotated-hash', invitationExpiresAt: new Date(0), }, @@ -712,7 +714,7 @@ describe('enrollment context for session-authorized or consumed-attempt OAuth', }) }) -describe('organization enrollment membership boundary', () => { +describe('organization enrollment bound identity', () => { const identity = { organizationId: 'organization-1', credentialGroupId: 'group-1', @@ -721,7 +723,7 @@ describe('organization enrollment membership boundary', () => { invitationTokenHash: ENROLLMENT.invitationTokenHash, } const row = { - enrollment: ENROLLMENT, + enrollment: { ...ENROLLMENT, userId: 'person-1' }, groupId: 'group-1', groupName: 'Accounts', groupStatus: 'active', @@ -748,21 +750,23 @@ describe('organization enrollment membership boundary', () => { getAuthorizedCredentialGroupOAuthContext(identity, 'gmail-option') ).resolves.toMatchObject({ organizationId: 'organization-1', - credentialOwnerId: 'member-1', + credentialOwnerId: 'person-1', workspaceName: 'Acme', }) }) - it('denies a valid invitation after the person leaves the organization', async () => { + it('retains the bound identity when the contributor is not an organization member', async () => { queueTableRows(schemaMock.member, []) await expect( getAuthorizedCredentialGroupOAuthContext(identity, 'gmail-option') - ).resolves.toBeNull() + ).resolves.toMatchObject({ credentialOwnerId: 'person-1' }) + expect(dbChainMockFns.from).not.toHaveBeenCalledWith(schemaMock.member) }) - it('denies ambiguous verified identities', async () => { + it('uses the bound user instead of looking up matching member emails', async () => { queueTableRows(schemaMock.member, [{ userId: 'member-1' }, { userId: 'member-2' }]) await expect( getAuthorizedCredentialGroupOAuthContext(identity, 'gmail-option') - ).resolves.toBeNull() + ).resolves.toMatchObject({ credentialOwnerId: 'person-1' }) + expect(dbChainMockFns.from).not.toHaveBeenCalledWith(schemaMock.member) }) it('conceals an enrollment from a different asserted organization', async () => { queueTableRows(schemaMock.member, [{ userId: 'member-1' }]) @@ -774,3 +778,67 @@ describe('organization enrollment membership boundary', () => { ).resolves.toBeNull() }) }) + +describe('verified immutable enrollment identity binding', () => { + const identity = { + workspaceId: 'workspace-1', + credentialGroupId: 'group-1', + enrollmentId: ENROLLMENT.id, + email: ENROLLMENT.email, + invitationTokenHash: ENROLLMENT.invitationTokenHash, + } + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + it('binds an invitation once to its verified recipient', async () => { + queueTableRows(schemaMock.credentialGroupEnrollment, [ + { enrollment: { ...ENROLLMENT, userId: null }, email: ENROLLMENT.email, verified: true }, + ]) + await bindCredentialGroupEnrollmentUser(identity, 'recipient') + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'recipient' }) + ) + expect(isNull).toHaveBeenCalledWith(schemaMock.credentialGroupEnrollment.userId) + expect(eq).toHaveBeenCalledWith( + schemaMock.credentialGroupEnrollment.invitationTokenHash, + identity.invitationTokenHash + ) + }) + it.each([ + { email: 'someone-else@example.com', verified: true }, + { email: ENROLLMENT.email, verified: false }, + ])('rejects a different or unverified recipient', async ({ email, verified }) => { + queueTableRows(schemaMock.credentialGroupEnrollment, [ + { enrollment: { ...ENROLLMENT, userId: null }, email, verified }, + ]) + await expect(bindCredentialGroupEnrollmentUser(identity, 'recipient')).rejects.toThrow( + 'verified email' + ) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + it('keeps the same bound user after an email change', async () => { + queueTableRows(schemaMock.credentialGroupEnrollment, [ + { + enrollment: { ...ENROLLMENT, userId: 'recipient' }, + email: 'new@example.com', + verified: true, + }, + ]) + await expect(bindCredentialGroupEnrollmentUser(identity, 'recipient')).resolves.toBeUndefined() + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + it('never reassigns a bound enrollment to a different user with the old email', async () => { + queueTableRows(schemaMock.credentialGroupEnrollment, [ + { + enrollment: { ...ENROLLMENT, userId: 'recipient' }, + email: ENROLLMENT.email, + verified: true, + }, + ]) + await expect(bindCredentialGroupEnrollmentUser(identity, 'new-user')).rejects.toThrow( + 'verified email' + ) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credential-groups/enrollments.ts b/apps/sim/lib/credential-groups/enrollments.ts index 3223ada9b96..fe6a93eefdf 100644 --- a/apps/sim/lib/credential-groups/enrollments.ts +++ b/apps/sim/lib/credential-groups/enrollments.ts @@ -5,7 +5,6 @@ import { credentialGroup, credentialGroupEnrollment, mcpServers, - member, organization, user, workspace, @@ -27,6 +26,7 @@ import { resourceScopeCondition } from '@/lib/core/resource-scope.server' import { getBaseUrl } from '@/lib/core/utils/urls' import type { ManagedMcpConnectorId } from '@/lib/credential-groups/managed-mcp-connectors' import { getManagedMcpConnector } from '@/lib/credential-groups/managed-mcp-connectors' +import { requireOrganizationAccountsSetup } from '@/lib/credential-groups/organization-setup' import { getCredentialGroupProviderAdapter } from '@/lib/credential-groups/provider-registry' import type { CredentialGroupProvider } from '@/lib/credential-groups/providers' import { @@ -135,19 +135,25 @@ export interface CredentialGroupOAuthContext { } export interface CredentialGroupMcpOAuthContext { + credentialGroupName: string + userId: string enrollmentId: string credentialGroupId: string - workspaceId: string + workspaceId?: string + organizationId?: string email: string enrollmentStatus: EnrollmentRow['status'] server: { + connectorId: ManagedMcpConnectorId id: string name: string url: string + oauthConfigVersion: number } } export interface PublicCredentialGroupEnrollmentIdentity { + userId?: string enrollmentId: string credentialGroupId: string workspaceId?: string @@ -277,26 +283,9 @@ async function loadLiveEnrollmentRow(scope: SQL | undefined) { const ownerScope = resourceScopeFromOwner(row) if (!(await isScopedCredentialGroupsAvailable(ownerScope))) return null - let credentialOwnerId = row.workspaceOwnerId - if (ownerScope.kind === 'organization') { - const memberships = await db - .select({ userId: user.id }) - .from(member) - .innerJoin(user, eq(user.id, member.userId)) - .where( - and( - eq(member.organizationId, ownerScope.organizationId), - eq(user.emailVerified, true), - sql`lower(btrim(${user.email})) = ${row.enrollment.email}` - ) - ) - .limit(2) - if (memberships.length !== 1) return null - credentialOwnerId = memberships[0].userId - } return { ...row, - credentialOwnerId, + credentialOwnerId: row.enrollment.userId, workspaceName: row.organizationName ?? row.workspaceName ?? '', } } @@ -324,6 +313,7 @@ function identityForPublicEnrollmentRow( ...resourceScopeFields(resourceScopeFromOwner(row)), email: row.enrollment.email, invitationTokenHash: row.enrollment.invitationTokenHash, + ...(row.enrollment.userId ? { userId: row.enrollment.userId } : {}), } } @@ -345,13 +335,82 @@ async function resolveAuthorizedPublicEnrollmentRow( !row || row.groupId !== identity.credentialGroupId || !sameResourceScope(resourceScopeFromOwner(row), resourceScopeFromOwner(identity)) || - row.enrollment.email !== identity.email + row.enrollment.email !== identity.email || + (identity.userId !== undefined && row.enrollment.userId !== identity.userId) ) { return null } return row } +/** Binds invitation authority to a verified signed-in user once, under the enrollment lifecycle lock. */ +export async function bindCredentialGroupEnrollmentUser( + identity: PublicCredentialGroupEnrollmentIdentity, + userId: string +): Promise { + if (!userId.trim()) + throw new CredentialGroupEnrollmentError('Sign in to connect your accounts', 400) + if (identity.organizationId) + await requireOrganizationAccountsSetup(identity.organizationId, identity.credentialGroupId) + await db.transaction(async (tx) => { + await lockCredentialGroupEnrollmentLifecycle(tx, identity.enrollmentId) + const [row] = await tx + .select({ + enrollment: credentialGroupEnrollment, + email: user.email, + verified: user.emailVerified, + }) + .from(credentialGroupEnrollment) + .innerJoin( + credentialGroup, + eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId) + ) + .innerJoin(user, eq(user.id, userId)) + .where( + and( + eq(credentialGroupEnrollment.id, identity.enrollmentId), + eq(credentialGroupEnrollment.credentialGroupId, identity.credentialGroupId), + eq(credentialGroupEnrollment.invitationTokenHash, identity.invitationTokenHash), + eq(credentialGroupEnrollment.email, identity.email), + resourceScopeCondition(credentialGroup, resourceScopeFromOwner(identity)), + eq(credentialGroup.status, 'active') + ) + ) + .limit(1) + .for('update', { of: credentialGroupEnrollment }) + if ( + !row || + row.enrollment.invitationExpiresAt.getTime() <= Date.now() || + row.enrollment.revokedAt || + ['revoked', 'delivery_failed'].includes(row.enrollment.status) + ) { + throw new CredentialGroupEnrollmentError('Invitation is invalid or expired', 404) + } + if ( + !row.verified || + (row.enrollment.userId + ? row.enrollment.userId !== userId + : normalizeEmail(row.email) !== identity.email) + ) { + throw new CredentialGroupEnrollmentError( + 'Sign in with the verified email address this invitation was sent to', + 400 + ) + } + if (!row.enrollment.userId) { + await tx + .update(credentialGroupEnrollment) + .set({ userId, updatedAt: new Date() }) + .where( + and( + eq(credentialGroupEnrollment.id, identity.enrollmentId), + isNull(credentialGroupEnrollment.userId) + ) + ) + } + }) +} + function toCredentialGroupEnrollment(row: EnrollmentRow): CredentialGroupEnrollmentRecord { return { id: row.id, @@ -417,7 +476,7 @@ async function getInvitationContext( .from(mcpServers) .where( and( - scope.kind === 'workspace' ? eq(mcpServers.workspaceId, scope.workspaceId) : sql`false`, + resourceScopeCondition(mcpServers, scope), eq(mcpServers.credentialGroupId, groupId), eq(mcpServers.authType, 'oauth'), eq(mcpServers.enabled, true), @@ -636,7 +695,7 @@ export async function listCredentialGroupEnrollments( .from(mcpServers) .where( and( - scope.kind === 'workspace' ? eq(mcpServers.workspaceId, scope.workspaceId) : sql`false`, + resourceScopeCondition(mcpServers, scope), eq(mcpServers.credentialGroupId, groupId), eq(mcpServers.authType, 'oauth'), eq(mcpServers.enabled, true), @@ -973,6 +1032,57 @@ export async function deleteCredentialGroupEnrollment( }) } +/** Revocation preserves the bound owner and prevents pending callbacks from restoring grants. */ +export async function revokeCredentialGroupEnrollment( + scope: ResourceScope, + groupId: string, + enrollmentId: string +) { + return db.transaction(async (tx) => { + await lockCredentialGroupEnrollmentLifecycle(tx, enrollmentId) + const [row] = await tx + .select({ enrollment: credentialGroupEnrollment }) + .from(credentialGroupEnrollment) + .innerJoin( + credentialGroup, + eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId) + ) + .where( + and( + eq(credentialGroupEnrollment.id, enrollmentId), + eq(credentialGroup.id, groupId), + resourceScopeCondition(credentialGroup, scope) + ) + ) + .limit(1) + .for('update', { of: credentialGroupEnrollment }) + if (!row) throw new CredentialGroupEnrollmentError('Enrollment not found', 404) + const now = new Date() + const credentials = await tx + .update(credential) + .set({ managedOauthStatus: 'revoked', revokedAt: now, updatedAt: now }) + .where( + and( + eq(credential.credentialGroupEnrollmentId, enrollmentId), + resourceScopeCondition(credential, scope) + ) + ) + .returning({ id: credential.id, type: credential.type }) + const [updated] = await tx + .update(credentialGroupEnrollment) + .set({ status: 'revoked', revokedAt: now, updatedAt: now }) + .where(eq(credentialGroupEnrollment.id, enrollmentId)) + .returning() + if (!updated) throw new Error('Enrollment revocation returned no row') + return { + credentialGroupEnrollment: toCredentialGroupEnrollment(updated), + retiredMcpConnectionIds: credentials + .filter((row) => row.type === 'managed_mcp') + .map((row) => row.id), + } + }) +} + export async function getPublicCredentialGroupEnrollment( token: string ): Promise { @@ -1025,7 +1135,7 @@ async function buildPublicCredentialGroupEnrollment( .from(mcpServers) .where( and( - row.workspaceId ? eq(mcpServers.workspaceId, row.workspaceId) : sql`false`, + resourceScopeCondition(mcpServers, resourceScopeFromOwner(row)), eq(mcpServers.credentialGroupId, row.groupId), eq(mcpServers.authType, 'oauth'), eq(mcpServers.enabled, true), @@ -1248,7 +1358,7 @@ export async function getAuthorizedCredentialGroupOAuthContext( function loadEnrollmentRowForIdentity( identity: Pick< PublicCredentialGroupEnrollmentIdentity, - 'workspaceId' | 'organizationId' | 'credentialGroupId' | 'enrollmentId' | 'email' + 'workspaceId' | 'organizationId' | 'credentialGroupId' | 'enrollmentId' | 'email' | 'userId' > ) { return loadLiveEnrollmentRow( @@ -1256,7 +1366,8 @@ function loadEnrollmentRowForIdentity( eq(credentialGroupEnrollment.id, identity.enrollmentId), eq(credentialGroupEnrollment.email, identity.email), eq(credentialGroup.id, identity.credentialGroupId), - resourceScopeCondition(credentialGroup, resourceScopeFromOwner(identity)) + resourceScopeCondition(credentialGroup, resourceScopeFromOwner(identity)), + identity.userId ? eq(credentialGroupEnrollment.userId, identity.userId) : undefined ) ) } @@ -1265,7 +1376,7 @@ function loadEnrollmentRowForIdentity( export async function getCredentialGroupOAuthContextForEnrollment( identity: Pick< PublicCredentialGroupEnrollmentIdentity, - 'workspaceId' | 'organizationId' | 'credentialGroupId' | 'enrollmentId' | 'email' + 'workspaceId' | 'organizationId' | 'credentialGroupId' | 'enrollmentId' | 'email' | 'userId' >, optionId: string ): Promise { @@ -1288,7 +1399,7 @@ export async function getAuthorizedCredentialGroupMcpOAuthContext( export async function getCredentialGroupMcpOAuthContextForEnrollment( identity: Pick< PublicCredentialGroupEnrollmentIdentity, - 'workspaceId' | 'organizationId' | 'credentialGroupId' | 'enrollmentId' | 'email' + 'workspaceId' | 'organizationId' | 'credentialGroupId' | 'enrollmentId' | 'email' | 'userId' >, mcpServerId: string ): Promise { @@ -1300,19 +1411,20 @@ async function credentialGroupMcpOAuthContextFromRow( row: NonNullable>>, mcpServerId: string ): Promise { - if (!row.workspaceId) return null + if (!row.enrollment.userId) return null const [server] = await db .select({ id: mcpServers.id, name: mcpServers.name, url: mcpServers.url, managedConnectorId: mcpServers.managedConnectorId, + oauthConfigVersion: mcpServers.oauthConfigVersion, }) .from(mcpServers) .where( and( eq(mcpServers.id, mcpServerId), - row.workspaceId ? eq(mcpServers.workspaceId, row.workspaceId) : sql`false`, + resourceScopeCondition(mcpServers, resourceScopeFromOwner(row)), eq(mcpServers.credentialGroupId, row.groupId), eq(mcpServers.authType, 'oauth'), eq(mcpServers.enabled, true), @@ -1327,11 +1439,19 @@ async function credentialGroupMcpOAuthContextFromRow( getManagedMcpConnector(server.managedConnectorId) return { enrollmentId: row.enrollment.id, + userId: row.enrollment.userId, credentialGroupId: row.groupId, - workspaceId: row.workspaceId, + credentialGroupName: row.groupName, + ...resourceScopeFields(resourceScopeFromOwner(row)), email: row.enrollment.email, enrollmentStatus: row.enrollment.status, - server: { id: server.id, name: server.name, url: server.url }, + server: { + connectorId: getManagedMcpConnector(server.managedConnectorId).id, + id: server.id, + name: server.name, + url: server.url, + oauthConfigVersion: server.oauthConfigVersion, + }, } } diff --git a/apps/sim/lib/credential-groups/indexing.test.ts b/apps/sim/lib/credential-groups/indexing.test.ts new file mode 100644 index 00000000000..376192e7cec --- /dev/null +++ b/apps/sim/lib/credential-groups/indexing.test.ts @@ -0,0 +1,23 @@ +/** @vitest-environment node */ +import { describe, expect, it } from 'vitest' +import { getCredentialGroupIndexingConnector } from '@/lib/credential-groups/indexing' + +describe('connected account indexing capabilities', () => { + it.each([ + ['gmail', 'gmail'], + ['google-drive', 'google_drive'], + ['google-calendar', 'google_calendar'], + ['github-repositories', 'github'], + ['confluence', 'confluence'], + ['jira', 'jira'], + ['slack', 'slack'], + ] as const)('uses the permission-aware connector registry for %s', (provider, type) => { + expect(getCredentialGroupIndexingConnector(provider)?.type).toBe(type) + }) + it.each(['notion', 'outlook', 'hubspot'] as const)( + 'does not advertise generic KB ingestion as per-person indexing for %s', + (provider) => { + expect(getCredentialGroupIndexingConnector(provider)).toBeUndefined() + } + ) +}) diff --git a/apps/sim/lib/credential-groups/indexing.ts b/apps/sim/lib/credential-groups/indexing.ts new file mode 100644 index 00000000000..0958c7d8564 --- /dev/null +++ b/apps/sim/lib/credential-groups/indexing.ts @@ -0,0 +1,12 @@ +import { + type CredentialGroupProvider, + findCredentialGroupProviderFromProviderId, +} from '@/lib/credential-groups/providers' +import { SEARCH_CONNECTORS } from '@/lib/sim-search/connectors' + +/** Only connectors with permission-scoped OAuth ingestion can index connected accounts. */ +export function getCredentialGroupIndexingConnector(provider: CredentialGroupProvider) { + return SEARCH_CONNECTORS.find( + (connector) => findCredentialGroupProviderFromProviderId(connector.providerId) === provider + ) +} diff --git a/apps/sim/lib/credential-groups/limits.ts b/apps/sim/lib/credential-groups/limits.ts index f7df344e79c..cad8c92b28b 100644 --- a/apps/sim/lib/credential-groups/limits.ts +++ b/apps/sim/lib/credential-groups/limits.ts @@ -1,6 +1,8 @@ export const CREDENTIAL_GROUP_MCP_SERVER_LIMIT = 50 +export const ORGANIZATION_ACCOUNT_WORKSPACE_LIMIT = 1000 export const CREDENTIAL_GROUP_WORKFLOW_ACCESS_LIMIT = 50 export const CREDENTIAL_GROUP_WORKFLOW_CATALOG_LIMIT = 500 export const CREDENTIAL_GROUP_WORKFLOW_NAME_MAX_LENGTH = 255 /** Knowledge connectors one credential option may back at once. */ export const CREDENTIAL_GROUP_KNOWLEDGE_CONNECTOR_ACCESS_LIMIT = 32 +export const ORGANIZATION_ACCOUNT_INDEXING_SOURCE_LIMIT = 100 diff --git a/apps/sim/lib/credential-groups/managed-mcp-service.ts b/apps/sim/lib/credential-groups/managed-mcp-service.ts index 27982aeb8ad..76590487f1b 100644 --- a/apps/sim/lib/credential-groups/managed-mcp-service.ts +++ b/apps/sim/lib/credential-groups/managed-mcp-service.ts @@ -7,7 +7,13 @@ import { mcpServers, } from '@sim/db/schema' import { getPostgresErrorCode } from '@sim/utils/errors' -import { and, eq, inArray, isNull, ne } from 'drizzle-orm' +import { and, eq, inArray, isNull, ne, sql } from 'drizzle-orm' +import { + resourceScopeColumns, + resourceScopeFromOwner, + resourceScopeKey, +} from '@/lib/core/resource-scope' +import { resourceScopeCondition } from '@/lib/core/resource-scope.server' import { encryptSecret } from '@/lib/core/security/encryption' import { getManagedMcpConnector, @@ -81,6 +87,34 @@ function toSummary(row: typeof mcpServers.$inferSelect): ManagedMcpConnectorSumm } } +/** Loads setup fields without reading the stored OAuth client secret. */ +export async function loadOrganizationDatabricksSetup( + organizationId: string, + credentialGroupId: string +) { + const [server] = await db + .select({ + id: mcpServers.id, + name: mcpServers.name, + url: mcpServers.url, + oauthClientId: mcpServers.oauthClientId, + hasOauthClientSecret: sql`${mcpServers.oauthClientSecret} IS NOT NULL`, + enabled: mcpServers.enabled, + }) + .from(mcpServers) + .where( + and( + eq(mcpServers.organizationId, organizationId), + eq(mcpServers.credentialGroupId, credentialGroupId), + eq(mcpServers.managedConnectorId, 'databricks'), + isNull(mcpServers.deletedAt) + ) + ) + .limit(1) + if (!server) throw new ManagedMcpConnectorError('Databricks has not been added', 'not_found') + return server +} + async function validateServerUrl(url: string): Promise { try { validateMcpDomain(url) @@ -143,18 +177,23 @@ async function retireManagedMcpCredentials( } export async function createManagedMcpConnector(params: { - workspaceId: string + workspaceId?: string + organizationId?: string credentialGroupId: string userId: string input: CreateManagedMcpConnectorInput }): Promise { + const scope = resourceScopeFromOwner(params) const connector = getManagedMcpConnector(params.input.connectorId) const url = resolveManagedMcpConnectorUrl( connector.id, params.input.connectorId === 'databricks' ? params.input.url : undefined ) await validateServerUrl(url) - const serverId = generateMcpServerId(params.workspaceId, url) + const serverId = generateMcpServerId( + scope.kind === 'workspace' ? scope.workspaceId : resourceScopeKey(scope), + url + ) const oauthClientId = params.input.connectorId === 'databricks' ? params.input.oauthClientId.trim() : null const oauthClientSecret = @@ -176,7 +215,7 @@ export async function createManagedMcpConnector(params: { .where( and( eq(credentialGroup.id, params.credentialGroupId), - eq(credentialGroup.workspaceId, params.workspaceId) + resourceScopeCondition(credentialGroup, scope) ) ) .limit(1) @@ -188,7 +227,7 @@ export async function createManagedMcpConnector(params: { .from(mcpServers) .where( and( - eq(mcpServers.workspaceId, params.workspaceId), + resourceScopeCondition(mcpServers, scope), eq(mcpServers.credentialGroupId, params.credentialGroupId), eq(mcpServers.managedConnectorId, connector.id), isNull(mcpServers.deletedAt) @@ -207,7 +246,7 @@ export async function createManagedMcpConnector(params: { .from(mcpServers) .where( and( - eq(mcpServers.workspaceId, params.workspaceId), + resourceScopeCondition(mcpServers, scope), eq(mcpServers.url, url), isNull(mcpServers.deletedAt) ) @@ -224,7 +263,7 @@ export async function createManagedMcpConnector(params: { const [existingUrl] = await tx .select() .from(mcpServers) - .where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, params.workspaceId))) + .where(and(eq(mcpServers.id, serverId), resourceScopeCondition(mcpServers, scope))) .limit(1) .for('update') const now = new Date() @@ -242,6 +281,7 @@ export async function createManagedMcpConnector(params: { authType: 'oauth', oauthClientId, oauthClientSecret, + oauthConfigVersion: existingUrl.oauthConfigVersion + 1, headers: {}, enabled: true, connectionStatus: 'disconnected', @@ -260,7 +300,7 @@ export async function createManagedMcpConnector(params: { .insert(mcpServers) .values({ id: serverId, - workspaceId: params.workspaceId, + ...resourceScopeColumns(scope), credentialGroupId: params.credentialGroupId, managedConnectorId: connector.id, createdBy: params.userId, @@ -299,11 +339,13 @@ export async function createManagedMcpConnector(params: { } export async function updateManagedMcpConnector(params: { - workspaceId: string + workspaceId?: string + organizationId?: string credentialGroupId: string connectorId: ManagedMcpConnectorId input: UpdateManagedMcpConnectorInput }): Promise { + const scope = resourceScopeFromOwner(params) if (params.connectorId !== 'databricks') { throw new ManagedMcpConnectorError( 'Only Databricks connector settings can be changed', @@ -315,7 +357,7 @@ export async function updateManagedMcpConnector(params: { .from(mcpServers) .where( and( - eq(mcpServers.workspaceId, params.workspaceId), + resourceScopeCondition(mcpServers, scope), eq(mcpServers.credentialGroupId, params.credentialGroupId), eq(mcpServers.managedConnectorId, params.connectorId), isNull(mcpServers.deletedAt) @@ -343,7 +385,7 @@ export async function updateManagedMcpConnector(params: { .where( and( eq(credentialGroup.id, params.credentialGroupId), - eq(credentialGroup.workspaceId, params.workspaceId) + resourceScopeCondition(credentialGroup, scope) ) ) .limit(1) @@ -356,7 +398,7 @@ export async function updateManagedMcpConnector(params: { .where( and( eq(mcpServers.id, current.id), - eq(mcpServers.workspaceId, params.workspaceId), + resourceScopeCondition(mcpServers, scope), eq(mcpServers.credentialGroupId, params.credentialGroupId), eq(mcpServers.managedConnectorId, 'databricks'), isNull(mcpServers.deletedAt) @@ -366,7 +408,10 @@ export async function updateManagedMcpConnector(params: { .for('update') if (!locked) throw new ManagedMcpConnectorError('Managed MCP connector not found', 'not_found') const urlChanged = url !== locked.url - const targetServerId = generateMcpServerId(params.workspaceId, url) + const targetServerId = generateMcpServerId( + scope.kind === 'workspace' ? scope.workspaceId : resourceScopeKey(scope), + url + ) if (urlChanged && targetServerId === locked.id) { throw new Error(`MCP server ID collision for ${locked.id}`) } @@ -376,7 +421,7 @@ export async function updateManagedMcpConnector(params: { .from(mcpServers) .where( and( - eq(mcpServers.workspaceId, params.workspaceId), + resourceScopeCondition(mcpServers, scope), eq(mcpServers.url, url), ne(mcpServers.id, locked.id), isNull(mcpServers.deletedAt) @@ -416,6 +461,9 @@ export async function updateManagedMcpConnector(params: { .set({ name: nextName, oauthClientId: nextOauthClientId, + oauthConfigVersion: changedCredentials + ? locked.oauthConfigVersion + 1 + : locked.oauthConfigVersion, ...(encryptedSecret !== undefined ? { oauthClientSecret: encryptedSecret } : {}), ...(changedCredentials ? { connectionStatus: 'disconnected', lastConnected: null, lastError: null } @@ -435,7 +483,7 @@ export async function updateManagedMcpConnector(params: { const [target] = await tx .select() .from(mcpServers) - .where(and(eq(mcpServers.id, targetServerId), eq(mcpServers.workspaceId, params.workspaceId))) + .where(and(eq(mcpServers.id, targetServerId), resourceScopeCondition(mcpServers, scope))) .limit(1) .for('update') if (target?.deletedAt === null) { @@ -463,6 +511,7 @@ export async function updateManagedMcpConnector(params: { authType: 'oauth', oauthClientId: nextOauthClientId, oauthClientSecret: nextOauthClientSecret, + oauthConfigVersion: locked.oauthConfigVersion + 1, headers: {}, enabled: true, connectionStatus: 'disconnected', @@ -477,7 +526,7 @@ export async function updateManagedMcpConnector(params: { .insert(mcpServers) .values({ id: targetServerId, - workspaceId: params.workspaceId, + ...resourceScopeColumns(scope), ...rowValues, createdAt: now, }) @@ -493,7 +542,8 @@ export async function updateManagedMcpConnector(params: { } export async function deleteManagedMcpConnector(params: { - workspaceId: string + workspaceId?: string + organizationId?: string credentialGroupId: string connectorId: ManagedMcpConnectorId }): Promise<{ @@ -501,6 +551,7 @@ export async function deleteManagedMcpConnector(params: { serverIds: string[] retiredMcpConnectionIds: string[] }> { + const scope = resourceScopeFromOwner(params) return db.transaction(async (tx) => { const [group] = await tx .select({ id: credentialGroup.id }) @@ -508,7 +559,7 @@ export async function deleteManagedMcpConnector(params: { .where( and( eq(credentialGroup.id, params.credentialGroupId), - eq(credentialGroup.workspaceId, params.workspaceId) + resourceScopeCondition(credentialGroup, scope) ) ) .limit(1) @@ -520,7 +571,7 @@ export async function deleteManagedMcpConnector(params: { .from(mcpServers) .where( and( - eq(mcpServers.workspaceId, params.workspaceId), + resourceScopeCondition(mcpServers, scope), eq(mcpServers.credentialGroupId, params.credentialGroupId), eq(mcpServers.managedConnectorId, params.connectorId), isNull(mcpServers.deletedAt) diff --git a/apps/sim/lib/credential-groups/mcp-connections.ts b/apps/sim/lib/credential-groups/mcp-connections.ts index b3aeb1c3044..12a891a9427 100644 --- a/apps/sim/lib/credential-groups/mcp-connections.ts +++ b/apps/sim/lib/credential-groups/mcp-connections.ts @@ -1,6 +1,8 @@ import { db } from '@sim/db' import { credential, credentialGroup, credentialGroupEnrollment, mcpServers } from '@sim/db/schema' -import { and, asc, eq, gt, inArray, isNull, or, sql } from 'drizzle-orm' +import { and, asc, eq, gt, inArray, isNotNull, isNull, or, sql } from 'drizzle-orm' +import { resourceScopeFromOwner } from '@/lib/core/resource-scope' +import { resourceScopeCondition } from '@/lib/core/resource-scope.server' import { getManagedMcpConnector } from '@/lib/credential-groups/managed-mcp-connectors' export const MAX_CREDENTIAL_GROUP_MCP_CONNECTION_PAGE_SIZE = 100 @@ -22,12 +24,14 @@ export class CredentialGroupMcpConnectionCursorNotFoundError extends Error { } interface ListCredentialGroupMcpConnectionReferencesInput { - workspaceId: string + workspaceId?: string + organizationId?: string credentialGroupId: string limit: number cursor?: string email?: string mcpServerId?: string + connectorId?: string } function decodeToolNames(value: unknown): string[] { @@ -41,31 +45,37 @@ function decodeToolNames(value: unknown): string[] { /** Lists one bounded page of active managed MCP connections without selecting token material. */ export async function listCredentialGroupMcpConnectionReferences({ workspaceId, + organizationId, credentialGroupId, limit, cursor, email, mcpServerId, + connectorId, }: ListCredentialGroupMcpConnectionReferencesInput): Promise<{ mcpConnections: CredentialGroupMcpConnectionReference[] nextCursor: string | null }> { + const ownerScope = resourceScopeFromOwner({ workspaceId, organizationId }) const scope = () => and( - eq(credential.workspaceId, workspaceId), + resourceScopeCondition(credential, ownerScope), eq(credential.type, 'managed_mcp'), eq(credential.managedOauthStatus, 'active'), + eq(credential.mcpOauthConfigVersion, mcpServers.oauthConfigVersion), eq(credentialGroup.id, credentialGroupId), - eq(credentialGroup.workspaceId, workspaceId), + resourceScopeCondition(credentialGroup, ownerScope), eq(credentialGroup.status, 'active'), inArray(credentialGroupEnrollment.status, ['in_progress', 'completed']), - eq(mcpServers.workspaceId, workspaceId), + resourceScopeCondition(mcpServers, ownerScope), + organizationId ? isNotNull(credentialGroupEnrollment.userId) : undefined, eq(mcpServers.authType, 'oauth'), eq(mcpServers.enabled, true), isNull(mcpServers.deletedAt), sql`${mcpServers.credentialGroupId} = ${credentialGroup.id}`, email ? eq(credentialGroupEnrollment.email, email) : undefined, - mcpServerId ? eq(mcpServers.id, mcpServerId) : undefined + mcpServerId ? eq(mcpServers.id, mcpServerId) : undefined, + connectorId ? eq(mcpServers.managedConnectorId, connectorId) : undefined ) let cursorPosition: { id: string; createdAt: Date } | undefined diff --git a/apps/sim/lib/credential-groups/mcp-oauth-state.test.ts b/apps/sim/lib/credential-groups/mcp-oauth-state.test.ts index dc0bfa802d4..e87ada68848 100644 --- a/apps/sim/lib/credential-groups/mcp-oauth-state.test.ts +++ b/apps/sim/lib/credential-groups/mcp-oauth-state.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { CredentialGroupOAuthStateVersionError } from '@/lib/credential-groups/oauth-attempt-version' const { mockRedis, values } = vi.hoisted(() => { const values = new Map() @@ -57,9 +58,11 @@ describe('Credential Group MCP OAuth state', () => { await createCredentialGroupMcpOAuthAttempt({ state, workspaceId: 'workspace-1', + userId: 'user-1', email: 'person@example.com', enrollmentId: 'enrollment-1', credentialGroupId: 'group-1', + oauthConfigVersion: 1, mcpServerId: 'mcp-server-1', codeVerifier: 'code-verifier', invitationToken: 'invitation-token', @@ -72,9 +75,11 @@ describe('Credential Group MCP OAuth state', () => { await expect(consumeCredentialGroupMcpOAuthAttempt(state)).resolves.toMatchObject({ state, workspaceId: 'workspace-1', + userId: 'user-1', email: 'person@example.com', enrollmentId: 'enrollment-1', credentialGroupId: 'group-1', + oauthConfigVersion: 1, mcpServerId: 'mcp-server-1', codeVerifier: 'code-verifier', invitationToken: 'invitation-token', @@ -89,9 +94,11 @@ describe('Credential Group MCP OAuth state', () => { createCredentialGroupMcpOAuthAttempt({ state: 'mcp_cg_state-1', workspaceId: 'workspace-1', + userId: 'user-1', email: 'person@example.com', enrollmentId: 'enrollment-1', credentialGroupId: 'group-1', + oauthConfigVersion: 1, mcpServerId: 'mcp-server-1', codeVerifier: 'code-verifier', invitationToken: 'invitation-token', @@ -104,9 +111,11 @@ describe('Credential Group MCP OAuth state', () => { createCredentialGroupMcpOAuthAttempt({ state: 'ordinary-state', workspaceId: 'workspace-1', + userId: 'user-1', email: 'person@example.com', enrollmentId: 'enrollment-1', credentialGroupId: 'group-1', + oauthConfigVersion: 1, mcpServerId: 'mcp-server-1', codeVerifier: 'code-verifier', invitationToken: 'invitation-token', @@ -117,9 +126,11 @@ describe('Credential Group MCP OAuth state', () => { it('keeps parallel MCP attempts pinned to their original enrollment when the invitation rotates', async () => { const params = { workspaceId: 'workspace-1', + userId: 'user-1', email: 'person@example.com', enrollmentId: 'enrollment-1', credentialGroupId: 'group-1', + oauthConfigVersion: 1, mcpServerId: 'mcp-server-1', codeVerifier: 'verifier', invitationToken: 'first-invitation', @@ -143,9 +154,11 @@ describe('Credential Group MCP OAuth state', () => { await createCredentialGroupMcpOAuthAttempt({ state: 'mcp_cg_attempt', workspaceId: 'workspace-1', + userId: 'user-1', email: 'person@example.com', enrollmentId: 'enrollment-1', credentialGroupId: 'group-1', + oauthConfigVersion: 1, mcpServerId: 'mcp-server-1', codeVerifier: 'verifier', invitationToken: 'token', @@ -160,4 +173,28 @@ describe('Credential Group MCP OAuth state', () => { expect(await consumeCredentialGroupMcpOAuthAttempt('mcp_cg_attempt')).toBeNull() } ) + it('requires a new authorization for pre-binding v2 state', async () => { + const state = 'mcp_cg_old' + await createCredentialGroupMcpOAuthAttempt({ + state, + organizationId: 'org-1', + userId: 'user-1', + email: 'person@example.com', + enrollmentId: 'enrollment-1', + credentialGroupId: 'group-1', + oauthConfigVersion: 1, + mcpServerId: 'server-1', + codeVerifier: 'verifier', + invitationToken: 'token', + }) + const [key, raw] = [...values.entries()][0] + const stored = { ...JSON.parse(raw), version: 2 } + stored.userId = undefined + stored.oauthConfigVersion = undefined + values.set(key, JSON.stringify(stored)) + await expect(consumeCredentialGroupMcpOAuthAttempt(state)).rejects.toBeInstanceOf( + CredentialGroupOAuthStateVersionError + ) + await expect(consumeCredentialGroupMcpOAuthAttempt(state)).resolves.toBeNull() + }) }) diff --git a/apps/sim/lib/credential-groups/mcp-oauth-state.ts b/apps/sim/lib/credential-groups/mcp-oauth-state.ts index e82b76c699c..b8dfd1bf875 100644 --- a/apps/sim/lib/credential-groups/mcp-oauth-state.ts +++ b/apps/sim/lib/credential-groups/mcp-oauth-state.ts @@ -1,9 +1,11 @@ import { sha256Hex } from '@sim/security/hash' import { getRedisClient } from '@/lib/core/config/redis' +import { resourceScopeFields, resourceScopeFromOwner } from '@/lib/core/resource-scope' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' +import { assertCredentialGroupOAuthAttemptVersion } from '@/lib/credential-groups/oauth-attempt-version' const MCP_OAUTH_ATTEMPT_TTL_MS = 10 * 60 * 1000 -const MCP_OAUTH_ATTEMPT_VERSION = 2 as const +const MCP_OAUTH_ATTEMPT_VERSION = 3 as const const MCP_OAUTH_STATE_PREFIX = 'mcp_cg_' const CONSUME_SCRIPT = ` @@ -25,8 +27,11 @@ return #keys ` interface StoredCredentialGroupMcpOAuthAttempt { + oauthConfigVersion: number + userId: string version: typeof MCP_OAUTH_ATTEMPT_VERSION - workspaceId: string + workspaceId?: string + organizationId?: string email: string enrollmentId: string credentialGroupId: string @@ -37,8 +42,11 @@ interface StoredCredentialGroupMcpOAuthAttempt { } export interface CredentialGroupMcpOAuthAttempt { + oauthConfigVersion: number + userId: string state: string - workspaceId: string + workspaceId?: string + organizationId?: string email: string enrollmentId: string credentialGroupId: string @@ -67,8 +75,17 @@ function isStoredAttempt(value: unknown): value is StoredCredentialGroupMcpOAuth const candidate = value as Record return ( candidate.version === MCP_OAUTH_ATTEMPT_VERSION && - typeof candidate.workspaceId === 'string' && - candidate.workspaceId.length > 0 && + typeof candidate.oauthConfigVersion === 'number' && + Number.isInteger(candidate.oauthConfigVersion) && + candidate.oauthConfigVersion > 0 && + typeof candidate.userId === 'string' && + candidate.userId.length > 0 && + ((typeof candidate.workspaceId === 'string' && + candidate.workspaceId.length > 0 && + candidate.organizationId === undefined) || + (typeof candidate.organizationId === 'string' && + candidate.organizationId.length > 0 && + candidate.workspaceId === undefined)) && typeof candidate.email === 'string' && candidate.email.length >= 3 && candidate.email.length <= 320 && @@ -86,8 +103,11 @@ export function isCredentialGroupMcpOAuthState(state: string): boolean { } export async function createCredentialGroupMcpOAuthAttempt(params: { + oauthConfigVersion: number + userId: string state: string - workspaceId: string + workspaceId?: string + organizationId?: string email: string enrollmentId: string credentialGroupId: string @@ -105,7 +125,9 @@ export async function createCredentialGroupMcpOAuthAttempt(params: { ]) const attempt: StoredCredentialGroupMcpOAuthAttempt = { version: MCP_OAUTH_ATTEMPT_VERSION, - workspaceId: params.workspaceId, + oauthConfigVersion: params.oauthConfigVersion, + userId: params.userId, + ...resourceScopeFields(resourceScopeFromOwner(params)), email: params.email, enrollmentId: params.enrollmentId, credentialGroupId: params.credentialGroupId, @@ -134,6 +156,7 @@ export async function consumeCredentialGroupMcpOAuthAttempt( if (raw === null) return null if (typeof raw !== 'string') throw new Error('Credential Group MCP OAuth state is malformed') const parsed: unknown = JSON.parse(raw) + assertCredentialGroupOAuthAttemptVersion(parsed, MCP_OAUTH_ATTEMPT_VERSION) if (!isStoredAttempt(parsed)) throw new Error('Credential Group MCP OAuth state is malformed') await requireRedis().srem(serverAttemptsKey(parsed.mcpServerId), attemptKey(state)) if (Date.now() - parsed.createdAt > MCP_OAUTH_ATTEMPT_TTL_MS) return null @@ -143,7 +166,9 @@ export async function consumeCredentialGroupMcpOAuthAttempt( ]) return { state, - workspaceId: parsed.workspaceId, + oauthConfigVersion: parsed.oauthConfigVersion, + userId: parsed.userId, + ...resourceScopeFields(resourceScopeFromOwner(parsed)), email: parsed.email, enrollmentId: parsed.enrollmentId, credentialGroupId: parsed.credentialGroupId, diff --git a/apps/sim/lib/credential-groups/mcp-oauth.ts b/apps/sim/lib/credential-groups/mcp-oauth.ts index 1f9d4415eec..98cf25f6ee4 100644 --- a/apps/sim/lib/credential-groups/mcp-oauth.ts +++ b/apps/sim/lib/credential-groups/mcp-oauth.ts @@ -1,4 +1,6 @@ import type { OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js' +import { sha256Hex } from '@sim/security/hash' +import { resourceScopeFields, resourceScopeFromOwner } from '@/lib/core/resource-scope' import type { CredentialGroupMcpOAuthContext } from '@/lib/credential-groups/enrollments' import { createCredentialGroupMcpOAuthAttempt } from '@/lib/credential-groups/mcp-oauth-state' import { encryptManagedMcpTokens, persistManagedMcpCredential } from '@/lib/credentials/managed-mcp' @@ -21,7 +23,7 @@ export async function startCredentialGroupMcpOAuth( return withMcpOauthRefreshLock(context.server.id, async () => { const clientRow = await getOrCreateOauthRow({ mcpServerId: context.server.id, - workspaceId: context.workspaceId, + ...resourceScopeFields(resourceScopeFromOwner(context)), }) const preregistered = await loadPreregisteredClient(context.server.id) const provider = new ManagedMcpOauthProvider({ @@ -42,8 +44,10 @@ export async function startCredentialGroupMcpOAuth( if (!(error instanceof McpOauthRedirectRequired)) throw error const attempt = provider.requireAuthorizationAttempt() await createCredentialGroupMcpOAuthAttempt({ + oauthConfigVersion: context.server.oauthConfigVersion, ...attempt, - workspaceId: context.workspaceId, + userId: context.userId, + ...resourceScopeFields(resourceScopeFromOwner(context)), email: context.email, enrollmentId: context.enrollmentId, credentialGroupId: context.credentialGroupId, @@ -58,12 +62,13 @@ export async function startCredentialGroupMcpOAuth( export async function completeCredentialGroupMcpOAuth( context: CredentialGroupMcpOAuthContext, codeVerifier: string, - authorizationCode: string -): Promise<{ connectionId: string; mcpServerId: string }> { + authorizationCode: string, + invitationToken: string +) { assertSafeOauthServerUrl(context.server.url) const clientRow = await getOrCreateOauthRow({ mcpServerId: context.server.id, - workspaceId: context.workspaceId, + ...resourceScopeFields(resourceScopeFromOwner(context)), }) const preregistered = await loadPreregisteredClient(context.server.id) let grantedTokens: OAuthTokens | undefined @@ -89,16 +94,19 @@ export async function completeCredentialGroupMcpOAuth( } const tools = await mcpService.discoverManagedMcpTools( context.server.id, - context.workspaceId, + resourceScopeFromOwner(context), provider, undefined, { requireComplete: true } ) - const connectionId = await persistManagedMcpCredential({ + const completion = await persistManagedMcpCredential({ + invitationTokenHash: sha256Hex(invitationToken), + oauthConfigVersion: context.server.oauthConfigVersion, enrollmentId: context.enrollmentId, credentialGroupId: context.credentialGroupId, email: context.email, - workspaceId: context.workspaceId, + userId: context.userId, + ...resourceScopeFields(resourceScopeFromOwner(context)), mcpServerId: context.server.id, mcpServerName: context.server.name, tokens: grantedTokens, @@ -108,5 +116,5 @@ export async function completeCredentialGroupMcpOAuth( inputSchema: tool.inputSchema, })), }) - return { connectionId, mcpServerId: context.server.id } + return { ...completion, mcpServerId: context.server.id } } diff --git a/apps/sim/lib/credential-groups/oauth-attempt-version.ts b/apps/sim/lib/credential-groups/oauth-attempt-version.ts new file mode 100644 index 00000000000..8c4159b56eb --- /dev/null +++ b/apps/sim/lib/credential-groups/oauth-attempt-version.ts @@ -0,0 +1,20 @@ +/** An attempt from another release cannot establish the identity and configuration bindings required now. */ +export class CredentialGroupOAuthStateVersionError extends Error { + constructor() { + super('Authorization was started before an update. Reopen your invitation and connect again.') + this.name = 'CredentialGroupOAuthStateVersionError' + } +} + +/** Rejects a different state protocol before decrypting any authorization material. */ +export function assertCredentialGroupOAuthAttemptVersion(value: unknown, expected: number): void { + if ( + value !== null && + typeof value === 'object' && + 'version' in value && + typeof value.version === 'number' && + value.version !== expected + ) { + throw new CredentialGroupOAuthStateVersionError() + } +} diff --git a/apps/sim/lib/credential-groups/oauth-state.test.ts b/apps/sim/lib/credential-groups/oauth-state.test.ts index 28bab899e50..43367d7a768 100644 --- a/apps/sim/lib/credential-groups/oauth-state.test.ts +++ b/apps/sim/lib/credential-groups/oauth-state.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { CredentialGroupOAuthStateVersionError } from '@/lib/credential-groups/oauth-attempt-version' const { mockRedis, values } = vi.hoisted(() => { const values = new Map() @@ -58,6 +59,7 @@ describe('credential group OAuth state', () => { const created = await createCredentialGroupOAuthAttempt({ provider: 'gmail', workspaceId: 'workspace-1', + userId: 'user-1', email: 'person@example.com', enrollmentId: 'enrollment-1', credentialGroupId: 'group-1', @@ -81,6 +83,7 @@ describe('credential group OAuth state', () => { expect(consumed).toMatchObject({ provider: 'gmail', workspaceId: 'workspace-1', + userId: 'user-1', email: 'person@example.com', enrollmentId: 'enrollment-1', credentialGroupId: 'group-1', @@ -100,6 +103,7 @@ describe('credential group OAuth state', () => { createCredentialGroupOAuthAttempt({ provider: 'gmail', workspaceId: 'workspace-1', + userId: 'user-1', email: 'person@example.com', enrollmentId: 'enrollment-1', credentialGroupId: 'group-1', @@ -118,6 +122,7 @@ describe('credential group OAuth state', () => { const created = await createCredentialGroupOAuthAttempt({ provider: 'github-repositories', workspaceId: 'workspace-1', + userId: 'user-1', email: 'person@example.com', enrollmentId: 'enrollment-1', credentialGroupId: 'group-1', @@ -146,6 +151,7 @@ describe('credential group OAuth state', () => { const created = await createCredentialGroupOAuthAttempt({ provider: 'slack', workspaceId: 'workspace-1', + userId: 'user-1', email: 'person@example.com', enrollmentId: 'enrollment-1', credentialGroupId: 'group-1', @@ -173,6 +179,7 @@ describe('credential group OAuth state', () => { const params = { provider: 'gmail' as const, workspaceId: 'workspace-1', + userId: 'user-1', email: 'person@example.com', enrollmentId: 'enrollment-1', credentialGroupId: 'group-1', @@ -193,6 +200,7 @@ describe('credential group OAuth state', () => { const secondAttempt = await consumeCredentialGroupOAuthAttempt(second.state) expect(firstAttempt).toMatchObject({ workspaceId: params.workspaceId, + userId: 'user-1', email: params.email, invitationToken: params.invitationToken, optionId: 'option-1', @@ -200,6 +208,7 @@ describe('credential group OAuth state', () => { }) expect(secondAttempt).toMatchObject({ workspaceId: params.workspaceId, + userId: 'user-1', email: params.email, invitationToken: 'second-invitation', optionId: 'option-2', @@ -213,6 +222,7 @@ describe('credential group OAuth state', () => { const created = await createCredentialGroupOAuthAttempt({ provider: 'gmail', workspaceId: 'workspace-1', + userId: 'user-1', email: 'person@example.com', enrollmentId: 'enrollment-1', credentialGroupId: 'group-1', @@ -235,6 +245,7 @@ describe('credential group OAuth state', () => { const created = await createCredentialGroupOAuthAttempt({ provider: 'gmail', workspaceId: 'workspace-1', + userId: 'user-1', email: 'person@example.com', enrollmentId: 'enrollment-1', credentialGroupId: 'group-1', @@ -259,6 +270,7 @@ describe('organization enrollment OAuth state', () => { const input = { provider: 'gmail' as const, organizationId: 'org-1', + userId: 'user-1', email: 'person@example.com', enrollmentId: 'enrollment-1', credentialGroupId: 'group-1', @@ -278,7 +290,7 @@ describe('organization enrollment OAuth state', () => { it('round trips explicit organization ownership and preserves the setup return destination', async () => { const { state } = await createCredentialGroupOAuthAttempt(input) const raw = JSON.parse([...values.values()][0]!) - expect(raw.version).toBe(4) + expect(raw.version).toBe(5) expect(raw.organizationId).toBe('org-1') expect(raw.workspaceId).toBeUndefined() const attempt = await consumeCredentialGroupOAuthAttempt(state) @@ -296,11 +308,15 @@ describe('organization enrollment OAuth state', () => { ).rejects.toThrow('exactly one') expect(mockRedis.set).not.toHaveBeenCalled() }) - it('does not interpret old workspace-only state as organization authority', async () => { + it.each([3, 4])('requires a new authorization for pre-binding v%s state', async (version) => { const { state } = await createCredentialGroupOAuthAttempt(input) const [key, raw] = [...values.entries()][0]! - values.set(key, JSON.stringify({ ...JSON.parse(raw), version: 3 })) - await expect(consumeCredentialGroupOAuthAttempt(state)).rejects.toThrow('malformed') + const stored = { ...JSON.parse(raw), version } + stored.userId = undefined + values.set(key, JSON.stringify(stored)) + await expect(consumeCredentialGroupOAuthAttempt(state)).rejects.toBeInstanceOf( + CredentialGroupOAuthStateVersionError + ) await expect(consumeCredentialGroupOAuthAttempt(state)).resolves.toBeNull() }) }) diff --git a/apps/sim/lib/credential-groups/oauth-state.ts b/apps/sim/lib/credential-groups/oauth-state.ts index d6cda024387..e832e82ebca 100644 --- a/apps/sim/lib/credential-groups/oauth-state.ts +++ b/apps/sim/lib/credential-groups/oauth-state.ts @@ -4,14 +4,14 @@ import { generateId } from '@sim/utils/id' import { getRedisClient } from '@/lib/core/config/redis' import { resourceScopeFields, resourceScopeFromOwner } from '@/lib/core/resource-scope' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' +import { assertCredentialGroupOAuthAttemptVersion } from '@/lib/credential-groups/oauth-attempt-version' import { type CredentialGroupProvider, isCredentialGroupProvider, } from '@/lib/credential-groups/providers' const OAUTH_ATTEMPT_TTL_MS = 10 * 60 * 1000 -const OAUTH_ATTEMPT_VERSION = 4 as const -const LEGACY_OAUTH_ATTEMPT_VERSION = 3 as const +const OAUTH_ATTEMPT_VERSION = 5 as const const OAUTH_ATTEMPT_STATE_PREFIX = 'cg_' const CONSUME_SCRIPT = ` @@ -24,7 +24,8 @@ return value ` interface StoredCredentialGroupOAuthAttempt { - version: typeof OAUTH_ATTEMPT_VERSION | typeof LEGACY_OAUTH_ATTEMPT_VERSION + version: typeof OAUTH_ATTEMPT_VERSION + userId: string provider: CredentialGroupProvider workspaceId?: string organizationId?: string @@ -45,6 +46,7 @@ interface StoredCredentialGroupOAuthAttempt { } export interface CredentialGroupOAuthAttempt { + userId: string state: string provider: CredentialGroupProvider nonceHash: string @@ -66,6 +68,7 @@ export interface CredentialGroupOAuthAttempt { } interface CreateCredentialGroupOAuthAttemptParams { + userId: string provider: CredentialGroupProvider workspaceId?: string organizationId?: string @@ -99,8 +102,9 @@ function isStoredAttempt(value: unknown): value is StoredCredentialGroupOAuthAtt if (!value || typeof value !== 'object') return false const candidate = value as Record return ( - (candidate.version === OAUTH_ATTEMPT_VERSION || - candidate.version === LEGACY_OAUTH_ATTEMPT_VERSION) && + candidate.version === OAUTH_ATTEMPT_VERSION && + typeof candidate.userId === 'string' && + candidate.userId.length > 0 && typeof candidate.provider === 'string' && isCredentialGroupProvider(candidate.provider) && ((typeof candidate.workspaceId === 'string' && @@ -146,7 +150,8 @@ export async function createCredentialGroupOAuthAttempt( encryptSecret(params.invitationToken), ]) const attempt: StoredCredentialGroupOAuthAttempt = { - version: params.organizationId ? OAUTH_ATTEMPT_VERSION : LEGACY_OAUTH_ATTEMPT_VERSION, + version: OAUTH_ATTEMPT_VERSION, + userId: params.userId, provider: params.provider, ...resourceScopeFields(resourceScopeFromOwner(params)), email: params.email, @@ -190,6 +195,7 @@ export async function consumeCredentialGroupOAuthAttempt( if (typeof raw !== 'string') throw new Error('Credential group OAuth state is malformed') const parsed: unknown = JSON.parse(raw) + assertCredentialGroupOAuthAttemptVersion(parsed, OAUTH_ATTEMPT_VERSION) if (!isStoredAttempt(parsed)) throw new Error('Credential group OAuth state is malformed') if (Date.now() - parsed.createdAt > OAUTH_ATTEMPT_TTL_MS) return null @@ -199,6 +205,7 @@ export async function consumeCredentialGroupOAuthAttempt( ]) return { state, + userId: parsed.userId, provider: parsed.provider, nonceHash: parsed.nonceHash, ...resourceScopeFields(resourceScopeFromOwner(parsed)), diff --git a/apps/sim/lib/credential-groups/oauth.test.ts b/apps/sim/lib/credential-groups/oauth.test.ts index b4ec99900be..e748e546aa2 100644 --- a/apps/sim/lib/credential-groups/oauth.test.ts +++ b/apps/sim/lib/credential-groups/oauth.test.ts @@ -47,6 +47,7 @@ import { startCredentialGroupOAuth, } from '@/lib/credential-groups/oauth' import { CredentialGroupInvitationUnavailableError } from '@/lib/credential-groups/provider-adapter' +import { dispatchMemberSyncsForCredentialOption } from '@/lib/knowledge/connectors/member-queue' const POLICY = { provider: 'gmail' as const, @@ -63,6 +64,7 @@ const CONTEXT = { workspaceId: 'workspace-1', workspaceName: 'Workspace', workspaceOwnerId: 'owner-1', + credentialOwnerId: 'person-1', email: 'person@example.com', enrollmentStatus: 'in_progress' as const, option: { @@ -144,6 +146,7 @@ describe('credential group OAuth persistence', () => { CONTEXT, { state: 'state-1', + userId: 'person-1', provider: 'gmail', nonceHash: 'nonce-hash', workspaceId: CONTEXT.workspaceId, @@ -180,6 +183,7 @@ describe('credential group OAuth persistence', () => { CONTEXT, { state: 'state-1', + userId: 'person-1', provider: 'gmail', nonceHash: 'nonce-hash', workspaceId: CONTEXT.workspaceId, @@ -211,7 +215,7 @@ describe('credential group OAuth persistence', () => { }) it.each([true, false])( - 'rechecks organization membership after the provider exchange (member=%s)', + 'accepts the bound person without requiring organization membership (member=%s)', async (currentMember) => { const context = { ...CONTEXT, @@ -222,6 +226,7 @@ describe('credential group OAuth persistence', () => { } const attempt = { state: 'state-1', + userId: 'person-1', provider: 'gmail' as const, nonceHash: 'nonce', organizationId: 'org-1', @@ -244,23 +249,25 @@ describe('credential group OAuth persistence', () => { dbChainMockFns.returning .mockResolvedValueOnce([{ id: 'credential-1' }]) .mockResolvedValueOnce([{ id: CONTEXT.enrollmentId }]) - if (currentMember) { - await expect( - completeCredentialGroupOAuth(context, attempt, 'authorization-code') - ).resolves.toMatchObject({ credentialId: 'credential-1', created: true }) - expect(dbChainMockFns.values).toHaveBeenCalledWith( - expect.objectContaining({ - organizationId: 'org-1', - workspaceId: null, - createdBy: 'person-1', - }) - ) - } else { - await expect( - completeCredentialGroupOAuth(context, attempt, 'authorization-code') - ).rejects.toBeInstanceOf(CredentialGroupInvitationUnavailableError) - expect(dbChainMockFns.insert).not.toHaveBeenCalled() - } + await expect( + completeCredentialGroupOAuth(context, attempt, 'authorization-code') + ).resolves.toMatchObject({ credentialId: 'credential-1', created: true }) + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ + organizationId: 'org-1', + workspaceId: null, + createdBy: 'person-1', + }) + ) + expect(eq).toHaveBeenCalledWith(schemaMock.credentialGroupEnrollment.userId, 'person-1') + expect(dispatchMemberSyncsForCredentialOption).toHaveBeenCalledWith({ + organizationId: 'org-1', + credentialGroupOptionId: 'option-1', + }) + expect(eq).toHaveBeenCalledWith( + schemaMock.credentialGroupEnrollment.invitationTokenHash, + expect.any(String) + ) } ) @@ -283,6 +290,7 @@ describe('credential group OAuth persistence', () => { { ...CONTEXT, enrollmentStatus: 'completed' }, { state: 'state-1', + userId: 'person-1', provider: 'gmail', nonceHash: 'nonce-hash', workspaceId: CONTEXT.workspaceId, @@ -343,6 +351,7 @@ describe('credential group OAuth persistence', () => { { ...CONTEXT, enrollmentStatus: 'completed' }, { state: 'state-1', + userId: 'person-1', provider: 'gmail', nonceHash: 'nonce-hash', workspaceId: CONTEXT.workspaceId, @@ -380,6 +389,7 @@ describe('credential group OAuth persistence', () => { CONTEXT, { state: 'state', + userId: 'person-1', provider: 'gmail', workspaceId: CONTEXT.workspaceId, email: CONTEXT.email, diff --git a/apps/sim/lib/credential-groups/oauth.ts b/apps/sim/lib/credential-groups/oauth.ts index 3ad58a58d16..8bb3afb9494 100644 --- a/apps/sim/lib/credential-groups/oauth.ts +++ b/apps/sim/lib/credential-groups/oauth.ts @@ -1,10 +1,10 @@ import { db } from '@sim/db' -import { credential, credentialGroup, credentialGroupEnrollment, member } from '@sim/db/schema' +import { credential, credentialGroup, credentialGroupEnrollment } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { sha256Hex } from '@sim/security/hash' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, ne, sql } from 'drizzle-orm' -import { acquireOrganizationUserMutationLocks } from '@/lib/billing/organizations/membership' import { resourceScopeColumns, resourceScopeFields, @@ -112,10 +112,12 @@ export async function startCredentialGroupOAuth( invitationToken: string, options: { completionRedirect?: boolean; returnTo?: 'search' } = {} ): Promise { + if (!context.credentialOwnerId) throw new CredentialGroupInvitationUnavailableError() const adapter = getOptionAdapter(context) const policy = await assertCurrentPolicy(context, adapter) const prepared = await adapter.prepareAuthorization(context, policy) const { state, nonce } = await createCredentialGroupOAuthAttempt({ + userId: context.credentialOwnerId, provider: policy.provider, ...resourceScopeFields(resourceScopeFromOwner(context)), email: context.email, @@ -138,31 +140,15 @@ async function persistGrant( context: CredentialGroupOAuthContext, adapter: CredentialGroupProviderAdapter, policy: CredentialGroupProviderPolicy, - grant: VerifiedCredentialGroupGrant + grant: VerifiedCredentialGroupGrant, + invitationTokenHash: string ): Promise { if (grant.providerId !== policy.providerId) { throw new CredentialGroupOAuthError('Provider returned a credential for another app.', 502) } const completion: CredentialGroupOAuthCompletion = await db.transaction(async (tx) => { - if (context.organizationId) { - if (!context.credentialOwnerId) throw new CredentialGroupInvitationUnavailableError() - await acquireOrganizationUserMutationLocks(tx, { - userId: context.credentialOwnerId, - organizationIds: [context.organizationId], - }) - const [membership] = await tx - .select({ id: member.id }) - .from(member) - .where( - and( - eq(member.organizationId, context.organizationId), - eq(member.userId, context.credentialOwnerId) - ) - ) - .limit(1) - if (!membership) throw new CredentialGroupInvitationUnavailableError() - } + if (!context.credentialOwnerId) throw new CredentialGroupInvitationUnavailableError() await lockCredentialGroupEnrollmentLifecycle(tx, context.enrollmentId) await tx.execute( sql`SELECT pg_advisory_xact_lock(hashtextextended(${`credential-group-oauth:${context.enrollmentId}:${context.option.id}`}, 0))` @@ -177,7 +163,9 @@ async function persistGrant( and( eq(credentialGroupEnrollment.id, context.enrollmentId), eq(credentialGroupEnrollment.credentialGroupId, context.credentialGroupId), - eq(credentialGroupEnrollment.email, context.email) + eq(credentialGroupEnrollment.email, context.email), + eq(credentialGroupEnrollment.userId, context.credentialOwnerId), + eq(credentialGroupEnrollment.invitationTokenHash, invitationTokenHash) ) ) .limit(1) @@ -301,7 +289,7 @@ async function persistGrant( .values({ id: generateId(), ...values, - createdBy: context.credentialOwnerId ?? context.workspaceOwnerId, + createdBy: context.credentialOwnerId, createdAt: now, }) .returning({ id: credential.id }) @@ -371,6 +359,7 @@ export async function completeCredentialGroupOAuth( attempt.email !== context.email || attempt.enrollmentId !== context.enrollmentId || attempt.credentialGroupId !== context.credentialGroupId || + attempt.userId !== context.credentialOwnerId || attempt.optionId !== context.option.id || attempt.provider !== context.option.provider ) { @@ -379,5 +368,5 @@ export async function completeCredentialGroupOAuth( const adapter = getOptionAdapter(context) const policy = await assertCurrentPolicy(context, adapter, attempt) const grant = await adapter.exchangeAndVerify({ context, attempt, code, policy }) - return persistGrant(context, adapter, policy, grant) + return persistGrant(context, adapter, policy, grant, sha256Hex(attempt.invitationToken)) } diff --git a/apps/sim/lib/credential-groups/organization-setup.test.ts b/apps/sim/lib/credential-groups/organization-setup.test.ts new file mode 100644 index 00000000000..b9dd5cef58c --- /dev/null +++ b/apps/sim/lib/credential-groups/organization-setup.test.ts @@ -0,0 +1,29 @@ +/** @vitest-environment node */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ policy: vi.fn() })) +vi.mock('@/lib/resource-policies/repository', () => ({ + requireResourcePolicy: mocks.policy, + ResourcePolicyNotFoundError: class extends Error {}, +})) + +import { requireOrganizationAccountsSetup } from '@/lib/credential-groups/organization-setup' +import { ResourcePolicyNotFoundError } from '@/lib/resource-policies/repository' + +describe('fresh organization account setup', () => { + beforeEach(() => vi.clearAllMocks()) + it('requires an existing org policy instead of creating grants for a legacy group', async () => { + mocks.policy.mockRejectedValue( + new ResourcePolicyNotFoundError('credential_group', 'legacy-group') + ) + await expect(requireOrganizationAccountsSetup('org-1', 'legacy-group')).rejects.toMatchObject({ + code: 'conflict', + }) + }) + it('does not conceal a malformed policy', async () => { + mocks.policy.mockRejectedValue(new Error('Malformed policy')) + await expect(requireOrganizationAccountsSetup('org-1', 'group-1')).rejects.toThrow( + 'Malformed policy' + ) + }) +}) diff --git a/apps/sim/lib/credential-groups/organization-setup.ts b/apps/sim/lib/credential-groups/organization-setup.ts new file mode 100644 index 00000000000..8eaf50b58d7 --- /dev/null +++ b/apps/sim/lib/credential-groups/organization-setup.ts @@ -0,0 +1,33 @@ +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { organizationAccountAccessPolicyCodec } from '@/lib/credential-groups/application/workspace-access-policy' +import type { DbOrTx } from '@/lib/db/types' +import { + ResourcePolicyNotFoundError, + requireResourcePolicy, +} from '@/lib/resource-policies/repository' + +/** A v2 policy is created atomically with a fresh org group; legacy grants are never adopted automatically. */ +export async function requireOrganizationAccountsSetup( + organizationId: string, + credentialGroupId: string, + executor?: DbOrTx +): Promise { + try { + await requireResourcePolicy( + { + organizationId, + resourceType: 'credential_group', + resourceId: credentialGroupId, + codec: organizationAccountAccessPolicyCodec, + }, + executor + ) + } catch (error) { + if (error instanceof ResourcePolicyNotFoundError) + throw new OrchestrationError( + 'conflict', + 'Existing organization accounts require a migration review. Check Search dependencies and reconnect people before enabling workflow sharing.' + ) + throw error + } +} diff --git a/apps/sim/lib/credential-groups/provider-configuration.ts b/apps/sim/lib/credential-groups/provider-configuration.ts index eb1e496371e..a5b5fef6461 100644 --- a/apps/sim/lib/credential-groups/provider-configuration.ts +++ b/apps/sim/lib/credential-groups/provider-configuration.ts @@ -12,7 +12,7 @@ const CREDENTIAL_GROUP_PROVIDER_CONFIGURATION_TYPE = const CREDENTIAL_GROUP_PROVIDER_CONFIGURATION_VERSION = 1 as const export interface SlackCredentialGroupConfiguration { - slackBotCredentialId: string + slackBotCredentialId?: string clientId: string clientSecret: string appId: string @@ -31,7 +31,8 @@ function isSlackConfiguration(value: unknown): value is SlackCredentialGroupConf if (!value || typeof value !== 'object') return false const candidate = value as Record return ( - typeof candidate.slackBotCredentialId === 'string' && + (candidate.slackBotCredentialId === undefined || + typeof candidate.slackBotCredentialId === 'string') && typeof candidate.clientId === 'string' && typeof candidate.clientSecret === 'string' && typeof candidate.appId === 'string' && @@ -118,7 +119,7 @@ export async function getSlackCredentialGroupConfiguration(params: { export async function listSlackCredentialGroupConfigurationsForBot(params: { workspaceId?: string | null organizationId?: string | null - slackBotCredentialId: string + slackBotCredentialId?: string }): Promise { const rows = await db .select({ encryptedProviderConfiguration: credentialGroup.encryptedProviderConfiguration }) diff --git a/apps/sim/lib/credential-groups/scoped-availability.test.ts b/apps/sim/lib/credential-groups/scoped-availability.test.ts index 7644078e212..f9b3a6e9523 100644 --- a/apps/sim/lib/credential-groups/scoped-availability.test.ts +++ b/apps/sim/lib/credential-groups/scoped-availability.test.ts @@ -54,15 +54,15 @@ describe('owner-scoped connected accounts availability', () => { isScopedCredentialGroupsAvailable({ kind: 'organization', organizationId: 'org-1' }) ).resolves.toBe(false) }) - it('retains the existing workspace gate and payer semantics', async () => { - const billing = { isEnterprise: true } + it('resolves a workspace to its organization instead of evaluating a workspace rollout', async () => { + const billing = { isEnterprise: true, organizationId: 'org-parent' } mocks.workspace.mockResolvedValue(billing) mocks.workspaceAvailable.mockResolvedValue(true) await expect( isScopedCredentialGroupsAvailable({ kind: 'workspace', workspaceId: 'ws-1' }) ).resolves.toBe(true) expect(mocks.workspaceAvailable).toHaveBeenCalledWith({ - workspaceId: 'ws-1', + organizationId: 'org-parent', ownerBilling: billing, }) expect(mocks.subscription).not.toHaveBeenCalled() diff --git a/apps/sim/lib/credential-groups/scoped-availability.ts b/apps/sim/lib/credential-groups/scoped-availability.ts index b5ec16ec3f8..8eb4649287a 100644 --- a/apps/sim/lib/credential-groups/scoped-availability.ts +++ b/apps/sim/lib/credential-groups/scoped-availability.ts @@ -8,11 +8,14 @@ import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import type { ResourceScope } from '@/lib/core/resource-scope' import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability' -/** Uses the resource's exact payer and flag scope; organization membership never implies workspace access. */ +/** Workspace callers inherit their canonical organization's rollout; authorization remains separate. */ export async function isScopedCredentialGroupsAvailable(scope: ResourceScope): Promise { if (scope.kind === 'workspace') { const ownerBilling = await getWorkspaceOwnerSubscriptionAccess(scope.workspaceId) - return isCredentialGroupsAvailable({ workspaceId: scope.workspaceId, ownerBilling }) + return isCredentialGroupsAvailable({ + organizationId: ownerBilling.organizationId, + ownerBilling, + }) } if (!(await isFeatureEnabled('credential-groups', { orgId: scope.organizationId }))) return false if (!isHosted) return true diff --git a/apps/sim/lib/credential-groups/self-enrollment.test.ts b/apps/sim/lib/credential-groups/self-enrollment.test.ts index 44a83e7b0b4..a2afa86b899 100644 --- a/apps/sim/lib/credential-groups/self-enrollment.test.ts +++ b/apps/sim/lib/credential-groups/self-enrollment.test.ts @@ -2,9 +2,15 @@ import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { issue } = vi.hoisted(() => ({ issue: vi.fn() })) +const { issue, authenticate, bind } = vi.hoisted(() => ({ + issue: vi.fn(), + authenticate: vi.fn(), + bind: vi.fn(), +})) vi.mock('@/lib/credential-groups/enrollments', () => ({ createCredentialGroupSelfEnrollmentLink: issue, + authenticatePublicCredentialGroupEnrollment: authenticate, + bindCredentialGroupEnrollmentUser: bind, CredentialGroupEnrollmentError: class extends Error { constructor( message: string, @@ -24,12 +30,15 @@ describe('viewer account enrollment', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() + authenticate.mockResolvedValue({ enrollmentId: 'enrollment' }) + bind.mockResolvedValue(undefined) }) it('uses the verified account email rather than a caller-supplied address', async () => { dbChainMockFns.limit .mockResolvedValueOnce([{ email: ' Viewer@Example.com ', emailVerified: true }]) .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) issue.mockResolvedValue({ enrollment: { id: 'enrollment' }, invitationLink: 'https://sim.test/enroll/token', @@ -53,6 +62,7 @@ describe('viewer account enrollment', () => { it('refuses a revoked enrollment without minting a link', async () => { dbChainMockFns.limit .mockResolvedValueOnce([{ email: 'viewer@example.com', emailVerified: true }]) + .mockResolvedValueOnce([]) .mockResolvedValueOnce([{ status: 'revoked' }]) await expect(createViewerCredentialGroupEnrollment(input)).rejects.toThrow( 'removed your access' @@ -64,6 +74,7 @@ describe('viewer account enrollment', () => { dbChainMockFns.limit .mockResolvedValueOnce([{ email: 'viewer@example.com', emailVerified: true }]) .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) .mockResolvedValueOnce([{ status: 'revoked' }]) issue.mockRejectedValue(new CredentialGroupEnrollmentError('Revoked', 409)) await expect(createViewerCredentialGroupEnrollment(input)).rejects.toThrow( diff --git a/apps/sim/lib/credential-groups/self-enrollment.ts b/apps/sim/lib/credential-groups/self-enrollment.ts index 626a868fca8..14e7b4bea61 100644 --- a/apps/sim/lib/credential-groups/self-enrollment.ts +++ b/apps/sim/lib/credential-groups/self-enrollment.ts @@ -5,6 +5,8 @@ import { and, eq } from 'drizzle-orm' import { OrchestrationError } from '@/lib/core/orchestration/types' import { resourceScopeFromOwner } from '@/lib/core/resource-scope' import { + authenticatePublicCredentialGroupEnrollment, + bindCredentialGroupEnrollmentUser, CredentialGroupEnrollmentError, createCredentialGroupSelfEnrollmentLink, } from '@/lib/credential-groups/enrollments' @@ -28,18 +30,34 @@ export async function createViewerCredentialGroupEnrollment(input: { 'Verify your email address before connecting an account' ) } - const email = normalizeEmail(viewer.email) + const [boundEnrollment] = await db + .select({ email: credentialGroupEnrollment.email }) + .from(credentialGroupEnrollment) + .where( + and( + eq(credentialGroupEnrollment.credentialGroupId, input.credentialGroupId), + eq(credentialGroupEnrollment.userId, input.userId) + ) + ) + .limit(1) + const email = boundEnrollment?.email ?? normalizeEmail(viewer.email) const revoked = new OrchestrationError( 'forbidden', 'An admin removed your access to Connected accounts' ) if (await isEnrollmentRevoked(input.credentialGroupId, email)) throw revoked try { - return await createCredentialGroupSelfEnrollmentLink( + const result = await createCredentialGroupSelfEnrollmentLink( resourceScopeFromOwner(input), input.credentialGroupId, email ) + const token = new URL(result.invitationLink).pathname.split('/').at(-1) + if (!token) throw new Error('Enrollment link is missing its token') + const identity = await authenticatePublicCredentialGroupEnrollment(token) + if (!identity) throw new Error('Enrollment is no longer available') + await bindCredentialGroupEnrollmentUser(identity, input.userId) + return result } catch (error) { /** The issue refused a revocation that landed after the read above; report it as such. */ if ( diff --git a/apps/sim/lib/credential-groups/service.ts b/apps/sim/lib/credential-groups/service.ts index d67564355b8..d0d766f4e57 100644 --- a/apps/sim/lib/credential-groups/service.ts +++ b/apps/sim/lib/credential-groups/service.ts @@ -20,6 +20,7 @@ import { import { resourceScopeCondition } from '@/lib/core/resource-scope.server' import { decodeCredentialGroupWorkflowAccessPolicy } from '@/lib/credential-groups/application/workflow-access-policy' import { getManagedMcpConnector } from '@/lib/credential-groups/managed-mcp-connectors' +import { requireOrganizationAccountsSetup } from '@/lib/credential-groups/organization-setup' import { credentialGroupScopePolicyVersion } from '@/lib/credential-groups/provider-adapter' import { decryptCredentialGroupProviderConfiguration } from '@/lib/credential-groups/provider-configuration' import { getCredentialGroupProviderAdapter } from '@/lib/credential-groups/provider-registry' @@ -165,7 +166,7 @@ async function toCredentialGroup( if (option.provider !== 'slack') { return { ...common, provider: option.provider, configurationStatus: 'ready' as const } } - if (!option.slackBotCredentialId) { + if (row.workspaceId && !option.slackBotCredentialId) { throw new Error(`Slack credential option ${option.id} has no custom bot`) } return { @@ -175,7 +176,8 @@ async function toCredentialGroup( requiredScopes: resolveSlackManagedUserScopes(option.requiredScopes), configurationStatus: !providerConfiguration.slack || - providerConfiguration.slack.slackBotCredentialId !== option.slackBotCredentialId + (row.workspaceId && + providerConfiguration.slack.slackBotCredentialId !== option.slackBotCredentialId) ? ('not_configured' as const) : option.scopeVersion !== credentialGroupScopePolicyVersion( @@ -282,6 +284,9 @@ export async function ensureWorkspaceAccountsGroup( 'Connected accounts is disabled. Enable it in Settings before connecting a source.' ) } + if (scope.kind === 'organization') { + await requireOrganizationAccountsSetup(scope.organizationId, existing.id, tx) + } if (!preparedOption) return existing const matching = existing.options.filter( (candidate) => candidate.provider === preparedOption.provider @@ -527,6 +532,7 @@ export async function getOrganizationAccountsGroup( .where(resourceScopeCondition(credentialGroup, { kind: 'organization', organizationId })) .limit(1) if (!row?.organizationId || row.workspaceId) return null + await requireOrganizationAccountsSetup(organizationId, row.id) return { ...(await toCredentialGroup(row, await listLinkedMcpServers(row.id))), workspaceId: null, diff --git a/apps/sim/lib/credential-groups/slack-managed-users.test.ts b/apps/sim/lib/credential-groups/slack-managed-users.test.ts index c1e2f1cf857..ba85371a37b 100644 --- a/apps/sim/lib/credential-groups/slack-managed-users.test.ts +++ b/apps/sim/lib/credential-groups/slack-managed-users.test.ts @@ -95,13 +95,16 @@ describe('Slack managed-user authorization', () => { organizationId: 'org-1', userId: 'user-1', credentialGroupId: 'group-1', - slackBotCredentialId: 'bot-1', + appId: 'A123', + teamId: 'T123', clientId: 'client-1', clientSecret: 'private-client-secret', }) const loaded = await loadSlackManagedUsersAttempt(created.state) expect(loaded).toMatchObject({ organizationId: 'org-1', userId: 'user-1' }) expect(loaded).not.toHaveProperty('workspaceId') + expect(loaded).not.toHaveProperty('slackBotCredentialId') + expect(fetch).not.toHaveBeenCalled() const [key, stored] = [...attempts.entries()][0] expect(stored).not.toContain('private-client-secret') attempts.set(key, JSON.stringify({ ...JSON.parse(stored), workspaceId: 'workspace-1' })) @@ -497,7 +500,7 @@ describe('Slack managed-user authorization', () => { }, code: 'single-use-code', }) - ).rejects.toThrow('different Slack app or workspace') + ).rejects.toThrow('different app or workspace') expect(fetchMock).toHaveBeenNthCalledWith( 2, 'https://slack.com/api/auth.revoke', diff --git a/apps/sim/lib/credential-groups/slack-managed-users.ts b/apps/sim/lib/credential-groups/slack-managed-users.ts index 621a3bacfa1..8c3f9719cc8 100644 --- a/apps/sim/lib/credential-groups/slack-managed-users.ts +++ b/apps/sim/lib/credential-groups/slack-managed-users.ts @@ -26,7 +26,7 @@ import { SLACK_CUSTOM_BOT_PROVIDER_ID, SLACK_CUSTOM_BOT_SECRET_TYPE } from '@/li const logger = createLogger('SlackManagedUsers') const SLACK_MANAGED_USERS_ATTEMPT_TTL_MS = 10 * 60 * 1000 -const SLACK_MANAGED_USERS_ATTEMPT_VERSION = 3 as const +const SLACK_MANAGED_USERS_ATTEMPT_VERSION = 4 as const const MAX_SLACK_RESPONSE_BYTES = 64 * 1024 const CONSUME_SCRIPT = ` local value = redis.call('GET', KEYS[1]) @@ -54,8 +54,8 @@ interface StoredSlackManagedUsersAttempt { userId: string credentialGroupId: string credentialGroupUpdatedAt: number - slackBotCredentialId: string - slackBotCredentialUpdatedAt: number + slackBotCredentialId?: string + slackBotCredentialUpdatedAt?: number expectedAppId: string expectedTeamId: string clientId: string @@ -71,8 +71,8 @@ export interface SlackManagedUsersAttempt { userId: string credentialGroupId: string credentialGroupUpdatedAt: number - slackBotCredentialId: string - slackBotCredentialUpdatedAt: number + slackBotCredentialId?: string + slackBotCredentialUpdatedAt?: number expectedAppId: string expectedTeamId: string clientId: string @@ -144,8 +144,11 @@ function isStoredAttempt(value: unknown): value is StoredSlackManagedUsersAttemp typeof candidate.userId === 'string' && typeof candidate.credentialGroupId === 'string' && typeof candidate.credentialGroupUpdatedAt === 'number' && - typeof candidate.slackBotCredentialId === 'string' && - typeof candidate.slackBotCredentialUpdatedAt === 'number' && + (candidate.organizationId !== undefined + ? candidate.slackBotCredentialId === undefined && + candidate.slackBotCredentialUpdatedAt === undefined + : typeof candidate.slackBotCredentialId === 'string' && + typeof candidate.slackBotCredentialUpdatedAt === 'number') && typeof candidate.expectedAppId === 'string' && typeof candidate.expectedTeamId === 'string' && typeof candidate.clientId === 'string' && @@ -439,7 +442,9 @@ export async function createSlackManagedUsersAttempt(params: { organizationId?: string userId: string credentialGroupId: string - slackBotCredentialId: string + slackBotCredentialId?: string + appId?: string + teamId?: string clientId: string clientSecret: string requiredScopes?: string[] @@ -466,17 +471,33 @@ export async function createSlackManagedUsersAttempt(params: { existingOption?.requiredScopes ?? (existingOption ? undefined : SLACK_SEARCH_USER_SCOPES) ) - const bot = await getSlackCustomBotCredential({ - ...resourceScopeFields(scope), - credentialId: params.slackBotCredentialId, - }) - if (!bot) throw new SlackManagedUsersError('Custom Slack bot not found.', 'invalid_response') - const identity = await verifySlackCustomBotAppIdentity(bot.botToken) - if (identity.teamId !== bot.teamId) { - throw new SlackManagedUsersError( - 'The custom bot token no longer belongs to its stored Slack workspace.', - 'invalid_response' + let bot: Awaited> = null + let identity: { appId: string; teamId: string } + if (scope.kind === 'organization') { + if ( + params.slackBotCredentialId || + !params.appId?.match(/^A[A-Z0-9]+$/) || + !params.teamId?.match(/^T[A-Z0-9]+$/) ) + throw new SlackManagedUsersError( + 'Organization Slack setup requires an App ID and workspace ID.', + 'invalid_response' + ) + identity = { appId: params.appId, teamId: params.teamId } + } else { + if (!params.slackBotCredentialId) + throw new SlackManagedUsersError('Select a custom Slack bot.', 'invalid_response') + bot = await getSlackCustomBotCredential({ + ...resourceScopeFields(scope), + credentialId: params.slackBotCredentialId, + }) + if (!bot) throw new SlackManagedUsersError('Custom Slack bot not found.', 'invalid_response') + identity = await verifySlackCustomBotAppIdentity(bot.botToken) + if (identity.teamId !== bot.teamId) + throw new SlackManagedUsersError( + 'The bot no longer belongs to its stored Slack workspace.', + 'invalid_response' + ) } const redis = requireRedis() const state = generateId() @@ -488,8 +509,9 @@ export async function createSlackManagedUsersAttempt(params: { userId: params.userId, credentialGroupId: group.id, credentialGroupUpdatedAt: group.updatedAt.getTime(), - slackBotCredentialId: bot.id, - slackBotCredentialUpdatedAt: bot.updatedAt.getTime(), + ...(bot + ? { slackBotCredentialId: bot.id, slackBotCredentialUpdatedAt: bot.updatedAt.getTime() } + : {}), expectedAppId: identity.appId, expectedTeamId: identity.teamId, clientId: params.clientId, @@ -546,8 +568,12 @@ async function parseSlackManagedUsersAttempt( userId: parsed.userId, credentialGroupId: parsed.credentialGroupId, credentialGroupUpdatedAt: parsed.credentialGroupUpdatedAt, - slackBotCredentialId: parsed.slackBotCredentialId, - slackBotCredentialUpdatedAt: parsed.slackBotCredentialUpdatedAt, + ...(parsed.workspaceId + ? { + slackBotCredentialId: parsed.slackBotCredentialId, + slackBotCredentialUpdatedAt: parsed.slackBotCredentialUpdatedAt, + } + : {}), expectedAppId: parsed.expectedAppId, expectedTeamId: parsed.expectedTeamId, clientId: parsed.clientId, @@ -564,7 +590,7 @@ export async function exchangeAndConfigureSlackManagedUsers(params: { }): Promise<{ credentialGroupId: string credentialGroupName: string - slackBotCredentialId: string + slackBotCredentialId?: string appId: string teamId: string requiredScopes: string[] @@ -593,7 +619,7 @@ export async function exchangeAndConfigureSlackManagedUsers(params: { grant.teamId !== params.attempt.expectedTeamId ) { throw new SlackManagedUsersError( - 'The Client ID and Client Secret belong to a different Slack app or workspace than the selected custom bot.', + 'Slack returned a different app or workspace than the configured App ID and workspace ID.', 'invalid_response' ) } @@ -636,58 +662,62 @@ export async function exchangeAndConfigureSlackManagedUsers(params: { 'invalid_state' ) } - const [botRow] = await tx - .select({ - id: credential.id, - updatedAt: credential.updatedAt, - encryptedServiceAccountKey: credential.encryptedServiceAccountKey, - }) - .from(credential) - .where( - and( - eq(credential.id, params.attempt.slackBotCredentialId), - resourceScopeCondition(credential, resourceScopeFromOwner(params.attempt)), - eq(credential.type, 'service_account'), - eq(credential.providerId, SLACK_CUSTOM_BOT_PROVIDER_ID) + if (params.attempt.workspaceId) { + if (!params.attempt.slackBotCredentialId) + throw new SlackManagedUsersError('Workspace Slack bot is missing.', 'invalid_state') + const [botRow] = await tx + .select({ + id: credential.id, + updatedAt: credential.updatedAt, + encryptedServiceAccountKey: credential.encryptedServiceAccountKey, + }) + .from(credential) + .where( + and( + eq(credential.id, params.attempt.slackBotCredentialId), + resourceScopeCondition(credential, resourceScopeFromOwner(params.attempt)), + eq(credential.type, 'service_account'), + eq(credential.providerId, SLACK_CUSTOM_BOT_PROVIDER_ID) + ) ) - ) - .limit(1) - if ( - !botRow?.encryptedServiceAccountKey || - botRow.updatedAt.getTime() !== params.attempt.slackBotCredentialUpdatedAt - ) { - throw new SlackManagedUsersError( - 'The custom bot changed while Slack authorization was in progress. Start again.', - 'invalid_state' - ) - } - const decrypted = await decryptSecret(botRow.encryptedServiceAccountKey) - const botSecret = parseSlackCustomBotSecret(JSON.parse(decrypted.decrypted) as unknown) - if (botSecret.teamId !== grant.teamId) { - throw new SlackManagedUsersError( - 'The custom bot no longer belongs to the verified Slack workspace.', - 'invalid_state' - ) + .limit(1) + if ( + !botRow?.encryptedServiceAccountKey || + botRow.updatedAt.getTime() !== params.attempt.slackBotCredentialUpdatedAt + ) { + throw new SlackManagedUsersError( + 'The custom bot changed while Slack authorization was in progress. Start again.', + 'invalid_state' + ) + } + const decrypted = await decryptSecret(botRow.encryptedServiceAccountKey) + const botSecret = parseSlackCustomBotSecret(JSON.parse(decrypted.decrypted) as unknown) + if (botSecret.teamId !== grant.teamId) { + throw new SlackManagedUsersError( + 'The custom bot no longer belongs to the verified Slack workspace.', + 'invalid_state' + ) + } + const sanitizedBotSecret = await encryptSecret(JSON.stringify(botSecret)) + const [cleanedBot] = await tx + .update(credential) + .set({ + encryptedServiceAccountKey: sanitizedBotSecret.encrypted, + authorizationAppId: null, + managedOauthScopeVersion: null, + updatedAt: now, + }) + .where(eq(credential.id, botRow.id)) + .returning({ id: credential.id }) + if (!cleanedBot) throw new Error('Slack custom bot cleanup returned no row') } - const sanitizedBotSecret = await encryptSecret(JSON.stringify(botSecret)) - const [cleanedBot] = await tx - .update(credential) - .set({ - encryptedServiceAccountKey: sanitizedBotSecret.encrypted, - authorizationAppId: null, - managedOauthScopeVersion: null, - updatedAt: now, - }) - .where(eq(credential.id, botRow.id)) - .returning({ id: credential.id }) - if (!cleanedBot) throw new Error('Slack custom bot cleanup returned no row') const currentConfiguration = await decryptCredentialGroupProviderConfiguration( group.encryptedProviderConfiguration ) const encryptedConfiguration = await encryptCredentialGroupProviderConfiguration({ ...currentConfiguration, slack: { - slackBotCredentialId: botRow.id, + slackBotCredentialId: params.attempt.slackBotCredentialId, clientId: params.attempt.clientId, clientSecret: params.attempt.clientSecret, appId: grant.appId, @@ -701,7 +731,7 @@ export async function exchangeAndConfigureSlackManagedUsers(params: { id: existingOption?.id ?? generateId(), provider: 'slack', label: existingOption?.label ?? 'Slack', - slackBotCredentialId: botRow.id, + slackBotCredentialId: params.attempt.slackBotCredentialId, authorizationAppId, requiredScopes: params.attempt.requiredScopes, scopeVersion, @@ -744,7 +774,7 @@ export async function exchangeAndConfigureSlackManagedUsers(params: { return { credentialGroupId: group.id, credentialGroupName: group.name, - slackBotCredentialId: botRow.id, + slackBotCredentialId: params.attempt.slackBotCredentialId, appId: grant.appId, teamId: grant.teamId, requiredScopes: params.attempt.requiredScopes, diff --git a/apps/sim/lib/credential-groups/slack-provider.ts b/apps/sim/lib/credential-groups/slack-provider.ts index 97b3419feb8..0860ff43313 100644 --- a/apps/sim/lib/credential-groups/slack-provider.ts +++ b/apps/sim/lib/credential-groups/slack-provider.ts @@ -40,7 +40,7 @@ async function getSlackPolicy(params: { executor?: DbOrTx }): Promise< CredentialGroupProviderPolicy & { - slackBotCredentialId: string + slackBotCredentialId?: string clientId: string clientSecret: string appId: string @@ -60,19 +60,27 @@ async function getSlackPolicy(params: { 'The selected custom Slack bot does not match this Credential Group configuration' ) } - const app = await getSlackCustomBotCredential({ - ...resourceScopeColumns(resourceScopeFromOwner(params)), - credentialId: managed.slackBotCredentialId, - ...(params.executor ? { executor: params.executor } : {}), - }) - if (!app) { - throw new CredentialGroupProviderConfigurationError( - 'The selected custom Slack bot is unavailable' - ) - } - if (app.teamId !== managed.teamId) { + if (params.workspaceId) { + if (!managed.slackBotCredentialId) + throw new CredentialGroupProviderConfigurationError('Configure the workspace Slack bot') + const app = await getSlackCustomBotCredential({ + ...resourceScopeColumns(resourceScopeFromOwner(params)), + credentialId: managed.slackBotCredentialId, + ...(params.executor ? { executor: params.executor } : {}), + }) + if (!app) { + throw new CredentialGroupProviderConfigurationError( + 'The selected custom Slack bot is unavailable' + ) + } + if (app.teamId !== managed.teamId) { + throw new CredentialGroupProviderConfigurationError( + 'The custom Slack bot no longer belongs to the configured Slack workspace' + ) + } + } else if (managed.slackBotCredentialId) { throw new CredentialGroupProviderConfigurationError( - 'The custom Slack bot no longer belongs to the configured Slack workspace' + 'Reconfigure Slack as an organization personal OAuth app' ) } const service = getCredentialGroupProviderService(PROVIDER) diff --git a/apps/sim/lib/credential-groups/trigger-subscriptions.ts b/apps/sim/lib/credential-groups/trigger-subscriptions.ts index 9c27581f6ac..16b208ced3b 100644 --- a/apps/sim/lib/credential-groups/trigger-subscriptions.ts +++ b/apps/sim/lib/credential-groups/trigger-subscriptions.ts @@ -1,6 +1,6 @@ import { db } from '@sim/db' -import { webhook, workflow, workflowDeploymentVersion } from '@sim/db/schema' -import { and, eq, inArray, isNull, or } from 'drizzle-orm' +import { webhook, workflow, workflowDeploymentVersion, workspace } from '@sim/db/schema' +import { and, eq, inArray, isNull, sql } from 'drizzle-orm' import { CREDENTIAL_GROUP_TRIGGER_PROVIDER } from '@/lib/credential-groups/trigger-constants' import { deliverableWebhookPredicate } from '@/lib/webhooks/delivery-predicate' import type { WebhookRecord, WorkflowRecord } from '@/lib/webhooks/polling/types' @@ -10,17 +10,18 @@ export interface CredentialGroupTriggerSubscription { workflow: WorkflowRecord } -/** Loads only deployed subscriptions in the source workspace that may read this group. */ +/** Loads opted-in Credential triggers deployed in currently allowed organization workspaces. */ export async function fetchCredentialGroupTriggerSubscriptions( - workspaceId: string, - allowedWorkflowIds: string[] + organizationId: string, + allowedWorkspaceIds: string[] ): Promise { - if (allowedWorkflowIds.length === 0) return [] - return db + if (allowedWorkspaceIds.length === 0) return [] + const subscriptions = await db .select({ webhook, workflow }) .from(webhook) .innerJoin(workflow, eq(webhook.workflowId, workflow.id)) - .leftJoin( + .innerJoin(workspace, eq(workspace.id, workflow.workspaceId)) + .innerJoin( workflowDeploymentVersion, and( eq(workflowDeploymentVersion.workflowId, workflow.id), @@ -31,14 +32,17 @@ export async function fetchCredentialGroupTriggerSubscriptions( and( eq(webhook.provider, CREDENTIAL_GROUP_TRIGGER_PROVIDER), deliverableWebhookPredicate(webhook), - eq(workflow.workspaceId, workspaceId), - inArray(workflow.id, allowedWorkflowIds), + eq(workspace.organizationId, organizationId), + isNull(workspace.archivedAt), + inArray(workflow.workspaceId, allowedWorkspaceIds), eq(workflow.isDeployed, true), isNull(workflow.archivedAt), - or( - eq(webhook.deploymentVersionId, workflowDeploymentVersion.id), - and(isNull(workflowDeploymentVersion.id), isNull(webhook.deploymentVersionId)) - ) + eq(webhook.deploymentVersionId, workflowDeploymentVersion.id), + sql`${workflowDeploymentVersion.state}::jsonb -> 'blocks' -> ${webhook.blockId} ->> 'type' = 'credential'` ) ) + .limit(1001) + if (subscriptions.length > 1000) + throw new Error('Organization connected account events exceed the 1000 subscriber limit') + return subscriptions } diff --git a/apps/sim/lib/credential-groups/trigger.test.ts b/apps/sim/lib/credential-groups/trigger.test.ts index 97e74af2ad6..1f8d1ac446d 100644 --- a/apps/sim/lib/credential-groups/trigger.test.ts +++ b/apps/sim/lib/credential-groups/trigger.test.ts @@ -1,21 +1,22 @@ /** * @vitest-environment node */ + import { beforeEach, describe, expect, it, vi } from 'vitest' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { buildOrganizationAccountAccessPolicy } from '@/lib/credential-groups/application/workspace-access-policy' const mocks = vi.hoisted(() => ({ - decodePolicy: vi.fn(), + resolveWorkspace: vi.fn(), + requireAccess: vi.fn(), fetchSubscriptions: vi.fn(), processEvent: vi.fn(), requirePolicy: vi.fn(), })) -vi.mock('@/lib/credential-groups/application/workflow-access-policy', () => ({ - credentialGroupWorkflowAccessPolicyCodec: { - resourceType: 'credential_group', - parse: (value: unknown) => value, - }, - decodeCredentialGroupWorkflowAccessPolicy: mocks.decodePolicy, +vi.mock('@/lib/credential-groups/application/organization-workspace-access', () => ({ + resolveOrganizationAccountsWorkspaceContext: mocks.resolveWorkspace, + requireOrganizationAccountsWorkspaceAccess: mocks.requireAccess, })) vi.mock('@/lib/resource-policies/repository', () => ({ @@ -37,7 +38,7 @@ import { const EVENT = { event: 'credential_added' as const, - workspaceId: 'workspace-1', + organizationId: 'org-1', credentialGroupId: 'group-1', credentialGroupName: 'Credential Group', enrollmentId: 'enrollment-1', @@ -71,12 +72,19 @@ function subscription(params: { workflowId: string; workspaceId?: string; eventT describe('Credential Group trigger delivery', () => { beforeEach(() => { vi.clearAllMocks() - mocks.requirePolicy.mockResolvedValue({ document: {} }) - mocks.decodePolicy.mockReturnValue(['workflow-allowed']) + mocks.requirePolicy.mockResolvedValue({ + document: buildOrganizationAccountAccessPolicy('group-1', ['workspace-1', 'workspace-2']), + }) + mocks.resolveWorkspace.mockImplementation(async (workspaceId: string) => ({ + workspaceId, + credentialGroupId: 'group-1', + status: 'active', + })) + mocks.requireAccess.mockResolvedValue(undefined) mocks.processEvent.mockResolvedValue({ success: true }) }) - it('delivers only to an allowed workflow in the source workspace watching the event', async () => { + it('delivers to matching subscribers across allowed workspaces without workflow grants', async () => { const allowed = subscription({ workflowId: 'workflow-allowed' }) mocks.fetchSubscriptions.mockResolvedValue([ allowed, @@ -87,7 +95,7 @@ describe('Credential Group trigger delivery', () => { await fireCredentialGroupTrigger(EVENT) - expect(mocks.processEvent).toHaveBeenCalledOnce() + expect(mocks.processEvent).toHaveBeenCalledTimes(3) expect(mocks.processEvent).toHaveBeenCalledWith( allowed.webhook, allowed.workflow, @@ -100,8 +108,10 @@ describe('Credential Group trigger delivery', () => { ) }) - it('does not scan subscriptions when no workflow has group access', async () => { - mocks.decodePolicy.mockReturnValue([]) + it('does not scan subscriptions when no workspace has access', async () => { + mocks.requirePolicy.mockResolvedValue({ + document: buildOrganizationAccountAccessPolicy('group-1', []), + }) await fireCredentialGroupTrigger(EVENT) @@ -109,6 +119,30 @@ describe('Credential Group trigger delivery', () => { expect(mocks.processEvent).not.toHaveBeenCalled() }) + it('skips a workspace revoked during fanout and continues to other subscribers', async () => { + mocks.fetchSubscriptions.mockResolvedValue([ + subscription({ workflowId: 'revoked' }), + subscription({ workflowId: 'allowed', workspaceId: 'workspace-2' }), + ]) + mocks.requireAccess.mockRejectedValueOnce(new OrchestrationError('forbidden', 'Revoked')) + await fireCredentialGroupTrigger(EVENT) + expect(mocks.processEvent).toHaveBeenCalledOnce() + expect(mocks.processEvent).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ id: 'allowed' }), + expect.any(Object), + expect.any(String) + ) + }) + + it('propagates malformed policy and delivery failures', async () => { + mocks.requirePolicy.mockRejectedValueOnce(new Error('Malformed policy')) + await expect(fireCredentialGroupTrigger(EVENT)).rejects.toThrow('Malformed policy') + mocks.fetchSubscriptions.mockResolvedValue([subscription({ workflowId: 'allowed' })]) + mocks.processEvent.mockResolvedValue({ success: false, statusCode: 500, error: 'Failed' }) + await expect(fireCredentialGroupTrigger(EVENT)).rejects.toThrow('Failed to deliver') + }) + it('uses null credential fields for form submissions', () => { expect( buildCredentialGroupTriggerPayload({ diff --git a/apps/sim/lib/credential-groups/trigger.ts b/apps/sim/lib/credential-groups/trigger.ts index dd3b15fc14a..c9fa7e964a6 100644 --- a/apps/sim/lib/credential-groups/trigger.ts +++ b/apps/sim/lib/credential-groups/trigger.ts @@ -1,10 +1,15 @@ -import { createLogger } from '@sim/logger' import { generateShortId } from '@sim/utils/id' import { isRecordLike } from '@sim/utils/object' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { - credentialGroupWorkflowAccessPolicyCodec, - decodeCredentialGroupWorkflowAccessPolicy, -} from '@/lib/credential-groups/application/workflow-access-policy' + requireOrganizationAccountsWorkspaceAccess, + resolveOrganizationAccountsWorkspaceContext, +} from '@/lib/credential-groups/application/organization-workspace-access' +import { + listOrganizationAccountWorkspaceIds, + organizationAccountAccessPolicyCodec, +} from '@/lib/credential-groups/application/workspace-access-policy' +import type { ManagedMcpConnectorId } from '@/lib/credential-groups/managed-mcp-connectors' import type { CredentialGroupProvider } from '@/lib/credential-groups/providers' import { CREDENTIAL_GROUP_EVENT_TRIGGER_ID, @@ -14,10 +19,9 @@ import { import { fetchCredentialGroupTriggerSubscriptions } from '@/lib/credential-groups/trigger-subscriptions' import { requireResourcePolicy } from '@/lib/resource-policies/repository' -const logger = createLogger('CredentialGroupTrigger') - interface CredentialGroupTriggerEventBase { - workspaceId: string + workspaceId?: string + organizationId?: string credentialGroupId: string credentialGroupName: string enrollmentId: string @@ -27,8 +31,9 @@ interface CredentialGroupTriggerEventBase { interface CredentialGroupTriggerCredential { credentialId: string - credentialGroupOptionId: string - provider: CredentialGroupProvider + credentialGroupOptionId: string | null + mcpServerId?: string + provider: CredentialGroupProvider | ManagedMcpConnectorId providerId: string displayName: string } @@ -53,7 +58,8 @@ export interface CredentialGroupTriggerPayload { enrollmentStatus: 'in_progress' | 'completed' credentialId: string | null credentialGroupOptionId: string | null - provider: CredentialGroupProvider | null + mcpServerId: string | null + provider: CredentialGroupProvider | ManagedMcpConnectorId | null providerId: string | null displayName: string | null } @@ -94,6 +100,7 @@ export function buildCredentialGroupTriggerPayload( enrollmentStatus: event.enrollmentStatus, credentialId: credential?.credentialId ?? null, credentialGroupOptionId: credential?.credentialGroupOptionId ?? null, + mcpServerId: credential?.mcpServerId ?? null, provider: credential?.provider ?? null, providerId: credential?.providerId ?? null, displayName: credential?.displayName ?? null, @@ -102,55 +109,54 @@ export function buildCredentialGroupTriggerPayload( /** * Fires deployed Credential Group triggers after the source mutation commits. - * Delivery is restricted to workflows explicitly allowed by the group's resource policy. + * Delivery requires an opted-in deployed Credential trigger and current organization workspace access. */ export async function fireCredentialGroupTrigger( event: CredentialGroupTriggerEvent ): Promise { - try { - const policy = await requireResourcePolicy({ - workspaceId: event.workspaceId, - resourceType: 'credential_group', - resourceId: event.credentialGroupId, - codec: credentialGroupWorkflowAccessPolicyCodec, - }) - const allowedWorkflowIds = new Set( - decodeCredentialGroupWorkflowAccessPolicy(policy.document, event.credentialGroupId) - ) - if (allowedWorkflowIds.size === 0) return + if (!event.organizationId) return + const policy = await requireResourcePolicy({ + organizationId: event.organizationId, + resourceType: 'credential_group', + resourceId: event.credentialGroupId, + codec: organizationAccountAccessPolicyCodec, + }) + const allowedWorkspaceIds = listOrganizationAccountWorkspaceIds(policy.document) + if (allowedWorkspaceIds.length === 0) return + const subscriptions = await fetchCredentialGroupTriggerSubscriptions( + event.organizationId, + allowedWorkspaceIds + ) + const matchingSubscriptions = subscriptions.filter(({ webhook }) => { + const config = parseCredentialGroupTriggerConfig(webhook.providerConfig) + return config.eventType === event.event + }) + if (matchingSubscriptions.length === 0) return - const subscriptions = await fetchCredentialGroupTriggerSubscriptions(event.workspaceId, [ - ...allowedWorkflowIds, - ]) - const matchingSubscriptions = subscriptions.filter(({ webhook, workflow }) => { - if (workflow.workspaceId !== event.workspaceId) return false - if (!allowedWorkflowIds.has(workflow.id)) return false - const config = parseCredentialGroupTriggerConfig(webhook.providerConfig) - return config.eventType === event.event - }) - if (matchingSubscriptions.length === 0) return - - const payload = buildCredentialGroupTriggerPayload(event) - const { processPolledWebhookEvent } = await import('@/lib/webhooks/processor') - for (const { webhook, workflow } of matchingSubscriptions) { - const requestId = generateShortId() - const result = await processPolledWebhookEvent(webhook, workflow, payload, requestId) - if (!result.success) { - logger.error(`[${requestId}] Failed to fire Credential Group trigger`, { - event: event.event, - credentialGroupId: event.credentialGroupId, - subscriberWorkflowId: workflow.id, - statusCode: result.statusCode, - error: result.error, - }) - } + const payload = buildCredentialGroupTriggerPayload(event) + const { processPolledWebhookEvent } = await import('@/lib/webhooks/processor') + for (const { webhook, workflow } of matchingSubscriptions) { + if (!workflow.workspaceId) throw new Error('Subscribed workflow is missing its workspace') + try { + const context = await resolveOrganizationAccountsWorkspaceContext(workflow.workspaceId) + if (context.credentialGroupId !== event.credentialGroupId || context.status !== 'active') + continue + await requireOrganizationAccountsWorkspaceAccess(context) + } catch (error) { + /** Revocations and workspace moves remove subscribers between discovery and delivery. */ + if ( + error instanceof OrchestrationError && + (error.code === 'forbidden' || error.code === 'not_found') + ) + continue + throw error + } + const requestId = generateShortId() + const result = await processPolledWebhookEvent(webhook, workflow, payload, requestId) + if (!result.success) { + throw new Error( + `Failed to deliver connected account event to workflow ${workflow.id}: ${result.error ?? result.statusCode}` + ) } - } catch (error) { - logger.error('Failed to emit Credential Group event', { - error, - event: event.event, - credentialGroupId: event.credentialGroupId, - enrollmentId: event.enrollmentId, - }) } } diff --git a/apps/sim/lib/credential-groups/types.ts b/apps/sim/lib/credential-groups/types.ts index 444fe6d48bf..accddd6511e 100644 --- a/apps/sim/lib/credential-groups/types.ts +++ b/apps/sim/lib/credential-groups/types.ts @@ -12,7 +12,7 @@ export type CredentialGroupOptionInput = }) | (CredentialGroupOptionInputBase & { provider: 'slack' - slackBotCredentialId: string + slackBotCredentialId?: string }) export type CredentialGroupOptionUpdateInput = CredentialGroupOptionInput & { id?: string } @@ -45,7 +45,7 @@ export type CredentialGroupOption = }) | (CredentialGroupOptionBase & { provider: 'slack' - slackBotCredentialId: string + slackBotCredentialId?: string configurationStatus: 'not_configured' | 'ready' | 'needs_update' }) diff --git a/apps/sim/lib/credential-groups/workspace-accounts.ts b/apps/sim/lib/credential-groups/workspace-accounts.ts index 4816760b15b..a17968a9c56 100644 --- a/apps/sim/lib/credential-groups/workspace-accounts.ts +++ b/apps/sim/lib/credential-groups/workspace-accounts.ts @@ -1,9 +1,10 @@ -import { type CredentialGroupOptionConfig, credentialGroup } from '@sim/db/schema' +import { type CredentialGroupOptionConfig, credentialGroup, resourcePolicy } from '@sim/db/schema' import { generateId } from '@sim/utils/id' import { credentialGroupWorkflowAccessPolicyCodec, requireDefaultCredentialGroupWorkflowAccessPolicy, } from '@/lib/credential-groups/application/workflow-access-policy' +import { buildOrganizationAccountAccessPolicy } from '@/lib/credential-groups/application/workspace-access-policy' import type { DbOrTx } from '@/lib/db/types' import { requireResourcePolicy } from '@/lib/resource-policies/repository' @@ -58,11 +59,20 @@ export async function createOrganizationAccountsGroup( organizationId, publicId: generateId(), name: 'Connected accounts', - description: 'Accounts connected for organization search.', + description: 'Accounts shared with workflows in approved workspaces.', options, createdBy: userId, }) .returning() if (!created) throw new Error('Connected accounts insert returned no row') + await executor.insert(resourcePolicy).values({ + id: generateId(), + organizationId, + resourceType: 'credential_group', + resourceId: created.id, + document: buildOrganizationAccountAccessPolicy(created.id, []), + createdBy: userId, + updatedBy: userId, + }) return created } diff --git a/apps/sim/lib/credentials/application/discover-managed-mcp-tools.test.ts b/apps/sim/lib/credentials/application/discover-managed-mcp-tools.test.ts index edec8f3d8a9..cd7349bddd1 100644 --- a/apps/sim/lib/credentials/application/discover-managed-mcp-tools.test.ts +++ b/apps/sim/lib/credentials/application/discover-managed-mcp-tools.test.ts @@ -84,6 +84,9 @@ describe('discoverManagedMcpToolsUseCase', () => { mcpServerId: context.mcpServerId, mcpServerName: context.mcpServerName, workspaceId: context.workspaceId, + scope: { kind: 'organization', organizationId: 'org-1' }, + oauthConfigVersion: 2, + grantedAt: new Date('2026-09-01'), tokenVersion: 'encrypted-token-version-1', tokens: { access_token: 'access-token' }, tools: [], @@ -120,7 +123,7 @@ describe('discoverManagedMcpToolsUseCase', () => { expect(mocks.loadRuntime).toHaveBeenCalledWith(context.credentialId, context.workspaceId) expect(mocks.discoverTools).toHaveBeenCalledWith( context.mcpServerId, - context.workspaceId, + { kind: 'organization', organizationId: 'org-1' }, { credentialId: context.credentialId, loadProvider: expect.any(Function) }, signal, { requireComplete: true } @@ -132,12 +135,17 @@ describe('discoverManagedMcpToolsUseCase', () => { serverName: context.mcpServerName, }), ]) - expect(mocks.saveToolSnapshot).toHaveBeenCalledWith(context.credentialId, [ - { - name: 'search_transcripts', - description: 'Search transcripts', - inputSchema: { type: 'object', properties: {} }, - }, - ]) + expect(mocks.saveToolSnapshot).toHaveBeenCalledWith( + context.credentialId, + [ + { + name: 'search_transcripts', + description: 'Search transcripts', + inputSchema: { type: 'object', properties: {} }, + }, + ], + 2, + new Date('2026-09-01') + ) }) }) diff --git a/apps/sim/lib/credentials/application/discover-managed-mcp-tools.ts b/apps/sim/lib/credentials/application/discover-managed-mcp-tools.ts index 67ac74c7c04..5e0c81eec3e 100644 --- a/apps/sim/lib/credentials/application/discover-managed-mcp-tools.ts +++ b/apps/sim/lib/credentials/application/discover-managed-mcp-tools.ts @@ -21,7 +21,10 @@ export interface DiscoverManagedMcpToolsInput { export const discoverManagedMcpToolsUseCase = defineAuthorizedWorkspaceUseCase({ operation: credentialOperations.useManagedMcp, resolveContext: async ({ input }: { input: DiscoverManagedMcpToolsInput }) => { - const context = await loadManagedMcpCredentialApplicationContext(input.credentialId) + const context = await loadManagedMcpCredentialApplicationContext( + input.credentialId, + input.workspaceId + ) if (!context || context.workspaceId !== input.workspaceId) { throw new OrchestrationError('not_found', 'Managed MCP connection not found') } @@ -36,7 +39,7 @@ export const discoverManagedMcpToolsUseCase = defineAuthorizedWorkspaceUseCase({ const runtime = await loadManagedMcpRuntimeCredential(context.credentialId, context.workspaceId) const tools = await mcpService.discoverManagedMcpTools( runtime.mcpServerId, - runtime.workspaceId, + runtime.scope, { credentialId: runtime.credentialId, loadProvider: () => loadManagedMcpAuthProvider(runtime.credentialId, runtime.workspaceId), @@ -50,7 +53,9 @@ export const discoverManagedMcpToolsUseCase = defineAuthorizedWorkspaceUseCase({ name: tool.name, ...(tool.description ? { description: tool.description } : {}), inputSchema: tool.inputSchema, - })) + })), + runtime.oauthConfigVersion, + runtime.grantedAt ) return { tools: tools.map((tool) => ({ diff --git a/apps/sim/lib/credentials/application/resolve-managed-oauth-token.ts b/apps/sim/lib/credentials/application/resolve-managed-oauth-token.ts index 418a0e455b5..f614c563a9d 100644 --- a/apps/sim/lib/credentials/application/resolve-managed-oauth-token.ts +++ b/apps/sim/lib/credentials/application/resolve-managed-oauth-token.ts @@ -1,4 +1,5 @@ import { AuditAction, AuditResourceType } from '@sim/audit' +import type { Principal } from '@sim/auth/principal' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { requireCredentialGroupCredentialAccess } from '@/lib/credential-groups/application/authorization' @@ -19,8 +20,19 @@ export interface ResolveManagedOAuthTokenInput { export const resolveManagedOAuthCredentialToken = defineAuthorizedWorkspaceUseCase({ operation: credentialOperations.useManagedOAuth, - resolveContext: async ({ input }: { input: ResolveManagedOAuthTokenInput }) => { - const context = await loadManagedOAuthCredentialApplicationContext(input.credentialId) + resolveContext: async ({ + input, + principal, + }: { + input: ResolveManagedOAuthTokenInput + principal: Principal + }) => { + if (principal.kind !== 'delegated') + throw new OrchestrationError('forbidden', 'Managed credentials require delegated execution') + const context = await loadManagedOAuthCredentialApplicationContext( + input.credentialId, + principal.workspaceId + ) if (!context) throw new OrchestrationError('not_found', 'Managed credential not found') return context }, @@ -31,7 +43,9 @@ export const resolveManagedOAuthCredentialToken = defineAuthorizedWorkspaceUseCa execute: async ({ input, context }): Promise => resolveManagedOAuthToken({ credentialId: context.credentialId, - workspaceId: context.workspaceId, + ...(context.organizationId + ? { organizationId: context.organizationId } + : { workspaceId: context.workspaceId }), expectedProviderId: input.expectedProviderId, requiredScopes: input.requiredScopes, }), diff --git a/apps/sim/lib/credentials/application/resolve-workflow-credentials.ts b/apps/sim/lib/credentials/application/resolve-workflow-credentials.ts new file mode 100644 index 00000000000..d771a44b67f --- /dev/null +++ b/apps/sim/lib/credentials/application/resolve-workflow-credentials.ts @@ -0,0 +1,65 @@ +import { db } from '@sim/db' +import { credential } from '@sim/db/schema' +import { and, asc, eq, inArray } from 'drizzle-orm' +import { defineAuthorizedWorkspaceUseCase, defineWorkspaceOperation } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + CREDENTIAL_GROUP_DELEGATION_AUDIENCE, + requireCredentialGroupWorkflowActor, +} from '@/lib/credential-groups/application/authorization' +import { resolveCredentialGroupWorkspaceContext } from '@/lib/credential-groups/application/context' + +interface ResolveWorkflowCredentialsInput { + workspaceId: string + credentialId?: string + providerIds?: string[] +} + +/** Resolves workspace credential references under the current workflow's authorization. */ +export const resolveWorkflowCredentials = defineAuthorizedWorkspaceUseCase({ + /** + * permission-group-exempt: Reference selection preserves workspace workflow access; token use enforces credential permissions. + */ + operation: defineWorkspaceOperation({ + id: 'credentials.workflow_references.resolve', + minimumRole: 'read', + workspaceApiKey: 'deny', + capability: 'none', + principalKinds: ['delegated'], + delegatedServices: ['executor'], + }), + resolveContext: ({ input }: { input: ResolveWorkflowCredentialsInput }) => + resolveCredentialGroupWorkspaceContext(input.workspaceId), + authorizationOptions: { + delegation: { + audience: CREDENTIAL_GROUP_DELEGATION_AUDIENCE, + isWithinScope: (principal) => principal.resourceScope === undefined, + }, + }, + authorizeResource: ({ principal }) => { + requireCredentialGroupWorkflowActor(principal) + }, + execute: async ({ input, context }) => { + const records = await db.query.credential.findMany({ + where: and( + eq(credential.workspaceId, context.workspaceId), + eq(credential.type, 'oauth'), + input.credentialId ? eq(credential.id, input.credentialId) : undefined, + input.providerIds?.length ? inArray(credential.providerId, input.providerIds) : undefined + ), + columns: { id: true, displayName: true, providerId: true }, + orderBy: [asc(credential.displayName)], + }) + if (input.credentialId && records.length !== 1) + throw new OrchestrationError('not_found', 'Credential not found in this workspace') + return records.map((record) => { + if (!record.providerId) + throw new Error(`OAuth credential ${record.id} is missing its provider`) + return { + credentialId: record.id, + displayName: record.displayName, + providerId: record.providerId, + } + }) + }, +}) diff --git a/apps/sim/lib/credentials/environment.ts b/apps/sim/lib/credentials/environment.ts index fb0ba4e1a0b..4d77e3dcb01 100644 --- a/apps/sim/lib/credentials/environment.ts +++ b/apps/sim/lib/credentials/environment.ts @@ -4,7 +4,6 @@ import { credentialGroup, credentialGroupEnrollment, credentialMember, - foldedEmail, permissions, user, workspace, @@ -876,11 +875,21 @@ export async function getEnrolledManagedOAuthCredentials( eq(credentialGroupEnrollment.id, credential.credentialGroupEnrollmentId) ) .innerJoin(credentialGroup, eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId)) - .innerJoin(user, eq(foldedEmail(user.email), credentialGroupEnrollment.email)) + .innerJoin(user, eq(user.id, credentialGroupEnrollment.userId)) + .innerJoin(workspace, eq(workspace.id, workspaceId)) .where( and( - eq(credential.workspaceId, workspaceId), - eq(credentialGroup.workspaceId, workspaceId), + or( + and( + eq(credential.workspaceId, workspaceId), + eq(credentialGroup.workspaceId, workspaceId) + ), + and( + eq(credential.organizationId, workspace.organizationId), + eq(credentialGroup.organizationId, workspace.organizationId) + ) + ), + eq(credential.createdBy, userId), eq(credential.type, 'managed_oauth'), credentialId === undefined ? undefined : eq(credential.id, credentialId), eq(user.id, userId), diff --git a/apps/sim/lib/credentials/managed-mcp.test.ts b/apps/sim/lib/credentials/managed-mcp.test.ts index e2fcec8e4e3..ee285d9e2b7 100644 --- a/apps/sim/lib/credentials/managed-mcp.test.ts +++ b/apps/sim/lib/credentials/managed-mcp.test.ts @@ -21,10 +21,17 @@ vi.mock('@/lib/workspaces/application/workspace-context', () => ({ loadActiveWorkspaceApplicationContext: vi.fn(), })) -import { persistManagedMcpCredential } from '@/lib/credentials/managed-mcp' +import { + persistManagedMcpCredential, + saveManagedMcpRuntimeTokens, + saveManagedMcpToolSnapshot, +} from '@/lib/credentials/managed-mcp' const input = { - workspaceId: 'workspace-1', + organizationId: 'org-1', + userId: 'person-1', + oauthConfigVersion: 2, + invitationTokenHash: 'current-invitation-hash', credentialGroupId: 'group-1', enrollmentId: 'enrollment-1', email: 'person@example.com', @@ -70,4 +77,49 @@ describe('managed MCP grant persistence', () => { expect(dbChainMockFns.update).not.toHaveBeenCalled() } ) + it('stores an organization grant bound to the current enrollment user and MCP setup', async () => { + queueTableRows(schemaMock.credentialGroupEnrollment, [source]) + queueTableRows(schemaMock.credential, []) + dbChainMockFns.returning + .mockResolvedValueOnce([{ id: 'mcp-cg-person' }]) + .mockResolvedValueOnce([{ id: 'enrollment-1' }]) + await expect(persistManagedMcpCredential(input)).resolves.toMatchObject({ + created: true, + enrollmentStatus: 'in_progress', + connectionId: expect.stringMatching(/^mcp-cg-/), + }) + expect(eq).toHaveBeenCalledWith(schemaMock.credentialGroupEnrollment.userId, 'person-1') + expect(eq).toHaveBeenCalledWith( + schemaMock.credentialGroupEnrollment.invitationTokenHash, + input.invitationTokenHash + ) + expect(eq).toHaveBeenCalledWith(schemaMock.mcpServers.oauthConfigVersion, 2) + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ + organizationId: 'org-1', + workspaceId: null, + mcpOauthConfigVersion: 2, + }) + ) + }) + + it('compares refresh writes with the encrypted token version', async () => { + queueTableRows(schemaMock.credential, [{ enrollmentId: 'enrollment-1' }]) + dbChainMockFns.returning.mockResolvedValue([{ id: 'mcp-cg-person' }]) + await saveManagedMcpRuntimeTokens('mcp-cg-person', input.tokens, 'previous-encrypted-token') + expect(eq).toHaveBeenCalledWith( + schemaMock.credential.encryptedOauthTokenSet, + 'previous-encrypted-token' + ) + }) + + it('refuses a tool snapshot captured before reconnect or configuration replacement', async () => { + const grantedAt = new Date('2026-09-01') + dbChainMockFns.returning.mockResolvedValue([]) + await expect(saveManagedMcpToolSnapshot('mcp-cg-person', [], 2, grantedAt)).rejects.toThrow( + 'grant changed' + ) + expect(eq).toHaveBeenCalledWith(schemaMock.credential.mcpOauthConfigVersion, 2) + expect(eq).toHaveBeenCalledWith(schemaMock.credential.grantedAt, grantedAt) + }) }) diff --git a/apps/sim/lib/credentials/managed-mcp.ts b/apps/sim/lib/credentials/managed-mcp.ts index 8b723df0217..df621ae31c7 100644 --- a/apps/sim/lib/credentials/managed-mcp.ts +++ b/apps/sim/lib/credentials/managed-mcp.ts @@ -9,12 +9,20 @@ import { } from '@sim/db/schema' import { getErrorMessage } from '@sim/utils/errors' import { and, eq, isNull, ne } from 'drizzle-orm' -import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access' import type { WorkspaceAuthorizationContext } from '@/lib/core/application' +import { + type ResourceScope, + resourceScopeColumns, + resourceScopeFromOwner, +} from '@/lib/core/resource-scope' +import { + resourceScopeCondition, + sameResourceScopeCondition, +} from '@/lib/core/resource-scope.server' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' -import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability' import { lockCredentialGroupEnrollmentLifecycle } from '@/lib/credential-groups/enrollments' import { getManagedMcpConnector } from '@/lib/credential-groups/managed-mcp-connectors' +import { isScopedCredentialGroupsAvailable } from '@/lib/credential-groups/scoped-availability' import { generateManagedMcpConnectionId } from '@/lib/mcp/utils' import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' @@ -28,6 +36,7 @@ interface ManagedMcpTokenEnvelope { } export interface ManagedMcpCredentialApplicationContext extends WorkspaceAuthorizationContext { + organizationId?: string credentialId: string credentialGroupId: string credentialGroupEnrollmentId: string @@ -36,6 +45,10 @@ export interface ManagedMcpCredentialApplicationContext extends WorkspaceAuthori } export interface ManagedMcpRuntimeCredential { + grantedAt: Date + oauthConfigVersion: number + scope: ResourceScope + credentialGroupId: string credentialId: string mcpServerId: string mcpServerName: string @@ -100,12 +113,14 @@ export async function decryptManagedMcpTokens(encrypted: string): Promise { const [row] = await db .select({ credentialId: credential.id, workspaceId: credential.workspaceId, + organizationId: credential.organizationId, credentialGroupId: credentialGroup.id, credentialGroupEnrollmentId: credentialGroupEnrollment.id, mcpServerId: mcpServers.id, @@ -119,23 +134,44 @@ export async function loadManagedMcpCredentialApplicationContext( ) .innerJoin(credentialGroup, eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId)) .innerJoin(mcpServers, eq(mcpServers.id, credential.mcpServerId)) - .where(and(eq(credential.id, credentialId), eq(credential.type, 'managed_mcp'))) + .where( + and( + eq(credential.id, credentialId), + eq(credential.type, 'managed_mcp'), + sameResourceScopeCondition(credential, credentialGroup), + sameResourceScopeCondition(credential, mcpServers), + eq(mcpServers.credentialGroupId, credentialGroup.id) + ) + ) .limit(1) - if (!row?.workspaceId) return null + if (!row) return null + const workspaceId = executingWorkspaceId ?? row.workspaceId + if (!workspaceId) return null if (!row.managedConnectorId) { throw new Error(`Managed MCP server ${row.mcpServerId} has no connector ID`) } getManagedMcpConnector(row.managedConnectorId) - const workspaceContext = await loadActiveWorkspaceApplicationContext(row.workspaceId) - return workspaceContext ? { ...row, ...workspaceContext } : null + const workspaceContext = await loadActiveWorkspaceApplicationContext(workspaceId) + if ( + !workspaceContext || + (row.organizationId + ? row.organizationId !== workspaceContext.workspaceOrganizationId + : row.workspaceId !== workspaceId) + ) + return null + return { ...row, ...workspaceContext, organizationId: row.organizationId ?? undefined } } export async function loadManagedMcpRuntimeCredential( credentialId: string, workspaceId: string ): Promise { - const ownerBilling = await getWorkspaceOwnerSubscriptionAccess(workspaceId) - if (!(await isCredentialGroupsAvailable({ workspaceId, ownerBilling }))) { + const context = await loadManagedMcpCredentialApplicationContext(credentialId, workspaceId) + if (!context) throw new ManagedMcpCredentialError('Managed MCP credential not found', 404) + const scope = resourceScopeFromOwner( + context.organizationId ? { organizationId: context.organizationId } : { workspaceId } + ) + if (!(await isScopedCredentialGroupsAvailable(scope))) { throw new ManagedMcpCredentialError( 'Managed MCP credentials are not available for this workspace', 403 @@ -149,7 +185,11 @@ export async function loadManagedMcpRuntimeCredential( status: credential.managedOauthStatus, encryptedTokens: credential.encryptedOauthTokenSet, tools: credential.mcpTools, + oauthConfigVersion: credential.mcpOauthConfigVersion, + serverOauthConfigVersion: mcpServers.oauthConfigVersion, + grantedAt: credential.grantedAt, enrollmentStatus: credentialGroupEnrollment.status, + enrollmentUserId: credentialGroupEnrollment.userId, groupStatus: credentialGroup.status, credentialGroupId: credentialGroup.id, linkedCredentialGroupId: mcpServers.credentialGroupId, @@ -167,9 +207,10 @@ export async function loadManagedMcpRuntimeCredential( .where( and( eq(credential.id, credentialId), - eq(credential.workspaceId, workspaceId), + resourceScopeCondition(credential, scope), eq(credential.type, 'managed_mcp'), - eq(mcpServers.workspaceId, workspaceId), + resourceScopeCondition(mcpServers, scope), + resourceScopeCondition(credentialGroup, scope), eq(mcpServers.authType, 'oauth'), eq(mcpServers.enabled, true), isNull(mcpServers.deletedAt) @@ -185,6 +226,8 @@ export async function loadManagedMcpRuntimeCredential( row.status !== 'active' || row.groupStatus !== 'active' || !['in_progress', 'completed'].includes(row.enrollmentStatus) || + (scope.kind === 'organization' && !row.enrollmentUserId) || + row.oauthConfigVersion !== row.serverOauthConfigVersion || row.linkedCredentialGroupId !== row.credentialGroupId ) { throw new ManagedMcpCredentialError('Managed MCP credential needs authorization', 401) @@ -193,27 +236,42 @@ export async function loadManagedMcpRuntimeCredential( throw new ManagedMcpCredentialError('Managed MCP credential token data is missing', 500) } if (!row.tools) throw new ManagedMcpCredentialError('Managed MCP tool metadata is missing', 500) + if (!row.grantedAt) + throw new ManagedMcpCredentialError('Managed MCP grant version is missing', 500) return { credentialId: row.credentialId, + oauthConfigVersion: row.serverOauthConfigVersion, + credentialGroupId: row.credentialGroupId, + scope, workspaceId, mcpServerId: row.mcpServerId, mcpServerName: row.mcpServerName, tokenVersion: row.encryptedTokens, + grantedAt: row.grantedAt, tokens: await decryptManagedMcpTokens(row.encryptedTokens), tools: row.tools, } } export async function persistManagedMcpCredential(params: { + invitationTokenHash: string + oauthConfigVersion: number + userId: string credentialGroupId: string email: string enrollmentId: string - workspaceId: string + workspaceId?: string + organizationId?: string mcpServerId: string mcpServerName: string tokens: OAuthTokens tools: Array<{ name: string; description?: string; inputSchema: Record }> -}): Promise { +}): Promise<{ + connectionId: string + created: boolean + enrollmentStatus: 'in_progress' | 'completed' +}> { + const scope = resourceScopeFromOwner(params) const encryptedOauthTokenSet = await encryptManagedMcpTokens(params.tokens) const now = new Date() const accessTokenExpiresAt = @@ -242,8 +300,11 @@ export async function persistManagedMcpCredential(params: { eq(credentialGroupEnrollment.id, params.enrollmentId), eq(credentialGroupEnrollment.credentialGroupId, params.credentialGroupId), eq(credentialGroupEnrollment.email, params.email), - eq(credentialGroup.workspaceId, params.workspaceId), - eq(mcpServers.workspaceId, params.workspaceId), + eq(credentialGroupEnrollment.userId, params.userId), + eq(credentialGroupEnrollment.invitationTokenHash, params.invitationTokenHash), + resourceScopeCondition(credentialGroup, scope), + resourceScopeCondition(mcpServers, scope), + eq(mcpServers.oauthConfigVersion, params.oauthConfigVersion), eq(mcpServers.authType, 'oauth'), eq(mcpServers.enabled, true), isNull(mcpServers.deletedAt) @@ -276,6 +337,7 @@ export async function persistManagedMcpCredential(params: { .limit(1) .for('update') const values = { + mcpOauthConfigVersion: params.oauthConfigVersion, displayName: params.mcpServerName, managedOauthStatus: 'active' as const, encryptedOauthTokenSet, @@ -299,7 +361,7 @@ export async function persistManagedMcpCredential(params: { const id = generateManagedMcpConnectionId() const insert: typeof credential.$inferInsert = { id, - workspaceId: params.workspaceId, + ...resourceScopeColumns(scope), type: 'managed_mcp', createdBy: null, credentialGroupEnrollmentId: params.enrollmentId, @@ -331,7 +393,11 @@ export async function persistManagedMcpCredential(params: { if (!updatedEnrollment) { throw new ManagedMcpCredentialError('Managed MCP enrollment is no longer available', 404) } - return connectionId + return { + connectionId, + created: !existing, + enrollmentStatus: source.enrollmentStatus === 'completed' ? 'completed' : 'in_progress', + } }) } @@ -391,7 +457,9 @@ export async function saveManagedMcpRuntimeTokens( /** Replaces the editor snapshot only after a complete live tools/list succeeds. */ export async function saveManagedMcpToolSnapshot( credentialId: string, - tools: ManagedMcpToolSnapshot[] + tools: ManagedMcpToolSnapshot[], + expectedConfigVersion: number, + expectedGrantedAt: Date ): Promise { const updated = await db .update(credential) @@ -400,7 +468,9 @@ export async function saveManagedMcpToolSnapshot( and( eq(credential.id, credentialId), eq(credential.type, 'managed_mcp'), - eq(credential.managedOauthStatus, 'active') + eq(credential.managedOauthStatus, 'active'), + eq(credential.mcpOauthConfigVersion, expectedConfigVersion), + eq(credential.grantedAt, expectedGrantedAt) ) ) .returning({ id: credential.id }) diff --git a/apps/sim/lib/credentials/managed-oauth.ts b/apps/sim/lib/credentials/managed-oauth.ts index 5d8268a0714..ab4dcc65f21 100644 --- a/apps/sim/lib/credentials/managed-oauth.ts +++ b/apps/sim/lib/credentials/managed-oauth.ts @@ -73,6 +73,7 @@ interface ResolveManagedOAuthTokenParams { } export interface ManagedOAuthCredentialApplicationContext extends WorkspaceAuthorizationContext { + organizationId?: string credentialId: string credentialGroupId: string credentialGroupEnrollmentId: string @@ -180,15 +181,24 @@ async function getManagedCredential(exec: DbOrTx, credentialId: string, owner?: /** Resolves the canonical workspace context for authorization without exposing token material. */ export async function loadManagedOAuthCredentialApplicationContext( - credentialId: string + credentialId: string, + executingWorkspaceId?: string ): Promise { const row = await getManagedCredential(db, credentialId) - if (!row?.workspaceId) return null - - const workspaceContext = await loadActiveWorkspaceApplicationContext(row.workspaceId) + if (!row) return null + const workspaceId = executingWorkspaceId ?? row.workspaceId + if (!workspaceId) return null + const workspaceContext = await loadActiveWorkspaceApplicationContext(workspaceId) if (!workspaceContext) return null + if ( + row.organizationId + ? row.organizationId !== workspaceContext.workspaceOrganizationId + : row.workspaceId !== workspaceId + ) + return null return { ...workspaceContext, + ...(row.organizationId ? { organizationId: row.organizationId } : {}), credentialId: row.id, credentialGroupId: row.credentialGroupId, credentialGroupEnrollmentId: row.credentialGroupEnrollmentId, diff --git a/apps/sim/lib/knowledge/access/availability.test.ts b/apps/sim/lib/knowledge/access/availability.test.ts index 3b0d77946f3..576966fdb67 100644 --- a/apps/sim/lib/knowledge/access/availability.test.ts +++ b/apps/sim/lib/knowledge/access/availability.test.ts @@ -28,7 +28,10 @@ vi.mock('@/lib/credential-groups/scoped-availability', () => ({ isScopedCredentialGroupsAvailable: mocks.scopedGroups, })) -import { resolveKnowledgeAccessAvailability } from '@/lib/knowledge/access/availability' +import { + requireOrganizationSearchAvailable, + resolveKnowledgeAccessAvailability, +} from '@/lib/knowledge/access/availability' describe('knowledge access availability ownership', () => { beforeEach(() => { @@ -37,7 +40,7 @@ describe('knowledge access availability ownership', () => { mocks.enterprise.mockResolvedValue(true) mocks.scopedGroups.mockResolvedValue(true) mocks.workspaceGroups.mockResolvedValue(true) - mocks.workspaceBilling.mockResolvedValue({ isEnterprise: true }) + mocks.workspaceBilling.mockResolvedValue({ isEnterprise: true, organizationId: 'org-parent' }) }) it('evaluates an organization flag and payer without consulting a workspace', async () => { @@ -46,8 +49,6 @@ describe('knowledge access availability ownership', () => { ).resolves.toEqual({ sourceMirrored: true, memberScoped: true }) expect(mocks.featureEnabled).toHaveBeenCalledWith('knowledge-member-access', { orgId: 'org-1', - workspaceId: undefined, - userId: 'viewer', }) expect(mocks.enterprise).toHaveBeenCalledWith('org-1', 'throw') expect(mocks.scopedGroups).toHaveBeenCalledWith({ @@ -82,13 +83,12 @@ describe('knowledge access availability ownership', () => { ).resolves.toEqual({ sourceMirrored: true, memberScoped: true }) expect(mocks.featureEnabled).toHaveBeenCalledWith('knowledge-member-access', { workspaceId: 'workspace-1', - orgId: undefined, userId: undefined, }) expect(mocks.workspaceBilling).toHaveBeenCalledWith('workspace-1') expect(mocks.workspaceGroups).toHaveBeenCalledWith({ - workspaceId: 'workspace-1', - ownerBilling: { isEnterprise: true }, + organizationId: 'org-parent', + ownerBilling: { isEnterprise: true, organizationId: 'org-parent' }, }) expect(mocks.enterprise).not.toHaveBeenCalled() expect(mocks.scopedGroups).not.toHaveBeenCalled() @@ -101,4 +101,49 @@ describe('knowledge access availability ownership', () => { expect(mocks.workspaceBilling).not.toHaveBeenCalled() expect(mocks.enterprise).not.toHaveBeenCalled() }) + + it('does not let a user-targeted rollout enable organization retrieval', async () => { + mocks.featureEnabled.mockImplementation( + async (_flag, context) => context.userId === 'platform-admin' + ) + await expect( + resolveKnowledgeAccessAvailability({ + organizationId: 'org-disabled', + userId: 'platform-admin', + }) + ).resolves.toEqual({ sourceMirrored: false, memberScoped: false }) + }) + + it.each([ + { knowledge: false, groups: true }, + { knowledge: true, groups: false }, + { knowledge: false, groups: false }, + ])( + 'denies organization Search when either required gate is off: %j', + async ({ knowledge, groups }) => { + mocks.featureEnabled.mockResolvedValue(knowledge) + mocks.scopedGroups.mockResolvedValue(groups) + await expect(requireOrganizationSearchAvailable('org-1')).rejects.toMatchObject({ + code: 'forbidden', + message: 'Search is not enabled for this organization', + }) + } + ) + + it('allows Search only for the organization enabled in the rollout', async () => { + mocks.featureEnabled.mockImplementation( + async (_flag, context) => context.orgId === 'org-enabled' + ) + await expect(requireOrganizationSearchAvailable('org-enabled')).resolves.toBeUndefined() + await expect(requireOrganizationSearchAvailable('org-other')).rejects.toThrow( + 'Search is not enabled' + ) + }) + + it('propagates a feature service failure instead of enabling Search', async () => { + mocks.featureEnabled.mockRejectedValue(new Error('Feature service unavailable')) + await expect(requireOrganizationSearchAvailable('org-1')).rejects.toThrow( + 'Feature service unavailable' + ) + }) }) diff --git a/apps/sim/lib/knowledge/access/availability.ts b/apps/sim/lib/knowledge/access/availability.ts index bd7b639e9bf..31c9d9722b6 100644 --- a/apps/sim/lib/knowledge/access/availability.ts +++ b/apps/sim/lib/knowledge/access/availability.ts @@ -13,9 +13,10 @@ import { isScopedCredentialGroupsAvailable } from '@/lib/credential-groups/scope * Who is asking. Members mode — creating, switching, syncing, and honouring * member tokens — is judged by the resource owner alone, because the member engine * has no person to speak for and every gate must agree with it. Retrieval - * defaults pass the signed-in user as well, so the flag's platform-admin + * defaults for workspaces pass the signed-in user as well, so the flag's platform-admin * clause lets an admin try hybrid retrieval anywhere; an actorless caller - * (a schedule, a cron, a workspace API key) passes none. + * (a schedule, a cron, a workspace API key) passes none. Organization access + * always uses the target organization alone, including retrieval. */ export interface KnowledgeMemberAccessContext { workspaceId?: string @@ -41,17 +42,19 @@ export interface KnowledgeAccessAvailability { export async function resolveKnowledgeAccessAvailability( context: KnowledgeMemberAccessContext ): Promise { + if (context.organizationId && context.workspaceId) + throw new Error('Knowledge access requires one resource owner') if ( - !(await isFeatureEnabled('knowledge-member-access', { - workspaceId: context.workspaceId, - orgId: context.organizationId, - userId: context.userId, - })) + !(await isFeatureEnabled( + 'knowledge-member-access', + context.organizationId + ? { orgId: context.organizationId } + : { workspaceId: context.workspaceId, userId: context.userId } + )) ) { return { sourceMirrored: false, memberScoped: false } } if (context.organizationId) { - if (context.workspaceId) throw new Error('Knowledge access requires one resource owner') return { sourceMirrored: !isHosted || (await isOrganizationOnEnterprisePlan(context.organizationId, 'throw')), @@ -70,7 +73,7 @@ export async function resolveKnowledgeAccessAvailability( return { sourceMirrored, memberScoped: await isCredentialGroupsAvailable({ - workspaceId: context.workspaceId, + organizationId: ownerBilling.organizationId, ownerBilling, }), } @@ -84,8 +87,9 @@ export async function resolveKnowledgeAccessAvailability( * check this; the reader's tokens come from `resolveKnowledgeAccessAvailability` * directly, which this is the `memberScoped` half of, so they can never * disagree. When it turns off, member-scoped documents are hidden on the next - * read, members-mode connectors wait rather than change anything, and search - * returns to the semantic-only default; nothing is deleted. + * read and members-mode connectors wait rather than change anything. Organization + * Search is blocked; workspace search returns to the semantic-only default. + * Nothing is deleted. */ export async function isKnowledgeMemberAccessAvailable( context: KnowledgeMemberAccessContext @@ -93,6 +97,12 @@ export async function isKnowledgeMemberAccessAvailable( return (await resolveKnowledgeAccessAvailability(context)).memberScoped } +/** Organization Search is available only when both owner-scoped rollout gates are enabled. */ +export async function requireOrganizationSearchAvailable(organizationId: string): Promise { + if (await isKnowledgeMemberAccessAvailable({ organizationId })) return + throw new OrchestrationError('forbidden', 'Search is not enabled for this organization') +} + /** Refuses with the one message every source-mirroring gate uses when the feature is off. */ export async function requireSourceMirroredAccessAvailable( context: KnowledgeMemberAccessContext diff --git a/apps/sim/lib/knowledge/access/scope.test.ts b/apps/sim/lib/knowledge/access/scope.test.ts index d26de3d84a0..57c9f882188 100644 --- a/apps/sim/lib/knowledge/access/scope.test.ts +++ b/apps/sim/lib/knowledge/access/scope.test.ts @@ -3,7 +3,7 @@ */ import type { Principal } from '@sim/auth/principal' import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' -import { inArray } from 'drizzle-orm' +import { eq, inArray } from 'drizzle-orm' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockAvailability, mockCheckWorkspaceAccess } = vi.hoisted(() => ({ @@ -472,6 +472,12 @@ describe('organization document ACL scope', () => { }) expect(mockCheckWorkspaceAccess).not.toHaveBeenCalled() }) + it('binds org indexing identities to the enrolled Sim user rather than a matching email', async () => { + queueTableRows(schemaMock.member, [{ id: 'membership-1' }]) + queueSubjects([]) + await resolveKnowledgeAccessScope(SESSION, organization) + expect(eq).toHaveBeenCalledWith(schemaMock.credentialGroupEnrollment.userId, schemaMock.user.id) + }) it('grants nothing after removal, even when stored provider grants remain', async () => { queueTableRows(schemaMock.member, []) queueSubjects([ diff --git a/apps/sim/lib/knowledge/access/scope.ts b/apps/sim/lib/knowledge/access/scope.ts index bafcfc67f0d..159adc1661d 100644 --- a/apps/sim/lib/knowledge/access/scope.ts +++ b/apps/sim/lib/knowledge/access/scope.ts @@ -185,7 +185,9 @@ async function loadUserAccessTokens( credentialGroupEnrollment, and( eq(credentialGroupEnrollment.credentialGroupId, credentialGroup.id), - eq(credentialGroupEnrollment.email, foldedEmail(user.email)), + scope.kind === 'organization' + ? eq(credentialGroupEnrollment.userId, user.id) + : eq(credentialGroupEnrollment.email, foldedEmail(user.email)), inArray(credentialGroupEnrollment.status, [...LIVE_ENROLLMENT_STATUSES]) ) ) diff --git a/apps/sim/lib/knowledge/application/operations.test.ts b/apps/sim/lib/knowledge/application/operations.test.ts index f01c7fb11cb..f6e46310845 100644 --- a/apps/sim/lib/knowledge/application/operations.test.ts +++ b/apps/sim/lib/knowledge/application/operations.test.ts @@ -57,9 +57,12 @@ describe('knowledge operation registry', () => { 'knowledge.connectors.update', 'knowledge.connectors.access.update', 'knowledge.search.sources.list', + 'knowledge.search.integrations.list', + 'knowledge.search.integrations.approve', 'knowledge.connectors.members.list', 'knowledge.connectors.members.enroll', 'knowledge.simSearch.connect', + 'knowledge.search.sources.connectApproved', 'knowledge.search.index.read', 'knowledge.search.sources.prepare', 'knowledge.connectors.delete', diff --git a/apps/sim/lib/knowledge/application/search.test.ts b/apps/sim/lib/knowledge/application/search.test.ts index 9a7c81fd70c..5f28d2e8b8c 100644 --- a/apps/sim/lib/knowledge/application/search.test.ts +++ b/apps/sim/lib/knowledge/application/search.test.ts @@ -2,10 +2,15 @@ * @vitest-environment node */ +import { member } from '@sim/db/schema' +import { queueTableRows, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { OrchestrationError } from '@/lib/core/orchestration/types' const mocks = vi.hoisted(() => ({ resolveWorkspace: vi.fn(), + resolveOrganization: vi.fn(), + requireOrganizationSearch: vi.fn(), resolvePermission: vi.fn(), getKnowledgeBase: vi.fn(), resolveBilling: vi.fn(), @@ -48,6 +53,7 @@ vi.mock('@/lib/billing/core/billing-attribution', () => ({ /** Retrieval defaults are the flag's concern; here the flag is off so the search stays as configured. */ vi.mock('@/lib/knowledge/access/availability', () => ({ isKnowledgeMemberAccessAvailable: async () => false, + requireOrganizationSearchAvailable: mocks.requireOrganizationSearch, })) vi.mock('@/lib/billing/calculations/usage-monitor', () => ({ @@ -56,6 +62,12 @@ vi.mock('@/lib/billing/calculations/usage-monitor', () => ({ vi.mock('@/lib/knowledge/application/contexts', () => ({ resolveKnowledgeWorkspaceContext: mocks.resolveWorkspace, + resolveKnowledgeOrganizationContext: mocks.resolveOrganization, +})) + +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + getUserPermissionConfig: async () => null, + getUserPermissionConfigForOrganization: async () => null, })) vi.mock('@/lib/knowledge/service', () => ({ @@ -107,6 +119,12 @@ const knowledgeBase = { describe('knowledge search application use case', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() + mocks.requireOrganizationSearch.mockResolvedValue(undefined) + mocks.resolveOrganization.mockResolvedValue({ + organizationId: 'org-canonical', + workspaceId: undefined, + }) mocks.resolveWorkspace.mockResolvedValue(workspace) mocks.resolvePermission.mockResolvedValue('read') mocks.getKnowledgeBase.mockResolvedValue(knowledgeBase) @@ -167,6 +185,28 @@ describe('knowledge search application use case', () => { expect(result.totalResults).toBe(0) }) + it('gates organization search using the persisted owner even when the request omits it', async () => { + mocks.getKnowledgeBase.mockResolvedValue({ + ...knowledgeBase, + workspaceId: null, + organizationId: 'org-canonical', + }) + queueTableRows(member, [{ role: 'member' }]) + mocks.requireOrganizationSearch.mockRejectedValue( + new OrchestrationError('forbidden', 'Search is not enabled for this organization') + ) + await expect( + searchKnowledge.execute({ + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + input: { knowledgeBaseIds: ['knowledge-1'], query: 'answer', topK: 5 }, + }) + ).rejects.toThrow('Search is not enabled for this organization') + expect(mocks.requireOrganizationSearch).toHaveBeenCalledExactlyOnceWith('org-canonical') + expect(mocks.resolveBilling).not.toHaveBeenCalled() + expect(mocks.generateEmbedding).not.toHaveBeenCalled() + expect(mocks.executeSearch).not.toHaveBeenCalled() + }) + it('authorizes every canonical knowledge base before billing and search', async () => { const result = await searchKnowledge.execute({ principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, diff --git a/apps/sim/lib/knowledge/application/search.ts b/apps/sim/lib/knowledge/application/search.ts index 4cc80ecc374..75dfcd491b8 100644 --- a/apps/sim/lib/knowledge/application/search.ts +++ b/apps/sim/lib/knowledge/application/search.ts @@ -21,6 +21,7 @@ import { isDurableSecretProvenanceEnforced, reportUnrecordedDurableProvenance, } from '@/lib/execution/durable-secret-provenance-enforcement' +import { requireOrganizationSearchAvailable } from '@/lib/knowledge/access/availability' import { createKnowledgeAccessProvider } from '@/lib/knowledge/access/scope' import type { KnowledgeAccessProvider } from '@/lib/knowledge/access/types' import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case' @@ -263,6 +264,7 @@ export const searchKnowledge = defineAuthorizedKnowledgeUseCase({ resolveKnowledgeSearchContext(input, principal), async execute({ principal, input, context }) { input.signal?.throwIfAborted() + if (context.organizationId) await requireOrganizationSearchAvailable(context.organizationId) const requestId = generateRequestId() const hasQuery = Boolean(input.query?.trim()) const filters = input.tagFilters ?? [] diff --git a/apps/sim/lib/knowledge/application/sim-search.ts b/apps/sim/lib/knowledge/application/sim-search.ts index c5f11dc5453..b2b2f8a9ade 100644 --- a/apps/sim/lib/knowledge/application/sim-search.ts +++ b/apps/sim/lib/knowledge/application/sim-search.ts @@ -24,6 +24,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { ensureWorkspaceAccountsGroup } from '@/lib/credential-groups/service' import { requireKnowledgeMemberAccessAvailable, + requireOrganizationSearchAvailable, requireSourceMirroredAccessAvailable, } from '@/lib/knowledge/access/availability' import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case' @@ -121,6 +122,7 @@ export const readSearchIndex = defineAuthorizedKnowledgeUseCase({ operation: knowledgeOperations.readSearchIndex, resolveContext: ({ input }: { input: ResourceOwner }) => resolveKnowledgeOwnerContext(input), async execute({ context }) { + if (context.organizationId) await requireOrganizationSearchAvailable(context.organizationId) return { workspaceId: context.workspaceId, knowledgeBaseId: (await findSearchIndex(resourceScopeFromOwner(context)))?.id ?? null, diff --git a/apps/sim/lib/knowledge/connectors/member-access.test.ts b/apps/sim/lib/knowledge/connectors/member-access.test.ts index 8d277bc3053..a56745e6fe0 100644 --- a/apps/sim/lib/knowledge/connectors/member-access.test.ts +++ b/apps/sim/lib/knowledge/connectors/member-access.test.ts @@ -679,6 +679,22 @@ describe('organization member credential binding', () => { ) ).toBe(true) } + expect( + hasMockCondition( + predicate, + (condition) => + condition.type === 'inArray' && condition.column === knowledgeConnector.status + ) + ).toBe(true) + expect( + hasMockCondition( + predicate, + (condition) => + condition.type === 'ne' && + condition.left === knowledgeConnector.memberSyncStatus && + condition.right === 'disabled' + ) + ).toBe(true) }) it('denies a connector whose current canonical owner or option no longer matches', async () => { diff --git a/apps/sim/lib/knowledge/connectors/member-access.ts b/apps/sim/lib/knowledge/connectors/member-access.ts index 7854e1ad939..005169b8407 100644 --- a/apps/sim/lib/knowledge/connectors/member-access.ts +++ b/apps/sim/lib/knowledge/connectors/member-access.ts @@ -7,7 +7,7 @@ import { knowledgeConnector, } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, isNull } from 'drizzle-orm' +import { and, eq, inArray, isNull, ne } from 'drizzle-orm' import { OrchestrationError } from '@/lib/core/orchestration/types' import { type ResourceOwner, @@ -42,6 +42,7 @@ import { rejectManagedOAuthToken, resolveManagedOAuthToken, } from '@/lib/credentials/managed-oauth' +import { MEMBER_LOCKABLE_CONNECTOR_STATUSES } from '@/lib/knowledge/connectors/sync-lock' import { CREDENTIAL_GROUP_CREDENTIAL_USE_ACTION, type ResourcePolicyBindingFor, @@ -279,6 +280,8 @@ export async function assertKnowledgeConnectorCredentialAccess( and( eq(knowledgeConnector.id, binding.connectorId), eq(knowledgeConnector.accessMode, 'members'), + inArray(knowledgeConnector.status, MEMBER_LOCKABLE_CONNECTOR_STATUSES), + ne(knowledgeConnector.memberSyncStatus, 'disabled'), eq(knowledgeConnector.credentialGroupId, binding.credentialGroupId), eq(knowledgeConnector.credentialGroupOptionId, binding.credentialGroupOptionId), resourceScopeCondition(knowledgeBase, scope), diff --git a/apps/sim/lib/knowledge/connectors/member-provisioning.test.ts b/apps/sim/lib/knowledge/connectors/member-provisioning.test.ts index c5dd93b14fc..12f729aaf89 100644 --- a/apps/sim/lib/knowledge/connectors/member-provisioning.test.ts +++ b/apps/sim/lib/knowledge/connectors/member-provisioning.test.ts @@ -30,9 +30,13 @@ vi.mock('@/lib/billing/core/billing-attribution', () => ({ vi.mock('@/lib/credential-groups/service', () => ({ ensureWorkspaceAccountsGroup: vi.fn(), })) +vi.mock('@/lib/credential-groups/organization-setup', () => ({ + requireOrganizationAccountsSetup: vi.fn(), +})) import type { CredentialGroupCredentialListContext } from '@/lib/credential-groups/credentials' import { inviteCredentialGroupEnrollment } from '@/lib/credential-groups/enrollments' +import { requireOrganizationAccountsSetup } from '@/lib/credential-groups/organization-setup' import { CredentialGroupProviderConfigurationError } from '@/lib/credential-groups/provider-adapter' import { ensureWorkspaceAccountsGroup } from '@/lib/credential-groups/service' import { @@ -73,6 +77,7 @@ describe('provisionKnowledgeConnectorMembersBinding', () => { }) beforeEach(() => { vi.mocked(ensureWorkspaceAccountsGroup).mockReset() + vi.mocked(requireOrganizationAccountsSetup).mockReset() }) it('reuses the configured Slack option in the workspace singleton', async () => { @@ -141,6 +146,7 @@ describe('provisionKnowledgeConnectorMembersBinding', () => { userId: 'user-1', }) ).resolves.toEqual({ credentialGroupId: 'accounts-1', credentialGroupOptionId: 'option-1' }) + expect(requireOrganizationAccountsSetup).toHaveBeenCalledWith('org-1', 'accounts-1') expect(ensureWorkspaceAccountsGroup).toHaveBeenCalledExactlyOnceWith( { kind: 'organization', organizationId: 'org-1' }, 'user-1', diff --git a/apps/sim/lib/knowledge/connectors/member-provisioning.ts b/apps/sim/lib/knowledge/connectors/member-provisioning.ts index 60dbe8ebe33..5a791842bec 100644 --- a/apps/sim/lib/knowledge/connectors/member-provisioning.ts +++ b/apps/sim/lib/knowledge/connectors/member-provisioning.ts @@ -27,6 +27,7 @@ import { loadScopedAccountsCredentialListContext, } from '@/lib/credential-groups/credentials' import { inviteCredentialGroupEnrollment } from '@/lib/credential-groups/enrollments' +import { requireOrganizationAccountsSetup } from '@/lib/credential-groups/organization-setup' import { CredentialGroupProviderConfigurationError } from '@/lib/credential-groups/provider-adapter' import { getCredentialGroupProviderAdapter } from '@/lib/credential-groups/provider-registry' import { @@ -113,6 +114,7 @@ export async function provisionKnowledgeConnectorMembersBinding(input: { option.status === 'active' && option.configurationStatus === 'ready' ) + if (input.organizationId) await requireOrganizationAccountsSetup(input.organizationId, group.id) if (options.length !== 1) { throw new OrchestrationError( 'validation', @@ -362,7 +364,9 @@ export async function resolveViewerConnectorMemberships(input: { and( resourceScopeCondition(credentialGroup, resourceScopeFromOwner(input)), eq(credentialGroup.id, group.credentialGroupId), - eq(credentialGroupEnrollment.email, email) + input.organizationId + ? eq(credentialGroupEnrollment.userId, input.userId) + : eq(credentialGroupEnrollment.email, email) ) ) diff --git a/apps/sim/lib/knowledge/connectors/organization-account-indexing.test.ts b/apps/sim/lib/knowledge/connectors/organization-account-indexing.test.ts new file mode 100644 index 00000000000..ac54dd2f2bf --- /dev/null +++ b/apps/sim/lib/knowledge/connectors/organization-account-indexing.test.ts @@ -0,0 +1,130 @@ +/** @vitest-environment node */ +import { credentialGroup, knowledgeBase, knowledgeConnector } from '@sim/db/schema' +import { + dbChainMockFns, + flattenMockConditions, + queueTableRows, + resetDbChainMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { validateBinding } = vi.hoisted(() => ({ validateBinding: vi.fn() })) +vi.mock('@/lib/knowledge/connectors/member-access', () => ({ + validateKnowledgeConnectorMembersBinding: validateBinding, +})) + +import { setOrganizationAccountIndexing } from '@/lib/knowledge/connectors/organization-account-indexing' + +const input = { + organizationId: 'org-1', + credentialGroupId: 'group-1', + optionId: 'gmail-option', + enabled: false, +} +const group = { + status: 'active', + options: [{ id: 'gmail-option', status: 'active', provider: 'gmail' }], +} +const source = { + id: 'source-1', + knowledgeBaseId: 'kb-1', + status: 'active', + memberSyncStatus: 'idle', + sourceConfig: {}, +} + +describe('organization provider indexing changes', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + validateBinding.mockReturnValue({ ok: true }) + queueTableRows(credentialGroup, [group]) + }) + + it('pauses all bound sources in one transaction, cancels queued work and retains documents', async () => { + queueTableRows(knowledgeConnector, [ + source, + { ...source, id: 'source-2', memberSyncStatus: 'pending' }, + ]) + await expect(setOrganizationAccountIndexing(input)).resolves.toMatchObject({ + enabled: false, + changed: true, + knowledgeBaseIds: ['kb-1'], + }) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'paused', + nextMemberSyncAt: null, + memberSyncLockToken: null, + memberSyncStatus: 'idle', + }) + ) + expect(dbChainMockFns.update).toHaveBeenCalledExactlyOnceWith(knowledgeConnector) + expect(dbChainMockFns.delete).not.toHaveBeenCalled() + const predicates = dbChainMockFns.where.mock.calls.flatMap(([condition]) => + flattenMockConditions(condition) + ) + for (const expected of [ + { type: 'eq', left: knowledgeBase.organizationId, right: 'org-1' }, + { type: 'eq', left: knowledgeBase.isSearchIndex, right: true }, + { type: 'eq', left: knowledgeConnector.credentialGroupId, right: 'group-1' }, + { type: 'eq', left: knowledgeConnector.credentialGroupOptionId, right: 'gmail-option' }, + { type: 'eq', left: knowledgeConnector.accessMode, right: 'members' }, + ]) + expect(predicates).toContainEqual(expected) + }) + + it('resumes a paused source with a fresh schedule and revalidates its member binding', async () => { + queueTableRows(knowledgeConnector, [{ ...source, status: 'paused' }]) + await setOrganizationAccountIndexing({ ...input, enabled: true }) + expect(validateBinding).toHaveBeenCalledWith( + expect.objectContaining({ credentialGroupOptionId: 'gmail-option', group }) + ) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'active', + nextMemberSyncAt: expect.any(Date), + lastMemberSyncError: null, + }) + ) + }) + + it('does not write when every source already has the requested state', async () => { + queueTableRows(knowledgeConnector, [{ ...source, status: 'paused' }]) + await expect(setOrganizationAccountIndexing(input)).resolves.toMatchObject({ changed: false }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('refuses the entire change when one source has an active run', async () => { + queueTableRows(knowledgeConnector, [ + source, + { ...source, id: 'source-2', memberSyncStatus: 'running' }, + ]) + await expect(setOrganizationAccountIndexing(input)).rejects.toMatchObject({ code: 'conflict' }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('refuses a stale or foreign option before touching any source', async () => { + await expect( + setOrganizationAccountIndexing({ ...input, optionId: 'foreign-option' }) + ).rejects.toMatchObject({ code: 'not_found' }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('requires setup instead of reporting indexing enabled without a source', async () => { + queueTableRows(knowledgeConnector, []) + await expect(setOrganizationAccountIndexing({ ...input, enabled: true })).rejects.toMatchObject( + { code: 'not_found' } + ) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('rejects re-enabling a source whose scopes no longer meet the ingestion requirements', async () => { + queueTableRows(knowledgeConnector, [{ ...source, status: 'paused' }]) + validateBinding.mockReturnValue({ ok: false, message: 'Reconnect with the required scopes' }) + await expect(setOrganizationAccountIndexing({ ...input, enabled: true })).rejects.toThrow( + 'Reconnect with the required scopes' + ) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/knowledge/connectors/organization-account-indexing.ts b/apps/sim/lib/knowledge/connectors/organization-account-indexing.ts new file mode 100644 index 00000000000..a1e34897ede --- /dev/null +++ b/apps/sim/lib/knowledge/connectors/organization-account-indexing.ts @@ -0,0 +1,136 @@ +import { db } from '@sim/db' +import { credentialGroup, knowledgeBase, knowledgeConnector } from '@sim/db/schema' +import { isPlainRecord } from '@sim/utils/object' +import { and, asc, eq, inArray, isNull } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { resourceScopeCondition } from '@/lib/core/resource-scope.server' +import { getCredentialGroupIndexingConnector } from '@/lib/credential-groups/indexing' +import { ORGANIZATION_ACCOUNT_INDEXING_SOURCE_LIMIT } from '@/lib/credential-groups/limits' +import { isCredentialGroupProvider } from '@/lib/credential-groups/providers' +import { validateKnowledgeConnectorMembersBinding } from '@/lib/knowledge/connectors/member-access' + +export interface SetOrganizationAccountIndexingInput { + organizationId: string + credentialGroupId: string + optionId: string + enabled: boolean +} + +/** Changes every Search source bound to this org option atomically, respecting running sync leases. */ +export async function setOrganizationAccountIndexing(input: SetOrganizationAccountIndexingInput) { + const scope = { kind: 'organization' as const, organizationId: input.organizationId } + return db.transaction(async (tx) => { + const [group] = await tx + .select({ options: credentialGroup.options, status: credentialGroup.status }) + .from(credentialGroup) + .where( + and( + eq(credentialGroup.id, input.credentialGroupId), + resourceScopeCondition(credentialGroup, scope) + ) + ) + .limit(1) + .for('update') + if (!group) + throw new OrchestrationError('not_found', 'Organization connected accounts were not found') + const option = group.options.find( + (candidate) => candidate.id === input.optionId && candidate.status === 'active' + ) + if (!option || !isCredentialGroupProvider(option.provider)) + throw new OrchestrationError('not_found', 'Connected account provider was not found') + const connector = getCredentialGroupIndexingConnector(option.provider) + if (!connector) + throw new OrchestrationError('validation', 'Indexing is not supported for this provider') + const sources = await tx + .select({ + id: knowledgeConnector.id, + knowledgeBaseId: knowledgeBase.id, + sourceConfig: knowledgeConnector.sourceConfig, + status: knowledgeConnector.status, + memberSyncStatus: knowledgeConnector.memberSyncStatus, + }) + .from(knowledgeConnector) + .innerJoin(knowledgeBase, eq(knowledgeBase.id, knowledgeConnector.knowledgeBaseId)) + .where( + and( + resourceScopeCondition(knowledgeBase, scope), + eq(knowledgeBase.isSearchIndex, true), + isNull(knowledgeBase.deletedAt), + eq(knowledgeConnector.credentialGroupId, input.credentialGroupId), + eq(knowledgeConnector.credentialGroupOptionId, option.id), + eq(knowledgeConnector.connectorType, connector.type), + eq(knowledgeConnector.accessMode, 'members'), + isNull(knowledgeConnector.archivedAt), + isNull(knowledgeConnector.deletedAt) + ) + ) + .orderBy(asc(knowledgeConnector.id)) + .limit(ORGANIZATION_ACCOUNT_INDEXING_SOURCE_LIMIT + 1) + .for('update') + if (sources.length > ORGANIZATION_ACCOUNT_INDEXING_SOURCE_LIMIT) + throw new OrchestrationError('validation', 'Too many indexing sources for one provider') + if (!sources.length) + throw new OrchestrationError('not_found', 'Set up an indexing source for this provider first') + const changed = sources.filter((source) => + input.enabled + ? source.status === 'paused' || + source.status === 'disabled' || + source.memberSyncStatus === 'disabled' + : source.status !== 'paused' + ) + if ( + changed.some((source) => source.status === 'syncing' || source.memberSyncStatus === 'running') + ) + throw new OrchestrationError( + 'conflict', + 'Indexing is running. Wait for the current sync to finish, then try again.' + ) + if (input.enabled) { + for (const source of changed) { + if (!isPlainRecord(source.sourceConfig)) + throw new OrchestrationError('validation', 'Indexing source settings are invalid') + const validation = validateKnowledgeConnectorMembersBinding({ + connectorMeta: connector.meta, + group, + credentialGroupOptionId: option.id, + sourceConfig: source.sourceConfig, + }) + if (!validation.ok) throw new OrchestrationError('validation', validation.message) + } + } + if (changed.length) { + const now = new Date() + await tx + .update(knowledgeConnector) + .set({ + status: input.enabled ? 'active' : 'paused', + memberSyncStatus: 'idle', + memberSyncLockToken: null, + memberSyncLockLeaseAt: null, + syncLockToken: null, + syncLockLeaseAt: null, + nextMemberSyncAt: input.enabled ? now : null, + updatedAt: now, + ...(input.enabled + ? { consecutiveFailures: 0, lastSyncError: null, lastMemberSyncError: null } + : {}), + }) + .where( + and( + inArray( + knowledgeConnector.id, + changed.map((source) => source.id) + ), + eq(knowledgeConnector.credentialGroupId, input.credentialGroupId), + eq(knowledgeConnector.credentialGroupOptionId, option.id) + ) + ) + } + return { + enabled: input.enabled, + changed: changed.length > 0, + providerName: connector.meta.name, + knowledgeBaseIds: [...new Set(sources.map((source) => source.knowledgeBaseId))], + } + }) +} diff --git a/apps/sim/lib/knowledge/mcp/route-handler.test.ts b/apps/sim/lib/knowledge/mcp/route-handler.test.ts index af9a5116d8d..4ce372558b5 100644 --- a/apps/sim/lib/knowledge/mcp/route-handler.test.ts +++ b/apps/sim/lib/knowledge/mcp/route-handler.test.ts @@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({ handle: vi.fn(), connect: vi.fn(), close: vi.fn(), + requireSearch: vi.fn(), })) vi.mock('@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js', () => ({ WebStandardStreamableHTTPServerTransport: class { @@ -63,6 +64,7 @@ vi.mock('@/lib/knowledge/application/connector-access', () => ({ vi.mock('@/lib/knowledge/access/availability', () => ({ requireKnowledgeMemberAccessAvailable: vi.fn(), requireSourceMirroredAccessAvailable: vi.fn(), + requireOrganizationSearchAvailable: mocks.requireSearch, })) vi.mock('@/connectors/registry', () => ({ CONNECTOR_META_REGISTRY: {} })) vi.mock('@/lib/sim-search/connectors', () => ({ @@ -72,6 +74,7 @@ vi.mock('@/lib/sim-search/connectors', () => ({ })) import { OAUTH_ACCESS_TOKEN_PREFIX } from '@/lib/auth/oauth-provider' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { createKnowledgeMcpHandlers } from '@/lib/knowledge/mcp/route-handler' import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' @@ -105,6 +108,7 @@ beforeEach(() => { resetDbChainMock() mocks.authenticate.mockResolvedValue(auth) mocks.config.mockResolvedValue(null) + mocks.requireSearch.mockResolvedValue(undefined) mocks.index.mockResolvedValue({ id: 'index-1' }) mocks.createServer.mockReturnValue({ connect: mocks.connect, close: mocks.close }) mocks.handle.mockImplementation( @@ -122,6 +126,7 @@ describe('organization MCP request admission', () => { expect(result.status).toBe(200) expect(result.headers.get('Cache-Control')).toBe('private, no-store') expect(mocks.index).toHaveBeenCalledWith({ kind: 'organization', organizationId: 'org-1' }) + expect(mocks.requireSearch).toHaveBeenCalledExactlyOnceWith('org-1') expect(mocks.createServer).toHaveBeenCalledWith( expect.objectContaining({ auth, organizationId: 'org-1', searchIndexId: 'index-1' }) ) @@ -168,6 +173,17 @@ describe('organization MCP request admission', () => { dbChainMockFns.limit.mockResolvedValue([]) expect((await post()).status).toBe(404) expect(mocks.index).not.toHaveBeenCalled() + expect(mocks.requireSearch).not.toHaveBeenCalled() + }) + it('rejects disabled organization Search before index lookup or MCP discovery', async () => { + mocks.requireSearch.mockRejectedValue( + new OrchestrationError('forbidden', 'Search is not enabled for this organization') + ) + const response = await post() + expect(response.status).toBe(403) + expect(mocks.requireSearch).toHaveBeenCalledWith('org-1') + expect(mocks.index).not.toHaveBeenCalled() + expect(mocks.createServer).not.toHaveBeenCalled() }) it.each(['disablePersonalApiKeys', 'hideKnowledgeBaseTab'])( 'enforces current organization policy: %s', diff --git a/apps/sim/lib/mcp/application/execute-managed-tool.test.ts b/apps/sim/lib/mcp/application/execute-managed-tool.test.ts index f543798bb50..e4d03b13e36 100644 --- a/apps/sim/lib/mcp/application/execute-managed-tool.test.ts +++ b/apps/sim/lib/mcp/application/execute-managed-tool.test.ts @@ -92,6 +92,9 @@ describe('executeManagedMcpToolUseCase', () => { mcpServerId: context.mcpServerId, mcpServerName: context.mcpServerName, workspaceId: context.workspaceId, + scope: { kind: 'organization', organizationId: 'org-1' }, + oauthConfigVersion: 2, + grantedAt: new Date('2026-09-01'), tokenVersion: 'encrypted-token-version-1', tokens: { access_token: 'access-token' }, tools: [], @@ -185,27 +188,32 @@ describe('executeManagedMcpToolUseCase', () => { expect(mocks.loadRuntime).toHaveBeenCalledWith(context.credentialId, context.workspaceId) expect(mocks.discoverTools).toHaveBeenCalledWith( context.mcpServerId, - context.workspaceId, + { kind: 'organization', organizationId: 'org-1' }, { credentialId: context.credentialId, loadProvider: expect.any(Function) }, signal, { requireComplete: true } ) - expect(mocks.saveToolSnapshot).toHaveBeenCalledWith(context.credentialId, [ - { - name: 'search_transcripts', - description: 'Search Fireflies transcripts', - inputSchema: { - type: 'object', - required: ['query'], - properties: { query: { type: 'string' } }, + expect(mocks.saveToolSnapshot).toHaveBeenCalledWith( + context.credentialId, + [ + { + name: 'search_transcripts', + description: 'Search Fireflies transcripts', + inputSchema: { + type: 'object', + required: ['query'], + properties: { query: { type: 'string' } }, + }, }, - }, - ]) + ], + 2, + new Date('2026-09-01') + ) expect(mocks.executeTool).toHaveBeenCalledWith( expect.objectContaining({ connectionId: context.credentialId, serverId: context.mcpServerId, - workspaceId: context.workspaceId, + scope: { kind: 'organization', organizationId: 'org-1' }, toolCall: { name: 'search_transcripts', arguments: { query: 'onboarding' }, diff --git a/apps/sim/lib/mcp/application/execute-managed-tool.ts b/apps/sim/lib/mcp/application/execute-managed-tool.ts index 00ed0296fae..a75aad9ae00 100644 --- a/apps/sim/lib/mcp/application/execute-managed-tool.ts +++ b/apps/sim/lib/mcp/application/execute-managed-tool.ts @@ -40,7 +40,10 @@ function requireToolSchema(value: unknown): McpToolSchema { export const executeManagedMcpToolUseCase = defineAuthorizedWorkspaceUseCase({ operation: credentialOperations.useManagedMcp, resolveContext: async ({ input }: { input: ExecuteManagedMcpToolInput }) => { - const context = await loadManagedMcpCredentialApplicationContext(input.credentialId) + const context = await loadManagedMcpCredentialApplicationContext( + input.credentialId, + input.workspaceId + ) if (!context) throw new OrchestrationError('not_found', 'Managed MCP connection not found') if (context.workspaceId !== input.workspaceId) { throw new OrchestrationError('not_found', 'Managed MCP connection not found') @@ -56,7 +59,7 @@ export const executeManagedMcpToolUseCase = defineAuthorizedWorkspaceUseCase({ const runtime = await loadManagedMcpRuntimeCredential(context.credentialId, context.workspaceId) const tools = await mcpService.discoverManagedMcpTools( runtime.mcpServerId, - runtime.workspaceId, + runtime.scope, { credentialId: runtime.credentialId, loadProvider: () => loadManagedMcpAuthProvider(runtime.credentialId, runtime.workspaceId), @@ -70,7 +73,9 @@ export const executeManagedMcpToolUseCase = defineAuthorizedWorkspaceUseCase({ name: tool.name, ...(tool.description ? { description: tool.description } : {}), inputSchema: tool.inputSchema, - })) + })), + runtime.oauthConfigVersion, + runtime.grantedAt ) const discovered = tools.find((tool) => tool.name === input.toolName) if (!discovered) { @@ -93,7 +98,7 @@ export const executeManagedMcpToolUseCase = defineAuthorizedWorkspaceUseCase({ const providerResult = await mcpService.executeManagedMcpTool({ connectionId: runtime.credentialId, serverId: runtime.mcpServerId, - workspaceId: runtime.workspaceId, + scope: runtime.scope, toolCall, extraHeaders, signal: input.signal, diff --git a/apps/sim/lib/mcp/application/managed-auth-provider.ts b/apps/sim/lib/mcp/application/managed-auth-provider.ts index 039062babfe..cf631231024 100644 --- a/apps/sim/lib/mcp/application/managed-auth-provider.ts +++ b/apps/sim/lib/mcp/application/managed-auth-provider.ts @@ -1,4 +1,6 @@ import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js' +import { resourceScopeFields } from '@/lib/core/resource-scope' +import { requireOrganizationAccountsWorkspaceAccess } from '@/lib/credential-groups/application/organization-workspace-access' import { loadManagedMcpRuntimeCredential, saveManagedMcpRuntimeTokens, @@ -12,9 +14,17 @@ export async function loadManagedMcpAuthProvider( workspaceId: string ): Promise { const current = await loadManagedMcpRuntimeCredential(credentialId, workspaceId) + if (current.scope.kind === 'organization') { + await requireOrganizationAccountsWorkspaceAccess({ + workspaceId, + workspaceOrganizationId: current.scope.organizationId, + organizationId: current.scope.organizationId, + credentialGroupId: current.credentialGroupId, + }) + } const clientRow = await getOrCreateOauthRow({ mcpServerId: current.mcpServerId, - workspaceId: current.workspaceId, + ...resourceScopeFields(current.scope), }) const preregistered = await loadPreregisteredClient(current.mcpServerId) let tokenVersion: string | null = current.tokenVersion diff --git a/apps/sim/lib/mcp/application/managed-connections.ts b/apps/sim/lib/mcp/application/managed-connections.ts index 226d765fe15..36dbfb60d99 100644 --- a/apps/sim/lib/mcp/application/managed-connections.ts +++ b/apps/sim/lib/mcp/application/managed-connections.ts @@ -31,7 +31,12 @@ export const listManagedMcpConnectionsUseCase = defineAuthorizedWorkspaceUseCase authorizationOptions: {}, async execute({ context }) { const ownerBilling = await getWorkspaceOwnerSubscriptionAccess(context.workspaceId) - if (!(await isCredentialGroupsAvailable({ workspaceId: context.workspaceId, ownerBilling }))) { + if ( + !(await isCredentialGroupsAvailable({ + organizationId: ownerBilling.organizationId, + ownerBilling, + })) + ) { return { servers: [], tools: [] } } const managedCatalogScope = () => diff --git a/apps/sim/lib/mcp/oauth/storage.ts b/apps/sim/lib/mcp/oauth/storage.ts index 3d5399e1b80..1e1f006b195 100644 --- a/apps/sim/lib/mcp/oauth/storage.ts +++ b/apps/sim/lib/mcp/oauth/storage.ts @@ -11,6 +11,11 @@ import { interruptibleSleep } from '@sim/utils/helpers' import { generateId, generateShortId } from '@sim/utils/id' import { and, eq, gt } from 'drizzle-orm' import { acquireLock, extendLock, releaseLock } from '@/lib/core/config/redis' +import { + resourceScopeColumns, + resourceScopeFromOwner, + sameResourceScope, +} from '@/lib/core/resource-scope' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' const logger = createLogger('McpOauthStorage') @@ -25,7 +30,8 @@ export interface McpOauthRow { id: string mcpServerId: string userId: string | null - workspaceId: string + workspaceId: string | null + organizationId: string | null clientInformation: OAuthClientInformationMixed | null tokens: OAuthTokens | null codeVerifier: string | null @@ -72,10 +78,16 @@ async function safeDecrypt( export async function getOrCreateOauthRow(params: { mcpServerId: string userId?: string | null - workspaceId: string + workspaceId?: string + organizationId?: string }): Promise { + const scope = resourceScopeFromOwner(params) const existing = await loadOauthRow(params) - if (existing) return existing + if (existing) { + if (!sameResourceScope(scope, resourceScopeFromOwner(existing))) + throw new Error('MCP OAuth client belongs to another scope') + return existing + } const id = generateId() try { @@ -83,11 +95,11 @@ export async function getOrCreateOauthRow(params: { id, mcpServerId: params.mcpServerId, userId: params.userId ?? null, - workspaceId: params.workspaceId, + ...resourceScopeColumns(scope), }) } catch (error) { const winner = await loadOauthRow(params) - if (winner) return winner + if (winner && sameResourceScope(scope, resourceScopeFromOwner(winner))) return winner throw error } @@ -95,7 +107,7 @@ export async function getOrCreateOauthRow(params: { id, mcpServerId: params.mcpServerId, userId: params.userId ?? null, - workspaceId: params.workspaceId, + ...resourceScopeColumns(scope), clientInformation: null, tokens: null, codeVerifier: null, @@ -113,6 +125,7 @@ async function mapOauthRow(row: RawOauthRow): Promise { mcpServerId: row.mcpServerId, userId: row.userId, workspaceId: row.workspaceId, + organizationId: row.organizationId, clientInformation: row.clientInformation ? await safeDecrypt( row.id, diff --git a/apps/sim/lib/mcp/service-pool.test.ts b/apps/sim/lib/mcp/service-pool.test.ts index 17125bbd3a6..e9a7006d8ff 100644 --- a/apps/sim/lib/mcp/service-pool.test.ts +++ b/apps/sim/lib/mcp/service-pool.test.ts @@ -149,7 +149,7 @@ describe('McpService connection reuse wiring', () => { mcpService.executeManagedMcpTool({ connectionId: 'disabled-grant', serverId: SERVER_ROW.id, - workspaceId: WORKSPACE_ID, + scope: { kind: 'workspace', workspaceId: WORKSPACE_ID }, toolCall: { name: 'slow', arguments: {} }, loadAuthProvider: vi.fn().mockRejectedValue(error), }) @@ -165,7 +165,7 @@ describe('McpService connection reuse wiring', () => { await mcpService.executeManagedMcpTool({ connectionId: 'personal-grant', serverId: SERVER_ROW.id, - workspaceId: WORKSPACE_ID, + scope: { kind: 'workspace', workspaceId: WORKSPACE_ID }, toolCall: { name: 'slow', arguments: {} }, loadAuthProvider, signal, diff --git a/apps/sim/lib/mcp/service.ts b/apps/sim/lib/mcp/service.ts index f2c27a5bf78..74ce71f6d66 100644 --- a/apps/sim/lib/mcp/service.ts +++ b/apps/sim/lib/mcp/service.ts @@ -12,6 +12,8 @@ import { interruptibleSleep } from '@sim/utils/helpers' import { backoffWithJitter } from '@sim/utils/retry' import { truncate } from '@sim/utils/string' import { and, eq, isNull, lte, or, sql } from 'drizzle-orm' +import { type ResourceScope, resourceScopeFields } from '@/lib/core/resource-scope' +import { resourceScopeCondition } from '@/lib/core/resource-scope.server' import { generateRequestId } from '@/lib/core/utils/request' import { McpClient } from '@/lib/mcp/client' import { mcpConnectionManager } from '@/lib/mcp/connection-manager' @@ -326,15 +328,17 @@ class McpService { private async getServerConfig( serverId: string, - workspaceId: string + scopeInput: string | ResourceScope ): Promise { + const scope: ResourceScope = + typeof scopeInput === 'string' ? { kind: 'workspace', workspaceId: scopeInput } : scopeInput const [server] = await db .select() .from(mcpServers) .where( and( eq(mcpServers.id, serverId), - eq(mcpServers.workspaceId, workspaceId), + resourceScopeCondition(mcpServers, scope), eq(mcpServers.enabled, true), isNull(mcpServers.deletedAt) ) @@ -356,7 +360,7 @@ class McpService { transport: 'streamable-http' as const, url: server.url || undefined, authType: (server.authType as McpServerConfig['authType']) ?? 'headers', - workspaceId: server.workspaceId, + ...resourceScopeFields(scope), headers: (server.headers as Record) || {}, timeout: server.timeout || 30000, retries: server.retries || 3, @@ -386,7 +390,7 @@ class McpService { transport: server.transport as McpTransport, url: server.url || undefined, authType: (server.authType as McpServerConfig['authType']) ?? 'headers', - workspaceId: server.workspaceId, + workspaceId, headers: (server.headers as Record) || {}, timeout: server.timeout || 30000, retries: server.retries || 3, @@ -490,12 +494,12 @@ class McpService { async discoverManagedMcpTools( serverId: string, - workspaceId: string, + scope: string | ResourceScope, auth: OAuthClientProvider | McpOauthCredentials, signal?: AbortSignal, options: { requireComplete?: boolean } = {} ): Promise { - const config = await this.getServerConfig(serverId, workspaceId) + const config = await this.getServerConfig(serverId, scope) if (!config) throw new Error('Managed MCP server is unavailable') return this.withServerClient( { key: '', serverId, allowPool: false }, @@ -510,14 +514,14 @@ class McpService { async executeManagedMcpTool(params: { connectionId: string serverId: string - workspaceId: string + scope: ResourceScope toolCall: McpToolCall loadAuthProvider: () => Promise extraHeaders?: Record signal?: AbortSignal timeoutMs?: number }): Promise { - const config = await this.getServerConfig(params.serverId, params.workspaceId) + const config = await this.getServerConfig(params.serverId, params.scope) if (!config) throw new Error('Managed MCP server is unavailable') const effectiveConfig = params.extraHeaders ? { ...config, headers: { ...config.headers, ...params.extraHeaders } } diff --git a/apps/sim/lib/mcp/types.ts b/apps/sim/lib/mcp/types.ts index d2341ca8118..e2675394b61 100644 --- a/apps/sim/lib/mcp/types.ts +++ b/apps/sim/lib/mcp/types.ts @@ -25,6 +25,7 @@ export interface McpServerConfig { */ userId?: string workspaceId?: string + organizationId?: string headers?: Record timeout?: number retries?: number diff --git a/apps/sim/lib/navigation/paths.ts b/apps/sim/lib/navigation/paths.ts index 841fa7bb250..b11070e5e5d 100644 --- a/apps/sim/lib/navigation/paths.ts +++ b/apps/sim/lib/navigation/paths.ts @@ -19,6 +19,9 @@ export const APP_ENTRY_PATH = '/home' */ export const WORKSPACES_PATH = '/workspace' +/** Opens full settings in the viewer's most recent accessible workspace. */ +export const WORKSPACE_SETTINGS_PATH = `${WORKSPACES_PATH}?redirect=settings` + /** Root of the organization surface; `/o` alone resolves like {@link APP_ENTRY_PATH}. */ const ORGANIZATIONS_PATH = '/o' diff --git a/apps/sim/lib/navigation/resolve-app-entry.test.ts b/apps/sim/lib/navigation/resolve-app-entry.test.ts index 0936817d91c..c34fd1f27f6 100644 --- a/apps/sim/lib/navigation/resolve-app-entry.test.ts +++ b/apps/sim/lib/navigation/resolve-app-entry.test.ts @@ -3,8 +3,13 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockResolveOrganizationLanding } = vi.hoisted(() => ({ +const { mockResolveOrganizationLanding, mockSearchAvailable } = vi.hoisted(() => ({ mockResolveOrganizationLanding: vi.fn(), + mockSearchAvailable: vi.fn(), +})) + +vi.mock('@/lib/knowledge/access/availability', () => ({ + isKnowledgeMemberAccessAvailable: mockSearchAvailable, })) vi.mock('@/lib/organizations/surface', () => ({ @@ -16,6 +21,7 @@ import { resolveAppEntryPath } from '@/lib/navigation/resolve-app-entry' describe('resolveAppEntryPath', () => { beforeEach(() => { vi.clearAllMocks() + mockSearchAvailable.mockResolvedValue(true) }) it('lands an organization member on that organization home', async () => { @@ -28,6 +34,15 @@ describe('resolveAppEntryPath', () => { }) ).resolves.toBe('/o/org-2/home') expect(mockResolveOrganizationLanding).toHaveBeenCalledWith('viewer', 'org-2') + expect(mockSearchAvailable).toHaveBeenCalledWith({ organizationId: 'org-2' }) + }) + + it('opens full workspace settings when Search is disabled', async () => { + mockResolveOrganizationLanding.mockResolvedValue('org-2') + mockSearchAvailable.mockResolvedValue(false) + await expect(resolveAppEntryPath({ user: { id: 'viewer' } })).resolves.toBe( + '/workspace?redirect=settings' + ) }) it('lands a viewer with no organization on the workspace picker', async () => { @@ -35,5 +50,6 @@ describe('resolveAppEntryPath', () => { await expect(resolveAppEntryPath({ user: { id: 'viewer' } })).resolves.toBe('/workspace') expect(mockResolveOrganizationLanding).toHaveBeenCalledWith('viewer', null) + expect(mockSearchAvailable).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/navigation/resolve-app-entry.ts b/apps/sim/lib/navigation/resolve-app-entry.ts index b71dda9168b..afa62a109ca 100644 --- a/apps/sim/lib/navigation/resolve-app-entry.ts +++ b/apps/sim/lib/navigation/resolve-app-entry.ts @@ -1,5 +1,10 @@ import { getActiveOrganizationId } from '@/lib/auth/session-response' -import { organizationRoutes, WORKSPACES_PATH } from '@/lib/navigation/paths' +import { isKnowledgeMemberAccessAvailable } from '@/lib/knowledge/access/availability' +import { + organizationRoutes, + WORKSPACE_SETTINGS_PATH, + WORKSPACES_PATH, +} from '@/lib/navigation/paths' import { resolveOrganizationLanding } from '@/lib/organizations/surface' interface EntrySession { @@ -7,14 +12,17 @@ interface EntrySession { } /** - * Where an authenticated viewer lands by default: the home of their organization - * (the session's active one when they belong to it, otherwise their first), or the - * workspace picker when they belong to no organization. + * Routes organization members to Home when Search is enabled and workspace settings otherwise. + * Viewers without an organization land on the workspace picker. */ export async function resolveAppEntryPath(session: EntrySession): Promise { const organizationId = await resolveOrganizationLanding( session.user.id, getActiveOrganizationId(session) ) - return organizationId ? organizationRoutes(organizationId).home : WORKSPACES_PATH + if (!organizationId) return WORKSPACES_PATH + const routes = organizationRoutes(organizationId) + return (await isKnowledgeMemberAccessAvailable({ organizationId })) + ? routes.home + : WORKSPACE_SETTINGS_PATH } diff --git a/apps/sim/lib/organizations/surface.test.ts b/apps/sim/lib/organizations/surface.test.ts index daf1c12efcd..e30cfbd2d0f 100644 --- a/apps/sim/lib/organizations/surface.test.ts +++ b/apps/sim/lib/organizations/surface.test.ts @@ -10,6 +10,10 @@ const { mockSearchAccess, mockPermissionConfig, featureFlags } = vi.hoisted(() = mockPermissionConfig: vi.fn(), featureFlags: { invitationsDisabled: false }, })) +vi.mock('@/lib/credential-groups/scoped-availability', () => ({ + isScopedCredentialGroupsAvailable: vi.fn().mockResolvedValue(true), +})) + vi.mock('@/lib/permission-groups/resolve.server', () => ({ getUserPermissionConfigForOrganization: mockPermissionConfig, })) @@ -60,6 +64,7 @@ describe('getOrganizationSurfaceContext', () => { canInviteMembers: true, canUsePersonalApiKeys: true, }, + connectedAccountsAvailable: true, searchAccess: { memberScoped: true, sourceMirrored: false }, }) expect(mockSearchAccess).toHaveBeenCalledWith({ organizationId: 'org-1' }) diff --git a/apps/sim/lib/organizations/surface.ts b/apps/sim/lib/organizations/surface.ts index 7f662bd0fec..6b5f95e8495 100644 --- a/apps/sim/lib/organizations/surface.ts +++ b/apps/sim/lib/organizations/surface.ts @@ -4,6 +4,7 @@ import { member, organization } from '@sim/db/schema' import { asc, count, eq } from 'drizzle-orm' import type { OrganizationRole } from '@/lib/api/contracts/primitives' import { isInvitationsDisabled } from '@/lib/core/config/env-flags' +import { isScopedCredentialGroupsAvailable } from '@/lib/credential-groups/scoped-availability' import { type KnowledgeAccessAvailability, resolveKnowledgeAccessAvailability, @@ -36,6 +37,7 @@ interface OrganizationSurfaceViewer { export interface OrganizationSurfaceContext { organization: OrganizationSurfaceOrganization viewer: OrganizationSurfaceViewer + connectedAccountsAvailable: boolean searchAccess: KnowledgeAccessAvailability } @@ -87,6 +89,10 @@ async function resolveOrganizationSurfaceContext( !capabilityDeniedBy('personal_api_key.use', config) && !capabilityDeniedBy('api_keys.manage', config), }, + connectedAccountsAvailable: await isScopedCredentialGroupsAvailable({ + kind: 'organization', + organizationId, + }), searchAccess: await resolveKnowledgeAccessAvailability({ organizationId }), } } diff --git a/apps/sim/lib/resource-policies/principals/registry.ts b/apps/sim/lib/resource-policies/principals/registry.ts index 923490c8bf1..ff737830218 100644 --- a/apps/sim/lib/resource-policies/principals/registry.ts +++ b/apps/sim/lib/resource-policies/principals/registry.ts @@ -7,11 +7,13 @@ import type { ResourcePolicyPrincipalType, } from '@/lib/resource-policies/principals/types' import { workflowResourcePolicyPrincipalDefinition } from '@/lib/resource-policies/principals/workflow' +import { workspaceResourcePolicyPrincipalDefinition } from '@/lib/resource-policies/principals/workspace' export const RESOURCE_POLICY_PRINCIPAL_DEFINITIONS = Object.freeze({ credential_group_actor: credentialGroupActorResourcePolicyPrincipalDefinition, knowledge_connector: knowledgeConnectorResourcePolicyPrincipalDefinition, workflow: workflowResourcePolicyPrincipalDefinition, + workspace: workspaceResourcePolicyPrincipalDefinition, } as const satisfies Record) export function getResourcePolicyPrincipalDefinition( diff --git a/apps/sim/lib/resource-policies/principals/types.ts b/apps/sim/lib/resource-policies/principals/types.ts index eb68f056736..3addc7b6e66 100644 --- a/apps/sim/lib/resource-policies/principals/types.ts +++ b/apps/sim/lib/resource-policies/principals/types.ts @@ -5,6 +5,11 @@ export interface WorkflowResourcePolicyPrincipal { workflowId: string } +export interface WorkspaceResourcePolicyPrincipal { + type: 'workspace' + workspaceId: string +} + export interface CredentialGroupActorResourcePolicyPrincipal { type: 'credential_group_actor' } @@ -15,12 +20,14 @@ export interface KnowledgeConnectorResourcePolicyPrincipal { } export type ResourcePolicyPrincipal = + | WorkspaceResourcePolicyPrincipal | WorkflowResourcePolicyPrincipal | CredentialGroupActorResourcePolicyPrincipal | KnowledgeConnectorResourcePolicyPrincipal export type ResourcePolicyPrincipalType = ResourcePolicyPrincipal['type'] export interface ResourcePolicyPrincipalEvaluationFacts { + currentWorkspaceId?: string credentialGroupActorEnrollmentId?: string currentWorkflow?: { workflowId: string @@ -32,7 +39,7 @@ export interface ResourcePolicyPrincipalEvaluationFacts { } export type ResourcePolicyPrincipalSelector = - | { type: 'catalog'; catalog: 'workflows' } + | { type: 'catalog'; catalog: 'workflows' | 'workspaces' } | { type: 'internal' } export interface ResourcePolicyPrincipalDefinition< diff --git a/apps/sim/lib/resource-policies/principals/workspace.ts b/apps/sim/lib/resource-policies/principals/workspace.ts new file mode 100644 index 00000000000..46e1c3043db --- /dev/null +++ b/apps/sim/lib/resource-policies/principals/workspace.ts @@ -0,0 +1,23 @@ +import { z } from 'zod' +import { defineResourcePolicyPrincipal } from '@/lib/resource-policies/principals/types' + +export const workspaceResourcePolicyPrincipalSchema = z + .object({ + type: z.literal('workspace'), + workspaceId: z + .string() + .min(1) + .max(128) + .refine((id) => id === id.trim(), { + message: 'Workspace ID must be canonical', + }), + }) + .strict() + +export const workspaceResourcePolicyPrincipalDefinition = defineResourcePolicyPrincipal({ + type: 'workspace', + schema: workspaceResourcePolicyPrincipalSchema, + label: 'Workspace', + selector: { type: 'catalog', catalog: 'workspaces' }, + matches: (principal, facts) => principal.workspaceId === facts.currentWorkspaceId, +}) diff --git a/apps/sim/lib/resource-policies/registry.ts b/apps/sim/lib/resource-policies/registry.ts index 2ab5f068614..25070bcd4ab 100644 --- a/apps/sim/lib/resource-policies/registry.ts +++ b/apps/sim/lib/resource-policies/registry.ts @@ -17,7 +17,7 @@ interface ResourcePolicyResourceDefinition { export const RESOURCE_POLICY_DEFINITIONS = Object.freeze({ credential_group: { actions: RESOURCE_POLICY_ACTIONS, - principalTypes: ['credential_group_actor', 'knowledge_connector', 'workflow'], + principalTypes: ['credential_group_actor', 'knowledge_connector', 'workflow', 'workspace'], conditionKeys: [ 'credential_group:ActorOwnsCredential', 'credential_group:OptionId', diff --git a/apps/sim/lib/resource-policies/repository.ts b/apps/sim/lib/resource-policies/repository.ts index 511edcf8368..e67bfbba3f1 100644 --- a/apps/sim/lib/resource-policies/repository.ts +++ b/apps/sim/lib/resource-policies/repository.ts @@ -1,6 +1,8 @@ import { db } from '@sim/db' import { resourcePolicy } from '@sim/db/schema' import { and, eq } from 'drizzle-orm' +import { resourceScopeFromOwner } from '@/lib/core/resource-scope' +import { resourceScopeCondition } from '@/lib/core/resource-scope.server' import type { DbOrTx } from '@/lib/db/types' import type { ResourcePolicyCodec, @@ -14,7 +16,8 @@ export interface StoredResourcePolicy< Document extends ResourcePolicyDocument, > { id: string - workspaceId: string + workspaceId: string | null + organizationId: string | null revision: number document: Document createdAt: Date @@ -67,7 +70,7 @@ async function loadResourcePolicyWithExecutor< .from(resourcePolicy) .where( and( - eq(resourcePolicy.workspaceId, input.workspaceId), + resourceScopeCondition(resourcePolicy, resourceScopeFromOwner(input)), eq(resourcePolicy.resourceType, input.resourceType), eq(resourcePolicy.resourceId, input.resourceId) ) @@ -79,6 +82,7 @@ async function loadResourcePolicyWithExecutor< return { id: row.id, workspaceId: row.workspaceId, + organizationId: row.organizationId, revision: row.revision, document: input.codec.parse(row.document, { type: input.resourceType, @@ -142,6 +146,7 @@ export async function writeResourcePolicy< return { id: updated.id, workspaceId: updated.workspaceId, + organizationId: updated.organizationId, revision: updated.revision, document, createdAt: updated.createdAt, @@ -157,7 +162,7 @@ export async function deleteResourcePolicyForResource< .delete(resourcePolicy) .where( and( - eq(resourcePolicy.workspaceId, input.workspaceId), + resourceScopeCondition(resourcePolicy, resourceScopeFromOwner(input)), eq(resourcePolicy.resourceType, input.resourceType), eq(resourcePolicy.resourceId, input.resourceId) ) diff --git a/apps/sim/lib/resource-policies/types.ts b/apps/sim/lib/resource-policies/types.ts index 7daed5be6ae..16fbfa99f76 100644 --- a/apps/sim/lib/resource-policies/types.ts +++ b/apps/sim/lib/resource-policies/types.ts @@ -29,8 +29,10 @@ export interface ResourcePolicyStatement { condition?: ResourcePolicyCondition } -export interface ResourcePolicyTarget { - workspaceId: string +export type ResourcePolicyTarget = ( + | { workspaceId: string; organizationId?: never } + | { organizationId: string; workspaceId?: never } +) & { resourceType: ResourceType resourceId: string } diff --git a/apps/sim/lib/selectors/manifest.test.ts b/apps/sim/lib/selectors/manifest.test.ts index df9322d33e3..599b78e1866 100644 --- a/apps/sim/lib/selectors/manifest.test.ts +++ b/apps/sim/lib/selectors/manifest.test.ts @@ -9,9 +9,9 @@ describe('selector manifest', () => { const count = (classification: (typeof classifications)[number]) => classifications.filter((value) => value === classification).length - expect(Object.keys(selectorManifest)).toHaveLength(94) + expect(Object.keys(selectorManifest)).toHaveLength(95) expect(count('provider-server')).toBe(82) - expect(count('internal-server')).toBe(11) + expect(count('internal-server')).toBe(12) expect(count('local')).toBe(1) expect(classifications).not.toContain('provider-legacy') }) diff --git a/apps/sim/lib/selectors/manifest.ts b/apps/sim/lib/selectors/manifest.ts index 26634cdebfe..fbafd6df703 100644 --- a/apps/sim/lib/selectors/manifest.ts +++ b/apps/sim/lib/selectors/manifest.ts @@ -368,6 +368,7 @@ export const selectorManifest = { staleTime: 0, }), 'workspace.credentialProviders': internalSelector([], { detail: true }), + 'workspace.organizationMcpProviders': internalSelector([], { detail: true }), 'workspace.credentialGroupProviders': internalSelector([], { detail: true, }), diff --git a/apps/sim/lib/selectors/server/internal.test.ts b/apps/sim/lib/selectors/server/internal.test.ts index a779c18b928..3ab88055c18 100644 --- a/apps/sim/lib/selectors/server/internal.test.ts +++ b/apps/sim/lib/selectors/server/internal.test.ts @@ -6,10 +6,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mockListWorkflows = vi.hoisted(() => vi.fn()) const mockFetchOpenRouterEmbeddingModelCatalog = vi.hoisted(() => vi.fn()) -const mockGetWorkspaceAccountsSettings = vi.hoisted(() => vi.fn()) +const mockGetWorkspaceOrganizationAccounts = vi.hoisted(() => vi.fn()) -vi.mock('@/lib/credential-groups/application/manage-groups', () => ({ - getWorkspaceAccountsSettings: { execute: mockGetWorkspaceAccountsSettings }, +vi.mock('@/lib/credential-groups/application/workspace-organization-accounts', () => ({ + getWorkspaceOrganizationAccounts: { execute: mockGetWorkspaceOrganizationAccounts }, })) vi.mock('@/lib/workflows/application/list-workflows', () => ({ @@ -39,42 +39,46 @@ function workflowArgs(): ExecuteServerSelectorArgs { } } -describe('workspace.credentialGroupProviders selector', () => { +describe.each([ + { + key: 'workspace.credentialGroupProviders', + field: 'providers', + option: { id: 'google-email', label: 'Gmail' }, + }, + { + key: 'workspace.organizationMcpProviders', + field: 'mcpProviders', + option: { id: 'fireflies', label: 'Fireflies' }, + }, +] as const)('$key selector', ({ key, field, option }) => { beforeEach(() => vi.clearAllMocks()) - - it('uses active options from the workspace container without a group selection', async () => { - mockGetWorkspaceAccountsSettings.mockResolvedValue({ - credentialGroup: { - options: [ - { provider: 'gmail', status: 'active' }, - { provider: 'slack', status: 'disabled' }, - ], - }, - }) - const args: ExecuteServerSelectorArgs = { - ...workflowArgs(), - selectorKey: 'workspace.credentialGroupProviders', - } - await expect( - internalSelectorAttachments['workspace.credentialGroupProviders'].execute(args) - ).resolves.toEqual({ + it('uses the authorized organization provider projection', async () => { + mockGetWorkspaceOrganizationAccounts.mockResolvedValue({ allowed: true, [field]: [option] }) + const args: ExecuteServerSelectorArgs = { ...workflowArgs(), selectorKey: key } + await expect(internalSelectorAttachments[key].execute(args)).resolves.toEqual({ kind: 'list', - items: [{ id: 'google-email', label: 'Gmail' }], + items: [option], }) - expect(mockGetWorkspaceAccountsSettings).toHaveBeenCalledWith({ + expect(mockGetWorkspaceOrganizationAccounts).toHaveBeenCalledWith({ principal: args.principal, input: { workspaceId: 'workspace-1' }, }) }) - - it('returns an empty list when the workspace has no container', async () => { - mockGetWorkspaceAccountsSettings.mockResolvedValue({ credentialGroup: null }) + it('refuses providers when workspace access is not granted', async () => { + mockGetWorkspaceOrganizationAccounts.mockResolvedValue({ allowed: false, [field]: [option] }) + await expect( + internalSelectorAttachments[key].execute({ ...workflowArgs(), selectorKey: key }) + ).rejects.toBeInstanceOf(SelectorOptionsUnavailableError) + }) + it('resolves a selected provider by ID', async () => { + mockGetWorkspaceOrganizationAccounts.mockResolvedValue({ allowed: true, [field]: [option] }) await expect( - internalSelectorAttachments['workspace.credentialGroupProviders'].execute({ + internalSelectorAttachments[key].execute({ ...workflowArgs(), - selectorKey: 'workspace.credentialGroupProviders', + selectorKey: key, + request: { kind: 'detail', id: option.id }, }) - ).resolves.toEqual({ kind: 'list', items: [] }) + ).resolves.toEqual({ kind: 'detail', item: option }) }) }) diff --git a/apps/sim/lib/selectors/server/internal.ts b/apps/sim/lib/selectors/server/internal.ts index be5970fe957..c7f7fde32c9 100644 --- a/apps/sim/lib/selectors/server/internal.ts +++ b/apps/sim/lib/selectors/server/internal.ts @@ -1,5 +1,4 @@ -import { getWorkspaceAccountsSettings } from '@/lib/credential-groups/application/manage-groups' -import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers' +import { getWorkspaceOrganizationAccounts } from '@/lib/credential-groups/application/workspace-organization-accounts' import { listInternalCredentials } from '@/lib/credentials/application/credential-crud' import { fetchOllamaEmbeddingModelCatalog } from '@/lib/embeddings/ollama-model-catalog.server' import { fetchOpenRouterEmbeddingModelCatalog } from '@/lib/embeddings/openrouter-model-catalog.server' @@ -214,20 +213,32 @@ export const internalSelectorAttachments = { destination: 'fixed', async execute(args: ExecuteServerSelectorArgs) { if (!args.workspaceId) throw new SelectorContextUnavailableError() - const { credentialGroup: group } = await getWorkspaceAccountsSettings.execute({ + const result = await getWorkspaceOrganizationAccounts.execute({ principal: args.principal, input: { workspaceId: args.workspaceId }, }) - const options = (group?.options ?? []) - .filter((option) => option.status === 'active') - .map((option) => { - const service = getCredentialGroupProviderService(option.provider) - return { id: service.providerId, label: service.name } - }) - .sort((left, right) => left.label.localeCompare(right.label)) + if (!result.allowed) throw new SelectorOptionsUnavailableError() + const options = result.providers if (args.request.kind === 'detail') { - const detailId = args.request.id - return detailSelectorResult(options.find((option) => option.id === detailId) ?? null) + const id = args.request.id + return detailSelectorResult(options.find((option) => option.id === id) ?? null) + } + return listSelectorResult(options) + }, + }, + 'workspace.organizationMcpProviders': { + destination: 'fixed', + async execute(args: ExecuteServerSelectorArgs) { + if (!args.workspaceId) throw new SelectorContextUnavailableError() + const result = await getWorkspaceOrganizationAccounts.execute({ + principal: args.principal, + input: { workspaceId: args.workspaceId }, + }) + if (!result.allowed) throw new SelectorOptionsUnavailableError() + const options = result.mcpProviders + if (args.request.kind === 'detail') { + const id = args.request.id + return detailSelectorResult(options.find((option) => option.id === id) ?? null) } return listSelectorResult(options) }, diff --git a/apps/sim/lib/settings/application/organization-section-access.test.ts b/apps/sim/lib/settings/application/organization-section-access.test.ts index ec7d4931d62..0e81e192caa 100644 --- a/apps/sim/lib/settings/application/organization-section-access.test.ts +++ b/apps/sim/lib/settings/application/organization-section-access.test.ts @@ -4,7 +4,18 @@ import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const mocks = vi.hoisted(() => ({ canOpen: vi.fn(), enterprise: vi.fn() })) +const mocks = vi.hoisted(() => ({ + canOpen: vi.fn(), + enterprise: vi.fn(), + groups: vi.fn(), + search: vi.fn(), +})) +vi.mock('@/lib/credential-groups/scoped-availability', () => ({ + isScopedCredentialGroupsAvailable: mocks.groups, +})) +vi.mock('@/lib/knowledge/access/availability', () => ({ + isKnowledgeMemberAccessAvailable: mocks.search, +})) vi.mock('@/lib/organizations/settings-access', () => ({ canOpenOrganizationSettingsSection: mocks.canOpen, })) @@ -20,9 +31,78 @@ describe('organization settings authorization', () => { setEnvFlags({ isHosted: true, isBillingEnabled: true }) mocks.canOpen.mockResolvedValue(true) mocks.enterprise.mockResolvedValue(true) + mocks.groups.mockResolvedValue(true) + mocks.search.mockResolvedValue(true) }) afterEach(resetEnvFlagsMock) + it.each(['connected-accounts', 'search-mcp', 'integrations'] as const)( + 'gates direct %s settings links using the target org', + async (section) => { + const gate = section === 'connected-accounts' ? mocks.groups : mocks.search + gate.mockResolvedValue(false) + await expect( + authorizeOrganizationSettingsSection({ + organizationId: 'target', + userId: 'viewer', + section, + }) + ).resolves.toBe(false) + expect(gate).toHaveBeenCalledExactlyOnceWith( + section === 'connected-accounts' + ? { kind: 'organization', organizationId: 'target' } + : { organizationId: 'target' } + ) + expect(mocks.enterprise).not.toHaveBeenCalled() + } + ) + + it.each([ + { groups: false, search: false, connectedAccounts: false, integrations: false }, + { groups: true, search: false, connectedAccounts: true, integrations: false }, + { groups: true, search: true, connectedAccounts: false, integrations: true }, + ])( + 'selects the setup page with groups=$groups and search=$search', + async ({ groups, search, connectedAccounts, integrations }) => { + mocks.groups.mockResolvedValue(groups) + mocks.search.mockResolvedValue(search) + const input = { organizationId: 'target', userId: 'admin' } + await expect( + authorizeOrganizationSettingsSection({ ...input, section: 'connected-accounts' }) + ).resolves.toBe(connectedAccounts) + await expect( + authorizeOrganizationSettingsSection({ ...input, section: 'integrations' }) + ).resolves.toBe(integrations) + } + ) + + it.each(['connected-accounts', 'integrations'] as const)( + 'checks role access before selecting the %s UI', + async (section) => { + mocks.canOpen.mockResolvedValue(false) + await expect( + authorizeOrganizationSettingsSection({ + organizationId: 'target', + userId: 'member', + section, + }) + ).resolves.toBe(false) + expect(mocks.groups).not.toHaveBeenCalled() + expect(mocks.search).not.toHaveBeenCalled() + } + ) + + it('propagates Search availability failures instead of selecting the old UI', async () => { + mocks.search.mockRejectedValue(new Error('Feature configuration unavailable')) + await expect( + authorizeOrganizationSettingsSection({ + organizationId: 'target', + userId: 'admin', + section: 'connected-accounts', + }) + ).rejects.toThrow('Feature configuration unavailable') + }) + it('checks current target organization membership before billing reads', async () => { mocks.canOpen.mockResolvedValue(false) expect( @@ -58,25 +138,6 @@ describe('organization settings authorization', () => { ).toBe(false) }) - it('gates Sim Search source setup on the enterprise plan when hosted', async () => { - mocks.enterprise.mockResolvedValue(false) - expect( - await authorizeOrganizationSettingsSection({ - organizationId: 'target', - userId: 'admin', - section: 'integrations', - }) - ).toBe(false) - mocks.enterprise.mockResolvedValue(true) - expect( - await authorizeOrganizationSettingsSection({ - organizationId: 'target', - userId: 'admin', - section: 'integrations', - }) - ).toBe(true) - }) - it('does not turn authorization infrastructure failures into empty settings', async () => { mocks.canOpen.mockRejectedValue(new Error('Membership database unavailable')) await expect( diff --git a/apps/sim/lib/settings/application/organization-section-access.ts b/apps/sim/lib/settings/application/organization-section-access.ts index ab13f44c8af..968872e0207 100644 --- a/apps/sim/lib/settings/application/organization-section-access.ts +++ b/apps/sim/lib/settings/application/organization-section-access.ts @@ -5,6 +5,8 @@ import { } from '@/components/settings/navigation' import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' import { getDeploymentShape } from '@/lib/core/config/deployment-shape' +import { isScopedCredentialGroupsAvailable } from '@/lib/credential-groups/scoped-availability' +import { isKnowledgeMemberAccessAvailable } from '@/lib/knowledge/access/availability' import { canOpenOrganizationSettingsSection } from '@/lib/organizations/settings-access' interface AuthorizeOrganizationSettingsSectionInput { @@ -21,9 +23,16 @@ export async function authorizeOrganizationSettingsSection({ }: AuthorizeOrganizationSettingsSectionInput): Promise { if (!(await canOpenOrganizationSettingsSection(organizationId, userId, section))) return false + if (section === 'connected-accounts') { + if (!(await isScopedCredentialGroupsAvailable({ kind: 'organization', organizationId }))) + return false + return !(await isKnowledgeMemberAccessAvailable({ organizationId })) + } + if (section === 'search-mcp' || section === 'integrations') + return isKnowledgeMemberAccessAvailable({ organizationId }) + const deployment = getDeploymentShape() - const needsEnterprisePlan = - deployment.hosted && section !== 'members' && section !== 'billing' && section !== 'search-mcp' + const needsEnterprisePlan = deployment.hosted && section !== 'members' && section !== 'billing' const hasEnterprisePlan = needsEnterprisePlan ? await isOrganizationOnEnterprisePlan(organizationId) : false diff --git a/apps/sim/lib/settings/application/workspace-section-access.test.ts b/apps/sim/lib/settings/application/workspace-section-access.test.ts index 9e94ffb2828..8c2e4f8a05f 100644 --- a/apps/sim/lib/settings/application/workspace-section-access.test.ts +++ b/apps/sim/lib/settings/application/workspace-section-access.test.ts @@ -27,8 +27,6 @@ const mocks = vi.hoisted(() => ({ }, }, getOrganizationSettingsFeatures: vi.fn((hasEnterprisePlan: boolean) => ({ hasEnterprisePlan })), - getWorkspaceOwnerSubscriptionAccess: vi.fn(), - isCredentialGroupsAvailable: vi.fn(), isCustomBlocksEligibleForOrganization: vi.fn(), isForkingAvailableForWorkspace: vi.fn(), isOrganizationOnEnterprisePlan: vi.fn(), @@ -49,7 +47,6 @@ vi.mock('@/components/settings/navigation', () => ({ }, UNIFIED_TO_WORKSPACE_SECTION: { secrets: 'secrets', - 'credential-groups': 'credential-groups', forks: 'forks', 'custom-blocks': 'custom-blocks', }, @@ -57,15 +54,9 @@ vi.mock('@/components/settings/navigation', () => ({ ['secrets', 'api-keys', 'inbox', 'mcp', 'custom-tools'].includes(section) ), })) -vi.mock('@/lib/billing/core/workspace-access', () => ({ - getWorkspaceOwnerSubscriptionAccess: mocks.getWorkspaceOwnerSubscriptionAccess, -})) vi.mock('@/lib/billing/core/subscription', () => ({ isOrganizationOnEnterprisePlan: mocks.isOrganizationOnEnterprisePlan, })) -vi.mock('@/lib/credential-groups/availability', () => ({ - isCredentialGroupsAvailable: mocks.isCredentialGroupsAvailable, -})) vi.mock('@/lib/core/config/deployment-shape', () => ({ getDeploymentShape: () => mocks.deploymentShape, })) @@ -119,8 +110,6 @@ describe('authorizeWorkspaceSettingsSection', () => { beforeEach(() => { vi.clearAllMocks() mocks.checkWorkspaceAccess.mockResolvedValue(PERSONAL_ACCESS) - mocks.getWorkspaceOwnerSubscriptionAccess.mockResolvedValue({ isEnterprise: true }) - mocks.isCredentialGroupsAvailable.mockResolvedValue(true) mocks.isCustomBlocksEligibleForOrganization.mockResolvedValue(true) mocks.isForkingAvailableForWorkspace.mockResolvedValue(true) mocks.isOrganizationOnEnterprisePlan.mockResolvedValue(true) @@ -144,13 +133,11 @@ describe('authorizeWorkspaceSettingsSection', () => { disposition: 'not-found', }) expect(mocks.canOpenOrganizationSettingsSection).not.toHaveBeenCalled() - expect(mocks.getWorkspaceOwnerSubscriptionAccess).not.toHaveBeenCalled() }) it('opens ordinary sections from workspace access alone', async () => { await expect(authorize('general')).resolves.toEqual({ allowed: true }) - expect(mocks.getWorkspaceOwnerSubscriptionAccess).not.toHaveBeenCalled() expect(mocks.canOpenOrganizationSettingsSection).not.toHaveBeenCalled() expect(mocks.resolveVerifiedUserAccessControlContext).not.toHaveBeenCalled() expect(mocks.isPlatformAdmin).not.toHaveBeenCalled() @@ -177,7 +164,6 @@ describe('authorizeWorkspaceSettingsSection', () => { allowed: false, disposition: 'redirect-general', }) - expect(mocks.getWorkspaceOwnerSubscriptionAccess).not.toHaveBeenCalled() expect(mocks.resolveVerifiedUserAccessControlContext).toHaveBeenCalledWith( 'viewer-1', 'workspace-1', @@ -191,7 +177,6 @@ describe('authorizeWorkspaceSettingsSection', () => { it('resolves environment access-control policy for the same section in a personal workspace', async () => { await authorize('secrets') - expect(mocks.getWorkspaceOwnerSubscriptionAccess).not.toHaveBeenCalled() expect(mocks.resolveVerifiedUserAccessControlContext).toHaveBeenCalledWith( 'viewer-1', 'workspace-1', @@ -201,7 +186,6 @@ describe('authorizeWorkspaceSettingsSection', () => { it('enforces canonical permission config independently of billing subscription state', async () => { mocks.checkWorkspaceAccess.mockResolvedValue(ORGANIZATION_ACCESS) - mocks.getWorkspaceOwnerSubscriptionAccess.mockResolvedValue({ isEnterprise: false }) mocks.resolveVerifiedUserAccessControlContext.mockResolvedValue({ entitled: true, config: { hideSecretsTab: true }, @@ -212,7 +196,6 @@ describe('authorizeWorkspaceSettingsSection', () => { allowed: false, disposition: 'redirect-general', }) - expect(mocks.getWorkspaceOwnerSubscriptionAccess).not.toHaveBeenCalled() }) it('passes the server-resolved deployment shape to both navigation gates', async () => { @@ -230,14 +213,7 @@ describe('authorizeWorkspaceSettingsSection', () => { }) it('resolves the exact entitlement source only for gated workspace sections', async () => { - mocks.resolveWorkspaceNavigation.mockReturnValue([{ id: 'credential-groups' }]) - await authorize('credential-groups') - expect(mocks.isCredentialGroupsAvailable).toHaveBeenCalledWith({ - workspaceId: 'workspace-1', - ownerBilling: { isEnterprise: true }, - }) - expect(mocks.isForkingAvailableForWorkspace).not.toHaveBeenCalled() - + mocks.checkWorkspaceAccess.mockResolvedValue(PERSONAL_ACCESS) mocks.resolveWorkspaceNavigation.mockReturnValue([{ id: 'forks' }]) await authorize('forks') expect(mocks.isForkingAvailableForWorkspace).toHaveBeenCalledWith(null, 'viewer-1') diff --git a/apps/sim/lib/settings/application/workspace-section-access.ts b/apps/sim/lib/settings/application/workspace-section-access.ts index 8f2194a6e2f..632370d2815 100644 --- a/apps/sim/lib/settings/application/workspace-section-access.ts +++ b/apps/sim/lib/settings/application/workspace-section-access.ts @@ -9,9 +9,7 @@ import { workspaceSectionUsesPermissionConfig, } from '@/components/settings/navigation' import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' -import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access' import { getDeploymentShape } from '@/lib/core/config/deployment-shape' -import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability' import { canOpenOrganizationSettingsSection } from '@/lib/organizations/settings-access' import { isPlatformAdmin } from '@/lib/permissions/super-user' import { isCustomBlocksEligibleForOrganization } from '@/lib/workflows/custom-blocks/operations' @@ -37,30 +35,21 @@ async function canOpenWorkspaceSection( }, permission: NonNullable>['permission']> ): Promise { - const needsOwnerBilling = section === 'credential-groups' - const ownerBilling = needsOwnerBilling - ? await getWorkspaceOwnerSubscriptionAccess(input.workspaceId) - : null - - const [accessControl, credentialGroupsAvailable, forksAvailable, customBlocksAvailable] = - await Promise.all([ - workspaceSectionUsesPermissionConfig(section) - ? resolveVerifiedUserAccessControlContext( - input.userId, - input.workspaceId, - workspace.organizationId - ) - : null, - section === 'credential-groups' && ownerBilling - ? isCredentialGroupsAvailable({ workspaceId: input.workspaceId, ownerBilling }) - : false, - section === 'forks' - ? isForkingAvailableForWorkspace(workspace.organizationId, input.userId) - : false, - section === 'custom-blocks' && workspace.organizationId - ? isCustomBlocksEligibleForOrganization(workspace.organizationId) - : false, - ]) + const [accessControl, forksAvailable, customBlocksAvailable] = await Promise.all([ + workspaceSectionUsesPermissionConfig(section) + ? resolveVerifiedUserAccessControlContext( + input.userId, + input.workspaceId, + workspace.organizationId + ) + : null, + section === 'forks' + ? isForkingAvailableForWorkspace(workspace.organizationId, input.userId) + : false, + section === 'custom-blocks' && workspace.organizationId + ? isCustomBlocksEligibleForOrganization(workspace.organizationId) + : false, + ]) const deployment = getDeploymentShape() const navigation = resolveWorkspaceNavigation({ @@ -68,7 +57,6 @@ async function canOpenWorkspaceSection( permissionConfig: accessControl?.config ?? {}, deployment, entitlements: { - credentialGroups: credentialGroupsAvailable, inbox: true, customBlocks: customBlocksAvailable, forks: forksAvailable, diff --git a/apps/sim/lib/workspaces/admin-move-source-impact.ts b/apps/sim/lib/workspaces/admin-move-source-impact.ts index 76a76e44716..e2c41e838bc 100644 --- a/apps/sim/lib/workspaces/admin-move-source-impact.ts +++ b/apps/sim/lib/workspaces/admin-move-source-impact.ts @@ -57,6 +57,7 @@ import { getCustomBlockUsageCounts } from '@/lib/workflows/custom-blocks/operati const ENTERPRISE_GATED_SECTION_LABELS: Record = { integrations: 'Sim Search source setup', 'search-mcp': null, + 'connected-accounts': 'organization connected accounts', members: null, billing: null, usage: 'organization usage monitoring', diff --git a/apps/sim/lib/workspaces/host-context.test.ts b/apps/sim/lib/workspaces/host-context.test.ts index db13e2865cf..c118a73b33c 100644 --- a/apps/sim/lib/workspaces/host-context.test.ts +++ b/apps/sim/lib/workspaces/host-context.test.ts @@ -13,6 +13,10 @@ const { mockGetOrganizationSettingsAccess: vi.fn(), })) +vi.mock('@/lib/credential-groups/scoped-availability', () => ({ + isScopedCredentialGroupsAvailable: vi.fn().mockResolvedValue(true), +})) + vi.mock('@/lib/workspaces/permissions/utils', () => ({ checkWorkspaceAccess: mockCheckWorkspaceAccess, })) diff --git a/apps/sim/lib/workspaces/host-context.ts b/apps/sim/lib/workspaces/host-context.ts index 0742770648d..e60716ca9ef 100644 --- a/apps/sim/lib/workspaces/host-context.ts +++ b/apps/sim/lib/workspaces/host-context.ts @@ -2,7 +2,7 @@ import { cache } from 'react' import type { WorkspaceHostContext } from '@/lib/api/contracts/workspaces' import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access' import { resolveDeploymentShape } from '@/lib/core/config/deployment-shape' -import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability' +import { isScopedCredentialGroupsAvailable } from '@/lib/credential-groups/scoped-availability' import { resolveKnowledgeAccessAvailability } from '@/lib/knowledge/access/availability' import { getOrganizationSettingsAccess } from '@/lib/organizations/settings-access' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' @@ -31,7 +31,12 @@ async function resolveWorkspaceHostContextForViewer( : Promise.resolve({ role: null, isMember: false, isAdmin: false }), ]) const [credentialGroupsAvailable, knowledgeAccess] = await Promise.all([ - isCredentialGroupsAvailable({ workspaceId, ownerBilling }), + hostOrganizationId + ? isScopedCredentialGroupsAvailable({ + kind: 'organization', + organizationId: hostOrganizationId, + }) + : Promise.resolve(false), resolveKnowledgeAccessAvailability({ workspaceId, ownerBilling }), ]) diff --git a/apps/sim/triggers/credential-group/event.ts b/apps/sim/triggers/credential-group/event.ts index d985a7a92b5..85ddc1e4be3 100644 --- a/apps/sim/triggers/credential-group/event.ts +++ b/apps/sim/triggers/credential-group/event.ts @@ -37,7 +37,7 @@ export const credentialGroupEventTrigger: TriggerConfig = { type: 'text', defaultValue: [ 'Choose whether to trigger on a new credential, a reconnection, or a submitted form', - 'Grant this workflow access in Connected accounts settings', + 'Ask an organization admin to allow this workspace in Connected accounts settings', 'Deploy the workflow to start receiving events', ] .map( @@ -60,11 +60,11 @@ export const credentialGroupEventTrigger: TriggerConfig = { }, credentialGroupId: { type: 'string', - description: 'Workspace accounts container ID', + description: 'Organization accounts container ID', }, credentialGroupName: { type: 'string', - description: 'Workspace accounts container name', + description: 'Organization accounts container name', }, enrollmentId: { type: 'string', @@ -88,6 +88,11 @@ export const credentialGroupEventTrigger: TriggerConfig = { description: 'Connected account option ID', condition: { field: 'eventType', value: [...CREDENTIAL_GROUP_CREDENTIAL_EVENT_TYPES] }, }, + mcpServerId: { + type: 'string', + description: 'Managed MCP server configuration ID, or null for an OAuth account', + condition: { field: 'eventType', value: [...CREDENTIAL_GROUP_CREDENTIAL_EVENT_TYPES] }, + }, provider: { type: 'string', description: 'Account provider', diff --git a/packages/auth/src/principal.ts b/packages/auth/src/principal.ts index 077c5797e0f..746a955f748 100644 --- a/packages/auth/src/principal.ts +++ b/packages/auth/src/principal.ts @@ -179,6 +179,7 @@ export interface OrganizationDelegatedPrincipal { /** Bearer identity established by a currently valid Credential Group invitation. */ interface CredentialGroupEnrollmentIdentity { kind: 'credential_group_enrollment' + userId: string credentialGroupId: string enrollmentId: string email: string diff --git a/packages/db/credential-group-resource-policies.ts b/packages/db/credential-group-resource-policies.ts index a8be90034e6..28d70c986fd 100644 --- a/packages/db/credential-group-resource-policies.ts +++ b/packages/db/credential-group-resource-policies.ts @@ -3,6 +3,7 @@ import type { Sql } from 'postgres' export const CREDENTIAL_GROUP_POLICY_BATCH_SIZE = 500 export const CREDENTIAL_GROUP_WORKFLOW_ACCESS_LIMIT = 50 export const CREDENTIAL_GROUP_POLICY_DOCUMENT_MAX_BYTES = 32 * 1024 +export const ORGANIZATION_ACCOUNT_POLICY_DOCUMENT_MAX_BYTES = 256 * 1024 const ACTOR_ACCESS_SID = 'CredentialGroupActorCredentialAccess' const WORKFLOW_ACCESS_SID = 'WorkflowCredentialAccess' @@ -69,7 +70,8 @@ export interface MissingCredentialGroupPolicyRow { export interface StoredCredentialGroupPolicyRow { id: string - workspaceId: string + organizationId?: string | null + workspaceId: string | null resourceId: string revision: number documentBytes: number @@ -353,6 +355,58 @@ export function parseCredentialGroupPolicyDocument( } } +/** Validates the org-only workspace sharing document without importing application code. */ +export function validateOrganizationAccountPolicyDocument( + value: unknown, + expectedResourceId: string +): void { + const document = requireRecord(value, 'Organization account policy') + requireExactKeys(document, ['version', 'resource', 'statements'], 'Organization account policy') + if (document.version !== 2) throw new Error('Organization account policy version must be 2') + const resource = requireRecord(document.resource, 'Organization account resource') + requireExactKeys(resource, ['type', 'id'], 'Organization account resource') + if ( + resource.type !== 'credential_group' || + requireCanonicalId(resource.id, 'Organization account resource ID') !== expectedResourceId + ) + throw new Error('Organization account policy resource does not match its canonical resource') + if (!Array.isArray(document.statements) || document.statements.length > 1) + throw new Error('Organization account policy supports only workspace access') + if (document.statements.length === 0) return + const statement = requireRecord( + document.statements[0], + 'Organization account workspace statement' + ) + requireExactKeys( + statement, + ['sid', 'effect', 'actions', 'principals'], + 'Organization account workspace statement' + ) + if ( + statement.sid !== 'WorkspaceCredentialAccess' || + statement.effect !== 'allow' || + !Array.isArray(statement.actions) || + statement.actions.length !== 1 || + statement.actions[0] !== CREDENTIAL_USE_ACTION + ) + throw new Error('Organization account workspace statement is invalid') + if ( + !Array.isArray(statement.principals) || + statement.principals.length < 1 || + statement.principals.length > 1000 + ) + throw new Error('Organization account policy supports 1-1000 workspaces') + let previous = '' + for (const value of statement.principals) { + const principal = requireRecord(value, 'Organization account workspace principal') + requireExactKeys(principal, ['type', 'workspaceId'], 'Organization account workspace principal') + const id = requireCanonicalId(principal.workspaceId, 'Organization account workspace ID') + if (principal.type !== 'workspace' || id <= previous) + throw new Error('Organization account workspace principals must be unique and sorted') + previous = id + } +} + function assertPage( rows: T[], afterId: string, @@ -414,16 +468,24 @@ export async function reconcileCredentialGroupResourcePolicies( if (!Number.isInteger(row.revision) || row.revision < 1) { throw new Error(`Credential Group policy ${row.id} has an invalid revision`) } + const maxBytes = row.organizationId + ? ORGANIZATION_ACCOUNT_POLICY_DOCUMENT_MAX_BYTES + : CREDENTIAL_GROUP_POLICY_DOCUMENT_MAX_BYTES if ( !Number.isInteger(row.documentBytes) || row.documentBytes < 0 || - row.documentBytes > CREDENTIAL_GROUP_POLICY_DOCUMENT_MAX_BYTES + row.documentBytes > maxBytes ) { - throw new Error( - `Credential Group policy ${row.id} exceeds the ${CREDENTIAL_GROUP_POLICY_DOCUMENT_MAX_BYTES}-byte limit` - ) + throw new Error(`Credential Group policy ${row.id} exceeds the ${maxBytes}-byte limit`) + } + if (row.organizationId) { + if (row.workspaceId) + throw new Error('Organization account policy cannot also belong to a workspace') + validateOrganizationAccountPolicyDocument(row.document, row.resourceId) + } else { + if (!row.workspaceId) throw new Error('Credential group policy has no owner') + parseCredentialGroupPolicyDocument(row.document, row.resourceId) } - parseCredentialGroupPolicyDocument(row.document, row.resourceId) } result.validated += rows.length afterId = lastId @@ -491,7 +553,7 @@ export function createPostgresCredentialGroupPolicyLifecycleStore( END IF; DELETE FROM "public"."resource_policy" - WHERE "workspace_id" = OLD."workspace_id" + WHERE ("workspace_id" = OLD."workspace_id" OR "organization_id" = OLD."organization_id") AND "resource_type" = 'credential_group' AND "resource_id" = OLD."id"; RETURN OLD; @@ -598,9 +660,9 @@ export function createPostgresCredentialGroupPolicyLifecycleStore( LEFT JOIN resource_policy rp ON rp.resource_type = 'credential_group' AND rp.resource_id = cg.id - WHERE cg.workspace_id IS NOT NULL - AND (rp.resource_id IS NULL - OR rp.workspace_id IS DISTINCT FROM cg.workspace_id) + WHERE rp.resource_id IS NULL + OR rp.workspace_id IS DISTINCT FROM cg.workspace_id + OR rp.organization_id IS DISTINCT FROM cg.organization_id UNION ALL @@ -608,7 +670,7 @@ export function createPostgresCredentialGroupPolicyLifecycleStore( FROM resource_policy rp LEFT JOIN credential_group cg ON cg.id = rp.resource_id WHERE rp.resource_type = 'credential_group' - AND (cg.id IS NULL OR cg.workspace_id IS NULL) + AND cg.id IS NULL ) violations ORDER BY resource_id LIMIT 1 @@ -621,11 +683,15 @@ export function createPostgresCredentialGroupPolicyLifecycleStore( SELECT id, workspace_id AS "workspaceId", + organization_id AS "organizationId", resource_id AS "resourceId", revision, octet_length(document::text)::integer AS "documentBytes", CASE - WHEN octet_length(document::text) <= ${CREDENTIAL_GROUP_POLICY_DOCUMENT_MAX_BYTES} + WHEN octet_length(document::text) <= CASE + WHEN organization_id IS NULL THEN ${CREDENTIAL_GROUP_POLICY_DOCUMENT_MAX_BYTES}::integer + ELSE ${ORGANIZATION_ACCOUNT_POLICY_DOCUMENT_MAX_BYTES}::integer + END THEN document ELSE NULL END AS document diff --git a/packages/db/migrations/0327_organization_connected_accounts.sql b/packages/db/migrations/0327_organization_connected_accounts.sql new file mode 100644 index 00000000000..cc70640a95d --- /dev/null +++ b/packages/db/migrations/0327_organization_connected_accounts.sql @@ -0,0 +1,191 @@ +SET LOCAL lock_timeout = '5s'; +--> statement-breakpoint +-- migration-safe: Replaces the organization credential type check with a superset including managed_mcp; old writers remain valid and no data is removed. +ALTER TABLE "credential" DROP CONSTRAINT IF EXISTS "credential_organization_type_check"; +--> statement-breakpoint +ALTER TABLE "mcp_server_oauth" ALTER COLUMN "workspace_id" DROP NOT NULL; +--> statement-breakpoint +ALTER TABLE "mcp_servers" ALTER COLUMN "workspace_id" DROP NOT NULL; +--> statement-breakpoint +ALTER TABLE "resource_policy" ALTER COLUMN "workspace_id" DROP NOT NULL; +--> statement-breakpoint +ALTER TABLE "credential" ADD COLUMN IF NOT EXISTS "mcp_oauth_config_version" integer; +--> statement-breakpoint +ALTER TABLE "credential_group_enrollment" ADD COLUMN IF NOT EXISTS "user_id" text; +--> statement-breakpoint +ALTER TABLE "mcp_server_oauth" ADD COLUMN IF NOT EXISTS "organization_id" text; +--> statement-breakpoint +ALTER TABLE "mcp_servers" ADD COLUMN IF NOT EXISTS "organization_id" text; +--> statement-breakpoint +ALTER TABLE "mcp_servers" ADD COLUMN IF NOT EXISTS "oauth_config_version" integer DEFAULT 1 NOT NULL; +--> statement-breakpoint +ALTER TABLE "resource_policy" ADD COLUMN IF NOT EXISTS "organization_id" text; +--> statement-breakpoint +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'credential_group_enrollment_user_id_user_id_fk' AND conrelid = '"public"."credential_group_enrollment"'::regclass) THEN + ALTER TABLE "credential_group_enrollment" ADD CONSTRAINT "credential_group_enrollment_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action NOT VALID; + END IF; +END; +$$; +--> statement-breakpoint +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'mcp_server_oauth_organization_id_organization_id_fk' AND conrelid = '"public"."mcp_server_oauth"'::regclass) THEN + ALTER TABLE "mcp_server_oauth" ADD CONSTRAINT "mcp_server_oauth_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action NOT VALID; + END IF; +END; +$$; +--> statement-breakpoint +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'mcp_servers_organization_id_organization_id_fk' AND conrelid = '"public"."mcp_servers"'::regclass) THEN + ALTER TABLE "mcp_servers" ADD CONSTRAINT "mcp_servers_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action NOT VALID; + END IF; +END; +$$; +--> statement-breakpoint +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'resource_policy_organization_id_organization_id_fk' AND conrelid = '"public"."resource_policy"'::regclass) THEN + ALTER TABLE "resource_policy" ADD CONSTRAINT "resource_policy_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action NOT VALID; + END IF; +END; +$$; +--> statement-breakpoint +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'credential_organization_type_check' AND conrelid = '"public"."credential"'::regclass) THEN + ALTER TABLE "credential" ADD CONSTRAINT "credential_organization_type_check" CHECK ("credential"."organization_id" IS NULL OR "credential"."type" IN ('oauth', 'managed_oauth', 'managed_mcp', 'service_account', 'personal_token')) NOT VALID; + END IF; +END; +$$; +--> statement-breakpoint +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'mcp_server_oauth_owner_check' AND conrelid = '"public"."mcp_server_oauth"'::regclass) THEN + ALTER TABLE "mcp_server_oauth" ADD CONSTRAINT "mcp_server_oauth_owner_check" CHECK (num_nonnulls("mcp_server_oauth"."workspace_id", "mcp_server_oauth"."organization_id") = 1) NOT VALID; + END IF; +END; +$$; +--> statement-breakpoint +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'mcp_servers_owner_check' AND conrelid = '"public"."mcp_servers"'::regclass) THEN + ALTER TABLE "mcp_servers" ADD CONSTRAINT "mcp_servers_owner_check" CHECK (num_nonnulls("mcp_servers"."workspace_id", "mcp_servers"."organization_id") = 1) NOT VALID; + END IF; +END; +$$; +--> statement-breakpoint +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'mcp_servers_organization_managed_check' AND conrelid = '"public"."mcp_servers"'::regclass) THEN + ALTER TABLE "mcp_servers" ADD CONSTRAINT "mcp_servers_organization_managed_check" CHECK ("mcp_servers"."organization_id" IS NULL OR "mcp_servers"."credential_group_id" IS NOT NULL) NOT VALID; + END IF; +END; +$$; +--> statement-breakpoint +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'resource_policy_owner_check' AND conrelid = '"public"."resource_policy"'::regclass) THEN + ALTER TABLE "resource_policy" ADD CONSTRAINT "resource_policy_owner_check" CHECK (num_nonnulls("resource_policy"."workspace_id", "resource_policy"."organization_id") = 1) NOT VALID; + END IF; +END; +$$; +--> statement-breakpoint +CREATE OR REPLACE FUNCTION "public"."sync_credential_group_resource_policy"() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = pg_catalog, public +AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + IF NEW."workspace_id" IS NULL THEN + RETURN NEW; + END IF; + INSERT INTO "public"."resource_policy" ( + "id", + "workspace_id", + "resource_type", + "resource_id", + "revision", + "document", + "created_by", + "updated_by" + ) + VALUES ( + gen_random_uuid()::text, + NEW."workspace_id", + 'credential_group', + NEW."id", + 1, + jsonb_build_object( + 'version', 1, + 'resource', jsonb_build_object('type', 'credential_group', 'id', NEW."id"), + 'statements', jsonb_build_array( + jsonb_build_object( + 'sid', 'CredentialGroupActorCredentialAccess', + 'effect', 'allow', + 'actions', jsonb_build_array('credential_groups.credentials.use'), + 'principals', jsonb_build_array( + jsonb_build_object('type', 'credential_group_actor') + ), + 'condition', jsonb_build_object( + 'Bool', jsonb_build_object( + 'credential_group:ActorOwnsCredential', true + ) + ) + ) + ) + ), + NEW."created_by", + NEW."created_by" + ); + RETURN NEW; + END IF; + + DELETE FROM "public"."resource_policy" + WHERE ("workspace_id" = OLD."workspace_id" OR "organization_id" = OLD."organization_id") + AND "resource_type" = 'credential_group' + AND "resource_id" = OLD."id"; + RETURN OLD; +END; +$$; + +--> statement-breakpoint +COMMIT; +--> statement-breakpoint +SET lock_timeout = 0; +--> statement-breakpoint +CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "credential_group_enrollment_group_user_unique" ON "credential_group_enrollment" USING btree ("credential_group_id","user_id") WHERE "credential_group_enrollment"."user_id" IS NOT NULL; +--> statement-breakpoint +CREATE INDEX CONCURRENTLY IF NOT EXISTS "credential_group_enrollment_user_id_idx" ON "credential_group_enrollment" USING btree ("user_id"); +--> statement-breakpoint +CREATE INDEX CONCURRENTLY IF NOT EXISTS "mcp_servers_organization_id_idx" ON "mcp_servers" USING btree ("organization_id"); +--> statement-breakpoint +CREATE INDEX CONCURRENTLY IF NOT EXISTS "resource_policy_organization_id_idx" ON "resource_policy" USING btree ("organization_id"); +--> statement-breakpoint +RESET lock_timeout; +--> statement-breakpoint +BEGIN; +--> statement-breakpoint +SET LOCAL lock_timeout = '5s'; +--> statement-breakpoint +DO $$ +DECLARE invalid_indexes text; +BEGIN + SELECT string_agg(required.index_name, ', ' ORDER BY required.index_name) INTO invalid_indexes + FROM (VALUES + ('"public"."credential_group_enrollment_group_user_unique"', '"public"."credential_group_enrollment"'), + ('"public"."credential_group_enrollment_user_id_idx"', '"public"."credential_group_enrollment"'), + ('"public"."mcp_servers_organization_id_idx"', '"public"."mcp_servers"'), + ('"public"."resource_policy_organization_id_idx"', '"public"."resource_policy"') + ) AS required(index_name, table_name) + LEFT JOIN pg_index AS actual ON actual.indexrelid = to_regclass(required.index_name) AND actual.indrelid = to_regclass(required.table_name) + WHERE NOT COALESCE(actual.indisvalid AND actual.indisready, false); + IF invalid_indexes IS NOT NULL THEN + RAISE EXCEPTION 'Connected accounts migration requires valid indexes: %', invalid_indexes + USING HINT = 'Repair listed indexes with DROP INDEX CONCURRENTLY and CREATE INDEX CONCURRENTLY, then rerun.'; + END IF; +END; +$$; diff --git a/packages/db/migrations/meta/0327_snapshot.json b/packages/db/migrations/meta/0327_snapshot.json new file mode 100644 index 00000000000..8642152425c --- /dev/null +++ b/packages/db/migrations/meta/0327_snapshot.json @@ -0,0 +1,25266 @@ +{ + "id": "1c843e33-9afa-45ab-9790-f0f825110b57", + "prevId": "621e13c3-aa09-4312-b403-8bf9edae0689", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_config": { + "name": "oauth_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_unreconciled_terminal_idx": { + "name": "async_jobs_schedule_unreconciled_terminal_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" IN ('completed', 'failed', 'cancelled') AND COALESCE(\"async_jobs\".\"metadata\" ->> 'scheduleReconciled', 'false') <> 'true'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_work_status": { + "name": "background_work_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "background_work_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_work_status_value", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_work_status_workspace_status_idx": { + "name": "background_work_status_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_workflow_status_idx": { + "name": "background_work_status_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_child_ws_idx": { + "name": "background_work_status_meta_child_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'childWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_other_ws_idx": { + "name": "background_work_status_meta_other_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'otherWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "background_work_status_workspace_id_workspace_id_fk": { + "name": "background_work_status_workspace_id_workspace_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "background_work_status_workflow_id_workflow_id_fk": { + "name": "background_work_status_workflow_id_workflow_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "include_thinking": { + "name": "include_thinking", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_tool_calls": { + "name": "include_tool_calls", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permission_decision": { + "name": "permission_decision", + "type": "copilot_tool_permission_decision", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "permission_decided_at": { + "name": "permission_decided_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_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_organization_id_idx": { + "name": "copilot_chats_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_org_created_idx": { + "name": "copilot_chats_user_org_created_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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" + }, + "copilot_chats_organization_id_organization_id_fk": { + "name": "copilot_chats_organization_id_organization_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "copilot_chats_owner_check": { + "name": "copilot_chats_owner_check", + "value": "num_nonnulls(\"copilot_chats\".\"workspace_id\", \"copilot_chats\".\"organization_id\") <= 1" + }, + "copilot_chats_organization_workflow_check": { + "name": "copilot_chats_organization_workflow_check", + "value": "\"copilot_chats\".\"organization_id\" IS NULL OR \"copilot_chats\".\"workflow_id\" IS NULL" + } + }, + "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": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unredacted": { + "name": "unredacted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_personal_token": { + "name": "encrypted_personal_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_app_id": { + "name": "authorization_app_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_enrollment_id": { + "name": "credential_group_enrollment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_option_id": { + "name": "credential_group_option_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_oauth_config_version": { + "name": "mcp_oauth_config_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "managed_oauth_scope_version": { + "name": "managed_oauth_scope_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "provider_subject_id": { + "name": "provider_subject_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_oauth_status": { + "name": "managed_oauth_status", + "type": "managed_oauth_credential_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "granted_scopes": { + "name": "granted_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "provider_metadata": { + "name": "provider_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "encrypted_oauth_token_set": { + "name": "encrypted_oauth_token_set", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_tools": { + "name": "mcp_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "mcp_tools_refreshed_at": { + "name": "mcp_tools_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "granted_at": { + "name": "granted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_organization_id_idx": { + "name": "credential_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_organization_account_unique": { + "name": "credential_organization_account_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential\".\"account_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_org_personal_token_unique": { + "name": "credential_org_personal_token_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential\".\"type\" = 'personal_token'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_idx": { + "name": "credential_group_enrollment_idx", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_mcp_server_idx": { + "name": "credential_mcp_server_idx", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_option_unique": { + "name": "credential_group_option_unique", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credential_group_option_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential\".\"type\" = 'managed_oauth'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_managed_mcp_enrollment_server_unique": { + "name": "credential_managed_mcp_enrollment_server_unique", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential\".\"type\" = 'managed_mcp'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_personal_token_identity_unique": { + "name": "credential_personal_token_identity_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'personal_token'", + "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_organization_id_organization_id_fk": { + "name": "credential_organization_id_organization_id_fk", + "tableFrom": "credential", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_credential_group_enrollment_id_credential_group_enrollment_id_fk": { + "name": "credential_credential_group_enrollment_id_credential_group_enrollment_id_fk", + "tableFrom": "credential", + "tableTo": "credential_group_enrollment", + "columnsFrom": ["credential_group_enrollment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_mcp_server_id_mcp_servers_id_fk": { + "name": "credential_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "credential", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_owner_check": { + "name": "credential_owner_check", + "value": "num_nonnulls(\"credential\".\"workspace_id\", \"credential\".\"organization_id\") = 1" + }, + "credential_organization_type_check": { + "name": "credential_organization_type_check", + "value": "\"credential\".\"organization_id\" IS NULL OR \"credential\".\"type\" IN ('oauth', 'managed_oauth', 'managed_mcp', 'service_account', 'personal_token')" + }, + "credential_personal_token_source_check": { + "name": "credential_personal_token_source_check", + "value": "(type::text <> 'personal_token') OR (\n created_by IS NOT NULL\n AND provider_id IS NOT NULL\n AND provider_id = 'gitlab'\n AND provider_subject_id IS NOT NULL\n AND provider_tenant_id IS NOT NULL\n AND encrypted_personal_token IS NOT NULL\n AND granted_scopes IS NOT NULL\n AND cardinality(granted_scopes) > 0\n AND account_id IS NULL\n AND env_key IS NULL\n AND env_owner_user_id IS NULL\n AND authorization_app_id IS NULL\n AND encrypted_oauth_token_set IS NULL\n AND encrypted_service_account_key IS NULL\n AND unredacted = false\n )" + }, + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_managed_oauth_source_check": { + "name": "credential_managed_oauth_source_check", + "value": "(type::text <> 'managed_oauth') OR (\n account_id IS NULL\n AND provider_id IS NOT NULL\n AND authorization_app_id IS NOT NULL\n AND provider_subject_id IS NOT NULL\n AND managed_oauth_status IS NOT NULL\n AND granted_scopes IS NOT NULL\n AND encrypted_oauth_token_set IS NOT NULL\n AND granted_at IS NOT NULL\n )" + }, + "credential_managed_oauth_group_binding_check": { + "name": "credential_managed_oauth_group_binding_check", + "value": "(type::text <> 'managed_oauth') OR (\n credential_group_enrollment_id IS NOT NULL\n AND credential_group_option_id IS NOT NULL\n AND managed_oauth_scope_version IS NOT NULL\n AND managed_oauth_scope_version > 0\n )" + }, + "credential_managed_mcp_source_check": { + "name": "credential_managed_mcp_source_check", + "value": "(type::text <> 'managed_mcp') OR (\n id LIKE 'mcp-cg-%'\n AND account_id IS NULL\n AND provider_id IS NULL\n AND authorization_app_id IS NULL\n AND credential_group_enrollment_id IS NOT NULL\n AND credential_group_option_id IS NULL\n AND mcp_server_id IS NOT NULL\n AND managed_oauth_status IS NOT NULL\n AND (managed_oauth_status <> 'active' OR (\n encrypted_oauth_token_set IS NOT NULL\n AND mcp_tools IS NOT NULL\n ))\n AND granted_at IS NOT NULL\n AND managed_oauth_scope_version IS NULL\n AND provider_subject_id IS NULL\n AND provider_tenant_id IS NULL\n AND granted_scopes IS NULL\n AND provider_metadata IS NULL\n AND created_by IS NULL\n AND env_key IS NULL\n AND env_owner_user_id IS NULL\n AND encrypted_service_account_key IS NULL\n AND unredacted = false\n )" + }, + "credential_creator_source_check": { + "name": "credential_creator_source_check", + "value": "(type::text = 'managed_mcp') OR created_by IS NOT NULL" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_group": { + "name": "credential_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_id": { + "name": "public_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "encrypted_provider_configuration": { + "name": "encrypted_provider_configuration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "credential_group_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_group_organization_id_idx": { + "name": "credential_group_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_organization_unique": { + "name": "credential_group_organization_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_public_id_unique": { + "name": "credential_group_public_id_unique", + "columns": [ + { + "expression": "public_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_workspace_unique": { + "name": "credential_group_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_group_workspace_id_workspace_id_fk": { + "name": "credential_group_workspace_id_workspace_id_fk", + "tableFrom": "credential_group", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_organization_id_organization_id_fk": { + "name": "credential_group_organization_id_organization_id_fk", + "tableFrom": "credential_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_created_by_user_id_fk": { + "name": "credential_group_created_by_user_id_fk", + "tableFrom": "credential_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_group_owner_check": { + "name": "credential_group_owner_check", + "value": "num_nonnulls(\"credential_group\".\"workspace_id\", \"credential_group\".\"organization_id\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.credential_group_enrollment": { + "name": "credential_group_enrollment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_group_id": { + "name": "credential_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "credential_group_enrollment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'invited'" + }, + "invitation_token_hash": { + "name": "invitation_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invitation_expires_at": { + "name": "invitation_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "invited_at": { + "name": "invited_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_delivery_error": { + "name": "last_delivery_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_group_enrollment_group_user_unique": { + "name": "credential_group_enrollment_group_user_unique", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential_group_enrollment\".\"user_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_user_id_idx": { + "name": "credential_group_enrollment_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_group_email_unique": { + "name": "credential_group_enrollment_group_email_unique", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_invitation_token_hash_unique": { + "name": "credential_group_enrollment_invitation_token_hash_unique", + "columns": [ + { + "expression": "invitation_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_group_status_idx": { + "name": "credential_group_enrollment_group_status_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_group_invited_at_id_idx": { + "name": "credential_group_enrollment_group_invited_at_id_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invited_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_group_enrollment_credential_group_id_credential_group_id_fk": { + "name": "credential_group_enrollment_credential_group_id_credential_group_id_fk", + "tableFrom": "credential_group_enrollment", + "tableTo": "credential_group", + "columnsFrom": ["credential_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_enrollment_user_id_user_id_fk": { + "name": "credential_group_enrollment_user_id_user_id_fk", + "tableFrom": "credential_group_enrollment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_enrollment_created_by_user_id_fk": { + "name": "credential_group_enrollment_created_by_user_id_fk", + "tableFrom": "credential_group_enrollment", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_group_enrollment_normalized_email_check": { + "name": "credential_group_enrollment_normalized_email_check", + "value": "\"credential_group_enrollment\".\"email\" = lower(btrim(\"credential_group_enrollment\".\"email\")) AND length(\"credential_group_enrollment\".\"email\") BETWEEN 3 AND 320" + }, + "credential_group_enrollment_invitation_token_hash_length_check": { + "name": "credential_group_enrollment_invitation_token_hash_length_check", + "value": "length(\"credential_group_enrollment\".\"invitation_token_hash\") = 64" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_block": { + "name": "custom_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "trace_child_runs": { + "name": "trace_child_runs", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_block_organization_id_idx": { + "name": "custom_block_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_workflow_id_idx": { + "name": "custom_block_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_organization_type_unique": { + "name": "custom_block_organization_type_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_block_organization_id_organization_id_fk": { + "name": "custom_block_organization_id_organization_id_fk", + "tableFrom": "custom_block", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_workflow_id_workflow_id_fk": { + "name": "custom_block_workflow_id_workflow_id_fk", + "tableFrom": "custom_block", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_created_by_user_id_fk": { + "name": "custom_block_created_by_user_id_fk", + "tableFrom": "custom_block", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_attempts": { + "name": "processing_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_queued_at": { + "name": "processing_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_queue_token": { + "name": "processing_queue_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_deferred_until": { + "name": "processing_deferred_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acl": { + "name": "acl", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{ws}'::text[]" + }, + "acl_requirements": { + "name": "acl_requirements", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "acl_verified_at": { + "name": "acl_verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_modified_at": { + "name": "source_modified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_seen_at": { + "name": "source_seen_at", + "type": "timestamp", + "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_acl_gin_idx": { + "name": "doc_acl_gin_idx", + "columns": [ + { + "expression": "acl", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "array_ops" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "gin", + "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_source_lookup_idx": { + "name": "doc_connector_source_lookup_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_reconciliation_idx": { + "name": "doc_connector_reconciliation_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "COALESCE(\"source_seen_at\", '-infinity'::timestamp)", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"user_excluded\" = false AND \"document\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_active_kb_token_count_idx": { + "name": "doc_active_kb_token_count_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "token_count", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"user_excluded\" = false AND \"document\".\"archived_at\" IS NULL AND \"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag1_lower_idx": { + "name": "doc_kb_tag1_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag1\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag2_lower_idx": { + "name": "doc_kb_tag2_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag2\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag3_lower_idx": { + "name": "doc_kb_tag3_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag3\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag4_lower_idx": { + "name": "doc_kb_tag4_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag4\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag5_lower_idx": { + "name": "doc_kb_tag5_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag5\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag6_lower_idx": { + "name": "doc_kb_tag6_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag6\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag7_lower_idx": { + "name": "doc_kb_tag7_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag7\")", + "asc": true, + "isExpression": 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": { + "doc_acl_token_shape_check": { + "name": "doc_acl_token_shape_check", + "value": "array_position(\"document\".\"acl\", NULL) IS NULL AND (cardinality(\"document\".\"acl\") = 0 OR (cardinality(\"document\".\"acl\") = array_length(string_to_array(array_to_string(\"document\".\"acl\", E'\\n'), E'\\n'), 1) AND array_to_string(\"document\".\"acl\", E'\\n') ~ '^((ws|pub|link|u:[^\\nA-Z]+@[^\\nA-Z]+|[gs]:[^\\n:]+:[^\\n:]+:[^\\n]+)(\\n(ws|pub|link|u:[^\\nA-Z]+@[^\\nA-Z]+|[gs]:[^\\n:]+:[^\\n:]+:[^\\n]+))*)$'))" + } + }, + "isRLSEnabled": false + }, + "public.document_secret_provenance": { + "name": "document_secret_provenance", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "document_secret_provenance_document_id_document_id_fk": { + "name": "document_secret_provenance_document_id_document_id_fk", + "tableFrom": "document_secret_provenance", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "document_secret_provenance_status_check": { + "name": "document_secret_provenance_status_check", + "value": "\"document_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_384": { + "name": "embedding_384", + "type": "vector(384)", + "primaryKey": false, + "notNull": false + }, + "embedding_768": { + "name": "embedding_768", + "type": "vector(768)", + "primaryKey": false, + "notNull": false + }, + "embedding_1024": { + "name": "embedding_1024", + "type": "vector(1024)", + "primaryKey": false, + "notNull": false + }, + "embedding_3072": { + "name": "embedding_3072", + "type": "vector(3072)", + "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 + } + }, + "embedding_384_vector_hnsw_idx": { + "name": "embedding_384_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding_384", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_768_vector_hnsw_idx": { + "name": "embedding_768_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding_768", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_1024_vector_hnsw_idx": { + "name": "embedding_1024_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding_1024", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_3072_vector_hnsw_idx": { + "name": "embedding_3072_vector_hnsw_idx", + "columns": [ + { + "expression": "(\"embedding_3072\"::halfvec(3072)) halfvec_cosine_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_kb_tag1_lower_idx": { + "name": "emb_kb_tag1_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag1\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_tag2_lower_idx": { + "name": "emb_kb_tag2_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag2\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_tag3_lower_idx": { + "name": "emb_kb_tag3_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag3\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_tag4_lower_idx": { + "name": "emb_kb_tag4_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag4\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_tag5_lower_idx": { + "name": "emb_kb_tag5_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag5\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_tag6_lower_idx": { + "name": "emb_kb_tag6_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag6\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_tag7_lower_idx": { + "name": "emb_kb_tag7_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag7\")", + "asc": true, + "isExpression": 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_width_check": { + "name": "embedding_width_check", + "value": "num_nonnulls(\"embedding\", \"embedding_384\", \"embedding_768\", \"embedding_1024\", \"embedding_3072\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.embedding_secret_provenance": { + "name": "embedding_secret_provenance", + "schema": "", + "columns": { + "embedding_id": { + "name": "embedding_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "embedding_secret_provenance_embedding_id_embedding_id_fk": { + "name": "embedding_secret_provenance_embedding_id_embedding_id_fk", + "tableFrom": "embedding_secret_provenance", + "tableTo": "embedding", + "columnsFrom": ["embedding_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_secret_provenance_status_check": { + "name": "embedding_secret_provenance_status_check", + "value": "\"embedding_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_references_workflow_id_idx": { + "name": "execution_large_value_references_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": ["key", "execution_id", "source"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_workflow_id_idx": { + "name": "execution_large_values_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.folder": { + "name": "folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "folder_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "folder_user_idx": { + "name": "folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_idx": { + "name": "folder_workspace_resource_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_parent_sort_idx": { + "name": "folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_deleted_at_idx": { + "name": "folder_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_deleted_partial_idx": { + "name": "folder_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"folder\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_name_active_unique": { + "name": "folder_workspace_resource_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"folder\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "folder_user_id_user_id_fk": { + "name": "folder_user_id_user_id_fk", + "tableFrom": "folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_workspace_id_workspace_id_fk": { + "name": "folder_workspace_id_workspace_id_fk", + "tableFrom": "folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_parent_id_folder_id_fk": { + "name": "folder_parent_id_folder_id_fk", + "tableFrom": "folder", + "tableTo": "folder", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "invitation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "membership_intent": { + "name": "membership_intent", + "type": "invitation_membership_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_status_idx": { + "name": "invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_pending_email_org_unique": { + "name": "invitation_pending_email_org_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"invitation\".\"status\" = 'pending' AND \"invitation\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitation_token_unique": { + "name": "invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_workspace_grant": { + "name": "invitation_workspace_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_workspace_grant_unique": { + "name": "invitation_workspace_grant_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_workspace_grant_workspace_id_idx": { + "name": "invitation_workspace_grant_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_grant_invitation_id_invitation_id_fk": { + "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "invitation", + "columnsFrom": ["invitation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_workspace_grant_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_ended_at_id_idx": { + "name": "job_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workflow_schedule", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_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 + }, + "is_search_index": { + "name": "is_search_index", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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_organization_id_idx": { + "name": "kb_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_organization_search_index_unique": { + "name": "kb_organization_search_index_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"is_search_index\" = true AND \"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_organization_name_active_unique": { + "name": "kb_organization_name_active_unique", + "columns": [ + { + "expression": "organization_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": {} + }, + "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": {} + }, + "kb_workspace_search_index_unique": { + "name": "kb_workspace_search_index_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"is_search_index\" = true AND \"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_organization_id_organization_id_fk": { + "name": "knowledge_base_organization_id_organization_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "organization", + "columnsFrom": ["organization_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": { + "kb_owner_check": { + "name": "kb_owner_check", + "value": "num_nonnulls(\"knowledge_base\".\"workspace_id\", \"knowledge_base\".\"organization_id\") <= 1" + }, + "kb_organization_folder_check": { + "name": "kb_organization_folder_check", + "value": "\"knowledge_base\".\"organization_id\" IS NULL OR \"knowledge_base\".\"folder_id\" IS NULL" + } + }, + "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 + }, + "access_mode": { + "name": "access_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workspace'" + }, + "credential_group_id": { + "name": "credential_group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_option_id": { + "name": "credential_group_option_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_sync_status": { + "name": "member_sync_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "member_sync_lock_token": { + "name": "member_sync_lock_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_sync_lock_lease_at": { + "name": "member_sync_lock_lease_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_member_sync_at": { + "name": "next_member_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_member_sync_at": { + "name": "last_member_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_member_sync_error": { + "name": "last_member_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_sync_consecutive_failures": { + "name": "member_sync_consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "access_rewrite_pending": { + "name": "access_rewrite_pending", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "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 + }, + "listing_checkpoint": { + "name": "listing_checkpoint", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "directory_checkpoint": { + "name": "directory_checkpoint", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_directory_sync_at": { + "name": "next_directory_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sync_lock_token": { + "name": "sync_lock_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_lock_lease_at": { + "name": "sync_lock_lease_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_member_sync_due_idx": { + "name": "kc_member_sync_due_idx", + "columns": [ + { + "expression": "member_sync_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_member_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"access_mode\" = 'members' AND \"knowledge_connector\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_directory_sync_due_idx": { + "name": "kc_directory_sync_due_idx", + "columns": [ + { + "expression": "next_directory_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"access_mode\" = 'admin' AND \"knowledge_connector\".\"deleted_at\" IS 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" + }, + "knowledge_connector_credential_group_id_credential_group_id_fk": { + "name": "knowledge_connector_credential_group_id_credential_group_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "credential_group", + "columnsFrom": ["credential_group_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kc_access_mode_check": { + "name": "kc_access_mode_check", + "value": "\"knowledge_connector\".\"access_mode\" IN ('workspace', 'members', 'admin')" + }, + "kc_member_sync_status_check": { + "name": "kc_member_sync_status_check", + "value": "\"knowledge_connector\".\"member_sync_status\" IN ('idle', 'pending', 'running', 'error', 'disabled')" + }, + "kc_sync_lock_exclusive_check": { + "name": "kc_sync_lock_exclusive_check", + "value": "NOT (\"knowledge_connector\".\"sync_lock_token\" IS NOT NULL AND \"knowledge_connector\".\"member_sync_lock_token\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_connector_member": { + "name": "knowledge_connector_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_token": { + "name": "subject_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_started_at": { + "name": "last_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_complete_listing_at": { + "name": "last_complete_listing_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_listed_count": { + "name": "last_listed_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_synced_through": { + "name": "member_synced_through", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "change_cursor": { + "name": "change_cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "listing_checkpoint": { + "name": "listing_checkpoint", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "suspended_at": { + "name": "suspended_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": { + "kcm_organization_id_idx": { + "name": "kcm_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcm_connector_credential_unique": { + "name": "kcm_connector_credential_unique", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcm_connector_queue_idx": { + "name": "kcm_connector_queue_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "first" + }, + { + "expression": "last_started_at", + "isExpression": false, + "asc": true, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcm_credential_idx": { + "name": "kcm_credential_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_member_workspace_id_workspace_id_fk": { + "name": "knowledge_connector_member_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_connector_member", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_connector_member_organization_id_organization_id_fk": { + "name": "knowledge_connector_member_organization_id_organization_id_fk", + "tableFrom": "knowledge_connector_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_connector_member_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_member_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_member", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_connector_member_credential_id_credential_id_fk": { + "name": "knowledge_connector_member_credential_id_credential_id_fk", + "tableFrom": "knowledge_connector_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kcm_owner_check": { + "name": "kcm_owner_check", + "value": "num_nonnulls(\"knowledge_connector_member\".\"workspace_id\", \"knowledge_connector_member\".\"organization_id\") = 1" + }, + "kcm_status_check": { + "name": "kcm_status_check", + "value": "\"knowledge_connector_member\".\"status\" IN ('active', 'suspended', 'disabled')" + }, + "kcm_subject_token_shape_check": { + "name": "kcm_subject_token_shape_check", + "value": "\"knowledge_connector_member\".\"subject_token\" ~ '^s:[^:]+:[^:]+:.+$'" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_connector_member_sync_log": { + "name": "knowledge_connector_member_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 + }, + "members_claimed": { + "name": "members_claimed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "members_completed": { + "name": "members_completed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "members_incomplete": { + "name": "members_incomplete", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "members_failed": { + "name": "members_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_listed": { + "name": "docs_listed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_hydrated_once": { + "name": "docs_hydrated_once", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "observations_added": { + "name": "observations_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "observations_removed": { + "name": "observations_removed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_tombstoned": { + "name": "docs_tombstoned", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_resurrected": { + "name": "docs_resurrected", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_purged": { + "name": "docs_purged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "credentials_audited": { + "name": "credentials_audited", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcmsl_connector_started_at_idx": { + "name": "kcmsl_connector_started_at_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcmsl_started_at_partial_idx": { + "name": "kcmsl_started_at_partial_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector_member_sync_log\".\"status\" = 'started'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_member_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_member_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_member_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kcmsl_status_check": { + "name": "kcmsl_status_check", + "value": "\"knowledge_connector_member_sync_log\".\"status\" IN ('started', 'partial', 'completed', 'failed')" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_skipped": { + "name": "docs_skipped", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "listed_count": { + "name": "listed_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_started_at_idx": { + "name": "kcsl_connector_started_at_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcsl_started_at_partial_idx": { + "name": "kcsl_started_at_partial_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector_sync_log\".\"status\" = 'started'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_document_observation": { + "name": "knowledge_document_observation", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "kdo_member_idx": { + "name": "kdo_member_idx", + "columns": [ + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_document_observation_document_id_document_id_fk": { + "name": "knowledge_document_observation_document_id_document_id_fk", + "tableFrom": "knowledge_document_observation", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_document_observation_member_id_knowledge_connector_member_id_fk": { + "name": "knowledge_document_observation_member_id_knowledge_connector_member_id_fk", + "tableFrom": "knowledge_document_observation", + "tableTo": "knowledge_connector_member", + "columnsFrom": ["member_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "knowledge_document_observation_document_id_member_id_pk": { + "name": "knowledge_document_observation_document_id_member_id_pk", + "columns": ["document_id", "member_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_external_directory": { + "name": "knowledge_external_directory", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sync_lock_token": { + "name": "sync_lock_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_lock_lease_at": { + "name": "sync_lock_lease_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_started_at": { + "name": "last_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_complete_sync_at": { + "name": "last_complete_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "ked_organization_id_idx": { + "name": "ked_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ked_workspace_identity_unique": { + "name": "ked_workspace_identity_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ked_organization_identity_unique": { + "name": "ked_organization_identity_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_external_directory_workspace_id_workspace_id_fk": { + "name": "knowledge_external_directory_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_external_directory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_external_directory_organization_id_organization_id_fk": { + "name": "knowledge_external_directory_organization_id_organization_id_fk", + "tableFrom": "knowledge_external_directory", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ked_owner_check": { + "name": "ked_owner_check", + "value": "num_nonnulls(\"knowledge_external_directory\".\"workspace_id\", \"knowledge_external_directory\".\"organization_id\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_external_group": { + "name": "knowledge_external_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_group_id": { + "name": "external_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_synced_at": { + "name": "last_synced_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": { + "keg_organization_id_idx": { + "name": "keg_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "keg_organization_identity_unique": { + "name": "keg_organization_identity_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "keg_organization_synced_idx": { + "name": "keg_organization_synced_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_synced_at", + "isExpression": false, + "asc": true, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "keg_identity_unique": { + "name": "keg_identity_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "keg_workspace_synced_idx": { + "name": "keg_workspace_synced_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_synced_at", + "isExpression": false, + "asc": true, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_external_group_organization_id_organization_id_fk": { + "name": "knowledge_external_group_organization_id_organization_id_fk", + "tableFrom": "knowledge_external_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "keg_workspace_fk": { + "name": "keg_workspace_fk", + "tableFrom": "knowledge_external_group", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "keg_owner_check": { + "name": "keg_owner_check", + "value": "num_nonnulls(\"knowledge_external_group\".\"workspace_id\", \"knowledge_external_group\".\"organization_id\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_external_group_member": { + "name": "knowledge_external_group_member", + "schema": "", + "columns": { + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_token": { + "name": "subject_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kegm_subject_token_idx": { + "name": "kegm_subject_token_idx", + "columns": [ + { + "expression": "subject_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kegm_group_fk": { + "name": "kegm_group_fk", + "tableFrom": "knowledge_external_group_member", + "tableTo": "knowledge_external_group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "knowledge_external_group_member_group_id_subject_token_pk": { + "name": "knowledge_external_group_member_group_id_subject_token_pk", + "columns": ["group_id", "subject_token"] + } + }, + "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": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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" + }, + "mcp_server_oauth_organization_id_organization_id_fk": { + "name": "mcp_server_oauth_organization_id_organization_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_server_oauth_owner_check": { + "name": "mcp_server_oauth_owner_check", + "value": "num_nonnulls(\"mcp_server_oauth\".\"workspace_id\", \"mcp_server_oauth\".\"organization_id\") = 1" + } + }, + "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": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_id": { + "name": "credential_group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_connector_id": { + "name": "managed_connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_config_version": { + "name": "oauth_config_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "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_organization_id_idx": { + "name": "mcp_servers_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_credential_group_idx": { + "name": "mcp_servers_credential_group_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_credential_group_managed_connector_unique": { + "name": "mcp_servers_credential_group_managed_connector_unique", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "managed_connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"mcp_servers\".\"credential_group_id\" IS NOT NULL AND \"mcp_servers\".\"managed_connector_id\" IS NOT NULL AND \"mcp_servers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_organization_id_organization_id_fk": { + "name": "mcp_servers_organization_id_organization_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_credential_group_id_credential_group_id_fk": { + "name": "mcp_servers_credential_group_id_credential_group_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "credential_group", + "columnsFrom": ["credential_group_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_servers_owner_check": { + "name": "mcp_servers_owner_check", + "value": "num_nonnulls(\"mcp_servers\".\"workspace_id\", \"mcp_servers\".\"organization_id\") = 1" + }, + "mcp_servers_organization_managed_check": { + "name": "mcp_servers_organization_managed_check", + "value": "\"mcp_servers\".\"organization_id\" IS NULL OR \"mcp_servers\".\"credential_group_id\" IS NOT NULL" + }, + "mcp_servers_credential_group_managed_connector_check": { + "name": "mcp_servers_credential_group_managed_connector_check", + "value": "\"mcp_servers\".\"credential_group_id\" IS NULL OR \"mcp_servers\".\"managed_connector_id\" IS NOT NULL" + }, + "mcp_servers_managed_connector_oauth_check": { + "name": "mcp_servers_managed_connector_oauth_check", + "value": "\"mcp_servers\".\"managed_connector_id\" IS NULL OR \"mcp_servers\".\"auth_type\" = 'oauth'" + } + }, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory_secret_provenance": { + "name": "memory_secret_provenance", + "schema": "", + "columns": { + "memory_id": { + "name": "memory_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "memory_secret_provenance_memory_id_memory_id_fk": { + "name": "memory_secret_provenance_memory_id_memory_id_fk", + "tableFrom": "memory_secret_provenance", + "tableTo": "memory", + "columnsFrom": ["memory_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "memory_secret_provenance_status_check": { + "name": "memory_secret_provenance_status_check", + "value": "\"memory_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_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 + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_access_token_client_id_idx": { + "name": "oauth_access_token_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_session_id_idx": { + "name": "oauth_access_token_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_refresh_id_idx": { + "name": "oauth_access_token_refresh_id_idx", + "columns": [ + { + "expression": "refresh_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_user_client_idx": { + "name": "oauth_access_token_user_client_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_expires_at_idx": { + "name": "oauth_access_token_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": ["refresh_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauth_client_user_id_idx": { + "name": "oauth_client_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "nullsNotDistinct": false, + "columns": ["client_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "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 + } + }, + "indexes": { + "oauth_consent_client_id_idx": { + "name": "oauth_consent_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_consent_user_client_reference_unique": { + "name": "oauth_consent_user_client_reference_unique", + "nullsNotDistinct": true, + "columns": ["user_id", "client_id", "reference_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_refresh_token": { + "name": "oauth_refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_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 + }, + "revoked": { + "name": "revoked", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "family_id": { + "name": "family_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_refresh_token_client_id_idx": { + "name": "oauth_refresh_token_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_token_session_id_idx": { + "name": "oauth_refresh_token_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_token_user_client_idx": { + "name": "oauth_refresh_token_user_client_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_token_expires_at_idx": { + "name": "oauth_refresh_token_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_family_id_oauth_token_family_id_fk": { + "name": "oauth_refresh_token_family_id_oauth_token_family_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_token_family", + "columnsFrom": ["family_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_refresh_token_token_unique": { + "name": "oauth_refresh_token_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + }, + "oauth_refresh_token_family_generation_unique": { + "name": "oauth_refresh_token_family_generation_unique", + "nullsNotDistinct": false, + "columns": ["family_id", "generation"] + } + }, + "policies": {}, + "checkConstraints": { + "oauth_refresh_token_generation_check": { + "name": "oauth_refresh_token_generation_check", + "value": "\"oauth_refresh_token\".\"generation\" BETWEEN 0 AND 1000" + } + }, + "isRLSEnabled": false + }, + "public.oauth_token_family": { + "name": "oauth_token_family", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consent_id": { + "name": "consent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_generation": { + "name": "current_generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_token_family_client_id_idx": { + "name": "oauth_token_family_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_token_family_session_id_idx": { + "name": "oauth_token_family_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_token_family_user_client_idx": { + "name": "oauth_token_family_user_client_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_token_family_consent_id_idx": { + "name": "oauth_token_family_consent_id_idx", + "columns": [ + { + "expression": "consent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_token_family_expires_at_idx": { + "name": "oauth_token_family_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_token_family_client_id_oauth_client_client_id_fk": { + "name": "oauth_token_family_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_token_family", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_token_family_session_id_session_id_fk": { + "name": "oauth_token_family_session_id_session_id_fk", + "tableFrom": "oauth_token_family", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_token_family_user_id_user_id_fk": { + "name": "oauth_token_family_user_id_user_id_fk", + "tableFrom": "oauth_token_family", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_token_family_consent_id_oauth_consent_id_fk": { + "name": "oauth_token_family_consent_id_oauth_consent_id_fk", + "tableFrom": "oauth_token_family", + "tableTo": "oauth_consent", + "columnsFrom": ["consent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "oauth_token_family_generation_check": { + "name": "oauth_token_family_generation_check", + "value": "\"oauth_token_family\".\"current_generation\" BETWEEN 0 AND 1000" + } + }, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "session_policy_settings": { + "name": "session_policy_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "security_policy_version": { + "name": "security_policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_byok_keys": { + "name": "organization_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_byok_organization_provider_idx": { + "name": "organization_byok_organization_provider_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_byok_keys_organization_id_organization_id_fk": { + "name": "organization_byok_keys_organization_id_organization_id_fk", + "tableFrom": "organization_byok_keys", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_byok_keys_created_by_user_id_fk": { + "name": "organization_byok_keys_created_by_user_id_fk", + "tableFrom": "organization_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_member_usage_limit": { + "name": "organization_member_usage_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_member_usage_limit_org_user_unique": { + "name": "org_member_usage_limit_org_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_member_usage_limit_organization_id_idx": { + "name": "org_member_usage_limit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_member_usage_limit_organization_id_organization_id_fk": { + "name": "organization_member_usage_limit_organization_id_organization_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["set_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_search_integration": { + "name": "organization_search_integration", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approved": { + "name": "approved", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "organization_search_integration_organization_id_organization_id_fk": { + "name": "organization_search_integration_organization_id_organization_id_fk", + "tableFrom": "organization_search_integration", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "organization_search_integration_organization_id_connector_type_pk": { + "name": "organization_search_integration_organization_id_connector_type_pk", + "columns": ["organization_id", "connector_type"] + } + }, + "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": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "oauth_config": { + "name": "oauth_config", + "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_organization_id_idx": { + "name": "pending_draft_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_draft_user_provider_org": { + "name": "pending_draft_user_provider_org", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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_organization_id_organization_id_fk": { + "name": "pending_credential_draft_organization_id_organization_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "organization", + "columnsFrom": ["organization_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": { + "pending_draft_owner_check": { + "name": "pending_draft_owner_check", + "value": "num_nonnulls(\"pending_credential_draft\".\"workspace_id\", \"pending_credential_draft\".\"organization_id\") = 1" + } + }, + "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 + }, + "membership_mode": { + "name": "membership_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'inherit'" + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_name_unique": { + "name": "permission_group_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_workspace": { + "name": "permission_group_workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pinned_item": { + "name": "pinned_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pinned_item_user_workspace_idx": { + "name": "pinned_item_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_resource_idx": { + "name": "pinned_item_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_user_resource_unique": { + "name": "pinned_item_user_resource_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pinned_item_user_id_user_id_fk": { + "name": "pinned_item_user_id_user_id_fk", + "tableFrom": "pinned_item", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pinned_item_workspace_id_workspace_id_fk": { + "name": "pinned_item_workspace_id_workspace_id_fk", + "tableFrom": "pinned_item", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.public_share": { + "name": "public_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "public_share_token_unique": { + "name": "public_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_unique": { + "name": "public_share_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "blocked_until": { + "name": "blocked_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resource_policy": { + "name": "resource_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "document": { + "name": "document", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "resource_policy_organization_id_idx": { + "name": "resource_policy_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resource_policy_resource_unique": { + "name": "resource_policy_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resource_policy_workspace_id_idx": { + "name": "resource_policy_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resource_policy_workspace_id_workspace_id_fk": { + "name": "resource_policy_workspace_id_workspace_id_fk", + "tableFrom": "resource_policy", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "resource_policy_organization_id_organization_id_fk": { + "name": "resource_policy_organization_id_organization_id_fk", + "tableFrom": "resource_policy", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "resource_policy_created_by_user_id_fk": { + "name": "resource_policy_created_by_user_id_fk", + "tableFrom": "resource_policy", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "resource_policy_updated_by_user_id_fk": { + "name": "resource_policy_updated_by_user_id_fk", + "tableFrom": "resource_policy", + "tableTo": "user", + "columnsFrom": ["updated_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "resource_policy_owner_check": { + "name": "resource_policy_owner_check", + "value": "num_nonnulls(\"resource_policy\".\"workspace_id\", \"resource_policy\".\"organization_id\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandbox_image": { + "name": "sandbox_image", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec": { + "name": "spec", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "sandbox_image_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "image_ref": { + "name": "image_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_image_id": { + "name": "provider_image_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_id": { + "name": "build_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "materialization_generation": { + "name": "materialization_generation", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_detail": { + "name": "error_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sandbox_image_provider_spec_unique": { + "name": "sandbox_image_provider_spec_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_status_idx": { + "name": "sandbox_image_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_last_used_idx": { + "name": "sandbox_image_last_used_idx", + "columns": [ + { + "expression": "last_used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_connection": { + "name": "scim_connection", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "last_request_at": { + "name": "last_request_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "reconcile_lock_token": { + "name": "reconcile_lock_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reconcile_lease_at": { + "name": "reconcile_lease_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "reconciled_at": { + "name": "reconciled_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": { + "scim_connection_organization_unique": { + "name": "scim_connection_organization_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_connection_reconcile_due_idx": { + "name": "scim_connection_reconcile_due_idx", + "columns": [ + { + "expression": "reconciled_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_connection_organization_id_organization_id_fk": { + "name": "scim_connection_organization_id_organization_id_fk", + "tableFrom": "scim_connection", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_connection_created_by_user_id_fk": { + "name": "scim_connection_created_by_user_id_fk", + "tableFrom": "scim_connection", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_credential": { + "name": "scim_credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_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()" + } + }, + "indexes": { + "scim_credential_token_hash_unique": { + "name": "scim_credential_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_credential_connection_idx": { + "name": "scim_credential_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_credential_connection_id_scim_connection_id_fk": { + "name": "scim_credential_connection_id_scim_connection_id_fk", + "tableFrom": "scim_credential", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_credential_revoked_by_user_id_fk": { + "name": "scim_credential_revoked_by_user_id_fk", + "tableFrom": "scim_credential", + "tableTo": "user", + "columnsFrom": ["revoked_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "scim_credential_created_by_user_id_fk": { + "name": "scim_credential_created_by_user_id_fk", + "tableFrom": "scim_credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_group": { + "name": "scim_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name_key": { + "name": "display_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "order_key": { + "name": "order_key", + "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": { + "scim_group_connection_display_name_unique": { + "name": "scim_group_connection_display_name_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_connection_external_id_unique": { + "name": "scim_group_connection_external_id_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "external_id is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_connection_order_idx": { + "name": "scim_group_connection_order_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_group_connection_id_scim_connection_id_fk": { + "name": "scim_group_connection_id_scim_connection_id_fk", + "tableFrom": "scim_group", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_group_mapping": { + "name": "scim_group_mapping", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_kind": { + "name": "target_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "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": { + "scim_group_mapping_group_idx": { + "name": "scim_group_mapping_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_mapping_permission_group_idx": { + "name": "scim_group_mapping_permission_group_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_mapping_workspace_idx": { + "name": "scim_group_mapping_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_mapping_group_target_unique": { + "name": "scim_group_mapping_group_target_unique", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"permission_group_id\", \"workspace_id\", \"role\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_group_mapping_group_id_scim_group_id_fk": { + "name": "scim_group_mapping_group_id_scim_group_id_fk", + "tableFrom": "scim_group_mapping", + "tableTo": "scim_group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_mapping_permission_group_id_permission_group_id_fk": { + "name": "scim_group_mapping_permission_group_id_permission_group_id_fk", + "tableFrom": "scim_group_mapping", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_mapping_workspace_id_workspace_id_fk": { + "name": "scim_group_mapping_workspace_id_workspace_id_fk", + "tableFrom": "scim_group_mapping", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_mapping_created_by_user_id_fk": { + "name": "scim_group_mapping_created_by_user_id_fk", + "tableFrom": "scim_group_mapping", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "scim_group_mapping_target_shape": { + "name": "scim_group_mapping_target_shape", + "value": "(\n (\"scim_group_mapping\".\"target_kind\" = 'permission_group' AND \"scim_group_mapping\".\"permission_group_id\" IS NOT NULL AND \"scim_group_mapping\".\"workspace_id\" IS NULL AND \"scim_group_mapping\".\"permission_type\" IS NULL AND \"scim_group_mapping\".\"role\" IS NULL)\n OR (\"scim_group_mapping\".\"target_kind\" = 'workspace' AND \"scim_group_mapping\".\"workspace_id\" IS NOT NULL AND \"scim_group_mapping\".\"permission_type\" IS NOT NULL AND \"scim_group_mapping\".\"permission_group_id\" IS NULL AND \"scim_group_mapping\".\"role\" IS NULL)\n OR (\"scim_group_mapping\".\"target_kind\" = 'org_role' AND \"scim_group_mapping\".\"role\" IS NOT NULL AND \"scim_group_mapping\".\"permission_group_id\" IS NULL AND \"scim_group_mapping\".\"workspace_id\" IS NULL AND \"scim_group_mapping\".\"permission_type\" IS NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.scim_group_member": { + "name": "scim_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_group_member_group_user_unique": { + "name": "scim_group_member_group_user_unique", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_member_scim_user_idx": { + "name": "scim_group_member_scim_user_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_group_member_group_id_scim_group_id_fk": { + "name": "scim_group_member_group_id_scim_group_id_fk", + "tableFrom": "scim_group_member", + "tableTo": "scim_group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_member_scim_user_id_scim_user_id_fk": { + "name": "scim_group_member_scim_user_id_scim_user_id_fk", + "tableFrom": "scim_group_member", + "tableTo": "scim_user", + "columnsFrom": ["scim_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_projection_grant": { + "name": "scim_projection_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_kind": { + "name": "target_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'directory'" + }, + "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": { + "scim_projection_grant_user_target_unique": { + "name": "scim_projection_grant_user_target_unique", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grant_connection_idx": { + "name": "scim_projection_grant_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_projection_grant_connection_id_scim_connection_id_fk": { + "name": "scim_projection_grant_connection_id_scim_connection_id_fk", + "tableFrom": "scim_projection_grant", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_projection_grant_scim_user_id_scim_user_id_fk": { + "name": "scim_projection_grant_scim_user_id_scim_user_id_fk", + "tableFrom": "scim_projection_grant", + "tableTo": "scim_user", + "columnsFrom": ["scim_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_request_log": { + "name": "scim_request_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scim_type": { + "name": "scim_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_request_log_connection_created_idx": { + "name": "scim_request_log_connection_created_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_request_log_connection_id_scim_connection_id_fk": { + "name": "scim_request_log_connection_id_scim_connection_id_fk", + "tableFrom": "scim_request_log", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_user": { + "name": "scim_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "attributes": { + "name": "attributes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "order_key": { + "name": "order_key", + "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": { + "scim_user_connection_user_unique": { + "name": "scim_user_connection_user_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_connection_user_name_unique": { + "name": "scim_user_connection_user_name_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_connection_external_id_unique": { + "name": "scim_user_connection_external_id_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "external_id is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_connection_order_idx": { + "name": "scim_user_connection_order_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_user_idx": { + "name": "scim_user_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_user_connection_id_scim_connection_id_fk": { + "name": "scim_user_connection_id_scim_connection_id_fk", + "tableFrom": "scim_user", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_user_user_id_user_id_fk": { + "name": "scim_user_user_id_user_id_fk", + "tableFrom": "scim_user", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_user_tombstone": { + "name": "scim_user_tombstone", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_user_tombstone_connection_external_id_unique": { + "name": "scim_user_tombstone_connection_external_id_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_tombstone_user_idx": { + "name": "scim_user_tombstone_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_user_tombstone_connection_id_scim_connection_id_fk": { + "name": "scim_user_tombstone_connection_id_scim_connection_id_fk", + "tableFrom": "scim_user_tombstone", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_user_tombstone_user_id_user_id_fk": { + "name": "scim_user_tombstone_user_id_user_id_fk", + "tableFrom": "scim_user_tombstone", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.secret_usage": { + "name": "secret_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_name": { + "name": "secret_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_scope": { + "name": "secret_scope", + "type": "secret_usage_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "secret_owner_user_id": { + "name": "secret_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "source": { + "name": "source", + "type": "secret_usage_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "usage_date": { + "name": "usage_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "use_count": { + "name": "use_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_execution_id": { + "name": "last_execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_trigger": { + "name": "last_trigger", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "secret_usage_bucket_unique": { + "name": "secret_usage_bucket_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "usage_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "secret_usage_secret_recent_idx": { + "name": "secret_usage_secret_recent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_used_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "secret_usage_workspace_id_workspace_id_fk": { + "name": "secret_usage_workspace_id_workspace_id_fk", + "tableFrom": "secret_usage", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auto_focus_on_click": { + "name": "auto_focus_on_click", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_member": { + "name": "skill_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_member_user_id_idx": { + "name": "skill_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skill_member_unique": { + "name": "skill_member_unique", + "columns": [ + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_member_skill_id_skill_id_fk": { + "name": "skill_member_skill_id_skill_id_fk", + "tableFrom": "skill_member", + "tableTo": "skill", + "columnsFrom": ["skill_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_user_id_user_id_fk": { + "name": "skill_member_user_id_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_invited_by_user_id_fk": { + "name": "skill_member_invited_by_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_domain": { + "name": "sso_domain", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sso_domain_organization_id_idx": { + "name": "sso_domain_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_domain_idx": { + "name": "sso_domain_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_org_domain_unique": { + "name": "sso_domain_org_domain_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_verified_unique": { + "name": "sso_domain_verified_unique", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'verified'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_domain_organization_id_organization_id_fk": { + "name": "sso_domain_organization_id_organization_id_fk", + "tableFrom": "sso_domain", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_domain_created_by_user_id_fk": { + "name": "sso_domain_created_by_user_id_fk", + "tableFrom": "sso_domain", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain_verified": { + "name": "domain_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "jit_provisioning_enabled": { + "name": "jit_provisioning_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "sso_provider_provider_id_unique": { + "name": "sso_provider_provider_id_unique", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "last_closed_period_start": { + "name": "last_closed_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscription_cycle_close_lagging_idx": { + "name": "subscription_cycle_close_lagging_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"subscription\".\"status\" in ('active', 'past_due') and \"subscription\".\"period_start\" is not null and (\"subscription\".\"last_closed_period_start\" is null or \"subscription\".\"last_closed_period_start\" < \"subscription\".\"period_start\")", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_jobs": { + "name": "table_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "capability_governed_user_id": { + "name": "capability_governed_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enrichment_details": { + "name": "enrichment_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_capability_governed_user_id_user_id_fk": { + "name": "table_row_executions_capability_governed_user_id_user_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user", + "columnsFrom": ["capability_governed_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capability_governed_user_id": { + "name": "capability_governed_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_governed_active_idx": { + "name": "table_run_dispatches_governed_active_idx", + "columns": [ + { + "expression": "capability_governed_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_run_dispatches\".\"status\" IN ('pending', 'dispatching')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "table_run_dispatches_capability_governed_user_id_user_id_fk": { + "name": "table_run_dispatches_capability_governed_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["capability_governed_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_views": { + "name": "table_views", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_views_table_created_idx": { + "name": "table_views_table_created_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_views_workspace_created_idx": { + "name": "table_views_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_views_table_default_unique": { + "name": "table_views_table_default_unique", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_views_table_id_user_table_definitions_id_fk": { + "name": "table_views_table_id_user_table_definitions_id_fk", + "tableFrom": "table_views", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_workspace_id_workspace_id_fk": { + "name": "table_views_workspace_id_workspace_id_fk", + "tableFrom": "table_views", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_created_by_user_id_fk": { + "name": "table_views_created_by_user_id_fk", + "tableFrom": "table_views", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.upload_session": { + "name": "upload_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "purpose": { + "name": "purpose", + "type": "upload_session_purpose", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "upload_session_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "storage_context": { + "name": "storage_context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "final_key": { + "name": "final_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_provider": { + "name": "storage_provider", + "type": "upload_session_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider_upload_id": { + "name": "provider_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_object_version": { + "name": "provider_object_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "part_size": { + "name": "part_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "part_count": { + "name": "part_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "upload_session_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'uploading'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "processing_lease_id": { + "name": "processing_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_lease_expires_at": { + "name": "processing_lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_file_id": { + "name": "completed_file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "upload_session_token_hash_unique": { + "name": "upload_session_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upload_session_final_key_unique": { + "name": "upload_session_final_key_unique", + "columns": [ + { + "expression": "final_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upload_session_status_expires_at_idx": { + "name": "upload_session_status_expires_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_period_cost_idx": { + "name": "usage_log_billing_period_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_created_at_cost_idx": { + "name": "usage_log_billing_entity_created_at_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "suspension_source": { + "name": "suspension_source", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_email_lower_idx": { + "name": "user_email_lower_idx", + "columns": [ + { + "expression": "lower(btrim(\"email\"))", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_version": { + "name": "rows_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "schema_locked": { + "name": "schema_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "insert_locked": { + "name": "insert_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "update_locked": { + "name": "update_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "delete_locked": { + "name": "delete_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_folder_id_idx": { + "name": "user_table_def_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_folder_id_folder_id_fk": { + "name": "user_table_definitions_folder_id_folder_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_row_secret_provenance": { + "name": "user_table_row_secret_provenance", + "schema": "", + "columns": { + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_table_row_secret_provenance_row_id_user_table_rows_id_fk": { + "name": "user_table_row_secret_provenance_row_id_user_table_rows_id_fk", + "tableFrom": "user_table_row_secret_provenance", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_table_row_secret_provenance_status_check": { + "name": "user_table_row_secret_provenance_status_check", + "value": "\"user_table_row_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_created_id_idx": { + "name": "user_table_rows_table_created_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_status": { + "name": "registration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_generation": { + "name": "registration_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config_fingerprint": { + "name": "config_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prepared_at": { + "name": "prepared_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_key": { + "name": "routing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_routing_key_active_idx": { + "name": "webhook_routing_key_active_idx", + "columns": [ + { + "expression": "routing_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NULL AND \"webhook\".\"routing_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_active_registration_unique": { + "name": "webhook_active_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'active' AND \"webhook\".\"block_id\" IS NOT NULL AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_candidate_registration_unique": { + "name": "webhook_candidate_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'candidate' AND \"webhook\".\"block_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_registration_status_generation_idx": { + "name": "webhook_registration_status_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_registration_status_check": { + "name": "webhook_registration_status_check", + "value": "\"webhook\".\"registration_status\" IS NULL OR \"webhook\".\"registration_status\" IN ('active', 'candidate', 'retired', 'orphaned')" + }, + "webhook_registration_generation_check": { + "name": "webhook_registration_generation_check", + "value": "\"webhook\".\"registration_generation\" IS NULL OR \"webhook\".\"registration_generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.webhook_path_claim": { + "name": "webhook_path_claim", + "schema": "", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_path_claim_workflow_idx": { + "name": "webhook_path_claim_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_path_claim_workflow_id_workflow_id_fk": { + "name": "webhook_path_claim_workflow_id_workflow_id_fk", + "tableFrom": "webhook_path_claim", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_path_claim_generation_check": { + "name": "webhook_path_claim_generation_check", + "value": "\"webhook_path_claim\".\"generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fork_sync_excluded": { + "name": "fork_sync_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_active_workspace_sort_idx": { + "name": "workflow_active_workspace_sort_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_folder_id_fk": { + "name": "workflow_folder_id_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "error_enabled": { + "name": "error_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "retry": { + "name": "retry", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_operation": { + "name": "workflow_deployment_operation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_active_version_id": { + "name": "previous_active_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preparing'" + }, + "component_readiness": { + "name": "component_readiness", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_deployment_operation_workflow_generation_unique": { + "name": "workflow_deployment_operation_workflow_generation_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_idempotency_unique": { + "name": "workflow_deployment_operation_workflow_idempotency_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_in_flight_unique": { + "name": "workflow_deployment_operation_workflow_in_flight_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_status_idx": { + "name": "workflow_deployment_operation_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_deployment_version_idx": { + "name": "workflow_deployment_operation_deployment_version_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_version_generation_idx": { + "name": "workflow_deployment_operation_workflow_version_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_operation_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_operation_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["previous_active_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_deployment_operation_action_check": { + "name": "workflow_deployment_operation_action_check", + "value": "\"workflow_deployment_operation\".\"action\" IN ('deploy', 'activate')" + }, + "workflow_deployment_operation_status_check": { + "name": "workflow_deployment_operation_status_check", + "value": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating', 'active', 'failed', 'superseded')" + }, + "workflow_deployment_operation_generation_check": { + "name": "workflow_deployment_operation_generation_check", + "value": "\"workflow_deployment_operation\".\"generation\" > 0" + }, + "workflow_deployment_operation_protocol_version_check": { + "name": "workflow_deployment_operation_protocol_version_check", + "value": "\"workflow_deployment_operation\".\"protocol_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "execution_deadline_at": { + "name": "execution_deadline_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_id_desc_idx": { + "name": "workflow_execution_logs_workspace_started_at_id_desc_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_deadline_idx": { + "name": "workflow_execution_logs_running_deadline_idx", + "columns": [ + { + "expression": "execution_deadline_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'running' AND \"workflow_execution_logs\".\"execution_deadline_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_redacting_started_at_idx": { + "name": "workflow_execution_logs_redacting_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'redacting'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_redacting_deadline_idx": { + "name": "workflow_execution_logs_redacting_deadline_idx", + "columns": [ + { + "expression": "execution_deadline_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'redacting' AND \"workflow_execution_logs\".\"execution_deadline_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_completed_ended_at_idx": { + "name": "workflow_execution_logs_completed_ended_at_idx", + "columns": [ + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'completed' AND \"workflow_execution_logs\".\"level\" = 'info' AND \"workflow_execution_logs\".\"ended_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_workspace_deleted_partial_idx": { + "name": "workflow_mcp_server_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "parameter_description_overrides": { + "name": "parameter_description_overrides", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_operation_id": { + "name": "deployment_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_scope": { + "name": "secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "mounted_secrets": { + "name": "mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "excluded_dates": { + "name": "excluded_dates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk": { + "name": "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_operation", + "columnsFrom": ["deployment_operation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_secret_scope": { + "name": "inbox_secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "inbox_mounted_secrets": { + "name": "inbox_mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "organization_assigned_at": { + "name": "organization_assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "forked_from_workspace_id": { + "name": "forked_from_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_forked_from_workspace_id_idx": { + "name": "workspace_forked_from_workspace_id_idx", + "columns": [ + { + "expression": "forked_from_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_inbox_provider_id_idx": { + "name": "workspace_inbox_provider_id_idx", + "columns": [ + { + "expression": "inbox_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace\".\"inbox_provider_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_forked_from_workspace_id_workspace_id_fk": { + "name": "workspace_forked_from_workspace_id_workspace_id_fk", + "tableFrom": "workspace", + "tableTo": "workspace", + "columnsFrom": ["forked_from_workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_storage_used_bytes_non_negative": { + "name": "workspace_storage_used_bytes_non_negative", + "value": "\"workspace\".\"storage_used_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_workspace_provider_idx": { + "name": "workspace_byok_workspace_provider_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_collab_state": { + "name": "workspace_file_collab_state", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "doc_state": { + "name": "doc_state", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_collab_state_file_id_workspace_files_id_fk": { + "name": "workspace_file_collab_state_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_collab_state", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_backfill": { + "name": "workspace_file_search_backfill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "after_workspace_id": { + "name": "after_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "after_file_id": { + "name": "after_file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_dispatch_queue": { + "name": "workspace_file_search_dispatch_queue", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enqueued_at": { + "name": "enqueued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_dispatched_at": { + "name": "last_dispatched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_search_dispatch_queue_schedule_idx": { + "name": "workspace_file_search_dispatch_queue_schedule_idx", + "columns": [ + { + "expression": "last_dispatched_at", + "isExpression": false, + "asc": true, + "nulls": "first" + }, + { + "expression": "enqueued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_queue_workspace_fk": { + "name": "workspace_file_search_queue_workspace_fk", + "tableFrom": "workspace_file_search_dispatch_queue", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_index": { + "name": "workspace_file_search_index", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_content_updated_at": { + "name": "source_content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "workspace_file_search_index_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "partial": { + "name": "partial", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "line_count": { + "name": "line_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "indexed_bytes": { + "name": "indexed_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "dispatched_at": { + "name": "dispatched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_search_index_workspace_status_idx": { + "name": "workspace_file_search_index_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_index_pending_dispatch_idx": { + "name": "workspace_file_search_index_pending_dispatch_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_index\".\"status\" = 'pending' AND \"workspace_file_search_index\".\"dispatched_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_index_active_dispatch_idx": { + "name": "workspace_file_search_index_active_dispatch_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dispatched_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_index\".\"status\" = 'pending' AND \"workspace_file_search_index\".\"dispatched_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_index_file_fk": { + "name": "workspace_file_search_index_file_fk", + "tableFrom": "workspace_file_search_index", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_search_index_workspace_fk": { + "name": "workspace_file_search_index_workspace_fk", + "tableFrom": "workspace_file_search_index", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_file_search_index_pk": { + "name": "workspace_file_search_index_pk", + "columns": ["file_id", "source_content_updated_at"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_segment": { + "name": "workspace_file_search_segment", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_content_updated_at": { + "name": "source_content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "line_number": { + "name": "line_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "segment_number": { + "name": "segment_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "segment_start": { + "name": "segment_start", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "line_length": { + "name": "line_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "workspace_file_search_segment_workspace_revision_idx": { + "name": "workspace_file_search_segment_workspace_revision_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_segment_workspace_content_trgm_idx": { + "name": "workspace_file_search_segment_workspace_content_trgm_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "content", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_segment_file_fk": { + "name": "workspace_file_search_segment_file_fk", + "tableFrom": "workspace_file_search_segment", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_search_segment_workspace_fk": { + "name": "workspace_file_search_segment_workspace_fk", + "tableFrom": "workspace_file_search_segment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_file_search_segment_pk": { + "name": "workspace_file_search_segment_pk", + "columns": ["file_id", "source_content_updated_at", "line_number", "segment_number"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_secret_provenance": { + "name": "workspace_file_secret_provenance", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_secret_provenance_file_id_workspace_files_id_fk": { + "name": "workspace_file_secret_provenance_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_secret_provenance", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_file_secret_provenance_status_check": { + "name": "workspace_file_secret_provenance_status_check", + "value": "\"workspace_file_secret_provenance\".\"status\" IN ('exact', 'unknown', 'unrecorded')" + } + }, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_organization_id_idx": { + "name": "workspace_files_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "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_organization_id_organization_id_fk": { + "name": "workspace_files_organization_id_organization_id_fk", + "tableFrom": "workspace_files", + "tableTo": "organization", + "columnsFrom": ["organization_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": { + "workspace_files_organization_binding_check": { + "name": "workspace_files_organization_binding_check", + "value": "\"workspace_files\".\"organization_id\" IS NULL OR (\"workspace_files\".\"workspace_id\" IS NULL AND \"workspace_files\".\"context\" = 'knowledge-base' AND \"workspace_files\".\"folder_id\" IS NULL AND \"workspace_files\".\"chat_id\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.workspace_fork_block_map": { + "name": "workspace_fork_block_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_workflow_id": { + "name": "parent_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_block_id": { + "name": "parent_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_workflow_id": { + "name": "child_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_block_id": { + "name": "child_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_block_map_child_ws_parent_unique": { + "name": "workspace_fork_block_map_child_ws_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_unique": { + "name": "workspace_fork_block_map_child_ws_child_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_parent_wf_idx": { + "name": "workspace_fork_block_map_child_ws_parent_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_wf_idx": { + "name": "workspace_fork_block_map_child_ws_child_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_block_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_block_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_block_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_dependent_value": { + "name": "workspace_fork_dependent_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_block_key": { + "name": "sub_block_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_dependent_value_child_ws_wf_idx": { + "name": "workspace_fork_dependent_value_child_ws_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_dependent_value_field_unique": { + "name": "workspace_fork_dependent_value_field_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sub_block_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_dependent_value", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_promote_run": { + "name": "workspace_fork_promote_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workspace_id": { + "name": "target_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "workspace_fork_promote_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_promote_run_child_ws_target_unique": { + "name": "workspace_fork_promote_run_child_ws_target_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_promote_run_target_ws_idx": { + "name": "workspace_fork_promote_run_target_ws_idx", + "columns": [ + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_promote_run_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_promote_run_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_promote_run_created_by_user_id_fk": { + "name": "workspace_fork_promote_run_created_by_user_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_resource_map": { + "name": "workspace_fork_resource_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "workspace_fork_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_resource_id": { + "name": "parent_resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_resource_id": { + "name": "child_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_resource_map_child_ws_idx": { + "name": "workspace_fork_resource_map_child_ws_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_ws_type_idx": { + "name": "workspace_fork_resource_map_child_ws_type_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_type_parent_unique": { + "name": "workspace_fork_resource_map_child_type_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_resource_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_resource_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_resource_map_created_by_user_id_fk": { + "name": "workspace_fork_resource_map_created_by_user_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_sandbox": { + "name": "workspace_sandbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "sandbox_language", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "dependencies": { + "name": "dependencies", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "cli_tools": { + "name": "cli_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "system_packages": { + "name": "system_packages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_sandbox_workspace_name_unique": { + "name": "workspace_sandbox_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_workspace_idx": { + "name": "workspace_sandbox_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_spec_hash_idx": { + "name": "workspace_sandbox_spec_hash_idx", + "columns": [ + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_sandbox_workspace_id_workspace_id_fk": { + "name": "workspace_sandbox_workspace_id_workspace_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_sandbox_created_by_user_id_fk": { + "name": "workspace_sandbox_created_by_user_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.background_work_kind": { + "name": "background_work_kind", + "schema": "public", + "values": ["deployment_side_effects", "fork_content_copy", "fork_sync", "fork_rollback"] + }, + "public.background_work_status_value": { + "name": "background_work_status_value", + "schema": "public", + "values": ["pending", "processing", "completed", "completed_with_warnings", "failed"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.copilot_tool_permission_decision": { + "name": "copilot_tool_permission_decision", + "schema": "public", + "values": ["allow", "allow_chat", "always_allow", "skip"] + }, + "public.credential_group_enrollment_status": { + "name": "credential_group_enrollment_status", + "schema": "public", + "values": ["invited", "delivery_failed", "in_progress", "completed", "revoked"] + }, + "public.credential_group_status": { + "name": "credential_group_status", + "schema": "public", + "values": ["active", "disabled"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": [ + "oauth", + "managed_oauth", + "managed_mcp", + "env_workspace", + "env_personal", + "service_account", + "personal_token" + ] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.folder_resource_type": { + "name": "folder_resource_type", + "schema": "public", + "values": ["workflow", "file", "knowledge_base", "table"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.managed_oauth_credential_status": { + "name": "managed_oauth_credential_status", + "schema": "public", + "values": ["active", "needs_reauth", "revoked"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.sandbox_image_status": { + "name": "sandbox_image_status", + "schema": "public", + "values": ["pending", "building", "ready", "failed"] + }, + "public.sandbox_language": { + "name": "sandbox_language", + "schema": "public", + "values": ["javascript", "python"] + }, + "public.secret_usage_scope": { + "name": "secret_usage_scope", + "schema": "public", + "values": ["workspace", "personal"] + }, + "public.secret_usage_source": { + "name": "secret_usage_source", + "schema": "public", + "values": ["workflow", "copilot", "mcp"] + }, + "public.upload_session_method": { + "name": "upload_session_method", + "schema": "public", + "values": ["put", "multipart"] + }, + "public.upload_session_provider": { + "name": "upload_session_provider", + "schema": "public", + "values": ["local", "s3", "blob", "gcs"] + }, + "public.upload_session_purpose": { + "name": "upload_session_purpose", + "schema": "public", + "values": [ + "workspace_file", + "table_import", + "knowledge_document", + "profile_picture", + "workspace_logo", + "mothership_attachment", + "execution_attachment" + ] + }, + "public.upload_session_status": { + "name": "upload_session_status", + "schema": "public", + "values": [ + "uploading", + "completing", + "finalizing", + "completed", + "aborting", + "aborted", + "failed", + "expired" + ] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool", "model_unbilled"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment", + "voice-output", + "api-tool" + ] + }, + "public.workspace_file_search_index_status": { + "name": "workspace_file_search_index_status", + "schema": "public", + "values": ["pending", "ready", "skipped", "failed"] + }, + "public.workspace_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": ["push", "pull"] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "file_folder", + "mcp_server", + "workflow_mcp_server", + "custom_block", + "custom_tool", + "skill" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 0696d87277e..bb21d021c69 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -2283,6 +2283,13 @@ "when": 1788844432798, "tag": "0326_organization_search_approvals", "breakpoints": true + }, + { + "idx": 327, + "version": "7", + "when": 1788849461024, + "tag": "0327_organization_connected_accounts", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 4dcee2ebfee..7c9a735a7f6 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -3861,14 +3861,16 @@ export const mcpServers = pgTable( 'mcp_servers', { id: text('id').primaryKey(), - workspaceId: text('workspace_id') - .notNull() - .references(() => workspace.id, { onDelete: 'cascade' }), + workspaceId: text('workspace_id').references(() => workspace.id, { onDelete: 'cascade' }), + organizationId: text('organization_id').references(() => organization.id, { + onDelete: 'cascade', + }), credentialGroupId: text('credential_group_id').references( (): AnyPgColumn => credentialGroup.id, { onDelete: 'set null' } ), managedConnectorId: text('managed_connector_id'), + oauthConfigVersion: integer('oauth_config_version').notNull().default(1), // Track who created the server, but workspace owns it createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }), @@ -3909,6 +3911,15 @@ export const mcpServers = pgTable( updatedAt: timestamp('updated_at').notNull().defaultNow(), }, (table) => ({ + ownerCheck: check( + 'mcp_servers_owner_check', + sql`num_nonnulls(${table.workspaceId}, ${table.organizationId}) = 1` + ), + organizationManagedCheck: check( + 'mcp_servers_organization_managed_check', + sql`${table.organizationId} IS NULL OR ${table.credentialGroupId} IS NOT NULL` + ), + organizationIdx: index('mcp_servers_organization_id_idx').on(table.organizationId), // Primary access pattern - active servers by workspace workspaceEnabledIdx: index('mcp_servers_workspace_enabled_idx').on( table.workspaceId, @@ -3955,9 +3966,10 @@ export const mcpServerOauth = pgTable( .references(() => mcpServers.id, { onDelete: 'cascade' }), /** Last workspace user who initiated/completed authorization. */ userId: text('user_id').references(() => user.id, { onDelete: 'set null' }), - workspaceId: text('workspace_id') - .notNull() - .references(() => workspace.id, { onDelete: 'cascade' }), + workspaceId: text('workspace_id').references(() => workspace.id, { onDelete: 'cascade' }), + organizationId: text('organization_id').references(() => organization.id, { + onDelete: 'cascade', + }), /** * Encrypted JSON of the RFC 7591 dynamic client registration result. @@ -3987,6 +3999,10 @@ export const mcpServerOauth = pgTable( updatedAt: timestamp('updated_at').notNull().defaultNow(), }, (table) => ({ + ownerCheck: check( + 'mcp_server_oauth_owner_check', + sql`num_nonnulls(${table.workspaceId}, ${table.organizationId}) = 1` + ), serverUnique: uniqueIndex('mcp_server_oauth_server_unique').on(table.mcpServerId), stateIdx: index('mcp_server_oauth_state_idx').on(table.state), }) @@ -4624,6 +4640,7 @@ export const credential = pgTable( mcpServerId: text('mcp_server_id').references(() => mcpServers.id, { onDelete: 'cascade', }), + mcpOauthConfigVersion: integer('mcp_oauth_config_version'), managedOauthScopeVersion: integer('managed_oauth_scope_version'), providerSubjectId: text('provider_subject_id'), providerTenantId: text('provider_tenant_id'), @@ -4650,7 +4667,7 @@ export const credential = pgTable( organizationIdIdx: index('credential_organization_id_idx').on(table.organizationId), organizationTypeCheck: check( 'credential_organization_type_check', - sql`${table.organizationId} IS NULL OR ${table.type} IN ('oauth', 'managed_oauth', 'service_account', 'personal_token')` + sql`${table.organizationId} IS NULL OR ${table.type} IN ('oauth', 'managed_oauth', 'managed_mcp', 'service_account', 'personal_token')` ), organizationAccountUnique: uniqueIndex('credential_organization_account_unique') .on(table.organizationId, table.accountId) @@ -4800,11 +4817,12 @@ export interface CredentialGroupOptionConfig { status: 'active' | 'disabled' } -/** Workspace-owned configuration for collecting several managed OAuth credentials. */ +/** Singleton configuration for collecting an organization's connected accounts. */ export const credentialGroup = pgTable( 'credential_group', { id: text('id').primaryKey(), + /** contract-pending(org connected accounts fully deployed and legacy Search migrated): remove workspace ownership. */ workspaceId: text('workspace_id').references(() => workspace.id, { onDelete: 'cascade' }), organizationId: text('organization_id').references(() => organization.id, { onDelete: 'cascade', @@ -4850,6 +4868,8 @@ export const credentialGroupEnrollment = pgTable( .notNull() .references(() => credentialGroup.id, { onDelete: 'cascade' }), email: text('email').notNull(), + /** Bound once after the invitee signs in with the verified invitation email. */ + userId: text('user_id').references(() => user.id, { onDelete: 'cascade' }), status: credentialGroupEnrollmentStatusEnum('status').notNull().default('invited'), invitationTokenHash: text('invitation_token_hash').notNull(), invitationExpiresAt: timestamp('invitation_expires_at').notNull(), @@ -4863,6 +4883,10 @@ export const credentialGroupEnrollment = pgTable( updatedAt: timestamp('updated_at').notNull().defaultNow(), }, (table) => ({ + groupUserUnique: uniqueIndex('credential_group_enrollment_group_user_unique') + .on(table.credentialGroupId, table.userId) + .where(sql`${table.userId} IS NOT NULL`), + userIdx: index('credential_group_enrollment_user_id_idx').on(table.userId), groupEmailUnique: uniqueIndex('credential_group_enrollment_group_email_unique').on( table.credentialGroupId, table.email @@ -5081,14 +5105,15 @@ export const permissionGroupMember = pgTable( }) ) -/** Versioned statement policy attached to one canonical workspace resource. */ +/** Versioned statement policy attached to one canonical workspace or organization resource. */ export const resourcePolicy = pgTable( 'resource_policy', { id: text('id').primaryKey(), - workspaceId: text('workspace_id') - .notNull() - .references(() => workspace.id, { onDelete: 'cascade' }), + workspaceId: text('workspace_id').references(() => workspace.id, { onDelete: 'cascade' }), + organizationId: text('organization_id').references(() => organization.id, { + onDelete: 'cascade', + }), resourceType: text('resource_type').notNull(), resourceId: text('resource_id').notNull(), revision: integer('revision').notNull().default(1), @@ -5099,6 +5124,11 @@ export const resourcePolicy = pgTable( updatedAt: timestamp('updated_at').notNull().defaultNow(), }, (table) => ({ + ownerCheck: check( + 'resource_policy_owner_check', + sql`num_nonnulls(${table.workspaceId}, ${table.organizationId}) = 1` + ), + organizationIdx: index('resource_policy_organization_id_idx').on(table.organizationId), resourceUnique: uniqueIndex('resource_policy_resource_unique').on( table.resourceType, table.resourceId diff --git a/packages/db/script-migrations/0010_backfill_credential_group_resource_policies.test.ts b/packages/db/script-migrations/0010_backfill_credential_group_resource_policies.test.ts index 9f844ea951c..f5644fd2b62 100644 --- a/packages/db/script-migrations/0010_backfill_credential_group_resource_policies.test.ts +++ b/packages/db/script-migrations/0010_backfill_credential_group_resource_policies.test.ts @@ -15,6 +15,7 @@ import { parseCredentialGroupPolicyDocument, reconcileCredentialGroupResourcePolicies, type StoredCredentialGroupPolicyRow, + validateOrganizationAccountPolicyDocument, } from '../credential-group-resource-policies' const WORKFLOW_POLICY = (id: string, workflowIds: string[]) => ({ @@ -336,7 +337,8 @@ describe('Credential Group resource policy lifecycle', () => { await store.listMissingPolicies('', 2) await store.findRelationalInvariantViolation() expect(queries[6]).toContain('AND cg.workspace_id IS NOT NULL') - expect(queries[7]).toContain('WHERE cg.workspace_id IS NOT NULL AND (rp.resource_id IS NULL') + expect(queries[7]).toContain('WHERE rp.resource_id IS NULL') + expect(queries[7]).toContain('rp.organization_id IS DISTINCT FROM cg.organization_id') }) it('keeps table creation in 0309 and lifecycle reconciliation in the db:push post-step', async () => { @@ -363,3 +365,48 @@ describe('Credential Group resource policy lifecycle', () => { expect(helperSource).not.toContain("document ? 'grants'") }) }) + +describe('organization account policy validation', () => { + const policy = (ids: string[]) => ({ + version: 2, + resource: { type: 'credential_group', id: 'group-org' }, + statements: ids.length + ? [ + { + sid: 'WorkspaceCredentialAccess', + effect: 'allow', + actions: ['credential_groups.credentials.use'], + principals: ids.map((workspaceId) => ({ type: 'workspace', workspaceId })), + }, + ] + : [], + }) + it('accepts deny-by-default and the maximum workspace allowlist', () => { + expect(() => validateOrganizationAccountPolicyDocument(policy([]), 'group-org')).not.toThrow() + expect(() => + validateOrganizationAccountPolicyDocument( + policy(Array.from({ length: 1000 }, (_, i) => `workspace-${String(i).padStart(4, '0')}`)), + 'group-org' + ) + ).not.toThrow() + }) + it.each([ + ['b', 'a'], + ['a', 'a'], + ])('rejects unsorted or duplicate workspace principals', (...ids) => { + expect(() => validateOrganizationAccountPolicyDocument(policy(ids), 'group-org')).toThrow( + 'unique and sorted' + ) + }) + it('rejects workflow grants and a policy for another group', () => { + expect(() => + validateOrganizationAccountPolicyDocument( + WORKFLOW_POLICY('group-org', ['workflow-1']), + 'group-org' + ) + ).toThrow('version must be 2') + expect(() => validateOrganizationAccountPolicyDocument(policy([]), 'other-group')).toThrow( + 'canonical resource' + ) + }) +})