diff --git a/apps/sim/.env.example b/apps/sim/.env.example index de5ae82cafa..927e437bad4 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -197,6 +197,7 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic # DATA_RETENTION_ENABLED= / NEXT_PUBLIC_DATA_RETENTION_ENABLED= # Runs retention deletion — off by default # DATA_DRAINS_ENABLED= / NEXT_PUBLIC_DATA_DRAINS_ENABLED= # Export streams # FORKING_ENABLED= # Workspace forks +# CREDENTIAL_GROUPS= # Enterprise managed OAuth collections # ORGANIZATIONS_ENABLED= / NEXT_PUBLIC_ORGANIZATIONS_ENABLED= # Organizations only # Instance organization (Optional). Most enterprise features read their settings from the diff --git a/apps/sim/app/api/auth/oauth/credentials/route.test.ts b/apps/sim/app/api/auth/oauth/credentials/route.test.ts index fd87f08f5e9..66f28ccef82 100644 --- a/apps/sim/app/api/auth/oauth/credentials/route.test.ts +++ b/apps/sim/app/api/auth/oauth/credentials/route.test.ts @@ -4,7 +4,13 @@ * @vitest-environment node */ -import { hybridAuthMockFns, permissionsMock, workflowsUtilsMock } from '@sim/testing' +import { + dbChainMockFns, + hybridAuthMockFns, + permissionsMock, + resetDbChainMock, + workflowsUtilsMock, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -26,6 +32,7 @@ describe('OAuth Credentials API Route', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() }) it('should handle unauthenticated user', async () => { @@ -90,4 +97,33 @@ describe('OAuth Credentials API Route', () => { expect(response.status).toBe(200) expect(data.credentials).toHaveLength(0) }) + + it('does not expose a managed credential requested by exact ID', async () => { + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ + success: true, + userId: 'user-123', + authType: 'session', + }) + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'managed-credential-1', + workspaceId: 'workspace-1', + type: 'managed_oauth', + displayName: 'Managed Gmail', + providerId: 'google-email', + accountId: null, + updatedAt: new Date('2026-01-01T00:00:00Z'), + accountProviderId: null, + accountScope: null, + accountUpdatedAt: null, + }, + ]) + + const response = await GET( + createMockRequestWithQuery('GET', '?credentialId=managed-credential-1') + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ credentials: [] }) + }) }) diff --git a/apps/sim/app/api/auth/oauth/token/route.test.ts b/apps/sim/app/api/auth/oauth/token/route.test.ts index c149d1909b0..a0fb99cdee4 100644 --- a/apps/sim/app/api/auth/oauth/token/route.test.ts +++ b/apps/sim/app/api/auth/oauth/token/route.test.ts @@ -12,8 +12,17 @@ import { import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockAuthorizeCredentialUse, mockResolveServiceAccountToken } = vi.hoisted(() => ({ +const { + mockAuthenticateManagedOAuthDelegation, + mockAuthorizeCredentialUse, + mockGetToolMetadata, + mockResolveManagedOAuthCredentialToken, + mockResolveServiceAccountToken, +} = vi.hoisted(() => ({ + mockAuthenticateManagedOAuthDelegation: vi.fn(), mockAuthorizeCredentialUse: vi.fn(), + mockGetToolMetadata: vi.fn(), + mockResolveManagedOAuthCredentialToken: vi.fn(), mockResolveServiceAccountToken: vi.fn(), })) @@ -27,6 +36,17 @@ vi.mock('@/lib/auth/credential-access', () => ({ authorizeCredentialUseForAuth: mockAuthorizeCredentialUse, })) +vi.mock('@/lib/credentials/application/managed-oauth-delegation', () => ({ + authenticateManagedOAuthDelegation: mockAuthenticateManagedOAuthDelegation, + InvalidManagedOAuthDelegationError: class InvalidManagedOAuthDelegationError extends Error {}, +})) + +vi.mock('@/lib/credentials/application/resolve-managed-oauth-token', () => ({ + resolveManagedOAuthCredentialToken: { execute: mockResolveManagedOAuthCredentialToken }, +})) + +vi.mock('@/tools/metadata', () => ({ getToolMetadata: mockGetToolMetadata })) + import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' import { GET, POST } from '@/app/api/auth/oauth/token/route' @@ -108,6 +128,38 @@ describe('OAuth Token API Routes', () => { expect(authOAuthUtilsMockFns.mockGetCredential).toHaveBeenCalled() }) + it('does not authenticate managed delegation for an ordinary OAuth credential', async () => { + mockAuthorizeCredentialUse.mockResolvedValueOnce({ + ok: true, + authType: 'internal_jwt', + requesterUserId: 'workflow-owner-id', + credentialOwnerUserId: 'workflow-owner-id', + }) + authOAuthUtilsMockFns.mockGetCredential.mockResolvedValueOnce({ + id: 'credential-id', + accessToken: 'test-token', + refreshToken: 'refresh-token', + accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000), + providerId: 'google', + }) + authOAuthUtilsMockFns.mockRefreshTokenIfNeeded.mockResolvedValueOnce({ + accessToken: 'fresh-token', + refreshed: false, + }) + + const response = await POST( + createMockRequest( + 'POST', + { credentialId: 'credential-id', workflowId: 'workflow-id' }, + { 'x-sim-managed-oauth-delegation': 'Bearer stale-delegation' } + ) + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toMatchObject({ accessToken: 'fresh-token' }) + expect(mockAuthenticateManagedOAuthDelegation).not.toHaveBeenCalled() + }) + it('should handle missing credentialId', async () => { const req = createMockRequest('POST', {}) @@ -332,6 +384,140 @@ describe('OAuth Token API Routes', () => { ) }) + describe('managed OAuth path', () => { + const managedCredential = { + accountId: '', + credentialId: 'managed-credential-id', + credentialType: 'managed_oauth', + providerId: 'google-email', + workspaceId: 'workspace-id', + usedCredentialTable: true, + } + + beforeEach(() => { + authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValueOnce(managedCredential) + mockGetToolMetadata.mockReturnValue({ + oauth: { + required: true, + provider: 'google-email', + requiredScopes: ['https://www.googleapis.com/auth/gmail.readonly'], + }, + }) + }) + + it('fails closed when workflow delegation is missing', async () => { + const response = await POST( + createMockRequest('POST', { + credentialId: 'managed-credential-id', + toolId: 'gmail_read', + }) + ) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toMatchObject({ + code: 'MANAGED_CREDENTIAL_DELEGATION_REQUIRED', + }) + expect(mockResolveManagedOAuthCredentialToken).not.toHaveBeenCalled() + }) + + it('resolves a manually supplied managed credential ID with scoped delegation', async () => { + const principal = { + kind: 'delegated' as const, + serviceId: 'executor' as const, + subjectUserId: 'user-id', + workspaceId: 'workspace-id', + delegationId: 'delegation-id', + audience: 'sim:managed-oauth-credentials', + issuedAt: new Date(Date.now() - 1_000), + expiresAt: new Date(Date.now() + 60_000), + resourceScope: { credentialId: 'managed-credential-id' }, + delegationContext: { + kind: 'workflow_execution' as const, + workflowId: 'workflow-id', + }, + } + mockAuthenticateManagedOAuthDelegation.mockResolvedValueOnce(principal) + mockResolveManagedOAuthCredentialToken.mockResolvedValueOnce({ + accessToken: 'managed-access-token', + refreshed: false, + }) + + const response = await POST( + createMockRequest( + 'POST', + { credentialId: 'managed-credential-id', toolId: 'gmail_read' }, + { 'x-sim-managed-oauth-delegation': 'Bearer delegated-token' } + ) + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ accessToken: 'managed-access-token' }) + expect(mockResolveManagedOAuthCredentialToken).toHaveBeenCalledWith({ + principal, + input: { + credentialId: 'managed-credential-id', + expectedProviderId: 'google-email', + requiredScopes: ['https://www.googleapis.com/auth/gmail.readonly'], + toolId: 'gmail_read', + }, + request: expect.any(NextRequest), + }) + }) + + it('uses the trusted provider scope policy when a Slack tool omits narrower scopes', async () => { + mockGetToolMetadata.mockReturnValueOnce({ + oauth: { + required: true, + provider: 'slack', + }, + }) + const principal = { + kind: 'delegated' as const, + serviceId: 'executor' as const, + subjectUserId: 'user-id', + workspaceId: 'workspace-id', + delegationId: 'delegation-id', + audience: 'sim:managed-oauth-credentials', + issuedAt: new Date(Date.now() - 1_000), + expiresAt: new Date(Date.now() + 60_000), + resourceScope: { credentialId: 'managed-credential-id' }, + delegationContext: { + kind: 'workflow_execution' as const, + workflowId: 'workflow-id', + }, + } + mockAuthenticateManagedOAuthDelegation.mockResolvedValueOnce(principal) + mockResolveManagedOAuthCredentialToken.mockResolvedValueOnce({ + accessToken: 'managed-slack-token', + refreshed: false, + }) + + const response = await POST( + createMockRequest( + 'POST', + { credentialId: 'managed-credential-id', toolId: 'slack_message' }, + { 'x-sim-managed-oauth-delegation': 'Bearer delegated-token' } + ) + ) + + expect(response.status).toBe(200) + expect(mockResolveManagedOAuthCredentialToken).toHaveBeenCalledWith({ + principal, + input: { + credentialId: 'managed-credential-id', + expectedProviderId: 'slack', + requiredScopes: expect.arrayContaining([ + 'channels:read', + 'channels:history', + 'chat:write', + ]), + toolId: 'slack_message', + }, + request: expect.any(NextRequest), + }) + }) + }) + describe('credentialAccountUserId + providerId path', () => { it('should reject unauthenticated requests', async () => { hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ diff --git a/apps/sim/app/api/auth/oauth/token/route.ts b/apps/sim/app/api/auth/oauth/token/route.ts index c3e1744dc1f..6d57016744a 100644 --- a/apps/sim/app/api/auth/oauth/token/route.ts +++ b/apps/sim/app/api/auth/oauth/token/route.ts @@ -1,19 +1,30 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { + MANAGED_OAUTH_DELEGATION_HEADER, oauthTokenGetContract, oauthTokenPostContract, } from '@/lib/api/contracts/oauth-connections' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getCredential, getOAuthToken } from '@/lib/oauth/credential-service' +import { + authenticateManagedOAuthDelegation, + InvalidManagedOAuthDelegationError, +} from '@/lib/credentials/application/managed-oauth-delegation' +import { resolveManagedOAuthCredentialToken } from '@/lib/credentials/application/resolve-managed-oauth-token' +import { ManagedOAuthCredentialError } from '@/lib/credentials/managed-oauth' +import { getCredential, getOAuthToken, resolveOAuthAccountId } from '@/lib/oauth/credential-service' import { completeOAuthCredentialToken, resolveCredentialToken } from '@/lib/oauth/token-resolution' +import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' import { captureServerEvent } from '@/lib/posthog/server' +import { getToolMetadata } from '@/tools/metadata' export const dynamic = 'force-dynamic' @@ -50,6 +61,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { credentialId, credentialAccountUserId, providerId, + toolId, workflowId, scopes, impersonateEmail, @@ -115,6 +127,129 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } } + const resolved = credentialId ? await resolveOAuthAccountId(credentialId) : null + if (resolved?.credentialType === 'managed_oauth' && resolved.credentialId) { + const managedOAuthDelegation = parsed.data.headers?.[MANAGED_OAUTH_DELEGATION_HEADER] + if (!managedOAuthDelegation) { + return NextResponse.json( + { + code: 'MANAGED_CREDENTIAL_DELEGATION_REQUIRED', + error: 'Managed credentials can only be used by an authenticated workflow execution', + }, + { status: 403 } + ) + } + + let managedOAuthPrincipal: WorkflowExecutionDelegatedPrincipal + try { + managedOAuthPrincipal = await authenticateManagedOAuthDelegation( + managedOAuthDelegation, + resolved.credentialId + ) + } catch (error) { + if (!(error instanceof InvalidManagedOAuthDelegationError)) throw error + return NextResponse.json( + { + code: 'MANAGED_CREDENTIAL_DELEGATION_INVALID', + error: error.message, + }, + { status: 401 } + ) + } + if (!toolId) { + return NextResponse.json( + { + code: 'MANAGED_CREDENTIAL_TOOL_REQUIRED', + error: 'A tool ID is required to use a managed credential', + }, + { status: 400 } + ) + } + + const toolMetadata = getToolMetadata(toolId) + if (!toolMetadata?.oauth?.required) { + logger.error(`[${requestId}] Tool is not configured for managed OAuth`, { toolId }) + return NextResponse.json( + { + code: 'MANAGED_CREDENTIAL_TOOL_UNSUPPORTED', + error: 'This tool is not configured to use managed credentials', + }, + { status: 500 } + ) + } + const requiredScopes = + toolMetadata.oauth.requiredScopes ?? + getCanonicalScopesForProvider(toolMetadata.oauth.provider) + if (requiredScopes.length === 0) { + logger.error(`[${requestId}] Tool has no trusted OAuth scope policy`, { + toolId, + providerId: toolMetadata.oauth.provider, + }) + return NextResponse.json( + { + code: 'MANAGED_CREDENTIAL_TOOL_UNSUPPORTED', + error: 'This tool is not configured to use managed credentials', + }, + { status: 500 } + ) + } + + try { + const result = await resolveManagedOAuthCredentialToken.execute({ + principal: managedOAuthPrincipal, + input: { + credentialId: resolved.credentialId, + expectedProviderId: toolMetadata.oauth.provider, + requiredScopes, + toolId, + }, + request, + }) + + captureServerEvent( + managedOAuthPrincipal.subjectUserId, + 'credential_used', + { + credential_type: 'managed_oauth', + provider_id: toolMetadata.oauth.provider, + workspace_id: managedOAuthPrincipal.workspaceId, + }, + { groups: { workspace: managedOAuthPrincipal.workspaceId } } + ) + + return NextResponse.json( + { + accessToken: result.accessToken, + ...(result.idToken ? { idToken: result.idToken } : {}), + }, + { status: 200 } + ) + } catch (error) { + if (error instanceof ManagedOAuthCredentialError) { + logger.warn(`[${requestId}] Managed OAuth credential rejected`, { + credentialId: resolved.credentialId, + code: error.code, + }) + return NextResponse.json( + { code: error.code, error: error.message }, + { status: error.statusCode } + ) + } + + const orchestrationError = asOrchestrationError(error) + if (orchestrationError) { + return NextResponse.json( + { + code: 'MANAGED_CREDENTIAL_UNAUTHORIZED', + error: orchestrationError.message, + }, + { status: statusForOrchestrationError(orchestrationError.code) } + ) + } + throw error + } + } + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) const result = await resolveCredentialToken(auth, { requestId, @@ -124,6 +259,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { impersonateEmail, callerUserId, auditRequest: request, + resolvedCredential: resolved, }) if (!result.ok) { diff --git a/apps/sim/app/api/credential-groups/enroll/[token]/complete/route.test.ts b/apps/sim/app/api/credential-groups/enroll/[token]/complete/route.test.ts new file mode 100644 index 00000000000..bad8457d5fd --- /dev/null +++ b/apps/sim/app/api/credential-groups/enroll/[token]/complete/route.test.ts @@ -0,0 +1,89 @@ +/** + * @vitest-environment node + */ +import { NextRequest, NextResponse } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + complete: vi.fn(), + ipRateLimit: vi.fn(), +})) + +vi.mock('@/lib/credential-groups/application/enrollment-auth', () => ({ + authenticateCredentialGroupEnrollment: mocks.authenticate, +})) + +vi.mock('@/lib/credential-groups/application/public-enrollment', () => ({ + completePublicCredentialGroupEnrollment: { execute: mocks.complete }, +})) + +vi.mock('@/lib/credential-groups/rate-limit', () => ({ + enforcePublicCredentialGroupIpRateLimit: mocks.ipRateLimit, +})) + +import { POST } from '@/app/api/credential-groups/enroll/[token]/complete/route' + +const principal = { + kind: 'credential_group_enrollment', + workspaceId: 'workspace-1', + credentialGroupId: 'group-1', + enrollmentId: 'enrollment-1', + email: 'alex@example.com', + invitationTokenHash: 'hash-1', +} as const +const context = { params: Promise.resolve({ token: 'invitation-token' }) } + +function request() { + return new NextRequest( + 'http://localhost:3000/api/credential-groups/enroll/invitation-token/complete', + { method: 'POST' } + ) +} + +describe('credential group enrollment completion route', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.ipRateLimit.mockResolvedValue(null) + mocks.authenticate.mockResolvedValue(principal) + mocks.complete.mockResolvedValue({ completed: true }) + }) + + it('submits a fully connected enrollment through its invitation principal', async () => { + const enrollmentRequest = request() + const response = await POST(enrollmentRequest, context) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe( + '/credential-groups/enroll/invitation-token?submitted=1' + ) + expect(response.headers.get('cache-control')).toBe('no-store') + expect(mocks.complete).toHaveBeenCalledWith({ + principal, + input: {}, + request: enrollmentRequest, + }) + }) + + it('redirects an incomplete enrollment without marking it complete', async () => { + mocks.complete.mockResolvedValue({ completed: false }) + + const response = await POST(request(), context) + + expect(response.headers.get('location')).toBe( + '/credential-groups/enroll/invitation-token?oauth=incomplete' + ) + }) + + it('stops before token lookup when the public IP budget is exhausted', async () => { + mocks.ipRateLimit.mockResolvedValue( + NextResponse.json({ error: 'Too many requests' }, { status: 429 }) + ) + + const response = await POST(request(), context) + + expect(response.status).toBe(429) + expect(mocks.authenticate).not.toHaveBeenCalled() + expect(mocks.complete).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/credential-groups/enroll/[token]/complete/route.ts b/apps/sim/app/api/credential-groups/enroll/[token]/complete/route.ts new file mode 100644 index 00000000000..d33a0709cab --- /dev/null +++ b/apps/sim/app/api/credential-groups/enroll/[token]/complete/route.ts @@ -0,0 +1,44 @@ +import type { NextRequest } from 'next/server' +import { completeCredentialGroupEnrollmentContract } from '@/lib/api/contracts/credential-groups' +import { parseRequest } from '@/lib/api/server' +import { asOrchestrationError } from '@/lib/core/orchestration/types' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { authenticateCredentialGroupEnrollment } from '@/lib/credential-groups/application/enrollment-auth' +import { completePublicCredentialGroupEnrollment } from '@/lib/credential-groups/application/public-enrollment' +import { enforcePublicCredentialGroupIpRateLimit } from '@/lib/credential-groups/rate-limit' +import { createCredentialGroupEnrollmentRedirect } from '@/app/api/credential-groups/enrollment-redirect' + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ token: string }> }) => { + const limited = await enforcePublicCredentialGroupIpRateLimit(request, 'complete') + if (limited) return limited + + const parsed = await parseRequest(completeCredentialGroupEnrollmentContract, request, context) + if (!parsed.success) return parsed.response + const { token } = parsed.data.params + const principal = await authenticateCredentialGroupEnrollment(token) + if (!principal) { + return createCredentialGroupEnrollmentRedirect(token, { oauth: 'unavailable' }) + } + const completion = await completePublicCredentialGroupEnrollment + .execute({ principal, input: {}, request }) + .catch((error: unknown) => { + if (asOrchestrationError(error)?.code === 'not_found') return null + throw error + }) + if (!completion) { + return createCredentialGroupEnrollmentRedirect(token, { oauth: 'unavailable' }) + } + const { completed } = completion + if (completed === null) { + return createCredentialGroupEnrollmentRedirect(token, { oauth: 'unavailable' }) + } + return createCredentialGroupEnrollmentRedirect( + token, + completed ? { submitted: '1' } : { oauth: 'incomplete' } + ) + } +) diff --git a/apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.test.ts b/apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.test.ts new file mode 100644 index 00000000000..acb2344a2b6 --- /dev/null +++ b/apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.test.ts @@ -0,0 +1,112 @@ +/** + * @vitest-environment node + */ +import { NextRequest, NextResponse } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + startOAuth: vi.fn(), + ipRateLimit: vi.fn(), + enrollmentRateLimit: vi.fn(), +})) + +vi.mock('@/lib/credential-groups/application/enrollment-auth', () => ({ + authenticateCredentialGroupEnrollment: mocks.authenticate, +})) + +vi.mock('@/lib/credential-groups/application/public-enrollment', () => ({ + startPublicCredentialGroupOAuth: { execute: mocks.startOAuth }, +})) + +vi.mock('@/lib/credential-groups/rate-limit', () => ({ + enforcePublicCredentialGroupIpRateLimit: mocks.ipRateLimit, + enforceCredentialGroupEnrollmentOAuthRateLimit: mocks.enrollmentRateLimit, +})) + +import { GET } from '@/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route' + +const principal = { + kind: 'credential_group_enrollment', + workspaceId: 'workspace-1', + credentialGroupId: 'group-1', + enrollmentId: 'enrollment-1', + email: 'alex@example.com', + invitationTokenHash: 'hash-1', +} as const +const context = { + params: Promise.resolve({ token: 'invitation-token', optionId: 'option-1' }), +} + +function request() { + return new NextRequest( + 'http://localhost:3000/api/credential-groups/enroll/invitation-token/oauth/option-1' + ) +} + +describe('credential group OAuth start route', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.ipRateLimit.mockResolvedValue(null) + mocks.enrollmentRateLimit.mockResolvedValue(null) + mocks.authenticate.mockResolvedValue(principal) + mocks.startOAuth.mockResolvedValue({ + authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth?state=state-1', + }) + }) + + it('redirects a valid enrollment to Google through its application operation', async () => { + const oauthRequest = request() + const response = await GET(oauthRequest, context) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toContain('https://accounts.google.com/') + expect(response.headers.get('cache-control')).toBe('no-store') + expect(mocks.startOAuth).toHaveBeenCalledWith({ + principal, + input: { invitationToken: 'invitation-token', optionId: 'option-1' }, + request: oauthRequest, + }) + }) + + it('returns an unavailable enrollment to its public page', async () => { + mocks.authenticate.mockResolvedValue(null) + + const response = await GET(request(), context) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe( + '/credential-groups/enroll/invitation-token?oauth=unavailable' + ) + expect(response.headers.get('cache-control')).toBe('no-store') + expect(mocks.startOAuth).not.toHaveBeenCalled() + }) + + it('returns a rate-limited OAuth start to its enrollment page before token lookup', async () => { + mocks.ipRateLimit.mockResolvedValue( + NextResponse.json({ error: 'Too many requests' }, { status: 429 }) + ) + + const response = await GET(request(), context) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe( + '/credential-groups/enroll/invitation-token?oauth=rate_limited' + ) + expect(mocks.authenticate).not.toHaveBeenCalled() + }) + + it('returns an exhausted enrollment OAuth budget to the enrollment page', async () => { + mocks.enrollmentRateLimit.mockResolvedValue( + NextResponse.json({ error: 'Too many requests' }, { status: 429 }) + ) + + const response = await GET(request(), context) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe( + '/credential-groups/enroll/invitation-token?oauth=rate_limited' + ) + expect(mocks.startOAuth).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.ts b/apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.ts new file mode 100644 index 00000000000..22921bc1f62 --- /dev/null +++ b/apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.ts @@ -0,0 +1,69 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { startCredentialGroupOAuthContract } from '@/lib/api/contracts/credential-groups' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { authenticateCredentialGroupEnrollment } from '@/lib/credential-groups/application/enrollment-auth' +import { startPublicCredentialGroupOAuth } from '@/lib/credential-groups/application/public-enrollment' +import { CredentialGroupOAuthError } from '@/lib/credential-groups/provider-adapter' +import { + enforceCredentialGroupEnrollmentOAuthRateLimit, + enforcePublicCredentialGroupIpRateLimit, +} from '@/lib/credential-groups/rate-limit' +import { createCredentialGroupEnrollmentRedirect } from '@/app/api/credential-groups/enrollment-redirect' + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +const logger = createLogger('CredentialGroupOAuthStartAPI') + +export const GET = withRouteHandler( + async ( + request: NextRequest, + context: { params: Promise<{ token: string; optionId: string }> } + ) => { + const limited = await enforcePublicCredentialGroupIpRateLimit(request, 'oauth-start') + + const parsed = await parseRequest(startCredentialGroupOAuthContract, request, context) + if (!parsed.success) return limited ?? parsed.response + const { token, optionId } = parsed.data.params + if (limited) { + return createCredentialGroupEnrollmentRedirect(token, { oauth: 'rate_limited' }) + } + const principal = await authenticateCredentialGroupEnrollment(token) + if (!principal) { + return createCredentialGroupEnrollmentRedirect(token, { oauth: 'unavailable' }) + } + + const enrollmentLimited = await enforceCredentialGroupEnrollmentOAuthRateLimit( + principal.enrollmentId + ) + if (enrollmentLimited) { + return createCredentialGroupEnrollmentRedirect(token, { oauth: 'rate_limited' }) + } + + try { + const { authorizationUrl } = await startPublicCredentialGroupOAuth.execute({ + principal, + input: { invitationToken: token, optionId }, + request, + }) + const response = NextResponse.redirect(authorizationUrl) + response.headers.set('Cache-Control', 'no-store') + response.headers.set('Referrer-Policy', 'no-referrer') + return response + } catch (error) { + logger.error('Failed to start managed OAuth authorization', { + error: getErrorMessage(error), + }) + return createCredentialGroupEnrollmentRedirect(token, { + oauth: + error instanceof CredentialGroupOAuthError && error.statusCode === 409 + ? 'configuration_changed' + : 'unavailable', + }) + } + } +) diff --git a/apps/sim/app/api/credential-groups/enrollment-redirect.ts b/apps/sim/app/api/credential-groups/enrollment-redirect.ts new file mode 100644 index 00000000000..a72ec009906 --- /dev/null +++ b/apps/sim/app/api/credential-groups/enrollment-redirect.ts @@ -0,0 +1,17 @@ +import { NextResponse } from 'next/server' + +export function createCredentialGroupEnrollmentRedirect( + token: string, + params: Record +): NextResponse { + const query = new URLSearchParams(params).toString() + const location = `/credential-groups/enroll/${encodeURIComponent(token)}${query ? `?${query}` : ''}` + return new NextResponse(null, { + status: 307, + headers: { + Location: location, + 'Cache-Control': 'no-store', + 'Referrer-Policy': 'no-referrer', + }, + }) +} 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 new file mode 100644 index 00000000000..cc1e0ee24a8 --- /dev/null +++ b/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.test.ts @@ -0,0 +1,128 @@ +/** + * @vitest-environment node + */ +import { NextRequest, NextResponse } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + completeOAuth: vi.fn(), + consumeAttempt: vi.fn(), + rateLimit: vi.fn(), +})) + +vi.mock('@/lib/credential-groups/application/enrollment-auth', () => ({ + authenticateCredentialGroupEnrollment: mocks.authenticate, +})) + +vi.mock('@/lib/credential-groups/application/public-enrollment', () => ({ + completePublicCredentialGroupOAuth: { execute: mocks.completeOAuth }, +})) + +vi.mock('@/lib/credential-groups/oauth-state', () => ({ + consumeCredentialGroupOAuthAttempt: mocks.consumeAttempt, +})) + +vi.mock('@/lib/credential-groups/rate-limit', () => ({ + enforcePublicCredentialGroupIpRateLimit: mocks.rateLimit, +})) + +import { GET } from '@/app/api/credential-groups/oauth/[provider]/callback/route' + +const principal = { + kind: 'credential_group_enrollment', + workspaceId: 'workspace-1', + credentialGroupId: 'group-1', + enrollmentId: 'enrollment-1', + email: 'alex@example.com', + invitationTokenHash: 'hash-1', +} as const +const attempt = { + provider: 'gmail', + invitationToken: 'invitation-token', + optionId: 'option-1', +} +const context = { params: Promise.resolve({ provider: 'gmail' }) } + +function request(query: string) { + return new NextRequest( + `http://localhost:3000/api/credential-groups/oauth/gmail/callback?${query}` + ) +} + +describe('credential group OAuth callback', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.rateLimit.mockResolvedValue(null) + mocks.consumeAttempt.mockResolvedValue(attempt) + mocks.authenticate.mockResolvedValue(principal) + mocks.completeOAuth.mockResolvedValue({ connectedOptionId: 'option-1' }) + }) + + it('consumes provider-bound state and enters the application operation', async () => { + const callbackRequest = request('state=state-1&code=code-1') + const response = await GET(callbackRequest, context) + + expect(mocks.consumeAttempt).toHaveBeenCalledWith('state-1') + expect(mocks.completeOAuth).toHaveBeenCalledWith({ + principal, + input: { attempt, code: 'code-1' }, + request: callbackRequest, + }) + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe( + '/credential-groups/enroll/invitation-token?connected=option-1' + ) + }) + + it('returns without exchanging when the user denies consent', async () => { + const response = await GET(request('state=state-1&error=access_denied'), context) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe( + '/credential-groups/enroll/invitation-token?oauth=denied' + ) + expect(mocks.authenticate).not.toHaveBeenCalled() + expect(mocks.completeOAuth).not.toHaveBeenCalled() + }) + + it('rejects replayed, expired, or cross-provider state', async () => { + mocks.consumeAttempt.mockResolvedValue(null) + + const replayedResponse = await GET(request('state=state-1&code=code-1'), context) + expect(replayedResponse.status).toBe(400) + + mocks.consumeAttempt.mockResolvedValue({ ...attempt, provider: 'slack' }) + const mismatchedResponse = await GET(request('state=state-2&code=code-2'), context) + expect(mismatchedResponse.status).toBe(400) + expect(mocks.completeOAuth).not.toHaveBeenCalled() + }) + + it('returns an unavailable enrollment redirect when the invitation was revoked in flight', async () => { + mocks.authenticate.mockResolvedValue(null) + + const response = await GET(request('state=state-1&code=code-1'), context) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe( + '/credential-groups/enroll/invitation-token?oauth=unavailable' + ) + expect(mocks.completeOAuth).not.toHaveBeenCalled() + }) + + it('returns a valid rate-limited callback to the enrollment page without exchanging', async () => { + mocks.rateLimit.mockResolvedValue( + NextResponse.json({ error: 'Too many requests' }, { status: 429 }) + ) + + const response = await GET(request('state=state-1&code=code-1'), context) + + expect(mocks.consumeAttempt).toHaveBeenCalledWith('state-1') + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe( + '/credential-groups/enroll/invitation-token?oauth=rate_limited' + ) + expect(mocks.authenticate).not.toHaveBeenCalled() + expect(mocks.completeOAuth).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.ts b/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.ts new file mode 100644 index 00000000000..7ef6b7dc03f --- /dev/null +++ b/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.ts @@ -0,0 +1,92 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { credentialGroupOAuthCallbackContract } from '@/lib/api/contracts/credential-groups' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { authenticateCredentialGroupEnrollment } from '@/lib/credential-groups/application/enrollment-auth' +import { completePublicCredentialGroupOAuth } from '@/lib/credential-groups/application/public-enrollment' +import { consumeCredentialGroupOAuthAttempt } from '@/lib/credential-groups/oauth-state' +import { CredentialGroupOAuthError } from '@/lib/credential-groups/provider-adapter' +import { enforcePublicCredentialGroupIpRateLimit } from '@/lib/credential-groups/rate-limit' +import { createCredentialGroupEnrollmentRedirect } from '@/app/api/credential-groups/enrollment-redirect' + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +const logger = createLogger('CredentialGroupOAuthCallbackAPI') + +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ provider: string }> }) => { + const limited = await enforcePublicCredentialGroupIpRateLimit(request, 'oauth-callback') + + const parsed = await parseRequest(credentialGroupOAuthCallbackContract, request, context) + if (!parsed.success) return limited ?? parsed.response + const { provider } = parsed.data.params + const { state, code, error: providerError } = parsed.data.query + + let attempt + try { + attempt = await consumeCredentialGroupOAuthAttempt(state) + } catch (error) { + logger.error('Failed to consume credential group OAuth state', { + error: getErrorMessage(error), + }) + return NextResponse.json( + { error: 'Authorization state is unavailable. Please try again.' }, + { status: 503, headers: { 'Cache-Control': 'no-store' } } + ) + } + if (!attempt || attempt.provider !== provider) { + if (limited) return limited + return NextResponse.json( + { error: 'Authorization state is invalid or expired.' }, + { status: 400, headers: { 'Cache-Control': 'no-store' } } + ) + } + if (limited) { + return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { + oauth: 'rate_limited', + }) + } + if (providerError) { + return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { oauth: 'denied' }) + } + if (!code) { + return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { oauth: 'failed' }) + } + + const principal = await authenticateCredentialGroupEnrollment(attempt.invitationToken) + if (!principal) { + return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { + oauth: 'unavailable', + }) + } + + try { + await completePublicCredentialGroupOAuth.execute({ + principal, + input: { attempt, code }, + request, + }) + return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { + connected: attempt.optionId, + }) + } catch (error) { + logger.error('Managed OAuth authorization failed', { + provider, + error: getErrorMessage(error), + }) + const status = + error instanceof CredentialGroupOAuthError && error.statusCode === 403 + ? error.message.startsWith('Sign in with') + ? 'account_mismatch' + : 'permissions_required' + : error instanceof CredentialGroupOAuthError && error.statusCode === 409 + ? 'configuration_changed' + : 'failed' + return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { oauth: status }) + } + } +) diff --git a/apps/sim/app/api/credential-groups/slack-managed-users/callback/route.ts b/apps/sim/app/api/credential-groups/slack-managed-users/callback/route.ts new file mode 100644 index 00000000000..441e0978439 --- /dev/null +++ b/apps/sim/app/api/credential-groups/slack-managed-users/callback/route.ts @@ -0,0 +1,123 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { slackCredentialGroupConfigurationCallbackContract } from '@/lib/api/contracts/credential-groups' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { asOrchestrationError } from '@/lib/core/orchestration/types' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { completeSlackCredentialGroupConfiguration } from '@/lib/credential-groups/application/slack-managed-users' +import { SlackManagedUsersError } from '@/lib/credential-groups/slack-managed-users' + +const logger = createLogger('SlackCredentialGroupConfigurationCallbackAPI') +const CHANNEL_NAME = 'slack-managed-users' + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +function jsonLiteral(value: unknown): string { + return JSON.stringify(value).replace(//g, '\\u003e') +} + +function closePopup(params: { + ok: boolean + message: string + state?: string + credentialGroupId?: string + slackBotCredentialId?: string + reason: string +}): NextResponse { + const title = params.ok ? 'Slack configured' : 'Slack setup failed' + const payload = { + type: CHANNEL_NAME, + ok: params.ok, + state: params.state, + credentialGroupId: params.credentialGroupId, + slackBotCredentialId: params.slackBotCredentialId, + reason: params.reason, + } + const body = `${title}

${escapeHtml(params.message)}

` + return new NextResponse(body, { + headers: { + 'Cache-Control': 'no-store, max-age=0', + 'Content-Type': 'text/html; charset=utf-8', + }, + }) +} + +export const GET = withRouteHandler(async (request: NextRequest) => { + const rawState = new URL(request.url).searchParams.get('state')?.slice(0, 512) + const session = await getSession() + if (!session?.user?.id || !session.session?.id) { + return closePopup({ + ok: false, + message: 'Sign in to Sim to complete this Slack setup.', + state: rawState, + reason: 'unauthenticated', + }) + } + const parsed = await parseRequest(slackCredentialGroupConfigurationCallbackContract, request, {}) + if (!parsed.success) { + return closePopup({ + ok: false, + message: 'Slack returned an invalid authorization response.', + state: rawState, + reason: 'invalid_callback', + }) + } + const { state, code, error: providerError } = parsed.data.query + try { + const result = await completeSlackCredentialGroupConfiguration.execute({ + principal: { + kind: 'session', + userId: session.user.id, + sessionId: session.session.id, + }, + input: { state, code, providerError }, + request, + }) + return result.ok + ? closePopup({ + ok: true, + message: 'Slack is ready for this Credential Group. You can close this window.', + state, + credentialGroupId: result.result.credentialGroupId, + slackBotCredentialId: result.result.slackBotCredentialId, + reason: result.reason, + }) + : closePopup({ + ok: false, + message: 'Slack authorization was cancelled.', + state, + reason: result.reason, + }) + } catch (error) { + const orchestrationError = asOrchestrationError(error) + const message = + error instanceof SlackManagedUsersError || orchestrationError + ? getErrorMessage(error) + : 'Slack setup failed. Please try again.' + logger.error('Slack Credential Group configuration callback failed', { + error: getErrorMessage(error), + }) + return closePopup({ + ok: false, + message, + state, + reason: + error instanceof SlackManagedUsersError + ? error.code + : (orchestrationError?.code ?? 'unknown'), + }) + } +}) diff --git a/apps/sim/app/api/credentials/[id]/members/route.ts b/apps/sim/app/api/credentials/[id]/members/route.ts index 72132ee56d0..7c87041c4d6 100644 --- a/apps/sim/app/api/credentials/[id]/members/route.ts +++ b/apps/sim/app/api/credentials/[id]/members/route.ts @@ -27,12 +27,19 @@ interface RouteContext { async function requireCredentialAdmin(credentialId: string, userId: string) { const [cred] = await db - .select({ id: credential.id, workspaceId: credential.workspaceId, type: credential.type }) + .select({ + id: credential.id, + workspaceId: credential.workspaceId, + type: credential.type, + providerId: credential.providerId, + }) .from(credential) .where(eq(credential.id, credentialId)) .limit(1) - if (!cred) return null + if (!cred || cred.type === 'managed_oauth') { + return null + } const perm = await getUserEntityPermissions(userId, 'workspace', cred.workspaceId) if (perm === null) return null @@ -67,12 +74,17 @@ export const GET = withRouteHandler(async (_request: NextRequest, context: Route const { id: credentialId } = await context.params const [cred] = await db - .select({ id: credential.id, workspaceId: credential.workspaceId, type: credential.type }) + .select({ + id: credential.id, + workspaceId: credential.workspaceId, + type: credential.type, + providerId: credential.providerId, + }) .from(credential) .where(eq(credential.id, credentialId)) .limit(1) - if (!cred) { + if (!cred || cred.type === 'managed_oauth') { return NextResponse.json({ error: 'Not found' }, { status: 404 }) } diff --git a/apps/sim/app/api/credentials/[id]/route.ts b/apps/sim/app/api/credentials/[id]/route.ts index 3ff1de37444..ca1eee11b9c 100644 --- a/apps/sim/app/api/credentials/[id]/route.ts +++ b/apps/sim/app/api/credentials/[id]/route.ts @@ -50,7 +50,7 @@ export const GET = withRouteHandler( try { const access = await getCredentialActorContext(id, session.user.id) - if (!access.credential) { + if (!access.credential || access.credential.type === 'managed_oauth') { return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) } if (!canUseCredential(access)) { @@ -82,6 +82,11 @@ export const PUT = withRouteHandler( const { id } = parsed.data.params const body = parsed.data.body + const currentAccess = await getCredentialActorContext(id, session.user.id) + if (!currentAccess.credential) { + return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) + } + const result = await performUpdateCredential({ credentialId: id, userId: session.user.id, @@ -149,6 +154,10 @@ export const DELETE = withRouteHandler( const { id } = await params try { + const currentAccess = await getCredentialActorContext(id, session.user.id) + if (!currentAccess.credential) { + return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) + } const result = await performDeleteCredential({ credentialId: id, userId: session.user.id, @@ -162,9 +171,11 @@ export const DELETE = withRouteHandler( ? 404 : result.errorCode === 'forbidden' ? 403 - : result.errorCode === 'validation' - ? 400 - : 500 + : result.errorCode === 'conflict' + ? 409 + : result.errorCode === 'validation' + ? 400 + : 500 return NextResponse.json({ error: result.error }, { status }) } diff --git a/apps/sim/app/api/credentials/draft/route.ts b/apps/sim/app/api/credentials/draft/route.ts index 2e693609438..15fdfcb5d7f 100644 --- a/apps/sim/app/api/credentials/draft/route.ts +++ b/apps/sim/app/api/credentials/draft/route.ts @@ -35,7 +35,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (credentialId) { const access = await getCredentialActorContext(credentialId, userId, { workspaceAccess }) - if (!access.credential || access.credential.workspaceId !== workspaceId || !access.isAdmin) { + if ( + !access.credential || + access.credential.type === 'managed_oauth' || + access.credential.workspaceId !== workspaceId || + !access.isAdmin + ) { return NextResponse.json( { error: 'Admin access required on the target credential' }, { status: 403 } diff --git a/apps/sim/app/api/credentials/memberships/route.ts b/apps/sim/app/api/credentials/memberships/route.ts index 7e855d2caca..33227c66de0 100644 --- a/apps/sim/app/api/credentials/memberships/route.ts +++ b/apps/sim/app/api/credentials/memberships/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { credential, credentialMember } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' +import { and, eq, ne } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { leaveCredentialQuerySchema } from '@/lib/api/contracts/credentials' import { getValidationErrorMessage } from '@/lib/api/server' @@ -31,7 +31,9 @@ export const GET = withRouteHandler(async () => { }) .from(credentialMember) .innerJoin(credential, eq(credentialMember.credentialId, credential.id)) - .where(eq(credentialMember.userId, session.user.id)) + .where( + and(eq(credentialMember.userId, session.user.id), ne(credential.type, 'managed_oauth')) + ) return NextResponse.json({ memberships }, { status: 200 }) } catch (error) { diff --git a/apps/sim/app/api/credentials/route.ts b/apps/sim/app/api/credentials/route.ts index 51d01be9981..991b76d712a 100644 --- a/apps/sim/app/api/credentials/route.ts +++ b/apps/sim/app/api/credentials/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { credential } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' +import { and, eq, ne } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { createWorkspaceCredentialContract, @@ -167,7 +167,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => { providerId: credential.providerId, }) .from(credential) - .where(and(eq(credential.id, lookupCredentialId), eq(credential.workspaceId, workspaceId))) + .where( + and( + eq(credential.id, lookupCredentialId), + eq(credential.workspaceId, workspaceId), + ne(credential.type, 'managed_oauth') + ) + ) .limit(1) if (!row) { @@ -182,7 +188,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { .where( and( eq(credential.accountId, lookupCredentialId), - eq(credential.workspaceId, workspaceId) + eq(credential.workspaceId, workspaceId), + ne(credential.type, 'managed_oauth') ) ) .limit(1) diff --git a/apps/sim/app/api/files/uploads/purposes.ts b/apps/sim/app/api/files/uploads/purposes.ts index 9c8810563a4..a7cf3aa4b72 100644 --- a/apps/sim/app/api/files/uploads/purposes.ts +++ b/apps/sim/app/api/files/uploads/purposes.ts @@ -251,6 +251,11 @@ async function principalUserId(principal: Principal, workspaceId?: string): Prom } case 'delegated': throw new UploadSessionError('forbidden', 'Delegated principals cannot create uploads') + case 'credential_group_enrollment': + throw new UploadSessionError( + 'forbidden', + 'Credential Group enrollment principals cannot create uploads' + ) } } diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/[enrollmentId]/resend/route.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/[enrollmentId]/resend/route.ts new file mode 100644 index 00000000000..d2d5dac5045 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/[enrollmentId]/resend/route.ts @@ -0,0 +1,31 @@ +import { resendCredentialGroupEnrollmentContract } from '@/lib/api/contracts/credential-groups' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { resendCredentialGroupEnrollmentSettings } from '@/lib/credential-groups/application/manage-enrollments' +import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' +import { enforceCredentialGroupInvitationRouteRateLimit } from '@/lib/credential-groups/rate-limit' +import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy' + +export const POST = defineInternalJsonRoute({ + contract: resendCredentialGroupEnrollmentContract, + auth: internalSessionAuth, + operation: credentialGroupOperations.resendEnrollment, + rateLimit: internalRateLimits.none({ + reason: 'Credential Group invitation resends use a shared per-workspace delivery budget', + }), + errorPolicy: createCredentialGroupInternalErrorPolicy( + 'Failed to resend credential group enrollment' + ), + async mapInput({ params }) { + await enforceCredentialGroupInvitationRouteRateLimit(params.id) + return { + assertedWorkspaceId: params.id, + credentialGroupId: params.groupId, + enrollmentId: params.enrollmentId, + } + }, + useCase: resendCredentialGroupEnrollmentSettings, +}) diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/[enrollmentId]/route.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/[enrollmentId]/route.ts new file mode 100644 index 00000000000..1a3fc67b10f --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/[enrollmentId]/route.ts @@ -0,0 +1,27 @@ +import { revokeCredentialGroupEnrollmentContract } from '@/lib/api/contracts/credential-groups' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { revokeCredentialGroupEnrollmentSettings } from '@/lib/credential-groups/application/manage-enrollments' +import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' +import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy' + +export const DELETE = defineInternalJsonRoute({ + contract: revokeCredentialGroupEnrollmentContract, + auth: internalSessionAuth, + operation: credentialGroupOperations.revokeEnrollment, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal Credential Group revocation behavior', + }), + errorPolicy: createCredentialGroupInternalErrorPolicy( + 'Failed to revoke credential group enrollment' + ), + mapInput: ({ params }) => ({ + assertedWorkspaceId: params.id, + credentialGroupId: params.groupId, + enrollmentId: params.enrollmentId, + }), + useCase: revokeCredentialGroupEnrollmentSettings, +}) diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/route.test.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/route.test.ts new file mode 100644 index 00000000000..4e74e4d5e6b --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/route.test.ts @@ -0,0 +1,126 @@ +/** + * @vitest-environment node + */ + +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + execute: vi.fn(), + getSession: vi.fn(), + rateLimit: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession })) + +vi.mock('@/lib/credential-groups/application/manage-enrollments', () => ({ + inviteCredentialGroupEnrollmentsSettings: { + operation: { id: 'credential_groups.invites.send_batch' }, + execute: mocks.execute, + }, +})) + +vi.mock('@/lib/credential-groups/rate-limit', () => { + class CredentialGroupInvitationRateLimitError extends Error { + readonly statusCode = 429 + + constructor( + readonly retryAfterSeconds: number, + readonly resetAt: Date + ) { + super('Rate limit exceeded') + } + } + return { + CredentialGroupInvitationRateLimitError, + enforceCredentialGroupInvitationRouteRateLimit: mocks.rateLimit, + } +}) + +import { CredentialGroupInvitationRateLimitError } from '@/lib/credential-groups/rate-limit' +import { POST } from '@/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/route' + +const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111' +const GROUP_ID = 'group-1' +const context = { params: Promise.resolve({ id: WORKSPACE_ID, groupId: GROUP_ID }) } + +function createRequest(body: unknown): NextRequest { + return new NextRequest( + `http://localhost:3000/api/workspaces/${WORKSPACE_ID}/credential-groups/${GROUP_ID}/enrollments`, + { + method: 'POST', + body: JSON.stringify(body), + headers: { 'content-type': 'application/json' }, + } + ) +} + +describe('credential group enrollment invitation route', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getSession.mockResolvedValue({ + user: { id: 'user-1' }, + session: { id: 'session-1' }, + }) + mocks.rateLimit.mockResolvedValue(undefined) + mocks.execute.mockResolvedValue({ + results: [{ email: 'alex@example.com', success: false, error: 'Delivery failed' }], + sentCount: 0, + failedCount: 1, + }) + }) + + it('authenticates before parsing the batch', async () => { + mocks.getSession.mockResolvedValue(null) + + const response = await POST(createRequest({}), context) + + expect(response.status).toBe(401) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('sends the entire validated batch through one application command', async () => { + const body = { emails: ['alex@example.com', 'sam@example.com'] } + const request = createRequest(body) + + const response = await POST(request, context) + + expect(response.status).toBe(200) + expect(mocks.execute).toHaveBeenCalledWith({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + assertedWorkspaceId: WORKSPACE_ID, + credentialGroupId: GROUP_ID, + emails: body.emails, + }, + request, + }) + expect(await response.json()).toMatchObject({ sentCount: 0, failedCount: 1 }) + }) + + it('rejects a batch larger than 100 before admission or delivery', async () => { + const response = await POST( + createRequest({ + emails: Array.from({ length: 101 }, (_, index) => `user-${index}@example.com`), + }), + context + ) + + expect(response.status).toBe(400) + expect(mocks.rateLimit).not.toHaveBeenCalled() + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('applies the shared workspace invitation rate limit', async () => { + mocks.rateLimit.mockRejectedValue( + new CredentialGroupInvitationRateLimitError(30, new Date('2026-08-14T12:00:00Z')) + ) + + const response = await POST(createRequest({ emails: ['alex@example.com'] }), context) + + expect(response.status).toBe(429) + expect(mocks.rateLimit).toHaveBeenCalledWith(WORKSPACE_ID) + expect(response.headers.get('retry-after')).toBe('30') + expect(mocks.execute).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/route.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/route.ts new file mode 100644 index 00000000000..455400482f1 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/route.ts @@ -0,0 +1,31 @@ +import { inviteCredentialGroupEnrollmentsContract } from '@/lib/api/contracts/credential-groups' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { inviteCredentialGroupEnrollmentsSettings } from '@/lib/credential-groups/application/manage-enrollments' +import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' +import { enforceCredentialGroupInvitationRouteRateLimit } from '@/lib/credential-groups/rate-limit' +import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy' + +export const POST = defineInternalJsonRoute({ + contract: inviteCredentialGroupEnrollmentsContract, + auth: internalSessionAuth, + operation: credentialGroupOperations.inviteBatch, + rateLimit: internalRateLimits.none({ + reason: 'Credential Group invitations use a shared per-workspace delivery budget', + }), + errorPolicy: createCredentialGroupInternalErrorPolicy( + 'Failed to invite credential group enrollments' + ), + async mapInput({ params, body }) { + await enforceCredentialGroupInvitationRouteRateLimit(params.id) + return { + assertedWorkspaceId: params.id, + credentialGroupId: params.groupId, + emails: body.emails, + } + }, + useCase: inviteCredentialGroupEnrollmentsSettings, +}) diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/route.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/route.ts new file mode 100644 index 00000000000..66a6c609dc8 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/route.ts @@ -0,0 +1,63 @@ +import { + deleteCredentialGroupContract, + getCredentialGroupContract, + updateCredentialGroupContract, +} from '@/lib/api/contracts/credential-groups' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + deleteCredentialGroupSettings, + getCredentialGroupSettings, + updateCredentialGroupSettings, +} from '@/lib/credential-groups/application/manage-groups' +import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' +import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy' + +const rateLimit = internalRateLimits.none({ + reason: 'Preserve existing internal Credential Group detail behavior', +}) + +export const GET = defineInternalJsonRoute({ + contract: getCredentialGroupContract, + auth: internalSessionAuth, + operation: credentialGroupOperations.readSettings, + rateLimit, + errorPolicy: createCredentialGroupInternalErrorPolicy('Failed to get credential group'), + mapInput: ({ params, query }) => ({ + assertedWorkspaceId: params.id, + credentialGroupId: params.groupId, + limit: query.limit, + cursor: query.cursor, + }), + useCase: getCredentialGroupSettings, +}) + +export const PATCH = defineInternalJsonRoute({ + contract: updateCredentialGroupContract, + auth: internalSessionAuth, + operation: credentialGroupOperations.update, + rateLimit, + errorPolicy: createCredentialGroupInternalErrorPolicy('Failed to update credential group'), + mapInput: ({ params, body }) => ({ + assertedWorkspaceId: params.id, + credentialGroupId: params.groupId, + update: body, + }), + useCase: updateCredentialGroupSettings, +}) + +export const DELETE = defineInternalJsonRoute({ + contract: deleteCredentialGroupContract, + auth: internalSessionAuth, + operation: credentialGroupOperations.delete, + rateLimit, + errorPolicy: createCredentialGroupInternalErrorPolicy('Failed to delete credential group'), + mapInput: ({ params }) => ({ + assertedWorkspaceId: params.id, + credentialGroupId: params.groupId, + }), + useCase: deleteCredentialGroupSettings, +}) diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/slack-managed-users/route.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/slack-managed-users/route.ts new file mode 100644 index 00000000000..21a9e594ede --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/slack-managed-users/route.ts @@ -0,0 +1,38 @@ +import { startSlackCredentialGroupConfigurationContract } from '@/lib/api/contracts/credential-groups' +import { + defineInternalJsonRoute, + extendInternalErrorPolicy, + internalErrorResponse, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' +import { startSlackCredentialGroupConfiguration } from '@/lib/credential-groups/application/slack-managed-users' +import { SlackManagedUsersError } from '@/lib/credential-groups/slack-managed-users' +import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy' + +const errorPolicy = extendInternalErrorPolicy( + createCredentialGroupInternalErrorPolicy('Failed to configure Slack for Credential Group'), + (error) => + error instanceof SlackManagedUsersError + ? internalErrorResponse(400, { error: error.message }) + : null +) + +export const POST = defineInternalJsonRoute({ + contract: startSlackCredentialGroupConfigurationContract, + auth: internalSessionAuth, + operation: credentialGroupOperations.startSlackConfiguration, + rateLimit: internalRateLimits.none({ + reason: 'Slack applies provider authorization limits and setup requires a workspace admin', + }), + errorPolicy, + mapInput: ({ params, body }) => ({ + assertedWorkspaceId: params.id, + credentialGroupId: params.groupId, + slackBotCredentialId: body.slackBotCredentialId, + clientId: body.clientId, + clientSecret: body.clientSecret, + }), + useCase: startSlackCredentialGroupConfiguration, +}) diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/error-policy.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/error-policy.ts new file mode 100644 index 00000000000..5e9d82df67d --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/error-policy.ts @@ -0,0 +1,43 @@ +import { + createInternalResourceConcealmentPolicy, + type InternalErrorPolicy, + internalErrorResponse, + internalOrchestrationErrorPolicy, +} from '@/lib/api/server/routes' +import { CredentialGroupEnrollmentError } from '@/lib/credential-groups/enrollments' +import { CredentialGroupProviderConfigurationError } from '@/lib/credential-groups/provider-adapter' +import { CredentialGroupInvitationRateLimitError } from '@/lib/credential-groups/rate-limit' + +export function createCredentialGroupInternalErrorPolicy( + unhandledMessage: string, + notFoundMessage = 'Credential group not found' +): InternalErrorPolicy { + if (!unhandledMessage.trim()) { + throw new Error('Credential Group error policy requires an unhandled message') + } + const base: InternalErrorPolicy = { + project(error) { + if (error instanceof CredentialGroupProviderConfigurationError) { + return internalErrorResponse(503, { error: error.message }) + } + if (error instanceof CredentialGroupEnrollmentError) { + return internalErrorResponse(error.status, { error: error.message }) + } + if (error instanceof CredentialGroupInvitationRateLimitError) { + return internalErrorResponse( + 429, + { error: error.message, retryAfter: error.resetAt.getTime() }, + { + 'Retry-After': String(error.retryAfterSeconds), + 'X-RateLimit-Reset': error.resetAt.toISOString(), + } + ) + } + return internalOrchestrationErrorPolicy.project(error) + }, + unhandled() { + return internalErrorResponse(500, { error: unhandledMessage }) + }, + } + return createInternalResourceConcealmentPolicy({ base, notFoundMessage }) +} diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/route.test.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/route.test.ts new file mode 100644 index 00000000000..2bbb4e1b378 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/route.test.ts @@ -0,0 +1,104 @@ +/** + * @vitest-environment node + */ + +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + create: vi.fn(), + getSession: vi.fn(), + list: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession })) + +vi.mock('@/lib/credential-groups/application/manage-groups', () => ({ + createCredentialGroupSettings: { + operation: { id: 'credential_groups.create' }, + execute: mocks.create, + }, + listCredentialGroupSettings: { + operation: { id: 'credential_groups.settings.list' }, + execute: mocks.list, + }, +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { CredentialGroupProviderConfigurationError } from '@/lib/credential-groups/provider-adapter' +import { GET, POST } from '@/app/api/workspaces/[id]/credential-groups/route' + +const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111' +const context = { params: Promise.resolve({ id: WORKSPACE_ID }) } + +function createRequest(method: 'GET' | 'POST', body?: Record): NextRequest { + return new NextRequest(`http://localhost:3000/api/workspaces/${WORKSPACE_ID}/credential-groups`, { + method, + ...(body + ? { body: JSON.stringify(body), headers: { 'content-type': 'application/json' } } + : {}), + }) +} + +describe('credential groups collection route', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getSession.mockResolvedValue({ + user: { id: 'user-1' }, + session: { id: 'session-1' }, + }) + mocks.list.mockResolvedValue({ credentialGroups: [] }) + }) + + it('authenticates before parsing the request body', async () => { + mocks.getSession.mockResolvedValue(null) + + const response = await POST(createRequest('POST', {}), context) + + expect(response.status).toBe(401) + expect(mocks.create).not.toHaveBeenCalled() + }) + + it('enters the application use case with the authenticated session principal', async () => { + const request = createRequest('GET') + const response = await GET(request, context) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ credentialGroups: [] }) + expect(mocks.list).toHaveBeenCalledWith({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: WORKSPACE_ID }, + request, + }) + }) + + it('preserves concealed entitlement failures from the application boundary', async () => { + mocks.list.mockRejectedValue( + new OrchestrationError('not_found', 'Credential Groups are not available') + ) + + const response = await GET(createRequest('GET'), context) + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ error: 'Credential Groups are not available' }) + }) + + it('fails fast when managed Gmail OAuth is not configured', async () => { + mocks.create.mockRejectedValue( + new CredentialGroupProviderConfigurationError('Managed Gmail authorization is not configured') + ) + + const response = await POST( + createRequest('POST', { + name: 'Support inboxes', + options: [{ provider: 'gmail', label: 'Gmail', required: true }], + }), + context + ) + + expect(response.status).toBe(503) + expect(await response.json()).toEqual({ + error: 'Managed Gmail authorization is not configured', + }) + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/route.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/route.ts new file mode 100644 index 00000000000..c776f985698 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/route.ts @@ -0,0 +1,45 @@ +import { + createCredentialGroupContract, + listCredentialGroupsContract, +} from '@/lib/api/contracts/credential-groups' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + createCredentialGroupSettings, + listCredentialGroupSettings, +} from '@/lib/credential-groups/application/manage-groups' +import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' +import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy' + +export const GET = defineInternalJsonRoute({ + contract: listCredentialGroupsContract, + auth: internalSessionAuth, + operation: credentialGroupOperations.listSettings, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal Credential Group list behavior', + }), + errorPolicy: createCredentialGroupInternalErrorPolicy( + 'Failed to list credential groups', + 'Workspace not found' + ), + mapInput: ({ params }) => ({ workspaceId: params.id }), + useCase: listCredentialGroupSettings, +}) + +export const POST = defineInternalJsonRoute({ + contract: createCredentialGroupContract, + auth: internalSessionAuth, + operation: credentialGroupOperations.create, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal Credential Group create behavior', + }), + errorPolicy: createCredentialGroupInternalErrorPolicy( + 'Failed to create credential group', + 'Workspace not found' + ), + mapInput: ({ params, body }) => ({ workspaceId: params.id, credentialGroup: body }), + useCase: createCredentialGroupSettings, +}) diff --git a/apps/sim/app/credential-groups/enroll/[token]/oauth-reconnect-link.test.tsx b/apps/sim/app/credential-groups/enroll/[token]/oauth-reconnect-link.test.tsx new file mode 100644 index 00000000000..7df36ad1d79 --- /dev/null +++ b/apps/sim/app/credential-groups/enroll/[token]/oauth-reconnect-link.test.tsx @@ -0,0 +1,27 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@sim/emcn', () => ({ + chipVariants: () => 'chip', +})) + +import { OAuthConnectLink } from '@/app/credential-groups/enroll/[token]/oauth-reconnect-link' + +describe('OAuthConnectLink', () => { + it('presents enrollment authorization as Connect', () => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const container = document.createElement('div') + const root = createRoot(container) + + act(() => root.render()) + + const link = container.querySelector('a') + expect(link?.textContent).toBe('Connect') + expect(link?.getAttribute('href')).toBe('/oauth/start') + act(() => root.unmount()) + }) +}) diff --git a/apps/sim/app/credential-groups/enroll/[token]/oauth-reconnect-link.tsx b/apps/sim/app/credential-groups/enroll/[token]/oauth-reconnect-link.tsx new file mode 100644 index 00000000000..f460446b24f --- /dev/null +++ b/apps/sim/app/credential-groups/enroll/[token]/oauth-reconnect-link.tsx @@ -0,0 +1,15 @@ +'use client' + +import { chipVariants } from '@sim/emcn' + +interface OAuthConnectLinkProps { + href: string +} + +export function OAuthConnectLink({ href }: OAuthConnectLinkProps) { + return ( + + Connect + + ) +} diff --git a/apps/sim/app/credential-groups/enroll/[token]/oauth-toast.test.tsx b/apps/sim/app/credential-groups/enroll/[token]/oauth-toast.test.tsx new file mode 100644 index 00000000000..b311fdaf86e --- /dev/null +++ b/apps/sim/app/credential-groups/enroll/[token]/oauth-toast.test.tsx @@ -0,0 +1,74 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockError, mockSetOAuthStatus, mockSuccess } = vi.hoisted(() => ({ + mockError: vi.fn(), + mockSetOAuthStatus: vi.fn().mockResolvedValue(null), + mockSuccess: vi.fn(), +})) + +vi.mock('@sim/emcn', () => ({ + useToast: () => ({ + toast: { + error: mockError, + success: mockSuccess, + }, + }), +})) + +vi.mock('nuqs', () => ({ + useQueryStates: () => [{}, mockSetOAuthStatus], +})) + +import { CredentialGroupOAuthToast } from '@/app/credential-groups/enroll/[token]/oauth-toast' + +function renderToast(variant: 'success' | 'error', message: string): Root { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const root = createRoot(document.createElement('div')) + act(() => root.render()) + return root +} + +describe('CredentialGroupOAuthToast', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('shows OAuth success once and removes callback state from the URL', () => { + const root = renderToast('success', 'Gmail connected successfully.') + + expect(mockSuccess).toHaveBeenCalledOnce() + expect(mockSuccess).toHaveBeenCalledWith('Gmail connected successfully.') + expect(mockSetOAuthStatus).toHaveBeenCalledWith( + { connected: null, oauth: null, submitted: null }, + { history: 'replace', scroll: false } + ) + act(() => root.unmount()) + }) + + it('uses the error toast and preserves unrelated query parameters', () => { + const root = renderToast('error', 'Authorization was canceled.') + + expect(mockError).toHaveBeenCalledWith('Authorization was canceled.') + expect(mockSetOAuthStatus).toHaveBeenCalledWith( + { connected: null, oauth: null, submitted: null }, + { history: 'replace', scroll: false } + ) + act(() => root.unmount()) + }) + + it('removes the submitted state after showing the completion toast', () => { + const root = renderToast('success', 'Accounts submitted successfully.') + + expect(mockSuccess).toHaveBeenCalledWith('Accounts submitted successfully.') + expect(mockSetOAuthStatus).toHaveBeenCalledWith( + { connected: null, oauth: null, submitted: null }, + { history: 'replace', scroll: false } + ) + act(() => root.unmount()) + }) +}) diff --git a/apps/sim/app/credential-groups/enroll/[token]/oauth-toast.tsx b/apps/sim/app/credential-groups/enroll/[token]/oauth-toast.tsx new file mode 100644 index 00000000000..c3029508020 --- /dev/null +++ b/apps/sim/app/credential-groups/enroll/[token]/oauth-toast.tsx @@ -0,0 +1,38 @@ +'use client' + +import { useEffect, useRef } from 'react' +import { useToast } from '@sim/emcn' +import { useQueryStates } from 'nuqs' +import { + credentialGroupEnrollmentStatusParsers, + credentialGroupEnrollmentStatusUrlKeys, +} from '@/app/credential-groups/enroll/[token]/search-params' + +interface CredentialGroupOAuthToastProps { + message: string + variant: 'success' | 'error' +} + +export function CredentialGroupOAuthToast({ message, variant }: CredentialGroupOAuthToastProps) { + const { toast } = useToast() + const [, setOAuthStatus] = useQueryStates( + credentialGroupEnrollmentStatusParsers, + credentialGroupEnrollmentStatusUrlKeys + ) + const shownRef = useRef(false) + + useEffect(() => { + if (shownRef.current) return + shownRef.current = true + + if (variant === 'success') toast.success(message) + else toast.error(message) + + void setOAuthStatus( + { connected: null, oauth: null, submitted: null }, + { history: 'replace', scroll: false } + ) + }, [message, setOAuthStatus, toast, variant]) + + return null +} diff --git a/apps/sim/app/credential-groups/enroll/[token]/page.tsx b/apps/sim/app/credential-groups/enroll/[token]/page.tsx new file mode 100644 index 00000000000..29e8ac35ac3 --- /dev/null +++ b/apps/sim/app/credential-groups/enroll/[token]/page.tsx @@ -0,0 +1,191 @@ +import { type ReactNode, Suspense } from 'react' +import { Chip, ToastProvider } from '@sim/emcn' +import type { Metadata } from 'next' +import { headers } from 'next/headers' +import { asOrchestrationError } from '@/lib/core/orchestration/types' +import { authenticateCredentialGroupEnrollment } from '@/lib/credential-groups/application/enrollment-auth' +import { readPublicCredentialGroupEnrollment } from '@/lib/credential-groups/application/public-enrollment' +import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers' +import { enforcePublicCredentialGroupIpRateLimit } from '@/lib/credential-groups/rate-limit' +import { SupportFooter } from '@/app/(auth)/components' +import { LogoShell } from '@/app/(landing)/components' +import { OAuthConnectLink } from '@/app/credential-groups/enroll/[token]/oauth-reconnect-link' +import { CredentialGroupOAuthToast } from '@/app/credential-groups/enroll/[token]/oauth-toast' +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' + +export const metadata: Metadata = { + title: 'Connect accounts', + robots: { index: false, follow: false }, +} + +export const dynamic = 'force-dynamic' + +interface CredentialGroupEnrollmentPageProps { + params: Promise<{ token: string }> + searchParams: Promise> +} + +interface PageShellProps { + children: ReactNode +} + +function PageShell({ children }: PageShellProps) { + return ( + + }> +
+ {children} +
+
+
+ ) +} + +function UnavailableInvitation({ rateLimited = false }: { rateLimited?: boolean }) { + return ( + +
+

+ {rateLimited ? 'Too many requests' : 'Invitation unavailable'} +

+

+ {rateLimited + ? 'This link has been opened too many times. Wait a few minutes and try again.' + : 'This private link is invalid, expired, or has been revoked. Ask the workspace admin to send a new invitation.'} +

+
+
+ ) +} + +const OAUTH_MESSAGES = { + denied: 'Authorization was canceled. Nothing was connected.', + account_mismatch: 'Choose the account matching the email address on this invitation.', + permissions_required: 'All requested permissions are required to connect this account.', + configuration_changed: 'This credential option changed. Reload the page and try again.', + rate_limited: 'Too many authorization attempts. Wait a few minutes and try again.', + incomplete: 'Connect every account before submitting.', + unavailable: 'Account authorization is temporarily unavailable. Please try again.', + failed: 'Account authorization did not complete. Please try again.', +} as const + +function getSearchParam( + searchParams: Record, + key: string +): string | undefined { + const value = searchParams[key] + return Array.isArray(value) ? value[0] : value +} + +export default async function CredentialGroupEnrollmentPage({ + params, + searchParams, +}: CredentialGroupEnrollmentPageProps) { + const requestHeaders = await headers() + const limited = await enforcePublicCredentialGroupIpRateLimit( + { headers: requestHeaders }, + 'metadata' + ) + if (limited) return + + const { token } = await params + if (!token || token.length > 128) return + + const principal = await authenticateCredentialGroupEnrollment(token) + if (!principal) return + const enrollmentResult = await readPublicCredentialGroupEnrollment + .execute({ principal, input: {} }) + .catch((error: unknown) => { + if (asOrchestrationError(error)?.code === 'not_found') return null + throw error + }) + if (!enrollmentResult) return + const { enrollment } = enrollmentResult + + const resolvedSearchParams = await searchParams + const oauthStatus = getSearchParam(resolvedSearchParams, 'oauth') + const connectedOptionId = getSearchParam(resolvedSearchParams, 'connected') + const submitted = getSearchParam(resolvedSearchParams, 'submitted') + const oauthMessage = + oauthStatus && oauthStatus in OAUTH_MESSAGES + ? OAUTH_MESSAGES[oauthStatus as keyof typeof OAUTH_MESSAGES] + : null + const activeOptions = enrollment.options.filter((option) => option.status === 'active') + const connectedOption = connectedOptionId + ? activeOptions.find((option) => option.id === connectedOptionId) + : undefined + const notification = submitted + ? { message: 'Accounts submitted successfully.', variant: 'success' as const } + : connectedOptionId + ? { + message: `${connectedOption ? getCredentialGroupProviderService(connectedOption.provider).name : 'Account'} connected successfully.`, + variant: 'success' as const, + } + : oauthMessage + ? { message: oauthMessage, variant: 'error' as const } + : null + const allConnected = + activeOptions.length > 0 && + activeOptions.every( + (option) => option.connections.length === 1 && option.connections[0]?.status === 'connected' + ) + + return ( + + {notification && ( + + + + )} +
+

+ Connect your accounts +

+

+ {enrollment.inviterName}{' '} + invited you to connect accounts for{' '} + {enrollment.workspaceName}. +

+
+ +
+ +
+ {activeOptions.map((option) => { + const ProviderIcon = getCredentialGroupProviderService(option.provider).icon + const connection = option.connections[0] + return ( + } + title={option.label} + description={connection?.email ?? 'Not connected'} + trailing={ + + } + /> + ) + })} +
+
+ {(allConnected || enrollment.status === 'completed') && ( +
+ + {enrollment.status === 'completed' ? 'Submitted' : 'Submit'} + +
+ )} +
+
+ ) +} diff --git a/apps/sim/app/credential-groups/enroll/[token]/search-params.ts b/apps/sim/app/credential-groups/enroll/[token]/search-params.ts new file mode 100644 index 00000000000..0b6411b0423 --- /dev/null +++ b/apps/sim/app/credential-groups/enroll/[token]/search-params.ts @@ -0,0 +1,13 @@ +import { parseAsString } from 'nuqs/server' + +/** One-shot OAuth result signals are nullable because absence means no toast. */ +export const credentialGroupEnrollmentStatusParsers = { + connected: parseAsString, + oauth: parseAsString, + submitted: parseAsString, +} as const + +export const credentialGroupEnrollmentStatusUrlKeys = { + history: 'replace', + clearOnDefault: true, +} as const diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx index a53c18c9452..9b54c76899d 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx @@ -60,11 +60,13 @@ vi.mock('@/lib/billing/core/subscription', () => ({ })) vi.mock('@/lib/core/config/env', () => ({ + env: {}, getEnv: vi.fn(), isTruthy: vi.fn(() => false), })) vi.mock('@/lib/core/config/env-flags', () => ({ + isAppConfigEnabled: false, isBillingEnabled: true, isHosted: true, })) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx index f3f0a652f3b..1a64d6206a7 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx @@ -14,6 +14,7 @@ import { isOrganizationOnEnterprisePlan } from '@/lib/billing' import { hasWorkspaceInboxAccess, hasWorkspaceSandboxAccess } from '@/lib/billing/core/subscription' import { getEnv, isTruthy } from '@/lib/core/config/env' import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags' +import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability' import { canOpenOrganizationSettingsSection } from '@/lib/organizations/settings-access' import { isPlatformAdmin } from '@/lib/permissions/super-user' import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' @@ -48,6 +49,7 @@ const TOP_LEVEL_REDIRECTS: Readonly stri const WORKSPACE_SECTION_MAP: Partial> = { teammates: 'teammates', secrets: 'secrets', + 'credential-groups': 'credential-groups', byok: 'byok', sandboxes: 'sandboxes', 'custom-tools': 'custom-tools', @@ -118,14 +120,16 @@ export default async function WorkspaceSettingsSectionPage({ const workspaceSection = WORKSPACE_SECTION_MAP[parsed] if (workspaceSection) { - const [permissionGroup, forksAvailable, inboxAvailable, sandboxes] = await Promise.all([ - hostContext.hostOrganizationId && hostContext.ownerBilling.isEnterprise - ? resolveWorkspaceGroup(session.user.id, hostContext.hostOrganizationId, workspaceId) - : null, - isForkingAvailableForWorkspace(hostContext.hostOrganizationId, session.user.id), - hasWorkspaceInboxAccess(workspaceId), - hasWorkspaceSandboxAccess(workspaceId), - ]) + const [permissionGroup, forksAvailable, inboxAvailable, sandboxes, credentialGroupsAvailable] = + await Promise.all([ + hostContext.hostOrganizationId && hostContext.ownerBilling.isEnterprise + ? resolveWorkspaceGroup(session.user.id, hostContext.hostOrganizationId, workspaceId) + : null, + isForkingAvailableForWorkspace(hostContext.hostOrganizationId, session.user.id), + hasWorkspaceInboxAccess(workspaceId), + hasWorkspaceSandboxAccess(workspaceId), + isCredentialGroupsAvailable(hostContext.ownerBilling), + ]) const customBlocksAvailable = isHosted ? hostContext.ownerBilling.isEnterprise : isTruthy(getEnv('NEXT_PUBLIC_CUSTOM_BLOCKS_ENABLED')) @@ -134,6 +138,7 @@ export default async function WorkspaceSettingsSectionPage({ permissionConfig: permissionGroup?.config ?? {}, entitlements: { byok: isHosted, + credentialGroups: credentialGroupsAvailable, inbox: inboxAvailable, customBlocks: customBlocksAvailable, forks: forksAvailable, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts index bde90b3f029..3f85cdc3d8b 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts @@ -80,6 +80,30 @@ export const groupIdUrlKeys = { clearOnDefault: true, } as const +/** `credential-group-id` deep-links Credential Groups to one collection's detail view. */ +export const credentialGroupIdParam = { + key: 'credential-group-id', + parser: parseAsString, +} as const + +/** Opening a credential group is a destination; closing replaces the detail URL. */ +export const credentialGroupIdUrlKeys = { + history: 'push', + clearOnDefault: true, +} as const + +/** Active view inside a credential-group detail page. */ +export const credentialGroupTabParam = { + key: 'credential-group-tab', + parser: parseAsStringLiteral(['details', 'people'] as const).withDefault('details'), +} as const + +/** Tab view-state: clean URLs, no back-stack churn. */ +export const credentialGroupTabUrlKeys = { + history: 'replace', + clearOnDefault: true, +} as const + /** * `group-tab` is the active tab inside the deep-linked permission-group detail * view, so a shared `group-id` link can land on the same tab (mirrors diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx index 632e818dec1..9ba369ef92a 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx @@ -84,6 +84,9 @@ 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) ) @@ -158,6 +161,9 @@ export function SettingsPage({ section }: SettingsPageProps) { {effectiveSection === 'browser' && } {effectiveSection === 'terminal' && } {effectiveSection === 'secrets' && } + {effectiveSection === 'credential-groups' && ( + + )} {effectiveSection === 'access-control' && organizationId && ( { { id: 'teammates', label: 'Teammates', section: 'workspace' }, { id: 'organization', label: 'Members', section: 'organization' }, { id: 'secrets', label: 'Secrets', section: 'workspace' }, + { id: 'credential-groups', label: 'Credential groups', 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' }, @@ -68,6 +69,7 @@ describe('unified settings navigation', () => { expect(idsForSection('workspace')).toEqual([ 'teammates', 'secrets', + 'credential-groups', 'mcp', 'custom-tools', 'byok', diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx index bd1cd4d6114..4449886f4a0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx @@ -253,7 +253,9 @@ export function CredentialSelector({ const comboboxOptions = useMemo(() => { if (isAllCredentials) { - const oauthCredentials = allWorkspaceCredentials.filter((c) => c.type === 'oauth') + const oauthCredentials = allWorkspaceCredentials.filter( + (credential) => credential.type === 'oauth' + ) return oauthCredentials.map((cred) => ({ label: cred.displayName, value: cred.id })) } if (isMergedKinds) return [] @@ -409,7 +411,9 @@ export function CredentialSelector({ } const matchedCred = ( - isAllCredentials ? allWorkspaceCredentials.filter((c) => c.type === 'oauth') : credentials + isAllCredentials + ? allWorkspaceCredentials.filter((credential) => credential.type === 'oauth') + : credentials ).find((c) => c.id === value) if (matchedCred) { handleSelect(value) 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 504105afec6..3119c40fe2a 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 @@ -141,6 +141,12 @@ export function SettingsSidebar({ if (item.id === 'forks' && !(forkingAvailable && canAdminWorkspace)) { return false } + if ( + item.id === 'credential-groups' && + (!hostContext.features?.credentialGroups || !canAdminWorkspace) + ) { + return false + } if (item.selfHostedOverride && !isHosted) { /** diff --git a/apps/sim/blocks/blocks/credential-group.ts b/apps/sim/blocks/blocks/credential-group.ts new file mode 100644 index 00000000000..5b1fbd3e7cc --- /dev/null +++ b/apps/sim/blocks/blocks/credential-group.ts @@ -0,0 +1,365 @@ +import { GridOffset } from '@sim/emcn/icons' +import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers' +import { + type CanonicalGroup, + resolveActiveCanonicalValue, +} from '@/lib/workflows/subblocks/visibility' +import { getQueryClient } from '@/app/_shell/providers/get-query-client' +import type { BlockConfig } from '@/blocks/types' +import { + CREDENTIAL_GROUP_LIST_STALE_TIME, + credentialGroupKeys, + fetchCredentialGroupList, +} from '@/hooks/queries/utils/credential-group-queries' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' + +const CREDENTIAL_GROUP_CANONICAL_GROUP = { + canonicalId: 'credentialGroupId', + basicId: 'credentialGroup', + advancedIds: ['manualCredentialGroup'], +} as const satisfies CanonicalGroup + +async function fetchCachedCredentialGroups() { + const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId + if (!workspaceId) return [] + + return getQueryClient().fetchQuery({ + queryKey: credentialGroupKeys.list(workspaceId), + queryFn: ({ signal }) => fetchCredentialGroupList(workspaceId, signal), + staleTime: CREDENTIAL_GROUP_LIST_STALE_TIME, + }) +} + +function resolveCredentialGroupIdForBlock(blockId: string): string | null { + const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId + if (!activeWorkflowId) return null + const values = useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[blockId] ?? {} + const canonicalModes = useWorkflowStore.getState().blocks[blockId]?.data?.canonicalModes + const value = resolveActiveCanonicalValue( + CREDENTIAL_GROUP_CANONICAL_GROUP, + values, + canonicalModes + ) + return typeof value === 'string' && value.trim() ? value.trim() : null +} + +interface CredentialGroupBlockOutput { + success: boolean + output: { + credentials: Array<{ + credentialId: string + email: string + displayName: string + providerId: string + providerSubjectId: string + providerTenantId: string | null + }> + credentialGroups: Array<{ + id: string + name: string + description: string | null + status: 'active' | 'disabled' + providerIds: string[] + createdAt: string + updatedAt: string + }> + people: Array<{ + id: string + email: string + status: string + expired: boolean + invitedAt: string + connections: Array<{ provider: string; status: string; count: number }> + }> + enrollmentId: string + email: string + status: string + invitedAt: string + expiresAt: string + count: number + hasMore: boolean + nextCursor: string | null + } +} + +const GROUP_OPERATIONS = ['list_credentials', 'send_invite', 'list_people'] as const +const LIST_OPERATIONS = ['list_credentials', 'list_people', 'list_groups'] as const + +export const CredentialGroupBlock: BlockConfig = { + type: 'credential_group', + name: 'Credential Groups', + description: 'Invite people and use credentials collected by Credential Groups', + longDescription: + 'List usable managed credentials, inspect invited people, send an account-connection invitation, or discover Credential Groups in the current workspace. The block returns credential IDs and account metadata without exposing OAuth tokens.', + bestPractices: ` + - Use "List Credentials" with a ForEach loop to run a provider block once for every connected account. + - Filter by email to select credentials belonging to one invited person, by provider to select one account type, or by both for an exact match. + - Continue with nextCursor until hasMore is false when a list operation returns multiple pages. + - "List Credentials" returns active, usable credentials only. Reconnect-needed and revoked credentials are excluded. + - Use "List People" to inspect invitation and connection progress without exposing credential secrets. + - "Send Invite" sends one email. Use a loop when invitations should come from a dynamic list. + `, + docsLink: 'https://docs.sim.ai/workflows/blocks/credential-group', + bgColor: '#7C3AED', + icon: GridOffset, + canvasPresentation: { + defaultTitle: 'Credential Groups', + sentences: { + byOperation: { + list_credentials: [ + { + text: 'List credentials from', + field: ['credentialGroup', 'manualCredentialGroup'], + core: true, + }, + { text: ', for', field: 'email' }, + { text: ', from', field: ['providerFilter', 'manualProviderIds'] }, + { text: ', up to', field: 'limit', after: 'credentials' }, + ], + send_invite: [ + { text: 'Invite', field: 'email', core: true }, + { + text: 'to', + field: ['credentialGroup', 'manualCredentialGroup'], + core: true, + }, + ], + list_people: [ + { + text: 'List people in', + field: ['credentialGroup', 'manualCredentialGroup'], + core: true, + }, + { text: ', matching', field: 'email' }, + { text: ', with status', field: 'peopleStatuses' }, + ], + list_groups: ['List Credential Groups', { text: ', up to', field: 'limit' }], + }, + }, + }, + category: 'blocks', + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'List Credentials', id: 'list_credentials' }, + { label: 'Send Invite', id: 'send_invite' }, + { label: 'List People', id: 'list_people' }, + { label: 'List Credential Groups', id: 'list_groups' }, + ], + value: () => 'list_credentials', + }, + { + id: 'credentialGroup', + title: 'Credential Group', + type: 'dropdown', + options: [], + required: { field: 'operation', value: [...GROUP_OPERATIONS] }, + mode: 'basic', + canonicalParamId: 'credentialGroupId', + condition: { field: 'operation', value: [...GROUP_OPERATIONS] }, + fetchOptions: async () => { + const groups = await fetchCachedCredentialGroups() + return groups + .filter((group) => group.status === 'active') + .map((group) => ({ label: group.name, id: group.id })) + .sort((a, b) => a.label.localeCompare(b.label)) + }, + fetchOptionById: async (_blockId: string, optionId: string) => { + const groups = await fetchCachedCredentialGroups() + const group = groups.find((candidate) => candidate.id === optionId) + return group ? { label: group.name, id: group.id } : null + }, + }, + { + id: 'manualCredentialGroup', + title: 'Credential Group ID', + type: 'short-input', + required: { field: 'operation', value: [...GROUP_OPERATIONS] }, + mode: 'advanced', + placeholder: 'Enter credential group ID', + canonicalParamId: 'credentialGroupId', + condition: { field: 'operation', value: [...GROUP_OPERATIONS] }, + }, + { + id: 'email', + title: 'Email', + type: 'short-input', + required: { field: 'operation', value: 'send_invite' }, + placeholder: 'person@example.com', + condition: { field: 'operation', value: [...GROUP_OPERATIONS] }, + }, + { + id: 'providerFilter', + title: 'Provider', + type: 'dropdown', + multiSelect: true, + emptyIsValid: true, + options: [], + required: false, + mode: 'basic', + canonicalParamId: 'credentialProviderIds', + dependsOn: ['credentialGroupId'], + condition: { field: 'operation', value: 'list_credentials' }, + fetchOptions: async (blockId: string) => { + const credentialGroupId = resolveCredentialGroupIdForBlock(blockId) + if (!credentialGroupId) return [] + const groups = await fetchCachedCredentialGroups() + const group = groups.find((candidate) => candidate.id === credentialGroupId) + if (!group) return [] + return group.options + .filter((option) => option.status === 'active') + .map((option) => { + const service = getCredentialGroupProviderService(option.provider) + return { id: service.providerId, label: service.name } + }) + .sort((a, b) => a.label.localeCompare(b.label)) + }, + fetchOptionById: async (blockId: string, optionId: string) => { + const credentialGroupId = resolveCredentialGroupIdForBlock(blockId) + if (!credentialGroupId) return null + const groups = await fetchCachedCredentialGroups() + const group = groups.find((candidate) => candidate.id === credentialGroupId) + const option = group?.options.find( + (candidate) => + candidate.status === 'active' && + getCredentialGroupProviderService(candidate.provider).providerId === optionId + ) + if (!option) return null + return { + id: optionId, + label: getCredentialGroupProviderService(option.provider).name, + } + }, + }, + { + id: 'manualProviderIds', + title: 'Provider IDs', + type: 'short-input', + required: false, + mode: 'advanced', + canonicalParamId: 'credentialProviderIds', + dependsOn: ['credentialGroupId'], + placeholder: '["google-email", "slack"] — leave empty for all providers', + condition: { field: 'operation', value: 'list_credentials' }, + }, + { + id: 'peopleStatuses', + title: 'Status', + type: 'dropdown', + multiSelect: true, + emptyIsValid: true, + options: [ + { label: 'Invited', id: 'invited' }, + { label: 'Delivery failed', id: 'delivery_failed' }, + { label: 'In progress', id: 'in_progress' }, + { label: 'Connected', id: 'completed' }, + { label: 'Revoked', id: 'revoked' }, + ], + condition: { field: 'operation', value: 'list_people' }, + }, + { + id: 'limit', + title: 'Limit', + type: 'short-input', + value: () => '100', + mode: 'advanced', + placeholder: '1-100', + condition: { field: 'operation', value: [...LIST_OPERATIONS] }, + }, + { + id: 'cursor', + title: 'Cursor', + type: 'short-input', + mode: 'advanced', + placeholder: 'nextCursor from a previous page', + condition: { field: 'operation', value: [...LIST_OPERATIONS] }, + }, + ], + tools: { access: [] }, + inputs: { + operation: { + type: 'string', + description: "'list_credentials', 'send_invite', 'list_people', or 'list_groups'", + }, + credentialGroupId: { type: 'string', description: 'Credential Group ID' }, + email: { + type: 'string', + description: 'Recipient email for invites or exact email filter for list operations', + }, + credentialProviderIds: { + type: 'json', + description: 'Optional OAuth provider IDs to include when listing credentials', + }, + peopleStatuses: { + type: 'json', + description: 'Optional invitation statuses to include when listing people', + }, + limit: { type: 'number', description: 'Maximum results per page (1-100)' }, + cursor: { type: 'string', description: 'nextCursor from a previous page' }, + }, + outputs: { + credentials: { + type: 'json', + description: + 'Usable credential references (credentialId, email, displayName, providerId, providerSubjectId, providerTenantId)', + condition: { field: 'operation', value: 'list_credentials' }, + }, + credentialGroups: { + type: 'json', + description: + 'Credential Group summaries (id, name, description, status, providerIds, createdAt, updatedAt)', + condition: { field: 'operation', value: 'list_groups' }, + }, + people: { + type: 'json', + description: + 'Invited people and current connection summaries (id, email, status, expired, invitedAt, connections)', + condition: { field: 'operation', value: 'list_people' }, + }, + enrollmentId: { + type: 'string', + description: 'Enrollment ID created or refreshed by the invitation', + condition: { field: 'operation', value: 'send_invite' }, + }, + email: { + type: 'string', + description: 'Normalized invitation recipient email', + condition: { field: 'operation', value: 'send_invite' }, + }, + status: { + type: 'string', + description: 'Invitation status', + condition: { field: 'operation', value: 'send_invite' }, + }, + invitedAt: { + type: 'string', + description: 'Invitation timestamp', + condition: { field: 'operation', value: 'send_invite' }, + }, + expiresAt: { + type: 'string', + description: 'Invitation expiration timestamp', + condition: { field: 'operation', value: 'send_invite' }, + }, + count: { + type: 'number', + description: 'Number of records returned', + condition: { field: 'operation', value: [...LIST_OPERATIONS] }, + }, + hasMore: { + type: 'boolean', + description: 'Whether another page is available', + condition: { field: 'operation', value: [...LIST_OPERATIONS] }, + }, + nextCursor: { + type: 'string', + description: 'Cursor for the next page, or null on the last page', + condition: { field: 'operation', value: [...LIST_OPERATIONS] }, + }, + }, +} diff --git a/apps/sim/blocks/registry-maps.ts b/apps/sim/blocks/registry-maps.ts index 733cf047f69..d12e0beade3 100644 --- a/apps/sim/blocks/registry-maps.ts +++ b/apps/sim/blocks/registry-maps.ts @@ -46,6 +46,7 @@ import { ConfluenceBlock, ConfluenceBlockMeta, ConfluenceV2Block } from '@/block import { ContextDevBlock, ContextDevBlockMeta } from '@/blocks/blocks/context_dev' import { ConvexBlock, ConvexBlockMeta } from '@/blocks/blocks/convex' import { CredentialBlock } from '@/blocks/blocks/credential' +import { CredentialGroupBlock } from '@/blocks/blocks/credential-group' import { CrowdStrikeBlock, CrowdStrikeBlockMeta } from '@/blocks/blocks/crowdstrike' import { CursorBlock, CursorBlockMeta, CursorV2Block } from '@/blocks/blocks/cursor' import { DagsterBlock, DagsterBlockMeta } from '@/blocks/blocks/dagster' @@ -404,6 +405,7 @@ export const BLOCK_REGISTRY: Record = { context_dev: ContextDevBlock, convex: ConvexBlock, credential: CredentialBlock, + credential_group: CredentialGroupBlock, crowdstrike: CrowdStrikeBlock, cursor: CursorBlock, cursor_v2: CursorV2Block, diff --git a/apps/sim/components/emails/credential-groups/credential-group-invitation-email.tsx b/apps/sim/components/emails/credential-groups/credential-group-invitation-email.tsx new file mode 100644 index 00000000000..1e2383a9579 --- /dev/null +++ b/apps/sim/components/emails/credential-groups/credential-group-invitation-email.tsx @@ -0,0 +1,50 @@ +import { Link, Text } from '@react-email/components' +import { baseStyles } from '@/components/emails/_styles' +import { EmailLayout } from '@/components/emails/components' +import { getBrandConfig } from '@/ee/whitelabeling' + +interface CredentialGroupInvitationEmailProps { + recipientEmail: string + inviterName: string + workspaceName: string + credentialGroupName: string + invitationLink: string +} + +export function CredentialGroupInvitationEmail({ + recipientEmail, + inviterName, + workspaceName, + credentialGroupName, + invitationLink, +}: CredentialGroupInvitationEmailProps) { + const brand = getBrandConfig() + + return ( + + Hello, + + {inviterName} invited {recipientEmail} to connect accounts + for {credentialGroupName} in the {workspaceName} workspace + on {brand.name}. + + + + Connect Accounts + + +
+ + + This private link expires in 7 days. {brand.name} will send you to each provider to sign in + and will never ask for your provider password. If you did not expect this invitation, you + can ignore it. + + + ) +} + +export default CredentialGroupInvitationEmail diff --git a/apps/sim/components/emails/credential-groups/index.ts b/apps/sim/components/emails/credential-groups/index.ts new file mode 100644 index 00000000000..fec4e2d1428 --- /dev/null +++ b/apps/sim/components/emails/credential-groups/index.ts @@ -0,0 +1 @@ +export { CredentialGroupInvitationEmail } from './credential-group-invitation-email' diff --git a/apps/sim/components/emails/credential-groups/render.ts b/apps/sim/components/emails/credential-groups/render.ts new file mode 100644 index 00000000000..63779541237 --- /dev/null +++ b/apps/sim/components/emails/credential-groups/render.ts @@ -0,0 +1,12 @@ +import { render } from '@react-email/render' +import { CredentialGroupInvitationEmail } from '@/components/emails/credential-groups/credential-group-invitation-email' + +export async function renderCredentialGroupInvitationEmail(params: { + recipientEmail: string + inviterName: string + workspaceName: string + credentialGroupName: string + invitationLink: string +}): Promise { + return await render(CredentialGroupInvitationEmail(params)) +} diff --git a/apps/sim/components/emails/subjects.ts b/apps/sim/components/emails/subjects.ts index b8df1a5f27f..a56b542d606 100644 --- a/apps/sim/components/emails/subjects.ts +++ b/apps/sim/components/emails/subjects.ts @@ -103,3 +103,11 @@ export function getRequestConfirmationSubject(userSubject: string, requestType?: export function getOtpSubject(resourceLabel: string): string { return `Verification code for ${resourceLabel}` } + +/** Names both the inviter and workspace so an external recipient can identify the request. */ +export function getCredentialGroupInvitationSubject( + inviterName: string, + workspaceName: string +): string { + return `${inviterName} invited you to connect accounts for ${workspaceName} on ${getBrandConfig().name}` +} diff --git a/apps/sim/components/settings/navigation.test.ts b/apps/sim/components/settings/navigation.test.ts index d23e5bab48e..6a66e3ed75a 100644 --- a/apps/sim/components/settings/navigation.test.ts +++ b/apps/sim/components/settings/navigation.test.ts @@ -50,6 +50,7 @@ describe('settings navigation boundaries', () => { 'teammates', 'organization', 'secrets', + 'credential-groups', 'custom-tools', 'mcp', 'apikeys', @@ -99,6 +100,7 @@ describe('settings navigation boundaries', () => { 'inbox', 'recently-deleted', 'forks', + 'credential-groups', 'custom-blocks', 'self-host', ]) @@ -114,6 +116,7 @@ describe('settings navigation boundaries', () => { permissionConfig: {}, entitlements: { byok: true, + credentialGroups: true, inbox: true, customBlocks: true, forks: true, @@ -139,6 +142,7 @@ describe('settings navigation boundaries', () => { permissionConfig: {}, entitlements: { byok: true, + credentialGroups: true, inbox: true, customBlocks: true, forks: true, @@ -158,6 +162,7 @@ describe('settings navigation boundaries', () => { permissionConfig: {}, entitlements: { byok: true, + credentialGroups: true, inbox: true, customBlocks: true, forks: true, @@ -450,6 +455,7 @@ describe('settings navigation boundaries', () => { permissionConfig: {}, entitlements: { byok: true, + credentialGroups: true, customBlocks: true, forks: true, inbox: true, @@ -474,6 +480,7 @@ describe('settings navigation boundaries', () => { }, entitlements: { byok: true, + credentialGroups: true, customBlocks: true, forks: true, inbox: true, @@ -488,6 +495,7 @@ describe('settings navigation boundaries', () => { 'workflow-mcp-servers', 'recently-deleted', 'forks', + 'credential-groups', 'custom-blocks', 'self-host', ]) diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts index 514dc2bd7d6..d7707e37bcb 100644 --- a/apps/sim/components/settings/navigation.ts +++ b/apps/sim/components/settings/navigation.ts @@ -5,6 +5,7 @@ import { Credit, Database, Globe, + GridOffset, HexSimple, Key, KeySquare, @@ -64,6 +65,7 @@ export type OrganizationSettingsSection = export type WorkspaceSettingsSection = | 'teammates' | 'secrets' + | 'credential-groups' | 'byok' | 'sandboxes' | 'custom-tools' @@ -99,6 +101,7 @@ export type UnifiedSettingsSection = | 'browser' | 'terminal' | 'secrets' + | 'credential-groups' | 'access-control' | 'custom-blocks' | 'audit-logs' @@ -527,6 +530,22 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] workspace: { id: 'secrets', group: 'workspace', order: 1 }, }, }, + { + label: 'Credential groups', + icon: GridOffset, + unified: { + id: 'credential-groups', + description: 'Collect and manage OAuth credentials for people outside this workspace.', + group: 'workspace', + order: 2, + requiresEnterprise: true, + allowNonOrgAdmin: true, + selfHostedOverride: true, + }, + planes: { + workspace: { id: 'credential-groups', group: 'enterprise', order: 10 }, + }, + }, { label: 'Custom tools', icon: Wrench, @@ -948,6 +967,7 @@ export interface WorkspacePermissionConfig { export interface WorkspaceSettingsEntitlements { byok: boolean + credentialGroups: boolean customBlocks: boolean forks: boolean inbox: boolean @@ -982,6 +1002,7 @@ export interface ResolvedWorkspaceNavigationItem const WORKSPACE_MUTATION_PERMISSION: Record = { teammates: 'admin', secrets: 'write', + 'credential-groups': 'admin', byok: 'admin', sandboxes: 'admin', 'custom-tools': 'write', @@ -1021,6 +1042,12 @@ export function resolveWorkspaceNavigation({ if (item.id === 'mcp' && permissionConfig.disableMcpTools) return [] if (item.id === 'custom-tools' && permissionConfig.disableCustomTools) return [] if (item.id === 'forks' && (permission !== 'admin' || !entitlements.forks)) return [] + if ( + item.id === 'credential-groups' && + (permission !== 'admin' || !entitlements.credentialGroups) + ) { + return [] + } if (item.id === 'byok' && !entitlements.byok) return [] if (item.id === 'custom-blocks' && !entitlements.customBlocks) return [] // Absent on Sim Cloud, where the managed service owns these settings. diff --git a/apps/sim/connectors/microsoft-excel/microsoft-excel.ts b/apps/sim/connectors/microsoft-excel/microsoft-excel.ts index fc93d0537f4..80b321cdb16 100644 --- a/apps/sim/connectors/microsoft-excel/microsoft-excel.ts +++ b/apps/sim/connectors/microsoft-excel/microsoft-excel.ts @@ -274,7 +274,7 @@ async function fetchWorksheets(accessToken: string, basePath: string): Promise void + onCreated: (groupId: string) => void + workspaceId: string +} + +export function CredentialGroupCreateModal({ + open, + onOpenChange, + onCreated, + workspaceId, +}: CredentialGroupCreateModalProps) { + const createGroup = useCreateCredentialGroup() + const [name, setName] = useState('') + const [description, setDescription] = useState('') + + const reset = () => { + setName('') + setDescription('') + createGroup.reset() + } + + const handleOpenChange = (nextOpen: boolean) => { + if (createGroup.isPending) return + onOpenChange(nextOpen) + if (!nextOpen) reset() + } + + const handleCreate = async () => { + if (!name.trim() || createGroup.isPending) return + try { + const result = await createGroup.mutateAsync({ + workspaceId, + body: { + name: name.trim(), + description: description.trim() || undefined, + options: [], + }, + }) + onCreated(result.credentialGroup.id) + handleOpenChange(false) + } catch { + return + } + } + + return ( + + handleOpenChange(false)}> + Create credential group + + + + + + {createGroup.error ? getErrorMessage(createGroup.error) : null} + + + handleOpenChange(false)} + cancelDisabled={createGroup.isPending} + primaryAction={{ + label: createGroup.isPending ? 'Creating...' : 'Create', + onClick: handleCreate, + disabled: !name.trim() || createGroup.isPending, + }} + /> + + ) +} diff --git a/apps/sim/ee/credential-groups/components/credential-group-detail.test.ts b/apps/sim/ee/credential-groups/components/credential-group-detail.test.ts new file mode 100644 index 00000000000..e0f11afb3c8 --- /dev/null +++ b/apps/sim/ee/credential-groups/components/credential-group-detail.test.ts @@ -0,0 +1,38 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import type { CredentialGroupEnrollmentDetail } from '@/lib/api/contracts/credential-groups' +import { getEnrollmentStatus } from '@/ee/credential-groups/components/credential-group-detail' + +const ENROLLMENT: CredentialGroupEnrollmentDetail = { + id: 'enrollment-1', + credentialGroupId: 'group-1', + email: 'person@example.com', + status: 'in_progress', + expiresAt: '2026-08-13T00:00:00.000Z', + invitedAt: '2026-08-12T00:00:00.000Z', + sentAt: '2026-08-12T00:00:00.000Z', + completedAt: null, + revokedAt: null, + expired: true, + createdAt: '2026-08-12T00:00:00.000Z', + updatedAt: '2026-08-13T00:00:00.000Z', + connections: [{ provider: 'gmail', status: 'needs_reauth', count: 1 }], +} + +describe('Credential Group enrollment status', () => { + it('keeps expired incomplete invitations ahead of credential reauthorization', () => { + expect(getEnrollmentStatus(ENROLLMENT, ['gmail'])).toEqual({ + label: 'Expired', + invalid: true, + }) + }) + + it('shows reauthorization for completed enrollments after their invitation expires', () => { + expect(getEnrollmentStatus({ ...ENROLLMENT, status: 'completed' }, ['gmail'])).toEqual({ + label: 'Reconnect needed', + invalid: false, + }) + }) +}) diff --git a/apps/sim/ee/credential-groups/components/credential-group-detail.tsx b/apps/sim/ee/credential-groups/components/credential-group-detail.tsx new file mode 100644 index 00000000000..3bab5af6bc0 --- /dev/null +++ b/apps/sim/ee/credential-groups/components/credential-group-detail.tsx @@ -0,0 +1,301 @@ +'use client' + +import { useState } from 'react' +import { Chip, ChipConfirmModal, ChipModalTabs, ChipTag, toast } from '@sim/emcn' +import { ArrowLeft, KeySquare, Plus } from '@sim/emcn/icons' +import { getErrorMessage } from '@sim/utils/errors' +import { useQueryState } from 'nuqs' +import type { + CredentialGroupEnrollment, + CredentialGroupEnrollmentConnection, + CredentialGroupEnrollmentDetail, +} from '@/lib/api/contracts/credential-groups' +import type { CredentialGroupProvider } from '@/lib/credential-groups/providers' +import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers' +import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' +import { + credentialGroupTabParam, + credentialGroupTabUrlKeys, +} from '@/app/workspace/[workspaceId]/settings/[section]/search-params' +import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' +import { CredentialGroupDetails } from '@/ee/credential-groups/components/credential-group-details' +import { CredentialGroupInviteModal } from '@/ee/credential-groups/components/credential-group-invite-modal' +import { + useCredentialGroupDetail, + useResendCredentialGroupEnrollment, + useRevokeCredentialGroupEnrollment, +} from '@/hooks/queries/credential-groups' +import { useWorkspaceCredentials } from '@/hooks/queries/credentials' + +interface CredentialGroupDetailProps { + workspaceId: string + groupId: string + onBack: () => void +} + +type CredentialGroupTab = 'details' | 'people' + +const CREDENTIAL_GROUP_TABS = [ + { value: 'details', label: 'Details' }, + { value: 'people', label: 'People' }, +] as const + +export function getEnrollmentStatus( + enrollment: CredentialGroupEnrollmentDetail, + activeProviders: CredentialGroupProvider[] +) { + if (enrollment.status === 'revoked') return { label: 'Revoked', invalid: false } + if (enrollment.status === 'delivery_failed') return { label: 'Delivery failed', invalid: true } + if (enrollment.status !== 'completed' && enrollment.expired) { + return { label: 'Expired', invalid: true } + } + const needsReauthorization = enrollment.connections.some( + (connection) => connection.status === 'needs_reauth' + ) + if (needsReauthorization) return { label: 'Reconnect needed', invalid: false } + const connectedProviders = new Set( + enrollment.connections + .filter((connection) => connection.status === 'active') + .map((connection) => connection.provider) + ) + const allProvidersConnected = + activeProviders.length > 0 && + activeProviders.every((provider) => connectedProviders.has(provider)) + if (enrollment.status === 'completed' && allProvidersConnected) { + return { label: 'Connected', invalid: false } + } + if (enrollment.status === 'completed') return { label: 'In progress', invalid: false } + if (enrollment.status === 'in_progress') return { label: 'In progress', invalid: false } + return { label: 'Invited', invalid: false } +} + +interface EnrollmentConnectionsProps { + connections: CredentialGroupEnrollmentConnection[] +} + +interface CredentialProviderIconProps { + provider: CredentialGroupProvider +} + +function CredentialProviderIcon({ provider }: CredentialProviderIconProps) { + const ProviderIcon = getCredentialGroupProviderService(provider).icon + return +} + +function EnrollmentConnections({ connections }: EnrollmentConnectionsProps) { + const connected = connections.filter((connection) => connection.status === 'active') + const count = connected.reduce((total, connection) => total + connection.count, 0) + const providers = [...new Set(connected.map((connection) => connection.provider))] + + return ( + + {providers.map((provider) => { + return + })} + + {count} connected {count === 1 ? 'account' : 'accounts'} + + + ) +} + +export function CredentialGroupDetail({ + workspaceId, + groupId, + onBack, +}: CredentialGroupDetailProps) { + const detail = useCredentialGroupDetail(workspaceId, groupId) + const slackBots = useWorkspaceCredentials({ + workspaceId, + type: 'service_account', + providerId: SLACK_CUSTOM_BOT_PROVIDER_ID, + }) + const resend = useResendCredentialGroupEnrollment() + const revoke = useRevokeCredentialGroupEnrollment() + const [activeTab, setActiveTab] = useQueryState(credentialGroupTabParam.key, { + ...credentialGroupTabParam.parser, + ...credentialGroupTabUrlKeys, + }) + const [showInvite, setShowInvite] = useState(false) + const [revokingEnrollmentId, setRevokingEnrollmentId] = useState(null) + const credentialGroup = detail.data?.pages[0]?.credentialGroup + const enrollments = detail.data?.pages.flatMap((page) => page.enrollments) ?? [] + const revokingEnrollment = revokingEnrollmentId + ? (enrollments.find((enrollment) => enrollment.id === revokingEnrollmentId) ?? null) + : null + const activeProviders = + credentialGroup?.options + .filter((option) => option.status === 'active') + .map((option) => option.provider) ?? [] + const configurationReady = + Boolean(credentialGroup?.options.length) && + credentialGroup?.options.every( + (option) => + option.provider !== 'slack' || + (option.configurationStatus === 'ready' && + slackBots.data?.some((bot) => bot.id === option.slackBotCredentialId)) + ) + + const actions: SettingsAction[] = credentialGroup + ? [ + { + text: 'Invite users', + icon: Plus, + variant: 'primary', + onSelect: () => setShowInvite(true), + disabled: credentialGroup.status !== 'active' || !configurationReady, + }, + ] + : [] + + const handleResend = async (enrollment: CredentialGroupEnrollment) => { + try { + await resend.mutateAsync({ workspaceId, groupId, enrollmentId: enrollment.id }) + toast.success(`Invitation resent to ${enrollment.email}`) + } catch (error) { + toast.error(getErrorMessage(error, 'Failed to resend invitation')) + } + } + + const handleRevoke = async () => { + if (!revokingEnrollment) return + try { + await revoke.mutateAsync({ + workspaceId, + groupId, + enrollmentId: revokingEnrollment.id, + }) + toast.success(`Invitation revoked for ${revokingEnrollment.email}`) + setRevokingEnrollmentId(null) + } catch (error) { + toast.error(getErrorMessage(error, 'Failed to revoke invitation')) + } + } + + const handleBack = () => { + void setActiveTab(null, { history: 'replace' }) + onBack() + } + + return ( + <> + + {detail.error ? ( + + {getErrorMessage(detail.error, "Couldn't load credential group")} + + ) : detail.isPending || !credentialGroup ? null : ( +
+ void setActiveTab(value as CredentialGroupTab)} + aria-label='Credential group sections' + /> + + {activeTab === 'details' && ( + + )} + + {activeTab === 'people' && ( + void detail.fetchNextPage()} + disabled={detail.isFetchingNextPage} + > + {detail.isFetchingNextPage ? 'Loading...' : 'Load more'} + + ) : undefined + } + > + {enrollments.length === 0 ? ( + No people invited yet + ) : ( +
+ {enrollments.map((enrollment) => { + const status = getEnrollmentStatus(enrollment, activeProviders) + return ( + } + title={enrollment.email} + description={ + + } + badge={ + + {status.label} + + } + trailing={ + enrollment.status === 'revoked' ? undefined : ( + void handleResend(enrollment), + disabled: resend.isPending, + }, + { + label: 'Revoke', + destructive: true, + onSelect: () => setRevokingEnrollmentId(enrollment.id), + }, + ]} + /> + ) + } + /> + ) + })} +
+ )} +
+ )} +
+ )} +
+ {credentialGroup && ( + + )} + !open && !revoke.isPending && setRevokingEnrollmentId(null)} + srTitle='Revoke invitation' + title='Revoke invitation?' + text={`Revoke the invitation for ${revokingEnrollment?.email ?? 'this user'}? Their private link will stop working immediately.`} + dismissLabel='Cancel' + confirm={{ + label: revoke.isPending ? 'Revoking...' : 'Revoke', + onClick: handleRevoke, + disabled: revoke.isPending, + }} + /> + + ) +} diff --git a/apps/sim/ee/credential-groups/components/credential-group-details.tsx b/apps/sim/ee/credential-groups/components/credential-group-details.tsx new file mode 100644 index 00000000000..501f0a7b366 --- /dev/null +++ b/apps/sim/ee/credential-groups/components/credential-group-details.tsx @@ -0,0 +1,299 @@ +'use client' + +import { useState } from 'react' +import { Chip, ChipConfirmModal, ChipInput, ChipTag, ChipTextarea, toast } from '@sim/emcn' +import { getErrorMessage } from '@sim/utils/errors' +import type { + CredentialGroup, + CredentialGroupOption, + UpdateCredentialGroupBody, +} from '@/lib/api/contracts/credential-groups' +import { + CREDENTIAL_GROUP_PROVIDER_IDS, + type CredentialGroupProvider, + type CredentialGroupStandardOAuthProvider, + getCredentialGroupProviderService, + getCredentialGroupProviderSupport, + isCredentialGroupStandardOAuthProvider, +} from '@/lib/credential-groups/providers' +import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' +import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' +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 { SettingRow } from '@/ee/components/setting-row' +import { SlackManagedUsersModal } from '@/ee/credential-groups/components/slack-managed-users-modal' +import { useUpdateCredentialGroup } from '@/hooks/queries/credential-groups' +import { useWorkspaceCredentials } from '@/hooks/queries/credentials' + +interface CredentialGroupDetailsProps { + credentialGroup: CredentialGroup + workspaceId: string +} + +function toOptionUpdateInput( + option: CredentialGroupOption +): NonNullable[number] { + const common = { + id: option.id, + label: getCredentialGroupProviderService(option.provider).name, + required: true, + } + if (option.provider !== 'slack') return { ...common, provider: option.provider } + return { + ...common, + provider: 'slack', + slackBotCredentialId: option.slackBotCredentialId, + } +} + +export function CredentialGroupDetails({ + credentialGroup, + workspaceId, +}: CredentialGroupDetailsProps) { + const updateGroup = useUpdateCredentialGroup() + const slackBots = useWorkspaceCredentials({ + workspaceId, + type: 'service_account', + providerId: SLACK_CUSTOM_BOT_PROVIDER_ID, + }) + const [name, setName] = useState(credentialGroup.name) + const [description, setDescription] = useState(credentialGroup.description ?? '') + const [slackSetupOpen, setSlackSetupOpen] = useState(false) + const [slackSetupCredentialId, setSlackSetupCredentialId] = useState() + const [removingProvider, setRemovingProvider] = useState(null) + + const normalizedDescription = description.trim() || null + const detailsDirty = + name.trim() !== credentialGroup.name || normalizedDescription !== credentialGroup.description + const isUpdating = updateGroup.isPending + + const updateOptions = async ( + options: NonNullable, + successMessage: string + ) => { + try { + await updateGroup.mutateAsync({ + workspaceId, + groupId: credentialGroup.id, + body: { options }, + }) + toast.success(successMessage) + return true + } catch (error) { + toast.error(getErrorMessage(error, 'Could not update account collection')) + return false + } + } + + const addProvider = async (provider: CredentialGroupStandardOAuthProvider) => { + const service = getCredentialGroupProviderService(provider) + const existing = credentialGroup.options.map(toOptionUpdateInput) + const nextOption: NonNullable[number] = { + provider, + label: service.name, + required: true, + } + return updateOptions([...existing, nextOption], `${service.name} added`) + } + + const openSlackSetup = (credentialId?: string) => { + setSlackSetupCredentialId(credentialId) + setSlackSetupOpen(true) + } + + const handleProviderAction = (provider: CredentialGroupProvider) => { + const support = getCredentialGroupProviderSupport(provider) + if (isCredentialGroupStandardOAuthProvider(provider)) { + void addProvider(provider) + return + } + if (support.configuration === 'slack_custom_bot') { + openSlackSetup() + return + } + throw new Error(`Unsupported Credential Group configuration: ${support.configuration}`) + } + + const handleSaveDetails = async () => { + if (!detailsDirty || !name.trim() || isUpdating) return + try { + await updateGroup.mutateAsync({ + workspaceId, + groupId: credentialGroup.id, + body: { name: name.trim(), description: normalizedDescription }, + }) + toast.success('Details saved') + } catch (error) { + toast.error(getErrorMessage(error, 'Could not save details')) + } + } + + const handleRemoveProvider = async () => { + if (!removingProvider) return + const service = getCredentialGroupProviderService(removingProvider) + const options = credentialGroup.options + .filter((option) => option.provider !== removingProvider) + .map(toOptionUpdateInput) + if (await updateOptions(options, `${service.name} removed`)) setRemovingProvider(null) + } + + return ( + <> +
+ void handleSaveDetails()} + disabled={!name.trim() || isUpdating} + > + {isUpdating ? 'Saving...' : 'Save changes'} + + ) : undefined + } + > +
+ + setName(event.target.value)} + error={!name.trim()} + /> + + + setDescription(event.target.value)} + placeholder='What these accounts will be used for' + rows={3} + /> + +
+
+ + +
+ {CREDENTIAL_GROUP_PROVIDER_IDS.map((provider) => { + const service = getCredentialGroupProviderService(provider) + const support = getCredentialGroupProviderSupport(provider) + const option = credentialGroup.options.find( + (candidate) => candidate.provider === provider + ) + const ProviderIcon = service.icon + const slackBot = + provider === 'slack' && option?.provider === 'slack' + ? slackBots.data?.find((bot) => bot.id === option.slackBotCredentialId) + : undefined + const slackNeedsSetup = + provider === 'slack' && + option?.provider === 'slack' && + (!slackBot || option.configurationStatus !== 'ready') + const descriptionText = + provider === 'slack' && option + ? slackBot + ? `${slackBot.displayName}${slackNeedsSetup ? ' needs managed-user setup' : ''}` + : slackBots.isPending + ? 'Loading custom Slack app...' + : 'Custom Slack app unavailable' + : support.description + + return ( + } + title={service.name} + description={descriptionText} + badge={ + option && !slackNeedsSetup ? ( + Connected + ) : undefined + } + trailing={ + option ? ( +
+ {slackNeedsSetup && option.provider === 'slack' && slackBot ? ( + openSlackSetup(slackBot.id)} disabled={isUpdating}> + Continue setup + + ) : null} + + openSlackSetup( + option?.provider === 'slack' + ? option.slackBotCredentialId + : undefined + ), + disabled: isUpdating, + }, + ] + : []), + { + label: 'Remove', + destructive: true, + onSelect: () => setRemovingProvider(provider), + disabled: isUpdating, + }, + ]} + /> +
+ ) : ( + handleProviderAction(provider)} + disabled={isUpdating || (provider === 'slack' && slackBots.isPending)} + > + {support.configuration === 'oauth' ? 'Add' : 'Set up'} + + ) + } + /> + ) + })} +
+
+
+ + { + setSlackSetupOpen(nextOpen) + if (!nextOpen) setSlackSetupCredentialId(undefined) + }} + bots={slackBots.data ?? []} + isLoading={slackBots.isPending} + error={slackBots.error} + initialCredentialId={slackSetupCredentialId} + /> + + !open && !isUpdating && setRemovingProvider(null)} + srTitle='Remove account type' + title={`Remove ${ + removingProvider ? getCredentialGroupProviderService(removingProvider).name : 'account' + }`} + text='People will no longer be asked to connect this account. Existing credentials are retained but will no longer be returned by this group.' + dismissLabel='Cancel' + confirm={{ + label: isUpdating ? 'Removing...' : 'Remove', + onClick: handleRemoveProvider, + disabled: isUpdating, + }} + /> + + ) +} diff --git a/apps/sim/ee/credential-groups/components/credential-group-invite-modal.tsx b/apps/sim/ee/credential-groups/components/credential-group-invite-modal.tsx new file mode 100644 index 00000000000..17d0bdf0682 --- /dev/null +++ b/apps/sim/ee/credential-groups/components/credential-group-invite-modal.tsx @@ -0,0 +1,117 @@ +'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 { useInviteCredentialGroupEnrollments } from '@/hooks/queries/credential-groups' + +interface CredentialGroupInviteModalProps { + open: boolean + onOpenChange: (open: boolean) => void + workspaceId: string + groupId: string +} + +export function CredentialGroupInviteModal({ + open, + onOpenChange, + workspaceId, + groupId, +}: CredentialGroupInviteModalProps) { + const invite = useInviteCredentialGroupEnrollments() + const [emails, setEmails] = useState([]) + const [deliveryError, setDeliveryError] = useState(null) + const canSubmit = emails.length > 0 && !invite.isPending + + const validateEmail = useCallback((email: string): string | null => { + const result = quickValidateEmail(email) + return result.isValid ? null : (result.reason ?? 'Invalid email') + }, []) + + const handleEmailsChange = useCallback((next: string[]) => { + setEmails(next) + setDeliveryError(null) + }, []) + + const handleOpenChange = (nextOpen: boolean) => { + if (invite.isPending) return + onOpenChange(nextOpen) + if (!nextOpen) { + setEmails([]) + setDeliveryError(null) + invite.reset() + } + } + + const handleSubmit = async () => { + if (!canSubmit) return + setDeliveryError(null) + try { + const result = await invite.mutateAsync({ + workspaceId, + groupId, + body: { emails }, + }) + const failures = result.results.filter((item) => !item.success) + if (failures.length === 0) { + toast.success( + result.sentCount === 1 ? 'Invitation sent' : `${result.sentCount} invitations 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 (error) { + setDeliveryError(getErrorMessage(error, 'Failed to send invitations')) + } + } + + return ( + + handleOpenChange(false)}>Invite users + + + + {deliveryError ?? (invite.error ? getErrorMessage(invite.error) : null)} + + + handleOpenChange(false)} + cancelDisabled={invite.isPending} + primaryAction={{ + label: invite.isPending ? 'Sending...' : 'Send invites', + onClick: handleSubmit, + disabled: !canSubmit, + }} + /> + + ) +} diff --git a/apps/sim/ee/credential-groups/components/credential-groups-settings.tsx b/apps/sim/ee/credential-groups/components/credential-groups-settings.tsx new file mode 100644 index 00000000000..a8ea91b9482 --- /dev/null +++ b/apps/sim/ee/credential-groups/components/credential-groups-settings.tsx @@ -0,0 +1,161 @@ +'use client' + +import { useState } from 'react' +import { ChipConfirmModal, ChipTag } from '@sim/emcn' +import { GridOffset, Plus } from '@sim/emcn/icons' +import { getErrorMessage } from '@sim/utils/errors' +import { useQueryState } from 'nuqs' +import { + credentialGroupIdParam, + credentialGroupIdUrlKeys, +} from '@/app/workspace/[workspaceId]/settings/[section]/search-params' +import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' +import { CredentialGroupCreateModal } from '@/ee/credential-groups/components/credential-group-create-modal' +import { CredentialGroupDetail } from '@/ee/credential-groups/components/credential-group-detail' +import { useCredentialGroups, useDeleteCredentialGroup } from '@/hooks/queries/credential-groups' + +interface CredentialGroupsSettingsProps { + workspaceId: string +} + +export function CredentialGroupsSettings({ workspaceId }: CredentialGroupsSettingsProps) { + const { data: groups = [], isPending, error } = useCredentialGroups(workspaceId) + const deleteGroup = useDeleteCredentialGroup() + const [search, setSearch] = useSettingsSearch() + const [showCreate, setShowCreate] = useState(false) + const [deletingGroupId, setDeletingGroupId] = useState(null) + const [selectedGroupId, setSelectedGroupId] = useQueryState(credentialGroupIdParam.key, { + ...credentialGroupIdParam.parser, + ...credentialGroupIdUrlKeys, + }) + const deletingGroup = groups.find((group) => group.id === deletingGroupId) + const selectedGroup = selectedGroupId + ? groups.find((group) => group.id === selectedGroupId) + : undefined + + const query = search.trim().toLowerCase() + const filtered = query + ? groups.filter((group) => + [group.name, group.description ?? '', ...group.options.map((option) => option.label)].some( + (value) => value.toLowerCase().includes(query) + ) + ) + : groups + + const actions: SettingsAction[] = [ + { + text: 'Create group', + icon: Plus, + variant: 'primary', + onSelect: () => setShowCreate(true), + }, + ] + + const handleDelete = async () => { + if (!deletingGroupId) return + try { + await deleteGroup.mutateAsync({ workspaceId, groupId: deletingGroupId }) + setDeletingGroupId(null) + } catch { + return + } + } + + if (selectedGroup) { + return ( + void setSelectedGroupId(null, { history: 'replace' })} + /> + ) + } + + return ( + <> + + {error ? ( + + {getErrorMessage(error, "Couldn't load credential groups")} + + ) : isPending ? null : groups.length === 0 ? ( + Click "Create group" above to get started + ) : filtered.length === 0 ? ( + No groups match "{search}" + ) : ( +
+ {filtered.map((group) => { + const optionCount = group.options.length + return ( + } + title={group.name} + description={`${optionCount} account type${optionCount === 1 ? '' : 's'} · ${group.description || 'Managed workspace credentials'}`} + onClick={() => void setSelectedGroupId(group.id)} + clickLabel={`Open ${group.name}`} + navigable + badge={ + group.status === 'disabled' ? ( + Disabled + ) : undefined + } + trailing={ + setDeletingGroupId(group.id), + }, + ]} + /> + } + /> + ) + })} +
+ )} +
+ void setSelectedGroupId(groupId)} + workspaceId={workspaceId} + /> + !open && !deleteGroup.isPending && setDeletingGroupId(null)} + srTitle='Delete credential group' + title='Delete credential group' + text={[ + `Delete ${deletingGroup?.name ?? 'this credential group'}?`, + { text: ' This cannot be undone.', error: true }, + ]} + dismissLabel='Cancel' + confirm={{ + label: deleteGroup.isPending ? 'Deleting...' : 'Delete', + onClick: handleDelete, + disabled: deleteGroup.isPending, + }} + /> + + ) +} diff --git a/apps/sim/ee/credential-groups/components/index.ts b/apps/sim/ee/credential-groups/components/index.ts new file mode 100644 index 00000000000..d8b5132e3d7 --- /dev/null +++ b/apps/sim/ee/credential-groups/components/index.ts @@ -0,0 +1 @@ +export { CredentialGroupsSettings } from '@/ee/credential-groups/components/credential-groups-settings' diff --git a/apps/sim/ee/credential-groups/components/slack-managed-users-modal.test.ts b/apps/sim/ee/credential-groups/components/slack-managed-users-modal.test.ts new file mode 100644 index 00000000000..5ad9c4a5217 --- /dev/null +++ b/apps/sim/ee/credential-groups/components/slack-managed-users-modal.test.ts @@ -0,0 +1,21 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { getSlackManagedUsersFailureNotification } from '@/ee/credential-groups/components/slack-managed-users-modal' + +describe('Slack managed-user authorization notifications', () => { + it('treats provider cancellation as a non-error outcome', () => { + expect(getSlackManagedUsersFailureNotification('provider_error')).toEqual({ + message: 'Slack authorization canceled', + variant: 'warning', + }) + }) + + it('keeps verification failures in the error state', () => { + expect(getSlackManagedUsersFailureNotification('invalid_response')).toEqual({ + message: 'Slack app verification failed. Please try again.', + variant: 'error', + }) + }) +}) 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 new file mode 100644 index 00000000000..a6bdef02c80 --- /dev/null +++ b/apps/sim/ee/credential-groups/components/slack-managed-users-modal.tsx @@ -0,0 +1,314 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { + ChipModal, + ChipModalBody, + ChipModalError, + ChipModalField, + ChipModalFooter, + ChipModalHeader, + Skeleton, + toast, +} from '@sim/emcn' +import { getErrorMessage } from '@sim/utils/errors' +import { useQueryClient } from '@tanstack/react-query' +import { SlackIcon } from '@/components/icons' +import type { WorkspaceCredential } from '@/lib/api/contracts' +import { useStartSlackCredentialGroupConfiguration } from '@/hooks/queries/credential-groups' +import { credentialGroupKeys } from '@/hooks/queries/utils/credential-group-queries' + +const CHANNEL_NAME = 'slack-managed-users' +const AUTHORIZATION_TIMEOUT_MS = 10 * 60 * 1000 + +interface SlackManagedUsersModalProps { + bots: WorkspaceCredential[] + credentialGroupId: string + error: Error | null + initialCredentialId?: string + isLoading: boolean + onOpenChange: (open: boolean) => void + open: boolean + workspaceId: string +} + +interface SlackManagedUsersMessage { + type: typeof CHANNEL_NAME + ok: boolean + reason?: string + state?: string + credentialGroupId?: string + slackBotCredentialId?: string +} + +export function getSlackManagedUsersFailureNotification(reason?: string): { + message: string + variant: 'error' | 'warning' +} { + return reason === 'provider_error' + ? { message: 'Slack authorization canceled', variant: 'warning' } + : { message: 'Slack app verification failed. Please try again.', variant: 'error' } +} + +function isSlackManagedUsersMessage(value: unknown): value is SlackManagedUsersMessage { + if (!value || typeof value !== 'object') return false + const message = value as Record + return ( + message.type === CHANNEL_NAME && + typeof message.ok === 'boolean' && + (message.reason === undefined || typeof message.reason === 'string') && + (message.state === undefined || typeof message.state === 'string') && + (message.credentialGroupId === undefined || typeof message.credentialGroupId === 'string') && + (message.slackBotCredentialId === undefined || typeof message.slackBotCredentialId === 'string') + ) +} + +export function SlackManagedUsersModal({ + bots, + credentialGroupId, + error, + initialCredentialId, + isLoading, + onOpenChange, + open, + workspaceId, +}: SlackManagedUsersModalProps) { + const queryClient = useQueryClient() + const startAuthorization = useStartSlackCredentialGroupConfiguration() + const [selectedCredentialId, setSelectedCredentialId] = useState(null) + const [clientId, setClientId] = useState('') + const [clientSecret, setClientSecret] = useState('') + const [pending, setPending] = useState(false) + const expectedState = useRef(null) + const expectedCredentialId = useRef(null) + const popup = useRef(null) + const popupWatcher = useRef(null) + + const defaultCredentialId = initialCredentialId + ? bots.some((bot) => bot.id === initialCredentialId) + ? initialCredentialId + : '' + : bots.length === 1 + ? bots[0].id + : '' + const effectiveCredentialId = selectedCredentialId ?? defaultCredentialId + const selectedBot = bots.find((bot) => bot.id === effectiveCredentialId) + + useEffect(() => { + if (!open) return + const channel = new BroadcastChannel(CHANNEL_NAME) + channel.onmessage = (event: MessageEvent) => { + if (!isSlackManagedUsersMessage(event.data)) return + if (!expectedState.current || event.data.state !== expectedState.current) return + const verifiedCredentialId = expectedCredentialId.current + expectedState.current = null + expectedCredentialId.current = null + if (popupWatcher.current !== null) window.clearInterval(popupWatcher.current) + popupWatcher.current = null + popup.current?.close() + popup.current = null + setPending(false) + if (!event.data.ok) { + const notification = getSlackManagedUsersFailureNotification(event.data.reason) + if (notification.variant === 'warning') toast.warning(notification.message) + else toast.error(notification.message) + return + } + if ( + event.data.credentialGroupId !== credentialGroupId || + !verifiedCredentialId || + event.data.slackBotCredentialId !== verifiedCredentialId + ) { + toast.error('Slack app verification failed. Please try again.') + return + } + if (!bots.some((bot) => bot.id === verifiedCredentialId)) { + toast.error('The verified Slack app is no longer available.') + return + } + void queryClient.invalidateQueries({ + queryKey: credentialGroupKeys.list(workspaceId), + }) + void queryClient.invalidateQueries({ + queryKey: credentialGroupKeys.detail(workspaceId, credentialGroupId), + }) + toast.success('Slack configured') + onOpenChange(false) + reset() + } + return () => channel.close() + }, [bots, credentialGroupId, onOpenChange, open, queryClient, workspaceId]) + + useEffect( + () => () => { + if (popupWatcher.current !== null) window.clearInterval(popupWatcher.current) + popup.current?.close() + }, + [] + ) + + const reset = () => { + popup.current?.close() + popup.current = null + if (popupWatcher.current !== null) window.clearInterval(popupWatcher.current) + popupWatcher.current = null + expectedState.current = null + expectedCredentialId.current = null + setSelectedCredentialId(null) + setClientId('') + setClientSecret('') + setPending(false) + startAuthorization.reset() + } + + const handleOpenChange = (nextOpen: boolean) => { + if (pending && !nextOpen) return + onOpenChange(nextOpen) + if (!nextOpen) reset() + } + + const handleSelectBot = (credentialId: string) => { + if (pending) return + setSelectedCredentialId(credentialId) + setClientId('') + setClientSecret('') + startAuthorization.reset() + } + + const handleSubmit = async () => { + if (!selectedBot || pending) return + if (!clientId.trim() || !clientSecret.trim()) return + + const opened = window.open('about:blank', 'slack-managed-users', 'width=720,height=760') + if (!opened) { + toast.error('Allow popups to verify the Slack app') + return + } + popup.current = opened + setPending(true) + try { + const result = await startAuthorization.mutateAsync({ + workspaceId, + credentialGroupId, + body: { + slackBotCredentialId: selectedBot.id, + clientId: clientId.trim(), + clientSecret: clientSecret.trim(), + }, + }) + expectedState.current = result.state + expectedCredentialId.current = selectedBot.id + opened.location.href = result.authorizationUrl + const startedAt = Date.now() + popupWatcher.current = window.setInterval(() => { + if (!opened.closed && Date.now() - startedAt < AUTHORIZATION_TIMEOUT_MS) return + window.clearInterval(popupWatcher.current ?? undefined) + popupWatcher.current = null + opened.close() + popup.current = null + expectedState.current = null + expectedCredentialId.current = null + setPending(false) + toast.error('Slack authorization expired. Please try again.') + }, 500) + } catch (authorizationError) { + opened.close() + popup.current = null + setPending(false) + toast.error(getErrorMessage(authorizationError, 'Could not start Slack authorization')) + } + } + + const noBots = !isLoading && bots.length === 0 + const primaryLabel = isLoading + ? 'Loading...' + : noBots + ? 'Add Slack' + : pending + ? 'Waiting for Slack...' + : 'Verify and add' + const primaryDisabled = + isLoading || noBots || !selectedBot || pending || !clientId.trim() || !clientSecret.trim() + + return ( + + handleOpenChange(false)} + closeDisabled={pending} + > + Set up Slack + + + {isLoading ? ( +
+ + +
+ ) : noBots ? ( +

+ Add a custom Slack app from Integrations before adding Slack to this group. +

+ ) : ( + <> + ({ + value: bot.id, + label: bot.displayName, + icon: SlackIcon, + }))} + placeholder='Select a custom bot' + disabled={pending} + required + /> + {selectedBot ? ( + <> + + + + ) : null} + + )} + {error ? getErrorMessage(error) : null} +
+ handleOpenChange(false)} + cancelDisabled={pending} + primaryAction={{ + label: primaryLabel, + onClick: () => void handleSubmit(), + disabled: primaryDisabled, + }} + /> +
+ ) +} diff --git a/apps/sim/executor/constants.ts b/apps/sim/executor/constants.ts index 6e905d0fc4f..77482255cff 100644 --- a/apps/sim/executor/constants.ts +++ b/apps/sim/executor/constants.ts @@ -41,6 +41,7 @@ export enum BlockType { WORKFLOW_INPUT = 'workflow_input', CREDENTIAL = 'credential', + CREDENTIAL_GROUP = 'credential_group', WAIT = 'wait', 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 new file mode 100644 index 00000000000..6e3b5281454 --- /dev/null +++ b/apps/sim/executor/handlers/credential-group/credential-group-handler.test.ts @@ -0,0 +1,189 @@ +/** + * @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(() => ({ + authenticate: vi.fn(), + buildHeaders: vi.fn(), + enforceInviteRateLimit: vi.fn(), + listCredentials: vi.fn(), + listGroups: vi.fn(), + listPeople: vi.fn(), + sendInvite: vi.fn(), +})) + +vi.mock('@/lib/credential-groups/application/delegation', () => ({ + authenticateCredentialGroupDelegation: mocks.authenticate, +})) + +vi.mock('@/lib/credential-groups/application/list-credentials', () => ({ + listCredentialGroupCredentials: { execute: mocks.listCredentials }, +})) + +vi.mock('@/lib/credential-groups/application/list-groups', () => ({ + listCredentialGroupsForWorkflow: { execute: mocks.listGroups }, +})) + +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('@/executor/utils/http', () => ({ + buildExecutorDelegationHeaders: mocks.buildHeaders, +})) + +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' }, +} + +const context = { + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + userId: 'user-1', +} as ExecutionContext + +const block = { metadata: { id: BlockType.CREDENTIAL_GROUP } } as SerializedBlock + +describe('CredentialGroupBlockHandler', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.buildHeaders.mockResolvedValue({ Authorization: 'Bearer executor-token' }) + mocks.authenticate.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 + ) + }) + + it('lists credentials with normalized provider, email, and page filters', async () => { + mocks.listCredentials.mockResolvedValue({ + credentials: [], + count: 0, + hasMore: false, + nextCursor: null, + }) + + const result = await new CredentialGroupBlockHandler().execute(context, block, { + operation: 'list_credentials', + credentialGroupId: ' group-1 ', + credentialProviderIds: '["google-email", "google-email"]', + email: ' person@example.com ', + limit: '25', + cursor: ' credential-1 ', + }) + + expect(mocks.authenticate).toHaveBeenCalledWith('Bearer executor-token', 'group-1') + expect(mocks.listCredentials).toHaveBeenCalledWith({ + principal, + input: { + credentialGroupId: 'group-1', + credentialProviderIds: ['google-email'], + email: 'person@example.com', + limit: 25, + cursor: 'credential-1', + }, + }) + expect(result).toEqual({ credentials: [], count: 0, hasMore: false, nextCursor: null }) + }) + + it('lists groups under workspace-scoped delegation', async () => { + mocks.listGroups.mockResolvedValue({ + credentialGroups: [], + count: 0, + hasMore: false, + nextCursor: null, + }) + + await new CredentialGroupBlockHandler().execute(context, block, { + operation: 'list_groups', + limit: 10, + }) + + expect(mocks.authenticate).toHaveBeenCalledWith('Bearer executor-token', undefined) + expect(mocks.listGroups).toHaveBeenCalledWith({ + principal, + input: { workspaceId: 'workspace-1', limit: 10, cursor: undefined }, + }) + }) + + 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', + credentialGroupId: 'group-1', + 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: { credentialGroupId: 'group-1', email: 'person@example.com' }, + }) + }) + + it('fails fast on unsupported people statuses', async () => { + await expect( + new CredentialGroupBlockHandler().execute(context, block, { + operation: 'list_people', + credentialGroupId: 'group-1', + peopleStatuses: ['unknown'], + }) + ).rejects.toThrow('People statuses contain an unsupported value') + expect(mocks.listPeople).not.toHaveBeenCalled() + }) + + 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.buildHeaders).not.toHaveBeenCalled() + expect(mocks.authenticate).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 new file mode 100644 index 00000000000..d038451e22e --- /dev/null +++ b/apps/sim/executor/handlers/credential-group/credential-group-handler.ts @@ -0,0 +1,207 @@ +import { createLogger } from '@sim/logger' +import { authenticateCredentialGroupDelegation } from '@/lib/credential-groups/application/delegation' +import { listCredentialGroupCredentials } from '@/lib/credential-groups/application/list-credentials' +import { listCredentialGroupsForWorkflow } from '@/lib/credential-groups/application/list-groups' +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 type { BlockOutput } from '@/blocks/types' +import { BlockType } from '@/executor/constants' +import type { BlockHandler, ExecutionContext, ExecutorDelegationOrigin } from '@/executor/types' +import { buildExecutorDelegationHeaders } from '@/executor/utils/http' +import type { SerializedBlock } from '@/serializer/types' + +const logger = createLogger('CredentialGroupBlockHandler') + +const CREDENTIAL_GROUP_OPERATION_IDS = [ + 'list_credentials', + 'send_invite', + 'list_people', + 'list_groups', +] 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 +} + +function delegationOrigin(ctx: ExecutionContext): ExecutorDelegationOrigin { + const origin = + ctx.executorDelegationOrigin ?? + (ctx.userId + ? { + subjectUserId: ctx.userId, + workflowId: ctx.workflowId, + ...(ctx.executionId ? { executionId: ctx.executionId } : {}), + } + : undefined) + if (!origin) { + throw new Error('Credential Group operations require an authenticated workflow execution') + } + return origin +} + +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) + const credentialGroupId = + operation === 'list_groups' + ? undefined + : requireString(inputs.credentialGroupId, 'Credential Group') + const headers = await buildExecutorDelegationHeaders(delegationOrigin(ctx)) + const authorization = headers.Authorization + if (!authorization) throw new Error('Executor delegation authorization is missing') + const principal = await authenticateCredentialGroupDelegation(authorization, credentialGroupId) + + switch (operation) { + case 'list_credentials': { + const credentialProviderIds = parseStringList( + inputs.credentialProviderIds, + 'Credential provider IDs' + ) + const result = await listCredentialGroupCredentials.execute({ + principal, + input: { + credentialGroupId: credentialGroupId!, + limit: parseLimit(inputs.limit), + cursor: parseOptionalString(inputs.cursor, 'Cursor'), + email: parseOptionalString(inputs.email, 'Email'), + credentialProviderIds, + }, + }) + logger.info('Listed Credential Group credentials', { + credentialGroupId, + count: result.count, + hasMore: result.hasMore, + }) + return result + } + case 'send_invite': { + await enforceCredentialGroupInvitationExecutionRateLimit(principal.workspaceId) + const result = await sendCredentialGroupInvite.execute({ + principal, + input: { + credentialGroupId: credentialGroupId!, + email: requireString(inputs.email, 'Email'), + }, + }) + logger.info('Sent Credential Group invitation', { + credentialGroupId, + 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 '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: { + credentialGroupId: credentialGroupId!, + 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', { + credentialGroupId, + count: result.count, + hasMore: result.hasMore, + }) + return result + } + case 'list_groups': { + const result = await listCredentialGroupsForWorkflow.execute({ + principal, + input: { + workspaceId: ctx.workspaceId, + limit: parseLimit(inputs.limit), + cursor: parseOptionalString(inputs.cursor, 'Cursor'), + }, + }) + logger.info('Listed Credential Groups', { + workspaceId: ctx.workspaceId, + count: result.count, + hasMore: result.hasMore, + }) + return result + } + } + } +} diff --git a/apps/sim/executor/handlers/credential/credential-handler.ts b/apps/sim/executor/handlers/credential/credential-handler.ts index 2619ae5bd07..ff01b77ff95 100644 --- a/apps/sim/executor/handlers/credential/credential-handler.ts +++ b/apps/sim/executor/handlers/credential/credential-handler.ts @@ -25,11 +25,14 @@ export class CredentialBlockHandler implements BlockHandler { const operation = typeof inputs.operation === 'string' ? inputs.operation : 'select' - if (operation === 'list') { - return this.listCredentials(ctx.workspaceId, inputs) + switch (operation) { + case 'select': + return this.selectCredential(ctx.workspaceId, inputs) + case 'list': + return this.listCredentials(ctx.workspaceId, inputs) + default: + throw new Error(`Unsupported Credential operation: ${operation}`) } - - return this.selectCredential(ctx.workspaceId, inputs) } private async selectCredential( diff --git a/apps/sim/executor/handlers/registry.ts b/apps/sim/executor/handlers/registry.ts index cd8c57d1c61..bbe0e52debb 100644 --- a/apps/sim/executor/handlers/registry.ts +++ b/apps/sim/executor/handlers/registry.ts @@ -9,6 +9,7 @@ import { AgentBlockHandler } from '@/executor/handlers/agent/agent-handler' import { ApiBlockHandler } from '@/executor/handlers/api/api-handler' import { ConditionBlockHandler } from '@/executor/handlers/condition/condition-handler' import { CredentialBlockHandler } from '@/executor/handlers/credential/credential-handler' +import { CredentialGroupBlockHandler } from '@/executor/handlers/credential-group/credential-group-handler' import { EvaluatorBlockHandler } from '@/executor/handlers/evaluator/evaluator-handler' import { FunctionBlockHandler } from '@/executor/handlers/function/function-handler' import { GenericBlockHandler } from '@/executor/handlers/generic/generic-handler' @@ -45,6 +46,7 @@ export function createBlockHandlers(): BlockHandler[] { new WorkflowBlockHandler(), new WaitBlockHandler(), new EvaluatorBlockHandler(), + new CredentialGroupBlockHandler(), new CredentialBlockHandler(), new GenericBlockHandler(), ] diff --git a/apps/sim/hooks/queries/credential-groups.ts b/apps/sim/hooks/queries/credential-groups.ts new file mode 100644 index 00000000000..d45dbed396d --- /dev/null +++ b/apps/sim/hooks/queries/credential-groups.ts @@ -0,0 +1,196 @@ +'use client' + +import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import type { ContractBodyInput } from '@/lib/api/contracts' +import { + createCredentialGroupContract, + deleteCredentialGroupContract, + getCredentialGroupContract, + inviteCredentialGroupEnrollmentsContract, + resendCredentialGroupEnrollmentContract, + revokeCredentialGroupEnrollmentContract, + startSlackCredentialGroupConfigurationContract, + updateCredentialGroupContract, +} from '@/lib/api/contracts/credential-groups' +import type { ContractJsonResponse } from '@/lib/api/contracts/types' +import { + CREDENTIAL_GROUP_DETAIL_STALE_TIME, + CREDENTIAL_GROUP_LIST_STALE_TIME, + credentialGroupKeys, + fetchCredentialGroupList, +} from '@/hooks/queries/utils/credential-group-queries' + +export function useCredentialGroups(workspaceId?: string) { + return useQuery({ + queryKey: credentialGroupKeys.list(workspaceId), + queryFn: async ({ signal }) => { + if (!workspaceId) return [] + return fetchCredentialGroupList(workspaceId, signal) + }, + enabled: Boolean(workspaceId), + staleTime: CREDENTIAL_GROUP_DETAIL_STALE_TIME, + }) +} + +export function useCredentialGroupDetail(workspaceId?: string, groupId?: string) { + return useInfiniteQuery({ + queryKey: credentialGroupKeys.detail(workspaceId, groupId), + queryFn: ({ signal, pageParam }) => { + if (!workspaceId || !groupId) + throw new Error('Credential group detail identifiers are required') + return requestJson(getCredentialGroupContract, { + params: { id: workspaceId, groupId }, + query: { limit: 50, ...(pageParam ? { cursor: pageParam } : {}) }, + signal, + }) + }, + initialPageParam: null as string | null, + getNextPageParam: (lastPage: ContractJsonResponse) => + lastPage.nextCursor ?? undefined, + enabled: Boolean(workspaceId && groupId), + staleTime: CREDENTIAL_GROUP_LIST_STALE_TIME, + }) +} + +export function useCreateCredentialGroup() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ + workspaceId, + body, + }: { + workspaceId: string + body: ContractBodyInput + }) => requestJson(createCredentialGroupContract, { params: { id: workspaceId }, body }), + onSettled: (_data, _error, variables) => { + queryClient.invalidateQueries({ queryKey: credentialGroupKeys.list(variables.workspaceId) }) + }, + }) +} + +export function useDeleteCredentialGroup() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ workspaceId, groupId }: { workspaceId: string; groupId: string }) => + requestJson(deleteCredentialGroupContract, { + params: { id: workspaceId, groupId }, + }), + onSettled: (_data, _error, variables) => { + queryClient.invalidateQueries({ queryKey: credentialGroupKeys.list(variables.workspaceId) }) + }, + }) +} + +export function useUpdateCredentialGroup() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ + workspaceId, + groupId, + body, + }: { + workspaceId: string + groupId: string + body: ContractBodyInput + }) => + requestJson(updateCredentialGroupContract, { + params: { id: workspaceId, groupId }, + body, + }), + onSettled: (_data, _error, variables) => { + queryClient.invalidateQueries({ queryKey: credentialGroupKeys.list(variables.workspaceId) }) + queryClient.invalidateQueries({ + queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId), + }) + }, + }) +} + +export function useStartSlackCredentialGroupConfiguration() { + return useMutation({ + mutationFn: async ({ + workspaceId, + credentialGroupId, + body, + }: { + workspaceId: string + credentialGroupId: string + body: ContractBodyInput + }) => + requestJson(startSlackCredentialGroupConfigurationContract, { + params: { id: workspaceId, groupId: credentialGroupId }, + body, + }), + }) +} + +export function useInviteCredentialGroupEnrollments() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ + workspaceId, + groupId, + body, + }: { + workspaceId: string + groupId: string + body: ContractBodyInput + }) => + requestJson(inviteCredentialGroupEnrollmentsContract, { + params: { id: workspaceId, groupId }, + body, + }), + onSettled: (_data, _error, variables) => { + queryClient.invalidateQueries({ + queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId), + }) + }, + }) +} + +export function useResendCredentialGroupEnrollment() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ + workspaceId, + groupId, + enrollmentId, + }: { + workspaceId: string + groupId: string + enrollmentId: string + }) => + requestJson(resendCredentialGroupEnrollmentContract, { + params: { id: workspaceId, groupId, enrollmentId }, + }), + onSettled: (_data, _error, variables) => { + queryClient.invalidateQueries({ + queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId), + }) + }, + }) +} + +export function useRevokeCredentialGroupEnrollment() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ + workspaceId, + groupId, + enrollmentId, + }: { + workspaceId: string + groupId: string + enrollmentId: string + }) => + requestJson(revokeCredentialGroupEnrollmentContract, { + params: { id: workspaceId, groupId, enrollmentId }, + }), + onSettled: (_data, _error, variables) => { + queryClient.invalidateQueries({ + queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId), + }) + }, + }) +} diff --git a/apps/sim/hooks/queries/utils/credential-group-queries.ts b/apps/sim/hooks/queries/utils/credential-group-queries.ts new file mode 100644 index 00000000000..780b2f31f49 --- /dev/null +++ b/apps/sim/hooks/queries/utils/credential-group-queries.ts @@ -0,0 +1,26 @@ +import { requestJson } from '@/lib/api/client/request' +import type { CredentialGroup } from '@/lib/api/contracts/credential-groups' +import { listCredentialGroupsContract } from '@/lib/api/contracts/credential-groups' + +export const CREDENTIAL_GROUP_DETAIL_STALE_TIME = Number.POSITIVE_INFINITY +export const CREDENTIAL_GROUP_LIST_STALE_TIME = 30 * 1000 + +export const credentialGroupKeys = { + all: ['credential-groups'] as const, + lists: () => [...credentialGroupKeys.all, 'list'] as const, + list: (workspaceId?: string) => [...credentialGroupKeys.lists(), workspaceId ?? ''] as const, + details: () => [...credentialGroupKeys.all, 'detail'] as const, + detail: (workspaceId?: string, groupId?: string) => + [...credentialGroupKeys.details(), workspaceId ?? '', groupId ?? ''] as const, +} + +export async function fetchCredentialGroupList( + workspaceId: string, + signal?: AbortSignal +): Promise { + const data = await requestJson(listCredentialGroupsContract, { + params: { id: workspaceId }, + signal, + }) + return data.credentialGroups +} diff --git a/apps/sim/hooks/use-permission-config.ts b/apps/sim/hooks/use-permission-config.ts index a161bf9a924..0f132d1dde7 100644 --- a/apps/sim/hooks/use-permission-config.ts +++ b/apps/sim/hooks/use-permission-config.ts @@ -20,6 +20,7 @@ import { DEFAULT_PERMISSION_GROUP_CONFIG, type PermissionGroupConfig, } from '@/lib/permission-groups/types' +import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay' import { overlayVisibility } from '@/blocks/visibility/context' import { useUserPermissionConfig } from '@/ee/access-control/hooks/permission-groups' @@ -56,6 +57,7 @@ export function usePermissionConfig(): PermissionConfigResult { const params = useParams() const workspaceId = typeof params?.workspaceId === 'string' ? params.workspaceId : undefined const blockOverlayVersion = useCustomBlockOverlayVersion() + const hostContext = useOptionalWorkspaceHostContext() const { data: permissionData, isLoading: isPermissionLoading } = useUserPermissionConfig(workspaceId) @@ -94,6 +96,9 @@ export function usePermissionConfig(): PermissionConfigResult { const isBlockAllowed = useMemo(() => { return (blockType: string) => { const normalizedBlockType = blockType.toLowerCase() + if (normalizedBlockType === 'credential_group' && !hostContext?.features?.credentialGroups) { + return false + } const availability = integrationAvailability.get(normalizedBlockType) if ( isDeploymentGatedIntegrationType(normalizedBlockType) && @@ -106,7 +111,7 @@ export function usePermissionConfig(): PermissionConfigResult { if (mergedAllowedIntegrations === null) return true return mergedAllowedIntegrations.includes(normalizedBlockType) } - }, [integrationAvailability, mergedAllowedIntegrations]) + }, [hostContext?.features?.credentialGroups, integrationAvailability, mergedAllowedIntegrations]) const isProviderAllowed = useMemo(() => { return (providerId: string) => { diff --git a/apps/sim/lib/api/contracts/credential-groups.test.ts b/apps/sim/lib/api/contracts/credential-groups.test.ts new file mode 100644 index 00000000000..edbea439b0f --- /dev/null +++ b/apps/sim/lib/api/contracts/credential-groups.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from 'vitest' +import { + createCredentialGroupBodySchema, + credentialGroupEnrollmentDetailSchema, + credentialGroupEnrollmentListQuerySchema, + credentialGroupSchema, + inviteCredentialGroupEnrollmentsBodySchema, + updateCredentialGroupBodySchema, +} from '@/lib/api/contracts/credential-groups' + +describe('credential group contracts', () => { + it('accepts a group before account types are added', () => { + const parsed = createCredentialGroupBodySchema.parse({ + name: 'Support team', + options: [], + }) + + expect(parsed.options).toEqual([]) + }) + + it('accepts one option per provider after the group exists', () => { + const parsed = updateCredentialGroupBodySchema.parse({ + options: [ + { provider: 'gmail', label: 'Gmail', required: true }, + { provider: 'google-calendar', label: 'Google Calendar', required: true }, + { + provider: 'slack', + label: 'Slack', + required: true, + slackBotCredentialId: '11111111-1111-4111-8111-111111111111', + }, + ], + }) + + expect(parsed.options).toHaveLength(3) + }) + + it('rejects the removed multiple-account option', () => { + const result = createCredentialGroupBodySchema.safeParse({ + name: 'Support team', + options: [ + { + provider: 'gmail', + label: 'Primary inbox', + required: true, + allowMultiple: true, + }, + ], + }) + + expect(result.success).toBe(false) + }) + + it('rejects duplicate option labels case-insensitively', () => { + const result = createCredentialGroupBodySchema.safeParse({ + name: 'Support team', + options: [ + { + provider: 'gmail', + label: 'Inbox', + required: true, + }, + { + provider: 'gmail', + label: 'inbox', + required: true, + }, + ], + }) + + expect(result.success).toBe(false) + }) + + it('rejects duplicate providers', () => { + const result = createCredentialGroupBodySchema.safeParse({ + name: 'Support team', + options: [ + { provider: 'gmail', label: 'Primary inbox', required: true }, + { provider: 'gmail', label: 'Escalations', required: true }, + ], + }) + + expect(result.success).toBe(false) + }) + + it('requires a custom bot for Slack option updates', () => { + const missingApp = updateCredentialGroupBodySchema.safeParse({ + options: [ + { + provider: 'slack', + label: 'Slack', + required: true, + }, + ], + }) + const withApp = updateCredentialGroupBodySchema.safeParse({ + options: [ + { + provider: 'slack', + label: 'Slack', + required: true, + slackBotCredentialId: '11111111-1111-4111-8111-111111111111', + }, + ], + }) + + expect(missingApp.success).toBe(false) + expect(withApp.success).toBe(true) + }) + + it('rejects duplicate option IDs on update', () => { + const option = { + id: 'option-1', + provider: 'gmail' as const, + required: true, + } + const result = updateCredentialGroupBodySchema.safeParse({ + options: [ + { ...option, label: 'Inbox' }, + { ...option, label: 'Escalations' }, + ], + }) + + expect(result.success).toBe(false) + }) + + it('rejects the authorization-app identity from settings responses', () => { + const result = credentialGroupSchema.safeParse({ + id: 'group-1', + workspaceId: 'workspace-1', + name: 'Support team', + description: null, + options: [ + { + id: 'option-1', + provider: 'gmail', + label: 'Inbox', + required: true, + status: 'active', + authorizationAppId: 'server-only', + }, + ], + status: 'active', + createdAt: '2026-08-10T12:00:00.000Z', + updatedAt: '2026-08-10T12:00:00.000Z', + }) + + expect(result.success).toBe(false) + }) + + it('accepts a batch of invitation emails', () => { + const result = inviteCredentialGroupEnrollmentsBodySchema.parse({ + emails: ['alex@example.com', 'sam@example.com'], + }) + + expect(result.emails).toEqual(['alex@example.com', 'sam@example.com']) + }) + + it('rejects invitation batches larger than 100 recipients', () => { + const result = inviteCredentialGroupEnrollmentsBodySchema.safeParse({ + emails: Array.from({ length: 101 }, (_, index) => `user-${index}@example.com`), + }) + + expect(result.success).toBe(false) + }) + + it('rejects invalid invitation email addresses', () => { + const result = inviteCredentialGroupEnrollmentsBodySchema.safeParse({ + emails: ['not-an-email'], + }) + + expect(result.success).toBe(false) + }) + + it('bounds enrollment pages and defaults them to 50 rows', () => { + expect(credentialGroupEnrollmentListQuerySchema.parse({})).toEqual({ limit: 50 }) + expect(credentialGroupEnrollmentListQuerySchema.safeParse({ limit: 101 }).success).toBe(false) + }) + + it('accepts aggregated provider connections on an enrollment', () => { + const result = credentialGroupEnrollmentDetailSchema.parse({ + id: 'enrollment-1', + credentialGroupId: 'group-1', + email: 'alex@example.com', + status: 'completed', + expiresAt: '2026-08-18T12:00:00.000Z', + invitedAt: '2026-08-11T12:00:00.000Z', + sentAt: '2026-08-11T12:00:01.000Z', + completedAt: '2026-08-11T12:05:00.000Z', + revokedAt: null, + expired: false, + createdAt: '2026-08-11T12:00:00.000Z', + updatedAt: '2026-08-11T12:05:00.000Z', + connections: [{ provider: 'gmail', status: 'active', count: 2 }], + }) + + expect(result.connections).toEqual([{ provider: 'gmail', status: 'active', count: 2 }]) + }) +}) diff --git a/apps/sim/lib/api/contracts/credential-groups.ts b/apps/sim/lib/api/contracts/credential-groups.ts new file mode 100644 index 00000000000..37cc07a4fe9 --- /dev/null +++ b/apps/sim/lib/api/contracts/credential-groups.ts @@ -0,0 +1,426 @@ +import { z } from 'zod' +import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + CREDENTIAL_GROUP_PROVIDER_IDS, + CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS, +} from '@/lib/credential-groups/providers' + +export const credentialGroupProviderSchema = z.enum(CREDENTIAL_GROUP_PROVIDER_IDS) +export const credentialGroupStatusSchema = z.enum(['active', 'disabled']) +export const credentialGroupEnrollmentStatusSchema = z.enum([ + 'invited', + 'delivery_failed', + 'in_progress', + 'completed', + 'revoked', +]) +export const credentialGroupOptionConfigurationStatusSchema = z.enum([ + 'not_configured', + 'ready', + 'needs_update', +]) + +const credentialGroupOptionFields = { + label: z.string().trim().min(1, 'Option label is required').max(100), + required: z.boolean(), +} as const + +const standardOAuthCredentialGroupOptionInputSchema = z + .object({ + provider: z.enum(CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS), + ...credentialGroupOptionFields, + }) + .strict() + +const slackCredentialGroupOptionInputSchema = z + .object({ + provider: z.literal('slack'), + ...credentialGroupOptionFields, + slackBotCredentialId: z.string().uuid('Select a custom Slack bot'), + }) + .strict() + +export const credentialGroupOptionInputSchema = z.discriminatedUnion('provider', [ + standardOAuthCredentialGroupOptionInputSchema, + slackCredentialGroupOptionInputSchema, +]) + +export const credentialGroupOptionSchema = z.discriminatedUnion('provider', [ + standardOAuthCredentialGroupOptionInputSchema.extend({ + id: z.string().min(1), + status: z.enum(['active', 'disabled']), + configurationStatus: credentialGroupOptionConfigurationStatusSchema, + }), + slackCredentialGroupOptionInputSchema.extend({ + id: z.string().min(1), + status: z.enum(['active', 'disabled']), + configurationStatus: credentialGroupOptionConfigurationStatusSchema, + }), +]) + +export const credentialGroupOptionUpdateInputSchema = z.discriminatedUnion('provider', [ + standardOAuthCredentialGroupOptionInputSchema.extend({ + id: z.string().min(1).max(128).optional(), + }), + slackCredentialGroupOptionInputSchema.extend({ id: z.string().min(1).max(128).optional() }), +]) + +export const credentialGroupSchema = z.object({ + id: z.string(), + workspaceId: z.string(), + name: z.string(), + description: z.string().nullable(), + options: z.array(credentialGroupOptionSchema).max(CREDENTIAL_GROUP_PROVIDER_IDS.length), + status: credentialGroupStatusSchema, + createdAt: z.string(), + updatedAt: z.string(), +}) + +export type CredentialGroup = z.output +export type CredentialGroupOption = z.output +export type CredentialGroupOptionInput = z.input + +export const credentialGroupEnrollmentSchema = z.object({ + id: z.string(), + credentialGroupId: z.string(), + email: z.string().email(), + status: credentialGroupEnrollmentStatusSchema, + expiresAt: z.string(), + invitedAt: z.string(), + sentAt: z.string().nullable(), + completedAt: z.string().nullable(), + revokedAt: z.string().nullable(), + expired: z.boolean(), + createdAt: z.string(), + updatedAt: z.string(), +}) + +export type CredentialGroupEnrollment = z.output + +export const credentialGroupEnrollmentConnectionSchema = z.object({ + provider: credentialGroupProviderSchema, + status: z.enum(['active', 'needs_reauth', 'revoked']), + count: z.number().int().positive(), +}) + +export const credentialGroupEnrollmentDetailSchema = credentialGroupEnrollmentSchema.extend({ + connections: z + .array(credentialGroupEnrollmentConnectionSchema) + .max(CREDENTIAL_GROUP_PROVIDER_IDS.length * 3), +}) + +export type CredentialGroupEnrollmentConnection = z.output< + typeof credentialGroupEnrollmentConnectionSchema +> +export type CredentialGroupEnrollmentDetail = z.output + +export const credentialGroupWorkspaceParamsSchema = z.object({ + id: workspaceIdSchema, +}) + +export const credentialGroupDetailParamsSchema = credentialGroupWorkspaceParamsSchema.extend({ + groupId: z.string().min(1, 'Credential group ID is required').max(128), +}) + +export const credentialGroupEnrollmentParamsSchema = credentialGroupDetailParamsSchema.extend({ + enrollmentId: z.string().min(1, 'Enrollment ID is required').max(128), +}) + +export const publicCredentialGroupEnrollmentParamsSchema = z.object({ + token: z.string().min(1, 'Invitation token is required').max(128), +}) + +export const startCredentialGroupOAuthParamsSchema = + publicCredentialGroupEnrollmentParamsSchema.extend({ + optionId: z.string().min(1, 'Credential option ID is required').max(128), + }) + +export const credentialGroupOAuthCallbackQuerySchema = z + .object({ + state: z.string().min(1, 'OAuth state is required').max(512), + code: z.string().min(1).max(2048).optional(), + error: z.string().min(1).max(256).optional(), + error_description: z.string().max(1000).optional(), + }) + .superRefine((query, ctx) => { + if (!query.code && !query.error) { + ctx.addIssue({ + code: 'custom', + path: ['code'], + message: 'OAuth callback must include a code or error', + }) + } + }) + +export const credentialGroupOAuthCallbackParamsSchema = z.object({ + provider: credentialGroupProviderSchema, +}) + +export const startSlackCredentialGroupConfigurationBodySchema = z + .object({ + slackBotCredentialId: z.string().uuid('Select a custom Slack bot'), + 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), + }) + .strict() + +export const slackCredentialGroupConfigurationCallbackQuerySchema = + credentialGroupOAuthCallbackQuerySchema + +export const credentialGroupEnrollmentListQuerySchema = z.object({ + cursor: z.string().min(1, 'Enrollment cursor cannot be empty').max(128).optional(), + limit: z.coerce.number().int().min(1).max(100).default(50), +}) + +export const inviteCredentialGroupEnrollmentsBodySchema = z + .object({ + emails: z + .array(z.string().trim().email('Enter a valid email address').max(320)) + .min(1, 'At least one email address is required') + .max(100, 'You can invite at most 100 people at once'), + }) + .strict() + +export type InviteCredentialGroupEnrollmentsBody = z.input< + typeof inviteCredentialGroupEnrollmentsBodySchema +> + +export const credentialGroupEnrollmentInviteResultSchema = z.discriminatedUnion('success', [ + z.object({ + email: z.string().email(), + success: z.literal(true), + enrollment: credentialGroupEnrollmentSchema, + }), + z.object({ + email: z.string().email(), + success: z.literal(false), + error: z.string(), + }), +]) + +export const createCredentialGroupBodySchema = z + .object({ + name: z.string().trim().min(1, 'Name is required').max(100), + description: z.string().trim().max(500).optional(), + options: z.array(credentialGroupOptionInputSchema).max(CREDENTIAL_GROUP_PROVIDER_IDS.length), + }) + .strict() + .superRefine((body, ctx) => { + const labels = new Set() + const providers = new Set() + for (const [index, option] of body.options.entries()) { + if (option.provider === 'slack') { + ctx.addIssue({ + code: 'custom', + path: ['options', index], + message: 'Create the Credential Group before configuring Slack', + }) + } + const normalized = option.label.toLocaleLowerCase() + if (labels.has(normalized)) { + ctx.addIssue({ + code: 'custom', + path: ['options', index, 'label'], + message: 'Credential option labels must be unique within a group', + }) + } + labels.add(normalized) + if (providers.has(option.provider)) { + ctx.addIssue({ + code: 'custom', + path: ['options', index, 'provider'], + message: 'Each provider can only be added once', + }) + } + providers.add(option.provider) + } + }) + +export type CreateCredentialGroupBody = z.input + +export const updateCredentialGroupBodySchema = z + .object({ + name: z.string().trim().min(1, 'Name is required').max(100).optional(), + description: z.string().trim().max(500).nullable().optional(), + options: z + .array(credentialGroupOptionUpdateInputSchema) + .max(CREDENTIAL_GROUP_PROVIDER_IDS.length) + .optional(), + status: credentialGroupStatusSchema.optional(), + }) + .strict() + .superRefine((body, ctx) => { + if (Object.keys(body).length === 0) { + ctx.addIssue({ code: 'custom', message: 'At least one field must be updated' }) + } + if (!body.options) return + const labels = new Set() + const optionIds = new Set() + const providers = new Set() + for (const [index, option] of body.options.entries()) { + const normalized = option.label.toLowerCase() + if (labels.has(normalized)) { + ctx.addIssue({ + code: 'custom', + path: ['options', index, 'label'], + message: 'Credential option labels must be unique within a group', + }) + } + labels.add(normalized) + if (providers.has(option.provider)) { + ctx.addIssue({ + code: 'custom', + path: ['options', index, 'provider'], + message: 'Each provider can only be added once', + }) + } + providers.add(option.provider) + if (option.id && optionIds.has(option.id)) { + ctx.addIssue({ + code: 'custom', + path: ['options', index, 'id'], + message: 'Credential option IDs must be unique within a group', + }) + } + if (option.id) optionIds.add(option.id) + } + }) + +export type UpdateCredentialGroupBody = z.input + +export const listCredentialGroupsContract = defineRouteContract({ + method: 'GET', + path: '/api/workspaces/[id]/credential-groups', + params: credentialGroupWorkspaceParamsSchema, + response: { + mode: 'json', + schema: z.object({ credentialGroups: z.array(credentialGroupSchema) }), + }, +}) + +export const createCredentialGroupContract = defineRouteContract({ + method: 'POST', + path: '/api/workspaces/[id]/credential-groups', + params: credentialGroupWorkspaceParamsSchema, + body: createCredentialGroupBodySchema, + response: { + mode: 'json', + status: 201, + schema: z.object({ credentialGroup: credentialGroupSchema }), + }, +}) + +export const getCredentialGroupContract = defineRouteContract({ + method: 'GET', + path: '/api/workspaces/[id]/credential-groups/[groupId]', + params: credentialGroupDetailParamsSchema, + query: credentialGroupEnrollmentListQuerySchema, + response: { + mode: 'json', + schema: z.object({ + credentialGroup: credentialGroupSchema, + enrollments: z.array(credentialGroupEnrollmentDetailSchema), + nextCursor: z.string().nullable(), + }), + }, +}) + +export const inviteCredentialGroupEnrollmentsContract = defineRouteContract({ + method: 'POST', + path: '/api/workspaces/[id]/credential-groups/[groupId]/enrollments', + params: credentialGroupDetailParamsSchema, + body: inviteCredentialGroupEnrollmentsBodySchema, + response: { + mode: 'json', + schema: z.object({ + results: z.array(credentialGroupEnrollmentInviteResultSchema).min(1).max(100), + sentCount: z.number().int().nonnegative(), + failedCount: z.number().int().nonnegative(), + }), + }, +}) + +export const resendCredentialGroupEnrollmentContract = defineRouteContract({ + method: 'POST', + path: '/api/workspaces/[id]/credential-groups/[groupId]/enrollments/[enrollmentId]/resend', + params: credentialGroupEnrollmentParamsSchema, + response: { + mode: 'json', + schema: z.object({ credentialGroupEnrollment: credentialGroupEnrollmentSchema }), + }, +}) + +export const revokeCredentialGroupEnrollmentContract = defineRouteContract({ + method: 'DELETE', + path: '/api/workspaces/[id]/credential-groups/[groupId]/enrollments/[enrollmentId]', + params: credentialGroupEnrollmentParamsSchema, + response: { + mode: 'json', + schema: z.object({ credentialGroupEnrollment: credentialGroupEnrollmentSchema }), + }, +}) + +export const deleteCredentialGroupContract = defineRouteContract({ + method: 'DELETE', + path: '/api/workspaces/[id]/credential-groups/[groupId]', + params: credentialGroupDetailParamsSchema, + response: { + mode: 'json', + schema: z.object({ success: z.literal(true) }), + }, +}) + +export const updateCredentialGroupContract = defineRouteContract({ + method: 'PATCH', + path: '/api/workspaces/[id]/credential-groups/[groupId]', + params: credentialGroupDetailParamsSchema, + body: updateCredentialGroupBodySchema, + response: { + mode: 'json', + schema: z.object({ credentialGroup: credentialGroupSchema }), + }, +}) + +export const startSlackCredentialGroupConfigurationContract = defineRouteContract({ + method: 'POST', + path: '/api/workspaces/[id]/credential-groups/[groupId]/slack-managed-users', + params: credentialGroupDetailParamsSchema, + body: startSlackCredentialGroupConfigurationBodySchema, + response: { + mode: 'json', + schema: z.object({ + authorizationUrl: z.string().url(), + state: z.string().min(1), + }), + }, +}) + +export const slackCredentialGroupConfigurationCallbackContract = defineRouteContract({ + method: 'GET', + path: '/api/credential-groups/slack-managed-users/callback', + query: slackCredentialGroupConfigurationCallbackQuerySchema, + response: { mode: 'text' }, +}) + +export const startCredentialGroupOAuthContract = defineRouteContract({ + method: 'GET', + path: '/api/credential-groups/enroll/[token]/oauth/[optionId]', + params: startCredentialGroupOAuthParamsSchema, + response: { mode: 'empty' }, +}) + +export const completeCredentialGroupEnrollmentContract = defineRouteContract({ + method: 'POST', + path: '/api/credential-groups/enroll/[token]/complete', + params: publicCredentialGroupEnrollmentParamsSchema, + response: { mode: 'empty' }, +}) + +export const credentialGroupOAuthCallbackContract = defineRouteContract({ + method: 'GET', + path: '/api/credential-groups/oauth/[provider]/callback', + params: credentialGroupOAuthCallbackParamsSchema, + query: credentialGroupOAuthCallbackQuerySchema, + response: { mode: 'empty' }, +}) diff --git a/apps/sim/lib/api/contracts/credentials.ts b/apps/sim/lib/api/contracts/credentials.ts index e3c00abec1f..944cbd54980 100644 --- a/apps/sim/lib/api/contracts/credentials.ts +++ b/apps/sim/lib/api/contracts/credentials.ts @@ -17,9 +17,14 @@ export const workspaceCredentialTypeSchema = z.enum([ 'env_personal', 'service_account', ]) +const creatableWorkspaceCredentialTypeSchema = z.enum([ + 'oauth', + 'env_workspace', + 'env_personal', + 'service_account', +]) export const workspaceCredentialRoleSchema = z.enum(['admin', 'member']) export const workspaceCredentialMemberStatusSchema = z.enum(['active', 'pending', 'revoked']) - export const workspaceCredentialSchema = z.object({ id: z.string(), workspaceId: z.string(), @@ -112,7 +117,7 @@ export const serviceAccountJsonSchema = z export const createCredentialBodySchema = z .object({ workspaceId: z.string().uuid('Workspace ID must be a valid UUID'), - type: workspaceCredentialTypeSchema, + type: creatableWorkspaceCredentialTypeSchema, displayName: z.string().trim().min(1).max(255).optional(), description: z.string().trim().max(500).optional(), providerId: z.string().trim().min(1).optional(), diff --git a/apps/sim/lib/api/contracts/oauth-connections.ts b/apps/sim/lib/api/contracts/oauth-connections.ts index c9c4951efd2..234f86419ea 100644 --- a/apps/sim/lib/api/contracts/oauth-connections.ts +++ b/apps/sim/lib/api/contracts/oauth-connections.ts @@ -7,6 +7,8 @@ import type { } from '@/lib/api/contracts/types' import { defineRouteContract } from '@/lib/api/contracts/types' +export const MANAGED_OAUTH_DELEGATION_HEADER = 'x-sim-managed-oauth-delegation' + export const oauthAccountSummarySchema = z.object({ id: z.string(), name: z.string(), @@ -70,6 +72,7 @@ export const oauthTokenRequestBodySchema = z credentialId: z.string().min(1).optional(), credentialAccountUserId: z.string().min(1).optional(), providerId: z.string().min(1).optional(), + toolId: z.string().min(1).optional(), workflowId: z.string().min(1).nullish(), scopes: z.array(z.string()).optional(), impersonateEmail: impersonateEmailSchema.optional(), @@ -91,6 +94,10 @@ export const oauthTokenPostQuerySchema = z.object({ userId: z.string().min(1).optional(), }) +export const oauthTokenPostHeadersSchema = z.object({ + [MANAGED_OAUTH_DELEGATION_HEADER]: z.string().min(1).optional(), +}) + const oauthTokenResponseSchema = z.object({ accessToken: z.string(), idToken: z.string().optional(), @@ -119,6 +126,7 @@ export const oauthTokenPostContract = defineRouteContract({ method: 'POST', path: '/api/auth/oauth/token', query: oauthTokenPostQuerySchema, + headers: oauthTokenPostHeadersSchema, body: oauthTokenRequestBodySchema, response: { mode: 'json', diff --git a/apps/sim/lib/api/contracts/workspaces.ts b/apps/sim/lib/api/contracts/workspaces.ts index 6d4c64e60a1..0485b7abfae 100644 --- a/apps/sim/lib/api/contracts/workspaces.ts +++ b/apps/sim/lib/api/contracts/workspaces.ts @@ -256,6 +256,11 @@ export const workspaceHostContextSchema = z.object({ isHostOrganizationMember: z.boolean(), isHostOrganizationAdmin: z.boolean(), }), + features: z + .object({ + credentialGroups: z.boolean(), + }) + .optional(), }) export type WorkspaceHostContext = z.output diff --git a/apps/sim/lib/auth/connectors/managed-oauth.ts b/apps/sim/lib/auth/connectors/managed-oauth.ts new file mode 100644 index 00000000000..ed2d8d364c0 --- /dev/null +++ b/apps/sim/lib/auth/connectors/managed-oauth.ts @@ -0,0 +1,133 @@ +import { createHash } from 'node:crypto' +import type { OAuth2Tokens } from '@better-auth/core/oauth2' +import type { GenericOAuthConfig } from 'better-auth/plugins' +import { OAuth2Client, type TokenPayload } from 'google-auth-library' +import { buildConnectorProviders } from '@/lib/auth/connectors/providers' + +const GOOGLE_OPENID_SCOPE = 'openid' +const GOOGLE_EMAIL_SCOPE = 'https://www.googleapis.com/auth/userinfo.email' +const GOOGLE_PROFILE_SCOPE = 'https://www.googleapis.com/auth/userinfo.profile' +const GMAIL_READONLY_SCOPE = 'https://www.googleapis.com/auth/gmail.readonly' +const GMAIL_MODIFY_SCOPE = 'https://www.googleapis.com/auth/gmail.modify' +const GMAIL_SEND_SCOPE = 'https://www.googleapis.com/auth/gmail.send' +const GMAIL_LABELS_SCOPE = 'https://www.googleapis.com/auth/gmail.labels' + +export interface ManagedOAuthConnectorIdentity { + providerSubjectId: string + providerTenantId: string | null + email: string + emailVerified: boolean + displayName?: string + avatarUrl?: string + nonce?: string + grantedScopes: string[] +} + +export interface ManagedOAuthConnectorConfig { + additionalScopes: string[] + requiresRefreshToken: boolean + pkce: boolean + prompt?: string + authorizationUrlParams?: Record + getAuthorizationAppId(clientId: string): string + verifyIdentity(params: { + tokens: OAuth2Tokens + clientId: string + }): Promise + hasRequiredScopes(grantedScopes: string[], requiredScopes: string[]): boolean + isTerminalRefreshError(errorCode: string | undefined): boolean +} + +export interface ConnectorProviderConfig extends GenericOAuthConfig { + managedOAuth: ManagedOAuthConnectorConfig +} + +function canonicalGoogleScope(scope: string): string { + if (scope === 'email') return GOOGLE_EMAIL_SCOPE + if (scope === 'profile') return GOOGLE_PROFILE_SCOPE + return scope +} + +function hasRequiredGoogleScopes( + providerId: string, + grantedScopes: string[], + requiredScopes: string[] +): boolean { + const granted = new Set(grantedScopes.map(canonicalGoogleScope)) + return requiredScopes.every((requestedScope) => { + const required = canonicalGoogleScope(requestedScope) + if (granted.has(required)) return true + return ( + providerId === 'google-email' && + granted.has(GMAIL_MODIFY_SCOPE) && + (required === GMAIL_READONLY_SCOPE || + required === GMAIL_SEND_SCOPE || + required === GMAIL_LABELS_SCOPE) + ) + }) +} + +function requireVerifiedGooglePayload(payload: TokenPayload | undefined): TokenPayload & { + sub: string + email: string +} { + if (!payload?.sub || !payload.email || payload.email_verified !== true) { + throw new Error('Google returned an invalid identity token') + } + return payload as TokenPayload & { sub: string; email: string } +} + +export function createGoogleManagedOAuthConnector(providerId: string): ManagedOAuthConnectorConfig { + return { + additionalScopes: [GOOGLE_OPENID_SCOPE], + requiresRefreshToken: true, + pkce: true, + prompt: 'consent select_account', + authorizationUrlParams: { include_granted_scopes: 'false' }, + getAuthorizationAppId(clientId) { + return `google:${createHash('sha256').update(clientId).digest('hex')}` + }, + async verifyIdentity({ tokens, clientId }) { + if (!tokens.idToken || !tokens.accessToken) { + throw new Error('Google returned an incomplete authorization') + } + const client = new OAuth2Client({ clientId }) + const ticket = await client.verifyIdToken({ idToken: tokens.idToken, audience: clientId }) + const payload = requireVerifiedGooglePayload(ticket.getPayload()) + const tokenInfo = await client.getTokenInfo(tokens.accessToken) + if (tokenInfo.aud !== clientId || tokenInfo.sub !== payload.sub) { + throw new Error('Google returned an access token for another identity') + } + return { + providerSubjectId: payload.sub, + providerTenantId: payload.hd ?? null, + email: payload.email, + emailVerified: true, + ...(payload.name ? { displayName: payload.name } : {}), + ...(payload.picture ? { avatarUrl: payload.picture } : {}), + ...(payload.nonce ? { nonce: payload.nonce } : {}), + grantedScopes: [...new Set(tokenInfo.scopes)], + } + }, + hasRequiredScopes(grantedScopes, requiredScopes) { + return hasRequiredGoogleScopes(providerId, grantedScopes, requiredScopes) + }, + isTerminalRefreshError(errorCode) { + return errorCode === 'invalid_grant' + }, + } +} + +export function getManagedOAuthConnectorProviderConfig( + providerId: string +): ConnectorProviderConfig | undefined { + if (providerId !== 'google-email' && providerId !== 'google-calendar') return undefined + const connector = buildConnectorProviders().find( + (candidate) => candidate.providerId === providerId + ) + if (!connector) return undefined + return { + ...connector, + managedOAuth: createGoogleManagedOAuthConnector(providerId), + } +} diff --git a/apps/sim/lib/auth/credential-access.ts b/apps/sim/lib/auth/credential-access.ts index 719cd30f8ca..2671bf1f0de 100644 --- a/apps/sim/lib/auth/credential-access.ts +++ b/apps/sim/lib/auth/credential-access.ts @@ -18,7 +18,7 @@ export interface CredentialAccessResult { credentialOwnerUserId?: string workspaceId?: string resolvedCredentialId?: string - credentialType?: 'oauth' | 'service_account' + credentialType?: 'oauth' | 'managed_oauth' | 'service_account' } const NO_CREDENTIAL_ACCESS = @@ -123,6 +123,13 @@ export async function authorizeCredentialUseForAuth( const accessError = credentialAccessError(platformAccess) if (accessError) return { ok: false, error: accessError } + if (platformCredential.type === 'managed_oauth') { + return { + ok: false, + error: 'Managed credential access requires scoped workflow delegation', + } + } + if (platformCredential.type === 'service_account') { return { ok: true, diff --git a/apps/sim/lib/auth/principal.test.ts b/apps/sim/lib/auth/principal.test.ts index d440b02be05..e686b076f2e 100644 --- a/apps/sim/lib/auth/principal.test.ts +++ b/apps/sim/lib/auth/principal.test.ts @@ -49,6 +49,19 @@ describe('principal subject users', () => { }) ).toThrow(PrincipalSubjectUserRequiredError) }) + + it('fails fast instead of fabricating a Sim user for an external enrollment', () => { + expect(() => + requirePrincipalSubjectUserId({ + kind: 'credential_group_enrollment', + workspaceId: 'workspace-1', + credentialGroupId: 'group-1', + enrollmentId: 'enrollment-1', + email: 'person@example.com', + invitationTokenHash: 'hash-1', + }) + ).toThrow(PrincipalSubjectUserRequiredError) + }) }) describe('principal actors', () => { @@ -86,6 +99,26 @@ describe('principal actors', () => { actorId: null, actorName: 'Workspace API key', }) + expect( + resolvePrincipalAuditAttribution({ + kind: 'credential_group_enrollment', + workspaceId: 'workspace-1', + credentialGroupId: 'group-1', + enrollmentId: 'enrollment-1', + email: 'person@example.com', + invitationTokenHash: 'hash-1', + }) + ).toEqual({ + actor: { + kind: 'credential_group_enrollment', + workspaceId: 'workspace-1', + credentialGroupId: 'group-1', + enrollmentId: 'enrollment-1', + email: 'person@example.com', + }, + actorId: null, + actorName: 'person@example.com', + }) }) it('projects principals into their shared actor identity', () => { @@ -158,4 +191,17 @@ describe('principal actors', () => { }) ).toThrow('Workspace API key attribution requires a workspace billing owner') }) + + it('fails fast when external enrollment identity is used for user attribution', () => { + expect(() => + resolvePrincipalAttribution({ + kind: 'credential_group_enrollment', + workspaceId: 'workspace-1', + credentialGroupId: 'group-1', + enrollmentId: 'enrollment-1', + email: 'person@example.com', + invitationTokenHash: 'hash-1', + }) + ).toThrow(PrincipalSubjectUserRequiredError) + }) }) diff --git a/apps/sim/lib/core/application/workspace-operation.ts b/apps/sim/lib/core/application/workspace-operation.ts index aebf094713e..fea7f27b091 100644 --- a/apps/sim/lib/core/application/workspace-operation.ts +++ b/apps/sim/lib/core/application/workspace-operation.ts @@ -1,14 +1,22 @@ -import type { DelegatedPrincipal, DelegatedServiceId, Principal } from '@sim/auth/principal' +import type { + CredentialGroupEnrollmentPrincipal, + DelegatedPrincipal, + DelegatedServiceId, + Principal, +} from '@sim/auth/principal' import type { PermissionType } from '@sim/platform-authz/workspace' import type { ApplicationOperation } from '@/lib/core/application/operation' type WorkspaceApiKeyPolicy = R extends 'admin' ? 'deny' : 'allow' | 'deny' -export type PrincipalKind = Principal['kind'] +export type PrincipalKind = Exclude type NonDelegatedPrincipalForOperation< O extends { readonly principalKinds: readonly PrincipalKind[] }, -> = Exclude, DelegatedPrincipal> +> = Exclude< + Extract, + DelegatedPrincipal | CredentialGroupEnrollmentPrincipal +> type DelegatedPrincipalForOperation< O extends { diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index baf2e9deffc..4ef32b4279f 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -528,6 +528,7 @@ export const env = createEnv({ TABLES_V2_API: z.boolean().optional(), // Enable the v2 tables HTTP API (public /api/v2/tables + internal /api/table/[tableId]/query predicate-grammar route) TABLE_LOCKS: z.boolean().optional(), // Enable per-table mutation locks (schema/insert/update/delete toggles) TABLE_VIEWS: z.boolean().optional(), // Enable saved table views (named filter/sort/column-visibility presets) and the column show/hide menu + CREDENTIAL_GROUPS: z.boolean().optional(), // Enable enterprise Credential Groups globally // Organizations - for self-hosted deployments ORGANIZATIONS_ENABLED: z.boolean().optional(), // Enable organizations on self-hosted (bypasses plan requirements) diff --git a/apps/sim/lib/core/config/feature-flags.test.ts b/apps/sim/lib/core/config/feature-flags.test.ts index 7f0d0dbea50..ca87caf815d 100644 --- a/apps/sim/lib/core/config/feature-flags.test.ts +++ b/apps/sim/lib/core/config/feature-flags.test.ts @@ -14,6 +14,8 @@ const { mockFetch, mockIsPlatformAdmin, envRef } = vi.hoisted(() => ({ FORKING_ENABLED: undefined as boolean | undefined, DEPLOY_AS_BLOCK: undefined as boolean | undefined, TABLES_V2_API: undefined as boolean | undefined, + TABLE_VIEWS: undefined as boolean | undefined, + CREDENTIAL_GROUPS: undefined as boolean | undefined, }, })) @@ -122,6 +124,8 @@ describe('isFeatureEnabled', () => { setEnvFlags({ isAppConfigEnabled: false }) envRef.FORKING_ENABLED = undefined envRef.DEPLOY_AS_BLOCK = undefined + envRef.CREDENTIAL_GROUPS = undefined + envRef.TABLE_VIEWS = undefined }) describe('workspace-forking flag', () => { @@ -160,6 +164,20 @@ describe('isFeatureEnabled', () => { }) }) + describe('credential-groups flag', () => { + it('uses a global fallback switch off AppConfig', async () => { + expect(await isFeatureEnabled('credential-groups')).toBe(false) + + envRef.CREDENTIAL_GROUPS = true + expect(await isFeatureEnabled('credential-groups')).toBe(true) + }) + + it('uses only the global AppConfig clause', async () => { + withAppConfig({ 'credential-groups': { enabled: true } }) + expect(await isFeatureEnabled('credential-groups')).toBe(true) + }) + }) + describe('table-views flag', () => { it('falls back to TABLE_VIEWS when AppConfig is disabled', async () => { envRef.TABLE_VIEWS = undefined diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index cef6ab2e66b..c068326a648 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -144,6 +144,12 @@ const FEATURE_FLAGS = { 'Off-AppConfig falls back to TABLE_VIEWS.', fallback: 'TABLE_VIEWS', }, + 'credential-groups': { + description: + 'Workspace-owned collections that gather managed OAuth credentials from external users. ' + + 'Global on/off only; hosted workspaces must also have an Enterprise subscription.', + fallback: 'CREDENTIAL_GROUPS', + }, } satisfies Record /** diff --git a/apps/sim/lib/credential-groups/application/authorization.ts b/apps/sim/lib/credential-groups/application/authorization.ts new file mode 100644 index 00000000000..f61a27f8ee3 --- /dev/null +++ b/apps/sim/lib/credential-groups/application/authorization.ts @@ -0,0 +1,26 @@ +import type { Principal } from '@sim/auth/principal' +import type { + WorkspaceAuthorizationContext, + WorkspaceDelegationPolicy, +} from '@/lib/core/application' +import type { CredentialGroupCredentialListContext } from '@/lib/credential-groups/credentials' + +export const CREDENTIAL_GROUP_DELEGATION_AUDIENCE = 'sim:credential-groups' + +export interface CredentialGroupApplicationContext + extends WorkspaceAuthorizationContext, + CredentialGroupCredentialListContext {} + +export const credentialGroupDelegationPolicy = { + audience: CREDENTIAL_GROUP_DELEGATION_AUDIENCE, + isWithinScope: ( + principal: Extract, + context: CredentialGroupApplicationContext + ) => principal.resourceScope?.credentialGroupId === context.credentialGroupId, +} satisfies WorkspaceDelegationPolicy + +export const credentialGroupWorkspaceDelegationPolicy = { + audience: CREDENTIAL_GROUP_DELEGATION_AUDIENCE, + isWithinScope: (principal: Extract) => + principal.resourceScope?.credentialGroupId === undefined, +} satisfies WorkspaceDelegationPolicy diff --git a/apps/sim/lib/credential-groups/application/context.ts b/apps/sim/lib/credential-groups/application/context.ts new file mode 100644 index 00000000000..3329e7a3b9d --- /dev/null +++ b/apps/sim/lib/credential-groups/application/context.ts @@ -0,0 +1,53 @@ +import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { CredentialGroupApplicationContext } from '@/lib/credential-groups/application/authorization' +import { + isCredentialGroupsAvailable, + resolveCredentialGroupsAvailability, +} from '@/lib/credential-groups/availability' +import { loadCredentialGroupCredentialListContext } from '@/lib/credential-groups/credentials' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export async function requireCredentialGroupsAvailable(workspaceId: string): Promise { + const ownerBilling = await getWorkspaceOwnerSubscriptionAccess(workspaceId) + const availability = await resolveCredentialGroupsAvailability(ownerBilling) + if (!availability.available) { + const message = + availability.reason === 'enterprise_plan_required' + ? 'Credential Groups are not available. Enterprise plan required.' + : 'Credential Groups are not available' + throw new OrchestrationError('forbidden', message) + } +} + +export async function requireCredentialGroupSettingsAvailable(workspaceId: string): Promise { + const ownerBilling = await getWorkspaceOwnerSubscriptionAccess(workspaceId) + if (!(await isCredentialGroupsAvailable(ownerBilling))) { + throw new OrchestrationError('not_found', 'Credential Groups are not available') + } +} + +export async function resolveCredentialGroupWorkspaceContext(workspaceId: string) { + const workspace = await loadActiveWorkspaceApplicationContext(workspaceId) + if (!workspace) throw new OrchestrationError('not_found', 'Workspace not found') + return workspace +} + +export async function resolveCredentialGroupContext( + credentialGroupId: string +): Promise { + const group = await loadCredentialGroupCredentialListContext(credentialGroupId) + if (!group) throw new OrchestrationError('not_found', 'Credential group not found') + return { ...(await resolveCredentialGroupWorkspaceContext(group.workspaceId)), ...group } +} + +export async function resolveCredentialGroupSettingsContext( + credentialGroupId: string, + assertedWorkspaceId: string +): Promise { + const context = await resolveCredentialGroupContext(credentialGroupId) + if (context.workspaceId !== assertedWorkspaceId) { + throw new OrchestrationError('not_found', 'Credential group not found') + } + return context +} diff --git a/apps/sim/lib/credential-groups/application/delegation.ts b/apps/sim/lib/credential-groups/application/delegation.ts new file mode 100644 index 00000000000..56ad6895c0a --- /dev/null +++ b/apps/sim/lib/credential-groups/application/delegation.ts @@ -0,0 +1,41 @@ +import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import { + InvalidInternalDelegationTokenError, + verifyInternalDelegationToken, +} from '@/lib/auth/internal' +import { + bindInternalExecutorDelegation, + InvalidInternalDelegationBindingError, +} from '@/lib/auth/internal-delegation' +import { CREDENTIAL_GROUP_DELEGATION_AUDIENCE } from '@/lib/credential-groups/application/authorization' + +export class InvalidCredentialGroupDelegationError extends Error { + constructor() { + super('Credential Group execution requires valid workflow delegation') + this.name = 'InvalidCredentialGroupDelegationError' + } +} + +/** Authenticates and binds executor claims to Credential Group application scope. */ +export async function authenticateCredentialGroupDelegation( + authorization: string, + credentialGroupId?: string +): Promise { + if (!authorization.startsWith('Bearer ')) throw new InvalidCredentialGroupDelegationError() + + try { + const claims = await verifyInternalDelegationToken(authorization.slice('Bearer '.length)) + return await bindInternalExecutorDelegation(claims, { + audience: CREDENTIAL_GROUP_DELEGATION_AUDIENCE, + ...(credentialGroupId ? { resourceScope: { credentialGroupId } } : {}), + }) + } catch (error) { + if ( + error instanceof InvalidInternalDelegationTokenError || + error instanceof InvalidInternalDelegationBindingError + ) { + throw new InvalidCredentialGroupDelegationError() + } + throw error + } +} diff --git a/apps/sim/lib/credential-groups/application/enrollment-auth.ts b/apps/sim/lib/credential-groups/application/enrollment-auth.ts new file mode 100644 index 00000000000..25df4bf0cf3 --- /dev/null +++ b/apps/sim/lib/credential-groups/application/enrollment-auth.ts @@ -0,0 +1,12 @@ +import type { CredentialGroupEnrollmentPrincipal } from '@sim/auth/principal' +import { authenticatePublicCredentialGroupEnrollment } from '@/lib/credential-groups/enrollments' + +/** Exchanges a valid invitation bearer for its bounded external enrollment principal. */ +export async function authenticateCredentialGroupEnrollment( + invitationToken: string +): Promise { + if (!invitationToken.trim() || invitationToken.length > 128) return null + const identity = await authenticatePublicCredentialGroupEnrollment(invitationToken) + if (!identity) return null + return Object.freeze({ kind: 'credential_group_enrollment' as const, ...identity }) +} diff --git a/apps/sim/lib/credential-groups/application/enrollment-operations.ts b/apps/sim/lib/credential-groups/application/enrollment-operations.ts new file mode 100644 index 00000000000..b53788bda45 --- /dev/null +++ b/apps/sim/lib/credential-groups/application/enrollment-operations.ts @@ -0,0 +1,32 @@ +import type { ApplicationOperation } from '@/lib/core/application' + +export interface CredentialGroupEnrollmentOperation + extends ApplicationOperation { + readonly principalKind: 'credential_group_enrollment' +} + +function defineCredentialGroupEnrollmentOperation( + operation: CredentialGroupEnrollmentOperation +): CredentialGroupEnrollmentOperation { + if (!operation.id.trim()) throw new Error('Credential Group enrollment operation ID is required') + return Object.freeze(operation) +} + +export const credentialGroupEnrollmentOperations = { + read: defineCredentialGroupEnrollmentOperation({ + id: 'credential_groups.enrollment.read', + principalKind: 'credential_group_enrollment', + }), + startOAuth: defineCredentialGroupEnrollmentOperation({ + id: 'credential_groups.enrollment.oauth.start', + principalKind: 'credential_group_enrollment', + }), + completeOAuth: defineCredentialGroupEnrollmentOperation({ + id: 'credential_groups.enrollment.oauth.complete', + principalKind: 'credential_group_enrollment', + }), + complete: defineCredentialGroupEnrollmentOperation({ + id: 'credential_groups.enrollment.complete', + principalKind: 'credential_group_enrollment', + }), +} as const diff --git a/apps/sim/lib/credential-groups/application/list-credentials.test.ts b/apps/sim/lib/credential-groups/application/list-credentials.test.ts new file mode 100644 index 00000000000..62254941165 --- /dev/null +++ b/apps/sim/lib/credential-groups/application/list-credentials.test.ts @@ -0,0 +1,276 @@ +/** + * @vitest-environment node + */ +import type { SessionPrincipal, WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getWorkspaceOwnerSubscriptionAccess: vi.fn(), + listCredentials: vi.fn(), + loadGroup: vi.fn(), + loadWorkspace: vi.fn(), + resolveCredentialGroupsAvailability: vi.fn(), + resolvePermission: vi.fn(), +})) + +vi.mock('@/lib/billing/core/workspace-access', () => ({ + getWorkspaceOwnerSubscriptionAccess: mocks.getWorkspaceOwnerSubscriptionAccess, +})) + +vi.mock('@/lib/credential-groups/availability', () => ({ + resolveCredentialGroupsAvailability: mocks.resolveCredentialGroupsAvailability, +})) + +vi.mock('@/lib/credential-groups/credentials', () => ({ + CredentialGroupCredentialCursorNotFoundError: class extends Error { + constructor() { + super('Credential group credential cursor not found') + this.name = 'CredentialGroupCredentialCursorNotFoundError' + } + }, + listCredentialGroupCredentialReferences: mocks.listCredentials, + loadCredentialGroupCredentialListContext: mocks.loadGroup, + MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE: 100, +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +import { listCredentialGroupCredentials } from '@/lib/credential-groups/application/list-credentials' +import { CredentialGroupCredentialCursorNotFoundError } from '@/lib/credential-groups/credentials' + +const groupContext = { + credentialGroupId: 'group-1', + workspaceId: 'workspace-1', + name: 'Credential Group', + status: 'active' as const, + options: [ + { + id: 'option-1', + provider: 'gmail' as const, + label: 'Work Gmail', + authorizationAppId: 'google:client-1', + requiredScopes: ['gmail.readonly'], + scopeVersion: 1, + required: true, + status: 'active' as const, + }, + { + id: 'option-disabled', + provider: 'gmail' as const, + label: 'Old Gmail', + authorizationAppId: 'google:client-1', + requiredScopes: ['gmail.readonly'], + scopeVersion: 1, + required: false, + status: 'disabled' as const, + }, + ], +} +const workspaceContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const input = { credentialGroupId: 'group-1', limit: 50 } + +function executorPrincipal(credentialGroupId = 'group-1'): WorkflowExecutionDelegatedPrincipal { + return { + kind: 'delegated', + serviceId: 'executor', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:credential-groups', + issuedAt: new Date(Date.now() - 1_000), + expiresAt: new Date(Date.now() + 60_000), + resourceScope: { credentialGroupId }, + delegationContext: { kind: 'workflow_execution', workflowId: 'workflow-1' }, + } +} + +describe('listCredentialGroupCredentials', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadGroup.mockResolvedValue(groupContext) + mocks.loadWorkspace.mockResolvedValue(workspaceContext) + mocks.resolvePermission.mockResolvedValue('read') + mocks.getWorkspaceOwnerSubscriptionAccess.mockResolvedValue({ isEnterprise: true }) + mocks.resolveCredentialGroupsAvailability.mockResolvedValue({ available: true }) + mocks.listCredentials.mockResolvedValue({ + credentials: [ + { + credentialId: 'credential-1', + email: 'person@example.com', + displayName: 'person@example.com', + providerId: 'google-email', + providerSubjectId: 'google-subject-1', + providerTenantId: null, + }, + ], + nextCursor: 'credential-1', + }) + }) + + it('rejects unsupported principals before loading the group', async () => { + const principal: SessionPrincipal = { + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', + } + + await expect( + listCredentialGroupCredentials.execute({ principal, input }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.loadGroup).not.toHaveBeenCalled() + }) + + it('rejects executor delegation scoped to another group', async () => { + await expect( + listCredentialGroupCredentials.execute({ + principal: executorPrincipal('group-2'), + input, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.listCredentials).not.toHaveBeenCalled() + }) + + it('returns a bounded page after current workspace and entitlement checks', async () => { + const result = await listCredentialGroupCredentials.execute({ + principal: executorPrincipal(), + input, + }) + + expect(mocks.resolvePermission).toHaveBeenCalledWith('user-1', 'workspace-1', null, undefined, { + forUpdate: undefined, + }) + expect(mocks.listCredentials).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + credentialGroupId: 'group-1', + limit: 50, + cursor: undefined, + email: undefined, + credentialProviderIds: undefined, + credentialGroupOptionIds: ['option-1'], + }) + expect(result).toEqual({ + credentials: [ + { + credentialId: 'credential-1', + email: 'person@example.com', + displayName: 'person@example.com', + providerId: 'google-email', + providerSubjectId: 'google-subject-1', + providerTenantId: null, + }, + ], + count: 1, + hasMore: true, + nextCursor: 'credential-1', + }) + }) + + it('filters by canonical providers active in the group', async () => { + await listCredentialGroupCredentials.execute({ + principal: executorPrincipal(), + input: { ...input, credentialProviderIds: ['google-email', 'google-email'] }, + }) + + expect(mocks.listCredentials).toHaveBeenCalledWith( + expect.objectContaining({ credentialProviderIds: ['google-email'] }) + ) + }) + + it('normalizes an exact enrollment email filter', async () => { + await listCredentialGroupCredentials.execute({ + principal: executorPrincipal(), + input: { ...input, email: ' Person@Example.COM ' }, + }) + + expect(mocks.listCredentials).toHaveBeenCalledWith( + expect.objectContaining({ email: 'person@example.com' }) + ) + }) + + it('rejects providers that are not active in the group before credential access', async () => { + await expect( + listCredentialGroupCredentials.execute({ + principal: executorPrincipal(), + input: { ...input, credentialProviderIds: ['slack'] }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + expect(mocks.getWorkspaceOwnerSubscriptionAccess).not.toHaveBeenCalled() + expect(mocks.listCredentials).not.toHaveBeenCalled() + }) + + it('fails before listing when the group is disabled', async () => { + mocks.loadGroup.mockResolvedValue({ ...groupContext, status: 'disabled' }) + + await expect( + listCredentialGroupCredentials.execute({ principal: executorPrincipal(), input }) + ).rejects.toMatchObject({ code: 'conflict' }) + expect(mocks.getWorkspaceOwnerSubscriptionAccess).not.toHaveBeenCalled() + expect(mocks.listCredentials).not.toHaveBeenCalled() + }) + + it('fails before listing when Credential Groups are unavailable', async () => { + mocks.resolveCredentialGroupsAvailability.mockResolvedValue({ + available: false, + reason: 'feature_disabled', + }) + + await expect( + listCredentialGroupCredentials.execute({ principal: executorPrincipal(), input }) + ).rejects.toMatchObject({ + code: 'forbidden', + message: 'Credential Groups are not available', + }) + expect(mocks.listCredentials).not.toHaveBeenCalled() + }) + + it('identifies the Enterprise requirement for unavailable hosted workspaces', async () => { + mocks.getWorkspaceOwnerSubscriptionAccess.mockResolvedValue({ isEnterprise: false }) + mocks.resolveCredentialGroupsAvailability.mockResolvedValue({ + available: false, + reason: 'enterprise_plan_required', + }) + + await expect( + listCredentialGroupCredentials.execute({ principal: executorPrincipal(), input }) + ).rejects.toMatchObject({ + code: 'forbidden', + message: 'Credential Groups are not available. Enterprise plan required.', + }) + expect(mocks.listCredentials).not.toHaveBeenCalled() + }) + + it('rejects limits outside the bounded page size', async () => { + await expect( + listCredentialGroupCredentials.execute({ + principal: executorPrincipal(), + input: { ...input, limit: 101 }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + expect(mocks.getWorkspaceOwnerSubscriptionAccess).not.toHaveBeenCalled() + expect(mocks.listCredentials).not.toHaveBeenCalled() + }) + + it('classifies a stale or cross-group cursor as invalid input', async () => { + mocks.listCredentials.mockRejectedValueOnce(new CredentialGroupCredentialCursorNotFoundError()) + + await expect( + listCredentialGroupCredentials.execute({ + principal: executorPrincipal(), + input: { ...input, cursor: 'credential-other' }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + }) +}) diff --git a/apps/sim/lib/credential-groups/application/list-credentials.ts b/apps/sim/lib/credential-groups/application/list-credentials.ts new file mode 100644 index 00000000000..3dec5d6e958 --- /dev/null +++ b/apps/sim/lib/credential-groups/application/list-credentials.ts @@ -0,0 +1,111 @@ +import { isValidEmailSyntax, normalizeEmail } from '@sim/utils/string' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { credentialGroupDelegationPolicy } from '@/lib/credential-groups/application/authorization' +import { + requireCredentialGroupsAvailable, + resolveCredentialGroupContext, +} from '@/lib/credential-groups/application/context' +import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' +import { + CredentialGroupCredentialCursorNotFoundError, + type CredentialGroupCredentialReference, + listCredentialGroupCredentialReferences, + MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE, +} from '@/lib/credential-groups/credentials' +import { + getCredentialGroupProviderId, + isCredentialGroupProvider, +} from '@/lib/credential-groups/providers' + +export interface ListCredentialGroupCredentialsInput { + credentialGroupId: string + limit: number + cursor?: string + email?: string + credentialProviderIds?: string[] +} + +export interface ListCredentialGroupCredentialsResult { + credentials: CredentialGroupCredentialReference[] + count: number + hasMore: boolean + nextCursor: string | null +} + +export const listCredentialGroupCredentials = defineAuthorizedWorkspaceUseCase({ + operation: credentialGroupOperations.listCredentials, + resolveContext: ({ input }: { input: ListCredentialGroupCredentialsInput }) => + resolveCredentialGroupContext(input.credentialGroupId), + authorizationOptions: { delegation: credentialGroupDelegationPolicy }, + execute: async ({ input, context }): Promise => { + if ( + !Number.isInteger(input.limit) || + input.limit < 1 || + input.limit > MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE + ) { + throw new OrchestrationError( + 'validation', + `Limit must be an integer between 1 and ${MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE}` + ) + } + if (context.status !== 'active') { + throw new OrchestrationError('conflict', 'Credential group is disabled') + } + + const email = input.email ? normalizeEmail(input.email) : undefined + if (email && !isValidEmailSyntax(email)) { + throw new OrchestrationError('validation', 'Email must be a valid address') + } + + const credentialProviderIds = [...new Set(input.credentialProviderIds ?? [])] + if (credentialProviderIds.some((providerId) => !providerId.trim())) { + throw new OrchestrationError('validation', 'Credential provider IDs must not be empty') + } + const activeOptions = context.options.filter((option) => option.status === 'active') + const activeProviderIds = new Set( + activeOptions.map((option) => { + if (!isCredentialGroupProvider(option.provider)) { + throw new Error(`Credential Group provider is not registered: ${option.provider}`) + } + return getCredentialGroupProviderId(option.provider) + }) + ) + const invalidProviderIds = credentialProviderIds.filter( + (providerId) => !activeProviderIds.has(providerId) + ) + if (invalidProviderIds.length > 0) { + throw new OrchestrationError( + 'validation', + `Credential providers are not active in this group: ${invalidProviderIds.join(', ')}` + ) + } + + await requireCredentialGroupsAvailable(context.workspaceId) + + let page + try { + page = await listCredentialGroupCredentialReferences({ + workspaceId: context.workspaceId, + credentialGroupId: context.credentialGroupId, + credentialGroupOptionIds: activeOptions.map((option) => option.id), + limit: input.limit, + cursor: input.cursor, + email, + credentialProviderIds: credentialProviderIds.length > 0 ? credentialProviderIds : undefined, + }) + } catch (error) { + if (error instanceof CredentialGroupCredentialCursorNotFoundError) { + throw new OrchestrationError('validation', error.message) + } + throw error + } + + return { + credentials: page.credentials, + count: page.credentials.length, + hasMore: page.nextCursor !== null, + nextCursor: page.nextCursor, + } + }, +}) diff --git a/apps/sim/lib/credential-groups/application/list-groups.ts b/apps/sim/lib/credential-groups/application/list-groups.ts new file mode 100644 index 00000000000..3a89e4fca2d --- /dev/null +++ b/apps/sim/lib/credential-groups/application/list-groups.ts @@ -0,0 +1,68 @@ +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { credentialGroupWorkspaceDelegationPolicy } from '@/lib/credential-groups/application/authorization' +import { + requireCredentialGroupsAvailable, + resolveCredentialGroupWorkspaceContext, +} from '@/lib/credential-groups/application/context' +import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' +import { + CredentialGroupCursorNotFoundError, + type CredentialGroupSummary, + listCredentialGroupSummaries, + MAX_CREDENTIAL_GROUP_PAGE_SIZE, +} from '@/lib/credential-groups/groups' + +export interface ListCredentialGroupsInput { + workspaceId: string + limit: number + cursor?: string +} + +export interface ListCredentialGroupsResult { + credentialGroups: CredentialGroupSummary[] + count: number + hasMore: boolean + nextCursor: string | null +} + +export const listCredentialGroupsForWorkflow = defineAuthorizedWorkspaceUseCase({ + operation: credentialGroupOperations.listGroups, + resolveContext: ({ input }: { input: ListCredentialGroupsInput }) => + resolveCredentialGroupWorkspaceContext(input.workspaceId), + authorizationOptions: { delegation: credentialGroupWorkspaceDelegationPolicy }, + execute: async ({ input, context }): Promise => { + if ( + !Number.isInteger(input.limit) || + input.limit < 1 || + input.limit > MAX_CREDENTIAL_GROUP_PAGE_SIZE + ) { + throw new OrchestrationError( + 'validation', + `Limit must be an integer between 1 and ${MAX_CREDENTIAL_GROUP_PAGE_SIZE}` + ) + } + await requireCredentialGroupsAvailable(context.workspaceId) + + let page + try { + page = await listCredentialGroupSummaries({ + workspaceId: context.workspaceId, + limit: input.limit, + cursor: input.cursor, + }) + } catch (error) { + if (error instanceof CredentialGroupCursorNotFoundError) { + throw new OrchestrationError('validation', error.message) + } + throw error + } + + return { + credentialGroups: page.credentialGroups, + count: page.credentialGroups.length, + hasMore: page.nextCursor !== null, + nextCursor: page.nextCursor, + } + }, +}) diff --git a/apps/sim/lib/credential-groups/application/list-people.ts b/apps/sim/lib/credential-groups/application/list-people.ts new file mode 100644 index 00000000000..418ee854c6b --- /dev/null +++ b/apps/sim/lib/credential-groups/application/list-people.ts @@ -0,0 +1,79 @@ +import { isValidEmailSyntax, normalizeEmail } from '@sim/utils/string' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { credentialGroupDelegationPolicy } from '@/lib/credential-groups/application/authorization' +import { + requireCredentialGroupsAvailable, + resolveCredentialGroupContext, +} from '@/lib/credential-groups/application/context' +import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' +import { + CredentialGroupEnrollmentError, + type CredentialGroupEnrollmentStatus, + listCredentialGroupEnrollments, +} from '@/lib/credential-groups/enrollments' + +export const CREDENTIAL_GROUP_PEOPLE_STATUSES = [ + 'invited', + 'delivery_failed', + 'in_progress', + 'completed', + 'revoked', +] as const satisfies readonly CredentialGroupEnrollmentStatus[] + +export interface ListCredentialGroupPeopleInput { + credentialGroupId: string + limit: number + cursor?: string + email?: string + statuses?: CredentialGroupEnrollmentStatus[] +} + +export const listCredentialGroupPeople = defineAuthorizedWorkspaceUseCase({ + operation: credentialGroupOperations.listPeople, + resolveContext: ({ input }: { input: ListCredentialGroupPeopleInput }) => + resolveCredentialGroupContext(input.credentialGroupId), + authorizationOptions: { delegation: credentialGroupDelegationPolicy }, + execute: async ({ input, context }) => { + if (context.status !== 'active') { + throw new OrchestrationError('conflict', 'Credential group is disabled') + } + if (!Number.isInteger(input.limit) || input.limit < 1 || input.limit > 100) { + throw new OrchestrationError('validation', 'Limit must be an integer between 1 and 100') + } + const email = input.email ? normalizeEmail(input.email) : undefined + if (email && !isValidEmailSyntax(email)) { + throw new OrchestrationError('validation', 'Email must be a valid address') + } + const statuses = [...new Set(input.statuses ?? [])] + const allowedStatuses = new Set(CREDENTIAL_GROUP_PEOPLE_STATUSES) + if (statuses.some((status) => !allowedStatuses.has(status))) { + throw new OrchestrationError('validation', 'People status filter is invalid') + } + await requireCredentialGroupsAvailable(context.workspaceId) + + try { + const page = await listCredentialGroupEnrollments( + context.workspaceId, + context.credentialGroupId, + input.limit, + input.cursor, + { email, statuses: statuses.length > 0 ? statuses : undefined } + ) + return { + people: page.enrollments, + count: page.enrollments.length, + hasMore: page.nextCursor !== null, + nextCursor: page.nextCursor, + } + } catch (error) { + if (error instanceof CredentialGroupEnrollmentError) { + throw new OrchestrationError( + error.status === 404 ? 'validation' : error.status === 409 ? 'conflict' : 'internal', + error.message + ) + } + throw error + } + }, +}) diff --git a/apps/sim/lib/credential-groups/application/manage-enrollments.test.ts b/apps/sim/lib/credential-groups/application/manage-enrollments.test.ts new file mode 100644 index 00000000000..10c044b9634 --- /dev/null +++ b/apps/sim/lib/credential-groups/application/manage-enrollments.test.ts @@ -0,0 +1,104 @@ +/** + * @vitest-environment node + */ +import type { SessionPrincipal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + invite: vi.fn(), + loadInviter: vi.fn(), + requireAvailable: vi.fn(), + resolveGroup: vi.fn(), + resolvePermission: vi.fn(), +})) + +vi.mock('@/lib/credential-groups/application/context', () => ({ + requireCredentialGroupSettingsAvailable: mocks.requireAvailable, + resolveCredentialGroupSettingsContext: mocks.resolveGroup, +})) + +vi.mock('@/lib/credential-groups/enrollments', () => ({ + CredentialGroupEnrollmentError: class CredentialGroupEnrollmentError extends Error { + constructor( + message: string, + readonly status: 404 | 409 | 502 + ) { + super(message) + } + }, + inviteCredentialGroupEnrollments: mocks.invite, + loadCredentialGroupInviterIdentity: mocks.loadInviter, + resendCredentialGroupEnrollment: vi.fn(), + revokeCredentialGroupEnrollment: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +import { inviteCredentialGroupEnrollmentsSettings } from '@/lib/credential-groups/application/manage-enrollments' + +const context = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + credentialGroupId: 'group-1', + name: 'Support', + status: 'active' as const, + options: [], +} +const principal: SessionPrincipal = { + kind: 'session', + userId: 'admin-1', + sessionId: 'session-1', +} +const input = { + assertedWorkspaceId: 'workspace-1', + credentialGroupId: 'group-1', + emails: [' Person@Example.com ', 'person@example.com'], +} + +describe('Credential Group enrollment Settings operations', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolveGroup.mockResolvedValue(context) + mocks.resolvePermission.mockResolvedValue('admin') + mocks.requireAvailable.mockResolvedValue(undefined) + mocks.loadInviter.mockResolvedValue({ name: 'Admin', email: 'admin@example.com' }) + mocks.invite.mockResolvedValue({ results: [], sentCount: 0, failedCount: 0 }) + }) + + it('requires current workspace-admin permission before delivery', async () => { + mocks.resolvePermission.mockResolvedValue('write') + + await expect( + inviteCredentialGroupEnrollmentsSettings.execute({ principal, input }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.invite).not.toHaveBeenCalled() + }) + + it('derives the inviter and normalizes recipients inside the application command', async () => { + await inviteCredentialGroupEnrollmentsSettings.execute({ principal, input }) + + expect(mocks.loadInviter).toHaveBeenCalledWith('admin-1') + expect(mocks.invite).toHaveBeenCalledWith('workspace-1', 'group-1', 'admin-1', 'Admin', { + emails: ['person@example.com'], + }) + }) + + it('rejects an unbounded batch even outside the HTTP adapter', async () => { + await expect( + inviteCredentialGroupEnrollmentsSettings.execute({ + principal, + input: { + ...input, + emails: Array.from({ length: 101 }, (_, index) => `person-${index}@example.com`), + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + expect(mocks.invite).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credential-groups/application/manage-enrollments.ts b/apps/sim/lib/credential-groups/application/manage-enrollments.ts new file mode 100644 index 00000000000..8896aa495c3 --- /dev/null +++ b/apps/sim/lib/credential-groups/application/manage-enrollments.ts @@ -0,0 +1,143 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + requireCredentialGroupSettingsAvailable, + resolveCredentialGroupSettingsContext, +} from '@/lib/credential-groups/application/context' +import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' +import { validateCredentialGroupInvitationEmails } from '@/lib/credential-groups/application/validation' +import { + CredentialGroupEnrollmentError, + inviteCredentialGroupEnrollments, + loadCredentialGroupInviterIdentity, + resendCredentialGroupEnrollment, + revokeCredentialGroupEnrollment, +} from '@/lib/credential-groups/enrollments' + +interface CredentialGroupEnrollmentSettingsInput { + assertedWorkspaceId: string + credentialGroupId: string +} + +function normalizeEnrollmentError(error: unknown): never { + if (error instanceof CredentialGroupEnrollmentError) { + if (error.status === 404) throw new OrchestrationError('not_found', error.message) + if (error.status === 409) throw new OrchestrationError('conflict', error.message) + } + throw error +} + +async function requireInviterIdentity(userId: string): Promise { + const inviter = await loadCredentialGroupInviterIdentity(userId) + const inviterName = inviter?.name?.trim() || inviter?.email + if (!inviterName) { + throw new OrchestrationError('conflict', 'Inviting user has no display identity') + } + return inviterName +} + +export interface InviteCredentialGroupEnrollmentsSettingsInput + extends CredentialGroupEnrollmentSettingsInput { + emails: string[] +} + +export const inviteCredentialGroupEnrollmentsSettings = defineAuthorizedWorkspaceUseCase({ + operation: credentialGroupOperations.inviteBatch, + resolveContext: ({ input }: { input: InviteCredentialGroupEnrollmentsSettingsInput }) => + resolveCredentialGroupSettingsContext(input.credentialGroupId, input.assertedWorkspaceId), + authorizationOptions: {}, + async execute({ principal, input, context }) { + await requireCredentialGroupSettingsAvailable(context.workspaceId) + const inviterName = await requireInviterIdentity(principal.userId) + const emails = validateCredentialGroupInvitationEmails(input.emails) + try { + return await inviteCredentialGroupEnrollments( + context.workspaceId, + context.credentialGroupId, + principal.userId, + inviterName, + { emails } + ) + } catch (error) { + normalizeEnrollmentError(error) + } + }, + projectAudit: ({ context, result }) => ({ + action: AuditAction.CREDENTIAL_GROUP_UPDATED, + resourceType: AuditResourceType.CREDENTIAL_GROUP, + resourceId: context.credentialGroupId, + resourceName: context.name, + description: `Sent ${result.sentCount} Credential Group invitation${result.sentCount === 1 ? '' : 's'}`, + metadata: { sentCount: result.sentCount, failedCount: result.failedCount }, + }), +}) + +export interface ResendCredentialGroupEnrollmentSettingsInput + extends CredentialGroupEnrollmentSettingsInput { + enrollmentId: string +} + +export const resendCredentialGroupEnrollmentSettings = defineAuthorizedWorkspaceUseCase({ + operation: credentialGroupOperations.resendEnrollment, + resolveContext: ({ input }: { input: ResendCredentialGroupEnrollmentSettingsInput }) => + resolveCredentialGroupSettingsContext(input.credentialGroupId, input.assertedWorkspaceId), + authorizationOptions: {}, + async execute({ principal, input, context }) { + await requireCredentialGroupSettingsAvailable(context.workspaceId) + const inviterName = await requireInviterIdentity(principal.userId) + try { + const credentialGroupEnrollment = await resendCredentialGroupEnrollment( + context.workspaceId, + context.credentialGroupId, + input.enrollmentId, + principal.userId, + inviterName + ) + return { credentialGroupEnrollment } + } catch (error) { + normalizeEnrollmentError(error) + } + }, + projectAudit: ({ context, result }) => ({ + action: AuditAction.CREDENTIAL_GROUP_UPDATED, + resourceType: AuditResourceType.CREDENTIAL_GROUP, + resourceId: context.credentialGroupId, + resourceName: context.name, + description: `Resent a Credential Group invitation to ${result.credentialGroupEnrollment.email}`, + metadata: { enrollmentId: result.credentialGroupEnrollment.id }, + }), +}) + +export interface RevokeCredentialGroupEnrollmentSettingsInput + extends CredentialGroupEnrollmentSettingsInput { + enrollmentId: string +} + +export const revokeCredentialGroupEnrollmentSettings = defineAuthorizedWorkspaceUseCase({ + operation: credentialGroupOperations.revokeEnrollment, + resolveContext: ({ input }: { input: RevokeCredentialGroupEnrollmentSettingsInput }) => + resolveCredentialGroupSettingsContext(input.credentialGroupId, input.assertedWorkspaceId), + authorizationOptions: {}, + async execute({ input, context }) { + await requireCredentialGroupSettingsAvailable(context.workspaceId) + try { + const credentialGroupEnrollment = await revokeCredentialGroupEnrollment( + context.workspaceId, + context.credentialGroupId, + input.enrollmentId + ) + return { credentialGroupEnrollment } + } catch (error) { + normalizeEnrollmentError(error) + } + }, + projectAudit: ({ context, result }) => ({ + action: AuditAction.CREDENTIAL_GROUP_UPDATED, + resourceType: AuditResourceType.CREDENTIAL_GROUP, + resourceId: context.credentialGroupId, + resourceName: context.name, + description: `Revoked Credential Group access for ${result.credentialGroupEnrollment.email}`, + metadata: { enrollmentId: result.credentialGroupEnrollment.id }, + }), +}) diff --git a/apps/sim/lib/credential-groups/application/manage-groups.test.ts b/apps/sim/lib/credential-groups/application/manage-groups.test.ts new file mode 100644 index 00000000000..3a889b91aa0 --- /dev/null +++ b/apps/sim/lib/credential-groups/application/manage-groups.test.ts @@ -0,0 +1,118 @@ +/** + * @vitest-environment node + */ +import type { CredentialGroupEnrollmentPrincipal, SessionPrincipal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + create: vi.fn(), + list: vi.fn(), + requireAvailable: vi.fn(), + resolveGroup: vi.fn(), + resolvePermission: vi.fn(), + resolveWorkspace: vi.fn(), +})) + +vi.mock('@/lib/credential-groups/application/context', () => ({ + requireCredentialGroupSettingsAvailable: mocks.requireAvailable, + resolveCredentialGroupSettingsContext: mocks.resolveGroup, + resolveCredentialGroupWorkspaceContext: mocks.resolveWorkspace, +})) + +vi.mock('@/lib/credential-groups/service', () => ({ + createCredentialGroup: mocks.create, + deleteCredentialGroup: vi.fn(), + getCredentialGroup: vi.fn(), + listCredentialGroups: mocks.list, + updateCredentialGroup: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +import { + createCredentialGroupSettings, + listCredentialGroupSettings, +} from '@/lib/credential-groups/application/manage-groups' + +const workspaceContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const sessionPrincipal: SessionPrincipal = { + kind: 'session', + userId: 'admin-1', + sessionId: 'session-1', +} +const enrollmentPrincipal: CredentialGroupEnrollmentPrincipal = { + kind: 'credential_group_enrollment', + workspaceId: 'workspace-1', + credentialGroupId: 'group-1', + enrollmentId: 'enrollment-1', + email: 'person@example.com', + invitationTokenHash: 'hash-1', +} + +describe('Credential Group Settings application operations', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolveWorkspace.mockResolvedValue(workspaceContext) + mocks.resolvePermission.mockResolvedValue('admin') + mocks.requireAvailable.mockResolvedValue(undefined) + mocks.list.mockResolvedValue([]) + mocks.create.mockResolvedValue({ id: 'group-1', name: 'Support' }) + }) + + it('rejects an enrollment bearer before loading workspace settings', async () => { + await expect( + listCredentialGroupSettings.execute({ + principal: enrollmentPrincipal, + input: { workspaceId: 'workspace-1' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.resolveWorkspace).not.toHaveBeenCalled() + }) + + it('requires current workspace-admin permission before listing', async () => { + mocks.resolvePermission.mockResolvedValue('read') + + await expect( + listCredentialGroupSettings.execute({ + principal: sessionPrincipal, + input: { workspaceId: 'workspace-1' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.list).not.toHaveBeenCalled() + }) + + it('lists settings only after authorization and entitlement checks', async () => { + const result = await listCredentialGroupSettings.execute({ + principal: sessionPrincipal, + input: { workspaceId: 'workspace-1' }, + }) + + expect(mocks.requireAvailable).toHaveBeenCalledWith('workspace-1') + expect(mocks.list).toHaveBeenCalledWith('workspace-1') + expect(result).toEqual({ credentialGroups: [] }) + }) + + it('derives created-by identity from the authenticated session principal', async () => { + await createCredentialGroupSettings.execute({ + principal: sessionPrincipal, + input: { + workspaceId: 'workspace-1', + credentialGroup: { name: 'Support', options: [] }, + }, + }) + + expect(mocks.create).toHaveBeenCalledWith('workspace-1', 'admin-1', { + name: 'Support', + options: [], + }) + }) +}) diff --git a/apps/sim/lib/credential-groups/application/manage-groups.ts b/apps/sim/lib/credential-groups/application/manage-groups.ts new file mode 100644 index 00000000000..f8e9f420bf2 --- /dev/null +++ b/apps/sim/lib/credential-groups/application/manage-groups.ts @@ -0,0 +1,175 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { getPostgresErrorCode } from '@sim/utils/errors' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + requireCredentialGroupSettingsAvailable, + resolveCredentialGroupSettingsContext, + resolveCredentialGroupWorkspaceContext, +} from '@/lib/credential-groups/application/context' +import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' +import { + validateCreateCredentialGroupInput, + validateCredentialGroupEnrollmentPage, + validateUpdateCredentialGroupInput, +} from '@/lib/credential-groups/application/validation' +import { + CredentialGroupEnrollmentError, + listCredentialGroupEnrollments, +} from '@/lib/credential-groups/enrollments' +import { + createCredentialGroup, + deleteCredentialGroup, + getCredentialGroup, + listCredentialGroups, + updateCredentialGroup, +} from '@/lib/credential-groups/service' +import type { + CreateCredentialGroupInput, + UpdateCredentialGroupInput, +} from '@/lib/credential-groups/types' + +function throwCredentialGroupConflict(error: unknown): never { + if (getPostgresErrorCode(error) === '23505') { + throw new OrchestrationError('conflict', 'A credential group with this name already exists') + } + throw error +} + +export interface ListCredentialGroupSettingsInput { + workspaceId: string +} + +export const listCredentialGroupSettings = defineAuthorizedWorkspaceUseCase({ + operation: credentialGroupOperations.listSettings, + resolveContext: ({ input }: { input: ListCredentialGroupSettingsInput }) => + resolveCredentialGroupWorkspaceContext(input.workspaceId), + authorizationOptions: {}, + async execute({ context }) { + await requireCredentialGroupSettingsAvailable(context.workspaceId) + return { credentialGroups: await listCredentialGroups(context.workspaceId) } + }, +}) + +export interface CreateCredentialGroupSettingsInput { + workspaceId: string + credentialGroup: CreateCredentialGroupInput +} + +export const createCredentialGroupSettings = defineAuthorizedWorkspaceUseCase({ + operation: credentialGroupOperations.create, + resolveContext: ({ input }: { input: CreateCredentialGroupSettingsInput }) => + resolveCredentialGroupWorkspaceContext(input.workspaceId), + authorizationOptions: {}, + async execute({ principal, input, context }) { + await requireCredentialGroupSettingsAvailable(context.workspaceId) + try { + const credentialGroup = await createCredentialGroup( + context.workspaceId, + principal.userId, + validateCreateCredentialGroupInput(input.credentialGroup) + ) + return { credentialGroup } + } catch (error) { + throwCredentialGroupConflict(error) + } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.CREDENTIAL_GROUP_UPDATED, + resourceType: AuditResourceType.CREDENTIAL_GROUP, + resourceId: result.credentialGroup.id, + resourceName: result.credentialGroup.name, + description: 'Created a Credential Group', + }), +}) + +interface CredentialGroupSettingsTargetInput { + assertedWorkspaceId: string + credentialGroupId: string +} + +export interface GetCredentialGroupSettingsInput extends CredentialGroupSettingsTargetInput { + limit: number + cursor?: string +} + +export const getCredentialGroupSettings = defineAuthorizedWorkspaceUseCase({ + operation: credentialGroupOperations.readSettings, + resolveContext: ({ input }: { input: GetCredentialGroupSettingsInput }) => + resolveCredentialGroupSettingsContext(input.credentialGroupId, input.assertedWorkspaceId), + authorizationOptions: {}, + async execute({ input, context }) { + await requireCredentialGroupSettingsAvailable(context.workspaceId) + validateCredentialGroupEnrollmentPage(input.limit) + const credentialGroup = await getCredentialGroup(context.workspaceId, context.credentialGroupId) + if (!credentialGroup) throw new OrchestrationError('not_found', 'Credential group not found') + try { + const enrollmentPage = await listCredentialGroupEnrollments( + context.workspaceId, + context.credentialGroupId, + input.limit, + input.cursor + ) + return { credentialGroup, ...enrollmentPage } + } catch (error) { + if (error instanceof CredentialGroupEnrollmentError && error.status === 404) { + throw new OrchestrationError('not_found', error.message) + } + throw error + } + }, +}) + +export interface UpdateCredentialGroupSettingsInput extends CredentialGroupSettingsTargetInput { + update: UpdateCredentialGroupInput +} + +export const updateCredentialGroupSettings = defineAuthorizedWorkspaceUseCase({ + operation: credentialGroupOperations.update, + resolveContext: ({ input }: { input: UpdateCredentialGroupSettingsInput }) => + resolveCredentialGroupSettingsContext(input.credentialGroupId, input.assertedWorkspaceId), + authorizationOptions: {}, + async execute({ input, context }) { + await requireCredentialGroupSettingsAvailable(context.workspaceId) + try { + const credentialGroup = await updateCredentialGroup( + context.workspaceId, + context.credentialGroupId, + validateUpdateCredentialGroupInput(input.update) + ) + if (!credentialGroup) { + throw new OrchestrationError('not_found', 'Credential group not found') + } + return { credentialGroup } + } catch (error) { + throwCredentialGroupConflict(error) + } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.CREDENTIAL_GROUP_UPDATED, + resourceType: AuditResourceType.CREDENTIAL_GROUP, + resourceId: result.credentialGroup.id, + resourceName: result.credentialGroup.name, + description: 'Updated a Credential Group', + }), +}) + +export const deleteCredentialGroupSettings = defineAuthorizedWorkspaceUseCase({ + operation: credentialGroupOperations.delete, + resolveContext: ({ input }: { input: CredentialGroupSettingsTargetInput }) => + resolveCredentialGroupSettingsContext(input.credentialGroupId, input.assertedWorkspaceId), + authorizationOptions: {}, + async execute({ context }) { + await requireCredentialGroupSettingsAvailable(context.workspaceId) + const deleted = await deleteCredentialGroup(context.workspaceId, context.credentialGroupId) + if (!deleted) throw new OrchestrationError('not_found', 'Credential group not found') + return { success: true as const } + }, + projectAudit: ({ context }) => ({ + action: AuditAction.CREDENTIAL_GROUP_UPDATED, + resourceType: AuditResourceType.CREDENTIAL_GROUP, + resourceId: context.credentialGroupId, + resourceName: context.name, + description: 'Deleted a Credential Group', + }), +}) diff --git a/apps/sim/lib/credential-groups/application/operations.ts b/apps/sim/lib/credential-groups/application/operations.ts new file mode 100644 index 00000000000..ecaa65a92fe --- /dev/null +++ b/apps/sim/lib/credential-groups/application/operations.ts @@ -0,0 +1,92 @@ +import { defineWorkspaceOperation } from '@/lib/core/application' + +export const credentialGroupOperations = { + listSettings: defineWorkspaceOperation({ + id: 'credential_groups.settings.list', + minimumRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + create: defineWorkspaceOperation({ + id: 'credential_groups.create', + minimumRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + readSettings: defineWorkspaceOperation({ + id: 'credential_groups.settings.read', + minimumRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + update: defineWorkspaceOperation({ + id: 'credential_groups.update', + minimumRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + delete: defineWorkspaceOperation({ + id: 'credential_groups.delete', + minimumRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + inviteBatch: defineWorkspaceOperation({ + id: 'credential_groups.invites.send_batch', + minimumRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + resendEnrollment: defineWorkspaceOperation({ + id: 'credential_groups.enrollments.resend', + minimumRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + revokeEnrollment: defineWorkspaceOperation({ + id: 'credential_groups.enrollments.revoke', + minimumRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + listCredentials: defineWorkspaceOperation({ + id: 'credential_groups.credentials.list', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['executor'], + }), + listGroups: defineWorkspaceOperation({ + id: 'credential_groups.list', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['executor'], + }), + listPeople: defineWorkspaceOperation({ + id: 'credential_groups.people.list', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['executor'], + }), + sendInvite: defineWorkspaceOperation({ + id: 'credential_groups.invites.send', + minimumRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['executor'], + }), + startSlackConfiguration: defineWorkspaceOperation({ + id: 'credential_groups.slack_configuration.start', + minimumRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + completeSlackConfiguration: defineWorkspaceOperation({ + id: 'credential_groups.slack_configuration.complete', + minimumRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), +} as const diff --git a/apps/sim/lib/credential-groups/application/public-enrollment.test.ts b/apps/sim/lib/credential-groups/application/public-enrollment.test.ts new file mode 100644 index 00000000000..9fae003f2de --- /dev/null +++ b/apps/sim/lib/credential-groups/application/public-enrollment.test.ts @@ -0,0 +1,107 @@ +/** + * @vitest-environment node + */ +import type { CredentialGroupEnrollmentPrincipal, SessionPrincipal } from '@sim/auth/principal' +import { sha256Hex } from '@sim/security/hash' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + completeEnrollment: vi.fn(), + getEnrollment: vi.fn(), + getOAuthContext: vi.fn(), + startOAuth: vi.fn(), +})) + +vi.mock('@/lib/credential-groups/enrollments', () => ({ + completeAuthorizedCredentialGroupEnrollment: mocks.completeEnrollment, + getAuthorizedCredentialGroupOAuthContext: mocks.getOAuthContext, + getAuthorizedPublicCredentialGroupEnrollment: mocks.getEnrollment, +})) + +vi.mock('@/lib/credential-groups/oauth', () => ({ + completeCredentialGroupOAuth: vi.fn(), + startCredentialGroupOAuth: mocks.startOAuth, +})) + +import { + readPublicCredentialGroupEnrollment, + startPublicCredentialGroupOAuth, +} from '@/lib/credential-groups/application/public-enrollment' + +const invitationToken = 'invitation-token' +const principal: CredentialGroupEnrollmentPrincipal = { + kind: 'credential_group_enrollment', + workspaceId: 'workspace-1', + credentialGroupId: 'group-1', + enrollmentId: 'enrollment-1', + email: 'person@example.com', + invitationTokenHash: sha256Hex(invitationToken), +} +const identity = { + workspaceId: principal.workspaceId, + credentialGroupId: principal.credentialGroupId, + enrollmentId: principal.enrollmentId, + email: principal.email, + invitationTokenHash: principal.invitationTokenHash, +} + +describe('public Credential Group enrollment application operations', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getEnrollment.mockResolvedValue({ status: 'invited', options: [] }) + mocks.getOAuthContext.mockResolvedValue({ + enrollmentId: 'enrollment-1', + credentialGroupId: 'group-1', + option: { id: 'option-1' }, + }) + mocks.startOAuth.mockResolvedValue('https://accounts.example/authorize') + }) + + it('rejects a workspace session before resolving invitation data', async () => { + const session: SessionPrincipal = { + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', + } + + await expect( + readPublicCredentialGroupEnrollment.execute({ principal: session, input: {} }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.getEnrollment).not.toHaveBeenCalled() + }) + + it('revalidates the invitation identity before returning enrollment metadata', async () => { + const result = await readPublicCredentialGroupEnrollment.execute({ principal, input: {} }) + + expect(mocks.getEnrollment).toHaveBeenCalledWith(identity) + expect(result).toEqual({ enrollment: { status: 'invited', options: [] } }) + }) + + it('fails closed when the current invitation no longer resolves', async () => { + mocks.getEnrollment.mockResolvedValue(null) + + await expect( + readPublicCredentialGroupEnrollment.execute({ principal, input: {} }) + ).rejects.toMatchObject({ code: 'not_found' }) + }) + + it('rejects a substituted bearer before creating provider state', async () => { + await expect( + startPublicCredentialGroupOAuth.execute({ + principal, + input: { invitationToken: 'different-token', optionId: 'option-1' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + expect(mocks.startOAuth).not.toHaveBeenCalled() + }) + + it('starts OAuth only for the option bound to the current enrollment principal', async () => { + const result = await startPublicCredentialGroupOAuth.execute({ + principal, + input: { invitationToken, optionId: 'option-1' }, + }) + + expect(mocks.getOAuthContext).toHaveBeenCalledWith(identity, 'option-1') + expect(result).toEqual({ authorizationUrl: 'https://accounts.example/authorize' }) + }) +}) diff --git a/apps/sim/lib/credential-groups/application/public-enrollment.ts b/apps/sim/lib/credential-groups/application/public-enrollment.ts new file mode 100644 index 00000000000..ccda73d5984 --- /dev/null +++ b/apps/sim/lib/credential-groups/application/public-enrollment.ts @@ -0,0 +1,188 @@ +import type { CredentialGroupEnrollmentPrincipal, Principal } from '@sim/auth/principal' +import { safeCompare } from '@sim/security/compare' +import { sha256Hex } from '@sim/security/hash' +import type { OperationUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { credentialGroupEnrollmentOperations } from '@/lib/credential-groups/application/enrollment-operations' +import { + completeAuthorizedCredentialGroupEnrollment, + getAuthorizedCredentialGroupOAuthContext, + getAuthorizedPublicCredentialGroupEnrollment, + type PublicCredentialGroupEnrollmentIdentity, +} from '@/lib/credential-groups/enrollments' +import { + completeCredentialGroupOAuth, + startCredentialGroupOAuth, +} from '@/lib/credential-groups/oauth' +import type { CredentialGroupOAuthAttempt } from '@/lib/credential-groups/oauth-state' + +interface AuthorizedCredentialGroupEnrollmentUseCaseDefinition { + operation: O + resolveContext(args: { principal: CredentialGroupEnrollmentPrincipal; input: I }): Promise + execute(args: { principal: CredentialGroupEnrollmentPrincipal; input: I; context: C }): Promise +} + +function requireCredentialGroupEnrollmentPrincipal( + principal: Principal +): asserts principal is CredentialGroupEnrollmentPrincipal { + if (principal.kind !== 'credential_group_enrollment') { + throw new OrchestrationError( + 'forbidden', + 'This operation requires a Credential Group invitation' + ) + } +} + +function requireMatchingContext( + principal: CredentialGroupEnrollmentPrincipal, + context: PublicCredentialGroupEnrollmentIdentity +): void { + if ( + context.workspaceId !== principal.workspaceId || + context.credentialGroupId !== principal.credentialGroupId || + context.enrollmentId !== principal.enrollmentId || + context.email !== principal.email || + !safeCompare(context.invitationTokenHash, principal.invitationTokenHash) + ) { + throw new OrchestrationError('not_found', 'Invitation is invalid or expired') + } +} + +function defineAuthorizedCredentialGroupEnrollmentUseCase< + const O extends + (typeof credentialGroupEnrollmentOperations)[keyof typeof credentialGroupEnrollmentOperations], + I, + C extends PublicCredentialGroupEnrollmentIdentity, + R, +>( + definition: AuthorizedCredentialGroupEnrollmentUseCaseDefinition +): OperationUseCase { + async function authorize(principal: Principal, input: I) { + requireCredentialGroupEnrollmentPrincipal(principal) + const context = await definition.resolveContext({ principal, input }) + requireMatchingContext(principal, context) + return { principal, input, context } + } + + return { + operation: definition.operation, + async authorize({ principal, input }) { + await authorize(principal, input) + }, + async execute({ principal, input }) { + const authorized = await authorize(principal, input) + return definition.execute(authorized) + }, + } +} + +function identityFromPrincipal( + principal: CredentialGroupEnrollmentPrincipal +): PublicCredentialGroupEnrollmentIdentity { + return { + workspaceId: principal.workspaceId, + credentialGroupId: principal.credentialGroupId, + enrollmentId: principal.enrollmentId, + email: principal.email, + invitationTokenHash: principal.invitationTokenHash, + } +} + +function requireInvitationToken( + principal: CredentialGroupEnrollmentPrincipal, + invitationToken: string +): void { + if (!safeCompare(sha256Hex(invitationToken), principal.invitationTokenHash)) { + throw new OrchestrationError('not_found', 'Invitation is invalid or expired') + } +} + +interface PublicEnrollmentContext extends PublicCredentialGroupEnrollmentIdentity { + enrollment: NonNullable>> +} + +async function resolvePublicEnrollmentContext( + principal: CredentialGroupEnrollmentPrincipal +): Promise { + const identity = identityFromPrincipal(principal) + const enrollment = await getAuthorizedPublicCredentialGroupEnrollment(identity) + if (!enrollment) throw new OrchestrationError('not_found', 'Invitation is invalid or expired') + return { ...identity, enrollment } +} + +export const readPublicCredentialGroupEnrollment = defineAuthorizedCredentialGroupEnrollmentUseCase( + { + operation: credentialGroupEnrollmentOperations.read, + resolveContext: ({ principal }) => resolvePublicEnrollmentContext(principal), + async execute({ context }) { + return { enrollment: context.enrollment } + }, + } +) + +export const completePublicCredentialGroupEnrollment = + defineAuthorizedCredentialGroupEnrollmentUseCase({ + operation: credentialGroupEnrollmentOperations.complete, + resolveContext: ({ principal }) => resolvePublicEnrollmentContext(principal), + async execute({ context }) { + const completed = await completeAuthorizedCredentialGroupEnrollment(context) + return { completed } + }, + }) + +interface PublicCredentialGroupOAuthInput { + invitationToken: string + optionId: string +} + +interface PublicCredentialGroupOAuthContext extends PublicCredentialGroupEnrollmentIdentity { + oauth: NonNullable>> +} + +async function resolvePublicOAuthContext( + principal: CredentialGroupEnrollmentPrincipal, + optionId: string +): Promise { + const identity = identityFromPrincipal(principal) + const oauth = await getAuthorizedCredentialGroupOAuthContext(identity, optionId) + if (!oauth) throw new OrchestrationError('not_found', 'Invitation is invalid or expired') + return { ...identity, oauth } +} + +export const startPublicCredentialGroupOAuth = defineAuthorizedCredentialGroupEnrollmentUseCase({ + operation: credentialGroupEnrollmentOperations.startOAuth, + resolveContext: ({ + principal, + input, + }: { + principal: CredentialGroupEnrollmentPrincipal + input: PublicCredentialGroupOAuthInput + }) => resolvePublicOAuthContext(principal, input.optionId), + async execute({ principal, input, context }) { + requireInvitationToken(principal, input.invitationToken) + return { + authorizationUrl: await startCredentialGroupOAuth(context.oauth, input.invitationToken), + } + }, +}) + +interface CompletePublicCredentialGroupOAuthInput { + attempt: CredentialGroupOAuthAttempt + code: string +} + +export const completePublicCredentialGroupOAuth = defineAuthorizedCredentialGroupEnrollmentUseCase({ + operation: credentialGroupEnrollmentOperations.completeOAuth, + resolveContext: ({ + principal, + input, + }: { + principal: CredentialGroupEnrollmentPrincipal + input: CompletePublicCredentialGroupOAuthInput + }) => resolvePublicOAuthContext(principal, input.attempt.optionId), + async execute({ principal, input, context }) { + requireInvitationToken(principal, input.attempt.invitationToken) + await completeCredentialGroupOAuth(context.oauth, input.attempt, input.code) + return { connectedOptionId: context.oauth.option.id } + }, +}) diff --git a/apps/sim/lib/credential-groups/application/send-invite.ts b/apps/sim/lib/credential-groups/application/send-invite.ts new file mode 100644 index 00000000000..6ae7d855219 --- /dev/null +++ b/apps/sim/lib/credential-groups/application/send-invite.ts @@ -0,0 +1,72 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { isValidEmailSyntax, normalizeEmail } from '@sim/utils/string' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { credentialGroupDelegationPolicy } from '@/lib/credential-groups/application/authorization' +import { + requireCredentialGroupsAvailable, + resolveCredentialGroupContext, +} from '@/lib/credential-groups/application/context' +import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' +import { + CredentialGroupEnrollmentError, + inviteCredentialGroupEnrollment, + loadCredentialGroupInviterIdentity, +} from '@/lib/credential-groups/enrollments' + +export interface SendCredentialGroupInviteInput { + credentialGroupId: string + email: string +} + +export const sendCredentialGroupInvite = defineAuthorizedWorkspaceUseCase({ + operation: credentialGroupOperations.sendInvite, + resolveContext: ({ input }: { input: SendCredentialGroupInviteInput }) => + resolveCredentialGroupContext(input.credentialGroupId), + authorizationOptions: { delegation: credentialGroupDelegationPolicy }, + execute: async ({ principal, input, context }) => { + if (context.status !== 'active') { + throw new OrchestrationError('conflict', 'Credential group is disabled') + } + const email = normalizeEmail(input.email) + if (!isValidEmailSyntax(email)) { + throw new OrchestrationError('validation', 'Email must be a valid address') + } + await requireCredentialGroupsAvailable(context.workspaceId) + + const userId = requirePrincipalSubjectUserId(principal) + const inviter = await loadCredentialGroupInviterIdentity(userId) + const inviterName = inviter?.name?.trim() || inviter?.email + if (!inviterName) { + throw new OrchestrationError('conflict', 'Inviting user has no display identity') + } + + try { + const enrollment = await inviteCredentialGroupEnrollment( + context.workspaceId, + context.credentialGroupId, + userId, + inviterName, + email + ) + return { enrollment } + } catch (error) { + if (error instanceof CredentialGroupEnrollmentError) { + throw new OrchestrationError( + error.status === 404 ? 'not_found' : error.status === 409 ? 'conflict' : 'internal', + error.message + ) + } + throw error + } + }, + projectAudit: ({ input, context, result }) => ({ + action: AuditAction.CREDENTIAL_GROUP_UPDATED, + resourceType: AuditResourceType.CREDENTIAL_GROUP, + resourceId: context.credentialGroupId, + resourceName: context.name, + description: `Invited ${result.enrollment.email} to connect accounts`, + metadata: { email: normalizeEmail(input.email), enrollmentId: result.enrollment.id }, + }), +}) diff --git a/apps/sim/lib/credential-groups/application/slack-managed-users.ts b/apps/sim/lib/credential-groups/application/slack-managed-users.ts new file mode 100644 index 00000000000..a448edbd284 --- /dev/null +++ b/apps/sim/lib/credential-groups/application/slack-managed-users.ts @@ -0,0 +1,138 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { db } from '@sim/db' +import { credentialGroup } from '@sim/db/schema' +import { eq } from 'drizzle-orm' +import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' +import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability' +import { + consumeSlackManagedUsersAttempt, + createSlackManagedUsersAttempt, + exchangeAndConfigureSlackManagedUsers, + loadSlackManagedUsersAttempt, + type SlackManagedUsersAttempt, +} from '@/lib/credential-groups/slack-managed-users' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +async function requireCredentialGroups(workspaceId: string): Promise { + const ownerBilling = await getWorkspaceOwnerSubscriptionAccess(workspaceId) + if (!(await isCredentialGroupsAvailable(ownerBilling))) { + throw new OrchestrationError('not_found', 'Credential Groups are not available') + } +} + +async function resolveWorkspace(workspaceId: string) { + const context = await loadActiveWorkspaceApplicationContext(workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context +} + +async function resolveCredentialGroup(groupId: string, assertedWorkspaceId: string) { + const [group] = await db + .select({ id: credentialGroup.id, workspaceId: credentialGroup.workspaceId }) + .from(credentialGroup) + .where(eq(credentialGroup.id, groupId)) + .limit(1) + if (!group || group.workspaceId !== assertedWorkspaceId) { + throw new OrchestrationError('not_found', 'Credential Group not found') + } + return { ...(await resolveWorkspace(group.workspaceId)), credentialGroupId: group.id } +} + +export interface StartSlackCredentialGroupConfigurationInput { + assertedWorkspaceId: string + credentialGroupId: string + slackBotCredentialId: string + clientId: string + clientSecret: string +} + +export const startSlackCredentialGroupConfiguration = defineAuthorizedWorkspaceUseCase({ + operation: credentialGroupOperations.startSlackConfiguration, + resolveContext: ({ input }: { input: StartSlackCredentialGroupConfigurationInput }) => + resolveCredentialGroup(input.credentialGroupId, input.assertedWorkspaceId), + authorizationOptions: {}, + async execute({ principal, input, context }) { + await requireCredentialGroups(context.workspaceId) + return createSlackManagedUsersAttempt({ + workspaceId: context.workspaceId, + userId: principal.userId, + credentialGroupId: context.credentialGroupId, + slackBotCredentialId: input.slackBotCredentialId, + clientId: input.clientId, + clientSecret: input.clientSecret, + }) + }, +}) + +interface SlackCredentialGroupConfigurationCallbackInput { + state: string + code?: string + providerError?: string +} + +type SlackCredentialGroupConfigurationCallbackContext = Awaited< + ReturnType +> & { + attempt: SlackManagedUsersAttempt +} + +export const completeSlackCredentialGroupConfiguration = defineAuthorizedWorkspaceUseCase({ + operation: credentialGroupOperations.completeSlackConfiguration, + resolveContext: async ({ + principal, + input, + }: { + principal: { kind: 'session'; userId: string; sessionId: string } + input: SlackCredentialGroupConfigurationCallbackInput + }): Promise => { + const attempt = await loadSlackManagedUsersAttempt(input.state) + if (!attempt) { + throw new OrchestrationError('validation', 'Authorization state is invalid or expired') + } + if (attempt.userId !== principal.userId) { + throw new OrchestrationError( + 'forbidden', + 'Authorization must be completed by the user who started it' + ) + } + return { ...(await resolveWorkspace(attempt.workspaceId)), attempt } + }, + authorizationOptions: {}, + async execute({ input, context }) { + await requireCredentialGroups(context.workspaceId) + const attempt = await consumeSlackManagedUsersAttempt(input.state) + if ( + !attempt || + attempt.workspaceId !== context.attempt.workspaceId || + attempt.userId !== context.attempt.userId || + attempt.credentialGroupId !== context.attempt.credentialGroupId || + attempt.slackBotCredentialId !== context.attempt.slackBotCredentialId || + attempt.clientId !== context.attempt.clientId || + attempt.createdAt !== context.attempt.createdAt + ) { + throw new OrchestrationError('validation', 'Authorization state is invalid or expired') + } + if (input.providerError) return { ok: false as const, reason: 'provider_error' as const } + if (!input.code) throw new OrchestrationError('validation', 'Authorization code is missing') + const result = await exchangeAndConfigureSlackManagedUsers({ attempt, code: input.code }) + return { ok: true as const, reason: 'authorized' as const, result } + }, + projectAudit: ({ result }) => + result.ok + ? { + action: AuditAction.CREDENTIAL_GROUP_UPDATED, + resourceType: AuditResourceType.CREDENTIAL_GROUP, + resourceId: result.result.credentialGroupId, + resourceName: result.result.credentialGroupName, + description: 'Configured Slack for a Credential Group', + metadata: { + slackBotCredentialId: result.result.slackBotCredentialId, + slackAppId: result.result.appId, + slackTeamId: result.result.teamId, + }, + } + : [], +}) diff --git a/apps/sim/lib/credential-groups/application/validation.ts b/apps/sim/lib/credential-groups/application/validation.ts new file mode 100644 index 00000000000..2b2c6eb24ab --- /dev/null +++ b/apps/sim/lib/credential-groups/application/validation.ts @@ -0,0 +1,130 @@ +import { isValidEmailSyntax, normalizeEmail } from '@sim/utils/string' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + CREDENTIAL_GROUP_PROVIDER_IDS, + isCredentialGroupProvider, +} from '@/lib/credential-groups/providers' +import type { + CreateCredentialGroupInput, + CredentialGroupOptionInput, + CredentialGroupOptionUpdateInput, + UpdateCredentialGroupInput, +} from '@/lib/credential-groups/types' + +function validateOption( + option: CredentialGroupOptionInput | CredentialGroupOptionUpdateInput, + index: number +): void { + if (!isCredentialGroupProvider(option.provider)) { + throw new OrchestrationError('validation', `Credential option ${index + 1} is unsupported`) + } + if (!option.label.trim() || option.label.trim().length > 100) { + throw new OrchestrationError( + 'validation', + `Credential option ${index + 1} requires a label of at most 100 characters` + ) + } + if (option.provider === 'slack' && !option.slackBotCredentialId.trim()) { + throw new OrchestrationError('validation', 'Select a custom Slack bot') + } +} + +function validateOptions( + options: Array +): void { + if (options.length > CREDENTIAL_GROUP_PROVIDER_IDS.length) { + throw new OrchestrationError('validation', 'Too many credential options') + } + const labels = new Set() + const providers = new Set() + const ids = new Set() + options.forEach((option, index) => { + validateOption(option, index) + const label = option.label.trim().toLocaleLowerCase() + if (labels.has(label)) { + throw new OrchestrationError('validation', 'Credential option labels must be unique') + } + if (providers.has(option.provider)) { + throw new OrchestrationError('validation', 'Each provider can only be added once') + } + if ('id' in option && option.id) { + if (ids.has(option.id)) { + throw new OrchestrationError('validation', 'Credential option IDs must be unique') + } + ids.add(option.id) + } + labels.add(label) + providers.add(option.provider) + }) +} + +function normalizeOption( + option: T +): T { + return { ...option, label: option.label.trim() } +} + +export function validateCreateCredentialGroupInput( + input: CreateCredentialGroupInput +): CreateCredentialGroupInput { + const name = input.name.trim() + if (!name || name.length > 100) { + throw new OrchestrationError('validation', 'Name must be between 1 and 100 characters') + } + const description = input.description?.trim() + if (description && description.length > 500) { + throw new OrchestrationError('validation', 'Description must be at most 500 characters') + } + validateOptions(input.options) + if (input.options.some((option) => option.provider === 'slack')) { + throw new OrchestrationError( + 'validation', + 'Create the Credential Group before configuring Slack' + ) + } + return { + name, + ...(description ? { description } : {}), + options: input.options.map(normalizeOption), + } +} + +export function validateUpdateCredentialGroupInput( + input: UpdateCredentialGroupInput +): UpdateCredentialGroupInput { + if (Object.keys(input).length === 0) { + throw new OrchestrationError('validation', 'At least one field must be updated') + } + const name = input.name?.trim() + if (input.name !== undefined && (!name || name.length > 100)) { + throw new OrchestrationError('validation', 'Name must be between 1 and 100 characters') + } + const description = input.description?.trim() + if (description && description.length > 500) { + throw new OrchestrationError('validation', 'Description must be at most 500 characters') + } + if (input.options) validateOptions(input.options) + return { + ...(name ? { name } : {}), + ...(input.description !== undefined ? { description: description || null } : {}), + ...(input.options ? { options: input.options.map(normalizeOption) } : {}), + ...(input.status ? { status: input.status } : {}), + } +} + +export function validateCredentialGroupEnrollmentPage(limit: number): void { + if (!Number.isInteger(limit) || limit < 1 || limit > 100) { + throw new OrchestrationError('validation', 'Limit must be an integer between 1 and 100') + } +} + +export function validateCredentialGroupInvitationEmails(emails: string[]): string[] { + if (emails.length < 1 || emails.length > 100) { + throw new OrchestrationError('validation', 'Invite between 1 and 100 people at once') + } + const normalized = [...new Set(emails.map(normalizeEmail))] + if (normalized.some((email) => !isValidEmailSyntax(email))) { + throw new OrchestrationError('validation', 'Every invitation email must be valid') + } + return normalized +} diff --git a/apps/sim/lib/credential-groups/availability.test.ts b/apps/sim/lib/credential-groups/availability.test.ts new file mode 100644 index 00000000000..51960c8184d --- /dev/null +++ b/apps/sim/lib/credential-groups/availability.test.ts @@ -0,0 +1,50 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockIsFeatureEnabled } = vi.hoisted(() => ({ + mockIsFeatureEnabled: vi.fn(), +})) + +vi.mock('@/lib/core/config/env-flags', () => ({ + isHosted: true, +})) + +vi.mock('@/lib/core/config/feature-flags', () => ({ + isFeatureEnabled: mockIsFeatureEnabled, +})) + +import { resolveCredentialGroupsAvailability } from '@/lib/credential-groups/availability' + +describe('resolveCredentialGroupsAvailability', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('attributes a disabled feature flag before considering the plan', async () => { + mockIsFeatureEnabled.mockResolvedValue(false) + + await expect(resolveCredentialGroupsAvailability({ isEnterprise: false })).resolves.toEqual({ + available: false, + reason: 'feature_disabled', + }) + }) + + it('requires Enterprise when the hosted feature is enabled', async () => { + mockIsFeatureEnabled.mockResolvedValue(true) + + await expect(resolveCredentialGroupsAvailability({ isEnterprise: false })).resolves.toEqual({ + available: false, + reason: 'enterprise_plan_required', + }) + }) + + it('allows Enterprise workspaces when the hosted feature is enabled', async () => { + mockIsFeatureEnabled.mockResolvedValue(true) + + await expect(resolveCredentialGroupsAvailability({ isEnterprise: true })).resolves.toEqual({ + available: true, + }) + }) +}) diff --git a/apps/sim/lib/credential-groups/availability.ts b/apps/sim/lib/credential-groups/availability.ts new file mode 100644 index 00000000000..cc56832f87c --- /dev/null +++ b/apps/sim/lib/credential-groups/availability.ts @@ -0,0 +1,25 @@ +import { isHosted } from '@/lib/core/config/env-flags' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' + +export type CredentialGroupsAvailability = + | { available: true } + | { available: false; reason: 'feature_disabled' | 'enterprise_plan_required' } + +export async function resolveCredentialGroupsAvailability(ownerBilling: { + isEnterprise: boolean +}): Promise { + if (!(await isFeatureEnabled('credential-groups'))) { + return { available: false, reason: 'feature_disabled' } + } + if (isHosted && !ownerBilling.isEnterprise) { + return { available: false, reason: 'enterprise_plan_required' } + } + return { available: true } +} + +/** Credential Groups are globally gated and restricted to Enterprise workspaces on Sim Cloud. */ +export async function isCredentialGroupsAvailable(ownerBilling: { + isEnterprise: boolean +}): Promise { + return (await resolveCredentialGroupsAvailability(ownerBilling)).available +} diff --git a/apps/sim/lib/credential-groups/credentials.test.ts b/apps/sim/lib/credential-groups/credentials.test.ts new file mode 100644 index 00000000000..200a2671d76 --- /dev/null +++ b/apps/sim/lib/credential-groups/credentials.test.ts @@ -0,0 +1,48 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { listCredentialGroupCredentialReferences } from '@/lib/credential-groups/credentials' + +describe('listCredentialGroupCredentialReferences', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('returns the invited email associated with each managed credential', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'credential-1', + email: 'person@example.com', + displayName: 'Personal Gmail', + providerId: 'google-email', + providerSubjectId: 'google-subject-1', + providerTenantId: null, + createdAt: new Date('2026-08-12T12:00:00.000Z'), + }, + ]) + + const result = await listCredentialGroupCredentialReferences({ + workspaceId: 'workspace-1', + credentialGroupId: 'group-1', + credentialGroupOptionIds: ['option-1'], + limit: 50, + }) + + expect(result).toEqual({ + credentials: [ + { + credentialId: 'credential-1', + email: 'person@example.com', + displayName: 'Personal Gmail', + providerId: 'google-email', + providerSubjectId: 'google-subject-1', + providerTenantId: null, + }, + ], + nextCursor: null, + }) + }) +}) diff --git a/apps/sim/lib/credential-groups/credentials.ts b/apps/sim/lib/credential-groups/credentials.ts new file mode 100644 index 00000000000..4450ef0ff85 --- /dev/null +++ b/apps/sim/lib/credential-groups/credentials.ts @@ -0,0 +1,173 @@ +import { db } from '@sim/db' +import { + type CredentialGroupOptionConfig, + credential, + credentialGroup, + credentialGroupEnrollment, +} from '@sim/db/schema' +import { and, asc, eq, gt, inArray, or } from 'drizzle-orm' + +export const MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE = 100 + +export interface CredentialGroupCredentialListContext { + credentialGroupId: string + workspaceId: string + name: string + status: 'active' | 'disabled' + options: CredentialGroupOptionConfig[] +} + +export interface CredentialGroupCredentialReference { + credentialId: string + email: string + displayName: string + providerId: string + providerSubjectId: string + providerTenantId: string | null +} + +export class CredentialGroupCredentialCursorNotFoundError extends Error { + constructor() { + super('Credential group credential cursor not found') + this.name = 'CredentialGroupCredentialCursorNotFoundError' + } +} + +interface ListCredentialGroupCredentialReferencesInput { + workspaceId: string + credentialGroupId: string + limit: number + cursor?: string + email?: string + credentialProviderIds?: string[] + credentialGroupOptionIds: string[] +} + +/** Loads the canonical group ownership needed by the application authorization boundary. */ +export async function loadCredentialGroupCredentialListContext( + credentialGroupId: string +): Promise { + const [row] = await db + .select({ + credentialGroupId: credentialGroup.id, + workspaceId: credentialGroup.workspaceId, + name: credentialGroup.name, + status: credentialGroup.status, + options: credentialGroup.options, + }) + .from(credentialGroup) + .where(eq(credentialGroup.id, credentialGroupId)) + .limit(1) + return row ?? null +} + +/** Lists one bounded page of active managed credentials without selecting token material. */ +export async function listCredentialGroupCredentialReferences({ + workspaceId, + credentialGroupId, + limit, + cursor, + email, + credentialProviderIds, + credentialGroupOptionIds, +}: ListCredentialGroupCredentialReferencesInput): Promise<{ + credentials: CredentialGroupCredentialReference[] + nextCursor: string | null +}> { + if (credentialGroupOptionIds.length === 0) { + if (cursor) throw new CredentialGroupCredentialCursorNotFoundError() + return { credentials: [], nextCursor: null } + } + + let cursorPosition: { id: string; createdAt: Date } | undefined + if (cursor) { + const [cursorRow] = await db + .select({ id: credential.id, createdAt: credential.createdAt }) + .from(credential) + .innerJoin( + credentialGroupEnrollment, + eq(credentialGroupEnrollment.id, credential.credentialGroupEnrollmentId) + ) + .where( + and( + eq(credential.id, cursor), + eq(credential.workspaceId, workspaceId), + eq(credential.type, 'managed_oauth'), + eq(credential.managedOauthStatus, 'active'), + eq(credentialGroupEnrollment.credentialGroupId, credentialGroupId), + inArray(credential.credentialGroupOptionId, credentialGroupOptionIds), + email ? eq(credentialGroupEnrollment.email, email) : undefined, + credentialProviderIds?.length + ? inArray(credential.providerId, credentialProviderIds) + : undefined, + inArray(credentialGroupEnrollment.status, ['in_progress', 'completed']) + ) + ) + .limit(1) + if (!cursorRow) throw new CredentialGroupCredentialCursorNotFoundError() + cursorPosition = cursorRow + } + + const rows = await db + .select({ + id: credential.id, + email: credentialGroupEnrollment.email, + displayName: credential.displayName, + providerId: credential.providerId, + providerSubjectId: credential.providerSubjectId, + providerTenantId: credential.providerTenantId, + createdAt: credential.createdAt, + }) + .from(credential) + .innerJoin( + credentialGroupEnrollment, + eq(credentialGroupEnrollment.id, credential.credentialGroupEnrollmentId) + ) + .where( + and( + eq(credential.workspaceId, workspaceId), + eq(credential.type, 'managed_oauth'), + eq(credential.managedOauthStatus, 'active'), + eq(credentialGroupEnrollment.credentialGroupId, credentialGroupId), + inArray(credential.credentialGroupOptionId, credentialGroupOptionIds), + email ? eq(credentialGroupEnrollment.email, email) : undefined, + credentialProviderIds?.length + ? inArray(credential.providerId, credentialProviderIds) + : undefined, + inArray(credentialGroupEnrollment.status, ['in_progress', 'completed']), + cursorPosition + ? or( + gt(credential.createdAt, cursorPosition.createdAt), + and( + eq(credential.createdAt, cursorPosition.createdAt), + gt(credential.id, cursorPosition.id) + ) + ) + : undefined + ) + ) + .orderBy(asc(credential.createdAt), asc(credential.id)) + .limit(limit + 1) + + const hasMore = rows.length > limit + const pageRows = hasMore ? rows.slice(0, limit) : rows + const nextCursor = hasMore ? pageRows.at(-1)?.id : null + if (hasMore && !nextCursor) throw new Error('Credential page cursor could not be derived') + return { + credentials: pageRows.map((row) => { + if (!row.providerId) throw new Error(`Managed credential ${row.id} has no provider ID`) + if (!row.providerSubjectId) { + throw new Error(`Managed credential ${row.id} has no provider subject ID`) + } + return { + credentialId: row.id, + email: row.email, + displayName: row.displayName, + providerId: row.providerId, + providerSubjectId: row.providerSubjectId, + providerTenantId: row.providerTenantId, + } + }), + nextCursor: nextCursor ?? null, + } +} diff --git a/apps/sim/lib/credential-groups/enrollments.test.ts b/apps/sim/lib/credential-groups/enrollments.test.ts new file mode 100644 index 00000000000..4a826736f2e --- /dev/null +++ b/apps/sim/lib/credential-groups/enrollments.test.ts @@ -0,0 +1,326 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { adapter } = vi.hoisted(() => ({ + adapter: { + getPolicy: vi.fn(), + hasRequiredScopes: vi.fn(), + }, +})) + +vi.mock('@/components/emails/render', () => ({ + renderCredentialGroupInvitationEmail: vi.fn(), +})) + +vi.mock('@/lib/messaging/email/mailer', () => ({ sendEmail: vi.fn() })) + +vi.mock('@/lib/billing/core/workspace-access', () => ({ + getWorkspaceOwnerSubscriptionAccess: vi.fn().mockResolvedValue({}), +})) + +vi.mock('@/lib/credential-groups/availability', () => ({ + isCredentialGroupsAvailable: vi.fn().mockResolvedValue(true), +})) + +vi.mock('@/lib/credential-groups/provider-registry', () => ({ + getCredentialGroupProviderAdapter: () => adapter, +})) + +import { + completeCredentialGroupEnrollment, + listCredentialGroupEnrollments, + resendCredentialGroupEnrollment, +} from '@/lib/credential-groups/enrollments' +import { CREDENTIAL_GROUP_PROVIDER_IDS } from '@/lib/credential-groups/providers' +import { sendEmail } from '@/lib/messaging/email/mailer' + +const MAX_CONNECTION_SUMMARIES = CREDENTIAL_GROUP_PROVIDER_IDS.length * 3 + +const ENROLLMENT = { + id: 'enrollment-1', + credentialGroupId: 'group-1', + email: 'alex@example.com', + status: 'completed' as const, + invitationTokenHash: 'a'.repeat(64), + invitationExpiresAt: new Date('2026-08-18T12:00:00.000Z'), + invitedAt: new Date('2026-08-11T12:00:00.000Z'), + sentAt: new Date('2026-08-11T12:00:01.000Z'), + completedAt: new Date('2026-08-11T12:05:00.000Z'), + revokedAt: null, + lastDeliveryError: null, + createdBy: 'user-1', + createdAt: new Date('2026-08-11T12:00:00.000Z'), + updatedAt: new Date('2026-08-11T12:05:00.000Z'), +} + +describe('listCredentialGroupEnrollments', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('returns bounded provider summaries instead of materializing every credential', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([{ options: [{ id: 'option-1', status: 'active' }] }]) + .mockResolvedValueOnce([{ enrollment: ENROLLMENT }]) + .mockResolvedValueOnce([ + { + enrollmentId: ENROLLMENT.id, + providerId: 'google-email', + status: 'active', + count: 2, + }, + { + enrollmentId: ENROLLMENT.id, + providerId: 'google-email', + status: 'needs_reauth', + count: 1, + }, + ]) + + const result = await listCredentialGroupEnrollments('workspace-1', 'group-1', 50) + + expect(result.enrollments[0]?.connections).toEqual([ + { provider: 'gmail', status: 'active', count: 2 }, + { provider: 'gmail', status: 'needs_reauth', count: 1 }, + ]) + expect(dbChainMockFns.limit).toHaveBeenNthCalledWith(3, MAX_CONNECTION_SUMMARIES + 1) + }) + + it('fails fast when a managed credential uses an unsupported provider', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([{ options: [{ id: 'option-1', status: 'active' }] }]) + .mockResolvedValueOnce([{ enrollment: ENROLLMENT }]) + .mockResolvedValueOnce([ + { + enrollmentId: ENROLLMENT.id, + providerId: 'unexpected-provider', + status: 'active', + count: 1, + }, + ]) + + await expect(listCredentialGroupEnrollments('workspace-1', 'group-1', 50)).rejects.toThrow( + 'Unsupported managed credential provider: unexpected-provider' + ) + }) + + it('rejects connection summaries beyond the bounded provider-state cardinality', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([{ options: [{ id: 'option-1', status: 'active' }] }]) + .mockResolvedValueOnce([{ enrollment: ENROLLMENT }]) + .mockResolvedValueOnce( + Array.from({ length: MAX_CONNECTION_SUMMARIES + 1 }, (_, index) => ({ + enrollmentId: ENROLLMENT.id, + providerId: 'google-email', + status: 'active', + count: index + 1, + })) + ) + + await expect(listCredentialGroupEnrollments('workspace-1', 'group-1', 50)).rejects.toThrow( + 'Managed credential connection summaries exceed the supported provider states' + ) + }) + + it('rejects an unbounded enrollment page request', async () => { + await expect(listCredentialGroupEnrollments('workspace-1', 'group-1', 101)).rejects.toThrow( + 'Credential group enrollment limit must be between 1 and 100' + ) + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) +}) + +describe('resendCredentialGroupEnrollment', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('does not reactivate an enrollment revoked while resend waits for its lifecycle lock', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([ + { + workspaceId: 'workspace-1', + workspaceName: 'Workspace', + groupId: 'group-1', + groupName: 'Group', + groupStatus: 'active', + options: [{ id: 'option-1', status: 'active' }], + }, + ]) + .mockResolvedValueOnce([{ enrollment: { ...ENROLLMENT, status: 'invited' } }]) + .mockResolvedValueOnce([{ ...ENROLLMENT, status: 'invited' }]) + .mockResolvedValueOnce([{ ...ENROLLMENT, status: 'revoked' }]) + + await expect( + resendCredentialGroupEnrollment('workspace-1', 'group-1', ENROLLMENT.id, 'user-1', 'Inviter') + ).rejects.toThrow('Revoked enrollment cannot be resent') + + expect(dbChainMockFns.execute).toHaveBeenCalledTimes(2) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + expect(sendEmail).not.toHaveBeenCalled() + }) + + it('rotates the invitation without hiding credentials from a completed enrollment', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([ + { + workspaceId: 'workspace-1', + workspaceName: 'Workspace', + groupId: 'group-1', + groupName: 'Group', + groupStatus: 'active', + options: [{ id: 'option-1', status: 'active' }], + }, + ]) + .mockResolvedValueOnce([{ enrollment: ENROLLMENT }]) + .mockResolvedValueOnce([ENROLLMENT]) + .mockResolvedValueOnce([ENROLLMENT]) + dbChainMockFns.returning + .mockResolvedValueOnce([ENROLLMENT]) + .mockResolvedValueOnce([{ ...ENROLLMENT, sentAt: new Date() }]) + vi.mocked(sendEmail).mockResolvedValueOnce({ success: true, message: 'sent' }) + + const result = await resendCredentialGroupEnrollment( + 'workspace-1', + 'group-1', + ENROLLMENT.id, + 'user-1', + 'Inviter' + ) + + expect(result.status).toBe('completed') + expect(dbChainMockFns.set).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ status: 'completed', completedAt: ENROLLMENT.completedAt }) + ) + }) +}) + +describe('completeCredentialGroupEnrollment', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + adapter.getPolicy.mockResolvedValue({ + provider: 'gmail', + providerId: 'google-email', + authorizationAppId: 'google:client', + requiredScopes: ['scope'], + scopeVersion: 1, + }) + adapter.hasRequiredScopes.mockReturnValue(true) + }) + + it('returns unavailable when revocation wins before completion acquires the lifecycle lock', async () => { + queueTableRows(schemaMock.credentialGroupEnrollment, [ + { + enrollment: { ...ENROLLMENT, status: 'in_progress' }, + groupId: 'group-1', + groupName: 'Group', + groupStatus: 'active', + options: [ + { + id: 'option-1', + provider: 'gmail', + label: 'Gmail', + required: true, + status: 'active', + }, + ], + workspaceId: 'workspace-1', + workspaceName: 'Workspace', + workspaceOwnerId: 'owner-1', + inviterName: 'Inviter', + }, + ]) + queueTableRows(schemaMock.credential, [ + { + optionId: 'option-1', + status: 'active', + scopeVersion: 1, + authorizationAppId: 'google:client', + grantedScopes: ['scope'], + displayName: 'alex@example.com', + metadata: { email: 'alex@example.com' }, + grantedAt: new Date('2026-08-11T12:05:00.000Z'), + }, + ]) + queueTableRows(schemaMock.credentialGroupEnrollment, [ + { + status: 'revoked', + invitationTokenHash: ENROLLMENT.invitationTokenHash, + invitationExpiresAt: ENROLLMENT.invitationExpiresAt, + }, + ]) + + await expect(completeCredentialGroupEnrollment('invitation-token')).resolves.toBeNull() + + expect(dbChainMockFns.execute).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('refuses completion when a connection needs reauthorization under the row locks', async () => { + queueTableRows(schemaMock.credentialGroupEnrollment, [ + { + enrollment: { ...ENROLLMENT, status: 'in_progress' }, + groupId: 'group-1', + groupName: 'Group', + groupStatus: 'active', + options: [ + { + id: 'option-1', + provider: 'gmail', + label: 'Gmail', + required: true, + status: 'active', + }, + ], + workspaceId: 'workspace-1', + workspaceName: 'Workspace', + workspaceOwnerId: 'owner-1', + inviterName: 'Inviter', + }, + ]) + queueTableRows(schemaMock.credentialGroupEnrollment, [ + { + status: 'in_progress', + invitationTokenHash: ENROLLMENT.invitationTokenHash, + invitationExpiresAt: ENROLLMENT.invitationExpiresAt, + }, + ]) + queueTableRows(schemaMock.credentialGroup, [ + { + status: 'active', + options: [ + { + id: 'option-1', + provider: 'gmail', + label: 'Gmail', + required: true, + status: 'active', + }, + ], + }, + ]) + queueTableRows(schemaMock.credential, [ + { + optionId: 'option-1', + status: 'needs_reauth', + scopeVersion: 1, + authorizationAppId: 'google:client', + grantedScopes: ['scope'], + grantedAt: new Date('2026-08-11T12:05:00.000Z'), + }, + ]) + + await expect(completeCredentialGroupEnrollment('invitation-token')).resolves.toBe(false) + + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(adapter.getPolicy).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credential-groups/enrollments.ts b/apps/sim/lib/credential-groups/enrollments.ts new file mode 100644 index 00000000000..aa3a8b24d9f --- /dev/null +++ b/apps/sim/lib/credential-groups/enrollments.ts @@ -0,0 +1,943 @@ +import { db } from '@sim/db' +import { + type CredentialGroupOptionConfig, + credential, + credentialGroup, + credentialGroupEnrollment, + user, + workspace, +} from '@sim/db/schema' +import { sha256Hex } from '@sim/security/hash' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { normalizeEmail, truncate } from '@sim/utils/string' +import { and, count, desc, eq, inArray, lt, or, sql } from 'drizzle-orm' +import { renderCredentialGroupInvitationEmail } from '@/components/emails/credential-groups/render' +import { getCredentialGroupInvitationSubject } from '@/components/emails/subjects' +import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability' +import { getCredentialGroupProviderAdapter } from '@/lib/credential-groups/provider-registry' +import type { CredentialGroupProvider } from '@/lib/credential-groups/providers' +import { + CREDENTIAL_GROUP_PROVIDER_IDS, + getCredentialGroupProviderFromProviderId, + isCredentialGroupProvider, +} from '@/lib/credential-groups/providers' +import type { + CredentialGroupEnrollmentConnection, + CredentialGroupEnrollmentDetail, + CredentialGroupEnrollmentRecord, + InviteCredentialGroupEnrollmentsInput, +} from '@/lib/credential-groups/types' +import type { DbOrTx } from '@/lib/db/types' +import { sendEmail } from '@/lib/messaging/email/mailer' +import { getFromEmailAddress } from '@/lib/messaging/email/utils' + +const INVITATION_TTL_MS = 7 * 24 * 60 * 60 * 1000 +const DELIVERY_CONCURRENCY = 5 +const MAX_ENROLLMENT_PAGE_SIZE = 100 +const CONNECTION_SUMMARIES_PER_ENROLLMENT = CREDENTIAL_GROUP_PROVIDER_IDS.length * 3 + +type EnrollmentRow = typeof credentialGroupEnrollment.$inferSelect + +export type CredentialGroupEnrollmentStatus = EnrollmentRow['status'] + +export interface ListCredentialGroupEnrollmentFilters { + email?: string + statuses?: CredentialGroupEnrollmentStatus[] +} + +interface InvitationContext { + workspaceId: string + workspaceName: string + groupId: string + groupName: string +} + +interface SendInvitationOptions { + expectedEnrollmentId?: string + revokedEnrollment: 'reactivate' | 'reject' +} + +export interface PublicCredentialGroupEnrollment { + inviterName: string + workspaceName: string + credentialGroupName: string + options: Array< + Pick & { + provider: CredentialGroupProvider + connections: Array<{ + email: string + displayName: string | null + avatarUrl: string | null + status: 'connected' | 'needs_reauth' | 'revoked' + grantedAt: string + }> + } + > + status: CredentialGroupEnrollmentRecord['status'] +} + +export interface CredentialGroupOAuthContext { + enrollmentId: string + credentialGroupId: string + workspaceId: string + workspaceName: string + workspaceOwnerId: string + email: string + enrollmentStatus: EnrollmentRow['status'] + option: CredentialGroupOptionConfig + options: CredentialGroupOptionConfig[] +} + +export interface PublicCredentialGroupEnrollmentIdentity { + enrollmentId: string + credentialGroupId: string + workspaceId: string + email: string + invitationTokenHash: string +} + +/** Serializes OAuth grant persistence and administrative revocation for one enrollment. */ +export async function lockCredentialGroupEnrollmentLifecycle( + executor: DbOrTx, + enrollmentId: string +): Promise { + if (!enrollmentId.trim()) throw new Error('Credential group enrollment ID is required') + await executor.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${`credential-group-enrollment:${enrollmentId}`}, 0))` + ) +} + +/** Serializes invitation issuance before an enrollment row is known or locked. */ +async function lockCredentialGroupInvitationTarget( + executor: DbOrTx, + groupId: string, + email: string +): Promise { + if (!groupId.trim()) throw new Error('Credential group ID is required') + if (!email.trim()) throw new Error('Credential group enrollment email is required') + await executor.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${`credential-group-invitation:${groupId}:${email}`}, 0))` + ) +} + +export class CredentialGroupEnrollmentError extends Error { + constructor( + message: string, + readonly status: 404 | 409 | 502 + ) { + super(message) + this.name = 'CredentialGroupEnrollmentError' + } +} + +function hashInvitationToken(token: string): string { + return sha256Hex(token) +} + +function metadataString(metadata: object | null, key: string): string | null { + const value = metadata ? (metadata as Record)[key] : undefined + return typeof value === 'string' && value.length > 0 ? value : null +} + +async function resolvePublicEnrollmentRowByIdentity( + identity: Pick & { + enrollmentId?: string + } +) { + const [row] = await db + .select({ + enrollment: credentialGroupEnrollment, + groupId: credentialGroup.id, + groupName: credentialGroup.name, + groupStatus: credentialGroup.status, + options: credentialGroup.options, + workspaceId: workspace.id, + workspaceName: workspace.name, + workspaceOwnerId: workspace.ownerId, + inviterName: user.name, + }) + .from(credentialGroupEnrollment) + .innerJoin(credentialGroup, eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId)) + .innerJoin(workspace, eq(workspace.id, credentialGroup.workspaceId)) + .leftJoin(user, eq(user.id, credentialGroupEnrollment.createdBy)) + .where( + and( + eq(credentialGroupEnrollment.invitationTokenHash, identity.invitationTokenHash), + identity.enrollmentId ? eq(credentialGroupEnrollment.id, identity.enrollmentId) : undefined + ) + ) + .limit(1) + + if (!row || row.groupStatus !== 'active') return null + if (row.enrollment.status === 'revoked' || row.enrollment.status === 'delivery_failed') + return null + if (row.enrollment.invitationExpiresAt.getTime() <= Date.now()) return null + + const ownerBilling = await getWorkspaceOwnerSubscriptionAccess(row.workspaceId) + if (!(await isCredentialGroupsAvailable(ownerBilling))) return null + return row +} + +function identityForPublicEnrollmentRow( + row: NonNullable>> +): PublicCredentialGroupEnrollmentIdentity { + return { + enrollmentId: row.enrollment.id, + credentialGroupId: row.groupId, + workspaceId: row.workspaceId, + email: row.enrollment.email, + invitationTokenHash: row.enrollment.invitationTokenHash, + } +} + +/** Authenticates a public invitation token without exposing the bearer value downstream. */ +export async function authenticatePublicCredentialGroupEnrollment( + token: string +): Promise { + const row = await resolvePublicEnrollmentRowByIdentity({ + invitationTokenHash: hashInvitationToken(token), + }) + return row ? identityForPublicEnrollmentRow(row) : null +} + +async function resolveAuthorizedPublicEnrollmentRow( + identity: PublicCredentialGroupEnrollmentIdentity +) { + const row = await resolvePublicEnrollmentRowByIdentity(identity) + if ( + !row || + row.groupId !== identity.credentialGroupId || + row.workspaceId !== identity.workspaceId || + row.enrollment.email !== identity.email + ) { + return null + } + return row +} + +function toCredentialGroupEnrollment(row: EnrollmentRow): CredentialGroupEnrollmentRecord { + return { + id: row.id, + credentialGroupId: row.credentialGroupId, + email: row.email, + status: row.status, + expiresAt: row.invitationExpiresAt.toISOString(), + invitedAt: row.invitedAt.toISOString(), + sentAt: row.sentAt?.toISOString() ?? null, + completedAt: row.completedAt?.toISOString() ?? null, + revokedAt: row.revokedAt?.toISOString() ?? null, + expired: row.invitationExpiresAt.getTime() <= Date.now(), + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + } +} + +function toCredentialGroupConnectionProvider( + providerId: string | null +): CredentialGroupEnrollmentConnection['provider'] { + if (!providerId) throw new Error('Managed credential provider is missing') + return getCredentialGroupProviderFromProviderId(providerId) +} + +function toCredentialGroupConnectionStatus( + status: (typeof credential.$inferSelect)['managedOauthStatus'] +): CredentialGroupEnrollmentConnection['status'] { + if (status === 'active' || status === 'needs_reauth' || status === 'revoked') return status + throw new Error('Managed credential status is missing') +} + +async function getInvitationContext( + workspaceId: string, + groupId: string +): Promise { + const [row] = await db + .select({ + workspaceId: credentialGroup.workspaceId, + workspaceName: workspace.name, + groupId: credentialGroup.id, + groupName: credentialGroup.name, + groupStatus: credentialGroup.status, + options: credentialGroup.options, + }) + .from(credentialGroup) + .innerJoin(workspace, eq(workspace.id, credentialGroup.workspaceId)) + .where(and(eq(credentialGroup.id, groupId), eq(credentialGroup.workspaceId, workspaceId))) + .limit(1) + + if (!row) throw new CredentialGroupEnrollmentError('Credential group not found', 404) + if (row.groupStatus !== 'active') { + throw new CredentialGroupEnrollmentError('Credential group is disabled', 409) + } + if (!row.options.some((option) => option.status === 'active')) { + throw new CredentialGroupEnrollmentError('Add an account type before inviting people', 409) + } + return row +} + +async function sendInvitation( + context: InvitationContext, + userId: string, + inviterName: string, + email: string, + options: SendInvitationOptions +): Promise { + const now = new Date() + const token = generateId() + const tokenHash = hashInvitationToken(token) + const expiresAt = new Date(now.getTime() + INVITATION_TTL_MS) + + const issued = await db.transaction(async (tx) => { + await lockCredentialGroupInvitationTarget(tx, context.groupId, email) + const [existing] = await tx + .select() + .from(credentialGroupEnrollment) + .where( + and( + eq(credentialGroupEnrollment.credentialGroupId, context.groupId), + eq(credentialGroupEnrollment.email, email) + ) + ) + .limit(1) + + let current = existing + if (existing) { + await lockCredentialGroupEnrollmentLifecycle(tx, existing.id) + const [locked] = await tx + .select() + .from(credentialGroupEnrollment) + .where( + and( + eq(credentialGroupEnrollment.id, existing.id), + eq(credentialGroupEnrollment.credentialGroupId, context.groupId), + eq(credentialGroupEnrollment.email, email) + ) + ) + .limit(1) + current = locked + } + + if (options.expectedEnrollmentId && current?.id !== options.expectedEnrollmentId) { + throw new CredentialGroupEnrollmentError('Enrollment not found', 404) + } + if (current?.status === 'revoked' && options.revokedEnrollment === 'reject') { + throw new CredentialGroupEnrollmentError('Revoked enrollment cannot be resent', 409) + } + + const preservesProgress = current?.status === 'in_progress' || current?.status === 'completed' + const nextStatus = preservesProgress ? current.status : ('invited' as const) + const mutableValues = { + status: nextStatus, + invitationTokenHash: tokenHash, + invitationExpiresAt: expiresAt, + invitedAt: now, + sentAt: null, + completedAt: preservesProgress ? current.completedAt : null, + revokedAt: null, + lastDeliveryError: null, + createdBy: userId, + updatedAt: now, + } + const [next] = current + ? await tx + .update(credentialGroupEnrollment) + .set(mutableValues) + .where(eq(credentialGroupEnrollment.id, current.id)) + .returning() + : await tx + .insert(credentialGroupEnrollment) + .values({ + id: generateId(), + credentialGroupId: context.groupId, + email, + ...mutableValues, + createdAt: now, + }) + .returning() + if (!next) throw new Error('Credential group enrollment write returned no row') + return next + }) + + const invitationLink = `${getBaseUrl()}/credential-groups/enroll/${token}` + const html = await renderCredentialGroupInvitationEmail({ + recipientEmail: email, + inviterName, + workspaceName: context.workspaceName, + credentialGroupName: context.groupName, + invitationLink, + }) + const result = await sendEmail({ + to: email, + subject: getCredentialGroupInvitationSubject(inviterName, context.workspaceName), + html, + from: getFromEmailAddress(), + emailType: 'transactional', + }) + + if (!result.success) { + const [failed] = await db + .update(credentialGroupEnrollment) + .set({ + status: issued.status === 'invited' ? 'delivery_failed' : issued.status, + lastDeliveryError: truncate(result.message, 500), + updatedAt: new Date(), + }) + .where( + and( + eq(credentialGroupEnrollment.id, issued.id), + eq(credentialGroupEnrollment.invitationTokenHash, tokenHash), + eq(credentialGroupEnrollment.status, issued.status) + ) + ) + .returning({ id: credentialGroupEnrollment.id }) + if (!failed) { + throw new CredentialGroupEnrollmentError( + 'Invitation was superseded by another enrollment action', + 409 + ) + } + throw new CredentialGroupEnrollmentError(result.message, 502) + } + + const [sent] = await db + .update(credentialGroupEnrollment) + .set({ sentAt: new Date(), lastDeliveryError: null, updatedAt: new Date() }) + .where( + and( + eq(credentialGroupEnrollment.id, issued.id), + eq(credentialGroupEnrollment.invitationTokenHash, tokenHash), + eq(credentialGroupEnrollment.status, issued.status) + ) + ) + .returning() + if (!sent) { + throw new CredentialGroupEnrollmentError( + 'Invitation was superseded by another delivery request', + 409 + ) + } + return toCredentialGroupEnrollment(sent) +} + +export async function listCredentialGroupEnrollments( + workspaceId: string, + groupId: string, + limit: number, + cursor?: string, + filters: ListCredentialGroupEnrollmentFilters = {} +): Promise<{ enrollments: CredentialGroupEnrollmentDetail[]; nextCursor: string | null }> { + if (!Number.isInteger(limit) || limit < 1 || limit > MAX_ENROLLMENT_PAGE_SIZE) { + throw new Error( + `Credential group enrollment limit must be between 1 and ${MAX_ENROLLMENT_PAGE_SIZE}` + ) + } + const [group] = await db + .select({ options: credentialGroup.options }) + .from(credentialGroup) + .where(and(eq(credentialGroup.id, groupId), eq(credentialGroup.workspaceId, workspaceId))) + .limit(1) + if (!group) throw new CredentialGroupEnrollmentError('Credential group not found', 404) + const activeOptionIds = group.options + .filter((option) => option.status === 'active') + .map((option) => option.id) + + let cursorPosition: { id: string; invitedAt: Date } | undefined + if (cursor) { + const [cursorRow] = await db + .select({ id: credentialGroupEnrollment.id, invitedAt: credentialGroupEnrollment.invitedAt }) + .from(credentialGroupEnrollment) + .innerJoin( + credentialGroup, + eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId) + ) + .where( + and( + eq(credentialGroupEnrollment.id, cursor), + eq(credentialGroup.id, groupId), + eq(credentialGroup.workspaceId, workspaceId), + filters.email ? eq(credentialGroupEnrollment.email, filters.email) : undefined, + filters.statuses?.length + ? inArray(credentialGroupEnrollment.status, filters.statuses) + : undefined + ) + ) + .limit(1) + if (!cursorRow) throw new CredentialGroupEnrollmentError('Enrollment cursor not found', 404) + cursorPosition = cursorRow + } + + const rows = await db + .select({ enrollment: credentialGroupEnrollment }) + .from(credentialGroupEnrollment) + .innerJoin(credentialGroup, eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId)) + .where( + and( + eq(credentialGroup.id, groupId), + eq(credentialGroup.workspaceId, workspaceId), + filters.email ? eq(credentialGroupEnrollment.email, filters.email) : undefined, + filters.statuses?.length + ? inArray(credentialGroupEnrollment.status, filters.statuses) + : undefined, + cursorPosition + ? or( + lt(credentialGroupEnrollment.invitedAt, cursorPosition.invitedAt), + and( + eq(credentialGroupEnrollment.invitedAt, cursorPosition.invitedAt), + lt(credentialGroupEnrollment.id, cursorPosition.id) + ) + ) + : undefined + ) + ) + .orderBy(desc(credentialGroupEnrollment.invitedAt), desc(credentialGroupEnrollment.id)) + .limit(limit + 1) + const hasNextPage = rows.length > limit + const pageRows = hasNextPage ? rows.slice(0, limit) : rows + const enrollmentIds = pageRows.map(({ enrollment }) => enrollment.id) + const connectionSummaryLimit = enrollmentIds.length * CONNECTION_SUMMARIES_PER_ENROLLMENT + const connectionRows = + enrollmentIds.length === 0 || activeOptionIds.length === 0 + ? [] + : await db + .select({ + enrollmentId: credential.credentialGroupEnrollmentId, + providerId: credential.providerId, + status: credential.managedOauthStatus, + count: count(credential.id), + }) + .from(credential) + .where( + and( + eq(credential.type, 'managed_oauth'), + inArray(credential.credentialGroupEnrollmentId, enrollmentIds), + inArray(credential.credentialGroupOptionId, activeOptionIds) + ) + ) + .groupBy( + credential.credentialGroupEnrollmentId, + credential.providerId, + credential.managedOauthStatus + ) + .limit(connectionSummaryLimit + 1) + if (connectionRows.length > connectionSummaryLimit) { + throw new Error('Managed credential connection summaries exceed the supported provider states') + } + const connectionsByEnrollment = new Map() + for (const connection of connectionRows) { + if (!connection.enrollmentId) { + throw new Error('Managed credential enrollment ID is missing') + } + const summary: CredentialGroupEnrollmentConnection = { + provider: toCredentialGroupConnectionProvider(connection.providerId), + status: toCredentialGroupConnectionStatus(connection.status), + count: connection.count, + } + const current = connectionsByEnrollment.get(connection.enrollmentId) + if (current) current.push(summary) + else connectionsByEnrollment.set(connection.enrollmentId, [summary]) + } + return { + enrollments: pageRows.map(({ enrollment }) => ({ + ...toCredentialGroupEnrollment(enrollment), + connections: connectionsByEnrollment.get(enrollment.id) ?? [], + })), + nextCursor: hasNextPage ? (pageRows.at(-1)?.enrollment.id ?? null) : null, + } +} + +export async function inviteCredentialGroupEnrollments( + workspaceId: string, + groupId: string, + userId: string, + inviterName: string, + body: InviteCredentialGroupEnrollmentsInput +) { + const context = await getInvitationContext(workspaceId, groupId) + const emails = [...new Set(body.emails.map(normalizeEmail))] + const results: Array< + | { email: string; success: true; enrollment: CredentialGroupEnrollmentRecord } + | { email: string; success: false; error: string } + > = [] + + for (let index = 0; index < emails.length; index += DELIVERY_CONCURRENCY) { + const chunk = emails.slice(index, index + DELIVERY_CONCURRENCY) + const chunkResults = await Promise.all( + chunk.map(async (email) => { + try { + const enrollment = await sendInvitation(context, userId, inviterName, email, { + revokedEnrollment: 'reactivate', + }) + return { email, success: true as const, enrollment } + } catch (error) { + return { + email, + success: false as const, + error: getErrorMessage(error, 'Failed to send invitation'), + } + } + }) + ) + results.push(...chunkResults) + } + + const sentCount = results.filter((result) => result.success).length + return { results, sentCount, failedCount: results.length - sentCount } +} + +export async function loadCredentialGroupInviterIdentity( + userId: string +): Promise<{ name: string | null; email: string } | null> { + const [row] = await db + .select({ name: user.name, email: user.email }) + .from(user) + .where(eq(user.id, userId)) + .limit(1) + return row ?? null +} + +export async function inviteCredentialGroupEnrollment( + workspaceId: string, + groupId: string, + userId: string, + inviterName: string, + email: string +): Promise { + const context = await getInvitationContext(workspaceId, groupId) + return sendInvitation(context, userId, inviterName, normalizeEmail(email), { + revokedEnrollment: 'reactivate', + }) +} + +export async function resendCredentialGroupEnrollment( + workspaceId: string, + groupId: string, + enrollmentId: string, + userId: string, + inviterName: string +): Promise { + const context = await getInvitationContext(workspaceId, groupId) + const [row] = await db + .select({ enrollment: credentialGroupEnrollment }) + .from(credentialGroupEnrollment) + .innerJoin(credentialGroup, eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId)) + .where( + and( + eq(credentialGroupEnrollment.id, enrollmentId), + eq(credentialGroup.id, groupId), + eq(credentialGroup.workspaceId, workspaceId) + ) + ) + .limit(1) + if (!row) throw new CredentialGroupEnrollmentError('Enrollment not found', 404) + return sendInvitation(context, userId, inviterName, row.enrollment.email, { + expectedEnrollmentId: enrollmentId, + revokedEnrollment: 'reject', + }) +} + +export async function revokeCredentialGroupEnrollment( + workspaceId: string, + groupId: string, + enrollmentId: string +): Promise { + const [existing] = await db + .select({ email: credentialGroupEnrollment.email }) + .from(credentialGroupEnrollment) + .innerJoin(credentialGroup, eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId)) + .where( + and( + eq(credentialGroupEnrollment.id, enrollmentId), + eq(credentialGroup.id, groupId), + eq(credentialGroup.workspaceId, workspaceId) + ) + ) + .limit(1) + if (!existing) throw new CredentialGroupEnrollmentError('Enrollment not found', 404) + + return db.transaction(async (tx) => { + await lockCredentialGroupInvitationTarget(tx, groupId, existing.email) + await lockCredentialGroupEnrollmentLifecycle(tx, enrollmentId) + const now = new Date() + const [revoked] = await tx + .update(credentialGroupEnrollment) + .set({ status: 'revoked', revokedAt: now, updatedAt: now }) + .where( + and( + eq(credentialGroupEnrollment.id, enrollmentId), + eq(credentialGroupEnrollment.credentialGroupId, groupId) + ) + ) + .returning() + if (!revoked) throw new Error('Credential group enrollment update returned no row') + + await tx + .update(credential) + .set({ managedOauthStatus: 'revoked', revokedAt: now, updatedAt: now }) + .where( + and( + eq(credential.type, 'managed_oauth'), + eq(credential.credentialGroupEnrollmentId, enrollmentId) + ) + ) + return toCredentialGroupEnrollment(revoked) + }) +} + +export async function getPublicCredentialGroupEnrollment( + token: string +): Promise { + const row = await resolvePublicEnrollmentRowByIdentity({ + invitationTokenHash: hashInvitationToken(token), + }) + return row ? buildPublicCredentialGroupEnrollment(row) : null +} + +export async function getAuthorizedPublicCredentialGroupEnrollment( + identity: PublicCredentialGroupEnrollmentIdentity +): Promise { + const row = await resolveAuthorizedPublicEnrollmentRow(identity) + if (!row) return null + + return buildPublicCredentialGroupEnrollment(row) +} + +async function buildPublicCredentialGroupEnrollment( + row: NonNullable>> +): Promise { + const connectionRows = await db + .select({ + optionId: credential.credentialGroupOptionId, + status: credential.managedOauthStatus, + scopeVersion: credential.managedOauthScopeVersion, + authorizationAppId: credential.authorizationAppId, + grantedScopes: credential.grantedScopes, + displayName: credential.displayName, + metadata: credential.providerMetadata, + grantedAt: credential.grantedAt, + }) + .from(credential) + .where( + and( + eq(credential.type, 'managed_oauth'), + eq(credential.credentialGroupEnrollmentId, row.enrollment.id) + ) + ) + + return { + inviterName: row.inviterName ?? 'A workspace admin', + workspaceName: row.workspaceName, + credentialGroupName: row.groupName, + options: await Promise.all( + row.options.map(async (option) => { + if (!isCredentialGroupProvider(option.provider)) { + throw new Error(`Unsupported Credential Group provider: ${option.provider}`) + } + const adapter = getCredentialGroupProviderAdapter(option.provider) + const policy = await adapter.getPolicy(option, { + workspaceId: row.workspaceId, + credentialGroupId: row.groupId, + }) + return { + id: option.id, + provider: option.provider, + label: option.label, + required: option.required, + status: option.status, + connections: connectionRows + .filter((connection) => connection.optionId === option.id && connection.grantedAt) + .map((connection) => { + const email = metadataString(connection.metadata, 'email') ?? connection.displayName + const status = + connection.status === 'revoked' + ? ('revoked' as const) + : connection.status !== 'active' || + connection.authorizationAppId !== policy.authorizationAppId || + connection.scopeVersion !== policy.scopeVersion || + !adapter.hasRequiredScopes( + connection.grantedScopes ?? [], + policy.requiredScopes + ) + ? ('needs_reauth' as const) + : ('connected' as const) + return { + email, + displayName: + metadataString(connection.metadata, 'displayName') ?? + metadataString(connection.metadata, 'name'), + avatarUrl: + metadataString(connection.metadata, 'avatarUrl') ?? + metadataString(connection.metadata, 'picture'), + status, + grantedAt: connection.grantedAt!.toISOString(), + } + }), + } + }) + ), + status: row.enrollment.status, + } +} + +/** Finalizes an enrollment only after every active credential option has one usable connection. */ +export async function completeCredentialGroupEnrollment(token: string): Promise { + const row = await resolvePublicEnrollmentRowByIdentity({ + invitationTokenHash: hashInvitationToken(token), + }) + if (!row) return null + return completeResolvedCredentialGroupEnrollment(row, identityForPublicEnrollmentRow(row)) +} + +export async function completeAuthorizedCredentialGroupEnrollment( + identity: PublicCredentialGroupEnrollmentIdentity +): Promise { + const row = await resolveAuthorizedPublicEnrollmentRow(identity) + if (!row) return null + return completeResolvedCredentialGroupEnrollment(row, identity) +} + +async function completeResolvedCredentialGroupEnrollment( + row: NonNullable>>, + identity: PublicCredentialGroupEnrollmentIdentity +): Promise { + return db.transaction(async (tx) => { + await lockCredentialGroupEnrollmentLifecycle(tx, row.enrollment.id) + const now = new Date() + const [current] = await tx + .select({ + status: credentialGroupEnrollment.status, + invitationTokenHash: credentialGroupEnrollment.invitationTokenHash, + invitationExpiresAt: credentialGroupEnrollment.invitationExpiresAt, + }) + .from(credentialGroupEnrollment) + .where(eq(credentialGroupEnrollment.id, row.enrollment.id)) + .limit(1) + if ( + !current || + current.status === 'revoked' || + current.status === 'delivery_failed' || + current.invitationTokenHash !== identity.invitationTokenHash || + current.invitationExpiresAt.getTime() <= now.getTime() + ) { + return null + } + + const [group] = await tx + .select({ + status: credentialGroup.status, + options: credentialGroup.options, + }) + .from(credentialGroup) + .where( + and( + eq(credentialGroup.id, identity.credentialGroupId), + eq(credentialGroup.workspaceId, identity.workspaceId) + ) + ) + .limit(1) + .for('update') + if (!group || group.status !== 'active') return null + + const activeOptions = group.options.filter((option) => option.status === 'active') + if (activeOptions.length === 0) return false + const connections = await tx + .select({ + optionId: credential.credentialGroupOptionId, + status: credential.managedOauthStatus, + scopeVersion: credential.managedOauthScopeVersion, + authorizationAppId: credential.authorizationAppId, + grantedScopes: credential.grantedScopes, + grantedAt: credential.grantedAt, + }) + .from(credential) + .where( + and( + eq(credential.type, 'managed_oauth'), + eq(credential.credentialGroupEnrollmentId, row.enrollment.id) + ) + ) + .for('update') + + for (const option of activeOptions) { + if (!isCredentialGroupProvider(option.provider)) { + throw new Error(`Unsupported Credential Group provider: ${option.provider}`) + } + const matchingConnections = connections.filter( + (connection) => connection.optionId === option.id + ) + if (matchingConnections.length !== 1) return false + const [connection] = matchingConnections + if (!connection || connection.status !== 'active' || !connection.grantedAt) return false + + const adapter = getCredentialGroupProviderAdapter(option.provider) + const policy = await adapter.getPolicy(option, { + workspaceId: identity.workspaceId, + credentialGroupId: identity.credentialGroupId, + executor: tx, + }) + if ( + connection.authorizationAppId !== policy.authorizationAppId || + connection.scopeVersion !== policy.scopeVersion || + !adapter.hasRequiredScopes(connection.grantedScopes ?? [], policy.requiredScopes) + ) { + return false + } + } + + const [completed] = await tx + .update(credentialGroupEnrollment) + .set({ status: 'completed', completedAt: now, updatedAt: now }) + .where( + and( + eq(credentialGroupEnrollment.id, row.enrollment.id), + inArray(credentialGroupEnrollment.status, ['invited', 'in_progress', 'completed']) + ) + ) + .returning({ id: credentialGroupEnrollment.id }) + if (!completed) throw new Error('Credential group enrollment completion returned no row') + return true + }) +} + +/** Resolves the private, server-only context bound to a public enrollment link and option. */ +export async function getCredentialGroupOAuthContext( + token: string, + optionId: string +): Promise { + const row = await resolvePublicEnrollmentRowByIdentity({ + invitationTokenHash: hashInvitationToken(token), + }) + if (!row) return null + const option = row.options.find((candidate) => candidate.id === optionId) + if (!option || option.status !== 'active') return null + return credentialGroupOAuthContextFromRow(row, option) +} + +export async function getAuthorizedCredentialGroupOAuthContext( + identity: PublicCredentialGroupEnrollmentIdentity, + optionId: string +): Promise { + const row = await resolveAuthorizedPublicEnrollmentRow(identity) + if (!row) return null + const option = row.options.find((candidate) => candidate.id === optionId) + if (!option || option.status !== 'active') return null + return credentialGroupOAuthContextFromRow(row, option) +} + +function credentialGroupOAuthContextFromRow( + row: NonNullable>>, + option: CredentialGroupOptionConfig +): CredentialGroupOAuthContext { + return { + enrollmentId: row.enrollment.id, + credentialGroupId: row.groupId, + workspaceId: row.workspaceId, + workspaceName: row.workspaceName, + workspaceOwnerId: row.workspaceOwnerId, + email: row.enrollment.email, + enrollmentStatus: row.enrollment.status, + option, + options: row.options, + } +} diff --git a/apps/sim/lib/credential-groups/groups.ts b/apps/sim/lib/credential-groups/groups.ts new file mode 100644 index 00000000000..abe6ed60b62 --- /dev/null +++ b/apps/sim/lib/credential-groups/groups.ts @@ -0,0 +1,110 @@ +import { db } from '@sim/db' +import { credentialGroup } from '@sim/db/schema' +import { and, desc, eq, lt, or } from 'drizzle-orm' +import { + getCredentialGroupProviderId, + isCredentialGroupProvider, +} from '@/lib/credential-groups/providers' + +export const MAX_CREDENTIAL_GROUP_PAGE_SIZE = 100 + +export interface CredentialGroupSummary { + id: string + name: string + description: string | null + status: 'active' | 'disabled' + providerIds: string[] + createdAt: string + updatedAt: string +} + +export class CredentialGroupCursorNotFoundError extends Error { + constructor() { + super('Credential group cursor not found') + this.name = 'CredentialGroupCursorNotFoundError' + } +} + +interface ListCredentialGroupSummariesInput { + workspaceId: string + limit: number + cursor?: string +} + +/** Lists a bounded page of group metadata without decrypting provider configuration. */ +export async function listCredentialGroupSummaries({ + workspaceId, + limit, + cursor, +}: ListCredentialGroupSummariesInput): Promise<{ + credentialGroups: CredentialGroupSummary[] + nextCursor: string | null +}> { + let cursorPosition: { id: string; createdAt: Date } | undefined + if (cursor) { + const [cursorRow] = await db + .select({ id: credentialGroup.id, createdAt: credentialGroup.createdAt }) + .from(credentialGroup) + .where(and(eq(credentialGroup.id, cursor), eq(credentialGroup.workspaceId, workspaceId))) + .limit(1) + if (!cursorRow) throw new CredentialGroupCursorNotFoundError() + cursorPosition = cursorRow + } + + const rows = await db + .select({ + id: credentialGroup.id, + name: credentialGroup.name, + description: credentialGroup.description, + status: credentialGroup.status, + options: credentialGroup.options, + createdAt: credentialGroup.createdAt, + updatedAt: credentialGroup.updatedAt, + }) + .from(credentialGroup) + .where( + and( + eq(credentialGroup.workspaceId, workspaceId), + cursorPosition + ? or( + lt(credentialGroup.createdAt, cursorPosition.createdAt), + and( + eq(credentialGroup.createdAt, cursorPosition.createdAt), + lt(credentialGroup.id, cursorPosition.id) + ) + ) + : undefined + ) + ) + .orderBy(desc(credentialGroup.createdAt), desc(credentialGroup.id)) + .limit(limit + 1) + + const hasMore = rows.length > limit + const pageRows = hasMore ? rows.slice(0, limit) : rows + const nextCursor = hasMore ? pageRows.at(-1)?.id : null + if (hasMore && !nextCursor) throw new Error('Credential group page cursor could not be derived') + + return { + credentialGroups: pageRows.map((row) => ({ + id: row.id, + name: row.name, + description: row.description, + status: row.status, + providerIds: [ + ...new Set( + row.options + .filter((option) => option.status === 'active') + .map((option) => { + if (!isCredentialGroupProvider(option.provider)) { + throw new Error(`Credential Group provider is not registered: ${option.provider}`) + } + return getCredentialGroupProviderId(option.provider) + }) + ), + ], + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + })), + nextCursor: nextCursor ?? null, + } +} diff --git a/apps/sim/lib/credential-groups/oauth-state.test.ts b/apps/sim/lib/credential-groups/oauth-state.test.ts new file mode 100644 index 00000000000..a172785076e --- /dev/null +++ b/apps/sim/lib/credential-groups/oauth-state.test.ts @@ -0,0 +1,129 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockRedis, values } = vi.hoisted(() => { + const values = new Map() + return { + values, + mockRedis: { + set: vi.fn(async (key: string, value: string) => { + if (values.has(key)) return null + values.set(key, value) + return 'OK' + }), + eval: vi.fn(async (_script: string, _keyCount: number, key: string) => { + const value = values.get(key) ?? null + values.delete(key) + return value + }), + }, + } +}) + +vi.mock('@/lib/core/config/redis', () => ({ + getRedisClient: vi.fn(() => mockRedis), +})) + +vi.mock('@/lib/core/security/encryption', () => ({ + encryptSecret: vi.fn(async (value: string) => ({ + encrypted: `encrypted:${Buffer.from(value).toString('base64')}`, + })), + decryptSecret: vi.fn(async (value: string) => ({ + decrypted: Buffer.from(value.replace(/^encrypted:/, ''), 'base64').toString(), + })), +})) + +import { getRedisClient } from '@/lib/core/config/redis' +import { + consumeCredentialGroupOAuthAttempt, + createCredentialGroupOAuthAttempt, + credentialGroupOAuthNonceMatches, +} from '@/lib/credential-groups/oauth-state' + +describe('credential group OAuth state', () => { + beforeEach(() => { + vi.clearAllMocks() + values.clear() + vi.mocked(getRedisClient).mockReturnValue(mockRedis as never) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('stores encrypted attempt material and consumes state once', async () => { + const created = await createCredentialGroupOAuthAttempt({ + provider: 'gmail', + enrollmentId: 'enrollment-1', + credentialGroupId: 'group-1', + optionId: 'option-1', + authorizationAppId: 'google:app', + scopeVersion: 1, + requiredScopes: ['openid', 'email'], + redirectUri: 'https://sim.ai/api/credential-groups/oauth/gmail/callback', + codeVerifier: 'code-verifier', + invitationToken: 'invitation-token', + }) + + const stored = [...values.values()][0] + expect(stored).not.toContain('code-verifier') + expect(stored).not.toContain('invitation-token') + + const consumed = await consumeCredentialGroupOAuthAttempt(created.state) + expect(consumed).toMatchObject({ + provider: 'gmail', + enrollmentId: 'enrollment-1', + credentialGroupId: 'group-1', + optionId: 'option-1', + codeVerifier: 'code-verifier', + invitationToken: 'invitation-token', + }) + expect(credentialGroupOAuthNonceMatches(created.nonce, consumed?.nonceHash ?? '')).toBe(true) + await expect(consumeCredentialGroupOAuthAttempt(created.state)).resolves.toBeNull() + }) + + it('fails closed when Redis is unavailable', async () => { + vi.mocked(getRedisClient).mockReturnValue(null) + + await expect( + createCredentialGroupOAuthAttempt({ + provider: 'gmail', + enrollmentId: 'enrollment-1', + credentialGroupId: 'group-1', + optionId: 'option-1', + authorizationAppId: 'google:app', + scopeVersion: 1, + requiredScopes: ['openid'], + redirectUri: 'https://sim.ai/callback', + codeVerifier: 'code-verifier', + invitationToken: 'invitation-token', + }) + ).rejects.toThrow('Credential group OAuth requires Redis') + }) + + it('supports providers without PKCE while preserving one-time state', async () => { + const created = await createCredentialGroupOAuthAttempt({ + provider: 'slack', + enrollmentId: 'enrollment-1', + credentialGroupId: 'group-1', + optionId: 'option-1', + authorizationAppId: 'slack:A123:T123', + scopeVersion: 1, + requiredScopes: ['users:read'], + redirectUri: 'https://sim.ai/api/credential-groups/oauth/slack/callback', + invitationToken: 'invitation-token', + }) + + const consumed = await consumeCredentialGroupOAuthAttempt(created.state) + + expect(consumed).toMatchObject({ + provider: 'slack', + authorizationAppId: 'slack:A123:T123', + invitationToken: 'invitation-token', + }) + expect(consumed?.codeVerifier).toBeUndefined() + await expect(consumeCredentialGroupOAuthAttempt(created.state)).resolves.toBeNull() + }) +}) diff --git a/apps/sim/lib/credential-groups/oauth-state.ts b/apps/sim/lib/credential-groups/oauth-state.ts new file mode 100644 index 00000000000..a9b00a9849a --- /dev/null +++ b/apps/sim/lib/credential-groups/oauth-state.ts @@ -0,0 +1,180 @@ +import { safeCompare } from '@sim/security/compare' +import { sha256Hex } from '@sim/security/hash' +import { generateId } from '@sim/utils/id' +import { getRedisClient } from '@/lib/core/config/redis' +import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' +import { + type CredentialGroupProvider, + isCredentialGroupProvider, +} from '@/lib/credential-groups/providers' + +const OAUTH_ATTEMPT_TTL_MS = 10 * 60 * 1000 +const OAUTH_ATTEMPT_VERSION = 2 as const + +const CONSUME_SCRIPT = ` +local value = redis.call('GET', KEYS[1]) +if not value then + return nil +end +redis.call('DEL', KEYS[1]) +return value +` + +interface StoredCredentialGroupOAuthAttempt { + version: typeof OAUTH_ATTEMPT_VERSION + provider: CredentialGroupProvider + enrollmentId: string + credentialGroupId: string + optionId: string + authorizationAppId: string + scopeVersion: number + requiredScopes: string[] + redirectUri: string + nonceHash: string + encryptedCodeVerifier?: string + encryptedInvitationToken: string + createdAt: number +} + +export interface CredentialGroupOAuthAttempt { + state: string + provider: CredentialGroupProvider + nonceHash: string + enrollmentId: string + credentialGroupId: string + optionId: string + authorizationAppId: string + scopeVersion: number + requiredScopes: string[] + redirectUri: string + codeVerifier?: string + invitationToken: string + createdAt: number +} + +interface CreateCredentialGroupOAuthAttemptParams { + provider: CredentialGroupProvider + enrollmentId: string + credentialGroupId: string + optionId: string + authorizationAppId: string + scopeVersion: number + requiredScopes: string[] + redirectUri: string + codeVerifier?: string + invitationToken: string +} + +function requireRedis() { + const redis = getRedisClient() + if (!redis) { + throw new Error('Credential group OAuth requires Redis') + } + return redis +} + +function attemptKey(state: string): string { + return `credential-group:oauth-attempt:${sha256Hex(state)}` +} + +function isStoredAttempt(value: unknown): value is StoredCredentialGroupOAuthAttempt { + if (!value || typeof value !== 'object') return false + const candidate = value as Record + return ( + candidate.version === OAUTH_ATTEMPT_VERSION && + typeof candidate.provider === 'string' && + isCredentialGroupProvider(candidate.provider) && + typeof candidate.enrollmentId === 'string' && + typeof candidate.credentialGroupId === 'string' && + typeof candidate.optionId === 'string' && + typeof candidate.authorizationAppId === 'string' && + typeof candidate.scopeVersion === 'number' && + Number.isInteger(candidate.scopeVersion) && + candidate.scopeVersion > 0 && + Array.isArray(candidate.requiredScopes) && + candidate.requiredScopes.length > 0 && + candidate.requiredScopes.every((scope) => typeof scope === 'string' && scope.length > 0) && + typeof candidate.redirectUri === 'string' && + typeof candidate.nonceHash === 'string' && + (candidate.encryptedCodeVerifier === undefined || + typeof candidate.encryptedCodeVerifier === 'string') && + typeof candidate.encryptedInvitationToken === 'string' && + typeof candidate.createdAt === 'number' + ) +} + +/** Creates a short-lived, one-time OAuth attempt. Only state and nonce leave the server. */ +export async function createCredentialGroupOAuthAttempt( + params: CreateCredentialGroupOAuthAttemptParams +): Promise<{ state: string; nonce: string }> { + const redis = requireRedis() + const state = generateId() + const nonce = generateId() + const [encryptedCodeVerifier, encryptedInvitationToken] = await Promise.all([ + params.codeVerifier ? encryptSecret(params.codeVerifier) : undefined, + encryptSecret(params.invitationToken), + ]) + const attempt: StoredCredentialGroupOAuthAttempt = { + version: OAUTH_ATTEMPT_VERSION, + provider: params.provider, + enrollmentId: params.enrollmentId, + credentialGroupId: params.credentialGroupId, + optionId: params.optionId, + authorizationAppId: params.authorizationAppId, + scopeVersion: params.scopeVersion, + requiredScopes: params.requiredScopes, + redirectUri: params.redirectUri, + nonceHash: sha256Hex(nonce), + ...(encryptedCodeVerifier ? { encryptedCodeVerifier: encryptedCodeVerifier.encrypted } : {}), + encryptedInvitationToken: encryptedInvitationToken.encrypted, + createdAt: Date.now(), + } + const stored = await redis.set( + attemptKey(state), + JSON.stringify(attempt), + 'PX', + OAUTH_ATTEMPT_TTL_MS, + 'NX' + ) + if (stored !== 'OK') throw new Error('Credential group OAuth state collision') + return { state, nonce } +} + +/** Atomically burns state before the single-use authorization code is exchanged. */ +export async function consumeCredentialGroupOAuthAttempt( + state: string +): Promise { + const redis = requireRedis() + const raw = await redis.eval(CONSUME_SCRIPT, 1, attemptKey(state)) + if (raw === null) return null + if (typeof raw !== 'string') throw new Error('Credential group OAuth state is malformed') + + const parsed: unknown = JSON.parse(raw) + if (!isStoredAttempt(parsed)) throw new Error('Credential group OAuth state is malformed') + if (Date.now() - parsed.createdAt > OAUTH_ATTEMPT_TTL_MS) return null + + const [codeVerifier, invitationToken] = await Promise.all([ + parsed.encryptedCodeVerifier ? decryptSecret(parsed.encryptedCodeVerifier) : undefined, + decryptSecret(parsed.encryptedInvitationToken), + ]) + return { + state, + provider: parsed.provider, + nonceHash: parsed.nonceHash, + enrollmentId: parsed.enrollmentId, + credentialGroupId: parsed.credentialGroupId, + optionId: parsed.optionId, + authorizationAppId: parsed.authorizationAppId, + scopeVersion: parsed.scopeVersion, + requiredScopes: parsed.requiredScopes, + redirectUri: parsed.redirectUri, + ...(codeVerifier ? { codeVerifier: codeVerifier.decrypted } : {}), + invitationToken: invitationToken.decrypted, + createdAt: parsed.createdAt, + } +} + +/** Compares a verified ID-token nonce with the hash retained in the OAuth attempt. */ +export function credentialGroupOAuthNonceMatches(nonce: string, storedNonceHash: string): boolean { + return safeCompare(sha256Hex(nonce), storedNonceHash) +} diff --git a/apps/sim/lib/credential-groups/oauth.test.ts b/apps/sim/lib/credential-groups/oauth.test.ts new file mode 100644 index 00000000000..d63b2d7c423 --- /dev/null +++ b/apps/sim/lib/credential-groups/oauth.test.ts @@ -0,0 +1,220 @@ +/** + * @vitest-environment node + */ +import { + dbChainMock, + dbChainMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { adapter } = vi.hoisted(() => ({ + adapter: { + provider: 'gmail' as const, + requiresRefreshToken: true, + getPolicy: vi.fn(), + prepareAuthorization: vi.fn(), + exchangeAndVerify: vi.fn(), + hasRequiredScopes: vi.fn(), + refreshToken: vi.fn(), + isTerminalRefreshError: vi.fn(), + }, +})) + +vi.mock('@/lib/credential-groups/provider-registry', () => ({ + getCredentialGroupProviderAdapter: () => adapter, +})) + +vi.mock('@/lib/credentials/managed-oauth', () => ({ + decryptManagedOAuthTokenSet: vi.fn(), + encryptManagedOAuthTokenSet: vi.fn().mockResolvedValue('encrypted-token-set'), +})) + +import { completeCredentialGroupOAuth } from '@/lib/credential-groups/oauth' + +const POLICY = { + provider: 'gmail' as const, + providerId: 'google-email', + authorizationAppId: 'google:client', + requiredScopes: ['openid', 'https://www.googleapis.com/auth/gmail.modify'], + scopeVersion: 1, +} + +const CONTEXT = { + enrollmentId: 'enrollment-1', + credentialGroupId: 'group-1', + workspaceId: 'workspace-1', + workspaceName: 'Workspace', + workspaceOwnerId: 'owner-1', + email: 'person@example.com', + enrollmentStatus: 'in_progress' as const, + option: { + id: 'option-1', + provider: 'gmail' as const, + label: 'Gmail', + required: true, + status: 'active' as const, + }, + options: [], +} + +const GROUP = { + status: 'active' as const, + options: [ + { + ...CONTEXT.option, + authorizationAppId: POLICY.authorizationAppId, + requiredScopes: POLICY.requiredScopes, + scopeVersion: POLICY.scopeVersion, + }, + ], +} + +describe('credential group OAuth persistence', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + adapter.getPolicy.mockResolvedValue(POLICY) + adapter.exchangeAndVerify.mockResolvedValue({ + providerId: POLICY.providerId, + providerSubjectId: 'google-subject-1', + providerTenantId: null, + displayName: 'person@example.com', + metadata: { email: 'person@example.com' }, + accessToken: 'access-token', + refreshToken: 'refresh-token', + grantedScopes: POLICY.requiredScopes, + accessTokenExpiresAt: new Date('2026-08-14T00:00:00Z'), + refreshTokenExpiresAt: null, + }) + }) + + it('does not reactivate a credential after its enrollment is revoked', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ status: 'revoked' }]) + + await expect( + completeCredentialGroupOAuth( + CONTEXT, + { + state: 'state-1', + provider: 'gmail', + nonceHash: 'nonce-hash', + enrollmentId: CONTEXT.enrollmentId, + credentialGroupId: CONTEXT.credentialGroupId, + optionId: CONTEXT.option.id, + authorizationAppId: POLICY.authorizationAppId, + scopeVersion: POLICY.scopeVersion, + requiredScopes: POLICY.requiredScopes, + redirectUri: 'https://sim.ai/api/credential-groups/oauth/gmail/callback', + codeVerifier: 'verifier', + invitationToken: 'invitation-token', + createdAt: Date.now(), + }, + 'authorization-code' + ) + ).rejects.toThrow('This account invitation was revoked.') + + expect(dbChainMockFns.execute).toHaveBeenCalledTimes(2) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) + + it('preserves completed enrollment state when an account reconnects', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ status: 'completed' }]) + queueTableRows(schemaMock.credentialGroup, [GROUP]) + queueTableRows(schemaMock.credential, [ + { + id: 'credential-1', + providerSubjectId: 'google-subject-1', + encryptedOauthTokenSet: null, + refreshTokenExpiresAt: null, + }, + ]) + dbChainMockFns.returning + .mockResolvedValueOnce([{ id: 'credential-1' }]) + .mockResolvedValueOnce([{ id: CONTEXT.enrollmentId }]) + + await completeCredentialGroupOAuth( + { ...CONTEXT, enrollmentStatus: 'completed' }, + { + state: 'state-1', + provider: 'gmail', + nonceHash: 'nonce-hash', + enrollmentId: CONTEXT.enrollmentId, + credentialGroupId: CONTEXT.credentialGroupId, + optionId: CONTEXT.option.id, + authorizationAppId: POLICY.authorizationAppId, + scopeVersion: POLICY.scopeVersion, + requiredScopes: POLICY.requiredScopes, + redirectUri: 'https://sim.ai/api/credential-groups/oauth/gmail/callback', + codeVerifier: 'verifier', + invitationToken: 'invitation-token', + createdAt: Date.now(), + }, + 'authorization-code' + ) + + const enrollmentUpdate = dbChainMockFns.set.mock.calls[1]?.[0] + expect(enrollmentUpdate).toEqual( + expect.objectContaining({ status: 'completed', updatedAt: expect.any(Date) }) + ) + expect(enrollmentUpdate).not.toHaveProperty('completedAt') + }) + + it('rejects an exchanged grant when the group policy changed before persistence', async () => { + const nextPolicy = { + ...POLICY, + requiredScopes: [...POLICY.requiredScopes, 'https://www.googleapis.com/auth/gmail.readonly'], + scopeVersion: 2, + } + adapter.getPolicy.mockResolvedValueOnce(POLICY).mockResolvedValueOnce(nextPolicy) + dbChainMockFns.limit.mockResolvedValueOnce([{ status: 'completed' }]) + queueTableRows(schemaMock.credentialGroup, [ + { + ...GROUP, + options: [ + { + ...GROUP.options[0], + requiredScopes: nextPolicy.requiredScopes, + scopeVersion: nextPolicy.scopeVersion, + }, + ], + }, + ]) + + await expect( + completeCredentialGroupOAuth( + { ...CONTEXT, enrollmentStatus: 'completed' }, + { + state: 'state-1', + provider: 'gmail', + nonceHash: 'nonce-hash', + enrollmentId: CONTEXT.enrollmentId, + credentialGroupId: CONTEXT.credentialGroupId, + optionId: CONTEXT.option.id, + authorizationAppId: POLICY.authorizationAppId, + scopeVersion: POLICY.scopeVersion, + requiredScopes: POLICY.requiredScopes, + redirectUri: 'https://sim.ai/api/credential-groups/oauth/gmail/callback', + codeVerifier: 'verifier', + invitationToken: 'invitation-token', + createdAt: Date.now(), + }, + 'authorization-code' + ) + ).rejects.toThrow('This credential option changed.') + + expect(adapter.getPolicy).toHaveBeenLastCalledWith( + expect.objectContaining({ id: 'option-1' }), + { + workspaceId: CONTEXT.workspaceId, + credentialGroupId: CONTEXT.credentialGroupId, + executor: dbChainMock.db, + } + ) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credential-groups/oauth.ts b/apps/sim/lib/credential-groups/oauth.ts new file mode 100644 index 00000000000..edac52336ae --- /dev/null +++ b/apps/sim/lib/credential-groups/oauth.ts @@ -0,0 +1,289 @@ +import { db } from '@sim/db' +import { credential, credentialGroup, credentialGroupEnrollment } from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { and, eq, ne, sql } from 'drizzle-orm' +import { + type CredentialGroupOAuthContext, + lockCredentialGroupEnrollmentLifecycle, +} from '@/lib/credential-groups/enrollments' +import { + type CredentialGroupOAuthAttempt, + createCredentialGroupOAuthAttempt, +} from '@/lib/credential-groups/oauth-state' +import type { + CredentialGroupProviderAdapter, + CredentialGroupProviderPolicy, + VerifiedCredentialGroupGrant, +} from '@/lib/credential-groups/provider-adapter' +import { CredentialGroupOAuthError } from '@/lib/credential-groups/provider-adapter' +import { getCredentialGroupProviderAdapter } from '@/lib/credential-groups/provider-registry' +import { + getCredentialGroupProviderService, + isCredentialGroupProvider, +} from '@/lib/credential-groups/providers' +import { + decryptManagedOAuthTokenSet, + encryptManagedOAuthTokenSet, +} from '@/lib/credentials/managed-oauth' + +function scopesEqual(left: string[], right: string[]): boolean { + const normalizedLeft = [...new Set(left)].sort() + const normalizedRight = [...new Set(right)].sort() + return ( + normalizedLeft.length === normalizedRight.length && + normalizedLeft.every((scope, index) => scope === normalizedRight[index]) + ) +} + +function policiesEqual( + left: CredentialGroupProviderPolicy, + right: CredentialGroupProviderPolicy +): boolean { + return ( + left.provider === right.provider && + left.providerId === right.providerId && + left.authorizationAppId === right.authorizationAppId && + left.scopeVersion === right.scopeVersion && + scopesEqual(left.requiredScopes, right.requiredScopes) + ) +} + +function getOptionAdapter(context: CredentialGroupOAuthContext): CredentialGroupProviderAdapter { + if (!isCredentialGroupProvider(context.option.provider)) { + throw new Error(`Unsupported Credential Group provider: ${context.option.provider}`) + } + return getCredentialGroupProviderAdapter(context.option.provider) +} + +async function assertCurrentPolicy( + context: CredentialGroupOAuthContext, + adapter: CredentialGroupProviderAdapter, + attempt?: CredentialGroupOAuthAttempt +): Promise { + const policy = await adapter.getPolicy(context.option, { + workspaceId: context.workspaceId, + credentialGroupId: context.credentialGroupId, + }) + const optionMatches = context.option.provider === policy.provider + const attemptMatches = + !attempt || + (attempt.provider === policy.provider && + attempt.authorizationAppId === policy.authorizationAppId && + attempt.scopeVersion === policy.scopeVersion && + scopesEqual(attempt.requiredScopes, policy.requiredScopes)) + if (!optionMatches || !attemptMatches) { + throw new CredentialGroupOAuthError( + 'This credential option changed. Reload the invitation and try again.', + 409 + ) + } + return policy +} + +/** Builds a provider authorization URL after persisting a provider-bound one-time attempt. */ +export async function startCredentialGroupOAuth( + context: CredentialGroupOAuthContext, + invitationToken: string +): Promise { + const adapter = getOptionAdapter(context) + const policy = await assertCurrentPolicy(context, adapter) + const prepared = await adapter.prepareAuthorization(context, policy) + const { state, nonce } = await createCredentialGroupOAuthAttempt({ + provider: policy.provider, + enrollmentId: context.enrollmentId, + credentialGroupId: context.credentialGroupId, + optionId: context.option.id, + authorizationAppId: policy.authorizationAppId, + scopeVersion: policy.scopeVersion, + requiredScopes: policy.requiredScopes, + redirectUri: prepared.redirectUri, + codeVerifier: prepared.codeVerifier, + invitationToken, + }) + return await prepared.buildAuthorizationUrl({ state, nonce }) +} + +async function persistGrant( + context: CredentialGroupOAuthContext, + adapter: CredentialGroupProviderAdapter, + policy: CredentialGroupProviderPolicy, + grant: VerifiedCredentialGroupGrant +): Promise { + if (grant.providerId !== policy.providerId) { + throw new CredentialGroupOAuthError('Provider returned a credential for another app.', 502) + } + + await db.transaction(async (tx) => { + await lockCredentialGroupEnrollmentLifecycle(tx, context.enrollmentId) + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${`credential-group-oauth:${context.enrollmentId}:${context.option.id}`}, 0))` + ) + const [enrollment] = await tx + .select({ status: credentialGroupEnrollment.status }) + .from(credentialGroupEnrollment) + .where(eq(credentialGroupEnrollment.id, context.enrollmentId)) + .limit(1) + if (!enrollment || enrollment.status === 'revoked') { + throw new CredentialGroupOAuthError('This account invitation was revoked.', 409) + } + + const [group] = await tx + .select({ status: credentialGroup.status, options: credentialGroup.options }) + .from(credentialGroup) + .where( + and( + eq(credentialGroup.id, context.credentialGroupId), + eq(credentialGroup.workspaceId, context.workspaceId) + ) + ) + .limit(1) + .for('update') + const currentOption = group?.options.find((option) => option.id === context.option.id) + if ( + !group || + group.status !== 'active' || + !currentOption || + currentOption.status !== 'active' || + currentOption.provider !== adapter.provider + ) { + throw new CredentialGroupOAuthError( + 'This credential option changed. Reload the invitation and try again.', + 409 + ) + } + const currentPolicy = await adapter.getPolicy(currentOption, { + workspaceId: context.workspaceId, + credentialGroupId: context.credentialGroupId, + executor: tx, + }) + if (!policiesEqual(currentPolicy, policy)) { + throw new CredentialGroupOAuthError( + 'This credential option changed. Reload the invitation and try again.', + 409 + ) + } + + const [existing] = await tx + .select({ + id: credential.id, + providerSubjectId: credential.providerSubjectId, + encryptedOauthTokenSet: credential.encryptedOauthTokenSet, + refreshTokenExpiresAt: credential.refreshTokenExpiresAt, + }) + .from(credential) + .where( + and( + eq(credential.type, 'managed_oauth'), + eq(credential.credentialGroupEnrollmentId, context.enrollmentId), + eq(credential.credentialGroupOptionId, context.option.id) + ) + ) + .limit(1) + + let refreshToken = grant.refreshToken + if ( + !refreshToken && + existing?.providerSubjectId === grant.providerSubjectId && + existing.encryptedOauthTokenSet + ) { + refreshToken = (await decryptManagedOAuthTokenSet(existing.encryptedOauthTokenSet)) + .refreshToken + } + if (adapter.requiresRefreshToken && !refreshToken) { + const service = getCredentialGroupProviderService(policy.provider) + throw new CredentialGroupOAuthError( + `${service.name} did not issue offline access. Remove Sim from the provider and try again.`, + 409 + ) + } + + const encryptedOauthTokenSet = await encryptManagedOAuthTokenSet({ + accessToken: grant.accessToken, + ...(refreshToken ? { refreshToken } : {}), + }) + const now = new Date() + const service = getCredentialGroupProviderService(policy.provider) + const values = { + workspaceId: context.workspaceId, + type: 'managed_oauth' as const, + displayName: grant.displayName, + description: `Managed ${service.name} account for ${context.workspaceName}`, + providerId: policy.providerId, + accountId: null, + authorizationAppId: policy.authorizationAppId, + credentialGroupEnrollmentId: context.enrollmentId, + credentialGroupOptionId: context.option.id, + managedOauthScopeVersion: policy.scopeVersion, + providerSubjectId: grant.providerSubjectId, + providerTenantId: grant.providerTenantId, + managedOauthStatus: 'active' as const, + grantedScopes: grant.grantedScopes, + providerMetadata: grant.metadata, + encryptedOauthTokenSet, + grantedAt: now, + revokedAt: null, + accessTokenExpiresAt: grant.accessTokenExpiresAt, + refreshTokenExpiresAt: grant.refreshTokenExpiresAt ?? existing?.refreshTokenExpiresAt ?? null, + lastRefreshedAt: null, + updatedAt: now, + } + + if (existing) { + const [updated] = await tx + .update(credential) + .set(values) + .where(eq(credential.id, existing.id)) + .returning({ id: credential.id }) + if (!updated) throw new Error('Managed OAuth credential update returned no row') + } else { + const [inserted] = await tx + .insert(credential) + .values({ + id: generateId(), + ...values, + createdBy: context.workspaceOwnerId, + createdAt: now, + }) + .returning({ id: credential.id }) + if (!inserted) throw new Error('Managed OAuth credential insert returned no row') + } + + const [updatedEnrollment] = await tx + .update(credentialGroupEnrollment) + .set({ + status: enrollment.status === 'completed' ? 'completed' : 'in_progress', + ...(enrollment.status === 'completed' ? {} : { completedAt: null }), + updatedAt: now, + }) + .where( + and( + eq(credentialGroupEnrollment.id, context.enrollmentId), + ne(credentialGroupEnrollment.status, 'revoked') + ) + ) + .returning({ id: credentialGroupEnrollment.id }) + if (!updatedEnrollment) { + throw new CredentialGroupOAuthError('This account invitation was revoked.', 409) + } + }) +} + +/** Exchanges a single-use code through its provider adapter and persists a normalized grant. */ +export async function completeCredentialGroupOAuth( + context: CredentialGroupOAuthContext, + attempt: CredentialGroupOAuthAttempt, + code: string +): Promise { + if ( + attempt.enrollmentId !== context.enrollmentId || + attempt.credentialGroupId !== context.credentialGroupId || + attempt.optionId !== context.option.id || + attempt.provider !== context.option.provider + ) { + throw new CredentialGroupOAuthError('Authorization state is invalid or expired.', 400) + } + const adapter = getOptionAdapter(context) + const policy = await assertCurrentPolicy(context, adapter, attempt) + const grant = await adapter.exchangeAndVerify({ context, attempt, code, policy }) + await persistGrant(context, adapter, policy, grant) +} diff --git a/apps/sim/lib/credential-groups/provider-adapter.ts b/apps/sim/lib/credential-groups/provider-adapter.ts new file mode 100644 index 00000000000..0f27828ace4 --- /dev/null +++ b/apps/sim/lib/credential-groups/provider-adapter.ts @@ -0,0 +1,86 @@ +import { createHash } from 'node:crypto' +import type { CredentialGroupOptionConfig, ManagedOAuthProviderMetadata } from '@sim/db/schema' +import type { CredentialGroupOAuthContext } from '@/lib/credential-groups/enrollments' +import type { CredentialGroupOAuthAttempt } from '@/lib/credential-groups/oauth-state' +import type { CredentialGroupProvider } from '@/lib/credential-groups/providers' +import type { DbOrTx } from '@/lib/db/types' +import type { RefreshTokenResult } from '@/lib/oauth' + +export interface CredentialGroupProviderPolicy { + provider: CredentialGroupProvider + providerId: string + authorizationAppId: string + requiredScopes: string[] + scopeVersion: number +} + +export function credentialGroupScopePolicyVersion(scopes: string[]): number { + const digest = createHash('sha256') + .update([...new Set(scopes)].sort().join('\0')) + .digest() + const version = digest.readUInt32BE(0) & 0x7fffffff + return version || 1 +} + +export interface VerifiedCredentialGroupGrant { + providerId: string + providerSubjectId: string + providerTenantId: string | null + displayName: string + metadata: ManagedOAuthProviderMetadata + accessToken: string + refreshToken?: string + grantedScopes: string[] + accessTokenExpiresAt: Date | null + refreshTokenExpiresAt: Date | null +} + +export interface PreparedCredentialGroupAuthorization { + redirectUri: string + codeVerifier?: string + buildAuthorizationUrl(params: { state: string; nonce: string }): string | Promise +} + +export interface CredentialGroupProviderAdapter { + provider: CredentialGroupProvider + requiresRefreshToken: boolean + getPolicy( + option: Pick | undefined, + context: { + workspaceId: string + credentialGroupId?: string + authorizationAppId?: string + executor?: DbOrTx + } + ): Promise + prepareAuthorization( + context: CredentialGroupOAuthContext, + policy: CredentialGroupProviderPolicy + ): Promise + exchangeAndVerify(params: { + context: CredentialGroupOAuthContext + attempt: CredentialGroupOAuthAttempt + code: string + policy: CredentialGroupProviderPolicy + }): Promise + hasRequiredScopes(grantedScopes: string[], requiredScopes: string[]): boolean + refreshToken(refreshToken: string): Promise + isTerminalRefreshError(errorCode: string | undefined): boolean +} + +export class CredentialGroupProviderConfigurationError extends Error { + constructor(message: string) { + super(message) + this.name = 'CredentialGroupProviderConfigurationError' + } +} + +export class CredentialGroupOAuthError extends Error { + constructor( + message: string, + readonly statusCode: 400 | 401 | 403 | 404 | 409 | 502 | 503 + ) { + super(message) + this.name = 'CredentialGroupOAuthError' + } +} diff --git a/apps/sim/lib/credential-groups/provider-configuration.ts b/apps/sim/lib/credential-groups/provider-configuration.ts new file mode 100644 index 00000000000..40ea17614ea --- /dev/null +++ b/apps/sim/lib/credential-groups/provider-configuration.ts @@ -0,0 +1,144 @@ +import { db } from '@sim/db' +import { credentialGroup } from '@sim/db/schema' +import { getErrorMessage } from '@sim/utils/errors' +import { and, eq, sql } from 'drizzle-orm' +import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' +import type { DbOrTx } from '@/lib/db/types' + +const CREDENTIAL_GROUP_PROVIDER_CONFIGURATION_TYPE = + 'credential-group-provider-configuration' as const +const CREDENTIAL_GROUP_PROVIDER_CONFIGURATION_VERSION = 1 as const + +export interface SlackCredentialGroupConfiguration { + slackBotCredentialId: string + clientId: string + clientSecret: string + appId: string + teamId: string + scopes: string[] + verifiedAt: string +} + +export interface CredentialGroupProviderConfiguration { + type: typeof CREDENTIAL_GROUP_PROVIDER_CONFIGURATION_TYPE + version: typeof CREDENTIAL_GROUP_PROVIDER_CONFIGURATION_VERSION + slack?: SlackCredentialGroupConfiguration +} + +function isSlackConfiguration(value: unknown): value is SlackCredentialGroupConfiguration { + if (!value || typeof value !== 'object') return false + const candidate = value as Record + return ( + typeof candidate.slackBotCredentialId === 'string' && + typeof candidate.clientId === 'string' && + typeof candidate.clientSecret === 'string' && + typeof candidate.appId === 'string' && + typeof candidate.teamId === 'string' && + Array.isArray(candidate.scopes) && + candidate.scopes.every((scope) => typeof scope === 'string') && + typeof candidate.verifiedAt === 'string' + ) +} + +function parseCredentialGroupProviderConfiguration( + value: unknown +): CredentialGroupProviderConfiguration { + if (!value || typeof value !== 'object') { + throw new Error('Credential Group provider configuration is malformed') + } + const candidate = value as Record + if ( + candidate.type !== CREDENTIAL_GROUP_PROVIDER_CONFIGURATION_TYPE || + candidate.version !== CREDENTIAL_GROUP_PROVIDER_CONFIGURATION_VERSION || + (candidate.slack !== undefined && !isSlackConfiguration(candidate.slack)) + ) { + throw new Error('Credential Group provider configuration is malformed') + } + return { + type: CREDENTIAL_GROUP_PROVIDER_CONFIGURATION_TYPE, + version: CREDENTIAL_GROUP_PROVIDER_CONFIGURATION_VERSION, + ...(candidate.slack ? { slack: candidate.slack as SlackCredentialGroupConfiguration } : {}), + } +} + +export function emptyCredentialGroupProviderConfiguration(): CredentialGroupProviderConfiguration { + return { + type: CREDENTIAL_GROUP_PROVIDER_CONFIGURATION_TYPE, + version: CREDENTIAL_GROUP_PROVIDER_CONFIGURATION_VERSION, + } +} + +export async function encryptCredentialGroupProviderConfiguration( + configuration: CredentialGroupProviderConfiguration +): Promise { + const parsed = parseCredentialGroupProviderConfiguration(configuration) + return (await encryptSecret(JSON.stringify(parsed))).encrypted +} + +export async function decryptCredentialGroupProviderConfiguration( + encryptedConfiguration: string | null +): Promise { + if (!encryptedConfiguration) return emptyCredentialGroupProviderConfiguration() + try { + const decrypted = await decryptSecret(encryptedConfiguration) + return parseCredentialGroupProviderConfiguration(JSON.parse(decrypted.decrypted) as unknown) + } catch (error) { + throw new Error( + `Credential Group provider configuration could not be read: ${getErrorMessage(error)}` + ) + } +} + +export async function getSlackCredentialGroupConfiguration(params: { + workspaceId: string + credentialGroupId: string + executor?: DbOrTx +}): Promise { + const executor = params.executor ?? db + const [row] = await executor + .select({ encryptedProviderConfiguration: credentialGroup.encryptedProviderConfiguration }) + .from(credentialGroup) + .where( + and( + eq(credentialGroup.id, params.credentialGroupId), + eq(credentialGroup.workspaceId, params.workspaceId) + ) + ) + .limit(1) + if (!row) return null + const configuration = await decryptCredentialGroupProviderConfiguration( + row.encryptedProviderConfiguration + ) + return configuration.slack ?? null +} + +export async function listSlackCredentialGroupConfigurationsForBot(params: { + workspaceId: string + slackBotCredentialId: string +}): Promise { + const rows = await db + .select({ encryptedProviderConfiguration: credentialGroup.encryptedProviderConfiguration }) + .from(credentialGroup) + .where( + and( + eq(credentialGroup.workspaceId, params.workspaceId), + sql`${credentialGroup.options} @> ${JSON.stringify([ + { provider: 'slack', slackBotCredentialId: params.slackBotCredentialId }, + ])}::jsonb` + ) + ) + return Promise.all( + rows.map(async (row) => { + const configuration = await decryptCredentialGroupProviderConfiguration( + row.encryptedProviderConfiguration + ) + if (!configuration.slack) { + throw new Error('Credential Group Slack configuration is missing') + } + if (configuration.slack.slackBotCredentialId !== params.slackBotCredentialId) { + throw new Error('Credential Group Slack configuration does not match its custom bot') + } + return configuration.slack + }) + ) +} diff --git a/apps/sim/lib/credential-groups/provider-registry.test.ts b/apps/sim/lib/credential-groups/provider-registry.test.ts new file mode 100644 index 00000000000..e190d73b20e --- /dev/null +++ b/apps/sim/lib/credential-groups/provider-registry.test.ts @@ -0,0 +1,73 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { createGoogleManagedOAuthConnector } from '@/lib/auth/connectors/managed-oauth' +import { getCredentialGroupProviderAdapter } from '@/lib/credential-groups/provider-registry' +import { + getCredentialGroupProviderFromProviderId, + getCredentialGroupProviderService, +} from '@/lib/credential-groups/providers' +import { SLACK_MANAGED_USER_SCOPES } from '@/lib/credential-groups/slack-managed-user-scopes' + +const GMAIL_MODIFY_SCOPE = 'https://www.googleapis.com/auth/gmail.modify' +const GMAIL_SEND_SCOPE = 'https://www.googleapis.com/auth/gmail.send' +const GMAIL_LABELS_SCOPE = 'https://www.googleapis.com/auth/gmail.labels' + +describe('Credential Group provider registry', () => { + it('derives provider identity and display metadata from the OAuth service catalog', () => { + const service = getCredentialGroupProviderService('gmail') + + expect(service.name).toBe('Gmail') + expect(service.providerId).toBe('google-email') + expect(getCredentialGroupProviderFromProviderId(service.providerId)).toBe('gmail') + }) + + it('maps Google Calendar to its existing OAuth provider', () => { + const service = getCredentialGroupProviderService('google-calendar') + + expect(service.name).toBe('Google Calendar') + expect(service.providerId).toBe('google-calendar') + expect(getCredentialGroupProviderFromProviderId(service.providerId)).toBe('google-calendar') + }) + + it('uses provider-owned scope implication rules', () => { + const managedOAuth = createGoogleManagedOAuthConnector('google-email') + const canonicalScopes = getCredentialGroupProviderService('gmail').scopes + const grantedScopes = canonicalScopes.filter( + (scope) => scope !== GMAIL_SEND_SCOPE && scope !== GMAIL_LABELS_SCOPE + ) + + expect(grantedScopes).toContain(GMAIL_MODIFY_SCOPE) + expect(managedOAuth.hasRequiredScopes(grantedScopes, canonicalScopes)).toBe(true) + expect(managedOAuth.hasRequiredScopes([], canonicalScopes)).toBe(false) + }) + + it('requires the complete Google Calendar scope policy', () => { + const managedOAuth = createGoogleManagedOAuthConnector('google-calendar') + const requiredScopes = getCredentialGroupProviderService('google-calendar').scopes + + expect(managedOAuth.hasRequiredScopes(requiredScopes, requiredScopes)).toBe(true) + expect(managedOAuth.hasRequiredScopes(requiredScopes.slice(1), requiredScopes)).toBe(false) + }) + + it('maps the legacy Slack tool scope bundle to the managed-user policy', () => { + const adapter = getCredentialGroupProviderAdapter('slack') + const canonicalScopes = getCredentialGroupProviderService('slack').scopes + + expect(adapter.hasRequiredScopes([...SLACK_MANAGED_USER_SCOPES], canonicalScopes)).toBe(true) + expect( + adapter.hasRequiredScopes( + SLACK_MANAGED_USER_SCOPES.filter((scope) => scope !== 'chat:write'), + canonicalScopes + ) + ).toBe(false) + expect(adapter.hasRequiredScopes(['chat:write'], ['chat:write'])).toBe(true) + }) + + it('fails fast for an unregistered managed provider ID', () => { + expect(() => getCredentialGroupProviderFromProviderId('unknown-provider')).toThrow( + 'Unsupported managed credential provider' + ) + }) +}) diff --git a/apps/sim/lib/credential-groups/provider-registry.ts b/apps/sim/lib/credential-groups/provider-registry.ts new file mode 100644 index 00000000000..5ec9e84a916 --- /dev/null +++ b/apps/sim/lib/credential-groups/provider-registry.ts @@ -0,0 +1,28 @@ +import type { CredentialGroupProviderAdapter } from '@/lib/credential-groups/provider-adapter' +import { + type CredentialGroupProvider, + getCredentialGroupProviderFromProviderId, +} from '@/lib/credential-groups/providers' +import { slackCredentialGroupProviderAdapter } from '@/lib/credential-groups/slack-provider' +import { createStandardOAuthCredentialGroupProviderAdapter } from '@/lib/credential-groups/standard-oauth-provider' + +const CREDENTIAL_GROUP_PROVIDER_ADAPTERS: Record< + CredentialGroupProvider, + CredentialGroupProviderAdapter +> = { + gmail: createStandardOAuthCredentialGroupProviderAdapter('gmail'), + 'google-calendar': createStandardOAuthCredentialGroupProviderAdapter('google-calendar'), + slack: slackCredentialGroupProviderAdapter, +} + +export function getCredentialGroupProviderAdapter( + provider: CredentialGroupProvider +): CredentialGroupProviderAdapter { + return CREDENTIAL_GROUP_PROVIDER_ADAPTERS[provider] +} + +export function getCredentialGroupProviderAdapterByProviderId( + providerId: string +): CredentialGroupProviderAdapter { + return getCredentialGroupProviderAdapter(getCredentialGroupProviderFromProviderId(providerId)) +} diff --git a/apps/sim/lib/credential-groups/providers.ts b/apps/sim/lib/credential-groups/providers.ts new file mode 100644 index 00000000000..ac9162a06d2 --- /dev/null +++ b/apps/sim/lib/credential-groups/providers.ts @@ -0,0 +1,84 @@ +import type { OAuthServiceConfig } from '@/lib/oauth' +import { getServiceConfigByServiceId } from '@/lib/oauth' + +export const CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS = ['gmail', 'google-calendar'] as const + +export type CredentialGroupStandardOAuthProvider = + (typeof CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS)[number] + +export const CREDENTIAL_GROUP_PROVIDER_IDS = [ + ...CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS, + 'slack', +] as const + +export type CredentialGroupProvider = (typeof CREDENTIAL_GROUP_PROVIDER_IDS)[number] + +export interface CredentialGroupProviderSupport { + serviceId: string + description: string + configuration: 'oauth' | 'slack_custom_bot' +} + +const CREDENTIAL_GROUP_PROVIDER_SUPPORT: Record< + CredentialGroupProvider, + CredentialGroupProviderSupport +> = { + gmail: { + serviceId: 'gmail', + description: 'Let each person connect one Gmail account', + configuration: 'oauth', + }, + 'google-calendar': { + serviceId: 'google-calendar', + description: 'Let each person connect one Google Calendar account', + configuration: 'oauth', + }, + slack: { + serviceId: 'slack', + description: 'Let each person connect through your custom Slack app', + configuration: 'slack_custom_bot', + }, +} + +export function isCredentialGroupProvider(value: string): value is CredentialGroupProvider { + return CREDENTIAL_GROUP_PROVIDER_IDS.some((provider) => provider === value) +} + +export function isCredentialGroupStandardOAuthProvider( + value: CredentialGroupProvider +): value is CredentialGroupStandardOAuthProvider { + return CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS.some((provider) => provider === value) +} + +export function getCredentialGroupProviderService( + provider: CredentialGroupProvider +): OAuthServiceConfig { + const support = CREDENTIAL_GROUP_PROVIDER_SUPPORT[provider] + const service = getServiceConfigByServiceId(support.serviceId) + if (!service) { + throw new Error( + `Credential Group provider ${provider} references missing OAuth service ${support.serviceId}` + ) + } + return service +} + +export function getCredentialGroupProviderSupport( + provider: CredentialGroupProvider +): CredentialGroupProviderSupport { + return CREDENTIAL_GROUP_PROVIDER_SUPPORT[provider] +} + +export function getCredentialGroupProviderId(provider: CredentialGroupProvider): string { + return getCredentialGroupProviderService(provider).providerId +} + +export function getCredentialGroupProviderFromProviderId( + providerId: string +): CredentialGroupProvider { + const provider = CREDENTIAL_GROUP_PROVIDER_IDS.find( + (candidate) => getCredentialGroupProviderId(candidate) === providerId + ) + if (!provider) throw new Error(`Unsupported managed credential provider: ${providerId}`) + return provider +} diff --git a/apps/sim/lib/credential-groups/rate-limit.ts b/apps/sim/lib/credential-groups/rate-limit.ts new file mode 100644 index 00000000000..a16f46304c7 --- /dev/null +++ b/apps/sim/lib/credential-groups/rate-limit.ts @@ -0,0 +1,127 @@ +import { NextResponse } from 'next/server' +import { RateLimitError, RateLimiter, type TokenBucketConfig } from '@/lib/core/rate-limiter' +import { getClientIp } from '@/lib/core/utils/request' + +const rateLimiter = new RateLimiter() + +const CREDENTIAL_GROUP_INVITATION_RATE_LIMIT = { + maxTokens: 5, + refillRate: 5, + refillIntervalMs: 60_000, +} as const + +function credentialGroupInvitationRateLimitKey(workspaceId: string): string { + return `route:credential-group-invitations:workspace:${workspaceId}` +} + +const PUBLIC_ENROLLMENT_METADATA_RATE_LIMIT: TokenBucketConfig = { + maxTokens: 120, + refillRate: 120, + refillIntervalMs: 60_000, +} + +const PUBLIC_OAUTH_START_RATE_LIMIT: TokenBucketConfig = { + maxTokens: 10, + refillRate: 10, + refillIntervalMs: 15 * 60_000, +} + +const PUBLIC_OAUTH_CALLBACK_RATE_LIMIT: TokenBucketConfig = { + maxTokens: 60, + refillRate: 60, + refillIntervalMs: 15 * 60_000, +} + +type PublicCredentialGroupRateLimitScope = + | 'metadata' + | 'oauth-start' + | 'oauth-callback' + | 'complete' + +function rateLimitResponse(retryAfterMs: number | undefined, fallbackMs: number): NextResponse { + const retryAfterSeconds = Math.ceil((retryAfterMs ?? fallbackMs) / 1000) + return NextResponse.json( + { error: 'Too many requests. Please try again later.' }, + { + status: 429, + headers: { + 'Retry-After': String(retryAfterSeconds), + 'Cache-Control': 'no-store', + }, + } + ) +} + +function configForPublicScope(scope: PublicCredentialGroupRateLimitScope): TokenBucketConfig { + if (scope === 'metadata') return PUBLIC_ENROLLMENT_METADATA_RATE_LIMIT + if (scope === 'oauth-start' || scope === 'complete') return PUBLIC_OAUTH_START_RATE_LIMIT + return PUBLIC_OAUTH_CALLBACK_RATE_LIMIT +} + +/** Per-IP guard for unauthenticated enrollment reads and OAuth endpoints. */ +export async function enforcePublicCredentialGroupIpRateLimit( + request: { headers: { get(name: string): string | null } }, + scope: PublicCredentialGroupRateLimitScope +): Promise { + const config = configForPublicScope(scope) + const ip = getClientIp(request) + const result = await rateLimiter.checkRateLimitDirect( + `public-credential-group:${scope}:ip:${ip}`, + config, + { failClosed: scope !== 'metadata' } + ) + return result.allowed ? null : rateLimitResponse(result.retryAfterMs, config.refillIntervalMs) +} + +/** Prevents one leaked invitation from starting unbounded provider consent flows. */ +export async function enforceCredentialGroupEnrollmentOAuthRateLimit( + enrollmentId: string +): Promise { + const result = await rateLimiter.checkRateLimitDirect( + `public-credential-group:oauth-start:enrollment:${enrollmentId}`, + PUBLIC_OAUTH_START_RATE_LIMIT, + { failClosed: true } + ) + return result.allowed + ? null + : rateLimitResponse(result.retryAfterMs, PUBLIC_OAUTH_START_RATE_LIMIT.refillIntervalMs) +} + +export class CredentialGroupInvitationRateLimitError extends RateLimitError { + constructor( + readonly retryAfterSeconds: number, + readonly resetAt: Date + ) { + super('Rate limit exceeded') + this.name = 'CredentialGroupInvitationRateLimitError' + } +} + +/** Shared workspace admission for HTTP batch invitations and resends. */ +export async function enforceCredentialGroupInvitationRouteRateLimit( + workspaceId: string +): Promise { + const result = await rateLimiter.checkRateLimitDirect( + credentialGroupInvitationRateLimitKey(workspaceId), + CREDENTIAL_GROUP_INVITATION_RATE_LIMIT, + { failClosed: true } + ) + if (!result.allowed) { + throw new CredentialGroupInvitationRateLimitError( + Math.max(1, Math.ceil((result.resetAt.getTime() - Date.now()) / 1000)), + result.resetAt + ) + } +} + +/** Applies the shared invitation budget to non-HTTP workflow execution. */ +export async function enforceCredentialGroupInvitationExecutionRateLimit( + workspaceId: string +): Promise { + const result = await rateLimiter.checkRateLimitDirect( + credentialGroupInvitationRateLimitKey(workspaceId), + CREDENTIAL_GROUP_INVITATION_RATE_LIMIT, + { failClosed: true } + ) + if (!result.allowed) throw new RateLimitError('Credential Group invitation rate limit exceeded') +} diff --git a/apps/sim/lib/credential-groups/service.test.ts b/apps/sim/lib/credential-groups/service.test.ts new file mode 100644 index 00000000000..c73afcc2bee --- /dev/null +++ b/apps/sim/lib/credential-groups/service.test.ts @@ -0,0 +1,89 @@ +/** + * @vitest-environment node + */ +import { + dbChainMock, + dbChainMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetPolicy } = vi.hoisted(() => ({ + mockGetPolicy: vi.fn(), +})) + +vi.mock('@/lib/credential-groups/provider-registry', () => ({ + getCredentialGroupProviderAdapter: () => ({ getPolicy: mockGetPolicy }), +})) + +import { updateCredentialGroup } from '@/lib/credential-groups/service' + +describe('Credential Group service', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('validates provider policy through the active update transaction', async () => { + const option = { + id: 'option-1', + provider: 'slack' as const, + label: 'Slack', + slackBotCredentialId: 'bot-1', + authorizationAppId: 'slack:A123:T123', + requiredScopes: ['chat:write'], + scopeVersion: 1, + required: true, + status: 'active' as const, + } + const existing = { + id: 'group-1', + workspaceId: 'workspace-1', + publicId: 'public-1', + name: 'Support accounts', + description: null, + options: [option], + encryptedProviderConfiguration: null, + status: 'active' as const, + createdBy: 'user-1', + createdAt: new Date('2026-08-13T00:00:00Z'), + updatedAt: new Date('2026-08-13T00:00:00Z'), + } + queueTableRows(schemaMock.credentialGroup, [existing]) + dbChainMockFns.returning.mockResolvedValueOnce([ + { ...existing, updatedAt: new Date('2026-08-13T01:00:00Z') }, + ]) + mockGetPolicy.mockResolvedValue({ + provider: 'slack', + providerId: 'slack', + authorizationAppId: option.authorizationAppId, + requiredScopes: option.requiredScopes, + scopeVersion: option.scopeVersion, + }) + + await expect( + updateCredentialGroup('workspace-1', 'group-1', { + options: [ + { + id: option.id, + provider: option.provider, + label: option.label, + slackBotCredentialId: option.slackBotCredentialId, + required: option.required, + }, + ], + }) + ).resolves.toMatchObject({ id: 'group-1' }) + + expect(mockGetPolicy).toHaveBeenCalledWith( + expect.objectContaining({ slackBotCredentialId: 'bot-1' }), + { + workspaceId: 'workspace-1', + credentialGroupId: 'group-1', + executor: dbChainMock.db, + } + ) + }) +}) diff --git a/apps/sim/lib/credential-groups/service.ts b/apps/sim/lib/credential-groups/service.ts new file mode 100644 index 00000000000..1ff48a9a7b1 --- /dev/null +++ b/apps/sim/lib/credential-groups/service.ts @@ -0,0 +1,268 @@ +import { db } from '@sim/db' +import { + type CredentialGroupOptionConfig, + credential, + credentialGroup, + credentialGroupEnrollment, +} from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { and, desc, eq, inArray } from 'drizzle-orm' +import { credentialGroupScopePolicyVersion } from '@/lib/credential-groups/provider-adapter' +import { decryptCredentialGroupProviderConfiguration } from '@/lib/credential-groups/provider-configuration' +import { getCredentialGroupProviderAdapter } from '@/lib/credential-groups/provider-registry' +import { isCredentialGroupProvider } from '@/lib/credential-groups/providers' +import { SLACK_MANAGED_USER_SCOPES } from '@/lib/credential-groups/slack-managed-user-scopes' +import type { + CreateCredentialGroupInput, + CredentialGroupOptionInput, + CredentialGroupRecord, + UpdateCredentialGroupInput, +} from '@/lib/credential-groups/types' +import type { DbOrTx } from '@/lib/db/types' + +function scopesEqual(left: string[], right: string[]): boolean { + const normalizedLeft = [...new Set(left)].sort() + const normalizedRight = [...new Set(right)].sort() + return ( + normalizedLeft.length === normalizedRight.length && + normalizedLeft.every((scope, index) => scope === normalizedRight[index]) + ) +} + +async function buildOption( + workspaceId: string, + option: CredentialGroupOptionInput, + credentialGroupId?: string, + executor: DbOrTx = db +): Promise { + const providerConfig = await getCredentialGroupProviderAdapter(option.provider).getPolicy( + option, + { workspaceId, credentialGroupId, executor } + ) + return { + id: generateId(), + provider: option.provider, + label: option.label, + ...(option.provider === 'slack' ? { slackBotCredentialId: option.slackBotCredentialId } : {}), + authorizationAppId: providerConfig.authorizationAppId, + requiredScopes: providerConfig.requiredScopes, + scopeVersion: providerConfig.scopeVersion, + required: option.required, + status: 'active', + } +} + +async function updateOptions( + workspaceId: string, + credentialGroupId: string, + inputs: NonNullable, + existingOptions: CredentialGroupOptionConfig[], + executor: DbOrTx +): Promise { + const existingById = new Map(existingOptions.map((option) => [option.id, option])) + return Promise.all( + inputs.map(async (input) => { + if (!input.id) return buildOption(workspaceId, input, credentialGroupId, executor) + const existing = existingById.get(input.id) + if (!existing) throw new Error(`Credential group option ${input.id} does not exist`) + if (input.provider !== existing.provider) { + throw new Error('A credential option provider cannot be changed; add a new option instead') + } + + const providerConfig = await getCredentialGroupProviderAdapter(input.provider).getPolicy( + input, + { workspaceId, credentialGroupId, executor } + ) + return { + id: existing.id, + provider: existing.provider, + label: input.label, + ...(input.provider === 'slack' ? { slackBotCredentialId: input.slackBotCredentialId } : {}), + authorizationAppId: providerConfig.authorizationAppId, + requiredScopes: providerConfig.requiredScopes, + scopeVersion: providerConfig.scopeVersion, + required: input.required, + status: existing.status, + } + }) + ) +} + +async function toCredentialGroup( + row: typeof credentialGroup.$inferSelect +): Promise { + const providerConfiguration = await decryptCredentialGroupProviderConfiguration( + row.encryptedProviderConfiguration + ) + return { + id: row.id, + workspaceId: row.workspaceId, + name: row.name, + description: row.description, + options: row.options.map((option) => { + if (!isCredentialGroupProvider(option.provider)) { + throw new Error(`Unsupported Credential Group provider: ${option.provider}`) + } + const common = { + id: option.id, + label: option.label, + required: option.required, + status: option.status, + } + if (option.provider !== 'slack') { + return { ...common, provider: option.provider, configurationStatus: 'ready' as const } + } + if (!option.slackBotCredentialId) { + throw new Error(`Slack credential option ${option.id} has no custom bot`) + } + return { + ...common, + provider: 'slack' as const, + slackBotCredentialId: option.slackBotCredentialId, + configurationStatus: + !providerConfiguration.slack || + providerConfiguration.slack.slackBotCredentialId !== option.slackBotCredentialId + ? ('not_configured' as const) + : option.scopeVersion !== + credentialGroupScopePolicyVersion([...SLACK_MANAGED_USER_SCOPES]) || + !SLACK_MANAGED_USER_SCOPES.every((scope) => + providerConfiguration.slack?.scopes.includes(scope) + ) + ? ('needs_update' as const) + : ('ready' as const), + } + }), + status: row.status, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + } +} + +export async function listCredentialGroups(workspaceId: string): Promise { + const rows = await db + .select() + .from(credentialGroup) + .where(eq(credentialGroup.workspaceId, workspaceId)) + .orderBy(desc(credentialGroup.createdAt)) + return Promise.all(rows.map(toCredentialGroup)) +} + +export async function getCredentialGroup( + workspaceId: string, + groupId: string +): Promise { + const [row] = await db + .select() + .from(credentialGroup) + .where(and(eq(credentialGroup.id, groupId), eq(credentialGroup.workspaceId, workspaceId))) + .limit(1) + return row ? toCredentialGroup(row) : null +} + +export async function createCredentialGroup( + workspaceId: string, + userId: string, + body: CreateCredentialGroupInput +): Promise { + const now = new Date() + const options = await Promise.all(body.options.map((option) => buildOption(workspaceId, option))) + const [created] = await db + .insert(credentialGroup) + .values({ + id: generateId(), + workspaceId, + publicId: generateId(), + name: body.name, + description: body.description || null, + options, + status: 'active', + createdBy: userId, + createdAt: now, + updatedAt: now, + }) + .returning() + + if (!created) throw new Error('Credential group insert returned no row') + return toCredentialGroup(created) +} + +export async function deleteCredentialGroup( + workspaceId: string, + groupId: string +): Promise { + const deleted = await db + .delete(credentialGroup) + .where(and(eq(credentialGroup.id, groupId), eq(credentialGroup.workspaceId, workspaceId))) + .returning({ id: credentialGroup.id }) + return deleted.length > 0 +} + +export async function updateCredentialGroup( + workspaceId: string, + groupId: string, + body: UpdateCredentialGroupInput +): Promise { + return db.transaction(async (tx) => { + const [existing] = await tx + .select() + .from(credentialGroup) + .where(and(eq(credentialGroup.id, groupId), eq(credentialGroup.workspaceId, workspaceId))) + .limit(1) + .for('update') + if (!existing) return null + + const nextOptions = + body.options !== undefined + ? await updateOptions(workspaceId, groupId, body.options, existing.options, tx) + : existing.options + const keepsSlack = nextOptions.some((option) => option.provider === 'slack') + const encryptedProviderConfiguration = keepsSlack + ? existing.encryptedProviderConfiguration + : null + const nextOptionById = new Map(nextOptions.map((option) => [option.id, option])) + const invalidatedOptionIds = existing.options + .filter((option) => { + const next = nextOptionById.get(option.id) + return ( + !next || + next.authorizationAppId !== option.authorizationAppId || + next.scopeVersion !== option.scopeVersion || + !scopesEqual(next.requiredScopes, option.requiredScopes) || + body.status === 'disabled' + ) + }) + .map((option) => option.id) + + const [updated] = await tx + .update(credentialGroup) + .set({ + ...(body.name !== undefined ? { name: body.name } : {}), + ...(body.description !== undefined ? { description: body.description || null } : {}), + ...(body.options !== undefined ? { options: nextOptions } : {}), + ...(body.options !== undefined ? { encryptedProviderConfiguration } : {}), + ...(body.status !== undefined ? { status: body.status } : {}), + updatedAt: new Date(), + }) + .where(and(eq(credentialGroup.id, groupId), eq(credentialGroup.workspaceId, workspaceId))) + .returning() + + if (!updated) throw new Error('Credential group update returned no row') + if (invalidatedOptionIds.length > 0) { + const enrollmentIds = tx + .select({ id: credentialGroupEnrollment.id }) + .from(credentialGroupEnrollment) + .where(eq(credentialGroupEnrollment.credentialGroupId, groupId)) + await tx + .update(credential) + .set({ managedOauthStatus: 'needs_reauth', updatedAt: new Date() }) + .where( + and( + eq(credential.type, 'managed_oauth'), + inArray(credential.credentialGroupEnrollmentId, enrollmentIds), + inArray(credential.credentialGroupOptionId, invalidatedOptionIds) + ) + ) + } + return toCredentialGroup(updated) + }) +} diff --git a/apps/sim/lib/credential-groups/slack-managed-user-scopes.ts b/apps/sim/lib/credential-groups/slack-managed-user-scopes.ts new file mode 100644 index 00000000000..9e6ec9990ce --- /dev/null +++ b/apps/sim/lib/credential-groups/slack-managed-user-scopes.ts @@ -0,0 +1,29 @@ +/** + * User-token policy requested and verified by Credential Group Slack OAuth. + * This is independent of the custom bot manifest and its configuration UI. + */ +export const SLACK_MANAGED_USER_SCOPES = [ + 'channels:history', + 'channels:read', + 'channels:write', + 'canvases:read', + 'canvases:write', + 'chat:write', + 'files:read', + 'files:write', + 'groups:history', + 'groups:read', + 'groups:write', + 'im:history', + 'im:read', + 'im:write', + 'mpim:history', + 'mpim:read', + 'mpim:write', + 'reactions:read', + 'reactions:write', + 'users.profile:read', + 'users.profile:write', + 'users:read', + 'users:read.email', +] as const diff --git a/apps/sim/lib/credential-groups/slack-managed-users.test.ts b/apps/sim/lib/credential-groups/slack-managed-users.test.ts new file mode 100644 index 00000000000..9e13d3ce11c --- /dev/null +++ b/apps/sim/lib/credential-groups/slack-managed-users.test.ts @@ -0,0 +1,399 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { attempts, redis } = vi.hoisted(() => { + const attempts = new Map() + return { + attempts, + redis: { + set: vi.fn(async (key: string, value: string) => { + if (attempts.has(key)) return null + attempts.set(key, value) + return 'OK' + }), + get: vi.fn(async (key: string) => attempts.get(key) ?? null), + eval: vi.fn(async (_script: string, _count: number, key: string) => { + const value = attempts.get(key) ?? null + attempts.delete(key) + return value + }), + }, + } +}) + +vi.mock('@/lib/core/config/redis', () => ({ getRedisClient: () => redis })) +vi.mock('@/lib/core/security/encryption', () => ({ + encryptSecret: vi.fn(async (value: string) => ({ + encrypted: `encrypted:${Buffer.from(value).toString('base64')}`, + })), + decryptSecret: vi.fn(async (value: string) => ({ + decrypted: + value === 'encrypted-bot' + ? JSON.stringify({ + type: 'slack_custom_bot', + signingSecret: 'signing-secret', + botToken: 'xoxb-token', + teamId: 'T123', + }) + : Buffer.from(value.replace(/^encrypted:/, ''), 'base64').toString(), + })), +})) +vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://sim.ai' })) + +import { SLACK_MANAGED_USER_SCOPES } from '@/lib/credential-groups/slack-managed-user-scopes' +import { + consumeSlackManagedUsersAttempt, + createSlackManagedUsersAttempt, + exchangeAndConfigureSlackManagedUsers, + exchangeSlackUserAuthorization, + loadSlackManagedUsersAttempt, + verifySlackCustomBotAppIdentity, + verifySlackUserIdentity, +} from '@/lib/credential-groups/slack-managed-users' + +function slackResponse(value: Record): Response { + return new Response(JSON.stringify(value), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) +} + +describe('Slack managed-user authorization', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + attempts.clear() + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('binds the bot token to Slack app and workspace identities', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + slackResponse({ ok: true, team_id: 'T123', user_id: 'U123', bot_id: 'B123' }) + ) + .mockResolvedValueOnce(slackResponse({ ok: true, bot: { id: 'B123', app_id: 'A123' } })) + vi.stubGlobal('fetch', fetchMock) + + await expect(verifySlackCustomBotAppIdentity('xoxb-token')).resolves.toEqual({ + appId: 'A123', + teamId: 'T123', + }) + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + 'https://slack.com/api/bots.info', + expect.objectContaining({ body: new URLSearchParams({ bot: 'B123' }) }) + ) + }) + + it('encrypts setup secrets and consumes the short-lived state once', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([ + { + id: '22222222-2222-4222-8222-222222222222', + updatedAt: new Date('2026-08-12T00:00:00Z'), + }, + ]) + .mockResolvedValueOnce([ + { + id: '11111111-1111-4111-8111-111111111111', + name: 'Support bot', + updatedAt: new Date('2026-08-12T00:00:00Z'), + encryptedServiceAccountKey: 'encrypted-bot', + }, + ]) + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValueOnce( + slackResponse({ ok: true, team_id: 'T123', user_id: 'U123', bot_id: 'B123' }) + ) + .mockResolvedValueOnce(slackResponse({ ok: true, bot: { app_id: 'A123' } })) + ) + + const created = await createSlackManagedUsersAttempt({ + workspaceId: 'workspace-1', + userId: 'user-1', + credentialGroupId: '22222222-2222-4222-8222-222222222222', + slackBotCredentialId: '11111111-1111-4111-8111-111111111111', + clientId: 'client-id', + clientSecret: 'client-secret', + }) + + expect(created.authorizationUrl).toContain('team=T123') + expect(created.authorizationUrl).toContain('user_scope=channels%3Ahistory') + expect([...attempts.values()][0]).not.toContain('client-secret') + await expect(loadSlackManagedUsersAttempt(created.state)).resolves.toMatchObject({ + credentialGroupId: '22222222-2222-4222-8222-222222222222', + slackBotCredentialId: '11111111-1111-4111-8111-111111111111', + expectedAppId: 'A123', + expectedTeamId: 'T123', + clientSecret: 'client-secret', + }) + await expect(consumeSlackManagedUsersAttempt(created.state)).resolves.toMatchObject({ + clientId: 'client-id', + }) + await expect(consumeSlackManagedUsersAttempt(created.state)).resolves.toBeNull() + }) + + it('returns an actionable error when the custom bot lacks users:read', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValueOnce(slackResponse({ ok: true, team_id: 'T123', bot_id: 'B123' })) + .mockResolvedValueOnce( + slackResponse({ ok: false, error: 'missing_scope', needed: 'users:read' }) + ) + ) + + await expect(verifySlackCustomBotAppIdentity('xoxb-token')).rejects.toThrow( + 'Add the users:read bot scope' + ) + }) + + it('stores Slack OAuth client configuration on the Credential Group', async () => { + const updatedAt = new Date('2026-08-12T00:00:00Z') + queueTableRows(schemaMock.credentialGroup, [ + { + id: '22222222-2222-4222-8222-222222222222', + workspaceId: 'workspace-1', + name: 'Support accounts', + options: [], + encryptedProviderConfiguration: null, + updatedAt, + }, + ]) + queueTableRows(schemaMock.credential, [ + { + id: '11111111-1111-4111-8111-111111111111', + updatedAt, + encryptedServiceAccountKey: 'encrypted-bot', + }, + ]) + dbChainMockFns.returning + .mockResolvedValueOnce([{ id: '11111111-1111-4111-8111-111111111111' }]) + .mockResolvedValueOnce([{ id: '22222222-2222-4222-8222-222222222222' }]) + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + slackResponse({ + ok: true, + app_id: 'A123', + team: { id: 'T123', name: 'Sim' }, + authed_user: { + id: 'U123', + access_token: 'xoxp-token', + token_type: 'user', + scope: SLACK_MANAGED_USER_SCOPES.join(','), + }, + }) + ) + .mockResolvedValueOnce(slackResponse({ ok: true, team_id: 'T123', user_id: 'U123' })) + .mockResolvedValueOnce( + slackResponse({ + ok: true, + user: { id: 'U123', profile: { email: 'theo@sim.ai' } }, + }) + ) + .mockResolvedValueOnce(slackResponse({ ok: true, revoked: true })) + vi.stubGlobal('fetch', fetchMock) + + await expect( + exchangeAndConfigureSlackManagedUsers({ + attempt: { + workspaceId: 'workspace-1', + userId: 'user-1', + credentialGroupId: '22222222-2222-4222-8222-222222222222', + credentialGroupUpdatedAt: updatedAt.getTime(), + slackBotCredentialId: '11111111-1111-4111-8111-111111111111', + slackBotCredentialUpdatedAt: updatedAt.getTime(), + expectedAppId: 'A123', + expectedTeamId: 'T123', + clientId: 'client-id', + clientSecret: 'client-secret', + redirectUri: 'https://sim.ai/callback', + createdAt: Date.now(), + }, + code: 'single-use-code', + }) + ).resolves.toMatchObject({ + credentialGroupId: '22222222-2222-4222-8222-222222222222', + slackBotCredentialId: '11111111-1111-4111-8111-111111111111', + }) + expect(dbChainMockFns.set).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ authorizationAppId: null, managedOauthScopeVersion: null }) + ) + expect(dbChainMockFns.set).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + encryptedProviderConfiguration: expect.any(String), + options: [ + expect.objectContaining({ + provider: 'slack', + slackBotCredentialId: '11111111-1111-4111-8111-111111111111', + }), + ], + }) + ) + expect(JSON.stringify(dbChainMockFns.set.mock.calls[1])).not.toContain('client-secret') + }) + + it('requires Slack to attest a user token, app, team, user, and scopes', async () => { + const fetchMock = vi.fn().mockResolvedValue( + slackResponse({ + ok: true, + app_id: 'A123', + team: { id: 'T123', name: 'Sim' }, + authed_user: { + id: 'U123', + access_token: 'xoxp-token', + token_type: 'user', + scope: 'users:read,users:read.email', + }, + }) + ) + vi.stubGlobal('fetch', fetchMock) + + const result = await exchangeSlackUserAuthorization({ + clientId: 'client-id', + clientSecret: 'client-secret', + code: 'single-use-code', + redirectUri: 'https://sim.ai/callback', + }) + + expect(result).toMatchObject({ + appId: 'A123', + teamId: 'T123', + userId: 'U123', + accessToken: 'xoxp-token', + tokenType: 'user', + }) + expect(fetchMock).toHaveBeenCalledWith( + 'https://slack.com/api/oauth.v2.access', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ Authorization: expect.stringMatching(/^Basic /) }), + }) + ) + }) + + it('fails closed when Slack omits the user token type', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + slackResponse({ + ok: true, + app_id: 'A123', + team: { id: 'T123', name: 'Sim' }, + authed_user: { + id: 'U123', + access_token: 'xoxp-token', + scope: 'users:read', + }, + }) + ) + ) + + await expect( + exchangeSlackUserAuthorization({ + clientId: 'client-id', + clientSecret: 'client-secret', + code: 'single-use-code', + redirectUri: 'https://sim.ai/callback', + }) + ).rejects.toThrow('Slack returned an incomplete authorization') + }) + + it('revokes the setup token and stores nothing when client credentials target another app', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + slackResponse({ + ok: true, + app_id: 'A999', + team: { id: 'T123', name: 'Sim' }, + authed_user: { + id: 'U123', + access_token: 'xoxp-token', + token_type: 'user', + scope: SLACK_MANAGED_USER_SCOPES.join(','), + }, + }) + ) + .mockResolvedValueOnce(slackResponse({ ok: true, revoked: true })) + vi.stubGlobal('fetch', fetchMock) + + await expect( + exchangeAndConfigureSlackManagedUsers({ + attempt: { + workspaceId: 'workspace-1', + userId: 'user-1', + credentialGroupId: '22222222-2222-4222-8222-222222222222', + credentialGroupUpdatedAt: new Date('2026-08-12T00:00:00Z').getTime(), + slackBotCredentialId: '11111111-1111-4111-8111-111111111111', + slackBotCredentialUpdatedAt: new Date('2026-08-12T00:00:00Z').getTime(), + expectedAppId: 'A123', + expectedTeamId: 'T123', + clientId: 'client-id', + clientSecret: 'client-secret', + redirectUri: 'https://sim.ai/callback', + createdAt: Date.now(), + }, + code: 'single-use-code', + }) + ).rejects.toThrow('different Slack app or workspace') + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + 'https://slack.com/api/auth.revoke', + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer xoxp-token' }), + }) + ) + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() + }) + + it('verifies the token identity and reads cosmetic profile metadata', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(slackResponse({ ok: true, team_id: 'T123', user_id: 'U123' })) + .mockResolvedValueOnce( + slackResponse({ + ok: true, + user: { + id: 'U123', + name: 'theo', + profile: { + email: 'theo@sim.ai', + display_name: 'Theo', + image_192: 'https://avatars.slack-edge.com/theo.png', + }, + }, + }) + ) + vi.stubGlobal('fetch', fetchMock) + + await expect( + verifySlackUserIdentity({ + accessToken: 'xoxp-token', + expectedTeamId: 'T123', + expectedUserId: 'U123', + }) + ).resolves.toEqual({ + userId: 'U123', + teamId: 'T123', + email: 'theo@sim.ai', + displayName: 'Theo', + avatarUrl: 'https://avatars.slack-edge.com/theo.png', + username: 'theo', + }) + }) +}) diff --git a/apps/sim/lib/credential-groups/slack-managed-users.ts b/apps/sim/lib/credential-groups/slack-managed-users.ts new file mode 100644 index 00000000000..3d7368d6f3c --- /dev/null +++ b/apps/sim/lib/credential-groups/slack-managed-users.ts @@ -0,0 +1,759 @@ +import { Buffer } from 'node:buffer' +import { db } from '@sim/db' +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, inArray, sql } from 'drizzle-orm' +import { getRedisClient } from '@/lib/core/config/redis' +import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { credentialGroupScopePolicyVersion } from '@/lib/credential-groups/provider-adapter' +import { + decryptCredentialGroupProviderConfiguration, + encryptCredentialGroupProviderConfiguration, +} from '@/lib/credential-groups/provider-configuration' +import { SLACK_MANAGED_USER_SCOPES } from '@/lib/credential-groups/slack-managed-user-scopes' +import type { DbOrTx } from '@/lib/db/types' +import { SLACK_CUSTOM_BOT_PROVIDER_ID, SLACK_CUSTOM_BOT_SECRET_TYPE } from '@/lib/oauth/types' + +const logger = createLogger('SlackManagedUsers') +const SLACK_MANAGED_USERS_ATTEMPT_TTL_MS = 10 * 60 * 1000 +const SLACK_MANAGED_USERS_ATTEMPT_VERSION = 2 as const +const MAX_SLACK_RESPONSE_BYTES = 64 * 1024 +const CONSUME_SCRIPT = ` +local value = redis.call('GET', KEYS[1]) +if not value then + return nil +end +redis.call('DEL', KEYS[1]) +return value +` + +interface SlackCustomBotSecret { + type: typeof SLACK_CUSTOM_BOT_SECRET_TYPE + signingSecret: string + botToken: string + teamId: string + botUserId?: string + teamName?: string + metadata?: Record +} + +interface StoredSlackManagedUsersAttempt { + version: typeof SLACK_MANAGED_USERS_ATTEMPT_VERSION + workspaceId: string + userId: string + credentialGroupId: string + credentialGroupUpdatedAt: number + slackBotCredentialId: string + slackBotCredentialUpdatedAt: number + expectedAppId: string + expectedTeamId: string + clientId: string + encryptedClientSecret: string + redirectUri: string + createdAt: number +} + +export interface SlackManagedUsersAttempt { + workspaceId: string + userId: string + credentialGroupId: string + credentialGroupUpdatedAt: number + slackBotCredentialId: string + slackBotCredentialUpdatedAt: number + expectedAppId: string + expectedTeamId: string + clientId: string + clientSecret: string + redirectUri: string + createdAt: number +} + +export interface SlackOAuthSuccess { + appId: string + teamId: string + teamName: string + userId: string + accessToken: string + scopes: string[] + tokenType: 'user' + expiresIn?: number + refreshToken?: string +} + +export interface VerifiedSlackUserIdentity { + userId: string + teamId: string + email: string + displayName?: string + avatarUrl?: string + username?: string +} + +export class SlackManagedUsersError extends Error { + constructor( + message: string, + readonly code: + | 'invalid_state' + | 'provider_error' + | 'invalid_client' + | 'invalid_response' + | 'missing_bot_scope' + | 'token_rotation_enabled' + | 'revoke_failed' + ) { + super(message) + this.name = 'SlackManagedUsersError' + } +} + +function requireRedis() { + const redis = getRedisClient() + if (!redis) throw new Error('Slack managed-user setup requires Redis') + return redis +} + +function attemptKey(state: string): string { + return `credential-group:slack-managed-users:${sha256Hex(state)}` +} + +function isStoredAttempt(value: unknown): value is StoredSlackManagedUsersAttempt { + if (!value || typeof value !== 'object') return false + const candidate = value as Record + return ( + candidate.version === SLACK_MANAGED_USERS_ATTEMPT_VERSION && + typeof candidate.workspaceId === 'string' && + typeof candidate.userId === 'string' && + typeof candidate.credentialGroupId === 'string' && + typeof candidate.credentialGroupUpdatedAt === 'number' && + typeof candidate.slackBotCredentialId === 'string' && + typeof candidate.slackBotCredentialUpdatedAt === 'number' && + typeof candidate.expectedAppId === 'string' && + typeof candidate.expectedTeamId === 'string' && + typeof candidate.clientId === 'string' && + typeof candidate.encryptedClientSecret === 'string' && + typeof candidate.redirectUri === 'string' && + typeof candidate.createdAt === 'number' + ) +} + +function parseSlackCustomBotSecret(value: unknown): SlackCustomBotSecret { + if (!value || typeof value !== 'object') { + throw new Error('Slack custom bot secret is malformed') + } + const candidate = value as Record + if ( + candidate.type !== SLACK_CUSTOM_BOT_SECRET_TYPE || + typeof candidate.signingSecret !== 'string' || + typeof candidate.botToken !== 'string' || + typeof candidate.teamId !== 'string' + ) { + throw new Error('Slack custom bot secret is malformed') + } + return { + type: SLACK_CUSTOM_BOT_SECRET_TYPE, + signingSecret: candidate.signingSecret, + botToken: candidate.botToken, + teamId: candidate.teamId, + ...(typeof candidate.botUserId === 'string' ? { botUserId: candidate.botUserId } : {}), + ...(typeof candidate.teamName === 'string' ? { teamName: candidate.teamName } : {}), + ...(candidate.metadata && typeof candidate.metadata === 'object' + ? { metadata: candidate.metadata as Record } + : {}), + } +} + +function stringField(value: unknown, key: string): string | null { + if (!value || typeof value !== 'object') return null + const field = (value as Record)[key] + return typeof field === 'string' && field.length > 0 ? field : null +} + +async function readBoundedJson(response: Response): Promise { + const declaredLength = Number(response.headers.get('content-length')) + if (Number.isFinite(declaredLength) && declaredLength > MAX_SLACK_RESPONSE_BYTES) { + throw new SlackManagedUsersError('Slack returned an oversized response.', 'invalid_response') + } + const text = await response.text() + if (Buffer.byteLength(text, 'utf8') > MAX_SLACK_RESPONSE_BYTES) { + throw new SlackManagedUsersError('Slack returned an oversized response.', 'invalid_response') + } + try { + return JSON.parse(text) as unknown + } catch { + throw new SlackManagedUsersError('Slack returned an invalid response.', 'invalid_response') + } +} + +function parseSlackOAuthResponse(value: unknown): SlackOAuthSuccess { + if (!value || typeof value !== 'object') { + throw new SlackManagedUsersError('Slack returned an invalid response.', 'invalid_response') + } + const response = value as Record + if (response.ok !== true) { + const errorCode = stringField(response, 'error') + throw new SlackManagedUsersError( + errorCode === 'invalid_client_id' || errorCode === 'bad_client_secret' + ? 'Slack rejected the Client ID or Client Secret.' + : 'Slack could not verify this app.', + errorCode === 'invalid_client_id' || errorCode === 'bad_client_secret' + ? 'invalid_client' + : 'provider_error' + ) + } + + const appId = stringField(response, 'app_id') + const team = response.team + const teamId = stringField(team, 'id') + const teamName = stringField(team, 'name') + const authedUser = response.authed_user + const userId = stringField(authedUser, 'id') + const accessToken = stringField(authedUser, 'access_token') + const tokenType = stringField(authedUser, 'token_type') + const scope = stringField(authedUser, 'scope') + if ( + !appId?.startsWith('A') || + !teamId?.startsWith('T') || + !teamName || + !userId?.startsWith('U') || + !accessToken || + tokenType !== 'user' || + !scope + ) { + throw new SlackManagedUsersError( + 'Slack returned an incomplete authorization.', + 'invalid_response' + ) + } + + const expiresIn = + authedUser && typeof authedUser === 'object' + ? (authedUser as Record).expires_in + : undefined + const refreshToken = stringField(authedUser, 'refresh_token') ?? undefined + return { + appId, + teamId, + teamName, + userId, + accessToken, + scopes: scope + .split(',') + .map((item) => item.trim()) + .filter(Boolean), + tokenType: 'user', + ...(typeof expiresIn === 'number' ? { expiresIn } : {}), + ...(refreshToken ? { refreshToken } : {}), + } +} + +export async function revokeSlackToken(token: string): Promise { + const response = await fetch('https://slack.com/api/auth.revoke', { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + cache: 'no-store', + }) + const value = await readBoundedJson(response) + if ( + !response.ok || + !value || + typeof value !== 'object' || + (value as Record).ok !== true || + (value as Record).revoked !== true + ) { + throw new SlackManagedUsersError( + 'Slack issued a setup token but could not revoke it. Try again.', + 'revoke_failed' + ) + } +} + +async function callSlackApi(method: string, accessToken: string, body?: URLSearchParams) { + let response: Response + try { + response = await fetch(`https://slack.com/api/${method}`, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + ...(body ? { body } : {}), + cache: 'no-store', + }) + } catch (error) { + logger.error('Slack API verification failed', { method, error: getErrorMessage(error) }) + throw new SlackManagedUsersError('Slack could not verify the authorization.', 'provider_error') + } + const value = await readBoundedJson(response) + if ( + !response.ok || + !value || + typeof value !== 'object' || + (value as Record).ok !== true + ) { + const providerError = stringField(value, 'error') + if (method === 'bots.info' && providerError === 'missing_scope') { + throw new SlackManagedUsersError( + 'Add the users:read bot scope to this Slack app, reinstall it, and update the custom bot credential before enabling managed users.', + 'missing_bot_scope' + ) + } + throw new SlackManagedUsersError('Slack could not verify the authorization.', 'provider_error') + } + return value as Record +} + +export async function verifySlackCustomBotAppIdentity(botToken: string): Promise<{ + appId: string + teamId: string +}> { + const auth = await callSlackApi('auth.test', botToken) + const teamId = stringField(auth, 'team_id') + const botId = stringField(auth, 'bot_id') + if (!teamId?.startsWith('T') || !botId?.startsWith('B')) { + throw new SlackManagedUsersError( + 'Slack did not identify this token as an installed bot.', + 'invalid_response' + ) + } + const info = await callSlackApi('bots.info', botToken, new URLSearchParams({ bot: botId })) + const appId = stringField(info.bot, 'app_id') + if (!appId?.startsWith('A')) { + throw new SlackManagedUsersError( + 'Slack did not return the app for this custom bot.', + 'invalid_response' + ) + } + return { appId, teamId } +} + +export async function verifySlackUserIdentity(params: { + accessToken: string + expectedTeamId: string + expectedUserId: string +}): Promise { + const auth = await callSlackApi('auth.test', params.accessToken) + const teamId = stringField(auth, 'team_id') + const userId = stringField(auth, 'user_id') + if (teamId !== params.expectedTeamId || userId !== params.expectedUserId) { + throw new SlackManagedUsersError( + 'Slack returned a credential for another user or workspace.', + 'invalid_response' + ) + } + + const info = await callSlackApi( + 'users.info', + params.accessToken, + new URLSearchParams({ user: params.expectedUserId }) + ) + const user = info.user + if (!user || typeof user !== 'object') { + throw new SlackManagedUsersError( + 'Slack returned an incomplete user profile.', + 'invalid_response' + ) + } + const profile = (user as Record).profile + const email = stringField(profile, 'email') + if (!email) { + throw new SlackManagedUsersError( + 'Slack did not return the user email required by this invitation.', + 'invalid_response' + ) + } + const displayName = stringField(profile, 'display_name') ?? stringField(profile, 'real_name') + const avatarUrl = stringField(profile, 'image_192') ?? stringField(profile, 'image_72') + const username = stringField(user, 'name') + return { + userId, + teamId, + email, + ...(displayName ? { displayName } : {}), + ...(avatarUrl ? { avatarUrl } : {}), + ...(username ? { username } : {}), + } +} + +export async function exchangeSlackUserAuthorization(params: { + clientId: string + clientSecret: string + code: string + redirectUri: string +}): Promise { + const basicAuth = Buffer.from(`${params.clientId}:${params.clientSecret}`, 'utf8').toString( + 'base64' + ) + const body = new URLSearchParams({ code: params.code, redirect_uri: params.redirectUri }) + let response: Response + try { + response = await fetch('https://slack.com/api/oauth.v2.access', { + method: 'POST', + headers: { + Authorization: `Basic ${basicAuth}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body, + cache: 'no-store', + }) + } catch (error) { + logger.error('Slack OAuth exchange failed', { error: getErrorMessage(error) }) + throw new SlackManagedUsersError('Slack could not complete authorization.', 'provider_error') + } + return parseSlackOAuthResponse(await readBoundedJson(response)) +} + +export function getSlackManagedUsersRedirectUri(): string { + return `${getBaseUrl()}/api/credential-groups/slack-managed-users/callback` +} + +export async function createSlackManagedUsersAttempt(params: { + workspaceId: string + userId: string + credentialGroupId: string + slackBotCredentialId: string + clientId: string + clientSecret: string +}): Promise<{ state: string; authorizationUrl: string }> { + const [group] = await db + .select({ id: credentialGroup.id, updatedAt: credentialGroup.updatedAt }) + .from(credentialGroup) + .where( + and( + eq(credentialGroup.id, params.credentialGroupId), + eq(credentialGroup.workspaceId, params.workspaceId) + ) + ) + .limit(1) + if (!group) throw new SlackManagedUsersError('Credential Group not found.', 'invalid_response') + const bot = await getSlackCustomBotCredential({ + workspaceId: params.workspaceId, + 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' + ) + } + const redis = requireRedis() + const state = generateId() + const redirectUri = getSlackManagedUsersRedirectUri() + const encryptedClientSecret = await encryptSecret(params.clientSecret) + const attempt: StoredSlackManagedUsersAttempt = { + version: SLACK_MANAGED_USERS_ATTEMPT_VERSION, + workspaceId: params.workspaceId, + userId: params.userId, + credentialGroupId: group.id, + credentialGroupUpdatedAt: group.updatedAt.getTime(), + slackBotCredentialId: bot.id, + slackBotCredentialUpdatedAt: bot.updatedAt.getTime(), + expectedAppId: identity.appId, + expectedTeamId: identity.teamId, + clientId: params.clientId, + encryptedClientSecret: encryptedClientSecret.encrypted, + redirectUri, + createdAt: Date.now(), + } + const stored = await redis.set( + attemptKey(state), + JSON.stringify(attempt), + 'PX', + SLACK_MANAGED_USERS_ATTEMPT_TTL_MS, + 'NX' + ) + if (stored !== 'OK') throw new Error('Slack managed-user state collision') + + const authorizationUrl = new URL('https://slack.com/oauth/v2/authorize') + authorizationUrl.searchParams.set('client_id', params.clientId) + authorizationUrl.searchParams.set('user_scope', SLACK_MANAGED_USER_SCOPES.join(',')) + authorizationUrl.searchParams.set('redirect_uri', redirectUri) + authorizationUrl.searchParams.set('state', state) + authorizationUrl.searchParams.set('team', identity.teamId) + return { state, authorizationUrl: authorizationUrl.toString() } +} + +export async function consumeSlackManagedUsersAttempt( + state: string +): Promise { + const redis = requireRedis() + const raw = await redis.eval(CONSUME_SCRIPT, 1, attemptKey(state)) + return parseSlackManagedUsersAttempt(raw) +} + +export async function loadSlackManagedUsersAttempt( + state: string +): Promise { + const redis = requireRedis() + const raw = await redis.get(attemptKey(state)) + return parseSlackManagedUsersAttempt(raw) +} + +async function parseSlackManagedUsersAttempt( + raw: unknown +): Promise { + if (raw === null) return null + if (typeof raw !== 'string') throw new Error('Slack managed-user state is malformed') + const parsed: unknown = JSON.parse(raw) + if (!isStoredAttempt(parsed)) throw new Error('Slack managed-user state is malformed') + if (Date.now() - parsed.createdAt > SLACK_MANAGED_USERS_ATTEMPT_TTL_MS) return null + const clientSecret = await decryptSecret(parsed.encryptedClientSecret) + return { + workspaceId: parsed.workspaceId, + userId: parsed.userId, + credentialGroupId: parsed.credentialGroupId, + credentialGroupUpdatedAt: parsed.credentialGroupUpdatedAt, + slackBotCredentialId: parsed.slackBotCredentialId, + slackBotCredentialUpdatedAt: parsed.slackBotCredentialUpdatedAt, + expectedAppId: parsed.expectedAppId, + expectedTeamId: parsed.expectedTeamId, + clientId: parsed.clientId, + clientSecret: clientSecret.decrypted, + redirectUri: parsed.redirectUri, + createdAt: parsed.createdAt, + } +} + +export async function exchangeAndConfigureSlackManagedUsers(params: { + attempt: SlackManagedUsersAttempt + code: string +}): Promise<{ + credentialGroupId: string + credentialGroupName: string + slackBotCredentialId: string + appId: string + teamId: string +}> { + const grant = await exchangeSlackUserAuthorization({ + clientId: params.attempt.clientId, + clientSecret: params.attempt.clientSecret, + code: params.code, + redirectUri: params.attempt.redirectUri, + }) + if (grant.expiresIn !== undefined || grant.refreshToken) { + await Promise.allSettled( + [grant.accessToken, grant.refreshToken] + .filter((token): token is string => Boolean(token)) + .map(revokeSlackToken) + ) + throw new SlackManagedUsersError( + 'Disable token rotation in the Slack app and try again.', + 'token_rotation_enabled' + ) + } + + try { + if ( + grant.appId !== params.attempt.expectedAppId || + 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.', + 'invalid_response' + ) + } + if (!SLACK_MANAGED_USER_SCOPES.every((scope) => grant.scopes.includes(scope))) { + throw new SlackManagedUsersError( + 'Slack did not grant every permission required for managed users.', + 'invalid_response' + ) + } + await verifySlackUserIdentity({ + accessToken: grant.accessToken, + expectedTeamId: grant.teamId, + expectedUserId: grant.userId, + }) + } finally { + await revokeSlackToken(grant.accessToken) + } + const authorizationAppId = `slack:${grant.appId}:${grant.teamId}` + const now = new Date() + const scopeVersion = credentialGroupScopePolicyVersion([...SLACK_MANAGED_USER_SCOPES]) + + return db.transaction(async (tx) => { + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${`slack-managed-users:${params.attempt.credentialGroupId}`}, 0))` + ) + const [group] = await tx + .select() + .from(credentialGroup) + .where( + and( + eq(credentialGroup.id, params.attempt.credentialGroupId), + eq(credentialGroup.workspaceId, params.attempt.workspaceId) + ) + ) + .limit(1) + .for('update') + if (!group || group.updatedAt.getTime() !== params.attempt.credentialGroupUpdatedAt) { + throw new SlackManagedUsersError( + 'The Credential Group changed while Slack authorization was in progress. Start again.', + '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), + eq(credential.workspaceId, params.attempt.workspaceId), + 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' + ) + } + 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, + clientId: params.attempt.clientId, + clientSecret: params.attempt.clientSecret, + appId: grant.appId, + teamId: grant.teamId, + scopes: [...new Set(grant.scopes)], + verifiedAt: now.toISOString(), + }, + }) + const existingOption = group.options.find((option) => option.provider === 'slack') + const nextOption = { + id: existingOption?.id ?? generateId(), + provider: 'slack', + label: existingOption?.label ?? 'Slack', + slackBotCredentialId: botRow.id, + authorizationAppId, + requiredScopes: [...SLACK_MANAGED_USER_SCOPES], + scopeVersion, + required: existingOption?.required ?? true, + status: existingOption?.status ?? ('active' as const), + } + const options = existingOption + ? group.options.map((option) => (option.id === existingOption.id ? nextOption : option)) + : [...group.options, nextOption] + const [updated] = await tx + .update(credentialGroup) + .set({ + options, + encryptedProviderConfiguration: encryptedConfiguration, + updatedAt: now, + }) + .where(eq(credentialGroup.id, group.id)) + .returning({ id: credentialGroup.id }) + if (!updated) throw new Error('Credential Group Slack configuration update returned no row') + if ( + existingOption && + (existingOption.authorizationAppId !== authorizationAppId || + existingOption.scopeVersion !== scopeVersion) + ) { + const enrollmentIds = tx + .select({ id: credentialGroupEnrollment.id }) + .from(credentialGroupEnrollment) + .where(eq(credentialGroupEnrollment.credentialGroupId, group.id)) + await tx + .update(credential) + .set({ managedOauthStatus: 'needs_reauth', updatedAt: now }) + .where( + and( + eq(credential.type, 'managed_oauth'), + inArray(credential.credentialGroupEnrollmentId, enrollmentIds), + eq(credential.credentialGroupOptionId, existingOption.id) + ) + ) + } + return { + credentialGroupId: group.id, + credentialGroupName: group.name, + slackBotCredentialId: botRow.id, + appId: grant.appId, + teamId: grant.teamId, + } + }) +} + +export async function getSlackCustomBotCredential(params: { + workspaceId: string + credentialId: string + executor?: DbOrTx +}): Promise<{ + id: string + name: string + updatedAt: Date + botToken: string + teamId: string + teamName?: string +} | null> { + const executor = params.executor ?? db + const [row] = await executor + .select({ + id: credential.id, + name: credential.displayName, + updatedAt: credential.updatedAt, + encryptedServiceAccountKey: credential.encryptedServiceAccountKey, + }) + .from(credential) + .where( + and( + eq(credential.workspaceId, params.workspaceId), + eq(credential.type, 'service_account'), + eq(credential.providerId, SLACK_CUSTOM_BOT_PROVIDER_ID), + eq(credential.id, params.credentialId) + ) + ) + .limit(1) + if (!row) return null + if (!row.encryptedServiceAccountKey) throw new Error('Slack custom bot secret is missing') + const decrypted = await decryptSecret(row.encryptedServiceAccountKey) + const secret = parseSlackCustomBotSecret(JSON.parse(decrypted.decrypted) as unknown) + return { + id: row.id, + name: row.name, + updatedAt: row.updatedAt, + botToken: secret.botToken, + teamId: secret.teamId, + teamName: secret.teamName, + } +} diff --git a/apps/sim/lib/credential-groups/slack-provider.ts b/apps/sim/lib/credential-groups/slack-provider.ts new file mode 100644 index 00000000000..d6633c862b7 --- /dev/null +++ b/apps/sim/lib/credential-groups/slack-provider.ts @@ -0,0 +1,239 @@ +import { normalizeEmail } from '@sim/utils/string' +import { getBaseUrl } from '@/lib/core/utils/urls' +import type { + CredentialGroupProviderAdapter, + CredentialGroupProviderPolicy, +} from '@/lib/credential-groups/provider-adapter' +import { + CredentialGroupOAuthError, + CredentialGroupProviderConfigurationError, + credentialGroupScopePolicyVersion, +} from '@/lib/credential-groups/provider-adapter' +import { getSlackCredentialGroupConfiguration } from '@/lib/credential-groups/provider-configuration' +import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers' +import { SLACK_MANAGED_USER_SCOPES } from '@/lib/credential-groups/slack-managed-user-scopes' +import { + exchangeSlackUserAuthorization, + getSlackCustomBotCredential, + revokeSlackToken, + verifySlackUserIdentity, +} from '@/lib/credential-groups/slack-managed-users' +import type { DbOrTx } from '@/lib/db/types' + +const PROVIDER = 'slack' as const + +async function getSlackPolicy(params: { + workspaceId: string + credentialGroupId: string + slackBotCredentialId?: string + executor?: DbOrTx +}): Promise< + CredentialGroupProviderPolicy & { + slackBotCredentialId: string + clientId: string + clientSecret: string + appId: string + teamId: string + } +> { + const managed = await getSlackCredentialGroupConfiguration({ + workspaceId: params.workspaceId, + credentialGroupId: params.credentialGroupId, + ...(params.executor ? { executor: params.executor } : {}), + }) + if (!managed) { + throw new CredentialGroupProviderConfigurationError('Configure Slack on this Credential Group') + } + if (params.slackBotCredentialId && managed.slackBotCredentialId !== params.slackBotCredentialId) { + throw new CredentialGroupProviderConfigurationError( + 'The selected custom Slack bot does not match this Credential Group configuration' + ) + } + const app = await getSlackCustomBotCredential({ + workspaceId: params.workspaceId, + 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' + ) + } + const service = getCredentialGroupProviderService(PROVIDER) + const requiredScopes = [...SLACK_MANAGED_USER_SCOPES] + const scopeVersion = credentialGroupScopePolicyVersion(requiredScopes) + if (!requiredScopes.every((scope) => managed.scopes.includes(scope))) { + throw new CredentialGroupProviderConfigurationError( + 'Managed-user permissions changed. Reconfigure Slack on this Credential Group.' + ) + } + return { + provider: PROVIDER, + providerId: service.providerId, + authorizationAppId: `slack:${managed.appId}:${managed.teamId}`, + requiredScopes, + scopeVersion, + slackBotCredentialId: managed.slackBotCredentialId, + clientId: managed.clientId, + clientSecret: managed.clientSecret, + appId: managed.appId, + teamId: managed.teamId, + } +} + +/** + * Slack tools still request the legacy canonical bot bundle when they do not declare + * operation-level scopes. Managed user grants translate only that exact fallback to the + * managed-user policy; explicit tool scopes remain exact requirements. + */ +function hasRequiredSlackScopes(grantedScopes: string[], requiredScopes: string[]): boolean { + const granted = new Set(grantedScopes) + const canonicalBotScopes = getCredentialGroupProviderService(PROVIDER).scopes + const isCanonicalFallback = + requiredScopes.length === canonicalBotScopes.length && + requiredScopes.every((scope) => canonicalBotScopes.includes(scope)) + const effectiveRequiredScopes = isCanonicalFallback ? SLACK_MANAGED_USER_SCOPES : requiredScopes + return effectiveRequiredScopes.every((scope) => granted.has(scope)) +} + +export const slackCredentialGroupProviderAdapter: CredentialGroupProviderAdapter = { + provider: PROVIDER, + requiresRefreshToken: false, + async getPolicy(option, context) { + if (!context.credentialGroupId) { + throw new CredentialGroupProviderConfigurationError('Credential Group context is required') + } + const slackBotCredentialId = option?.slackBotCredentialId + return getSlackPolicy({ + workspaceId: context.workspaceId, + credentialGroupId: context.credentialGroupId, + ...(slackBotCredentialId ? { slackBotCredentialId } : {}), + ...(context.executor ? { executor: context.executor } : {}), + }) + }, + async prepareAuthorization(context, policy) { + const currentPolicy = await getSlackPolicy({ + workspaceId: context.workspaceId, + credentialGroupId: context.credentialGroupId, + slackBotCredentialId: context.option.slackBotCredentialId, + }) + if (currentPolicy.authorizationAppId !== policy.authorizationAppId) { + throw new CredentialGroupOAuthError( + 'This credential option changed. Reload the invitation and try again.', + 409 + ) + } + const redirectUri = `${getBaseUrl()}/api/credential-groups/oauth/${PROVIDER}/callback` + return { + redirectUri, + buildAuthorizationUrl: ({ state }) => { + const authorizationUrl = new URL('https://slack.com/oauth/v2/authorize') + authorizationUrl.searchParams.set('client_id', currentPolicy.clientId) + authorizationUrl.searchParams.set('user_scope', policy.requiredScopes.join(',')) + authorizationUrl.searchParams.set('redirect_uri', redirectUri) + authorizationUrl.searchParams.set('state', state) + authorizationUrl.searchParams.set('team', currentPolicy.teamId) + return authorizationUrl.toString() + }, + } + }, + async exchangeAndVerify({ context, attempt, code, policy }) { + const currentPolicy = await getSlackPolicy({ + workspaceId: context.workspaceId, + credentialGroupId: context.credentialGroupId, + slackBotCredentialId: context.option.slackBotCredentialId, + }) + const redirectUri = `${getBaseUrl()}/api/credential-groups/oauth/${PROVIDER}/callback` + if ( + currentPolicy.authorizationAppId !== policy.authorizationAppId || + attempt.redirectUri !== redirectUri + ) { + throw new CredentialGroupOAuthError('Authorization state is invalid or expired.', 400) + } + + let grant: Awaited> + try { + grant = await exchangeSlackUserAuthorization({ + clientId: currentPolicy.clientId, + clientSecret: currentPolicy.clientSecret, + code, + redirectUri: attempt.redirectUri, + }) + } catch { + throw new CredentialGroupOAuthError( + 'Slack could not complete authorization. Please try again.', + 502 + ) + } + + if (grant.expiresIn !== undefined || grant.refreshToken) { + await Promise.allSettled( + [grant.accessToken, grant.refreshToken] + .filter((token): token is string => Boolean(token)) + .map(revokeSlackToken) + ) + throw new CredentialGroupOAuthError( + 'Slack token rotation was enabled after this app was configured. Disable it and try again.', + 409 + ) + } + + try { + if ( + grant.appId !== currentPolicy.appId || + grant.teamId !== currentPolicy.teamId || + !hasRequiredSlackScopes(grant.scopes, policy.requiredScopes) + ) { + throw new CredentialGroupOAuthError( + 'All requested Slack permissions are required to connect this account.', + 403 + ) + } + const identity = await verifySlackUserIdentity({ + accessToken: grant.accessToken, + expectedTeamId: grant.teamId, + expectedUserId: grant.userId, + }) + const email = normalizeEmail(identity.email) + if (email !== context.email) { + throw new CredentialGroupOAuthError( + `Sign in with ${context.email} to complete this invitation.`, + 403 + ) + } + + return { + providerId: policy.providerId, + providerSubjectId: identity.userId, + providerTenantId: identity.teamId, + displayName: email, + metadata: { + email, + ...(identity.displayName ? { displayName: identity.displayName } : {}), + ...(identity.avatarUrl ? { avatarUrl: identity.avatarUrl } : {}), + ...(identity.username ? { username: identity.username } : {}), + }, + accessToken: grant.accessToken, + grantedScopes: [...new Set(grant.scopes)], + accessTokenExpiresAt: null, + refreshTokenExpiresAt: null, + } + } catch (error) { + await revokeSlackToken(grant.accessToken) + if (error instanceof CredentialGroupOAuthError) throw error + throw new CredentialGroupOAuthError('Slack could not verify the granted access.', 502) + } + }, + hasRequiredScopes: hasRequiredSlackScopes, + async refreshToken() { + throw new Error('Slack managed credentials do not use token refresh') + }, + isTerminalRefreshError() { + return false + }, +} diff --git a/apps/sim/lib/credential-groups/standard-oauth-provider.test.ts b/apps/sim/lib/credential-groups/standard-oauth-provider.test.ts new file mode 100644 index 00000000000..4fc36880347 --- /dev/null +++ b/apps/sim/lib/credential-groups/standard-oauth-provider.test.ts @@ -0,0 +1,198 @@ +/** + * @vitest-environment node + */ +import { createHash } from 'node:crypto' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { CredentialGroupOAuthContext } from '@/lib/credential-groups/enrollments' +import type { CredentialGroupOAuthAttempt } from '@/lib/credential-groups/oauth-state' + +const { mockGetToken, mockVerifyIdentity } = vi.hoisted(() => ({ + mockGetToken: vi.fn(), + mockVerifyIdentity: vi.fn(), +})) + +vi.mock('@/lib/core/utils/urls', () => ({ + getBaseUrl: () => 'https://sim.example.com', +})) + +vi.mock('@/lib/auth/connectors/managed-oauth', () => ({ + getManagedOAuthConnectorProviderConfig: (providerId: string) => + providerId === 'google-calendar' + ? { + providerId, + clientId: 'client-1', + clientSecret: 'secret-1', + authorizationUrl: 'https://accounts.example.com/authorize', + tokenUrl: 'https://accounts.example.com/token', + accessType: 'offline', + scopes: ['calendar.read', 'profile'], + getToken: mockGetToken, + managedOAuth: { + additionalScopes: ['openid'], + requiresRefreshToken: true, + pkce: true, + prompt: 'consent select_account', + authorizationUrlParams: { include_granted_scopes: 'false' }, + getAuthorizationAppId: (clientId: string) => `google:${clientId}`, + verifyIdentity: mockVerifyIdentity, + hasRequiredScopes: (granted: string[], required: string[]) => + required.every((scope) => granted.includes(scope)), + isTerminalRefreshError: (errorCode: string | undefined) => + errorCode === 'invalid_grant', + }, + } + : undefined, +})) + +import { createStandardOAuthCredentialGroupProviderAdapter } from '@/lib/credential-groups/standard-oauth-provider' + +const adapter = createStandardOAuthCredentialGroupProviderAdapter('google-calendar') + +function buildContext(): CredentialGroupOAuthContext { + return { + enrollmentId: 'enrollment-1', + credentialGroupId: 'group-1', + workspaceId: 'workspace-1', + workspaceName: 'Workspace', + workspaceOwnerId: 'owner-1', + email: 'person@example.com', + enrollmentStatus: 'in_progress', + option: { + id: 'option-1', + provider: 'google-calendar', + label: 'Google Calendar', + authorizationAppId: 'google:client-1', + requiredScopes: ['calendar.read', 'profile', 'openid'], + scopeVersion: 1, + required: true, + status: 'active', + }, + options: [], + } +} + +function buildAttempt(scopeVersion: number): CredentialGroupOAuthAttempt { + return { + state: 'state-1', + provider: 'google-calendar', + nonceHash: createHash('sha256').update('nonce-1').digest('hex'), + enrollmentId: 'enrollment-1', + credentialGroupId: 'group-1', + optionId: 'option-1', + authorizationAppId: 'google:client-1', + scopeVersion, + requiredScopes: ['calendar.read', 'profile', 'openid'], + redirectUri: 'https://sim.example.com/api/credential-groups/oauth/google-calendar/callback', + codeVerifier: 'verifier-1', + invitationToken: 'invitation-1', + createdAt: Date.now(), + } +} + +describe('standard OAuth Credential Group provider', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetToken.mockResolvedValue({ + tokenType: 'Bearer', + accessToken: 'access-1', + refreshToken: 'refresh-1', + accessTokenExpiresAt: new Date('2026-08-14T01:00:00Z'), + }) + mockVerifyIdentity.mockResolvedValue({ + providerSubjectId: 'google-sub-1', + providerTenantId: 'example.com', + email: 'person@example.com', + emailVerified: true, + displayName: 'Person', + avatarUrl: 'https://example.com/avatar.png', + nonce: 'nonce-1', + grantedScopes: ['calendar.read', 'profile', 'openid'], + }) + }) + + it('builds authorization from the existing connector configuration', async () => { + const context = buildContext() + const policy = await adapter.getPolicy(context.option, { + workspaceId: context.workspaceId, + credentialGroupId: context.credentialGroupId, + }) + const prepared = await adapter.prepareAuthorization(context, policy) + const authorizationUrl = new URL( + await prepared.buildAuthorizationUrl({ state: 'state-1', nonce: 'nonce-1' }) + ) + + expect(policy).toMatchObject({ + provider: 'google-calendar', + providerId: 'google-calendar', + authorizationAppId: 'google:client-1', + requiredScopes: ['calendar.read', 'profile', 'openid'], + }) + expect(prepared.codeVerifier).toHaveLength(86) + expect(authorizationUrl.origin).toBe('https://accounts.example.com') + expect(authorizationUrl.searchParams.get('client_id')).toBe('client-1') + expect(authorizationUrl.searchParams.get('state')).toBe('state-1') + expect(authorizationUrl.searchParams.get('nonce')).toBe('nonce-1') + expect(authorizationUrl.searchParams.get('login_hint')).toBe('person@example.com') + expect(authorizationUrl.searchParams.get('include_granted_scopes')).toBe('false') + expect(authorizationUrl.searchParams.get('code_challenge_method')).toBe('S256') + }) + + it('persists a verified provider identity and returned scopes', async () => { + const context = buildContext() + const policy = await adapter.getPolicy(context.option, { + workspaceId: context.workspaceId, + credentialGroupId: context.credentialGroupId, + }) + const grant = await adapter.exchangeAndVerify({ + context, + attempt: buildAttempt(policy.scopeVersion), + code: 'code-1', + policy, + }) + + expect(mockGetToken).toHaveBeenCalledWith({ + code: 'code-1', + redirectURI: 'https://sim.example.com/api/credential-groups/oauth/google-calendar/callback', + codeVerifier: 'verifier-1', + }) + expect(grant).toMatchObject({ + providerId: 'google-calendar', + providerSubjectId: 'google-sub-1', + providerTenantId: 'example.com', + displayName: 'person@example.com', + accessToken: 'access-1', + refreshToken: 'refresh-1', + grantedScopes: ['calendar.read', 'profile', 'openid'], + metadata: { + email: 'person@example.com', + displayName: 'Person', + avatarUrl: 'https://example.com/avatar.png', + }, + }) + }) + + it('rejects a different invited email', async () => { + mockVerifyIdentity.mockResolvedValueOnce({ + providerSubjectId: 'google-sub-2', + providerTenantId: null, + email: 'other@example.com', + emailVerified: true, + nonce: 'nonce-1', + grantedScopes: ['calendar.read', 'profile', 'openid'], + }) + const context = buildContext() + const policy = await adapter.getPolicy(context.option, { + workspaceId: context.workspaceId, + credentialGroupId: context.credentialGroupId, + }) + + await expect( + adapter.exchangeAndVerify({ + context, + attempt: buildAttempt(policy.scopeVersion), + code: 'code-1', + policy, + }) + ).rejects.toMatchObject({ statusCode: 403 }) + }) +}) diff --git a/apps/sim/lib/credential-groups/standard-oauth-provider.ts b/apps/sim/lib/credential-groups/standard-oauth-provider.ts new file mode 100644 index 00000000000..dcbd3bea2a9 --- /dev/null +++ b/apps/sim/lib/credential-groups/standard-oauth-provider.ts @@ -0,0 +1,353 @@ +import { randomBytes } from 'node:crypto' +import { + applyDefaultAccessTokenExpiry, + createAuthorizationURL, + type OAuth2Tokens, + validateAuthorizationCode, +} from '@better-auth/core/oauth2' +import { normalizeEmail } from '@sim/utils/string' +import { + type ConnectorProviderConfig, + getManagedOAuthConnectorProviderConfig, +} from '@/lib/auth/connectors/managed-oauth' +import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { credentialGroupOAuthNonceMatches } from '@/lib/credential-groups/oauth-state' +import type { + CredentialGroupProviderAdapter, + CredentialGroupProviderPolicy, +} from '@/lib/credential-groups/provider-adapter' +import { + CredentialGroupOAuthError, + CredentialGroupProviderConfigurationError, + credentialGroupScopePolicyVersion, +} from '@/lib/credential-groups/provider-adapter' +import type { CredentialGroupStandardOAuthProvider } from '@/lib/credential-groups/providers' +import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers' +import { refreshOAuthToken } from '@/lib/oauth' + +const OAUTH_DISCOVERY_TIMEOUT_MS = 10_000 +const OAUTH_DISCOVERY_MAX_BYTES = 256 * 1024 + +interface OAuthEndpoints { + authorizationEndpoint: string + tokenEndpoint: string +} + +interface CurrentStandardOAuthProvider { + connector: ConnectorProviderConfig + policy: CredentialGroupProviderPolicy +} + +function staticParams( + value: ConnectorProviderConfig['authorizationUrlParams'], + label: string +): Record { + if (typeof value === 'function') { + throw new CredentialGroupProviderConfigurationError( + `${label} cannot depend on an authenticated Sim request` + ) + } + return value ?? {} +} + +async function resolveOAuthEndpoints( + connector: ConnectorProviderConfig, + providerName: string +): Promise { + if (connector.discoveryUrl) { + let response: Response + try { + response = await fetch(connector.discoveryUrl, { + headers: connector.discoveryHeaders, + signal: AbortSignal.timeout(OAUTH_DISCOVERY_TIMEOUT_MS), + }) + } catch { + throw new CredentialGroupOAuthError( + `${providerName} authorization is temporarily unavailable.`, + 503 + ) + } + if (!response.ok) { + await response.body?.cancel().catch(() => {}) + throw new CredentialGroupOAuthError( + `${providerName} authorization is temporarily unavailable.`, + 503 + ) + } + let document: unknown + try { + document = await readResponseJsonWithLimit(response, { + maxBytes: OAUTH_DISCOVERY_MAX_BYTES, + label: `${providerName} OAuth discovery response`, + }) + } catch { + throw new CredentialGroupOAuthError( + `${providerName} authorization is temporarily unavailable.`, + 503 + ) + } + if (!document || typeof document !== 'object') { + throw new CredentialGroupOAuthError(`${providerName} OAuth configuration is invalid.`, 503) + } + const discovery = document as Record + if ( + typeof discovery.authorization_endpoint !== 'string' || + typeof discovery.token_endpoint !== 'string' + ) { + throw new CredentialGroupOAuthError(`${providerName} OAuth configuration is invalid.`, 503) + } + return { + authorizationEndpoint: discovery.authorization_endpoint, + tokenEndpoint: discovery.token_endpoint, + } + } + + if (!connector.authorizationUrl || !connector.tokenUrl) { + throw new CredentialGroupProviderConfigurationError( + `${providerName} OAuth endpoints are not configured` + ) + } + return { + authorizationEndpoint: connector.authorizationUrl, + tokenEndpoint: connector.tokenUrl, + } +} + +function getCurrentProvider( + provider: CredentialGroupStandardOAuthProvider +): CurrentStandardOAuthProvider { + const service = getCredentialGroupProviderService(provider) + const connector = getManagedOAuthConnectorProviderConfig(service.providerId) + if (!connector) { + throw new CredentialGroupProviderConfigurationError( + `Managed ${service.name} authorization is not configured` + ) + } + const requiredScopes = [ + ...new Set([...(connector.scopes ?? []), ...connector.managedOAuth.additionalScopes]), + ] + if (requiredScopes.length === 0) { + throw new CredentialGroupProviderConfigurationError( + `Managed ${service.name} authorization has no scope policy` + ) + } + return { + connector, + policy: { + provider, + providerId: service.providerId, + authorizationAppId: connector.managedOAuth.getAuthorizationAppId(connector.clientId), + requiredScopes, + scopeVersion: credentialGroupScopePolicyVersion(requiredScopes), + }, + } +} + +function assertCurrentPolicy( + expected: CredentialGroupProviderPolicy, + current: CredentialGroupProviderPolicy +): void { + if ( + expected.provider !== current.provider || + expected.providerId !== current.providerId || + expected.authorizationAppId !== current.authorizationAppId || + expected.scopeVersion !== current.scopeVersion + ) { + throw new CredentialGroupOAuthError( + 'This credential option changed. Reload the invitation and try again.', + 409 + ) + } +} + +function generatePkceVerifier(): string { + return randomBytes(64).toString('base64url') +} + +async function exchangeAuthorizationCode(params: { + connector: ConnectorProviderConfig + code: string + codeVerifier?: string + redirectUri: string + tokenEndpoint: string +}): Promise { + const { connector, code, codeVerifier, redirectUri, tokenEndpoint } = params + const tokens = connector.getToken + ? await connector.getToken({ code, redirectURI: redirectUri, codeVerifier }) + : await validateAuthorizationCode({ + headers: connector.authorizationHeaders, + code, + codeVerifier, + redirectURI: redirectUri, + options: { + clientId: connector.clientId, + clientSecret: connector.clientSecret, + redirectURI: redirectUri, + }, + tokenEndpoint, + authentication: connector.authentication, + additionalParams: staticParams(connector.tokenUrlParams, 'OAuth token parameters'), + }) + return applyDefaultAccessTokenExpiry(tokens, connector.accessTokenExpiresIn) +} + +/** + * Reuses the native connector's OAuth client, endpoints, scopes, and exchange hooks while + * persisting the result through public enrollment instead of a signed-in Sim account. + */ +export function createStandardOAuthCredentialGroupProviderAdapter( + provider: CredentialGroupStandardOAuthProvider +): CredentialGroupProviderAdapter { + return { + provider, + get requiresRefreshToken() { + return getCurrentProvider(provider).connector.managedOAuth.requiresRefreshToken + }, + async getPolicy() { + return getCurrentProvider(provider).policy + }, + async prepareAuthorization(context, policy) { + const current = getCurrentProvider(provider) + assertCurrentPolicy(policy, current.policy) + const managed = current.connector.managedOAuth + const endpoints = await resolveOAuthEndpoints( + current.connector, + getCredentialGroupProviderService(provider).name + ) + const redirectUri = `${getBaseUrl()}/api/credential-groups/oauth/${provider}/callback` + const codeVerifier = managed.pkce ? generatePkceVerifier() : undefined + return { + redirectUri, + ...(codeVerifier ? { codeVerifier } : {}), + buildAuthorizationUrl: async ({ state, nonce }) => { + const authorizationUrl = await createAuthorizationURL({ + id: current.connector.providerId, + options: { + clientId: current.connector.clientId, + clientSecret: current.connector.clientSecret, + redirectURI: redirectUri, + }, + authorizationEndpoint: endpoints.authorizationEndpoint, + state, + ...(codeVerifier ? { codeVerifier } : {}), + scopes: policy.requiredScopes, + redirectURI: redirectUri, + prompt: managed.prompt ?? current.connector.prompt, + accessType: current.connector.accessType, + responseType: current.connector.responseType, + responseMode: current.connector.responseMode, + loginHint: context.email, + additionalParams: { + ...staticParams( + current.connector.authorizationUrlParams, + 'OAuth authorization parameters' + ), + ...managed.authorizationUrlParams, + nonce, + }, + }) + return authorizationUrl.toString() + }, + } + }, + async exchangeAndVerify({ context, attempt, code, policy }) { + const current = getCurrentProvider(provider) + assertCurrentPolicy(policy, current.policy) + const redirectUri = `${getBaseUrl()}/api/credential-groups/oauth/${provider}/callback` + if (attempt.redirectUri !== redirectUri) { + throw new CredentialGroupOAuthError('Authorization state is invalid or expired.', 400) + } + const managed = current.connector.managedOAuth + if (managed.pkce && !attempt.codeVerifier) { + throw new CredentialGroupOAuthError('Authorization state is invalid or expired.', 400) + } + const service = getCredentialGroupProviderService(provider) + const endpoints = await resolveOAuthEndpoints(current.connector, service.name) + let tokens: OAuth2Tokens + try { + tokens = await exchangeAuthorizationCode({ + connector: current.connector, + code, + ...(attempt.codeVerifier ? { codeVerifier: attempt.codeVerifier } : {}), + redirectUri: attempt.redirectUri, + tokenEndpoint: endpoints.tokenEndpoint, + }) + } catch { + throw new CredentialGroupOAuthError( + `${service.name} could not complete authorization. Please try again.`, + 502 + ) + } + if (tokens.tokenType !== 'Bearer' || !tokens.accessToken) { + throw new CredentialGroupOAuthError( + `${service.name} returned an incomplete authorization.`, + 502 + ) + } + let identity: Awaited> + try { + identity = await managed.verifyIdentity({ + tokens, + clientId: current.connector.clientId, + }) + } catch { + throw new CredentialGroupOAuthError( + `${service.name} returned an invalid identity token.`, + 502 + ) + } + if ( + !identity.emailVerified || + !identity.nonce || + !credentialGroupOAuthNonceMatches(identity.nonce, attempt.nonceHash) + ) { + throw new CredentialGroupOAuthError( + `${service.name} returned an invalid identity token.`, + 502 + ) + } + const email = normalizeEmail(identity.email) + if (email !== context.email) { + throw new CredentialGroupOAuthError( + `Sign in with ${context.email} to complete this invitation.`, + 403 + ) + } + if (!managed.hasRequiredScopes(identity.grantedScopes, policy.requiredScopes)) { + throw new CredentialGroupOAuthError( + `All requested ${service.name} permissions are required to connect this account.`, + 403 + ) + } + return { + providerId: policy.providerId, + providerSubjectId: identity.providerSubjectId, + providerTenantId: identity.providerTenantId, + displayName: email, + metadata: { + email, + ...(identity.displayName ? { displayName: identity.displayName } : {}), + ...(identity.avatarUrl ? { avatarUrl: identity.avatarUrl } : {}), + }, + accessToken: tokens.accessToken, + ...(tokens.refreshToken ? { refreshToken: tokens.refreshToken } : {}), + grantedScopes: identity.grantedScopes, + accessTokenExpiresAt: tokens.accessTokenExpiresAt ?? null, + refreshTokenExpiresAt: tokens.refreshTokenExpiresAt ?? null, + } + }, + hasRequiredScopes(grantedScopes, requiredScopes) { + return getCurrentProvider(provider).connector.managedOAuth.hasRequiredScopes( + grantedScopes, + requiredScopes + ) + }, + async refreshToken(refreshToken) { + return refreshOAuthToken(getCurrentProvider(provider).policy.providerId, refreshToken) + }, + isTerminalRefreshError(errorCode) { + return getCurrentProvider(provider).connector.managedOAuth.isTerminalRefreshError(errorCode) + }, + } +} diff --git a/apps/sim/lib/credential-groups/types.ts b/apps/sim/lib/credential-groups/types.ts new file mode 100644 index 00000000000..a9c39dc5ebe --- /dev/null +++ b/apps/sim/lib/credential-groups/types.ts @@ -0,0 +1,95 @@ +import type { CredentialGroupProvider } from '@/lib/credential-groups/providers' + +interface CredentialGroupOptionInputBase { + label: string + required: boolean +} + +export type CredentialGroupOptionInput = + | (CredentialGroupOptionInputBase & { + provider: Exclude + }) + | (CredentialGroupOptionInputBase & { + provider: 'slack' + slackBotCredentialId: string + }) + +export type CredentialGroupOptionUpdateInput = CredentialGroupOptionInput & { id?: string } + +export interface CreateCredentialGroupInput { + name: string + description?: string + options: CredentialGroupOptionInput[] +} + +export interface UpdateCredentialGroupInput { + name?: string + description?: string | null + options?: CredentialGroupOptionUpdateInput[] + status?: 'active' | 'disabled' +} + +interface CredentialGroupOptionBase { + id: string + label: string + required: boolean + status: 'active' | 'disabled' +} + +export type CredentialGroupOption = + | (CredentialGroupOptionBase & { + provider: Exclude + configurationStatus: 'ready' + }) + | (CredentialGroupOptionBase & { + provider: 'slack' + slackBotCredentialId: string + configurationStatus: 'not_configured' | 'ready' | 'needs_update' + }) + +export interface CredentialGroupRecord { + id: string + workspaceId: string + name: string + description: string | null + options: CredentialGroupOption[] + status: 'active' | 'disabled' + createdAt: string + updatedAt: string +} + +export type CredentialGroupEnrollmentStatus = + | 'invited' + | 'delivery_failed' + | 'in_progress' + | 'completed' + | 'revoked' + +export interface CredentialGroupEnrollmentRecord { + id: string + credentialGroupId: string + email: string + status: CredentialGroupEnrollmentStatus + expiresAt: string + invitedAt: string + sentAt: string | null + completedAt: string | null + revokedAt: string | null + expired: boolean + createdAt: string + updatedAt: string +} + +export interface CredentialGroupEnrollmentConnection { + provider: CredentialGroupProvider + status: 'active' | 'needs_reauth' | 'revoked' + count: number +} + +export interface CredentialGroupEnrollmentDetail extends CredentialGroupEnrollmentRecord { + connections: CredentialGroupEnrollmentConnection[] +} + +export interface InviteCredentialGroupEnrollmentsInput { + emails: string[] +} diff --git a/apps/sim/lib/credentials/application/authorization.ts b/apps/sim/lib/credentials/application/authorization.ts new file mode 100644 index 00000000000..7038b7f2eb3 --- /dev/null +++ b/apps/sim/lib/credentials/application/authorization.ts @@ -0,0 +1,13 @@ +import type { Principal } from '@sim/auth/principal' +import type { WorkspaceDelegationPolicy } from '@/lib/core/application' +import type { ManagedOAuthCredentialApplicationContext } from '@/lib/credentials/managed-oauth' + +export const MANAGED_OAUTH_DELEGATION_AUDIENCE = 'sim:managed-oauth-credentials' + +export const managedOAuthCredentialDelegationPolicy = { + audience: MANAGED_OAUTH_DELEGATION_AUDIENCE, + isWithinScope: ( + principal: Extract, + context: ManagedOAuthCredentialApplicationContext + ) => principal.resourceScope?.credentialId === context.credentialId, +} satisfies WorkspaceDelegationPolicy diff --git a/apps/sim/lib/credentials/application/managed-oauth-delegation.ts b/apps/sim/lib/credentials/application/managed-oauth-delegation.ts new file mode 100644 index 00000000000..95c85426c0d --- /dev/null +++ b/apps/sim/lib/credentials/application/managed-oauth-delegation.ts @@ -0,0 +1,41 @@ +import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import { + InvalidInternalDelegationTokenError, + verifyInternalDelegationToken, +} from '@/lib/auth/internal' +import { + bindInternalExecutorDelegation, + InvalidInternalDelegationBindingError, +} from '@/lib/auth/internal-delegation' +import { MANAGED_OAUTH_DELEGATION_AUDIENCE } from '@/lib/credentials/application/authorization' + +export class InvalidManagedOAuthDelegationError extends Error { + constructor() { + super('Managed credential execution requires valid workflow delegation') + this.name = 'InvalidManagedOAuthDelegationError' + } +} + +/** Authenticates and binds an executor delegation to one managed credential ID. */ +export async function authenticateManagedOAuthDelegation( + authorization: string, + credentialId: string +): Promise { + if (!authorization.startsWith('Bearer ')) throw new InvalidManagedOAuthDelegationError() + + try { + const claims = await verifyInternalDelegationToken(authorization.slice('Bearer '.length)) + return await bindInternalExecutorDelegation(claims, { + audience: MANAGED_OAUTH_DELEGATION_AUDIENCE, + resourceScope: { credentialId }, + }) + } catch (error) { + if ( + error instanceof InvalidInternalDelegationTokenError || + error instanceof InvalidInternalDelegationBindingError + ) { + throw new InvalidManagedOAuthDelegationError() + } + throw error + } +} diff --git a/apps/sim/lib/credentials/application/operations.ts b/apps/sim/lib/credentials/application/operations.ts index 4a3dcde7c11..3f1fad25074 100644 --- a/apps/sim/lib/credentials/application/operations.ts +++ b/apps/sim/lib/credentials/application/operations.ts @@ -7,4 +7,11 @@ export const credentialOperations = { workspaceApiKey: 'allow', principalKinds: ['personal_api_key', 'workspace_api_key'], }), + useManagedOAuth: defineWorkspaceOperation({ + id: 'credentials.managed_oauth.use', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['executor'], + }), } as const diff --git a/apps/sim/lib/credentials/application/resolve-managed-oauth-token.test.ts b/apps/sim/lib/credentials/application/resolve-managed-oauth-token.test.ts new file mode 100644 index 00000000000..0a3e2b24b73 --- /dev/null +++ b/apps/sim/lib/credentials/application/resolve-managed-oauth-token.test.ts @@ -0,0 +1,111 @@ +/** + * @vitest-environment node + */ +import type { SessionPrincipal, WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadContext: vi.fn(), + resolvePermission: vi.fn(), + resolveToken: vi.fn(), + recordAudit: vi.fn(), +})) + +vi.mock('@/lib/credentials/managed-oauth', () => ({ + loadManagedOAuthCredentialApplicationContext: mocks.loadContext, + resolveManagedOAuthToken: mocks.resolveToken, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { CREDENTIAL_ACCESSED: 'credential.accessed' }, + AuditResourceType: { CREDENTIAL: 'credential' }, + recordAudit: mocks.recordAudit, +})) + +import { resolveManagedOAuthCredentialToken } from '@/lib/credentials/application/resolve-managed-oauth-token' + +const context = { + credentialId: 'credential-1', + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, +} + +const input = { + credentialId: 'credential-1', + expectedProviderId: 'google-email', + requiredScopes: ['https://www.googleapis.com/auth/gmail.readonly'], + toolId: 'gmail_read', +} + +function executorPrincipal(credentialId = 'credential-1'): WorkflowExecutionDelegatedPrincipal { + return { + kind: 'delegated', + serviceId: 'executor', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:managed-oauth-credentials', + issuedAt: new Date(Date.now() - 1_000), + expiresAt: new Date(Date.now() + 60_000), + resourceScope: { credentialId }, + delegationContext: { kind: 'workflow_execution', workflowId: 'workflow-1' }, + } +} + +describe('resolveManagedOAuthCredentialToken', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadContext.mockResolvedValue(context) + mocks.resolvePermission.mockResolvedValue('read') + mocks.resolveToken.mockResolvedValue({ accessToken: 'access-token', refreshed: false }) + }) + + it('rejects unsupported principals before loading the credential', async () => { + const principal: SessionPrincipal = { + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', + } + + await expect( + resolveManagedOAuthCredentialToken.execute({ principal, input }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.loadContext).not.toHaveBeenCalled() + }) + + it('rejects a delegation scoped to another credential', async () => { + await expect( + resolveManagedOAuthCredentialToken.execute({ + principal: executorPrincipal('credential-2'), + input, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.resolveToken).not.toHaveBeenCalled() + }) + + it('resolves the token only after current workspace authorization', async () => { + const result = await resolveManagedOAuthCredentialToken.execute({ + principal: executorPrincipal(), + input, + }) + + expect(mocks.resolvePermission).toHaveBeenCalledWith('user-1', 'workspace-1', null, undefined, { + forUpdate: undefined, + }) + expect(mocks.resolveToken).toHaveBeenCalledWith({ + credentialId: 'credential-1', + workspaceId: 'workspace-1', + expectedProviderId: 'google-email', + requiredScopes: ['https://www.googleapis.com/auth/gmail.readonly'], + }) + expect(result).toEqual({ accessToken: 'access-token', refreshed: false }) + expect(mocks.recordAudit).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/sim/lib/credentials/application/resolve-managed-oauth-token.ts b/apps/sim/lib/credentials/application/resolve-managed-oauth-token.ts new file mode 100644 index 00000000000..3213f8731f8 --- /dev/null +++ b/apps/sim/lib/credentials/application/resolve-managed-oauth-token.ts @@ -0,0 +1,47 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { managedOAuthCredentialDelegationPolicy } from '@/lib/credentials/application/authorization' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { + loadManagedOAuthCredentialApplicationContext, + type ResolvedManagedOAuthToken, + resolveManagedOAuthToken, +} from '@/lib/credentials/managed-oauth' + +export interface ResolveManagedOAuthTokenInput { + credentialId: string + expectedProviderId: string + requiredScopes: string[] + toolId: string +} + +export const resolveManagedOAuthCredentialToken = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.useManagedOAuth, + resolveContext: async ({ input }: { input: ResolveManagedOAuthTokenInput }) => { + const context = await loadManagedOAuthCredentialApplicationContext(input.credentialId) + if (!context) throw new OrchestrationError('not_found', 'Managed credential not found') + return context + }, + authorizationOptions: { delegation: managedOAuthCredentialDelegationPolicy }, + execute: async ({ input, context }): Promise => + resolveManagedOAuthToken({ + credentialId: context.credentialId, + workspaceId: context.workspaceId, + expectedProviderId: input.expectedProviderId, + requiredScopes: input.requiredScopes, + }), + projectAudit({ input, context }) { + return { + action: AuditAction.CREDENTIAL_ACCESSED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: context.credentialId, + description: `Accessed managed OAuth credential for provider ${input.expectedProviderId}`, + metadata: { + provider: input.expectedProviderId, + credentialType: 'managed_oauth', + toolId: input.toolId, + }, + } + }, +}) diff --git a/apps/sim/lib/credentials/managed-oauth.test.ts b/apps/sim/lib/credentials/managed-oauth.test.ts new file mode 100644 index 00000000000..1df5b3543e7 --- /dev/null +++ b/apps/sim/lib/credentials/managed-oauth.test.ts @@ -0,0 +1,83 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getBilling: vi.fn(), + isAvailable: vi.fn(), + getAdapter: vi.fn(), + decryptSecret: vi.fn(), +})) + +vi.mock('@/lib/billing/core/workspace-access', () => ({ + getWorkspaceOwnerSubscriptionAccess: mocks.getBilling, +})) + +vi.mock('@/lib/credential-groups/availability', () => ({ + isCredentialGroupsAvailable: mocks.isAvailable, +})) + +vi.mock('@/lib/credential-groups/provider-registry', () => ({ + getCredentialGroupProviderAdapterByProviderId: mocks.getAdapter, +})) + +vi.mock('@/lib/core/security/encryption', () => ({ + decryptSecret: mocks.decryptSecret, + encryptSecret: vi.fn(), +})) + +import { resolveManagedOAuthToken } from '@/lib/credentials/managed-oauth' + +describe('managed OAuth token resolution', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.getBilling.mockResolvedValue({ plan: 'enterprise' }) + mocks.isAvailable.mockResolvedValue(true) + mocks.decryptSecret.mockResolvedValue({ + decrypted: JSON.stringify({ + type: 'managed-oauth-token-set', + version: 1, + tokenType: 'Bearer', + accessToken: 'xoxp-slack-token', + }), + }) + mocks.getAdapter.mockReturnValue({ + getPolicy: vi.fn().mockResolvedValue({ + authorizationAppId: 'slack:A123:T123', + scopeVersion: 1, + }), + hasRequiredScopes: vi.fn().mockReturnValue(true), + }) + }) + + it('uses a non-expiring Slack access token without entering refresh', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'credential-1', + workspaceId: 'workspace-1', + type: 'managed_oauth', + providerId: 'slack', + authorizationAppId: 'slack:A123:T123', + managedOauthScopeVersion: 1, + managedOauthStatus: 'active', + grantedScopes: ['chat:write'], + encryptedOauthTokenSet: 'encrypted-token-set', + accessTokenExpiresAt: null, + refreshTokenExpiresAt: null, + }, + ]) + + await expect( + resolveManagedOAuthToken({ + credentialId: 'credential-1', + workspaceId: 'workspace-1', + expectedProviderId: 'slack', + requiredScopes: ['chat:write'], + }) + ).resolves.toEqual({ accessToken: 'xoxp-slack-token', refreshed: false }) + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credentials/managed-oauth.ts b/apps/sim/lib/credentials/managed-oauth.ts new file mode 100644 index 00000000000..196711eab8b --- /dev/null +++ b/apps/sim/lib/credentials/managed-oauth.ts @@ -0,0 +1,441 @@ +import { db } from '@sim/db' +import { credential, credentialGroupEnrollment } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { and, eq, sql } from 'drizzle-orm' +import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access' +import type { WorkspaceAuthorizationContext } from '@/lib/core/application' +import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' +import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability' +import { + type CredentialGroupProviderAdapter, + CredentialGroupProviderConfigurationError, + type CredentialGroupProviderPolicy, +} from '@/lib/credential-groups/provider-adapter' +import { getCredentialGroupProviderAdapterByProviderId } from '@/lib/credential-groups/provider-registry' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +const logger = createLogger('ManagedOAuthCredential') + +const MANAGED_OAUTH_TOKEN_SET_TYPE = 'managed-oauth-token-set' as const +const MANAGED_OAUTH_TOKEN_SET_VERSION = 1 as const +const ACCESS_TOKEN_REFRESH_WINDOW_MS = 30_000 + +export type ManagedOAuthCredentialErrorCode = + | 'MANAGED_CREDENTIAL_NOT_FOUND' + | 'MANAGED_CREDENTIAL_UNAVAILABLE' + | 'MANAGED_CREDENTIAL_PROVIDER_MISMATCH' + | 'MANAGED_CREDENTIAL_REVOKED' + | 'MANAGED_CREDENTIAL_NEEDS_REAUTH' + | 'MANAGED_CREDENTIAL_INSUFFICIENT_SCOPE' + | 'MANAGED_CREDENTIAL_INVALID_TOKEN_SET' + | 'MANAGED_CREDENTIAL_REFRESH_FAILED' + +export class ManagedOAuthCredentialError extends Error { + constructor( + readonly code: ManagedOAuthCredentialErrorCode, + message: string, + readonly statusCode: 401 | 403 | 404 | 500 | 502 | 503 + ) { + super(message) + this.name = 'ManagedOAuthCredentialError' + } +} + +export interface ManagedOAuthTokenSet { + type: typeof MANAGED_OAUTH_TOKEN_SET_TYPE + version: typeof MANAGED_OAUTH_TOKEN_SET_VERSION + tokenType: 'Bearer' + accessToken: string + refreshToken?: string + idToken?: string +} + +export interface ResolvedManagedOAuthToken { + accessToken: string + idToken?: string + refreshed: boolean +} + +type DbOrTx = typeof db | Parameters[0]>[0] + +interface ResolveManagedOAuthTokenParams { + credentialId: string + workspaceId: string + expectedProviderId: string + requiredScopes: string[] +} + +export interface ManagedOAuthCredentialApplicationContext extends WorkspaceAuthorizationContext { + credentialId: string +} + +function isManagedOAuthTokenSet(value: unknown): value is ManagedOAuthTokenSet { + if (!value || typeof value !== 'object') return false + const candidate = value as Record + return ( + candidate.type === MANAGED_OAUTH_TOKEN_SET_TYPE && + candidate.version === MANAGED_OAUTH_TOKEN_SET_VERSION && + candidate.tokenType === 'Bearer' && + typeof candidate.accessToken === 'string' && + candidate.accessToken.length > 0 && + (candidate.refreshToken === undefined || + (typeof candidate.refreshToken === 'string' && candidate.refreshToken.length > 0)) && + (candidate.idToken === undefined || + (typeof candidate.idToken === 'string' && candidate.idToken.length > 0)) + ) +} + +/** Encrypts the versioned token envelope written by managed OAuth callbacks and refreshes. */ +export async function encryptManagedOAuthTokenSet(tokenSet: { + accessToken: string + refreshToken?: string + idToken?: string +}): Promise { + const accessToken = tokenSet.accessToken.trim() + const refreshToken = tokenSet.refreshToken?.trim() + const idToken = tokenSet.idToken?.trim() + if (!accessToken) { + throw new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_INVALID_TOKEN_SET', + 'Managed OAuth access token is empty', + 500 + ) + } + + const envelope: ManagedOAuthTokenSet = { + type: MANAGED_OAUTH_TOKEN_SET_TYPE, + version: MANAGED_OAUTH_TOKEN_SET_VERSION, + tokenType: 'Bearer', + accessToken, + ...(refreshToken ? { refreshToken } : {}), + ...(idToken ? { idToken } : {}), + } + return (await encryptSecret(JSON.stringify(envelope))).encrypted +} + +/** Decrypts and strictly validates a managed OAuth token envelope. */ +export async function decryptManagedOAuthTokenSet( + encryptedTokenSet: string +): Promise { + try { + const { decrypted } = await decryptSecret(encryptedTokenSet) + const parsed: unknown = JSON.parse(decrypted) + if (!isManagedOAuthTokenSet(parsed)) throw new Error('Invalid managed OAuth token envelope') + return parsed + } catch (error) { + logger.error('Failed to decrypt managed OAuth token set', { + error: getErrorMessage(error), + }) + throw new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_INVALID_TOKEN_SET', + 'Managed credential token data is invalid', + 500 + ) + } +} + +async function getManagedCredential(exec: DbOrTx, credentialId: string, workspaceId?: string) { + const [row] = await exec + .select({ + id: credential.id, + workspaceId: credential.workspaceId, + type: credential.type, + providerId: credential.providerId, + authorizationAppId: credential.authorizationAppId, + managedOauthScopeVersion: credential.managedOauthScopeVersion, + managedOauthStatus: credential.managedOauthStatus, + grantedScopes: credential.grantedScopes, + encryptedOauthTokenSet: credential.encryptedOauthTokenSet, + accessTokenExpiresAt: credential.accessTokenExpiresAt, + refreshTokenExpiresAt: credential.refreshTokenExpiresAt, + credentialGroupId: credentialGroupEnrollment.credentialGroupId, + }) + .from(credential) + .innerJoin( + credentialGroupEnrollment, + eq(credentialGroupEnrollment.id, credential.credentialGroupEnrollmentId) + ) + .where( + and( + eq(credential.id, credentialId), + eq(credential.type, 'managed_oauth'), + workspaceId ? eq(credential.workspaceId, workspaceId) : undefined + ) + ) + .limit(1) + return row ?? null +} + +/** Resolves the canonical workspace context for authorization without exposing token material. */ +export async function loadManagedOAuthCredentialApplicationContext( + credentialId: string +): Promise { + const row = await getManagedCredential(db, credentialId) + if (!row) return null + + const workspaceContext = await loadActiveWorkspaceApplicationContext(row.workspaceId) + if (!workspaceContext) return null + return { ...workspaceContext, credentialId: row.id } +} + +async function assertManagedCredentialUsable( + row: NonNullable>>, + expectedProviderId: string, + requiredScopes: string[] +): Promise { + if (row.providerId !== expectedProviderId) { + throw new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_PROVIDER_MISMATCH', + 'Managed credential belongs to a different provider', + 403 + ) + } + if (row.managedOauthStatus === 'revoked') { + throw new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_REVOKED', + 'Managed credential has been revoked', + 401 + ) + } + if (row.managedOauthStatus !== 'active') { + throw new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_NEEDS_REAUTH', + 'Managed credential needs to be authorized again', + 401 + ) + } + if (!row.authorizationAppId || !row.encryptedOauthTokenSet || !row.grantedScopes?.length) { + throw new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_INVALID_TOKEN_SET', + 'Managed credential metadata is incomplete', + 500 + ) + } + + const adapter = getCredentialGroupProviderAdapterByProviderId(row.providerId) + let policy: CredentialGroupProviderPolicy + try { + policy = await adapter.getPolicy(undefined, { + workspaceId: row.workspaceId, + credentialGroupId: row.credentialGroupId, + authorizationAppId: row.authorizationAppId, + }) + } catch (error) { + if (!(error instanceof CredentialGroupProviderConfigurationError)) throw error + throw new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_NEEDS_REAUTH', + 'Managed credential authorization app is unavailable', + 401 + ) + } + if ( + row.authorizationAppId !== policy.authorizationAppId || + row.managedOauthScopeVersion !== policy.scopeVersion + ) { + throw new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_NEEDS_REAUTH', + 'Managed credential was authorized with a different OAuth app', + 401 + ) + } + + if (!adapter.hasRequiredScopes(row.grantedScopes, requiredScopes)) { + throw new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_INSUFFICIENT_SCOPE', + 'Managed credential is missing one or more required scopes', + 403 + ) + } + return adapter +} + +function hasFreshAccessToken(accessTokenExpiresAt: Date | null, now: Date): boolean { + return ( + accessTokenExpiresAt === null || + accessTokenExpiresAt.getTime() > now.getTime() + ACCESS_TOKEN_REFRESH_WINDOW_MS + ) +} + +async function markManagedCredentialNeedsReauth( + exec: DbOrTx, + credentialId: string, + updatedAt: Date +): Promise { + await exec + .update(credential) + .set({ managedOauthStatus: 'needs_reauth', updatedAt }) + .where(and(eq(credential.id, credentialId), eq(credential.managedOauthStatus, 'active'))) +} + +/** Resolves a managed credential ID into a usable token without exposing it to list APIs. */ +export async function resolveManagedOAuthToken( + params: ResolveManagedOAuthTokenParams +): Promise { + const initial = await getManagedCredential(db, params.credentialId, params.workspaceId) + if (!initial) { + throw new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_NOT_FOUND', + 'Managed credential not found', + 404 + ) + } + + const ownerBilling = await getWorkspaceOwnerSubscriptionAccess(initial.workspaceId) + if (!(await isCredentialGroupsAvailable(ownerBilling))) { + throw new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_UNAVAILABLE', + 'Managed credentials are not available for this workspace', + 403 + ) + } + + try { + await assertManagedCredentialUsable(initial, params.expectedProviderId, params.requiredScopes) + } catch (error) { + if ( + error instanceof ManagedOAuthCredentialError && + error.code === 'MANAGED_CREDENTIAL_NEEDS_REAUTH' + ) { + await markManagedCredentialNeedsReauth(db, initial.id, new Date()) + } + throw error + } + const initialTokenSet = await decryptManagedOAuthTokenSet(initial.encryptedOauthTokenSet!) + const now = new Date() + if (hasFreshAccessToken(initial.accessTokenExpiresAt, now)) { + return { + accessToken: initialTokenSet.accessToken, + ...(initialTokenSet.idToken ? { idToken: initialTokenSet.idToken } : {}), + refreshed: false, + } + } + + const refreshOutcome = await db.transaction(async (tx) => { + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${`managed-oauth:${params.credentialId}`}, 0))` + ) + const current = await getManagedCredential(tx, params.credentialId, params.workspaceId) + if (!current) { + return { + error: new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_NOT_FOUND', + 'Managed credential not found', + 404 + ), + } + } + + let adapter: CredentialGroupProviderAdapter + try { + adapter = await assertManagedCredentialUsable( + current, + params.expectedProviderId, + params.requiredScopes + ) + } catch (error) { + if ( + error instanceof ManagedOAuthCredentialError && + error.code === 'MANAGED_CREDENTIAL_NEEDS_REAUTH' + ) { + await markManagedCredentialNeedsReauth(tx, current.id, new Date()) + } + return { + error: + error instanceof ManagedOAuthCredentialError + ? error + : new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_INVALID_TOKEN_SET', + 'Managed credential metadata is invalid', + 500 + ), + } + } + + const currentTokenSet = await decryptManagedOAuthTokenSet(current.encryptedOauthTokenSet!) + const lockedAt = new Date() + if (hasFreshAccessToken(current.accessTokenExpiresAt, lockedAt)) { + return { + token: { + accessToken: currentTokenSet.accessToken, + ...(currentTokenSet.idToken ? { idToken: currentTokenSet.idToken } : {}), + refreshed: false, + }, + } + } + + if ( + !currentTokenSet.refreshToken || + (current.refreshTokenExpiresAt && current.refreshTokenExpiresAt <= lockedAt) + ) { + await markManagedCredentialNeedsReauth(tx, current.id, lockedAt) + return { + error: new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_NEEDS_REAUTH', + 'Managed credential needs to be authorized again', + 401 + ), + } + } + + const refreshed = await adapter.refreshToken(currentTokenSet.refreshToken) + if (!refreshed.ok) { + const terminal = adapter.isTerminalRefreshError(refreshed.errorCode) + if (terminal) { + await markManagedCredentialNeedsReauth(tx, current.id, new Date()) + } + return { + error: new ManagedOAuthCredentialError( + terminal ? 'MANAGED_CREDENTIAL_NEEDS_REAUTH' : 'MANAGED_CREDENTIAL_REFRESH_FAILED', + terminal + ? 'Managed credential needs to be authorized again' + : 'Managed credential refresh failed', + terminal ? 401 : 502 + ), + } + } + + const encryptedOauthTokenSet = await encryptManagedOAuthTokenSet({ + accessToken: refreshed.accessToken, + refreshToken: refreshed.refreshToken, + idToken: currentTokenSet.idToken, + }) + const refreshedAt = new Date() + const accessTokenExpiresAt = new Date(refreshedAt.getTime() + refreshed.expiresIn * 1000) + const [updated] = await tx + .update(credential) + .set({ + encryptedOauthTokenSet, + accessTokenExpiresAt, + lastRefreshedAt: refreshedAt, + updatedAt: refreshedAt, + }) + .where(and(eq(credential.id, current.id), eq(credential.managedOauthStatus, 'active'))) + .returning({ id: credential.id }) + if (!updated) { + return { + error: new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_NEEDS_REAUTH', + 'Managed credential changed while its token was refreshing', + 401 + ), + } + } + + return { + token: { + accessToken: refreshed.accessToken, + ...(currentTokenSet.idToken ? { idToken: currentTokenSet.idToken } : {}), + refreshed: true, + }, + } + }) + + if ('error' in refreshOutcome && refreshOutcome.error) throw refreshOutcome.error + if ('token' in refreshOutcome && refreshOutcome.token) return refreshOutcome.token + throw new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_REFRESH_FAILED', + 'Managed credential refresh returned no token', + 500 + ) +} diff --git a/apps/sim/lib/credentials/orchestration/index.ts b/apps/sim/lib/credentials/orchestration/index.ts index f0dabc6795b..ff34fd0a244 100644 --- a/apps/sim/lib/credentials/orchestration/index.ts +++ b/apps/sim/lib/credentials/orchestration/index.ts @@ -1,11 +1,22 @@ import { AuditAction, AuditResourceType, auditUpdatedFields, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { credential, environment, webhook, workspaceEnvironment } from '@sim/db/schema' +import { + credential, + credentialGroup, + environment, + webhook, + workspaceEnvironment, +} from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, sql } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { decryptSecret } from '@/lib/core/security/encryption' +import { listSlackCredentialGroupConfigurationsForBot } from '@/lib/credential-groups/provider-configuration' +import { + SlackManagedUsersError, + verifySlackCustomBotAppIdentity, +} from '@/lib/credential-groups/slack-managed-users' import { getCredentialActorContext } from '@/lib/credentials/access' import { AtlassianValidationError } from '@/lib/credentials/atlassian-service-account' import { @@ -169,6 +180,9 @@ export async function performUpdateCredential( if (!access.credential) { return { success: false, error: 'Credential not found', errorCode: 'not_found' } } + if (access.credential.type === 'managed_oauth') { + return { success: false, error: 'Credential not found', errorCode: 'not_found' } + } if (!access.hasWorkspaceAccess || !access.isAdmin) { return { success: false, @@ -250,6 +264,41 @@ export async function performUpdateCredential( : null try { + const slackConfigurations = + providerId === SLACK_CUSTOM_BOT_PROVIDER_ID + ? await listSlackCredentialGroupConfigurationsForBot({ + workspaceId: access.credential.workspaceId, + slackBotCredentialId: access.credential.id, + }) + : [] + if (slackConfigurations.length > 0) { + if (!params.botToken) { + throw new ServiceAccountSecretError( + 'Bot token is required to reconnect a managed-user Slack app' + ) + } + try { + const identity = await verifySlackCustomBotAppIdentity(params.botToken) + if ( + slackConfigurations.some( + (configuration) => + identity.appId !== configuration.appId || identity.teamId !== configuration.teamId + ) + ) { + throw new ServiceAccountSecretError( + 'This bot token belongs to a different Slack app or workspace. Create a new custom bot credential for a different Slack app.' + ) + } + } catch (error) { + if (error instanceof ServiceAccountSecretError) throw error + if (error instanceof SlackManagedUsersError) { + throw new ServiceAccountSecretError(error.message) + } + throw new ServiceAccountSecretError( + 'Could not verify that the replacement bot token belongs to the configured Slack app' + ) + } + } const secret = await verifyAndBuildServiceAccountSecret(providerId, { signingSecret: params.signingSecret, botToken: params.botToken, @@ -387,6 +436,9 @@ export async function performDeleteCredential( if (!access.credential) { return { success: false, error: 'Credential not found', errorCode: 'not_found' } } + if (access.credential.type === 'managed_oauth') { + return { success: false, error: 'Credential not found', errorCode: 'not_found' } + } if (!access.hasWorkspaceAccess || !access.isAdmin) { return { success: false, @@ -402,6 +454,31 @@ export async function performDeleteCredential( } } + if (access.credential.providerId === SLACK_CUSTOM_BOT_PROVIDER_ID) { + const [binding] = await db + .select({ id: credentialGroup.id }) + .from(credentialGroup) + .where( + and( + eq(credentialGroup.workspaceId, access.credential.workspaceId), + sql`EXISTS ( + SELECT 1 + FROM jsonb_array_elements(${credentialGroup.options}) AS option + WHERE option->>'slackBotCredentialId' = ${access.credential.id} + AND option->>'status' = 'active' + )` + ) + ) + .limit(1) + if (binding) { + return { + success: false, + error: 'Remove this custom Slack bot from its Credential Groups before deleting it.', + errorCode: 'conflict', + } + } + } + if (access.credential.type === 'env_personal' && access.credential.envKey) { const ownerUserId = access.credential.envOwnerUserId if (!ownerUserId) { diff --git a/apps/sim/lib/credentials/queries.test.ts b/apps/sim/lib/credentials/queries.test.ts index cc5dfb3a368..e5ff19c7d1a 100644 --- a/apps/sim/lib/credentials/queries.test.ts +++ b/apps/sim/lib/credentials/queries.test.ts @@ -1,9 +1,62 @@ /** * @vitest-environment node */ -import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, drizzleOrmMock, resetDbChainMock, schemaMock } from '@sim/testing' import { beforeEach, describe, expect, it } from 'vitest' -import { listWorkspacePrincipalCredentials } from '@/lib/credentials/queries' +import { + listVisibleWorkspaceCredentials, + listWorkspacePrincipalCredentials, +} from '@/lib/credentials/queries' + +describe('listVisibleWorkspaceCredentials', () => { + beforeEach(() => { + resetDbChainMock() + }) + + it('always excludes managed OAuth credentials from selector-backed listings', async () => { + dbChainMockFns.orderBy.mockResolvedValueOnce([]) + + await listVisibleWorkspaceCredentials({ + workspaceId: 'workspace-1', + userId: 'user-1', + workspaceAccess: { canAdmin: true }, + }) + + expect(drizzleOrmMock.ne).toHaveBeenCalledWith(schemaMock.credential.type, 'managed_oauth') + }) + + it('does not expose Credential Group configuration on a custom Slack bot', async () => { + dbChainMockFns.orderBy.mockResolvedValueOnce([ + { + id: 'credential-1', + workspaceId: 'workspace-1', + type: 'service_account', + displayName: 'Support bot', + description: null, + providerId: 'slack-custom-bot', + accountId: null, + envKey: null, + envOwnerUserId: null, + createdBy: 'user-1', + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), + encryptedServiceAccountKey: 'encrypted', + memberRole: null, + }, + ]) + + const { data } = await listVisibleWorkspaceCredentials({ + workspaceId: 'workspace-1', + userId: 'user-1', + workspaceAccess: { canAdmin: true }, + }) + const [result] = data + + expect(result).not.toHaveProperty('managedOAuthConfigurationStatus') + expect(result).not.toHaveProperty('authorizationAppId') + expect(result).not.toHaveProperty('managedOauthScopeVersion') + }) +}) describe('listWorkspacePrincipalCredentials', () => { beforeEach(() => { diff --git a/apps/sim/lib/credentials/queries.ts b/apps/sim/lib/credentials/queries.ts index 607145b80a7..92122ebf37f 100644 --- a/apps/sim/lib/credentials/queries.ts +++ b/apps/sim/lib/credentials/queries.ts @@ -1,6 +1,6 @@ import { db } from '@sim/db' import { credential, credentialMember } from '@sim/db/schema' -import { and, eq, inArray, isNotNull, or, sql } from 'drizzle-orm' +import { and, eq, inArray, isNotNull, ne, or, sql } from 'drizzle-orm' import type { V2CredentialSortBy } from '@/lib/api/contracts/v2/credentials' import { type CursorKey, @@ -119,7 +119,10 @@ export async function listVisibleWorkspaceCredentials(params: { limit, } = params - const whereClauses = [eq(credential.workspaceId, workspaceId)] + const whereClauses = [ + eq(credential.workspaceId, workspaceId), + ne(credential.type, 'managed_oauth'), + ] if (types?.length) whereClauses.push(inArray(credential.type, types)) if (providerId) whereClauses.push(eq(credential.providerId, providerId)) const ownedEnvSecretsClause = params.ownedEnvSecretsOnly diff --git a/apps/sim/lib/oauth/credential-service.ts b/apps/sim/lib/oauth/credential-service.ts index 7a1cf470d4b..84eaf0fc674 100644 --- a/apps/sim/lib/oauth/credential-service.ts +++ b/apps/sim/lib/oauth/credential-service.ts @@ -114,6 +114,17 @@ export async function resolveOAuthAccountId( } } + if (credentialRow.type === 'managed_oauth') { + return { + accountId: '', + credentialId: credentialRow.id, + credentialType: 'managed_oauth', + workspaceId: credentialRow.workspaceId, + providerId: credentialRow.providerId ?? undefined, + usedCredentialTable: true, + } + } + if (credentialRow.type !== 'oauth' || !credentialRow.accountId) { return null } diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 0ba237f5fa2..125f3de697c 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -2003,7 +2003,6 @@ export async function refreshOAuthToken( hasClientId: !!config.clientId, hasClientSecret: !!config.clientSecret, hasRefreshToken: !!refreshToken, - refreshTokenPrefix: refreshToken ? `${refreshToken.substring(0, 10)}...` : 'none', }) return { ok: false, diff --git a/apps/sim/lib/oauth/token-resolution.ts b/apps/sim/lib/oauth/token-resolution.ts index 4ee18823f8b..298ccd29592 100644 --- a/apps/sim/lib/oauth/token-resolution.ts +++ b/apps/sim/lib/oauth/token-resolution.ts @@ -9,6 +9,7 @@ import type { AuthResult } from '@/lib/auth/hybrid' import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' import { getCredential, + type ResolvedCredential, refreshTokenIfNeeded, resolveOAuthAccountId, resolveServiceAccountToken, @@ -45,6 +46,8 @@ export interface ResolveCredentialTokenInput { */ callerUserId?: string auditRequest?: CredentialAuditRequest + /** Reuses a credential lookup already performed by the route's managed-OAuth dispatch. */ + resolvedCredential?: ResolvedCredential | null } export type ResolveCredentialTokenResult = @@ -187,7 +190,9 @@ export async function resolveCredentialToken( * on the other, so they resolve together — this runs per credentialed tool call. */ const [resolved, authz] = await Promise.all([ - resolveOAuthAccountId(credentialId), + input.resolvedCredential === undefined + ? resolveOAuthAccountId(credentialId) + : input.resolvedCredential, authorizeCredentialUseForAuth(auth, { credentialId, workflowId, callerUserId }), ]) diff --git a/apps/sim/lib/posthog/events.ts b/apps/sim/lib/posthog/events.ts index c822757165b..c0d31b0d5c7 100644 --- a/apps/sim/lib/posthog/events.ts +++ b/apps/sim/lib/posthog/events.ts @@ -334,25 +334,45 @@ export interface PostHogEventMap { } credential_connected: { - credential_type: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' + credential_type: + | 'oauth' + | 'managed_oauth' + | 'env_workspace' + | 'env_personal' + | 'service_account' provider_id: string workspace_id: string } credential_deleted: { - credential_type: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' + credential_type: + | 'oauth' + | 'managed_oauth' + | 'env_workspace' + | 'env_personal' + | 'service_account' provider_id: string workspace_id: string } credential_shared: { - credential_type: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' + credential_type: + | 'oauth' + | 'managed_oauth' + | 'env_workspace' + | 'env_personal' + | 'service_account' role: 'admin' | 'member' workspace_id: string } credential_unshared: { - credential_type: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' + credential_type: + | 'oauth' + | 'managed_oauth' + | 'env_workspace' + | 'env_personal' + | 'service_account' workspace_id: string } @@ -733,7 +753,12 @@ export interface PostHogEventMap { /** A stored credential's plaintext secret was deliberately retrieved via the token API. */ credential_used: { - credential_type: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' + credential_type: + | 'oauth' + | 'managed_oauth' + | 'env_workspace' + | 'env_personal' + | 'service_account' provider_id: string workspace_id?: string } diff --git a/apps/sim/lib/uploads/upload-session/service.ts b/apps/sim/lib/uploads/upload-session/service.ts index 73fed9321c1..98f972f4250 100644 --- a/apps/sim/lib/uploads/upload-session/service.ts +++ b/apps/sim/lib/uploads/upload-session/service.ts @@ -456,6 +456,11 @@ export function createUploadSessionAuthBinding( }, } } + case 'credential_group_enrollment': + throw new UploadSessionError( + 'forbidden', + 'Credential Group enrollment principals cannot create uploads' + ) } } diff --git a/apps/sim/lib/workspaces/host-context.ts b/apps/sim/lib/workspaces/host-context.ts index d350c5a1763..4b01ec40ece 100644 --- a/apps/sim/lib/workspaces/host-context.ts +++ b/apps/sim/lib/workspaces/host-context.ts @@ -1,6 +1,7 @@ import { cache } from 'react' import type { WorkspaceHostContext } from '@/lib/api/contracts/workspaces' import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access' +import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability' import { getOrganizationSettingsAccess } from '@/lib/organizations/settings-access' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' @@ -27,6 +28,7 @@ async function resolveWorkspaceHostContextForViewer( ? getOrganizationSettingsAccess(hostOrganizationId, userId) : Promise.resolve({ isMember: false, isAdmin: false }), ]) + const credentialGroupsAvailable = await isCredentialGroupsAvailable(ownerBilling) return { workspace: { @@ -42,6 +44,9 @@ async function resolveWorkspaceHostContextForViewer( isHostOrganizationMember: hostOrganizationAccess.isMember, isHostOrganizationAdmin: hostOrganizationAccess.isAdmin, }, + features: { + credentialGroups: credentialGroupsAvailable, + }, } } diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index e6b4e1a82b2..bd8a5f3d9ea 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -254,7 +254,11 @@ const mockRegistryTools: Record = { name: 'Gmail Read', description: 'Read Gmail messages', version: '1.0.0', - oauth: { required: true, provider: 'google-email' }, + oauth: { + required: true, + provider: 'google-email', + requiredScopes: ['https://www.googleapis.com/auth/gmail.readonly'], + }, params: {}, request: { url: '/api/tools/gmail/read', method: 'GET' }, }, @@ -263,7 +267,11 @@ const mockRegistryTools: Record = { name: 'Gmail Send', description: 'Send Gmail messages', version: '1.0.0', - oauth: { required: true, provider: 'google-email' }, + oauth: { + required: true, + provider: 'google-email', + requiredScopes: ['https://www.googleapis.com/auth/gmail.modify'], + }, params: {}, request: { url: '/api/tools/gmail/send', method: 'POST' }, }, @@ -3779,6 +3787,58 @@ describe('Copilot OAuth Credential Enforcement', () => { }) }) +describe('Managed OAuth Credential Delegation', () => { + it('passes an opaque credential ID with trusted tool scope and origin-bound delegation', async () => { + mockGenerateInternalToken.mockResolvedValueOnce('legacy-token') + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ accessToken: 'managed-access-token' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ messages: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + global.fetch = Object.assign(fetchMock, { preconnect: vi.fn() }) as typeof fetch + + const executorDelegationOrigin = { + subjectUserId: 'origin-user', + workflowId: 'origin-workflow', + executionId: 'origin-execution', + } + const context = createToolExecutionContext({ + userId: 'current-user', + workflowId: 'current-workflow', + executionId: 'current-execution', + executorDelegationOrigin, + }) + + await executeTool( + 'gmail_read', + { oauthCredential: 'managed-credential-id' }, + { executionContext: context } + ) + + expect(mockGenerateInternalDelegationToken).toHaveBeenCalledWith(executorDelegationOrigin) + const [tokenUrl, tokenRequest] = fetchMock.mock.calls[0] + expect(String(tokenUrl)).toContain('/api/auth/oauth/token') + expect(tokenRequest.headers).toMatchObject({ + Authorization: 'Bearer legacy-token', + 'x-sim-managed-oauth-delegation': 'Bearer executor-token', + }) + expect(JSON.parse(tokenRequest.body)).toMatchObject({ + credentialId: 'managed-credential-id', + toolId: 'gmail_read', + scopes: ['https://www.googleapis.com/auth/gmail.readonly'], + }) + }) +}) + describe('Copilot Env Variable Reference Resolution', () => { let cleanupEnvVars: () => void diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 95dd3735c8e..7bcae647256 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -4,6 +4,7 @@ import { sleep } from '@sim/utils/helpers' import { isPlainRecord, isRecordLike } from '@sim/utils/object' import { backoffWithJitter, parseRetryAfter } from '@sim/utils/retry' import { DrizzleQueryError } from 'drizzle-orm/errors' +import { MANAGED_OAUTH_DELEGATION_HEADER } from '@/lib/api/contracts/oauth-connections' import { getBYOKKey } from '@/lib/api-key/byok' import { type GenerateInternalDelegationTokenInput, @@ -1700,11 +1701,12 @@ async function executeToolImplementation( `[${requestId}] Tool ${toolId} needs access token for credential: ${contextParams.credential}` ) try { - const workflowId = contextParams._context?.workflowId - const userId = contextParams._context?.userId + const workflowId = scope.workflowId + const userId = scope.userId const tokenPayload: OAuthTokenPayload = { credentialId: contextParams.credential as string, + toolId, } if (workflowId) { tokenPayload.workflowId = workflowId @@ -1713,8 +1715,9 @@ async function executeToolImplementation( tokenPayload.impersonateEmail = contextParams.impersonateUserEmail as string } if (tool?.oauth?.provider) { - const { getCanonicalScopesForProvider } = await import('@/lib/oauth/utils') - const providerScopes = getCanonicalScopesForProvider(tool.oauth.provider) + const providerScopes = + tool.oauth.requiredScopes ?? + (await import('@/lib/oauth/utils')).getCanonicalScopesForProvider(tool.oauth.provider) if (providerScopes.length > 0) { tokenPayload.scopes = providerScopes } @@ -1763,6 +1766,21 @@ async function executeToolImplementation( } catch (_e) { // Swallow token generation errors; the request will fail and be reported upstream } + const managedCredentialDelegation = + executionContext?.executorDelegationOrigin ?? + (workflowId && userId + ? { + subjectUserId: userId, + workflowId, + ...(scope.executionId ? { executionId: scope.executionId } : {}), + } + : undefined) + if (managedCredentialDelegation) { + const delegationHeaders = await buildExecutorDelegationHeaders( + managedCredentialDelegation + ) + tokenHeaders[MANAGED_OAUTH_DELEGATION_HEADER] = delegationHeaders.Authorization + } } // boundary-raw-fetch: same-origin token route, authenticated by internal JWT on the server and the session cookie in the browser diff --git a/apps/sim/tools/types.ts b/apps/sim/tools/types.ts index 18f42aa4a2a..a7f910166f2 100644 --- a/apps/sim/tools/types.ts +++ b/apps/sim/tools/types.ts @@ -287,6 +287,7 @@ export interface OAuthTokenPayload { credentialId?: string credentialAccountUserId?: string providerId?: string + toolId?: string workflowId?: string impersonateEmail?: string scopes?: string[] diff --git a/apps/sim/triggers/slack/capabilities.test.ts b/apps/sim/triggers/slack/capabilities.test.ts index be56ce1cd07..4e965129564 100644 --- a/apps/sim/triggers/slack/capabilities.test.ts +++ b/apps/sim/triggers/slack/capabilities.test.ts @@ -53,3 +53,26 @@ describe('buildSlackManifest - description', () => { ) }) }) + +describe('buildSlackManifest - managed users', () => { + it('adds user OAuth configuration and its bot prerequisite', () => { + const manifest = buildSlackManifest(new Set(['action_send']), { + appName: 'Managed Slack', + webhookUrl: 'https://sim.ai/api/webhooks/slack/custom/credential-id', + managedUserAuthorization: { + redirectUrls: ['https://sim.ai/setup', 'https://sim.ai/connect'], + userScopes: ['im:history', 'users:read'], + }, + }) + + expect(manifest).toMatchObject({ + oauth_config: { + redirect_urls: ['https://sim.ai/setup', 'https://sim.ai/connect'], + scopes: { + bot: ['chat:write', 'users:read'], + user: ['im:history', 'users:read'], + }, + }, + }) + }) +}) diff --git a/apps/sim/triggers/slack/capabilities.ts b/apps/sim/triggers/slack/capabilities.ts index 26bae846059..b2499064062 100644 --- a/apps/sim/triggers/slack/capabilities.ts +++ b/apps/sim/triggers/slack/capabilities.ts @@ -213,6 +213,10 @@ export interface BuildManifestOptions { webhookUrl: string | null /** Shown on the bot's Slack profile and as the assistant description. */ description?: string + managedUserAuthorization?: { + redirectUrls: readonly string[] + userScopes: readonly string[] + } } /** @@ -227,10 +231,15 @@ export interface BuildManifestOptions { */ export function buildSlackManifest( enabled: ReadonlySet, - { appName, webhookUrl, description }: BuildManifestOptions + { appName, webhookUrl, description, managedUserAuthorization }: BuildManifestOptions ): Record { const active = SLACK_CAPABILITIES.filter((c) => enabled.has(c.id)) - const scopes = [...new Set(active.flatMap((c) => c.scopes))].sort() + const scopes = [ + ...new Set([ + ...active.flatMap((c) => c.scopes), + ...(managedUserAuthorization ? ['users:read'] : []), + ]), + ].sort() const events = [...new Set(active.flatMap((c) => c.events))].sort() const displayName = appName.trim() || 'Sim Workflow Bot' const trimmedDescription = description?.trim() || '' @@ -255,14 +264,27 @@ export function buildSlackManifest( } } + const oauthConfig: Record = { scopes: { bot: scopes } } + if (managedUserAuthorization) { + const redirectUrls = [ + ...new Set(managedUserAuthorization.redirectUrls.map((url) => url.trim()).filter(Boolean)), + ] + const userScopes = [ + ...new Set(managedUserAuthorization.userScopes.map((scope) => scope.trim()).filter(Boolean)), + ].sort() + if (redirectUrls.length === 0 || userScopes.length === 0) { + throw new Error('Managed Slack users require redirect URLs and user scopes') + } + oauthConfig.redirect_urls = redirectUrls + oauthConfig.scopes = { bot: scopes, user: userScopes } + } + const manifest: Record = { display_information: trimmedDescription ? { name: displayName, description: trimmedDescription } : { name: displayName }, features, - oauth_config: { - scopes: { bot: scopes }, - }, + oauth_config: oauthConfig, settings: { org_deploy_enabled: false, socket_mode_enabled: false, diff --git a/packages/audit/src/types.ts b/packages/audit/src/types.ts index f33e8269094..d82dc76bbaf 100644 --- a/packages/audit/src/types.ts +++ b/packages/audit/src/types.ts @@ -124,6 +124,7 @@ export const AuditAction = { CREDENTIAL_MEMBER_ADDED: 'credential_member.added', CREDENTIAL_MEMBER_REMOVED: 'credential_member.removed', CREDENTIAL_MEMBER_ROLE_CHANGED: 'credential_member.role_changed', + CREDENTIAL_GROUP_UPDATED: 'credential_group.updated', // Password PASSWORD_RESET_REQUESTED: 'password.reset_requested', @@ -225,6 +226,7 @@ export const AuditResourceType = { CHAT: 'chat', CONNECTOR: 'connector', CREDENTIAL: 'credential', + CREDENTIAL_GROUP: 'credential_group', CUSTOM_BLOCK: 'custom_block', CUSTOM_TOOL: 'custom_tool', DATA_DRAIN: 'data_drain', diff --git a/packages/auth/src/principal.ts b/packages/auth/src/principal.ts index 114fe2a0663..51b9f479bf0 100644 --- a/packages/auth/src/principal.ts +++ b/packages/auth/src/principal.ts @@ -3,6 +3,7 @@ export type Principal = | PersonalApiKeyPrincipal | WorkspaceApiKeyPrincipal | DelegatedPrincipal + | CredentialGroupEnrollmentPrincipal export interface SessionPrincipal { kind: 'session' @@ -36,9 +37,21 @@ export interface DelegatedPrincipal { tableId?: string chatId?: string executionId?: string + credentialId?: string + credentialGroupId?: string } } +/** Bearer identity established by a currently valid Credential Group invitation. */ +export interface CredentialGroupEnrollmentPrincipal { + kind: 'credential_group_enrollment' + workspaceId: string + credentialGroupId: string + enrollmentId: string + email: string + invitationTokenHash: string +} + export type DelegatedServiceId = DelegatedPrincipal['serviceId'] export class PrincipalSubjectUserRequiredError extends Error { @@ -57,6 +70,7 @@ export function requirePrincipalSubjectUserId(principal: Principal): string { case 'delegated': return principal.subjectUserId case 'workspace_api_key': + case 'credential_group_enrollment': throw new PrincipalSubjectUserRequiredError(principal.kind) } } @@ -82,6 +96,13 @@ export type PrincipalActor = subjectUserId: string delegationId: string } + | { + kind: 'credential_group_enrollment' + workspaceId: string + credentialGroupId: string + enrollmentId: string + email: string + } export interface PrincipalAttribution { actor: PrincipalActor @@ -125,6 +146,14 @@ export function toPrincipalActor(principal: Principal): PrincipalActor { subjectUserId: principal.subjectUserId, delegationId: principal.delegationId, } + case 'credential_group_enrollment': + return { + kind: principal.kind, + workspaceId: principal.workspaceId, + credentialGroupId: principal.credentialGroupId, + enrollmentId: principal.enrollmentId, + email: principal.email, + } } } @@ -140,6 +169,8 @@ export function resolvePrincipalAuditAttribution(principal: Principal): Principa return { actor, actorId: actor.subjectUserId } case 'workspace_api_key': return { actor, actorId: null, actorName: 'Workspace API key' } + case 'credential_group_enrollment': + return { actor, actorId: null, actorName: actor.email } } } @@ -162,5 +193,7 @@ export function resolvePrincipalAttribution( } case 'delegated': return { actor, attributedUserId: actor.subjectUserId } + case 'credential_group_enrollment': + throw new PrincipalSubjectUserRequiredError(actor.kind) } } diff --git a/packages/db/migrations/0291_fuzzy_wong.sql b/packages/db/migrations/0291_fuzzy_wong.sql new file mode 100644 index 00000000000..45509a6667c --- /dev/null +++ b/packages/db/migrations/0291_fuzzy_wong.sql @@ -0,0 +1,86 @@ +-- migration-safe: additive enums, nullable columns, and new empty tables are backward-compatible; constraints on the existing credential table are NOT VALID and its indexes are created concurrently. +CREATE TYPE "public"."credential_group_enrollment_status" AS ENUM('invited', 'delivery_failed', 'in_progress', 'completed', 'revoked');--> statement-breakpoint +CREATE TYPE "public"."credential_group_status" AS ENUM('active', 'disabled');--> statement-breakpoint +CREATE TYPE "public"."managed_oauth_credential_status" AS ENUM('active', 'needs_reauth', 'revoked');--> statement-breakpoint +ALTER TYPE "public"."credential_type" ADD VALUE 'managed_oauth' BEFORE 'env_workspace';--> statement-breakpoint +CREATE TABLE "credential_group" ( + "id" text PRIMARY KEY NOT NULL, + "workspace_id" text NOT NULL, + "public_id" text NOT NULL, + "name" text NOT NULL, + "description" text, + "options" jsonb NOT NULL, + "encrypted_provider_configuration" text, + "status" "credential_group_status" DEFAULT 'active' NOT NULL, + "created_by" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "credential_group_enrollment" ( + "id" text PRIMARY KEY NOT NULL, + "credential_group_id" text NOT NULL, + "email" text NOT NULL, + "status" "credential_group_enrollment_status" DEFAULT 'invited' NOT NULL, + "invitation_token_hash" text NOT NULL, + "invitation_expires_at" timestamp NOT NULL, + "invited_at" timestamp NOT NULL, + "sent_at" timestamp, + "completed_at" timestamp, + "revoked_at" timestamp, + "last_delivery_error" text, + "created_by" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "credential_group_enrollment_normalized_email_check" CHECK ("credential_group_enrollment"."email" = lower(btrim("credential_group_enrollment"."email")) AND length("credential_group_enrollment"."email") BETWEEN 3 AND 320), + CONSTRAINT "credential_group_enrollment_invitation_token_hash_length_check" CHECK (length("credential_group_enrollment"."invitation_token_hash") = 64) +); +--> statement-breakpoint +ALTER TABLE "credential" ADD COLUMN "authorization_app_id" text;--> statement-breakpoint +ALTER TABLE "credential" ADD COLUMN "credential_group_enrollment_id" text;--> statement-breakpoint +ALTER TABLE "credential" ADD COLUMN "credential_group_option_id" text;--> statement-breakpoint +ALTER TABLE "credential" ADD COLUMN "managed_oauth_scope_version" integer;--> statement-breakpoint +ALTER TABLE "credential" ADD COLUMN "provider_subject_id" text;--> statement-breakpoint +ALTER TABLE "credential" ADD COLUMN "provider_tenant_id" text;--> statement-breakpoint +ALTER TABLE "credential" ADD COLUMN "managed_oauth_status" "managed_oauth_credential_status";--> statement-breakpoint +ALTER TABLE "credential" ADD COLUMN "granted_scopes" text[];--> statement-breakpoint +ALTER TABLE "credential" ADD COLUMN "provider_metadata" jsonb;--> statement-breakpoint +ALTER TABLE "credential" ADD COLUMN "encrypted_oauth_token_set" text;--> statement-breakpoint +ALTER TABLE "credential" ADD COLUMN "granted_at" timestamp;--> statement-breakpoint +ALTER TABLE "credential" ADD COLUMN "revoked_at" timestamp;--> statement-breakpoint +ALTER TABLE "credential" ADD COLUMN "access_token_expires_at" timestamp;--> statement-breakpoint +ALTER TABLE "credential" ADD COLUMN "refresh_token_expires_at" timestamp;--> statement-breakpoint +ALTER TABLE "credential" ADD COLUMN "last_refreshed_at" timestamp;--> statement-breakpoint +ALTER TABLE "credential_group" ADD CONSTRAINT "credential_group_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "credential_group" ADD CONSTRAINT "credential_group_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "credential_group_enrollment" ADD CONSTRAINT "credential_group_enrollment_credential_group_id_credential_group_id_fk" FOREIGN KEY ("credential_group_id") REFERENCES "public"."credential_group"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "credential_group_enrollment" ADD CONSTRAINT "credential_group_enrollment_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "credential_group_public_id_unique" ON "credential_group" USING btree ("public_id");--> statement-breakpoint +CREATE INDEX "credential_group_workspace_status_idx" ON "credential_group" USING btree ("workspace_id","status");--> statement-breakpoint +CREATE UNIQUE INDEX "credential_group_workspace_name_unique" ON "credential_group" USING btree ("workspace_id",lower("name"));--> statement-breakpoint +CREATE UNIQUE INDEX "credential_group_enrollment_group_email_unique" ON "credential_group_enrollment" USING btree ("credential_group_id","email");--> statement-breakpoint +CREATE UNIQUE INDEX "credential_group_enrollment_invitation_token_hash_unique" ON "credential_group_enrollment" USING btree ("invitation_token_hash");--> statement-breakpoint +CREATE INDEX "credential_group_enrollment_group_status_idx" ON "credential_group_enrollment" USING btree ("credential_group_id","status");--> statement-breakpoint +CREATE INDEX "credential_group_enrollment_group_invited_at_id_idx" ON "credential_group_enrollment" USING btree ("credential_group_id","invited_at","id");--> statement-breakpoint +ALTER TABLE "credential" ADD CONSTRAINT "credential_credential_group_enrollment_id_credential_group_enrollment_id_fk" FOREIGN KEY ("credential_group_enrollment_id") REFERENCES "public"."credential_group_enrollment"("id") ON DELETE cascade ON UPDATE no action NOT VALID;--> statement-breakpoint +COMMIT;--> statement-breakpoint +SET lock_timeout = 0;--> statement-breakpoint +CREATE INDEX CONCURRENTLY IF NOT EXISTS "credential_group_enrollment_idx" ON "credential" USING btree ("credential_group_enrollment_id");--> statement-breakpoint +CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "credential_group_option_unique" ON "credential" USING btree ("credential_group_enrollment_id","credential_group_option_id") WHERE "credential"."type" = 'managed_oauth';--> statement-breakpoint +ALTER TABLE "credential" ADD CONSTRAINT "credential_managed_oauth_source_check" CHECK ((type::text <> 'managed_oauth') OR ( + account_id IS NULL + AND provider_id IS NOT NULL + AND authorization_app_id IS NOT NULL + AND provider_subject_id IS NOT NULL + AND managed_oauth_status IS NOT NULL + AND granted_scopes IS NOT NULL + AND cardinality(granted_scopes) > 0 + AND encrypted_oauth_token_set IS NOT NULL + AND granted_at IS NOT NULL + )) NOT VALID;--> statement-breakpoint +ALTER TABLE "credential" ADD CONSTRAINT "credential_managed_oauth_group_binding_check" CHECK ((type::text <> 'managed_oauth') OR ( + credential_group_enrollment_id IS NOT NULL + AND credential_group_option_id IS NOT NULL + AND managed_oauth_scope_version IS NOT NULL + AND managed_oauth_scope_version > 0 + )) NOT VALID; diff --git a/packages/db/migrations/meta/0291_snapshot.json b/packages/db/migrations/meta/0291_snapshot.json new file mode 100644 index 00000000000..ce1538c2829 --- /dev/null +++ b/packages/db/migrations/meta/0291_snapshot.json @@ -0,0 +1,19634 @@ +{ + "id": "56c6fcb7-e404-407f-a24f-3963cae57f78", + "prevId": "836e092e-f975-4cb6-b12e-f02f5e11551a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_number_idx": { + "name": "academy_certificate_number_idx", + "columns": [ + { + "expression": "certificate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_work_status": { + "name": "background_work_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "background_work_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_work_status_value", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_work_status_workspace_status_idx": { + "name": "background_work_status_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_workflow_status_idx": { + "name": "background_work_status_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_child_ws_idx": { + "name": "background_work_status_meta_child_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'childWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_other_ws_idx": { + "name": "background_work_status_meta_other_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'otherWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "background_work_status_workspace_id_workspace_id_fk": { + "name": "background_work_status_workspace_id_workspace_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "background_work_status_workflow_id_workflow_id_fk": { + "name": "background_work_status_workflow_id_workflow_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "include_thinking": { + "name": "include_thinking", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_tool_calls": { + "name": "include_tool_calls", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permission_decision": { + "name": "permission_decision", + "type": "copilot_tool_permission_decision", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "permission_decided_at": { + "name": "permission_decided_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_idx": { + "name": "copilot_async_tool_calls_tool_call_id_idx", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "auto_allowed_tools": { + "name": "auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_deleted_partial_idx": { + "name": "copilot_chats_user_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_chats\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_user_created_at_idx": { + "name": "copilot_messages_user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"role\" = 'user' AND \"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "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 + }, + "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": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_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_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_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_credential_group_enrollment_id_credential_group_enrollment_id_fk": { + "name": "credential_credential_group_enrollment_id_credential_group_enrollment_id_fk", + "tableFrom": "credential", + "tableTo": "credential_group_enrollment", + "columnsFrom": ["credential_group_enrollment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_managed_oauth_source_check": { + "name": "credential_managed_oauth_source_check", + "value": "(type::text <> 'managed_oauth') OR (\n account_id IS NULL\n AND provider_id IS NOT NULL\n AND authorization_app_id IS NOT NULL\n AND provider_subject_id IS NOT NULL\n AND managed_oauth_status IS NOT NULL\n AND granted_scopes IS NOT NULL\n AND cardinality(granted_scopes) > 0\n AND encrypted_oauth_token_set IS NOT NULL\n AND granted_at IS NOT NULL\n )" + }, + "credential_managed_oauth_group_binding_check": { + "name": "credential_managed_oauth_group_binding_check", + "value": "(type::text <> 'managed_oauth') OR (\n credential_group_enrollment_id IS NOT NULL\n AND credential_group_option_id IS NOT NULL\n AND managed_oauth_scope_version IS NOT NULL\n AND managed_oauth_scope_version > 0\n )" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_group": { + "name": "credential_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_id": { + "name": "public_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "encrypted_provider_configuration": { + "name": "encrypted_provider_configuration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "credential_group_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_group_public_id_unique": { + "name": "credential_group_public_id_unique", + "columns": [ + { + "expression": "public_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_workspace_status_idx": { + "name": "credential_group_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_workspace_name_unique": { + "name": "credential_group_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"name\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_group_workspace_id_workspace_id_fk": { + "name": "credential_group_workspace_id_workspace_id_fk", + "tableFrom": "credential_group", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_created_by_user_id_fk": { + "name": "credential_group_created_by_user_id_fk", + "tableFrom": "credential_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_group_enrollment": { + "name": "credential_group_enrollment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_group_id": { + "name": "credential_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "credential_group_enrollment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'invited'" + }, + "invitation_token_hash": { + "name": "invitation_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invitation_expires_at": { + "name": "invitation_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "invited_at": { + "name": "invited_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_delivery_error": { + "name": "last_delivery_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_group_enrollment_group_email_unique": { + "name": "credential_group_enrollment_group_email_unique", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_invitation_token_hash_unique": { + "name": "credential_group_enrollment_invitation_token_hash_unique", + "columns": [ + { + "expression": "invitation_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_group_status_idx": { + "name": "credential_group_enrollment_group_status_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_group_invited_at_id_idx": { + "name": "credential_group_enrollment_group_invited_at_id_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invited_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_group_enrollment_credential_group_id_credential_group_id_fk": { + "name": "credential_group_enrollment_credential_group_id_credential_group_id_fk", + "tableFrom": "credential_group_enrollment", + "tableTo": "credential_group", + "columnsFrom": ["credential_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_enrollment_created_by_user_id_fk": { + "name": "credential_group_enrollment_created_by_user_id_fk", + "tableFrom": "credential_group_enrollment", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_group_enrollment_normalized_email_check": { + "name": "credential_group_enrollment_normalized_email_check", + "value": "\"credential_group_enrollment\".\"email\" = lower(btrim(\"credential_group_enrollment\".\"email\")) AND length(\"credential_group_enrollment\".\"email\") BETWEEN 3 AND 320" + }, + "credential_group_enrollment_invitation_token_hash_length_check": { + "name": "credential_group_enrollment_invitation_token_hash_length_check", + "value": "length(\"credential_group_enrollment\".\"invitation_token_hash\") = 64" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_block": { + "name": "custom_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_block_organization_id_idx": { + "name": "custom_block_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_workflow_id_idx": { + "name": "custom_block_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_organization_type_unique": { + "name": "custom_block_organization_type_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_block_organization_id_organization_id_fk": { + "name": "custom_block_organization_id_organization_id_fk", + "tableFrom": "custom_block", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_workflow_id_workflow_id_fk": { + "name": "custom_block_workflow_id_workflow_id_fk", + "tableFrom": "custom_block", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_created_by_user_id_fk": { + "name": "custom_block_created_by_user_id_fk", + "tableFrom": "custom_block", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_secret_provenance": { + "name": "document_secret_provenance", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "document_secret_provenance_document_id_document_id_fk": { + "name": "document_secret_provenance_document_id_document_id_fk", + "tableFrom": "document_secret_provenance", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "document_secret_provenance_status_check": { + "name": "document_secret_provenance_status_check", + "value": "\"document_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.embedding_secret_provenance": { + "name": "embedding_secret_provenance", + "schema": "", + "columns": { + "embedding_id": { + "name": "embedding_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "embedding_secret_provenance_embedding_id_embedding_id_fk": { + "name": "embedding_secret_provenance_embedding_id_embedding_id_fk", + "tableFrom": "embedding_secret_provenance", + "tableTo": "embedding", + "columnsFrom": ["embedding_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_secret_provenance_status_check": { + "name": "embedding_secret_provenance_status_check", + "value": "\"embedding_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_references_workflow_id_idx": { + "name": "execution_large_value_references_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": ["key", "execution_id", "source"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_workflow_id_idx": { + "name": "execution_large_values_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.folder": { + "name": "folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "folder_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "folder_user_idx": { + "name": "folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_idx": { + "name": "folder_workspace_resource_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_parent_sort_idx": { + "name": "folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_deleted_at_idx": { + "name": "folder_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_deleted_partial_idx": { + "name": "folder_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"folder\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_name_active_unique": { + "name": "folder_workspace_resource_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"folder\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "folder_user_id_user_id_fk": { + "name": "folder_user_id_user_id_fk", + "tableFrom": "folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_workspace_id_workspace_id_fk": { + "name": "folder_workspace_id_workspace_id_fk", + "tableFrom": "folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_parent_id_folder_id_fk": { + "name": "folder_parent_id_folder_id_fk", + "tableFrom": "folder", + "tableTo": "folder", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "invitation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "membership_intent": { + "name": "membership_intent", + "type": "invitation_membership_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_status_idx": { + "name": "invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_pending_email_org_unique": { + "name": "invitation_pending_email_org_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"invitation\".\"status\" = 'pending' AND \"invitation\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitation_token_unique": { + "name": "invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_workspace_grant": { + "name": "invitation_workspace_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_workspace_grant_unique": { + "name": "invitation_workspace_grant_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_workspace_grant_workspace_id_idx": { + "name": "invitation_workspace_grant_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_grant_invitation_id_invitation_id_fk": { + "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "invitation", + "columnsFrom": ["invitation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_workspace_grant_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_ended_at_id_idx": { + "name": "job_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workflow_schedule", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_folder_id_idx": { + "name": "kb_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_folder_id_folder_id_fk": { + "name": "knowledge_base_folder_id_folder_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_id_idx": { + "name": "kcsl_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "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": { + "mothership_settings_workspace_id_idx": { + "name": "mothership_settings_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "session_policy_settings": { + "name": "session_policy_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "security_policy_version": { + "name": "security_policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_member_usage_limit": { + "name": "organization_member_usage_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_member_usage_limit_org_user_unique": { + "name": "org_member_usage_limit_org_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_member_usage_limit_organization_id_idx": { + "name": "org_member_usage_limit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_member_usage_limit_organization_id_organization_id_fk": { + "name": "organization_member_usage_limit_organization_id_organization_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["set_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_type_created_idx": { + "name": "outbox_event_type_created_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "automatic_resume_retry_count": { + "name": "automatic_resume_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_name_unique": { + "name": "permission_group_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_workspace": { + "name": "permission_group_workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pinned_item": { + "name": "pinned_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pinned_item_user_workspace_idx": { + "name": "pinned_item_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_resource_idx": { + "name": "pinned_item_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_user_resource_unique": { + "name": "pinned_item_user_resource_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pinned_item_user_id_user_id_fk": { + "name": "pinned_item_user_id_user_id_fk", + "tableFrom": "pinned_item", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pinned_item_workspace_id_workspace_id_fk": { + "name": "pinned_item_workspace_id_workspace_id_fk", + "tableFrom": "pinned_item", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.public_share": { + "name": "public_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "public_share_token_unique": { + "name": "public_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_unique": { + "name": "public_share_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandbox_image": { + "name": "sandbox_image", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec": { + "name": "spec", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "sandbox_image_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "image_ref": { + "name": "image_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_image_id": { + "name": "provider_image_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_id": { + "name": "build_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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 + } + }, + "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 + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_jobs": { + "name": "table_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enrichment_details": { + "name": "enrichment_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_views": { + "name": "table_views", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_views_table_created_idx": { + "name": "table_views_table_created_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_views_table_default_unique": { + "name": "table_views_table_default_unique", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_views_table_id_user_table_definitions_id_fk": { + "name": "table_views_table_id_user_table_definitions_id_fk", + "tableFrom": "table_views", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_workspace_id_workspace_id_fk": { + "name": "table_views_workspace_id_workspace_id_fk", + "tableFrom": "table_views", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_created_by_user_id_fk": { + "name": "table_views_created_by_user_id_fk", + "tableFrom": "table_views", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.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_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_version": { + "name": "rows_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "schema_locked": { + "name": "schema_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "insert_locked": { + "name": "insert_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "update_locked": { + "name": "update_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "delete_locked": { + "name": "delete_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_folder_id_idx": { + "name": "user_table_def_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_folder_id_folder_id_fk": { + "name": "user_table_definitions_folder_id_folder_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_row_secret_provenance": { + "name": "user_table_row_secret_provenance", + "schema": "", + "columns": { + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_table_row_secret_provenance_row_id_user_table_rows_id_fk": { + "name": "user_table_row_secret_provenance_row_id_user_table_rows_id_fk", + "tableFrom": "user_table_row_secret_provenance", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_table_row_secret_provenance_status_check": { + "name": "user_table_row_secret_provenance_status_check", + "value": "\"user_table_row_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_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_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_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_key_idx": { + "name": "workspace_file_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_collab_state": { + "name": "workspace_file_collab_state", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "doc_state": { + "name": "doc_state", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_collab_state_file_id_workspace_files_id_fk": { + "name": "workspace_file_collab_state_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_collab_state", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_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')" + } + }, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_folder_id_folder_id_fk": { + "name": "workspace_files_folder_id_folder_id_fk", + "tableFrom": "workspace_files", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_block_map": { + "name": "workspace_fork_block_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_workflow_id": { + "name": "parent_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_block_id": { + "name": "parent_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_workflow_id": { + "name": "child_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_block_id": { + "name": "child_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_block_map_child_ws_parent_unique": { + "name": "workspace_fork_block_map_child_ws_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_unique": { + "name": "workspace_fork_block_map_child_ws_child_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_parent_wf_idx": { + "name": "workspace_fork_block_map_child_ws_parent_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_wf_idx": { + "name": "workspace_fork_block_map_child_ws_child_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_block_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_block_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_block_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_dependent_value": { + "name": "workspace_fork_dependent_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_block_key": { + "name": "sub_block_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_dependent_value_child_ws_wf_idx": { + "name": "workspace_fork_dependent_value_child_ws_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_dependent_value_field_unique": { + "name": "workspace_fork_dependent_value_field_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sub_block_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_dependent_value", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_promote_run": { + "name": "workspace_fork_promote_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workspace_id": { + "name": "target_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "workspace_fork_promote_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_promote_run_child_ws_target_unique": { + "name": "workspace_fork_promote_run_child_ws_target_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_promote_run_target_ws_idx": { + "name": "workspace_fork_promote_run_target_ws_idx", + "columns": [ + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_promote_run_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_promote_run_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_promote_run_created_by_user_id_fk": { + "name": "workspace_fork_promote_run_created_by_user_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_resource_map": { + "name": "workspace_fork_resource_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "workspace_fork_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_resource_id": { + "name": "parent_resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_resource_id": { + "name": "child_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_resource_map_child_ws_idx": { + "name": "workspace_fork_resource_map_child_ws_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_ws_type_idx": { + "name": "workspace_fork_resource_map_child_ws_type_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_type_parent_unique": { + "name": "workspace_fork_resource_map_child_type_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_resource_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_resource_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_resource_map_created_by_user_id_fk": { + "name": "workspace_fork_resource_map_created_by_user_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_sandbox": { + "name": "workspace_sandbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "sandbox_language", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "dependencies": { + "name": "dependencies", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "cli_tools": { + "name": "cli_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "system_packages": { + "name": "system_packages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_sandbox_workspace_name_unique": { + "name": "workspace_sandbox_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_workspace_idx": { + "name": "workspace_sandbox_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_spec_hash_idx": { + "name": "workspace_sandbox_spec_hash_idx", + "columns": [ + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_sandbox_workspace_id_workspace_id_fk": { + "name": "workspace_sandbox_workspace_id_workspace_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_sandbox_created_by_user_id_fk": { + "name": "workspace_sandbox_created_by_user_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.background_work_kind": { + "name": "background_work_kind", + "schema": "public", + "values": ["deployment_side_effects", "fork_content_copy", "fork_sync", "fork_rollback"] + }, + "public.background_work_status_value": { + "name": "background_work_status_value", + "schema": "public", + "values": ["pending", "processing", "completed", "completed_with_warnings", "failed"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.copilot_tool_permission_decision": { + "name": "copilot_tool_permission_decision", + "schema": "public", + "values": ["allow", "allow_chat", "always_allow", "skip"] + }, + "public.credential_group_enrollment_status": { + "name": "credential_group_enrollment_status", + "schema": "public", + "values": ["invited", "delivery_failed", "in_progress", "completed", "revoked"] + }, + "public.credential_group_status": { + "name": "credential_group_status", + "schema": "public", + "values": ["active", "disabled"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": ["oauth", "managed_oauth", "env_workspace", "env_personal", "service_account"] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.folder_resource_type": { + "name": "folder_resource_type", + "schema": "public", + "values": ["workflow", "file", "knowledge_base", "table"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.managed_oauth_credential_status": { + "name": "managed_oauth_credential_status", + "schema": "public", + "values": ["active", "needs_reauth", "revoked"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.sandbox_image_status": { + "name": "sandbox_image_status", + "schema": "public", + "values": ["pending", "building", "ready", "failed"] + }, + "public.sandbox_language": { + "name": "sandbox_language", + "schema": "public", + "values": ["javascript", "python"] + }, + "public.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"] + }, + "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" + ] + }, + "public.workspace_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": ["push", "pull"] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "mcp_server", + "workflow_mcp_server", + "custom_tool", + "skill" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 8260eb2cad0..ce8b5e5972b 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -2031,6 +2031,13 @@ "when": 1786662875077, "tag": "0290_settings_auto_focus_on_click", "breakpoints": true + }, + { + "idx": 291, + "version": "7", + "when": 1786704849273, + "tag": "0291_fuzzy_wong", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 09f51b084a5..6132d2f513e 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -3726,11 +3726,26 @@ export const usageLog = pgTable( export const credentialTypeEnum = pgEnum('credential_type', [ 'oauth', + 'managed_oauth', 'env_workspace', 'env_personal', 'service_account', ]) +export const managedOauthCredentialStatusEnum = pgEnum('managed_oauth_credential_status', [ + 'active', + 'needs_reauth', + 'revoked', +]) + +export interface ManagedOAuthProviderMetadata { + email: string + displayName?: string + avatarUrl?: string + username?: string + tenantDisplayName?: string +} + export const credential = pgTable( 'credential', { @@ -3746,6 +3761,24 @@ export const credential = pgTable( envKey: text('env_key'), envOwnerUserId: text('env_owner_user_id').references(() => user.id, { onDelete: 'cascade' }), encryptedServiceAccountKey: text('encrypted_service_account_key'), + authorizationAppId: text('authorization_app_id'), + credentialGroupEnrollmentId: text('credential_group_enrollment_id').references( + (): AnyPgColumn => credentialGroupEnrollment.id, + { onDelete: 'cascade' } + ), + credentialGroupOptionId: text('credential_group_option_id'), + managedOauthScopeVersion: integer('managed_oauth_scope_version'), + providerSubjectId: text('provider_subject_id'), + providerTenantId: text('provider_tenant_id'), + managedOauthStatus: managedOauthCredentialStatusEnum('managed_oauth_status'), + grantedScopes: text('granted_scopes').array(), + providerMetadata: jsonb('provider_metadata').$type(), + encryptedOauthTokenSet: text('encrypted_oauth_token_set'), + grantedAt: timestamp('granted_at'), + revokedAt: timestamp('revoked_at'), + accessTokenExpiresAt: timestamp('access_token_expires_at'), + refreshTokenExpiresAt: timestamp('refresh_token_expires_at'), + lastRefreshedAt: timestamp('last_refreshed_at'), createdBy: text('created_by') .notNull() .references(() => user.id, { onDelete: 'cascade' }), @@ -3758,6 +3791,12 @@ export const credential = pgTable( providerIdIdx: index('credential_provider_id_idx').on(table.providerId), accountIdIdx: index('credential_account_id_idx').on(table.accountId), envOwnerUserIdIdx: index('credential_env_owner_user_id_idx').on(table.envOwnerUserId), + credentialGroupEnrollmentIdx: index('credential_group_enrollment_idx').on( + table.credentialGroupEnrollmentId + ), + credentialGroupOptionUnique: uniqueIndex('credential_group_option_unique') + .on(table.credentialGroupEnrollmentId, table.credentialGroupOptionId) + .where(sql`${table.type} = 'managed_oauth'`), workspaceAccountUnique: uniqueIndex('credential_workspace_account_unique') .on(table.workspaceId, table.accountId) .where(sql`account_id IS NOT NULL`), @@ -3771,6 +3810,29 @@ export const credential = pgTable( 'credential_oauth_source_check', sql`(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)` ), + managedOauthSourceConstraint: check( + 'credential_managed_oauth_source_check', + sql`(type::text <> 'managed_oauth') OR ( + account_id IS NULL + AND provider_id IS NOT NULL + AND authorization_app_id IS NOT NULL + AND provider_subject_id IS NOT NULL + AND managed_oauth_status IS NOT NULL + AND granted_scopes IS NOT NULL + AND cardinality(granted_scopes) > 0 + AND encrypted_oauth_token_set IS NOT NULL + AND granted_at IS NOT NULL + )` + ), + managedOauthGroupBindingConstraint: check( + 'credential_managed_oauth_group_binding_check', + sql`(type::text <> 'managed_oauth') OR ( + credential_group_enrollment_id IS NOT NULL + AND credential_group_option_id IS NOT NULL + AND managed_oauth_scope_version IS NOT NULL + AND managed_oauth_scope_version > 0 + )` + ), workspaceEnvSourceConstraint: check( 'credential_workspace_env_source_check', sql`(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)` @@ -3782,6 +3844,108 @@ export const credential = pgTable( }) ) +export const credentialGroupStatusEnum = pgEnum('credential_group_status', ['active', 'disabled']) + +export interface CredentialGroupOptionConfig { + id: string + provider: string + label: string + slackBotCredentialId?: string + authorizationAppId: string + requiredScopes: string[] + scopeVersion: number + required: boolean + status: 'active' | 'disabled' +} + +/** Workspace-owned configuration for collecting several managed OAuth credentials. */ +export const credentialGroup = pgTable( + 'credential_group', + { + id: text('id').primaryKey(), + workspaceId: text('workspace_id') + .notNull() + .references(() => workspace.id, { onDelete: 'cascade' }), + publicId: text('public_id').notNull(), + name: text('name').notNull(), + description: text('description'), + options: jsonb('options').$type().notNull(), + encryptedProviderConfiguration: text('encrypted_provider_configuration'), + status: credentialGroupStatusEnum('status').notNull().default('active'), + createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + }, + (table) => ({ + publicIdUnique: uniqueIndex('credential_group_public_id_unique').on(table.publicId), + workspaceStatusIdx: index('credential_group_workspace_status_idx').on( + table.workspaceId, + table.status + ), + workspaceNameUnique: uniqueIndex('credential_group_workspace_name_unique').on( + table.workspaceId, + sql`lower(${table.name})` + ), + }) +) + +export const credentialGroupEnrollmentStatusEnum = pgEnum('credential_group_enrollment_status', [ + 'invited', + 'delivery_failed', + 'in_progress', + 'completed', + 'revoked', +]) + +/** Email-bound invitation and resumable progress for one credential-group recipient. */ +export const credentialGroupEnrollment = pgTable( + 'credential_group_enrollment', + { + id: text('id').primaryKey(), + credentialGroupId: text('credential_group_id') + .notNull() + .references(() => credentialGroup.id, { onDelete: 'cascade' }), + email: text('email').notNull(), + status: credentialGroupEnrollmentStatusEnum('status').notNull().default('invited'), + invitationTokenHash: text('invitation_token_hash').notNull(), + invitationExpiresAt: timestamp('invitation_expires_at').notNull(), + invitedAt: timestamp('invited_at').notNull(), + sentAt: timestamp('sent_at'), + completedAt: timestamp('completed_at'), + revokedAt: timestamp('revoked_at'), + lastDeliveryError: text('last_delivery_error'), + createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + }, + (table) => ({ + groupEmailUnique: uniqueIndex('credential_group_enrollment_group_email_unique').on( + table.credentialGroupId, + table.email + ), + invitationTokenHashUnique: uniqueIndex( + 'credential_group_enrollment_invitation_token_hash_unique' + ).on(table.invitationTokenHash), + groupStatusIdx: index('credential_group_enrollment_group_status_idx').on( + table.credentialGroupId, + table.status + ), + groupInvitedAtIdIdx: index('credential_group_enrollment_group_invited_at_id_idx').on( + table.credentialGroupId, + table.invitedAt, + table.id + ), + normalizedEmail: check( + 'credential_group_enrollment_normalized_email_check', + sql`${table.email} = lower(btrim(${table.email})) AND length(${table.email}) BETWEEN 3 AND 320` + ), + invitationTokenHashLength: check( + 'credential_group_enrollment_invitation_token_hash_length_check', + sql`length(${table.invitationTokenHash}) = 64` + ), + }) +) + export const credentialMemberRoleEnum = pgEnum('credential_member_role', ['admin', 'member']) export const credentialMemberStatusEnum = pgEnum('credential_member_status', [ 'active', diff --git a/packages/emcn/src/icons/grid-offset.tsx b/packages/emcn/src/icons/grid-offset.tsx new file mode 100644 index 00000000000..7e91134d5e9 --- /dev/null +++ b/packages/emcn/src/icons/grid-offset.tsx @@ -0,0 +1,24 @@ +import type { SVGProps } from 'react' + +/** Rounded grid icon with an intersection offset toward the lower-left. */ +export function GridOffset(props: SVGProps) { + return ( + + ) +} diff --git a/packages/emcn/src/icons/index.ts b/packages/emcn/src/icons/index.ts index 54b2f5162ff..57ccd890355 100644 --- a/packages/emcn/src/icons/index.ts +++ b/packages/emcn/src/icons/index.ts @@ -64,6 +64,7 @@ export { FolderOpen } from './folder-open' export { FolderPlus } from './folder-plus' export { FormInput } from './form-input' export { Globe } from './globe' +export { GridOffset } from './grid-offset' export { Hammer } from './hammer' export { Hand } from './hand' export { Heading1 } from './heading1' diff --git a/packages/testing/src/mocks/audit.mock.ts b/packages/testing/src/mocks/audit.mock.ts index 78827f39e8d..14eab8005f3 100644 --- a/packages/testing/src/mocks/audit.mock.ts +++ b/packages/testing/src/mocks/audit.mock.ts @@ -55,6 +55,7 @@ export const auditMock = { CREDENTIAL_MEMBER_ADDED: 'credential_member.added', CREDENTIAL_MEMBER_REMOVED: 'credential_member.removed', CREDENTIAL_MEMBER_ROLE_CHANGED: 'credential_member.role_changed', + CREDENTIAL_GROUP_UPDATED: 'credential_group.updated', CREDIT_PURCHASED: 'credit.purchased', CUSTOM_BLOCK_PUBLISHED: 'custom_block.published', CUSTOM_BLOCK_UPDATED: 'custom_block.updated', @@ -197,6 +198,7 @@ export const auditMock = { CHAT: 'chat', CONNECTOR: 'connector', CREDENTIAL: 'credential', + CREDENTIAL_GROUP: 'credential_group', CUSTOM_BLOCK: 'custom_block', CUSTOM_TOOL: 'custom_tool', DATA_DRAIN: 'data_drain', diff --git a/packages/testing/src/mocks/schema.mock.ts b/packages/testing/src/mocks/schema.mock.ts index e5975e696ff..8e6e10097fb 100644 --- a/packages/testing/src/mocks/schema.mock.ts +++ b/packages/testing/src/mocks/schema.mock.ts @@ -1072,7 +1072,16 @@ export const schemaMock = { createdAt: 'createdAt', }, credentialTypeEnum: { - enumValues: ['oauth', 'env_workspace', 'env_personal', 'service_account'] as const, + enumValues: [ + 'oauth', + 'managed_oauth', + 'env_workspace', + 'env_personal', + 'service_account', + ] as const, + }, + managedOauthCredentialStatusEnum: { + enumValues: ['active', 'needs_reauth', 'revoked'] as const, }, credential: { id: 'id', @@ -1085,6 +1094,52 @@ export const schemaMock = { envKey: 'envKey', envOwnerUserId: 'envOwnerUserId', encryptedServiceAccountKey: 'encryptedServiceAccountKey', + authorizationAppId: 'authorizationAppId', + providerSubjectId: 'providerSubjectId', + providerTenantId: 'providerTenantId', + managedOauthStatus: 'managedOauthStatus', + grantedScopes: 'grantedScopes', + providerMetadata: 'providerMetadata', + encryptedOauthTokenSet: 'encryptedOauthTokenSet', + grantedAt: 'grantedAt', + revokedAt: 'revokedAt', + accessTokenExpiresAt: 'accessTokenExpiresAt', + refreshTokenExpiresAt: 'refreshTokenExpiresAt', + lastRefreshedAt: 'lastRefreshedAt', + createdBy: 'createdBy', + createdAt: 'createdAt', + updatedAt: 'updatedAt', + }, + credentialGroupStatusEnum: { + enumValues: ['active', 'disabled'] as const, + }, + credentialGroup: { + id: 'id', + workspaceId: 'workspaceId', + publicId: 'publicId', + name: 'name', + description: 'description', + options: 'options', + status: 'status', + createdBy: 'createdBy', + createdAt: 'createdAt', + updatedAt: 'updatedAt', + }, + credentialGroupEnrollmentStatusEnum: { + enumValues: ['invited', 'delivery_failed', 'in_progress', 'completed', 'revoked'] as const, + }, + credentialGroupEnrollment: { + id: 'id', + credentialGroupId: 'credentialGroupId', + email: 'email', + status: 'status', + invitationTokenHash: 'invitationTokenHash', + invitationExpiresAt: 'invitationExpiresAt', + invitedAt: 'invitedAt', + sentAt: 'sentAt', + completedAt: 'completedAt', + revokedAt: 'revokedAt', + lastDeliveryError: 'lastDeliveryError', createdBy: 'createdBy', createdAt: 'createdAt', updatedAt: 'updatedAt', diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 6665ac74a79..a4239c9c42d 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 1108, - zodRoutes: 1108, + totalRoutes: 1118, + zodRoutes: 1118, nonZodRoutes: 0, } as const diff --git a/scripts/check-tool-registry-boundary.baseline.json b/scripts/check-tool-registry-boundary.baseline.json index 5e8ebeb9d3b..ef913731a9c 100644 --- a/scripts/check-tool-registry-boundary.baseline.json +++ b/scripts/check-tool-registry-boundary.baseline.json @@ -10,49 +10,49 @@ "gateways": {} }, "app/workspace/[workspaceId]/chat/[chatId]/page.tsx": { - "modules": 2890, + "modules": 2919, "gateways": { - "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1328, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 971, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 836, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 833, + "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1327, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 982, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 847, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 844, "apps/sim/triggers/registry.ts": 446, - "apps/sim/blocks/registry.ts": 301, - "apps/sim/lib/auth/index.ts": 297, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 295 + "apps/sim/blocks/registry.ts": 309, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 295, + "apps/sim/lib/auth/index.ts": 204 } }, "app/workspace/[workspaceId]/files/[fileId]/page.tsx": { - "modules": 1909, + "modules": 1931, "gateways": { "apps/sim/triggers/registry.ts": 446, - "apps/sim/blocks/registry.ts": 328, - "apps/sim/lib/auth/index.ts": 300, - "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 275, + "apps/sim/blocks/registry.ts": 337, + "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 280, + "apps/sim/lib/auth/index.ts": 210, "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 143, "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 129, "apps/sim/lib/api/contracts/index.ts": 107, - "apps/sim/lib/webhooks/providers/index.ts": 99 + "apps/sim/lib/webhooks/providers/index.ts": 100 } }, "app/workspace/[workspaceId]/files/[fileId]/view/page.tsx": { - "modules": 57, + "modules": 59, "gateways": { - "apps/sim/app/workspace/[workspaceId]/files/[fileId]/view/file-viewer.tsx": 56, - "apps/sim/hooks/queries/workspace-files.ts": 53 + "apps/sim/app/workspace/[workspaceId]/files/[fileId]/view/file-viewer.tsx": 58, + "apps/sim/hooks/queries/workspace-files.ts": 55 } }, "app/workspace/[workspaceId]/files/page.tsx": { - "modules": 1909, + "modules": 1931, "gateways": { "apps/sim/triggers/registry.ts": 446, - "apps/sim/blocks/registry.ts": 328, - "apps/sim/lib/auth/index.ts": 300, - "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 277, + "apps/sim/blocks/registry.ts": 337, + "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 282, + "apps/sim/lib/auth/index.ts": 210, "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 143, "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 129, "apps/sim/lib/api/contracts/index.ts": 107, - "apps/sim/lib/webhooks/providers/index.ts": 99 + "apps/sim/lib/webhooks/providers/index.ts": 100 } }, "app/workspace/[workspaceId]/home/layout.tsx": { @@ -60,120 +60,120 @@ "gateways": {} }, "app/workspace/[workspaceId]/home/page.tsx": { - "modules": 2890, + "modules": 2919, "gateways": { - "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1328, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 971, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 836, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 833, + "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1327, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 982, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 847, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 844, "apps/sim/triggers/registry.ts": 446, - "apps/sim/blocks/registry.ts": 301, - "apps/sim/lib/auth/index.ts": 297, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 295 + "apps/sim/blocks/registry.ts": 309, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 295, + "apps/sim/lib/auth/index.ts": 204 } }, "app/workspace/[workspaceId]/integrations/[block]/page.tsx": { - "modules": 1268, + "modules": 1284, "gateways": { - "apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx": 1243, - "apps/sim/blocks/registry.ts": 925, + "apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx": 1259, + "apps/sim/blocks/registry.ts": 935, "apps/sim/triggers/index.ts": 482, - "apps/sim/lib/api/contracts/index.ts": 128, - "apps/sim/stores/workflows/registry/store.ts": 82, + "apps/sim/lib/api/contracts/index.ts": 127, + "apps/sim/stores/workflows/registry/store.ts": 67, + "apps/sim/hooks/queries/deployments.ts": 61, "apps/sim/lib/api/contracts/tools/index.ts": 60, - "apps/sim/hooks/queries/deployments.ts": 59, - "apps/sim/lib/workflows/comparison/compare.ts": 56 + "apps/sim/lib/workflows/comparison/compare.ts": 58 } }, "app/workspace/[workspaceId]/integrations/connected/[credentialId]/page.tsx": { - "modules": 1254, + "modules": 1266, "gateways": { - "apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx": 1253, + "apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx": 1265, "apps/sim/triggers/registry.ts": 481, - "apps/sim/blocks/registry.ts": 331, + "apps/sim/blocks/registry.ts": 339, "apps/sim/lib/api/contracts/index.ts": 134, - "apps/sim/stores/workflows/registry/store.ts": 62, + "apps/sim/stores/workflows/registry/store.ts": 64, + "apps/sim/hooks/queries/deployments.ts": 61, "apps/sim/lib/api/contracts/tools/index.ts": 60, - "apps/sim/hooks/queries/deployments.ts": 59, - "apps/sim/lib/workflows/comparison/compare.ts": 56 + "apps/sim/lib/workflows/comparison/compare.ts": 58 } }, "app/workspace/[workspaceId]/integrations/page.tsx": { - "modules": 1253, + "modules": 1269, "gateways": { - "apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx": 979, - "apps/sim/blocks/registry.ts": 926, + "apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx": 993, + "apps/sim/blocks/registry.ts": 936, "apps/sim/triggers/index.ts": 482, - "apps/sim/lib/api/contracts/index.ts": 130, - "apps/sim/stores/workflows/registry/store.ts": 83, + "apps/sim/lib/api/contracts/index.ts": 129, + "apps/sim/stores/workflows/registry/store.ts": 67, + "apps/sim/hooks/queries/deployments.ts": 61, "apps/sim/lib/api/contracts/tools/index.ts": 60, - "apps/sim/hooks/queries/deployments.ts": 59, - "apps/sim/lib/workflows/comparison/compare.ts": 56 + "apps/sim/lib/workflows/comparison/compare.ts": 58 } }, "app/workspace/[workspaceId]/knowledge/[id]/[documentId]/page.tsx": { - "modules": 1460, + "modules": 1482, "gateways": { - "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx": 1183, + "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx": 1203, "apps/sim/triggers/registry.ts": 481, - "apps/sim/blocks/registry.ts": 322, - "apps/sim/blocks/registry-maps.ts": 319, + "apps/sim/blocks/registry.ts": 330, + "apps/sim/blocks/registry-maps.ts": 327, "apps/sim/lib/api/contracts/index.ts": 119, + "apps/sim/connectors/registry.ts": 62, "apps/sim/lib/api/contracts/tools/index.ts": 60, - "apps/sim/connectors/registry.ts": 53, "apps/sim/app/workspace/[workspaceId]/components/index.ts": 52 } }, "app/workspace/[workspaceId]/knowledge/[id]/page.tsx": { - "modules": 1461, + "modules": 1483, "gateways": { - "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx": 1184, + "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx": 1204, "apps/sim/triggers/registry.ts": 481, - "apps/sim/blocks/registry.ts": 322, - "apps/sim/blocks/registry-maps.ts": 319, + "apps/sim/blocks/registry.ts": 330, + "apps/sim/blocks/registry-maps.ts": 327, "apps/sim/lib/api/contracts/index.ts": 119, + "apps/sim/connectors/registry.ts": 62, "apps/sim/lib/api/contracts/tools/index.ts": 60, - "apps/sim/connectors/registry.ts": 53, "apps/sim/app/workspace/[workspaceId]/components/index.ts": 52 } }, "app/workspace/[workspaceId]/knowledge/page.tsx": { - "modules": 2091, + "modules": 2138, "gateways": { "apps/sim/triggers/registry.ts": 446, - "apps/sim/blocks/registry.ts": 317, - "apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts": 249, - "apps/sim/lib/knowledge/application/knowledge-bases.ts": 198, + "apps/sim/blocks/registry.ts": 325, + "apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts": 273, + "apps/sim/lib/knowledge/application/knowledge-bases.ts": 218, "apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx": 168, "apps/sim/lib/auth/index.ts": 158, - "apps/sim/lib/knowledge/orchestration/index.ts": 121, - "apps/sim/lib/knowledge/orchestration/connectors.ts": 116 + "apps/sim/lib/knowledge/orchestration/index.ts": 141, + "apps/sim/lib/knowledge/orchestration/connectors.ts": 136 } }, "app/workspace/[workspaceId]/layout.tsx": { - "modules": 1957, + "modules": 1974, "gateways": { "apps/sim/triggers/registry.ts": 446, - "apps/sim/blocks/registry.ts": 316, - "apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/index.ts": 255, - "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx": 247, - "apps/sim/lib/auth/index.ts": 180, - "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts": 151, + "apps/sim/blocks/registry.ts": 324, + "apps/sim/lib/auth/index.ts": 272, + "apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/index.ts": 270, + "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx": 262, + "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts": 165, "apps/sim/lib/api/contracts/index.ts": 109, - "apps/sim/lib/webhooks/providers/index.ts": 99 + "apps/sim/lib/webhooks/providers/index.ts": 100 } }, "app/workspace/[workspaceId]/logs/page.tsx": { - "modules": 1696, + "modules": 1711, "gateways": { - "apps/sim/app/workspace/[workspaceId]/logs/logs.tsx": 1421, + "apps/sim/app/workspace/[workspaceId]/logs/logs.tsx": 1434, "apps/sim/triggers/registry.ts": 481, - "apps/sim/app/workspace/[workspaceId]/logs/components/index.ts": 418, - "apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/index.ts": 366, - "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 322, - "apps/sim/blocks/registry.ts": 318, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 286, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 256 + "apps/sim/app/workspace/[workspaceId]/logs/components/index.ts": 419, + "apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/index.ts": 363, + "apps/sim/blocks/registry.ts": 326, + "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 319, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 282, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 252 } }, "app/workspace/[workspaceId]/page.tsx": { @@ -185,14 +185,14 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/[section]/page.tsx": { - "modules": 1977, + "modules": 2003, "gateways": { "apps/sim/triggers/registry.ts": 446, - "apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx": 409, - "apps/sim/blocks/registry.ts": 320, + "apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx": 420, + "apps/sim/blocks/registry.ts": 325, "apps/sim/lib/auth/index.ts": 282, - "apps/sim/lib/api/contracts/index.ts": 106, - "apps/sim/lib/webhooks/providers/index.ts": 99, + "apps/sim/lib/api/contracts/index.ts": 105, + "apps/sim/lib/webhooks/providers/index.ts": 100, "apps/sim/lib/api/contracts/tools/index.ts": 59, "apps/sim/lib/workflows/lifecycle.ts": 48 } @@ -202,15 +202,15 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/billing/credit-usage/page.tsx": { - "modules": 1573, + "modules": 1588, "gateways": { - "apps/sim/lib/auth/index.ts": 1445, + "apps/sim/lib/auth/index.ts": 1455, "apps/sim/triggers/index.ts": 447, - "apps/sim/blocks/registry.ts": 330, - "apps/sim/blocks/registry-maps.ts": 327, + "apps/sim/blocks/registry.ts": 339, + "apps/sim/blocks/registry-maps.ts": 336, "apps/sim/lib/api/contracts/index.ts": 122, - "apps/sim/lib/webhooks/providers/index.ts": 99, - "apps/sim/stores/workflows/registry/store.ts": 71, + "apps/sim/lib/webhooks/providers/index.ts": 100, + "apps/sim/stores/workflows/registry/store.ts": 64, "apps/sim/lib/api/contracts/tools/index.ts": 60 } }, @@ -223,115 +223,115 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/secrets/[credentialId]/page.tsx": { - "modules": 1283, + "modules": 1297, "gateways": { - "apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx": 1282, - "apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts": 993, - "apps/sim/components/permissions/index.ts": 980, - "apps/sim/components/permissions/add-people-modal.tsx": 971, - "apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx": 969, + "apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx": 1296, + "apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts": 1005, + "apps/sim/components/permissions/index.ts": 992, + "apps/sim/components/permissions/add-people-modal.tsx": 983, + "apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx": 981, "apps/sim/triggers/registry.ts": 481, - "apps/sim/blocks/registry.ts": 333, - "apps/sim/blocks/registry-maps.ts": 330 + "apps/sim/blocks/registry.ts": 343, + "apps/sim/blocks/registry-maps.ts": 340 } }, "app/workspace/[workspaceId]/skills/[skillId]/page.tsx": { - "modules": 1374, + "modules": 1388, "gateways": { - "apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx": 1373, + "apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx": 1387, "apps/sim/triggers/registry.ts": 481, - "apps/sim/blocks/registry.ts": 332, - "apps/sim/blocks/registry-maps.ts": 330, - "apps/sim/lib/api/contracts/index.ts": 126, + "apps/sim/blocks/registry.ts": 342, + "apps/sim/blocks/registry-maps.ts": 340, + "apps/sim/lib/api/contracts/index.ts": 125, "apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts": 89, "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx": 86, "apps/sim/lib/api/contracts/tools/index.ts": 60 } }, "app/workspace/[workspaceId]/skills/new/page.tsx": { - "modules": 1372, + "modules": 1386, "gateways": { - "apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx": 1371, + "apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx": 1385, "apps/sim/triggers/registry.ts": 481, - "apps/sim/blocks/registry.ts": 332, - "apps/sim/blocks/registry-maps.ts": 330, - "apps/sim/lib/api/contracts/index.ts": 126, + "apps/sim/blocks/registry.ts": 342, + "apps/sim/blocks/registry-maps.ts": 340, + "apps/sim/lib/api/contracts/index.ts": 125, "apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts": 89, "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx": 86, "apps/sim/lib/api/contracts/tools/index.ts": 60 } }, "app/workspace/[workspaceId]/skills/page.tsx": { - "modules": 1236, + "modules": 1250, "gateways": { - "apps/sim/app/workspace/[workspaceId]/skills/skills.tsx": 962, - "apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/index.ts": 950, - "apps/sim/blocks/registry.ts": 938, - "apps/sim/blocks/registry-maps.ts": 936, + "apps/sim/app/workspace/[workspaceId]/skills/skills.tsx": 974, + "apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/index.ts": 962, + "apps/sim/blocks/registry.ts": 950, + "apps/sim/blocks/registry-maps.ts": 948, "apps/sim/triggers/index.ts": 482, - "apps/sim/lib/api/contracts/index.ts": 135, - "apps/sim/stores/workflows/registry/store.ts": 84, - "apps/sim/hooks/queries/deployments.ts": 60 + "apps/sim/lib/api/contracts/index.ts": 134, + "apps/sim/stores/workflows/registry/store.ts": 68, + "apps/sim/hooks/queries/deployments.ts": 62 } }, "app/workspace/[workspaceId]/tables/[tableId]/page.tsx": { - "modules": 2186, + "modules": 2204, "gateways": { - "apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx": 574, + "apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx": 577, "apps/sim/triggers/registry.ts": 446, - "apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts": 328, - "apps/sim/lib/auth/index.ts": 302, - "apps/sim/blocks/registry.ts": 301, - "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 286, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 259, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 230 + "apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts": 331, + "apps/sim/blocks/registry.ts": 309, + "apps/sim/lib/auth/index.ts": 303, + "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 289, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 261, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 232 } }, "app/workspace/[workspaceId]/tables/page.tsx": { - "modules": 1767, + "modules": 1788, "gateways": { "apps/sim/triggers/registry.ts": 446, - "apps/sim/blocks/registry.ts": 327, - "apps/sim/lib/auth/index.ts": 296, - "apps/sim/app/workspace/[workspaceId]/tables/tables.tsx": 121, + "apps/sim/blocks/registry.ts": 336, + "apps/sim/lib/auth/index.ts": 293, + "apps/sim/app/workspace/[workspaceId]/tables/tables.tsx": 126, "apps/sim/lib/api/contracts/index.ts": 111, - "apps/sim/lib/webhooks/providers/index.ts": 99, + "apps/sim/lib/webhooks/providers/index.ts": 100, "apps/sim/lib/api/contracts/tools/index.ts": 60, "apps/sim/app/workspace/[workspaceId]/components/index.ts": 50 } }, "app/workspace/[workspaceId]/upgrade/page.tsx": { - "modules": 263, + "modules": 267, "gateways": { - "apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx": 256, - "apps/sim/app/workspace/[workspaceId]/upgrade/hooks/index.ts": 210, + "apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx": 260, + "apps/sim/app/workspace/[workspaceId]/upgrade/hooks/index.ts": 213, "apps/sim/lib/billing/client/upgrade.ts": 205, "apps/sim/hooks/queries/organization.ts": 201, - "apps/sim/hooks/queries/workspace.ts": 192, - "apps/sim/lib/api/contracts/index.ts": 190, + "apps/sim/hooks/queries/workspace.ts": 193, + "apps/sim/lib/api/contracts/index.ts": 191, "apps/sim/lib/api/contracts/tools/index.ts": 61, "apps/sim/lib/api/contracts/v1/index.ts": 38 } }, "app/workspace/[workspaceId]/w/[workflowId]/layout.tsx": { - "modules": 2145, + "modules": 2160, "gateways": { - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 2144, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 2159, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 540, "apps/sim/triggers/registry.ts": 481, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 458, - "apps/sim/blocks/registry.ts": 318, + "apps/sim/blocks/registry.ts": 326, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 285, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 141, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 134 } }, "app/workspace/[workspaceId]/w/[workflowId]/page.tsx": { - "modules": 2172, + "modules": 2187, "gateways": { - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 2171, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 2186, "apps/sim/triggers/registry.ts": 481, - "apps/sim/blocks/registry.ts": 318, + "apps/sim/blocks/registry.ts": 326, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 305, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 267, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 224, @@ -340,42 +340,42 @@ } }, "app/workspace/[workspaceId]/w/page.tsx": { - "modules": 2145, + "modules": 2160, "gateways": { "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 904, "apps/sim/triggers/registry.ts": 481, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 458, - "apps/sim/blocks/registry.ts": 318, + "apps/sim/blocks/registry.ts": 326, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 285, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 141, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 134, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 133 + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 129 } }, "app/workspace/layout.tsx": { - "modules": 1194, + "modules": 1208, "gateways": { - "apps/sim/app/workspace/providers/socket-provider.tsx": 1184, + "apps/sim/app/workspace/providers/socket-provider.tsx": 1198, "apps/sim/triggers/registry.ts": 481, - "apps/sim/blocks/registry.ts": 333, - "apps/sim/blocks/registry-maps.ts": 330, - "apps/sim/lib/api/contracts/index.ts": 139, - "apps/sim/stores/workflows/registry/store.ts": 63, - "apps/sim/hooks/queries/deployments.ts": 60, + "apps/sim/blocks/registry.ts": 343, + "apps/sim/blocks/registry-maps.ts": 340, + "apps/sim/lib/api/contracts/index.ts": 138, + "apps/sim/stores/workflows/registry/store.ts": 65, + "apps/sim/hooks/queries/deployments.ts": 62, "apps/sim/lib/api/contracts/tools/index.ts": 60 } }, "app/workspace/page.tsx": { - "modules": 1188, + "modules": 1202, "gateways": { - "apps/sim/lib/auth/stale-session-recovery.ts": 959, + "apps/sim/lib/auth/stale-session-recovery.ts": 971, "apps/sim/triggers/index.ts": 482, - "apps/sim/blocks/registry.ts": 333, - "apps/sim/blocks/registry-maps.ts": 330, - "apps/sim/lib/api/contracts/index.ts": 138, - "apps/sim/stores/workflows/registry/store.ts": 62, + "apps/sim/blocks/registry.ts": 343, + "apps/sim/blocks/registry-maps.ts": 340, + "apps/sim/lib/api/contracts/index.ts": 137, + "apps/sim/stores/workflows/registry/store.ts": 64, "apps/sim/lib/api/contracts/tools/index.ts": 60, - "apps/sim/hooks/queries/deployments.ts": 56 + "apps/sim/hooks/queries/deployments.ts": 58 } } }