diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 3f8b795c4ec..16168dc5990 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -316,8 +316,8 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile - - name: Configure E2E hostname - run: echo "127.0.0.1 e2e.sim.ai" | sudo tee -a /etc/hosts + - name: Configure E2E hostnames + run: echo "127.0.0.1 e2e.sim.ai mcp.e2e.sim.ai" | sudo tee -a /etc/hosts - name: Install Chromium working-directory: apps/sim diff --git a/apps/docs/content/docs/en/platform/enterprise/sso.mdx b/apps/docs/content/docs/en/platform/enterprise/sso.mdx index ee3d53be3ec..112ec7f28ef 100644 --- a/apps/docs/content/docs/en/platform/enterprise/sso.mdx +++ b/apps/docs/content/docs/en/platform/enterprise/sso.mdx @@ -252,7 +252,7 @@ SSO provisioning creates internal organization members. External workspace membe }, { question: "A user already has an account with the same email — what happens when they sign in with SSO?", - answer: "Sim links the SSO identity to the existing account automatically, as long as your identity provider reports the email as verified (email_verified) or the provider is trusted. Most OIDC providers (Okta, Google Workspace, Auth0) assert email_verified, so linking just works. If sign-in fails with 'account not linked' — common with SAML providers that omit the claim — add the provider's ID to SSO_TRUSTED_PROVIDER_IDS on self-hosted and restart." + answer: "Before verified-domain enforcement is activated, Sim links the SSO identity when your identity provider reports a verified email or the provider is operator-trusted. After enforcement is activated, the provider's verified organization domain is the account-linking trust boundary." }, { question: "Who can configure SSO on Sim Cloud?", @@ -281,11 +281,16 @@ Self-hosted deployments use environment variables instead of the billing/plan ch SSO_ENABLED=true NEXT_PUBLIC_SSO_ENABLED=true +# Enable only after the SSO schema migration, provider audit, and approved +# domain-verification backfill described below. +SSO_DOMAIN_VERIFICATION_ENABLED=false + # Required if you want users auto-added to your organization on first SSO sign-in ORGANIZATIONS_ENABLED=true NEXT_PUBLIC_ORGANIZATIONS_ENABLED=true -# Optional: comma-separated SSO provider IDs to trust for automatic account linking +# Optional while domain verification is disabled: comma-separated SSO provider IDs +# to trust for automatic account linking # (links an SSO sign-in to an existing account with the same email). Needed when your # IdP does not assert email_verified — typically SAML providers, or OIDC providers that # omit the claim. Set it to the Provider ID you registered, then restart. @@ -295,12 +300,27 @@ SSO_TRUSTED_PROVIDER_IDS=custom-oidc,partner-saml ``` - When someone signs in with SSO and an account with the same email already exists - (for example, they previously signed up with email/password), Sim links the SSO - identity to that account automatically as long as your IdP reports the email as - verified, or the provider is trusted. If you hit an `account not linked` error, - either confirm your IdP sends `email_verified`, or add the provider's ID to - `SSO_TRUSTED_PROVIDER_IDS` and restart. + While `SSO_DOMAIN_VERIFICATION_ENABLED` is false, existing linking behavior uses + the IdP's `email_verified` claim or `SSO_TRUSTED_PROVIDER_IDS`. Once the flag is + true, Sim requires the SSO provider's organization domain to be verified and no + longer trusts an IdP-controlled email-verification claim by itself. + + + + For upgrades, leave domain verification disabled until the database migration + has landed and + `SSO_AUDIT_APPROVED_PROVIDER_IDS=reviewed-provider-ids bun run --cwd packages/db db:audit-sso-providers` + passes. The approval list records the providers whose existing account links + and active sessions operators chose to retain; omit a provider until its + links are migrated or removed and its sessions are revoked. The audit reports + linked-user and active-session counts and requires one owner organization and + one registrable domain per provider. + Remediate legacy user-scoped, public-suffix, duplicate, or overlapping rows; + explicitly backfill `domain_verified` only for domains your operators have + verified; quiesce SSO writes and rerun the audit immediately before enabling + the flag. Internal pseudo-domains are not eligible for verified-domain + enforcement. Existing internal-domain providers can remain unchanged while + the flag is off, but must move to a registrable domain before activation. You can register providers through the **Settings UI** (same as cloud) or by running the registration script directly against your database. @@ -318,6 +338,7 @@ SSO_PROVIDER_ID=okta \ SSO_ISSUER=https://dev-1234567.okta.com/oauth2/default \ SSO_DOMAIN=company.com \ SSO_USER_EMAIL=admin@company.com \ +SSO_ORGANIZATION_ID=your-organization-id \ SSO_OIDC_CLIENT_ID=your-client-id \ SSO_OIDC_CLIENT_SECRET=your-client-secret \ bun run packages/db/scripts/register-sso-provider.ts @@ -332,6 +353,7 @@ SSO_PROVIDER_ID=adfs \ SSO_ISSUER=https://your-instance.com \ SSO_DOMAIN=company.com \ SSO_USER_EMAIL=admin@company.com \ +SSO_ORGANIZATION_ID=your-organization-id \ SSO_SAML_ENTRY_POINT=https://adfs.company.com/adfs/ls \ SSO_SAML_CERT="-----BEGIN CERTIFICATE----- ... @@ -345,5 +367,7 @@ To remove a provider: ```bash SSO_USER_EMAIL=admin@company.com \ +SSO_ORGANIZATION_ID=your-organization-id \ +SSO_PROVIDER_ID=adfs \ bun run packages/db/scripts/deregister-sso-provider.ts ``` diff --git a/apps/sim/app/api/auth/[...all]/route.test.ts b/apps/sim/app/api/auth/[...all]/route.test.ts index 0d82eac149d..f462da8a7c9 100644 --- a/apps/sim/app/api/auth/[...all]/route.test.ts +++ b/apps/sim/app/api/auth/[...all]/route.test.ts @@ -13,6 +13,13 @@ const handlerMocks = vi.hoisted(() => ({ session: { id: 'anon-session' }, })), isAuthDisabled: false, + withSSOCallbackIntent: vi.fn((_providerId: string, callback: () => Promise) => + callback() + ), +})) + +vi.mock('@/lib/auth/sso/provider-operation-intent', () => ({ + withSSOCallbackIntent: handlerMocks.withSSOCallbackIntent, })) vi.mock('better-auth/next-js', () => ({ @@ -137,3 +144,82 @@ describe('auth catch-all route organization mutations', () => { expect(json).toEqual({ data: { ok: true } }) }) }) + +describe('auth catch-all route SSO mutations', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it.each([ + 'register', + 'update-provider', + 'delete-provider', + 'request-domain-verification', + 'verify-domain', + ])('blocks the raw Better Auth /sso/%s endpoint', async (path) => { + const req = createMockRequest( + 'POST', + undefined, + {}, + `http://localhost:3000/api/auth/sso/${path}` + ) + const res = await POST(req as any) + + expect(res.status).toBe(404) + expect(handlerMocks.betterAuthPOST).not.toHaveBeenCalled() + }) + + it('continues to delegate non-mutation SSO endpoints', async () => { + const { NextResponse } = await import('next/server') + handlerMocks.betterAuthPOST.mockResolvedValueOnce( + new NextResponse(null, { status: 200 }) as any + ) + const req = createMockRequest( + 'POST', + undefined, + {}, + 'http://localhost:3000/api/auth/sign-in/sso' + ) + + const res = await POST(req as any) + expect(res.status).toBe(200) + expect(handlerMocks.betterAuthPOST).toHaveBeenCalledTimes(1) + expect(handlerMocks.withSSOCallbackIntent).not.toHaveBeenCalled() + }) + + it('registers an intent around OIDC GET callbacks', async () => { + const { NextResponse } = await import('next/server') + handlerMocks.betterAuthGET.mockResolvedValueOnce(new NextResponse(null, { status: 302 }) as any) + const req = createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/auth/sso/callback/acme' + ) + + const res = await GET(req as any) + + expect(res.status).toBe(302) + expect(handlerMocks.withSSOCallbackIntent).toHaveBeenCalledWith('acme', expect.any(Function)) + expect(handlerMocks.betterAuthGET).toHaveBeenCalledTimes(1) + }) + + it('registers an intent around SAML POST callbacks', async () => { + const { NextResponse } = await import('next/server') + handlerMocks.betterAuthPOST.mockResolvedValueOnce( + new NextResponse(null, { status: 302 }) as any + ) + const req = createMockRequest( + 'POST', + undefined, + {}, + 'http://localhost:3000/api/auth/sso/saml2/callback/acme' + ) + + const res = await POST(req as any) + + expect(res.status).toBe(302) + expect(handlerMocks.withSSOCallbackIntent).toHaveBeenCalledWith('acme', expect.any(Function)) + expect(handlerMocks.betterAuthPOST).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/sim/app/api/auth/[...all]/route.ts b/apps/sim/app/api/auth/[...all]/route.ts index 8456afff4cf..a9a354f544d 100644 --- a/apps/sim/app/api/auth/[...all]/route.ts +++ b/apps/sim/app/api/auth/[...all]/route.ts @@ -2,6 +2,7 @@ import { toNextJsHandler } from 'better-auth/next-js' import { type NextRequest, NextResponse } from 'next/server' import { auth } from '@/lib/auth' import { createAnonymousSession, ensureAnonymousUserExists } from '@/lib/auth/anonymous' +import { withSSOCallbackIntent } from '@/lib/auth/sso/provider-operation-intent' import { isAuthDisabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -9,6 +10,13 @@ export const dynamic = 'force-dynamic' const { GET: betterAuthGET, POST: betterAuthPOST } = toNextJsHandler(auth.handler) const SAFE_ORGANIZATION_POST_PATHS = new Set(['organization/check-slug', 'organization/set-active']) +const BLOCKED_SSO_MUTATION_PATHS = new Set([ + 'sso/register', + 'sso/update-provider', + 'sso/delete-provider', + 'sso/request-domain-verification', + 'sso/verify-domain', +]) function getAuthPath(request: NextRequest): string { const pathname = request.nextUrl?.pathname ?? new URL(request.url).pathname @@ -19,6 +27,23 @@ function isBlockedOrganizationMutationPath(path: string): boolean { return path.startsWith('organization/') && !SAFE_ORGANIZATION_POST_PATHS.has(path) } +function getSSOCallbackProviderId(path: string): string | null { + const prefix = path.startsWith('sso/callback/') + ? 'sso/callback/' + : path.startsWith('sso/saml2/callback/') + ? 'sso/saml2/callback/' + : null + if (!prefix) return null + + const encodedProviderId = path.slice(prefix.length).split('/')[0] + if (!encodedProviderId) return null + try { + return decodeURIComponent(encodedProviderId) + } catch { + return null + } +} + export const GET = withRouteHandler(async (request: NextRequest) => { const path = getAuthPath(request) @@ -27,7 +52,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json(createAnonymousSession()) } - return betterAuthGET(request) + const callbackProviderId = getSSOCallbackProviderId(path) + return callbackProviderId + ? withSSOCallbackIntent(callbackProviderId, () => betterAuthGET(request)) + : betterAuthGET(request) }) export const POST = withRouteHandler(async (request: NextRequest) => { @@ -40,5 +68,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - return betterAuthPOST(request) + if (BLOCKED_SSO_MUTATION_PATHS.has(path)) { + return NextResponse.json( + { error: 'SSO mutations are handled by application API routes.' }, + { status: 404 } + ) + } + + const callbackProviderId = getSSOCallbackProviderId(path) + return callbackProviderId + ? withSSOCallbackIntent(callbackProviderId, () => betterAuthPOST(request)) + : betterAuthPOST(request) }) diff --git a/apps/sim/app/api/auth/sso/providers/[id]/domain-verification/request/route.ts b/apps/sim/app/api/auth/sso/providers/[id]/domain-verification/request/route.ts new file mode 100644 index 00000000000..5ed8b139c0c --- /dev/null +++ b/apps/sim/app/api/auth/sso/providers/[id]/domain-verification/request/route.ts @@ -0,0 +1,71 @@ +import { withSSOProviderMutationLock } from '@sim/db' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { requestSsoDomainVerificationContract } from '@/lib/api/contracts/auth' +import { parseRequest } from '@/lib/api/server' +import { auth, getSession } from '@/lib/auth' +import { + collectAuthHeaders, + getDomainVerificationRecordName, + getDomainVerificationRecordValue, + getManagedSSOProvider, + ssoManagementErrorResponse, +} from '@/lib/auth/sso/management' +import { env, isTruthy } from '@/lib/core/config/env' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +const logger = createLogger('SSODomainVerificationRequestRoute') + +type RouteContext = { params: Promise<{ id: string }> } + +export const POST = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + try { + if (!env.SSO_ENABLED) { + return NextResponse.json({ error: 'SSO is not enabled' }, { status: 400 }) + } + if (!isTruthy(env.SSO_DOMAIN_VERIFICATION_ENABLED)) { + return NextResponse.json({ error: 'SSO domain verification is not enabled' }, { status: 404 }) + } + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + const parsed = await parseRequest(requestSsoDomainVerificationContract, request, context) + if (!parsed.success) return parsed.response + + const { provider, result } = await withSSOProviderMutationLock(async () => { + const provider = await getManagedSSOProvider(parsed.data.params.id, session.user.id, { + requireCreator: true, + }) + const result = await auth.api.requestDomainVerification({ + body: { providerId: provider.providerId }, + headers: collectAuthHeaders(request), + }) + return { provider, result } + }) + + return NextResponse.json( + { + recordName: getDomainVerificationRecordName(provider.providerId, provider.domain), + recordValue: getDomainVerificationRecordValue( + provider.providerId, + result.domainVerificationToken + ), + }, + { status: 201 } + ) + } catch (error) { + const managedResponse = ssoManagementErrorResponse(error) + if (managedResponse) return managedResponse + logger.error('Failed to request SSO domain verification', { + error: getErrorMessage(error, 'Unknown error'), + }) + return NextResponse.json( + { error: 'Failed to request SSO domain verification' }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/auth/sso/providers/[id]/domain-verification/route.test.ts b/apps/sim/app/api/auth/sso/providers/[id]/domain-verification/route.test.ts new file mode 100644 index 00000000000..a3c1766231c --- /dev/null +++ b/apps/sim/app/api/auth/sso/providers/[id]/domain-verification/route.test.ts @@ -0,0 +1,173 @@ +/** + * @vitest-environment node + */ +import { createEnvMock, createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + dbState, + memberTable, + mockGetSession, + mockIsOrganizationOnEnterprisePlan, + mockRequestDomainVerification, + mockVerifyDomain, + mockWithSSODomainVerificationIntent, + mockWithSSOProviderMutationLock, + ssoProviderTable, +} = vi.hoisted(() => ({ + dbState: { + members: [] as Array<{ role: string }>, + providers: [] as Array>, + }, + memberTable: { + userId: 'member.userId', + organizationId: 'member.organizationId', + role: 'member.role', + }, + mockGetSession: vi.fn(), + mockIsOrganizationOnEnterprisePlan: vi.fn(), + mockRequestDomainVerification: vi.fn(), + mockVerifyDomain: vi.fn(), + mockWithSSODomainVerificationIntent: vi.fn( + (_provider: unknown, callback: () => Promise) => callback() + ), + mockWithSSOProviderMutationLock: vi.fn((callback: () => Promise) => callback()), + ssoProviderTable: { + id: 'sso.id', + issuer: 'sso.issuer', + domain: 'sso.domain', + domainVerified: 'sso.domainVerified', + oidcConfig: 'sso.oidcConfig', + samlConfig: 'sso.samlConfig', + userId: 'sso.userId', + providerId: 'sso.providerId', + organizationId: 'sso.organizationId', + }, +})) + +function makeBuilder(rows: unknown[]): Promise & { + where: () => ReturnType + limit: () => Promise +} { + const builder = Promise.resolve(rows) as Promise & { + where: () => ReturnType + limit: () => Promise + } + builder.where = () => makeBuilder(rows) + builder.limit = () => Promise.resolve(rows) + return builder +} + +vi.mock('@sim/db', () => ({ + db: { + select: () => ({ + from: (table: unknown) => + makeBuilder(table === memberTable ? dbState.members : dbState.providers), + }), + }, + member: memberTable, + ssoProvider: ssoProviderTable, + withSSOProviderMutationLock: mockWithSSOProviderMutationLock, +})) + +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, + auth: { + api: { + requestDomainVerification: mockRequestDomainVerification, + verifyDomain: mockVerifyDomain, + }, + }, +})) + +vi.mock('@/lib/auth/sso/provider-operation-intent', () => ({ + withSSODomainVerificationIntent: mockWithSSODomainVerificationIntent, +})) + +vi.mock('@/lib/billing', () => ({ + isOrganizationOnEnterprisePlan: mockIsOrganizationOnEnterprisePlan, +})) + +vi.mock('@/lib/core/config/env', () => + createEnvMock({ SSO_ENABLED: 'true', SSO_DOMAIN_VERIFICATION_ENABLED: 'true' }) +) + +import { POST as requestVerification } from '@/app/api/auth/sso/providers/[id]/domain-verification/request/route' +import { POST as verifyDomain } from '@/app/api/auth/sso/providers/[id]/domain-verification/verify/route' + +const PROVIDER = { + id: 'row-1', + issuer: 'https://idp.example.com', + domain: 'acme.com', + domainVerified: false, + oidcConfig: null, + samlConfig: '{}', + userId: 'creator-1', + providerId: 'acme-saml', + organizationId: 'org-1', +} +const context = { params: Promise.resolve({ id: 'row-1' }) } + +describe('SSO domain verification façades', () => { + beforeEach(() => { + vi.clearAllMocks() + dbState.members = [{ role: 'admin' }] + dbState.providers = [PROVIDER] + mockGetSession.mockResolvedValue({ user: { id: 'creator-1' } }) + mockIsOrganizationOnEnterprisePlan.mockResolvedValue(true) + mockRequestDomainVerification.mockResolvedValue({ domainVerificationToken: 'secret-token' }) + mockVerifyDomain.mockResolvedValue(undefined) + }) + + it('preserves Better Auth creator identity authorization', async () => { + mockGetSession.mockResolvedValue({ user: { id: 'different-admin' } }) + const response = await requestVerification(createMockRequest('POST'), context) + expect(response.status).toBe(403) + expect(mockRequestDomainVerification).not.toHaveBeenCalled() + }) + + it('returns DNS instructions only to the creator', async () => { + const response = await requestVerification( + createMockRequest('POST', undefined, { cookie: 'session=one' }), + context + ) + expect(response.status).toBe(201) + await expect(response.json()).resolves.toEqual({ + recordName: '_better-auth-token-acme-saml.acme.com', + recordValue: '_better-auth-token-acme-saml=secret-token', + }) + expect(mockRequestDomainVerification).toHaveBeenCalledWith( + expect.objectContaining({ headers: expect.objectContaining({ cookie: 'session=one' }) }) + ) + expect(mockWithSSOProviderMutationLock).toHaveBeenCalledOnce() + }) + + it('builds instructions from the provider domain observed inside the mutation lock', async () => { + mockWithSSOProviderMutationLock.mockImplementationOnce( + async (callback: () => Promise) => { + dbState.providers = [{ ...PROVIDER, domain: 'updated.acme.com' }] + return callback() + } + ) + + const response = await requestVerification(createMockRequest('POST'), context) + + expect(response.status).toBe(201) + await expect(response.json()).resolves.toMatchObject({ + recordName: '_better-auth-token-acme-saml.updated.acme.com', + }) + }) + + it('delegates DNS verification to Better Auth', async () => { + const response = await verifyDomain(createMockRequest('POST'), context) + expect(response.status).toBe(200) + expect(mockVerifyDomain).toHaveBeenCalledWith( + expect.objectContaining({ body: { providerId: 'acme-saml' } }) + ) + expect(mockWithSSODomainVerificationIntent).toHaveBeenCalledWith( + { id: 'row-1', providerId: 'acme-saml' }, + expect.any(Function) + ) + expect(mockWithSSOProviderMutationLock).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/auth/sso/providers/[id]/domain-verification/verify/route.ts b/apps/sim/app/api/auth/sso/providers/[id]/domain-verification/verify/route.ts new file mode 100644 index 00000000000..bd01088711c --- /dev/null +++ b/apps/sim/app/api/auth/sso/providers/[id]/domain-verification/verify/route.ts @@ -0,0 +1,58 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { verifySsoDomainContract } from '@/lib/api/contracts/auth' +import { parseRequest } from '@/lib/api/server' +import { auth, getSession } from '@/lib/auth' +import { + collectAuthHeaders, + getManagedSSOProvider, + ssoManagementErrorResponse, +} from '@/lib/auth/sso/management' +import { withSSODomainVerificationIntent } from '@/lib/auth/sso/provider-operation-intent' +import { env, isTruthy } from '@/lib/core/config/env' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +const logger = createLogger('SSODomainVerificationRoute') + +type RouteContext = { params: Promise<{ id: string }> } + +export const POST = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + try { + if (!env.SSO_ENABLED) { + return NextResponse.json({ error: 'SSO is not enabled' }, { status: 400 }) + } + if (!isTruthy(env.SSO_DOMAIN_VERIFICATION_ENABLED)) { + return NextResponse.json({ error: 'SSO domain verification is not enabled' }, { status: 404 }) + } + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + const parsed = await parseRequest(verifySsoDomainContract, request, context) + if (!parsed.success) return parsed.response + + const provider = await getManagedSSOProvider(parsed.data.params.id, session.user.id, { + requireCreator: true, + }) + await withSSODomainVerificationIntent( + { id: provider.id, providerId: provider.providerId }, + () => + auth.api.verifyDomain({ + body: { providerId: provider.providerId }, + headers: collectAuthHeaders(request), + }) + ) + + return NextResponse.json({ success: true }) + } catch (error) { + const managedResponse = ssoManagementErrorResponse(error) + if (managedResponse) return managedResponse + logger.error('Failed to verify SSO domain', { + error: getErrorMessage(error, 'Unknown error'), + }) + return NextResponse.json({ error: 'Failed to verify SSO domain' }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/auth/sso/providers/[id]/route.test.ts b/apps/sim/app/api/auth/sso/providers/[id]/route.test.ts new file mode 100644 index 00000000000..fd653c29dcd --- /dev/null +++ b/apps/sim/app/api/auth/sso/providers/[id]/route.test.ts @@ -0,0 +1,373 @@ +/** + * @vitest-environment node + */ +import { createEnvMock, createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + accountTable, + dbState, + memberTable, + mockDeleteSSOProvider, + mockGetSession, + mockIsOrganizationOnEnterprisePlan, + mockAssertNoActiveSSOProviderOperations, + mockUpdateSSOProvider, + mockWithSSOProviderMutationLock, + ssoProviderTable, +} = vi.hoisted(() => ({ + accountTable: { + id: 'account.id', + providerId: 'account.providerId', + }, + dbState: { + accounts: [] as Array<{ id: string }>, + members: [] as Array<{ role: string }>, + providers: [] as Array>, + }, + memberTable: { + userId: 'member.userId', + organizationId: 'member.organizationId', + role: 'member.role', + }, + mockDeleteSSOProvider: vi.fn(), + mockGetSession: vi.fn(), + mockIsOrganizationOnEnterprisePlan: vi.fn(), + mockAssertNoActiveSSOProviderOperations: vi.fn(), + mockUpdateSSOProvider: vi.fn(), + mockWithSSOProviderMutationLock: vi.fn((callback: () => Promise) => callback()), + ssoProviderTable: { + id: 'sso.id', + issuer: 'sso.issuer', + domain: 'sso.domain', + domainVerified: 'sso.domainVerified', + oidcConfig: 'sso.oidcConfig', + samlConfig: 'sso.samlConfig', + userId: 'sso.userId', + providerId: 'sso.providerId', + organizationId: 'sso.organizationId', + }, +})) + +function makeBuilder(rows: unknown[]): Promise & { + where: () => ReturnType + limit: () => Promise +} { + const builder = Promise.resolve(rows) as Promise & { + where: () => ReturnType + limit: () => Promise + } + builder.where = () => makeBuilder(rows) + builder.limit = () => Promise.resolve(rows) + return builder +} + +vi.mock('@sim/db', () => ({ + db: { + select: () => ({ + from: (table: unknown) => + makeBuilder( + table === memberTable + ? dbState.members + : table === accountTable + ? dbState.accounts + : dbState.providers + ), + }), + }, + account: accountTable, + member: memberTable, + ssoProvider: ssoProviderTable, + withSSOProviderMutationLock: mockWithSSOProviderMutationLock, +})) + +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, + auth: { + api: { + updateSSOProvider: mockUpdateSSOProvider, + deleteSSOProvider: mockDeleteSSOProvider, + }, + }, +})) + +vi.mock('@/lib/auth/sso/provider-operation-intent', () => ({ + assertNoActiveSSOProviderOperations: mockAssertNoActiveSSOProviderOperations, +})) + +vi.mock('@/lib/billing', () => ({ + isOrganizationOnEnterprisePlan: mockIsOrganizationOnEnterprisePlan, +})) + +vi.mock('@/lib/core/config/env', () => + createEnvMock({ SSO_ENABLED: 'true', SSO_DOMAIN_VERIFICATION_ENABLED: 'true' }) +) + +vi.mock('@/lib/core/utils/urls', () => ({ + getBaseUrl: () => 'https://app.example.com', +})) + +import { SSOManagementError } from '@/lib/auth/sso/management' +import { env } from '@/lib/core/config/env' +import { DELETE, PATCH } from '@/app/api/auth/sso/providers/[id]/route' + +const PROVIDER = { + id: 'row-1', + issuer: 'https://old-idp.example.com', + domain: 'old.example.com', + domainVerified: true, + oidcConfig: null, + samlConfig: JSON.stringify({ entryPoint: 'https://old-idp.example.com/sso' }), + userId: 'creator-1', + providerId: 'acme-saml', + organizationId: 'org-1', +} + +const SAML_UPDATE = { + issuer: 'https://idp.example.com', + domain: 'new.example.com', + mapping: { + id: 'name-id', + email: 'email', + name: 'name', + image: 'picture', + }, + entryPoint: 'https://idp.example.com/sso', + cert: '-----BEGIN CERTIFICATE-----\nPUBLIC\n-----END CERTIFICATE-----', + wantAssertionsSigned: true, +} + +const context = { params: Promise.resolve({ id: 'row-1' }) } + +describe('/api/auth/sso/providers/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + dbState.accounts = [] + dbState.members = [{ role: 'admin' }] + dbState.providers = [PROVIDER] + mockGetSession.mockResolvedValue({ user: { id: 'admin-1' } }) + mockIsOrganizationOnEnterprisePlan.mockResolvedValue(true) + mockUpdateSSOProvider.mockResolvedValue({ domainVerified: false }) + mockDeleteSSOProvider.mockResolvedValue({ success: true }) + }) + + it('updates through PATCH with the resolved immutable provider ID', async () => { + const response = await PATCH(createMockRequest('PATCH', SAML_UPDATE), context) + const payload = await response.json() + + expect(response.status).toBe(200) + expect(payload.domainVerified).toBe(false) + expect(mockUpdateSSOProvider).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + providerId: 'acme-saml', + domain: 'new.example.com', + samlConfig: expect.objectContaining({ + spMetadata: { + metadata: expect.stringContaining( + 'https://app.example.com/api/auth/sso/saml2/callback/acme-saml' + ), + }, + idpMetadata: { + metadata: expect.stringContaining('https://idp.example.com/sso'), + }, + }), + }), + }) + ) + expect(mockAssertNoActiveSSOProviderOperations).toHaveBeenCalledWith('acme-saml') + }) + + it('re-checks domain overlap inside the mutation lock before updating', async () => { + mockWithSSOProviderMutationLock.mockImplementationOnce( + async (callback: () => Promise) => { + dbState.providers = [ + PROVIDER, + { + id: 'concurrent', + issuer: 'https://other-idp.example.com', + domain: 'login.new.example.com', + oidcConfig: '{}', + samlConfig: null, + userId: 'creator-2', + providerId: 'concurrent-provider', + organizationId: 'org-2', + }, + ] + return callback() + } + ) + + const response = await PATCH(createMockRequest('PATCH', SAML_UPDATE), context) + + expect(response.status).toBe(409) + expect(mockWithSSOProviderMutationLock).toHaveBeenCalledOnce() + expect(mockUpdateSSOProvider).not.toHaveBeenCalled() + }) + + it('rejects an identity update while an SSO callback is in progress', async () => { + mockAssertNoActiveSSOProviderOperations.mockRejectedValueOnce( + new SSOManagementError( + 'An SSO operation is currently completing for this provider. Try again shortly.', + 409, + 'SSO_OPERATION_IN_PROGRESS' + ) + ) + + const response = await PATCH(createMockRequest('PATCH', SAML_UPDATE), context) + + expect(response.status).toBe(409) + await expect(response.json()).resolves.toMatchObject({ code: 'SSO_OPERATION_IN_PROGRESS' }) + expect(mockUpdateSSOProvider).not.toHaveBeenCalled() + }) + + it('rejects a stale update when the provider changes before lock acquisition', async () => { + mockWithSSOProviderMutationLock.mockImplementationOnce( + async (callback: () => Promise) => { + dbState.providers = [ + { + ...PROVIDER, + issuer: 'https://concurrent-idp.example.com', + domain: 'concurrent.example.com', + }, + ] + return callback() + } + ) + + const response = await PATCH( + createMockRequest('PATCH', { + ...SAML_UPDATE, + issuer: PROVIDER.issuer, + domain: PROVIDER.domain, + }), + context + ) + + expect(response.status).toBe(409) + await expect(response.json()).resolves.toMatchObject({ code: 'SSO_PROVIDER_CHANGED' }) + expect(mockUpdateSSOProvider).not.toHaveBeenCalled() + }) + + it('reports the compatibility-active state while verification enforcement is disabled', async () => { + const previousValue = env.SSO_DOMAIN_VERIFICATION_ENABLED + env.SSO_DOMAIN_VERIFICATION_ENABLED = undefined + try { + const response = await PATCH(createMockRequest('PATCH', SAML_UPDATE), context) + await expect(response.json()).resolves.toMatchObject({ domainVerified: true }) + } finally { + env.SSO_DOMAIN_VERIFICATION_ENABLED = previousValue + } + }) + + it('rejects attempts to change the provider type', async () => { + const response = await PATCH( + createMockRequest('PATCH', { + issuer: 'https://idp.example.com', + domain: 'new.example.com', + mapping: { id: 'sub', email: 'email', name: 'name', image: 'picture' }, + clientId: 'client', + clientSecret: 'secret', + scopes: ['openid'], + pkce: true, + authorizationEndpoint: 'https://idp.example.com/auth', + tokenEndpoint: 'https://idp.example.com/token', + jwksEndpoint: 'https://idp.example.com/jwks', + }), + context + ) + expect(response.status).toBe(400) + expect(mockUpdateSSOProvider).not.toHaveBeenCalled() + }) + + it('rejects members before invoking Better Auth', async () => { + dbState.members = [{ role: 'member' }] + const response = await PATCH(createMockRequest('PATCH', SAML_UPDATE), context) + expect(response.status).toBe(403) + expect(mockUpdateSSOProvider).not.toHaveBeenCalled() + }) + + it('rejects administrators who do not belong to the provider organization', async () => { + dbState.members = [] + const response = await PATCH(createMockRequest('PATCH', SAML_UPDATE), context) + expect(response.status).toBe(403) + expect(mockUpdateSSOProvider).not.toHaveBeenCalled() + }) + + it('deletes with the resolved provider ID', async () => { + const response = await DELETE(createMockRequest('DELETE'), context) + expect(response.status).toBe(200) + expect(mockDeleteSSOProvider).toHaveBeenCalledWith( + expect.objectContaining({ body: { providerId: 'acme-saml' } }) + ) + expect(mockAssertNoActiveSSOProviderOperations).toHaveBeenCalledWith('acme-saml') + }) + + it('does not delete by stale provider ID when the row disappears before lock acquisition', async () => { + mockWithSSOProviderMutationLock.mockImplementationOnce( + async (callback: () => Promise) => { + dbState.providers = [] + return callback() + } + ) + + const response = await DELETE(createMockRequest('DELETE'), context) + + expect(response.status).toBe(404) + expect(mockDeleteSSOProvider).not.toHaveBeenCalled() + }) + + it('allows administrator cleanup after the organization loses Enterprise', async () => { + mockIsOrganizationOnEnterprisePlan.mockResolvedValue(false) + + const updateResponse = await PATCH(createMockRequest('PATCH', SAML_UPDATE), context) + expect(updateResponse.status).toBe(403) + expect(mockUpdateSSOProvider).not.toHaveBeenCalled() + + const deleteResponse = await DELETE(createMockRequest('DELETE'), context) + expect(deleteResponse.status).toBe(200) + expect(mockDeleteSSOProvider).toHaveBeenCalledWith( + expect.objectContaining({ body: { providerId: 'acme-saml' } }) + ) + }) + + it('blocks identity changes and deletion while Better Auth account links exist', async () => { + dbState.accounts = [{ id: 'linked-account' }] + + const updateResponse = await PATCH(createMockRequest('PATCH', SAML_UPDATE), context) + expect(updateResponse.status).toBe(409) + expect(mockUpdateSSOProvider).not.toHaveBeenCalled() + + const deleteResponse = await DELETE(createMockRequest('DELETE'), context) + expect(deleteResponse.status).toBe(409) + expect(mockDeleteSSOProvider).not.toHaveBeenCalled() + }) + + it('blocks an identity update when a link appears while waiting for the mutation lock', async () => { + mockWithSSOProviderMutationLock.mockImplementationOnce( + async (callback: () => Promise) => { + dbState.accounts = [{ id: 'concurrent-link' }] + return callback() + } + ) + + const response = await PATCH(createMockRequest('PATCH', SAML_UPDATE), context) + + expect(response.status).toBe(409) + expect(mockUpdateSSOProvider).not.toHaveBeenCalled() + }) + + it('blocks deletion when a link appears while waiting for the mutation lock', async () => { + mockWithSSOProviderMutationLock.mockImplementationOnce( + async (callback: () => Promise) => { + dbState.accounts = [{ id: 'concurrent-link' }] + return callback() + } + ) + + const response = await DELETE(createMockRequest('DELETE'), context) + + expect(response.status).toBe(409) + expect(mockDeleteSSOProvider).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/auth/sso/providers/[id]/route.ts b/apps/sim/app/api/auth/sso/providers/[id]/route.ts new file mode 100644 index 00000000000..b9a19d79a44 --- /dev/null +++ b/apps/sim/app/api/auth/sso/providers/[id]/route.ts @@ -0,0 +1,183 @@ +import { withSSOProviderMutationLock } from '@sim/db' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { deleteSsoProviderContract, updateSsoProviderContract } from '@/lib/api/contracts/auth' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { auth, getSession } from '@/lib/auth' +import { + assertSSOProviderAvailable, + assertSSOProviderHasNoAccountLinks, + buildSSOProviderConfiguration, + collectAuthHeaders, + getManagedSSOProvider, + requireNormalizedSSODomain, + SSOManagementError, + ssoManagementErrorResponse, +} from '@/lib/auth/sso/management' +import { assertNoActiveSSOProviderOperations } from '@/lib/auth/sso/provider-operation-intent' +import { env, isTruthy } from '@/lib/core/config/env' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +const logger = createLogger('SSOProviderRoute') + +type RouteContext = { params: Promise<{ id: string }> } + +export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + try { + if (!env.SSO_ENABLED) { + return NextResponse.json({ error: 'SSO is not enabled' }, { status: 400 }) + } + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + const parsed = await parseRequest(updateSsoProviderContract, request, context, { + validationErrorResponse: (error) => + NextResponse.json( + { error: getValidationErrorMessage(error, 'Validation failed') }, + { status: 400 } + ), + }) + if (!parsed.success) return parsed.response + + const provider = await getManagedSSOProvider(parsed.data.params.id, session.user.id) + const body = parsed.data.body + const isSamlProvider = Boolean(provider.samlConfig) + if (isSamlProvider !== 'entryPoint' in body) { + throw new SSOManagementError('Provider type cannot be changed', 400, 'SSO_TYPE_IMMUTABLE') + } + + const domain = requireNormalizedSSODomain(body.domain, provider.domain) + const changesProviderIdentity = domain !== provider.domain || body.issuer !== provider.issuer + if (changesProviderIdentity) { + await assertSSOProviderHasNoAccountLinks(provider.providerId) + } + await assertSSOProviderAvailable({ + providerId: provider.providerId, + domain, + organizationId: provider.organizationId!, + excludeRowId: provider.id, + }) + + const providerConfig = await buildSSOProviderConfiguration( + { ...body, domain }, + { + providerId: provider.providerId, + existingConfig: isSamlProvider ? provider.samlConfig : provider.oidcConfig, + existingIssuer: provider.issuer, + existingDomain: provider.domain, + } + ) + const mutation = await withSSOProviderMutationLock(async () => { + const currentProvider = await getManagedSSOProvider(parsed.data.params.id, session.user.id) + const currentIsSamlProvider = Boolean(currentProvider.samlConfig) + if (currentIsSamlProvider !== 'entryPoint' in body) { + throw new SSOManagementError('Provider type cannot be changed', 400, 'SSO_TYPE_IMMUTABLE') + } + + const configurationSourceChanged = + currentProvider.issuer !== provider.issuer || + currentProvider.domain !== provider.domain || + currentProvider.oidcConfig !== provider.oidcConfig || + currentProvider.samlConfig !== provider.samlConfig + if (configurationSourceChanged) { + throw new SSOManagementError( + 'The SSO provider changed while this update was being prepared. Reload and try again.', + 409, + 'SSO_PROVIDER_CHANGED' + ) + } + + const currentDomain = requireNormalizedSSODomain(body.domain, currentProvider.domain) + if (currentDomain !== currentProvider.domain || body.issuer !== currentProvider.issuer) { + await assertNoActiveSSOProviderOperations(currentProvider.providerId) + await assertSSOProviderHasNoAccountLinks(currentProvider.providerId) + } + await assertSSOProviderAvailable({ + providerId: currentProvider.providerId, + domain: currentDomain, + organizationId: currentProvider.organizationId!, + excludeRowId: currentProvider.id, + }) + + const updated = await auth.api.updateSSOProvider({ + body: { ...providerConfig, domain: currentDomain }, + headers: collectAuthHeaders(request), + }) + return { + updated, + provider: currentProvider, + domain: currentDomain, + isSamlProvider: currentIsSamlProvider, + } + }) + + logger.info('SSO provider updated', { + providerId: mutation.provider.providerId, + providerType: mutation.isSamlProvider ? 'saml' : 'oidc', + domain: mutation.domain, + organizationId: mutation.provider.organizationId, + }) + + return NextResponse.json({ + success: true, + providerId: mutation.provider.providerId, + providerType: mutation.isSamlProvider ? ('saml' as const) : ('oidc' as const), + domainVerified: isTruthy(env.SSO_DOMAIN_VERIFICATION_ENABLED) + ? mutation.updated.domainVerified + : true, + message: 'SSO provider updated successfully', + }) + } catch (error) { + const managedResponse = ssoManagementErrorResponse(error) + if (managedResponse) return managedResponse + logger.error('Failed to update SSO provider', { + error: getErrorMessage(error, 'Unknown error'), + }) + return NextResponse.json({ error: 'Failed to update SSO provider' }, { status: 500 }) + } +}) + +export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + try { + if (!env.SSO_ENABLED) { + return NextResponse.json({ error: 'SSO is not enabled' }, { status: 400 }) + } + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + const parsed = await parseRequest(deleteSsoProviderContract, request, context) + if (!parsed.success) return parsed.response + + const provider = await withSSOProviderMutationLock(async () => { + const currentProvider = await getManagedSSOProvider(parsed.data.params.id, session.user.id, { + requireEnterprise: false, + }) + await assertNoActiveSSOProviderOperations(currentProvider.providerId) + await assertSSOProviderHasNoAccountLinks(currentProvider.providerId) + await auth.api.deleteSSOProvider({ + body: { providerId: currentProvider.providerId }, + headers: collectAuthHeaders(request), + }) + return currentProvider + }) + + logger.info('SSO provider deleted', { + providerId: provider.providerId, + organizationId: provider.organizationId, + }) + return NextResponse.json({ success: true }) + } catch (error) { + const managedResponse = ssoManagementErrorResponse(error) + if (managedResponse) return managedResponse + logger.error('Failed to delete SSO provider', { + error: getErrorMessage(error, 'Unknown error'), + }) + return NextResponse.json({ error: 'Failed to delete SSO provider' }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/auth/sso/providers/route.test.ts b/apps/sim/app/api/auth/sso/providers/route.test.ts new file mode 100644 index 00000000000..1bd7cbf6ab4 --- /dev/null +++ b/apps/sim/app/api/auth/sso/providers/route.test.ts @@ -0,0 +1,147 @@ +/** + * @vitest-environment node + */ +import { createEnvMock, createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + dbState, + memberTable, + mockGetSession, + mockIsOrganizationOnEnterprisePlan, + ssoProviderTable, +} = vi.hoisted(() => ({ + dbState: { + members: [] as Array<{ role: string }>, + providers: [] as Array>, + }, + memberTable: { + userId: 'member.userId', + organizationId: 'member.organizationId', + role: 'member.role', + }, + mockGetSession: vi.fn(), + mockIsOrganizationOnEnterprisePlan: vi.fn(), + ssoProviderTable: { + id: 'sso.id', + providerId: 'sso.providerId', + domain: 'sso.domain', + issuer: 'sso.issuer', + oidcConfig: 'sso.oidcConfig', + samlConfig: 'sso.samlConfig', + userId: 'sso.userId', + organizationId: 'sso.organizationId', + domainVerified: 'sso.domainVerified', + }, +})) + +function makeBuilder(rows: unknown[]): Promise & { + where: () => ReturnType + limit: () => Promise +} { + const builder = Promise.resolve(rows) as Promise & { + where: () => ReturnType + limit: () => Promise + } + builder.where = () => makeBuilder(rows) + builder.limit = () => Promise.resolve(rows) + return builder +} + +vi.mock('@sim/db', () => ({ + db: { + select: () => ({ + from: (table: unknown) => + makeBuilder(table === memberTable ? dbState.members : dbState.providers), + }), + }, + member: memberTable, + ssoProvider: ssoProviderTable, +})) + +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) + +vi.mock('@/lib/core/config/env', () => createEnvMock({ SSO_DOMAIN_VERIFICATION_ENABLED: 'true' })) + +vi.mock('@/lib/billing', () => ({ + isOrganizationOnEnterprisePlan: mockIsOrganizationOnEnterprisePlan, +})) + +vi.mock('@/lib/core/rate-limiter', () => ({ + enforceIpRateLimit: vi.fn(), +})) + +import { GET } from '@/app/api/auth/sso/providers/route' + +describe('GET /api/auth/sso/providers', () => { + beforeEach(() => { + vi.clearAllMocks() + dbState.members = [{ role: 'admin' }] + dbState.providers = [ + { + id: 'row-1', + providerId: 'acme-saml', + domain: 'acme.com', + issuer: 'https://idp.example.com', + oidcConfig: null, + samlConfig: '{}', + userId: 'creator-1', + organizationId: 'org-1', + domainVerified: false, + }, + ] + mockGetSession.mockResolvedValue({ user: { id: 'creator-1' } }) + mockIsOrganizationOnEnterprisePlan.mockResolvedValue(true) + }) + + it('returns activation state and creator capability without a verification token', async () => { + const response = await GET( + createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/auth/sso/providers?organizationId=org-1' + ) + ) + const payload = await response.json() + + expect(response.status).toBe(200) + expect(payload.providers[0]).toMatchObject({ + domainVerified: false, + isCreator: true, + canManageVerification: true, + }) + expect(payload.providers[0]).not.toHaveProperty('domainVerificationToken') + expect(payload.providers[0]).not.toHaveProperty('userId') + }) + + it('loads for an admin before the Enterprise plan gate is satisfied', async () => { + mockIsOrganizationOnEnterprisePlan.mockResolvedValue(false) + const response = await GET( + createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/auth/sso/providers?organizationId=org-1' + ) + ) + + expect(response.status).toBe(200) + expect(mockIsOrganizationOnEnterprisePlan).not.toHaveBeenCalled() + }) + + it('fails closed for non-admin organization members', async () => { + dbState.members = [{ role: 'member' }] + const response = await GET( + createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/auth/sso/providers?organizationId=org-1' + ) + ) + expect(response.status).toBe(403) + }) +}) diff --git a/apps/sim/app/api/auth/sso/providers/route.ts b/apps/sim/app/api/auth/sso/providers/route.ts index 8428eebc1e1..e0c7392d4a5 100644 --- a/apps/sim/app/api/auth/sso/providers/route.ts +++ b/apps/sim/app/api/auth/sso/providers/route.ts @@ -1,10 +1,15 @@ -import { db, member, ssoProvider } from '@sim/db' +import { db, ssoProvider } from '@sim/db' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' +import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { listSsoProvidersContract } from '@/lib/api/contracts/auth' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' +import { + authorizeOrganizationSSOAdmin, + ssoManagementErrorResponse, +} from '@/lib/auth/sso/management' +import { env, isTruthy } from '@/lib/core/config/env' import { enforceIpRateLimit } from '@/lib/core/rate-limiter' import { REDACTED_MARKER } from '@/lib/core/security/redaction' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -32,18 +37,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { let verifiedOrganizationId: string | null = null if (organizationId) { - const [membership] = await db - .select({ organizationId: member.organizationId, role: member.role }) - .from(member) - .where(and(eq(member.userId, userId), eq(member.organizationId, organizationId))) - .limit(1) - if (!membership) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - if (membership.role !== 'owner' && membership.role !== 'admin') { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - verifiedOrganizationId = membership.organizationId + await authorizeOrganizationSSOAdmin(userId, organizationId) + verifiedOrganizationId = organizationId } const whereClause = verifiedOrganizationId @@ -60,11 +55,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => { samlConfig: ssoProvider.samlConfig, userId: ssoProvider.userId, organizationId: ssoProvider.organizationId, + domainVerified: ssoProvider.domainVerified, }) .from(ssoProvider) .where(whereClause) providers = results.map((provider) => { + const { userId: creatorUserId, ...publicProvider } = provider let oidcConfig = provider.oidcConfig if (oidcConfig) { try { @@ -76,9 +73,14 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } } return { - ...provider, + ...publicProvider, + domainVerified: isTruthy(env.SSO_DOMAIN_VERIFICATION_ENABLED) + ? provider.domainVerified + : true, oidcConfig, providerType: (provider.samlConfig ? 'saml' : 'oidc') as 'oidc' | 'saml', + isCreator: creatorUserId === userId, + canManageVerification: creatorUserId === userId, } }) } else { @@ -101,6 +103,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ providers }) } catch (error) { + const managedResponse = ssoManagementErrorResponse(error) + if (managedResponse) return managedResponse logger.error('Failed to fetch SSO providers', { error }) return NextResponse.json({ error: 'Failed to fetch SSO providers' }, { status: 500 }) } diff --git a/apps/sim/app/api/auth/sso/register/route.test.ts b/apps/sim/app/api/auth/sso/register/route.test.ts index cdacc7c6c88..aeccab8baae 100644 --- a/apps/sim/app/api/auth/sso/register/route.test.ts +++ b/apps/sim/app/api/auth/sso/register/route.test.ts @@ -5,51 +5,50 @@ import { createEnvMock, createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { + dbState, + memberTable, mockGetSession, + mockIsOrganizationOnEnterprisePlan, mockRegisterSSOProvider, - mockHasSSOAccess, - mockValidateUrlWithDNS, mockSecureFetchWithPinnedIP, - dbState, - memberTable, + mockValidateUrlWithDNS, + mockWithSSOProviderMutationLock, ssoProviderTable, } = vi.hoisted(() => ({ - mockGetSession: vi.fn(), - mockRegisterSSOProvider: vi.fn(), - mockHasSSOAccess: vi.fn(), - mockValidateUrlWithDNS: vi.fn(), - mockSecureFetchWithPinnedIP: vi.fn(), - dbState: { members: [] as any[], providers: [] as any[] }, + dbState: { + members: [] as Array<{ role: string }>, + providers: [] as Array>, + }, memberTable: { userId: 'member.userId', organizationId: 'member.organizationId', role: 'member.role', }, + mockGetSession: vi.fn(), + mockIsOrganizationOnEnterprisePlan: vi.fn(), + mockRegisterSSOProvider: vi.fn(), + mockSecureFetchWithPinnedIP: vi.fn(), + mockValidateUrlWithDNS: vi.fn(), + mockWithSSOProviderMutationLock: vi.fn((callback: () => Promise) => callback()), ssoProviderTable: { id: 'sso.id', providerId: 'sso.providerId', domain: 'sso.domain', - issuer: 'sso.issuer', - userId: 'sso.userId', organizationId: 'sso.organizationId', - oidcConfig: 'sso.oidcConfig', - samlConfig: 'sso.samlConfig', }, })) -function makeBuilder(rows: any[]): any { - const thenable: any = Promise.resolve(rows) - thenable.where = (condition: any) => { - const values = condition?.values - if (Array.isArray(values) && values.length > 0) { - const target = String(values[values.length - 1]).toLowerCase() - return makeBuilder(rows.filter((r) => String(r.domain ?? '').toLowerCase() === target)) - } - return makeBuilder(rows) +function makeBuilder(rows: unknown[]): Promise & { + where: () => ReturnType + limit: () => Promise +} { + const builder = Promise.resolve(rows) as Promise & { + where: () => ReturnType + limit: () => Promise } - thenable.limit = () => Promise.resolve(rows) - thenable.orderBy = () => Promise.resolve(rows) - return thenable + builder.where = () => makeBuilder(rows) + builder.limit = () => Promise.resolve(rows) + return builder } vi.mock('@sim/db', () => ({ @@ -61,6 +60,7 @@ vi.mock('@sim/db', () => ({ }, member: memberTable, ssoProvider: ssoProviderTable, + withSSOProviderMutationLock: mockWithSSOProviderMutationLock, })) vi.mock('@/lib/auth', () => ({ @@ -69,15 +69,7 @@ vi.mock('@/lib/auth', () => ({ })) vi.mock('@/lib/billing', () => ({ - hasSSOAccess: mockHasSSOAccess, -})) - -vi.mock('@/lib/auth/sso/domain', () => ({ - normalizeSSODomain: (input: unknown): string | null => { - if (typeof input !== 'string') return null - const value = input.trim().toLowerCase() - return /^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(value) ? value : null - }, + isOrganizationOnEnterprisePlan: mockIsOrganizationOnEnterprisePlan, })) vi.mock('@/lib/core/security/input-validation.server', () => ({ @@ -85,7 +77,9 @@ vi.mock('@/lib/core/security/input-validation.server', () => ({ secureFetchWithPinnedIP: mockSecureFetchWithPinnedIP, })) -vi.mock('@/lib/core/config/env', () => createEnvMock({ SSO_ENABLED: 'true' })) +vi.mock('@/lib/core/config/env', () => + createEnvMock({ SSO_ENABLED: 'true', SSO_DOMAIN_VERIFICATION_ENABLED: 'true' }) +) import { POST } from '@/app/api/auth/sso/register/route' @@ -94,6 +88,7 @@ const OIDC_BODY = { providerId: 'acme-oidc', issuer: 'https://idp.acme.com', domain: 'acme.com', + orgId: 'org-1', clientId: 'client-id', clientSecret: 'client-secret', authorizationEndpoint: 'https://idp.acme.com/authorize', @@ -102,263 +97,186 @@ const OIDC_BODY = { jwksEndpoint: 'https://idp.acme.com/jwks', } +const SAML_BODY = { + providerType: 'saml' as const, + providerId: 'acme-saml', + issuer: 'https://idp.acme.com', + domain: 'acme.com', + orgId: 'org-1', + entryPoint: 'https://idp.acme.com/sso', + cert: 'public-test-certificate', +} + function request(body: Record) { - return createMockRequest('POST', body) + return createMockRequest('POST', body, { cookie: 'session=one' }) } describe('POST /api/auth/sso/register', () => { beforeEach(() => { vi.clearAllMocks() - dbState.members = [] + dbState.members = [{ role: 'owner' }] dbState.providers = [] - mockGetSession.mockResolvedValue({ user: { id: 'u1' } }) - mockHasSSOAccess.mockResolvedValue(true) + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mockIsOrganizationOnEnterprisePlan.mockResolvedValue(true) mockValidateUrlWithDNS.mockResolvedValue({ isValid: true, resolvedIP: '1.2.3.4' }) - mockSecureFetchWithPinnedIP.mockRejectedValue(new Error('discovery not mocked for this test')) - mockRegisterSSOProvider.mockResolvedValue({ providerId: 'acme-oidc' }) + mockSecureFetchWithPinnedIP.mockRejectedValue(new Error('Discovery unavailable')) + mockRegisterSSOProvider.mockResolvedValue({ + providerId: 'acme-oidc', + domainVerified: false, + domainVerificationToken: 'verification-token', + }) }) - it('rejects callers without an Enterprise plan', async () => { - mockHasSSOAccess.mockResolvedValue(false) - const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' })) - expect(res.status).toBe(403) + it('rejects members and non-members', async () => { + for (const members of [[], [{ role: 'member' }]]) { + dbState.members = members + const response = await POST(request(OIDC_BODY)) + expect(response.status).toBe(403) + } expect(mockRegisterSSOProvider).not.toHaveBeenCalled() }) - it('rejects callers who are not an admin/owner of the target org', async () => { - dbState.members = [{ organizationId: 'org1', role: 'member' }] - const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' })) - expect(res.status).toBe(403) + it('rejects organizations without Enterprise authorization', async () => { + mockIsOrganizationOnEnterprisePlan.mockResolvedValue(false) + const response = await POST(request(OIDC_BODY)) + expect(response.status).toBe(403) expect(mockRegisterSSOProvider).not.toHaveBeenCalled() }) - it('rejects an invalid domain', async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] - const res = await POST(request({ ...OIDC_BODY, domain: 'not-a-domain', orgId: 'org1' })) - expect(res.status).toBe(400) + it('rejects comma lists and public suffixes', async () => { + for (const domain of ['acme.com,subsidiary.com', 'com']) { + const response = await POST(request({ ...OIDC_BODY, domain })) + expect(response.status).toBe(400) + } expect(mockRegisterSSOProvider).not.toHaveBeenCalled() }) - it('rejects a domain already registered by another organization', async () => { - dbState.members = [{ organizationId: 'org-attacker', role: 'owner' }] - dbState.providers = [{ domain: 'acme.com', userId: 'u-victim', organizationId: 'org-victim' }] - const res = await POST(request({ ...OIDC_BODY, orgId: 'org-attacker' })) - const json = await res.json() - expect(res.status).toBe(409) - expect(json.code).toBe('SSO_DOMAIN_ALREADY_REGISTERED') + it.each([OIDC_BODY, SAML_BODY])( + 'rejects built-in trusted IDs for $providerType registration', + async (body) => { + const response = await POST(request({ ...body, providerId: 'google' })) + await expect(response.json()).resolves.toMatchObject({ code: 'SSO_PROVIDER_ID_RESERVED' }) + expect(response.status).toBe(400) + expect(mockRegisterSSOProvider).not.toHaveBeenCalled() + } + ) + + it('rejects non-web SAML issuer and entry-point schemes', async () => { + const samlBody = { + providerType: 'saml', + providerId: 'acme-saml', + issuer: 'https://idp.acme.com', + domain: 'acme.com', + orgId: 'org-1', + entryPoint: 'https://idp.acme.com/sso', + cert: 'public-test-certificate', + } + for (const body of [ + { ...samlBody, issuer: 'not a url' }, + { ...samlBody, issuer: 'javascript:alert(1)' }, + { ...samlBody, entryPoint: 'data:text/html,invalid' }, + ]) { + const response = await POST(request(body)) + expect(response.status).toBe(400) + } expect(mockRegisterSSOProvider).not.toHaveBeenCalled() }) - it('matches conflicts across casing variants', async () => { - dbState.members = [{ organizationId: 'org-attacker', role: 'owner' }] - dbState.providers = [{ domain: 'ACME.com', userId: 'u-victim', organizationId: 'org-victim' }] - const res = await POST(request({ ...OIDC_BODY, orgId: 'org-attacker' })) - expect(res.status).toBe(409) + it('rejects parent and child domain overlap owned by another tenant', async () => { + for (const existingDomain of ['acme.com', 'login.acme.com']) { + dbState.providers = [ + { + id: 'existing', + providerId: 'other', + domain: existingDomain, + organizationId: 'org-other', + }, + ] + const domain = existingDomain === 'acme.com' ? 'login.acme.com' : 'acme.com' + const response = await POST(request({ ...OIDC_BODY, domain })) + expect(response.status).toBe(409) + } expect(mockRegisterSSOProvider).not.toHaveBeenCalled() }) - it('registers when the domain is unclaimed', async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] - const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' })) - expect(res.status).toBe(200) - expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1) - }) - - it('allows the owning tenant to update its own provider for the same domain', async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] - dbState.providers = [{ domain: 'acme.com', userId: 'u1', organizationId: 'org1' }] - const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' })) - expect(res.status).toBe(200) - expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1) - }) + it('re-checks domain overlap inside the mutation lock after configuration work', async () => { + mockWithSSOProviderMutationLock.mockImplementationOnce( + async (callback: () => Promise) => { + dbState.providers = [ + { + id: 'concurrent', + providerId: 'concurrent-provider', + domain: 'login.acme.com', + organizationId: 'org-concurrent', + }, + ] + return callback() + } + ) - it('lets an org admin adopt their own user-scoped provider for the same domain', async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] - dbState.providers = [{ domain: 'acme.com', userId: 'u1', organizationId: null }] - const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' })) - expect(res.status).toBe(200) - expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1) - }) + const response = await POST(request(OIDC_BODY)) - it("still blocks an org admin from claiming another user's user-scoped domain", async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] - dbState.providers = [{ domain: 'acme.com', userId: 'someone-else', organizationId: null }] - const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' })) - expect(res.status).toBe(409) + expect(response.status).toBe(409) + expect(mockWithSSOProviderMutationLock).toHaveBeenCalledOnce() expect(mockRegisterSSOProvider).not.toHaveBeenCalled() }) - it('normalizes the domain before persisting it', async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] - const res = await POST(request({ ...OIDC_BODY, domain: 'ACME.com', orgId: 'org1' })) - expect(res.status).toBe(200) - expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1) - const config = mockRegisterSSOProvider.mock.calls[0][0].body - expect(config.domain).toBe('acme.com') - }) - - it('passes skipDiscovery since Sim already resolved and validated the OIDC endpoints', async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] - const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' })) - expect(res.status).toBe(200) - const config = mockRegisterSSOProvider.mock.calls[0][0].body - expect(config.oidcConfig.skipDiscovery).toBe(true) + it('enforces one provider per organization', async () => { + dbState.providers = [ + { + id: 'existing', + providerId: 'existing-id', + domain: 'other.com', + organizationId: 'org-1', + }, + ] + const response = await POST(request(OIDC_BODY)) + expect(response.status).toBe(409) + expect(mockRegisterSSOProvider).not.toHaveBeenCalled() }) - it('omits userInfoEndpoint when skipUserInfoEndpoint is requested, forcing ID token claims', async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] - const res = await POST(request({ ...OIDC_BODY, skipUserInfoEndpoint: true, orgId: 'org1' })) - expect(res.status).toBe(200) - const config = mockRegisterSSOProvider.mock.calls[0][0].body - expect(config.oidcConfig.userInfoEndpoint).toBeUndefined() - }) + it('creates a pending provider and forwards request headers', async () => { + const response = await POST(request({ ...OIDC_BODY, domain: 'ACME.COM' })) + const payload = await response.json() - it('does not SSRF-validate userInfoEndpoint when skipUserInfoEndpoint is requested', async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] - mockValidateUrlWithDNS.mockImplementation(async (url: string, label: string) => { - if (label === 'OIDC userInfoEndpoint') { - return { isValid: false, error: 'resolves to a private IP address' } - } - return { isValid: true, resolvedIP: '1.2.3.4' } + expect(response.status).toBe(200) + expect(payload).toMatchObject({ + providerId: 'acme-oidc', + domainVerified: false, }) - const res = await POST(request({ ...OIDC_BODY, skipUserInfoEndpoint: true, orgId: 'org1' })) - expect(res.status).toBe(200) - const config = mockRegisterSSOProvider.mock.calls[0][0].body - expect(config.oidcConfig.userInfoEndpoint).toBeUndefined() + expect(payload).not.toHaveProperty('domainVerificationToken') + expect(mockRegisterSSOProvider).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ domain: 'acme.com' }), + headers: expect.objectContaining({ cookie: 'session=one' }), + }) + ) }) - it('does not SSRF-validate a discovered userinfo_endpoint when skipUserInfoEndpoint is requested', async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] - mockValidateUrlWithDNS.mockImplementation(async (url: string, label: string) => { - if (label === 'OIDC userinfo_endpoint') { - return { isValid: false, error: 'resolves to a private IP address' } + it.each([ + { body: OIDC_BODY, configKey: 'oidcConfig' }, + { body: SAML_BODY, configKey: 'samlConfig' }, + ])( + 'forwards $body.providerType claim mapping in the protocol config', + async ({ body, configKey }) => { + const mapping = { + id: 'custom-subject', + email: 'custom-email', + name: 'custom-name', + image: 'custom-picture', } - return { isValid: true, resolvedIP: '1.2.3.4' } - }) - mockSecureFetchWithPinnedIP.mockResolvedValue({ - ok: true, - json: async () => ({ - authorization_endpoint: 'https://idp.acme.com/authorize', - token_endpoint: 'https://idp.acme.com/token', - userinfo_endpoint: 'http://169.254.169.254/userinfo', - jwks_uri: 'https://idp.acme.com/jwks', - }), - }) - const discoveredBody = { - ...OIDC_BODY, - authorizationEndpoint: undefined, - tokenEndpoint: undefined, - jwksEndpoint: undefined, - skipUserInfoEndpoint: true, - } - const res = await POST(request({ ...discoveredBody, orgId: 'org1' })) - expect(res.status).toBe(200) - const config = mockRegisterSSOProvider.mock.calls[0][0].body - expect(config.oidcConfig.userInfoEndpoint).toBeUndefined() - }) + const response = await POST(request({ ...body, mapping })) + expect(response.status).toBe(200) - it('keeps userInfoEndpoint when skipUserInfoEndpoint is not requested', async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] - const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' })) - expect(res.status).toBe(200) - const config = mockRegisterSSOProvider.mock.calls[0][0].body - expect(config.oidcConfig.userInfoEndpoint).toBe('https://idp.acme.com/userinfo') - }) - - it('selects tokenEndpointAuthentication from the discovery document when endpoints are auto-discovered', async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] - mockSecureFetchWithPinnedIP.mockResolvedValue({ - ok: true, - json: async () => ({ - authorization_endpoint: 'https://idp.acme.com/authorize', - token_endpoint: 'https://idp.acme.com/token', - userinfo_endpoint: 'https://idp.acme.com/userinfo', - jwks_uri: 'https://idp.acme.com/jwks', - token_endpoint_auth_methods_supported: ['client_secret_post'], - }), - }) - const discoveredBody = { - ...OIDC_BODY, - authorizationEndpoint: undefined, - tokenEndpoint: undefined, - jwksEndpoint: undefined, + const forwardedBody = mockRegisterSSOProvider.mock.calls[0][0].body + expect(forwardedBody).not.toHaveProperty('mapping') + expect(forwardedBody[configKey]).toMatchObject({ mapping }) } - const res = await POST(request({ ...discoveredBody, orgId: 'org1' })) - expect(res.status).toBe(200) - const config = mockRegisterSSOProvider.mock.calls[0][0].body - expect(config.oidcConfig.tokenEndpointAuthentication).toBe('client_secret_post') - }) + ) - it('still selects tokenEndpointAuthentication from discovery when all endpoints are explicit', async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] - mockSecureFetchWithPinnedIP.mockResolvedValue({ - ok: true, - json: async () => ({ - token_endpoint_auth_methods_supported: ['client_secret_post'], - }), - }) - const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' })) - expect(res.status).toBe(200) - const config = mockRegisterSSOProvider.mock.calls[0][0].body - expect(config.oidcConfig.tokenEndpointAuthentication).toBe('client_secret_post') - expect(config.oidcConfig.authorizationEndpoint).toBe(OIDC_BODY.authorizationEndpoint) - }) - - it('registers successfully when discovery is unreachable and all endpoints are explicit', async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] - mockSecureFetchWithPinnedIP.mockRejectedValue(new Error('ECONNREFUSED')) - const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' })) - expect(res.status).toBe(200) - const config = mockRegisterSSOProvider.mock.calls[0][0].body - expect(config.oidcConfig.skipDiscovery).toBe(true) - expect(config.oidcConfig.authorizationEndpoint).toBe(OIDC_BODY.authorizationEndpoint) - expect(config.oidcConfig.tokenEndpointAuthentication).toBe('client_secret_post') - }) - - it('prefers client_secret_post over client_secret_basic when an IdP supports both', async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] - mockSecureFetchWithPinnedIP.mockResolvedValue({ - ok: true, - json: async () => ({ - token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'], - }), - }) - const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' })) - expect(res.status).toBe(200) - const config = mockRegisterSSOProvider.mock.calls[0][0].body - expect(config.oidcConfig.tokenEndpointAuthentication).toBe('client_secret_post') - }) - - it('defaults to client_secret_post when discovery advertises no auth methods', async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] - mockSecureFetchWithPinnedIP.mockResolvedValue({ - ok: true, - json: async () => ({}), - }) - const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' })) - expect(res.status).toBe(200) - const config = mockRegisterSSOProvider.mock.calls[0][0].body - expect(config.oidcConfig.tokenEndpointAuthentication).toBe('client_secret_post') - }) - - it('surfaces the specific discovery failure reason when endpoints are missing', async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] - mockValidateUrlWithDNS.mockImplementation(async (url: string, label: string) => { - if (label === 'OIDC discovery URL') { - return { isValid: false, error: 'resolves to a private IP address' } - } - return { isValid: true, resolvedIP: '1.2.3.4' } - }) - const discoveredBody = { - ...OIDC_BODY, - authorizationEndpoint: undefined, - tokenEndpoint: undefined, - jwksEndpoint: undefined, - } - const res = await POST(request({ ...discoveredBody, orgId: 'org1' })) - const json = await res.json() - expect(res.status).toBe(400) - expect(json.error).toContain('resolves to a private IP address') - expect(mockRegisterSSOProvider).not.toHaveBeenCalled() + it('maps adapter uniqueness races to 409', async () => { + mockRegisterSSOProvider.mockRejectedValue({ code: '23505' }) + const response = await POST(request(OIDC_BODY)) + expect(response.status).toBe(409) }) }) diff --git a/apps/sim/app/api/auth/sso/register/route.ts b/apps/sim/app/api/auth/sso/register/route.ts index d826c4641fb..b7abdb9fbc7 100644 --- a/apps/sim/app/api/auth/sso/register/route.ts +++ b/apps/sim/app/api/auth/sso/register/route.ts @@ -1,71 +1,25 @@ -import { db, member, ssoProvider } from '@sim/db' +import { withSSOProviderMutationLock } from '@sim/db' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { and, eq, sql } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' import { ssoRegistrationContract } from '@/lib/api/contracts/auth' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { auth, getSession } from '@/lib/auth' -import { normalizeSSODomain } from '@/lib/auth/sso/domain' -import { hasSSOAccess } from '@/lib/billing' -import { env } from '@/lib/core/config/env' import { - secureFetchWithPinnedIP, - validateUrlWithDNS, -} from '@/lib/core/security/input-validation.server' -import { REDACTED_MARKER } from '@/lib/core/security/redaction' -import { getBaseUrl } from '@/lib/core/utils/urls' + assertSSOProviderAvailable, + authorizeOrganizationSSO, + buildSSOProviderConfiguration, + collectAuthHeaders, + requireNormalizedSSODomain, + ssoManagementErrorResponse, + validateSSOProviderId, +} from '@/lib/auth/sso/management' +import { env, isTruthy } from '@/lib/core/config/env' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('SSORegisterRoute') -type TokenEndpointAuthMethod = 'client_secret_basic' | 'client_secret_post' - -/** - * Prefers client_secret_post over client_secret_basic when an IdP supports both: - * better-auth sends client_secret_basic credentials without URL-encoding per - * RFC 6749 §2.3.1, so a '+' in the client secret is decoded as a space, causing - * invalid_client errors. Matches the same default in register-sso-provider.ts. - */ -function selectTokenEndpointAuthMethod( - supportedMethods: unknown, - existing?: TokenEndpointAuthMethod -): TokenEndpointAuthMethod { - if (existing) return existing - if (!Array.isArray(supportedMethods) || supportedMethods.length === 0) { - return 'client_secret_post' - } - if (supportedMethods.includes('client_secret_post')) return 'client_secret_post' - if (supportedMethods.includes('client_secret_basic')) return 'client_secret_basic' - return 'client_secret_post' -} - -type DiscoveryResult = - | { ok: true; discovery: Record } - | { ok: false; error: string } - -const OIDC_DISCOVERY_TIMEOUT_MS = 10000 - -async function fetchOIDCDiscoveryDocument(discoveryUrl: string): Promise { - const urlValidation = await validateUrlWithDNS(discoveryUrl, 'OIDC discovery URL') - if (!urlValidation.isValid || !urlValidation.resolvedIP) { - return { ok: false, error: urlValidation.error ?? 'SSRF validation failed' } - } - - try { - const response = await secureFetchWithPinnedIP(discoveryUrl, urlValidation.resolvedIP, { - headers: { Accept: 'application/json' }, - timeout: OIDC_DISCOVERY_TIMEOUT_MS, - }) - if (!response.ok) { - return { ok: false, error: `Discovery request failed with status ${response.status}` } - } - return { ok: true, discovery: (await response.json()) as Record } - } catch (error) { - return { ok: false, error: getErrorMessage(error, 'Unknown error') } - } -} - export const POST = withRouteHandler(async (request: NextRequest) => { try { if (!env.SSO_ENABLED) { @@ -77,459 +31,72 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) } - const hasAccess = await hasSSOAccess(session.user.id) - if (!hasAccess) { - return NextResponse.json({ error: 'SSO requires an Enterprise plan' }, { status: 403 }) - } - const parsed = await parseRequest( ssoRegistrationContract, request, {}, { - validationErrorResponse: (error) => { - logger.warn('Invalid SSO registration request', { errors: error.issues }) - return NextResponse.json( + validationErrorResponse: (error) => + NextResponse.json( { error: getValidationErrorMessage(error, 'Validation failed') }, { status: 400 } - ) - }, + ), } ) if (!parsed.success) return parsed.response const body = parsed.data.body - const { providerId, issuer, providerType, mapping, orgId } = body - - if (orgId) { - const [membership] = await db - .select({ organizationId: member.organizationId, role: member.role }) - .from(member) - .where(and(eq(member.userId, session.user.id), eq(member.organizationId, orgId))) - .limit(1) - if (!membership) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - if (membership.role !== 'owner' && membership.role !== 'admin') { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - } - - const domain = normalizeSSODomain(body.domain) - if (!domain) { - return NextResponse.json({ error: 'Enter a valid domain like company.com' }, { status: 400 }) - } - - const isOwnedByCaller = (provider: { - userId: string | null - organizationId: string | null - }): boolean => { - if (provider.userId === session.user.id && !provider.organizationId) return true - return orgId ? provider.organizationId === orgId : false - } - - const findDomainConflict = async () => - ( - await db - .select({ - userId: ssoProvider.userId, - organizationId: ssoProvider.organizationId, - }) - .from(ssoProvider) - .where(sql`lower(${ssoProvider.domain}) = ${domain}`) - ).find((provider) => !isOwnedByCaller(provider)) - - const domainConflictResponse = () => - NextResponse.json( - { - error: 'This domain is already registered for SSO by another organization.', - code: 'SSO_DOMAIN_ALREADY_REGISTERED', - }, - { status: 409 } - ) - - if (await findDomainConflict()) { - logger.warn('Rejected SSO registration for domain owned by another tenant', { - domain, - orgId, - userId: session.user.id, - }) - return domainConflictResponse() - } - - const headers: Record = {} - request.headers.forEach((value, key) => { - headers[key] = value - }) - - const providerConfig: any = { - providerId, - issuer, + await authorizeOrganizationSSO(session.user.id, body.orgId) + validateSSOProviderId(body.providerId) + const domain = requireNormalizedSSODomain(body.domain) + await assertSSOProviderAvailable({ + providerId: body.providerId, domain, - mapping, - ...(orgId ? { organizationId: orgId } : {}), - } - - if (providerType === 'oidc') { - const { - clientId, - clientSecret: rawClientSecret, - scopes, - pkce, - authorizationEndpoint, - tokenEndpoint, - userInfoEndpoint, - skipUserInfoEndpoint, - jwksEndpoint, - } = body - - let clientSecret = rawClientSecret - if (rawClientSecret === REDACTED_MARKER) { - const ownerClause = orgId - ? and(eq(ssoProvider.providerId, providerId), eq(ssoProvider.organizationId, orgId)) - : and(eq(ssoProvider.providerId, providerId), eq(ssoProvider.userId, session.user.id)) - const [existing] = await db - .select({ oidcConfig: ssoProvider.oidcConfig }) - .from(ssoProvider) - .where(ownerClause) - .limit(1) - if (!existing?.oidcConfig) { - return NextResponse.json( - { error: 'Cannot update: existing provider not found. Re-enter your client secret.' }, - { status: 400 } - ) - } - try { - clientSecret = JSON.parse(existing.oidcConfig).clientSecret - } catch { - return NextResponse.json( - { - error: 'Cannot update: failed to read existing secret. Re-enter your client secret.', - }, - { status: 400 } - ) - } - } - - const oidcConfig: any = { - clientId, - clientSecret, - scopes: Array.isArray(scopes) - ? scopes.filter((s: string) => s !== 'offline_access') - : ['openid', 'profile', 'email'].filter((s: string) => s !== 'offline_access'), - pkce: pkce ?? true, - } - - oidcConfig.authorizationEndpoint = authorizationEndpoint - oidcConfig.tokenEndpoint = tokenEndpoint - oidcConfig.userInfoEndpoint = userInfoEndpoint - oidcConfig.jwksEndpoint = jwksEndpoint - - const userProvidedEndpoints: Record = { - authorizationEndpoint, - tokenEndpoint, - jwksEndpoint, - ...(skipUserInfoEndpoint ? {} : { userInfoEndpoint }), - } - - for (const [name, endpointUrl] of Object.entries(userProvidedEndpoints)) { - if (endpointUrl) { - const endpointValidation = await validateUrlWithDNS(endpointUrl, `OIDC ${name}`) - if (!endpointValidation.isValid) { - logger.warn('Explicitly provided OIDC endpoint failed SSRF validation', { - endpoint: name, - url: endpointUrl, - error: endpointValidation.error, - }) - return NextResponse.json( - { - error: `OIDC ${name} failed security validation: ${endpointValidation.error}`, - }, - { status: 400 } - ) - } - } - } - - const needsDiscovery = - !oidcConfig.authorizationEndpoint || !oidcConfig.tokenEndpoint || !oidcConfig.jwksEndpoint - - const discoveryUrl = `${issuer.replace(/\/$/, '')}/.well-known/openid-configuration` - const discoveryResult = await fetchOIDCDiscoveryDocument(discoveryUrl) - - if (needsDiscovery) { - logger.info('Fetching OIDC discovery document for missing endpoints', { - discoveryUrl, - hasAuthEndpoint: !!oidcConfig.authorizationEndpoint, - hasTokenEndpoint: !!oidcConfig.tokenEndpoint, - hasJwksEndpoint: !!oidcConfig.jwksEndpoint, - }) - - if (!discoveryResult.ok) { - logger.error('Failed to fetch OIDC discovery document', { discoveryResult }) - return NextResponse.json( - { - error: `Failed to fetch OIDC discovery document: ${discoveryResult.error}. Provide all endpoints explicitly or verify the issuer URL.`, - }, - { status: 400 } - ) - } - - const { discovery } = discoveryResult - - const discoveredEndpoints: Record = { - authorization_endpoint: discovery.authorization_endpoint, - token_endpoint: discovery.token_endpoint, - jwks_uri: discovery.jwks_uri, - ...(skipUserInfoEndpoint ? {} : { userinfo_endpoint: discovery.userinfo_endpoint }), - } - - for (const [key, value] of Object.entries(discoveredEndpoints)) { - if (typeof value === 'string') { - const endpointValidation = await validateUrlWithDNS(value, `OIDC ${key}`) - if (!endpointValidation.isValid) { - logger.warn('OIDC discovered endpoint failed SSRF validation', { - endpoint: key, - url: value, - error: endpointValidation.error, - }) - return NextResponse.json( - { - error: `Discovered OIDC ${key} failed security validation: ${endpointValidation.error}`, - }, - { status: 400 } - ) - } - } - } - - oidcConfig.authorizationEndpoint = - oidcConfig.authorizationEndpoint || discovery.authorization_endpoint - oidcConfig.tokenEndpoint = oidcConfig.tokenEndpoint || discovery.token_endpoint - oidcConfig.userInfoEndpoint = oidcConfig.userInfoEndpoint || discovery.userinfo_endpoint - oidcConfig.jwksEndpoint = oidcConfig.jwksEndpoint || discovery.jwks_uri - oidcConfig.tokenEndpointAuthentication = selectTokenEndpointAuthMethod( - discovery.token_endpoint_auth_methods_supported, - oidcConfig.tokenEndpointAuthentication - ) - - logger.info('Merged OIDC endpoints (user-provided + discovery)', { - providerId, - issuer, - authorizationEndpoint: oidcConfig.authorizationEndpoint, - tokenEndpoint: oidcConfig.tokenEndpoint, - userInfoEndpoint: oidcConfig.userInfoEndpoint, - jwksEndpoint: oidcConfig.jwksEndpoint, - tokenEndpointAuthentication: oidcConfig.tokenEndpointAuthentication, - }) - } else { - logger.info('Using explicitly provided OIDC endpoints (all present)', { - providerId, - issuer, - authorizationEndpoint: oidcConfig.authorizationEndpoint, - tokenEndpoint: oidcConfig.tokenEndpoint, - userInfoEndpoint: oidcConfig.userInfoEndpoint, - jwksEndpoint: oidcConfig.jwksEndpoint, - }) - - if (!discoveryResult.ok) { - logger.info('OIDC discovery unavailable; falling back to the default token auth method', { - providerId, - discoveryUrl, - }) - } - oidcConfig.tokenEndpointAuthentication = selectTokenEndpointAuthMethod( - discoveryResult.ok - ? discoveryResult.discovery.token_endpoint_auth_methods_supported - : undefined, - oidcConfig.tokenEndpointAuthentication - ) - } - - if (skipUserInfoEndpoint) { - oidcConfig.userInfoEndpoint = undefined - logger.info('Skipping UserInfo endpoint for provider, claims will come from the ID token', { - providerId, - }) - } - - if ( - !oidcConfig.authorizationEndpoint || - !oidcConfig.tokenEndpoint || - !oidcConfig.jwksEndpoint - ) { - const missing: string[] = [] - if (!oidcConfig.authorizationEndpoint) missing.push('authorizationEndpoint') - if (!oidcConfig.tokenEndpoint) missing.push('tokenEndpoint') - if (!oidcConfig.jwksEndpoint) missing.push('jwksEndpoint') - - logger.error('Missing required OIDC endpoints after discovery merge', { - missing, - authorizationEndpoint: oidcConfig.authorizationEndpoint, - tokenEndpoint: oidcConfig.tokenEndpoint, - jwksEndpoint: oidcConfig.jwksEndpoint, - }) - return NextResponse.json( - { - error: `Missing required OIDC endpoints: ${missing.join(', ')}. Please provide these explicitly or verify the issuer supports OIDC discovery.`, - }, - { status: 400 } - ) - } - - oidcConfig.skipDiscovery = true - providerConfig.oidcConfig = oidcConfig - } else if (providerType === 'saml') { - const { - entryPoint, - cert, - callbackUrl, - audience, - wantAssertionsSigned, - signatureAlgorithm, - digestAlgorithm, - identifierFormat, - idpMetadata, - } = body - - const computedCallbackUrl = - callbackUrl || `${getBaseUrl()}/api/auth/sso/saml2/callback/${providerId}` - - const escapeXml = (str: string) => - str.replace(/[<>&"']/g, (c) => { - switch (c) { - case '<': - return '<' - case '>': - return '>' - case '&': - return '&' - case '"': - return '"' - case "'": - return ''' - default: - return c - } - }) - - const spMetadataXml = ` - - - - -` - - const certBase64 = cert - .replace(/-----BEGIN CERTIFICATE-----/g, '') - .replace(/-----END CERTIFICATE-----/g, '') - .replace(/\s/g, '') - - const computedIdpMetadataXml = - idpMetadata || - ` - - - - - - ${certBase64} - - - - - - -` - - const samlConfig: any = { - entryPoint, - cert, - callbackUrl: computedCallbackUrl, - spMetadata: { - metadata: spMetadataXml, - }, - idpMetadata: { - metadata: computedIdpMetadataXml, - }, - } - - if (audience) samlConfig.audience = audience - if (wantAssertionsSigned !== undefined) samlConfig.wantAssertionsSigned = wantAssertionsSigned - if (signatureAlgorithm) samlConfig.signatureAlgorithm = signatureAlgorithm - if (digestAlgorithm) samlConfig.digestAlgorithm = digestAlgorithm - if (identifierFormat) samlConfig.identifierFormat = identifierFormat - - providerConfig.samlConfig = samlConfig - } - - logger.info('Calling Better Auth registerSSOProvider with config:', { - providerId: providerConfig.providerId, - domain: providerConfig.domain, - hasOidcConfig: !!providerConfig.oidcConfig, - hasSamlConfig: !!providerConfig.samlConfig, - samlConfigKeys: providerConfig.samlConfig ? Object.keys(providerConfig.samlConfig) : [], - fullConfig: JSON.stringify( - { - ...providerConfig, - oidcConfig: providerConfig.oidcConfig - ? { - ...providerConfig.oidcConfig, - clientSecret: REDACTED_MARKER, - } - : undefined, - samlConfig: providerConfig.samlConfig - ? { - ...providerConfig.samlConfig, - cert: REDACTED_MARKER, - } - : undefined, - }, - null, - 2 - ), + organizationId: body.orgId, }) - if (await findDomainConflict()) { - logger.warn('Rejected SSO registration: domain was claimed during registration', { + const providerConfig = await buildSSOProviderConfiguration( + { ...body, domain }, + { + providerId: body.providerId, + organizationId: body.orgId, + } + ) + const registration = await withSSOProviderMutationLock(async () => { + await assertSSOProviderAvailable({ + providerId: body.providerId, domain, - orgId, - userId: session.user.id, + organizationId: body.orgId, + }) + return auth.api.registerSSOProvider({ + body: providerConfig, + headers: collectAuthHeaders(request), }) - return domainConflictResponse() - } - - const registration = await auth.api.registerSSOProvider({ - body: providerConfig, - headers, }) - logger.info('SSO provider registered successfully', { - providerId, - providerType, + logger.info('SSO provider registered', { + providerId: body.providerId, + providerType: body.providerType, domain, + organizationId: body.orgId, }) return NextResponse.json({ success: true, providerId: registration.providerId, - providerType, - message: `${providerType.toUpperCase()} provider registered successfully`, + providerType: body.providerType, + domainVerified: isTruthy(env.SSO_DOMAIN_VERIFICATION_ENABLED) + ? registration.domainVerified + : true, + message: `${body.providerType.toUpperCase()} provider registered successfully`, }) } catch (error) { + const managedResponse = ssoManagementErrorResponse(error) + if (managedResponse) return managedResponse + logger.error('Failed to register SSO provider', { - error, - errorMessage: getErrorMessage(error, 'Unknown error'), - errorStack: error instanceof Error ? error.stack : undefined, - errorDetails: JSON.stringify(error), + error: getErrorMessage(error, 'Unknown error'), }) - - return NextResponse.json( - { - error: 'Failed to register SSO provider', - details: getErrorMessage(error, 'Unknown error'), - }, - { status: 500 } - ) + return NextResponse.json({ error: 'Failed to register SSO provider' }, { status: 500 }) } }) diff --git a/apps/sim/app/api/mcp/servers/test-connection/route.test.ts b/apps/sim/app/api/mcp/servers/test-connection/route.test.ts index fa95bb499c5..1fd275edfad 100644 --- a/apps/sim/app/api/mcp/servers/test-connection/route.test.ts +++ b/apps/sim/app/api/mcp/servers/test-connection/route.test.ts @@ -194,6 +194,19 @@ describe('MCP server test-connection route', () => { expect(mockClientOptions).not.toHaveBeenCalled() }) + it('classifies a successful headerless connection as unauthenticated', async () => { + mockDetectMcpAuthType.mockResolvedValue('headers') + const response = await POST(createTestRequest()) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.data).toEqual( + expect.objectContaining({ success: true, authType: 'none', toolCount: 0 }) + ) + expect(mockConnect).toHaveBeenCalledTimes(1) + expect(mockListTools).toHaveBeenCalledTimes(1) + }) + it('blocks an env-resolved private URL before forwarding configured credentials', async () => { const token = 'private-static-token' mockResolveMcpConfigEnvVars.mockResolvedValueOnce({ diff --git a/apps/sim/app/api/mcp/servers/test-connection/route.ts b/apps/sim/app/api/mcp/servers/test-connection/route.ts index 6a12a8346ae..cf4f3080af2 100644 --- a/apps/sim/app/api/mcp/servers/test-connection/route.ts +++ b/apps/sim/app/api/mcp/servers/test-connection/route.ts @@ -222,6 +222,9 @@ export const POST = withRouteHandler( const tools = await client.listTools() result.toolCount = tools.length result.success = true + if (Object.keys(testConfig.headers ?? {}).length === 0) { + result.authType = 'none' + } } catch { logger.warn(`[${requestId}] Connection established but could not list tools`) result.success = false diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx index f3afefe0820..85a537a6ca9 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx @@ -18,6 +18,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { ChevronDown, ChevronRight } from 'lucide-react' import type { McpAuthType, McpTransport } from '@/lib/mcp/types' +import type { McpReadinessState } from '@/app/workspace/[workspaceId]/settings/components/mcp/mcp-readiness-state' import { checkEnvVarTrigger, EnvVarDropdown, @@ -63,6 +64,8 @@ export interface McpServerFormModalProps { workspaceId: string availableEnvVars?: Set allowedMcpDomains: string[] | null + readinessState?: McpReadinessState + readinessError?: string } const ENV_VAR_PATTERN = /\{\{[^}]+\}\}/ @@ -121,6 +124,7 @@ function getTestButtonLabel( interface FormattedInputProps { ref?: React.RefObject + ariaLabel?: string placeholder: string value: string scrollLeft: number @@ -134,6 +138,7 @@ interface FormattedInputProps { function FormattedInput({ ref, + ariaLabel, placeholder, value, scrollLeft, @@ -152,6 +157,7 @@ function FormattedInput({
(null) + const readinessStateRef = useRef(readinessState) + readinessStateRef.current = readinessState const [formData, setFormData] = useState(DEFAULT_FORM_DATA) const [originalData, setOriginalData] = useState(DEFAULT_FORM_DATA) @@ -422,6 +432,13 @@ export function McpServerFormModal({ const isDomainBlocked = !!formData.url?.trim() && !isDomainAllowed(formData.url, allowedMcpDomains) const isFormValid = !!(formData.name.trim() && formData.url?.trim()) + const isReady = readinessState === 'ready' + const readinessMessage = + readinessState === 'error' + ? readinessError || 'Unable to load MCP server settings. Test and save are unavailable.' + : readinessState === 'loading' + ? 'MCP server settings are loading. Test and save are unavailable.' + : null const testButtonLabel = getTestButtonLabel(testResult, isTestingConnection) const computeHasChanges = (): boolean => { @@ -485,7 +502,7 @@ export function McpServerFormModal({ } const handleTestConnection = async () => { - if (!isFormValid) return + if (!isReady || !isFormValid) return await testConnection({ name: formData.name, @@ -498,7 +515,7 @@ export function McpServerFormModal({ } const handleSubmitForm = async () => { - if (!isFormValid || isDomainBlocked) return + if (!isReady || !isFormValid || isDomainBlocked) return setIsSubmitting(true) setSubmitError(null) @@ -524,6 +541,7 @@ export function McpServerFormModal({ ) return } + if (readinessStateRef.current !== 'ready') return await onSubmit({ name: formData.name.trim(), @@ -556,6 +574,7 @@ export function McpServerFormModal({ } const handleSubmitJson = async () => { + if (!isReady) return const config = parseJsonConfig(jsonInput) if (!config) return @@ -587,6 +606,7 @@ export function McpServerFormModal({ ) return } + if (readinessStateRef.current !== 'ready') return await onSubmit({ name: config.name, @@ -607,7 +627,7 @@ export function McpServerFormModal({ } const isSubmitDisabled = - isSubmitting || !isFormValid || isDomainBlocked || (mode === 'edit' && !hasChanges) + !isReady || isSubmitting || !isFormValid || isDomainBlocked || (mode === 'edit' && !hasChanges) const title = mode === 'add' ? 'Add MCP server' : 'Edit MCP server' const submitLabel = mode === 'add' ? 'Add server' : 'Save' @@ -629,7 +649,7 @@ export function McpServerFormModal({ ? { label: testButtonLabel, onClick: handleTestConnection, - disabled: isTestingConnection || !isFormValid || isDomainBlocked, + disabled: !isReady || isTestingConnection || !isFormValid || isDomainBlocked, } : undefined @@ -638,7 +658,7 @@ export function McpServerFormModal({ ? { label: isSubmitting ? 'Adding...' : submitLabel, onClick: handleSubmitJson, - disabled: isSubmitting || !jsonInput.trim(), + disabled: !isReady || isSubmitting || !jsonInput.trim(), } : { label: isSubmitting ? (mode === 'add' ? 'Adding...' : 'Saving...') : submitLabel, @@ -705,6 +725,7 @@ export function McpServerFormModal({ > )} - {submitError} + {readinessMessage ?? submitError} onOpenChange(false)} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp-readiness-state.test.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp-readiness-state.test.ts new file mode 100644 index 00000000000..49483987bd2 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp-readiness-state.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { type McpQueryReadiness, resolveMcpReadinessState } from './mcp-readiness-state' + +const readyState = { + serversSuccess: true, + serversError: false, + serversPlaceholder: false, + allowedDomainsSuccess: true, + allowedDomainsError: false, + allowedDomainsPlaceholder: false, +} satisfies McpQueryReadiness + +describe('resolveMcpReadinessState', () => { + it('requires both successful non-placeholder queries', () => { + expect(resolveMcpReadinessState({ ...readyState, serversSuccess: false })).toBe('loading') + expect(resolveMcpReadinessState({ ...readyState, serversPlaceholder: true })).toBe('loading') + expect(resolveMcpReadinessState({ ...readyState, allowedDomainsSuccess: false })).toBe( + 'loading' + ) + expect(resolveMcpReadinessState({ ...readyState, allowedDomainsPlaceholder: true })).toBe( + 'loading' + ) + }) + + it('gives either query error priority over stale data', () => { + expect( + resolveMcpReadinessState({ + ...readyState, + serversSuccess: false, + serversPlaceholder: true, + allowedDomainsError: true, + }) + ).toBe('error') + }) + + it('reports ready only when both boundaries are usable', () => { + expect(resolveMcpReadinessState(readyState)).toBe('ready') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp-readiness-state.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp-readiness-state.ts new file mode 100644 index 00000000000..23ae186d932 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp-readiness-state.ts @@ -0,0 +1,23 @@ +export type McpReadinessState = 'loading' | 'error' | 'ready' + +export interface McpQueryReadiness { + serversSuccess: boolean + serversError: boolean + serversPlaceholder: boolean + allowedDomainsSuccess: boolean + allowedDomainsError: boolean + allowedDomainsPlaceholder: boolean +} + +export function resolveMcpReadinessState(state: McpQueryReadiness): McpReadinessState { + if (state.serversError || state.allowedDomainsError) return 'error' + if ( + !state.serversSuccess || + state.serversPlaceholder || + !state.allowedDomainsSuccess || + state.allowedDomainsPlaceholder + ) { + return 'loading' + } + return 'ready' +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx index 5558e23fb35..51e3f291495 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx @@ -23,6 +23,7 @@ import { mcpServerIdParam, mcpServerIdUrlKeys, } from '@/app/workspace/[workspaceId]/settings/[section]/search-params' +import { resolveMcpReadinessState } from '@/app/workspace/[workspaceId]/settings/components/mcp/mcp-readiness-state' import { getRefreshActionState } from '@/app/workspace/[workspaceId]/settings/components/mcp/refresh-action-state' import { getServerToolsLabel } from '@/app/workspace/[workspaceId]/settings/components/mcp/server-tools-label' import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' @@ -111,7 +112,11 @@ function ServerListItem({ showDiscoveryError return ( -
+
@@ -143,7 +148,7 @@ function ServerListItem({ {isConnecting ? 'Reopen authorization' : 'Authorize'} )} (null) @@ -215,6 +243,9 @@ export function MCP() { const [serverToDeleteId, setServerToDeleteId] = useState(null) const showDeleteDialog = serverToDeleteId !== null + useEffect(() => { + if (!isReady) setServerToDeleteId(null) + }, [isReady]) const [selectedServerId, setSelectedServerId] = useQueryState(mcpServerIdParam.key, { ...mcpServerIdParam.parser, @@ -226,19 +257,21 @@ export function MCP() { useEffect(() => { if (didDeepLinkRefreshRef.current) return if (!initialServerIdRef.current) return + if (canEdit && !isReady) return didDeepLinkRefreshRef.current = true if (canEdit) forceRefreshTools(workspaceId) refetchStoredTools() - }, [canEdit, workspaceId, forceRefreshTools, refetchStoredTools]) + }, [canEdit, isReady, workspaceId, forceRefreshTools, refetchStoredTools]) const [expandedTools, setExpandedTools] = useState>(() => new Set()) const handleRemoveServer = (serverId: string) => { + if (!isReady) return setServerToDeleteId(serverId) } const confirmDeleteServer = async () => { - if (!serverToDeleteId) return + if (!serverToDeleteId || !isReady) return const serverId = serverToDeleteId setServerToDeleteId(null) @@ -278,7 +311,7 @@ export function MCP() { const handleViewDetails = (serverId: string) => { setSelectedServerId(serverId) - if (canEdit) forceRefreshTools(workspaceId) + if (canEdit && isReady) forceRefreshTools(workspaceId) refetchStoredTools() } @@ -301,6 +334,7 @@ export function MCP() { } const handleRefreshServer = async (serverId: string) => { + if (!isReady) return try { const result = await refreshServerMutation.mutateAsync({ workspaceId, serverId }) logger.info( @@ -395,14 +429,41 @@ export function MCP() { return issues } - // Only a failure to load the server LIST replaces the list. A tool-discovery failure must - // not blank the page — the servers still render, each row surfacing its own discovery - // state via `toolsStateByServer`. - const listError = serversError + // Tool-discovery failures do not blank the page; required server and policy query failures do. const hasServers = servers && servers.length > 0 const showNoResults = searchTerm.trim() && filteredServers.length === 0 && servers.length > 0 - if (selectedServer) { + const editModal = canEdit ? ( + { + if (!open) setEditingServerId(null) + }} + mode='edit' + initialData={editInitialData} + onSubmit={async (config) => { + if (!isReady || !editingServerId) { + throw new Error(readinessError || 'MCP server settings are not ready') + } + const currentServer = servers.find((server) => server.id === editingServerId) + await updateServerMutation.mutateAsync({ + workspaceId, + serverId: editingServerId, + updates: { + ...config, + enabled: currentServer?.enabled ?? true, + }, + }) + }} + workspaceId={workspaceId} + availableEnvVars={availableEnvVars} + allowedMcpDomains={allowedMcpDomains} + readinessState={readinessState} + readinessError={readinessError} + /> + ) : null + + if (selectedServer && isReady) { const { server, tools } = selectedServer const transportLabel = formatTransportLabel(server.transport || 'http') const isCurrentRefresh = refreshServerMutation.variables?.serverId === server.id @@ -413,299 +474,301 @@ export function MCP() { }) return ( - handleRefreshServer(server.id), - disabled: refreshAction.disabled, - }, - { - text: 'Edit', - onSelect: () => setEditingServerId(server.id), - }, - ] - : [] - } - > - -
-
- Server name -

{server.name || 'Unnamed server'}

-
- -
- Transport -

{transportLabel}

-
- - {server.url && ( -
- URL -

{server.url}

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

- {getServerToolsLabel( - [], - server.connectionStatus, - server.lastError, - server.authType - )} -

-
- )} - - {canEdit && server.authType === 'oauth' && server.connectionStatus !== 'connected' && ( -
- Authentication -
- { - await startOauthForServer(server.id) - }} - > - {connectingOauthServers.has(server.id) ? 'Reopen authorization' : 'Authorize'} - + <> +
+ handleRefreshServer(server.id), + disabled: !isReady || refreshAction.disabled, + }, + { + text: 'Edit', + onSelect: () => setEditingServerId(server.id), + disabled: !isReady, + }, + ] + : [] + } + > + +
+
+ Server name +

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

-
- )} -
- - - {tools.length === 0 ? ( -

No tools available

- ) : ( -
- {tools.map((tool) => { - const issues = getStoredToolIssues(server.id, tool.name) - const affectedWorkflows = issues.map((i) => i.workflowName) - const isExpanded = expandedTools.has(tool.name) - const hasParams = - tool.inputSchema?.properties && - Object.keys(tool.inputSchema.properties).length > 0 - const requiredParams = tool.inputSchema?.required || [] +
+ Transport +

{transportLabel}

+
- return ( -
- - - {isExpanded && hasParams && ( -
-

- Parameters -

-
- {Object.entries(tool.inputSchema!.properties!).map( - ([paramName, param]) => { - const isRequired = requiredParams.includes(paramName) - const paramType = - typeof param === 'object' && param !== null - ? (param as { type?: string }).type || 'any' - : 'any' - const paramDesc = - typeof param === 'object' && param !== null - ? (param as { description?: string }).description - : undefined - - return ( -
-
- - {paramName} - - - {paramType} - - {isRequired && ( - - required + disabled={!hasParams} + > +
+
+

+ {tool.name} +

+ {issues.length > 0 && ( + + +
+ + {getIssueBadgeLabel(issues[0].issue)} - )} -
- {paramDesc && ( -

- {paramDesc} -

- )} -
- ) - } +
+ + + Update in: {affectedWorkflows.join(', ')} + + + )} +
+ {tool.description && ( +

+ {tool.description} +

+ )} +
+ {hasParams && ( + )} -
+ + + {isExpanded && hasParams && ( +
+

+ Parameters +

+
+ {Object.entries(tool.inputSchema!.properties!).map( + ([paramName, param]) => { + const isRequired = requiredParams.includes(paramName) + const paramType = + typeof param === 'object' && param !== null + ? (param as { type?: string }).type || 'any' + : 'any' + const paramDesc = + typeof param === 'object' && param !== null + ? (param as { description?: string }).description + : undefined + + return ( +
+
+ + {paramName} + + + {paramType} + + {isRequired && ( + + required + + )} +
+ {paramDesc && ( +

+ {paramDesc} +

+ )} +
+ ) + } + )} +
+
+ )}
- )} -
- ) - })} -
- )} -
- - {canEdit && ( - { - if (!open) setEditingServerId(null) - }} - mode='edit' - initialData={editInitialData} - onSubmit={async (config) => { - const currentServer = servers.find((s) => s.id === selectedServerId) - await updateServerMutation.mutateAsync({ - workspaceId, - serverId: selectedServerId!, - updates: { - ...config, - enabled: currentServer?.enabled ?? true, - }, - }) - }} - workspaceId={workspaceId} - availableEnvVars={availableEnvVars} - allowedMcpDomains={allowedMcpDomains} - /> - )} - + ) + })} +
+ )} + + + + {editModal} + ) } return ( <> - setShowAddModal(true), - disabled: serversLoading, - }, - ] - : [] - } +
- {listError ? ( -
-

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

-
- ) : serversLoading ? ( - Loading... - ) : !hasServers ? ( - - {canEdit ? 'Click "Add server" above to get started' : 'No MCP servers configured'} - - ) : ( -
- {filteredServers.map((server) => { - if (!server?.id) return null - const tools = toolsByServer[server.id] || [] - const serverToolsState = toolsStateByServer.get(server.id) - const isLoadingTools = serverToolsState - ? serverToolsState.isLoading || serverToolsState.isFetching - : false - - return ( - handleRemoveServer(server.id)} - onViewDetails={() => handleViewDetails(server.id)} - onAuthorize={() => startOauthForServer(server.id)} - /> - ) - })} - {showNoResults && ( - - No servers found matching "{searchTerm}" - - )} -
- )} - + { + if (isReady) setShowAddModal(true) + }, + disabled: !isReady, + }, + ] + : [] + } + > + {readinessState === 'error' ? ( +
+

+ {readinessError || 'Unable to load MCP server settings'} +

+
+ ) : readinessState === 'loading' ? ( + Loading... + ) : !hasServers ? ( + + {canEdit ? 'Click "Add server" above to get started' : 'No MCP servers configured'} + + ) : ( +
+ {filteredServers.map((server) => { + if (!server?.id) return null + const tools = toolsByServer[server.id] || [] + const serverToolsState = toolsStateByServer.get(server.id) + const isLoadingTools = serverToolsState + ? serverToolsState.isLoading || serverToolsState.isFetching + : false + + return ( + handleRemoveServer(server.id)} + onViewDetails={() => handleViewDetails(server.id)} + onAuthorize={() => startOauthForServer(server.id)} + /> + ) + })} + {showNoResults && ( + + No servers found matching "{searchTerm}" + + )} +
+ )} +
+
+ + {editModal} {canEdit && ( { + if (!isReady) { + throw new Error(readinessError || 'MCP server settings are not ready') + } const result = await createServerMutation.mutateAsync({ workspaceId, config: { ...config, enabled: true }, @@ -724,6 +790,8 @@ export function MCP() { workspaceId={workspaceId} availableEnvVars={availableEnvVars} allowedMcpDomains={allowedMcpDomains} + readinessState={readinessState} + readinessError={readinessError} /> )} 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 70b7186e03e..c4810981a89 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 @@ -95,7 +95,7 @@ export function SettingsSidebar({ const isSSOProviderOwner = useMemo(() => { if (isHosted) return null if (!userId || isLoadingSSO) return null - return ssoProvidersData?.providers?.some((p) => p.userId === userId) || false + return ssoProvidersData?.providers?.some((provider) => provider.isCreator) || false }, [userId, ssoProvidersData?.providers, isLoadingSSO]) const navigationItems = useMemo(() => { diff --git a/apps/sim/e2e/README.md b/apps/sim/e2e/README.md index 1043db1005d..6737e1e57d8 100644 --- a/apps/sim/e2e/README.md +++ b/apps/sim/e2e/README.md @@ -13,7 +13,7 @@ per-run pgvector database. 1. Map the hosted E2E origin to loopback: ```bash - echo "127.0.0.1 e2e.sim.ai" | sudo tee -a /etc/hosts + echo "127.0.0.1 e2e.sim.ai mcp.e2e.sim.ai" | sudo tee -a /etc/hosts ``` The runner refuses to start unless every resolved address is loopback and an @@ -253,6 +253,104 @@ Better Auth claims. The workflow adds its target member before enabling any restrictions, deletes the group atomically, and then proves unrestricted Read-authority readiness again. +## Enterprise integration workflows + +Step 6b adds independent SAML, data-retention, and MCP lifecycles to the same +single-worker workflows project. Each case uses `enterpriseOrganizationAdmin` +in `settings-primary`, registers LIFO cleanup before its first mutation, and +uses the authenticated persona request context for same-origin discovery and +restoration. + +The SAML case creates a pending provider with `.invalid` issuer and entry-point +URLs and a public certificate from Node's reviewed public root store. No private +key is committed or generated. The test never attempts SSO login, provider +egress, or DNS resolution. It proves only the pending state and the presence of +TXT instructions; it never parses or logs the verification response body. +Traces and video remain off, and the test reloads immediately after checking the +instruction region so the verification value cannot remain in a failure +screenshot. Certificates, verification tokens, and token-bearing response +bodies must never be attached to reports or copied into logs. + +Better Auth native domain verification is controlled independently by +`SSO_DOMAIN_VERIFICATION_ENABLED` and defaults off for upgrades and self-hosted +deployments. It must not be enabled in a hosted environment until the SSO schema +migration has landed separately and +`SSO_AUDIT_APPROVED_PROVIDER_IDS=reviewed-provider-ids bun run --cwd packages/db db:audit-sso-providers` +passes. The command validates every provider row and reports linked-user and +active-session counts; the approval list records each operator decision to +retain those links/sessions. Unapproved links must be migrated or removed and +their sessions revoked before the provider is approved. Only explicitly approved existing providers +may be backfilled as verified; unknown rows remain inactive, with link/session +disposition documented before enabling the flag. SSO writes must be quiesced +across that interval and the audit rerun immediately before the flag changes. +The hermetic profile sets it to true. Legacy user-scoped +provider rows must be assigned to an audited organization or removed before the +migration; its check constraints and preflight reject them. Providers with +linked Better Auth accounts cannot change issuer/domain or be deleted until an +operator completes the documented account-link and session migration. +Better Auth 1.6.13 does not honor explicit `requestSignUp` +for SAML callbacks, so implicit signup remains enabled only behind the +verified-domain gate to preserve intended JIT organization provisioning; +`trustEmailVerified` changes from its compatibility value to false only when +verified-domain enforcement is active. Verification requests retain Better Auth's +creator identity requirement in addition to organization owner/admin +authorization. If the creator leaves, recovery is an authorized delete and +recreate by the next configuring admin after any linked accounts and sessions +have been explicitly migrated. Successful live TXT +verification is a manual release check; hermetic browser coverage never +performs it. + +The MCP fake binds an ephemeral listener to numeric `127.0.0.1` and advertises +`http://mcp.e2e.sim.ai:/mcp`. The production app receives only the +`mcp.e2e.sim.ai` allowlist; `E2E_MCP_SERVER_URL` is a non-secret Playwright-only +value and is absent from build, app, realtime, migration, seed, and auth-capture +environments. The browser first proves local denial of a non-allowlisted +`.invalid` URL without test/create traffic, then performs real connection +tests, create-or-soft-delete revival, deterministic `e2e_lookup` discovery, +edit/reprobe, and delete. Cleanup lists active run-prefixed rows and deletes +them through the scoped production API. The fake log records only sequence, +method/path, JSON-RPC method, status, and a session-safe label—never headers, +bodies, credentials, or raw session IDs—and is included in normal leak scanning. + +The retention case captures the complete configured snapshot before mutation. +Every browser and cleanup PUT omits `piiRedaction`, preserves unrelated +configured values, and carries the complete concrete retention override array. +It restores the exact seeded 30-day log, 90-day soft-delete, 30-day task, null +PII, and empty-override baseline. The orchestrator's trusted post-run database +probe independently requires that baseline after Playwright finishes. + +Run the three workflows together or focus one file: + +```bash +bun run test:e2e -- --reuse-build \ + --project=hosted-billing-chromium-workflows --no-deps \ + e2e/settings/workflows/{sso,data-retention,mcp}.spec.ts + +bun run test:e2e -- --reuse-build \ + --project=hosted-billing-chromium-workflows --no-deps \ + e2e/settings/workflows/sso.spec.ts + +bun run test:e2e -- --reuse-build \ + --project=hosted-billing-chromium-workflows --no-deps \ + e2e/settings/workflows/data-retention.spec.ts + +bun run test:e2e -- --reuse-build \ + --project=hosted-billing-chromium-workflows --no-deps \ + e2e/settings/workflows/mcp.spec.ts +``` + +For the Step 6b repeatability gate, run all three twice in one orchestrated +single-worker stack. Retries are already fixed to zero by the runner: + +```bash +bun run test:e2e -- --reuse-build \ + --project=hosted-billing-chromium-workflows --no-deps --repeat-each=2 \ + e2e/settings/workflows/{sso,data-retention,mcp}.spec.ts +``` + +The one-shot run still owns and drops its unique guarded database; do not invoke +raw `playwright test`. + The cache lives under ignored `e2e/.cache/builds/`. A hit requires matching source contents (including uncommitted/untracked files), build/public profile, Node/Bun/Next versions, platform, `BUILD_ID`, and the cached artifact checksum. diff --git a/apps/sim/e2e/fakes/mcp/server.ts b/apps/sim/e2e/fakes/mcp/server.ts new file mode 100644 index 00000000000..1fd824a47bb --- /dev/null +++ b/apps/sim/e2e/fakes/mcp/server.ts @@ -0,0 +1,437 @@ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http' +import type { AddressInfo } from 'node:net' +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' +import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js' +import { generateId } from '@sim/utils/id' +import { z } from 'zod' + +export const E2E_MCP_TOOL = { + name: 'e2e_lookup', + description: 'Looks up a deterministic E2E fixture by query.', + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'Fixture query to look up.', + }, + }, + required: ['query'], + }, +} as const + +const MCP_PATH = '/mcp' +const JSON_CONTENT_TYPE = 'application/json' +const DEFAULT_MAX_BODY_BYTES = 64 * 1024 +const MAX_SESSION_HEADER_BYTES = 256 +const MAX_RECORDED_PATH_LENGTH = 256 +const MAX_RECORDED_RPC_METHOD_LENGTH = 128 +const EXPECTED_RPC_METHODS = new Set([ + 'initialize', + 'notifications/initialized', + 'tools/list', + 'ping', +]) + +export interface McpFakeRequestRecord { + sequence: number + method: string + path: string + rpcMethod?: string + status: number + session: string | null + unexpected: boolean +} + +export interface McpFakeServerOptions { + hostname?: '127.0.0.1' + maxBodyBytes?: number + port?: number +} + +export interface McpFakeServer { + readonly baseUrl: string | null + readonly requestLog: readonly McpFakeRequestRecord[] + start(): Promise + stop(): Promise +} + +interface SessionContext { + label: string + server: McpServer + transport: StreamableHTTPServerTransport +} + +interface JsonRpcEnvelope { + jsonrpc?: unknown + method?: unknown +} + +class RequestBodyError extends Error { + constructor( + message: string, + readonly status: number, + readonly code: number + ) { + super(message) + } +} + +function validateOptions(options: McpFakeServerOptions): void { + if ( + options.port !== undefined && + (!Number.isInteger(options.port) || options.port < 0 || options.port > 65535) + ) { + throw new Error('MCP fake port must be an integer from 0 to 65535') + } + if ( + options.maxBodyBytes !== undefined && + (!Number.isInteger(options.maxBodyBytes) || options.maxBodyBytes < 1) + ) { + throw new Error('MCP fake maxBodyBytes must be a positive integer') + } +} + +function cloneRequestLog(records: McpFakeRequestRecord[]): McpFakeRequestRecord[] { + return structuredClone(records) +} + +function sendJsonRpcError( + response: ServerResponse, + status: number, + code: number, + message: string +): void { + const body = JSON.stringify({ + jsonrpc: '2.0', + error: { code, message }, + id: null, + }) + response.writeHead(status, { + 'content-length': Buffer.byteLength(body), + 'content-type': `${JSON_CONTENT_TYPE}; charset=utf-8`, + }) + response.end(body) +} + +function getSingleHeader(request: IncomingMessage, name: string): string | undefined { + const value = request.headers[name] + return Array.isArray(value) ? value[0] : value +} + +function getRpcMethod(body: unknown): string | undefined { + if (typeof body !== 'object' || body === null || Array.isArray(body)) return undefined + const method = (body as JsonRpcEnvelope).method + return typeof method === 'string' ? method : undefined +} + +function boundedRecordValue(value: string, maxLength: number, overflowLabel: string): string { + return value.length <= maxLength ? value : overflowLabel +} + +async function readJsonBody(request: IncomingMessage, maxBodyBytes: number): Promise { + const contentType = getSingleHeader(request, 'content-type') + ?.split(';', 1)[0] + ?.trim() + .toLowerCase() + if (contentType !== JSON_CONTENT_TYPE) { + throw new RequestBodyError(`MCP fake requires ${JSON_CONTENT_TYPE}`, 415, -32000) + } + + const declaredLength = Number(getSingleHeader(request, 'content-length')) + if (Number.isFinite(declaredLength) && declaredLength > maxBodyBytes) { + request.resume() + throw new RequestBodyError('MCP fake request body is too large', 413, -32000) + } + + const chunks: Buffer[] = [] + let totalBytes = 0 + for await (const chunk of request) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + totalBytes += buffer.length + if (totalBytes > maxBodyBytes) { + request.resume() + throw new RequestBodyError('MCP fake request body is too large', 413, -32000) + } + chunks.push(buffer) + } + + try { + return JSON.parse(Buffer.concat(chunks).toString('utf8')) + } catch { + throw new RequestBodyError('MCP fake received invalid JSON', 400, -32700) + } +} + +function createSessionServer(): McpServer { + const server = new McpServer( + { name: 'sim-e2e-mcp-fake', version: '1.0.0' }, + { capabilities: { tools: {} } } + ) + server.registerTool( + E2E_MCP_TOOL.name, + { + description: E2E_MCP_TOOL.description, + inputSchema: { + query: z.string().describe(E2E_MCP_TOOL.inputSchema.properties.query.description), + }, + }, + async ({ query }) => ({ + content: [{ type: 'text', text: `fixture:${query}` }], + }) + ) + return server +} + +/** + * Creates an orchestrator-owned Streamable HTTP MCP fake. It listens only on + * numeric IPv4 loopback while advertising the allowlisted E2E hostname. + */ +export function createMcpFakeServer(options: McpFakeServerOptions = {}): McpFakeServer { + validateOptions(options) + + const hostname = options.hostname ?? '127.0.0.1' + const port = options.port ?? 0 + const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES + const records: McpFakeRequestRecord[] = [] + const sessions = new Map() + let sequence = 0 + let sessionSequence = 0 + let baseUrl: string | null = null + + const labelForSession = (sessionId: string | undefined): string | null => + sessionId ? (sessions.get(sessionId)?.label ?? null) : null + + const createSession = async (): Promise => { + sessionSequence += 1 + const label = `session-${sessionSequence}` + const server = createSessionServer() + const transport = new StreamableHTTPServerTransport({ + enableJsonResponse: true, + sessionIdGenerator: generateId, + onsessioninitialized: (sessionId) => { + sessions.set(sessionId, context) + }, + onsessionclosed: (sessionId) => { + sessions.delete(sessionId) + }, + }) + const context = { label, server, transport } satisfies SessionContext + await server.connect(transport) + return context + } + + const handleRequest = async ( + request: IncomingMessage, + response: ServerResponse, + observeRecord: (record: McpFakeRequestRecord) => void + ): Promise => { + sequence += 1 + const method = request.method?.toUpperCase() ?? 'UNKNOWN' + const url = new URL(request.url ?? '/', 'http://mcp-fake.invalid') + const sessionId = getSingleHeader(request, 'mcp-session-id') + const record: McpFakeRequestRecord = { + sequence, + method, + path: boundedRecordValue(url.pathname, MAX_RECORDED_PATH_LENGTH, ''), + status: 500, + session: labelForSession(sessionId), + unexpected: false, + } + records.push(record) + observeRecord(record) + + if (url.pathname !== MCP_PATH || url.search !== '') { + record.unexpected = true + record.status = 404 + sendJsonRpcError(response, record.status, -32001, 'MCP endpoint not found') + return + } + + if (sessionId && Buffer.byteLength(sessionId) > MAX_SESSION_HEADER_BYTES) { + record.unexpected = true + record.status = 400 + sendJsonRpcError(response, record.status, -32600, 'Invalid MCP session identifier') + return + } + + if (method === 'GET') { + record.status = 405 + response.writeHead(record.status, { + allow: 'POST, DELETE', + 'content-length': '0', + }) + response.end() + return + } + + if (method === 'DELETE') { + const context = sessionId ? sessions.get(sessionId) : undefined + if (!context) { + record.unexpected = true + record.status = sessionId ? 404 : 400 + sendJsonRpcError( + response, + record.status, + sessionId ? -32001 : -32000, + sessionId ? 'MCP session not found' : 'MCP session identifier is required' + ) + return + } + record.session = context.label + await context.transport.handleRequest(request, response) + record.status = response.statusCode + return + } + + if (method !== 'POST') { + record.unexpected = true + record.status = 405 + sendJsonRpcError(response, record.status, -32000, 'Method not allowed') + return + } + + let body: unknown + try { + body = await readJsonBody(request, maxBodyBytes) + } catch (error) { + const bodyError = + error instanceof RequestBodyError + ? error + : new RequestBodyError('Unable to read MCP request body', 400, -32700) + record.unexpected = true + record.status = bodyError.status + sendJsonRpcError(response, bodyError.status, bodyError.code, bodyError.message) + return + } + + const rpcMethod = getRpcMethod(body) + record.rpcMethod = rpcMethod + ? boundedRecordValue(rpcMethod, MAX_RECORDED_RPC_METHOD_LENGTH, '') + : undefined + const validEnvelope = + typeof body === 'object' && + body !== null && + !Array.isArray(body) && + (body as JsonRpcEnvelope).jsonrpc === '2.0' && + rpcMethod !== undefined + if (!validEnvelope) { + record.unexpected = true + record.status = 400 + sendJsonRpcError(response, record.status, -32600, 'Invalid JSON-RPC request') + return + } + if (!EXPECTED_RPC_METHODS.has(rpcMethod)) record.unexpected = true + + let context: SessionContext | undefined + if (isInitializeRequest(body)) { + if (sessionId) { + context = sessions.get(sessionId) + record.unexpected = true + } else { + context = await createSession() + } + } else { + context = sessionId ? sessions.get(sessionId) : undefined + } + + if (!context) { + record.unexpected = true + record.status = sessionId ? 404 : 400 + sendJsonRpcError( + response, + record.status, + sessionId ? -32001 : -32000, + sessionId ? 'MCP session not found' : 'MCP session identifier is required' + ) + return + } + + record.session = context.label + await context.transport.handleRequest(request, response, body) + record.status = response.statusCode + record.session = labelForSession(context.transport.sessionId) ?? context.label + } + + const server: Server = createServer((request, response) => { + let requestRecord: McpFakeRequestRecord | undefined + void handleRequest(request, response, (record) => { + requestRecord = record + }).catch(() => { + const record = requestRecord + if (record) { + record.unexpected = true + record.status = 500 + } + if (!response.headersSent) { + sendJsonRpcError(response, 500, -32603, 'MCP fake internal error') + } else { + response.destroy() + } + }) + }) + + return { + get baseUrl() { + return baseUrl + }, + get requestLog() { + return cloneRequestLog(records) + }, + async start() { + if (baseUrl) return baseUrl + await new Promise((resolve, reject) => { + const handleError = (error: Error) => { + server.off('listening', handleListening) + reject(error) + } + const handleListening = () => { + server.off('error', handleError) + resolve() + } + server.once('error', handleError) + server.once('listening', handleListening) + server.listen(port, hostname) + }) + const address = server.address() as AddressInfo + baseUrl = `http://mcp.e2e.sim.ai:${address.port}${MCP_PATH}` + return baseUrl + }, + async stop() { + const failures: unknown[] = [] + for (const context of new Set(sessions.values())) { + try { + await context.server.close() + } catch (error) { + failures.push(error) + } + } + sessions.clear() + if (server.listening) { + try { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())) + server.closeIdleConnections() + server.closeAllConnections() + }) + } catch (error) { + failures.push(error) + } + } + baseUrl = null + if (failures.length > 0) { + throw new AggregateError(failures, 'Unable to stop MCP fake server') + } + }, + } +} + +/** Starts the MCP fake in one call for orchestration code. */ +export async function startMcpFakeServer( + options: McpFakeServerOptions = {} +): Promise { + const server = createMcpFakeServer(options) + await server.start() + return server +} diff --git a/apps/sim/e2e/foundation/mcp-fake.spec.ts b/apps/sim/e2e/foundation/mcp-fake.spec.ts new file mode 100644 index 00000000000..689b10ba6ac --- /dev/null +++ b/apps/sim/e2e/foundation/mcp-fake.spec.ts @@ -0,0 +1,161 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' +import { expect, test } from '@playwright/test' +import { E2E_MCP_TOOL, startMcpFakeServer } from '../fakes/mcp/server' +import { assertValidMcpFakeTraffic } from '../support/mcp-requests' + +function loopbackUrl(advertisedUrl: string): URL { + const url = new URL(advertisedUrl) + url.hostname = '127.0.0.1' + return url +} + +async function connectClient(url: URL, name: string) { + const transport = new StreamableHTTPClientTransport(url) + const client = new Client({ name, version: '1.0.0' }, { capabilities: {} }) + await client.connect(transport) + return { client, transport } +} + +test('MCP fake maintains independent sessions and deterministic discovery', async () => { + const fake = await startMcpFakeServer() + const advertisedUrl = fake.baseUrl + expect(advertisedUrl).toMatch(/^http:\/\/mcp\.e2e\.sim\.ai:\d+\/mcp$/) + const url = loopbackUrl(advertisedUrl!) + const first = await connectClient(url, 'foundation-mcp-client-one') + const second = await connectClient(url, 'foundation-mcp-client-two') + + try { + const [firstTools, secondTools] = await Promise.all([ + first.client.listTools(), + second.client.listTools(), + ]) + await first.client.ping() + for (const result of [firstTools, secondTools]) { + expect(result.tools).toHaveLength(1) + expect(result.tools[0]).toMatchObject({ + name: E2E_MCP_TOOL.name, + description: E2E_MCP_TOOL.description, + inputSchema: E2E_MCP_TOOL.inputSchema, + }) + } + + const firstSessionId = first.transport.sessionId + const secondSessionId = second.transport.sessionId + expect(firstSessionId).toBeTruthy() + expect(secondSessionId).toBeTruthy() + expect(firstSessionId).not.toBe(secondSessionId) + + await Promise.all([first.transport.terminateSession(), second.transport.terminateSession()]) + + const records = fake.requestLog + expect( + records.filter(({ rpcMethod }) => rpcMethod === 'initialize').map(({ session }) => session) + ).toEqual(['session-1', 'session-2']) + expect( + records.filter(({ rpcMethod }) => rpcMethod === 'tools/list').map(({ session }) => session) + ).toEqual(expect.arrayContaining(['session-1', 'session-2'])) + expect(records.some(({ rpcMethod, status }) => rpcMethod === 'ping' && status === 200)).toBe( + true + ) + expect(records.filter(({ method, status }) => method === 'GET' && status === 405)).toHaveLength( + 2 + ) + expect( + records.filter(({ method, status }) => method === 'DELETE' && status === 200) + ).toHaveLength(2) + expect(records.every(({ unexpected }) => !unexpected)).toBe(true) + expect(JSON.stringify(records)).not.toContain(firstSessionId) + expect(JSON.stringify(records)).not.toContain(secondSessionId) + assertValidMcpFakeTraffic(records, true) + } finally { + await Promise.allSettled([first.client.close(), second.client.close()]) + await fake.stop() + } +}) + +test('MCP fake bounds and safely records unsupported traffic', async () => { + const fake = await startMcpFakeServer({ maxBodyBytes: 1024 }) + const advertisedUrl = fake.baseUrl! + const url = loopbackUrl(advertisedUrl) + const connection = await connectClient(url, 'foundation-mcp-malformed-client') + const sessionId = connection.transport.sessionId! + + try { + const commonHeaders = { + accept: 'application/json, text/event-stream', + 'content-type': 'application/json', + } + const malformed = await fetch(url, { + method: 'POST', + headers: commonHeaders, + body: '{not-json', + }) + expect(malformed.status).toBe(400) + expect(await malformed.json()).toMatchObject({ + jsonrpc: '2.0', + error: { code: -32700 }, + id: null, + }) + + const unsupportedRpc = await fetch(url, { + method: 'POST', + headers: { + ...commonHeaders, + 'mcp-session-id': sessionId, + 'mcp-protocol-version': connection.transport.protocolVersion!, + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 99, method: 'resources/list' }), + }) + expect(unsupportedRpc.status).toBe(200) + expect(await unsupportedRpc.json()).toMatchObject({ + jsonrpc: '2.0', + error: { code: -32601 }, + id: 99, + }) + + const wrongContentType = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'text/plain' }, + body: '{}', + }) + expect(wrongContentType.status).toBe(415) + + const oversized = await fetch(url, { + method: 'POST', + headers: commonHeaders, + body: JSON.stringify({ value: 'x'.repeat(2048) }), + }) + expect(oversized.status).toBe(413) + + const unsupportedPath = await fetch(new URL('/other', url)) + expect(unsupportedPath.status).toBe(404) + const unsupportedMethod = await fetch(url, { method: 'PUT' }) + expect(unsupportedMethod.status).toBe(405) + + const records = fake.requestLog + expect(records.filter(({ unexpected }) => unexpected)).toHaveLength(6) + expect(() => assertValidMcpFakeTraffic(records, false)).toThrow( + /MCP fake received unsupported requests/ + ) + for (const record of records) { + expect(Object.keys(record).sort()).toEqual( + ['method', 'path', 'rpcMethod', 'sequence', 'session', 'status', 'unexpected'].filter( + (key) => key !== 'rpcMethod' || record.rpcMethod !== undefined + ) + ) + } + const serialized = JSON.stringify(records) + expect(serialized).not.toContain(sessionId) + expect(serialized).not.toContain('not-json') + expect(serialized).not.toContain('x'.repeat(32)) + } finally { + await connection.client.close() + await fake.stop() + } +}) + +test('MCP lifecycle validation is conditional on the workflow marker', () => { + expect(() => assertValidMcpFakeTraffic([], false)).not.toThrow() + expect(() => assertValidMcpFakeTraffic([], true)).toThrow(/initialize/) +}) diff --git a/apps/sim/e2e/foundation/safety.spec.ts b/apps/sim/e2e/foundation/safety.spec.ts index f73a4b63a02..34e67e000f9 100644 --- a/apps/sim/e2e/foundation/safety.spec.ts +++ b/apps/sim/e2e/foundation/safety.spec.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { createServer } from 'node:net' import os from 'node:os' import path from 'node:path' @@ -10,9 +10,14 @@ import { assertSafeDatabaseName, buildRunDatabaseUrl, } from '../support/database' -import { createHostedBillingProfile } from '../support/deployment-profile' +import { + createHostedBillingProfile, + E2E_HOST, + E2E_MCP_HOST, + isHostedProfileSensitiveKey, +} from '../support/deployment-profile' import { buildChildEnvironment, discoverEnvFileKeys } from '../support/env' -import { areValidE2eHostAddresses, isLoopbackAddress } from '../support/hosts' +import { areValidE2eHostAddresses, E2E_HOSTS, isLoopbackAddress } from '../support/hosts' import { assertPortAvailable, spawnManagedProcess, @@ -75,6 +80,7 @@ test.describe('foundation safety guards', () => { runId: 'projection_test', databaseUrl: 'postgresql://127.0.0.1:5432/sim_e2e_projection_test', stripeApiBaseUrl: 'http://127.0.0.1:40123', + mcpServerUrl: 'http://mcp.e2e.sim.ai:40124/mcp', runtimeHomeDirectory: path.join(os.tmpdir(), 'sim-e2e-projection-runtime'), setupHomeDirectory: path.join(os.tmpdir(), 'sim-e2e-projection-setup'), authCaptureHomeDirectory: path.join(os.tmpdir(), 'sim-e2e-projection-auth'), @@ -93,6 +99,14 @@ test.describe('foundation safety guards', () => { expect(build.env.E2E_RUN_ID).toBe('build_sentinel') expect(build.env.DEPLOY_AS_BLOCK).toBe('true') expect(app.env.DEPLOY_AS_BLOCK).toBe('true') + expect(build.env.SSO_ENABLED).toBe('true') + expect(app.env.SSO_ENABLED).toBe('true') + expect(build.env.SSO_DOMAIN_VERIFICATION_ENABLED).toBe('true') + expect(app.env.SSO_DOMAIN_VERIFICATION_ENABLED).toBe('true') + expect(build.env.NEXT_PUBLIC_SSO_ENABLED).toBe('true') + expect(app.env.NEXT_PUBLIC_SSO_ENABLED).toBe('true') + expect(build.env.ALLOWED_MCP_DOMAINS).toBe(E2E_MCP_HOST) + expect(app.env.ALLOWED_MCP_DOMAINS).toBe(E2E_MCP_HOST) for (const key of [ 'RESEND_API_KEY', 'AWS_SES_REGION', @@ -126,18 +140,56 @@ test.describe('foundation safety guards', () => { expect(authCapture.env.DATABASE_URL).toBeUndefined() expect(playwright.env.ADMIN_API_KEY).toBeUndefined() expect(playwright.env.DATABASE_URL).toBeUndefined() + expect(playwright.env.E2E_MCP_SERVER_URL).toBe('http://mcp.e2e.sim.ai:40124/mcp') + expect(isHostedProfileSensitiveKey('E2E_MCP_SERVER_URL')).toBe(false) expect(realtime.env.ADMIN_API_KEY).toBeUndefined() expect(realtime.env.STRIPE_SECRET_KEY).toBeUndefined() expect(migration.env.ADMIN_API_KEY).toBeUndefined() expect(migration.env.MIGRATION_DATABASE_URL).toBe(app.env.DATABASE_URL) for (const environment of [realtime, migration, seed, authCapture, playwright]) { expect(environment.env.DEPLOY_AS_BLOCK).toBeUndefined() + expect(environment.env.ALLOWED_MCP_DOMAINS).toBeUndefined() + expect(environment.env.SSO_ENABLED).toBeUndefined() + expect(environment.env.SSO_DOMAIN_VERIFICATION_ENABLED).toBeUndefined() + expect(environment.env.NEXT_PUBLIC_SSO_ENABLED).toBeUndefined() + } + for (const environment of [build, app, realtime, migration, seed, authCapture]) { + expect(environment.env.E2E_MCP_SERVER_URL).toBeUndefined() } expect(app.env.HOME).not.toBe(seed.env.HOME) expect(seed.env.HOME).not.toBe(authCapture.env.HOME) expect(authCapture.env.HOME).not.toBe(playwright.env.HOME) }) + test('committed E2E sources contain no SSO private keys or verification tokens', () => { + const privateKeyMarker = ['-----BEGIN ', 'PRIVATE KEY-----'].join('') + const verificationTokenField = ['domainVerification', 'Token'].join('') + const sourceExtensions = new Set([ + '.js', + '.json', + '.md', + '.pem', + '.ts', + '.tsx', + '.yaml', + '.yml', + ]) + const files = collectFiles(path.join(process.cwd(), 'e2e')).filter((file) => + sourceExtensions.has(path.extname(file)) + ) + + for (const file of files) { + const source = readFileSync(file, 'utf8') + const relativePath = path.relative(process.cwd(), file) + if (source.includes(privateKeyMarker)) { + throw new Error(`${relativePath} contains a private key`) + } + if (source.includes(verificationTokenField)) { + throw new Error(`${relativePath} contains an SSO verification token field`) + } + } + }) + test('database guards reject shared or remote targets', () => { expect(() => assertSafeDatabaseName('simstudio')).toThrow() expect(() => assertSafeDatabaseName('sim_e2e_valid_run')).not.toThrow() @@ -178,6 +230,7 @@ test.describe('foundation safety guards', () => { }) test('loopback detection rejects public addresses', () => { + expect(E2E_HOSTS).toEqual([E2E_HOST, E2E_MCP_HOST]) expect(isLoopbackAddress('127.0.0.1')).toBe(true) expect(isLoopbackAddress('127.10.20.30')).toBe(true) expect(isLoopbackAddress('::1')).toBe(true) @@ -236,6 +289,18 @@ test.describe('foundation safety guards', () => { ).toThrow(/coupled E2E projects must remain unsharded/) }) + test('repeat-each can increase coverage without weakening orchestration', () => { + expect( + parseRunOptions( + ['--project=hosted-billing-chromium-workflows', '--no-deps', '--repeat-each=2'], + { ci: false } + ).playwrightArgs + ).toContain('--repeat-each=2') + expect(() => + parseRunOptions(['--project=hosted-billing-chromium-workflows', '--repeat-each']) + ).toThrow(/requires a value/) + }) + test('Playwright CLI arguments cannot override orchestration invariants', () => { expect(() => parseRunOptions(['--workers=8'])).toThrow(/cannot override/) expect(() => parseRunOptions(['-j8'])).toThrow(/cannot override/) @@ -394,3 +459,11 @@ test.describe('foundation safety guards', () => { } }) }) + +function collectFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + if (entry.isDirectory() && entry.name.startsWith('.')) return [] + const entryPath = path.join(directory, entry.name) + return entry.isDirectory() ? collectFiles(entryPath) : [entryPath] + }) +} diff --git a/apps/sim/e2e/scripts/capture-auth-states.ts b/apps/sim/e2e/scripts/capture-auth-states.ts index 8184104941e..be4975a4fa2 100644 --- a/apps/sim/e2e/scripts/capture-auth-states.ts +++ b/apps/sim/e2e/scripts/capture-auth-states.ts @@ -143,12 +143,13 @@ async function signInThroughUi( personaKey: string ): Promise { for (let attempt = 0; attempt <= UI_RETRY_DELAYS_MS.length; attempt += 1) { - const responsePromise = page.waitForResponse((response) => { - const url = new URL(response.url()) - return url.pathname === '/api/auth/sign-in/email' - }) - await page.getByRole('button', { name: 'Sign in' }).click() - const response = await responsePromise + const [response] = await Promise.all([ + page.waitForResponse((candidate) => { + const url = new URL(candidate.url()) + return url.pathname === '/api/auth/sign-in/email' + }), + page.getByRole('button', { name: 'Sign in', exact: true }).click(), + ]) if (response.status() === 200) { try { await page.waitForURL(/\/workspace(?:\/|$)/, { timeout: 30_000 }) diff --git a/apps/sim/e2e/scripts/options.ts b/apps/sim/e2e/scripts/options.ts index 73d9e24ec9b..08d70bad441 100644 --- a/apps/sim/e2e/scripts/options.ts +++ b/apps/sim/e2e/scripts/options.ts @@ -30,7 +30,7 @@ const FORBIDDEN_OPTIONS = [ '--ui', ] as const const SAFE_BOOLEAN_OPTIONS = new Set(['--no-deps', '--headed', '--quiet']) -const SAFE_VALUE_OPTIONS = new Set(['--grep', '--grep-invert', '-g']) +const SAFE_VALUE_OPTIONS = new Set(['--grep', '--grep-invert', '--repeat-each', '-g']) export interface E2eRunOptions { playwrightArgs: string[] diff --git a/apps/sim/e2e/scripts/run.ts b/apps/sim/e2e/scripts/run.ts index b9a85ce6ff7..cf38b65badd 100644 --- a/apps/sim/e2e/scripts/run.ts +++ b/apps/sim/e2e/scripts/run.ts @@ -10,6 +10,7 @@ import { writeFileSync, } from 'node:fs' import path from 'node:path' +import { type McpFakeServer, startMcpFakeServer } from '../fakes/mcp/server' import { type StripeFakeServer, startStripeFakeServer } from '../fakes/stripe/server' import { buildRunDatabaseUrl, @@ -24,16 +25,18 @@ import { assertNoForbiddenProviderTraffic, } from '../support/diagnostics' import { formatRedactedEnvironmentSummary } from '../support/env' -import { assertE2eHostResolvesToLoopback } from '../support/hosts' +import { assertE2eHostsResolveToLoopback } from '../support/hosts' import { assertNoSyntheticSecretLeaks, loadSyntheticSecretCanaryForScan, scrubUnscannableArtifacts, } from '../support/leak-canary' +import { assertValidMcpFakeTraffic, writeMcpRequestLog } from '../support/mcp-requests' import { getRunDirectory, SIM_APP_DIR } from '../support/paths' import { assertAdminApiBoundary, assertManifestWorkspaceIdentities, + assertSettingsPrimaryRetentionRestored, type FoundationProvisioningResult, inspectFoundationUsers, } from '../support/probes' @@ -105,14 +108,69 @@ async function main(): Promise { let runDatabase: RunDatabase | null = null let databaseCreationComplete = false let stripeFake: StripeFakeServer | null = null + let mcpFake: McpFakeServer | null = null let realtime: ManagedProcess | null = null let app: ManagedProcess | null = null let cleanupPromise: Promise | null = null + let fakeFinalizationPromise: Promise | null = null let leakCanarySecrets: string[] = runtimeSecretValues(runtimeSecrets) let canaryCoverageComplete = true let diagnosticsRetained = true let failed = false + const persistFakeRequestLogs = (): void => { + const failures: unknown[] = [] + if (stripeFake) { + try { + writeFileSync( + path.join(logsDirectory, 'stripe-requests.json'), + JSON.stringify(stripeFake.requestLog, null, 2) + ) + } catch (error) { + failures.push(error) + } + } + if (mcpFake) { + try { + writeMcpRequestLog(logsDirectory, mcpFake.requestLog) + } catch (error) { + failures.push(error) + } + } + if (failures.length > 0) { + throw new AggregateError(failures, 'Unable to persist E2E fake request logs') + } + } + + const finalizeFakeServers = (): Promise => { + if (fakeFinalizationPromise) return fakeFinalizationPromise + fakeFinalizationPromise = (async () => { + const failures: unknown[] = [] + try { + persistFakeRequestLogs() + } catch (error) { + failures.push(error) + } + for (const fake of [mcpFake, stripeFake]) { + if (!fake) continue + try { + await fake.stop() + } catch (error) { + failures.push(error) + } + } + try { + persistFakeRequestLogs() + } catch (error) { + failures.push(error) + } + if (failures.length > 0) { + throw new AggregateError(failures, 'Unable to finalize E2E fake servers') + } + })() + return fakeFinalizationPromise + } + const cleanup = (): Promise => { if (cleanupPromise) return cleanupPromise cleanupPromise = (async () => { @@ -123,20 +181,10 @@ async function main(): Promise { failures.push(error) } - if (stripeFake) { - try { - writeFileSync( - path.join(logsDirectory, 'stripe-requests.json'), - JSON.stringify(stripeFake.requestLog, null, 2) - ) - } catch (error) { - failures.push(error) - } - try { - await stripeFake.stop() - } catch (error) { - failures.push(error) - } + try { + await finalizeFakeServers() + } catch (error) { + failures.push(error) } for (const sensitiveDirectory of [storageStateDirectory, privateDirectory, homesDirectory]) { @@ -172,15 +220,10 @@ async function main(): Promise { console.error(`Received ${signal}; cleaning up the E2E run`) let lockTransferred = false try { - if (stripeFake) { - try { - writeFileSync( - path.join(logsDirectory, 'stripe-requests.json'), - JSON.stringify(stripeFake.requestLog, null, 2) - ) - } catch (error) { - console.error(error) - } + try { + await finalizeFakeServers() + } catch (error) { + console.error(error) } if (runDatabase) { let cleanupLogFd: number | null = null @@ -256,8 +299,12 @@ async function main(): Promise { process.on('SIGTERM', handleSigterm) try { - const hostAddresses = await assertE2eHostResolvesToLoopback() - console.info(`E2E host resolved to loopback: ${hostAddresses.join(', ')}`) + const hostAddresses = await assertE2eHostsResolveToLoopback() + console.info( + `E2E hosts resolved to loopback: ${Object.entries(hostAddresses) + .map(([hostname, addresses]) => `${hostname}=${addresses.join(',')}`) + .join(' ')}` + ) await Promise.all([assertPortAvailable(3000), assertPortAvailable(3002)]) const runDatabaseName = createRunDatabaseName(runId) @@ -273,11 +320,17 @@ async function main(): Promise { port: 0, }) if (!stripeFake.baseUrl) throw new Error('Stripe fake did not expose a base URL') + mcpFake = await startMcpFakeServer({ + hostname: '127.0.0.1', + port: 0, + }) + if (!mcpFake.baseUrl) throw new Error('MCP fake did not expose a base URL') const profile = createHostedBillingProfile({ runId, databaseUrl: runDatabase.url, stripeApiBaseUrl: stripeFake.baseUrl, + mcpServerUrl: mcpFake.baseUrl, runtimeHomeDirectory, setupHomeDirectory, authCaptureHomeDirectory, @@ -374,9 +427,13 @@ async function main(): Promise { process.off('SIGINT', handleSigint) process.off('SIGTERM', handleSigterm) - if (runDatabase && existsSync(manifestPath)) { + if (runDatabase) { try { - await assertManifestWorkspaceIdentities(runDatabase.url, manifestPath) + assertSeededScenarioManifestExists(manifestPath) + await Promise.all([ + assertManifestWorkspaceIdentities(runDatabase.url, manifestPath), + assertSettingsPrimaryRetentionRestored(runDatabase.url, manifestPath), + ]) } catch (error) { failed = true process.exitCode = 1 @@ -403,6 +460,25 @@ async function main(): Promise { process.exitCode = 1 console.error(error) } + try { + persistFakeRequestLogs() + } catch (error) { + failed = true + process.exitCode = 1 + console.error(error) + } + if (mcpFake) { + try { + assertValidMcpFakeTraffic( + mcpFake.requestLog, + hasMcpWorkflowCompletionMarker(markerDirectory) + ) + } catch (error) { + failed = true + process.exitCode = 1 + console.error(error) + } + } try { assertNoForbiddenProviderTraffic( [app?.logPath, realtime?.logPath].filter((value): value is string => Boolean(value)) @@ -553,4 +629,17 @@ function hasFoundationCompletionMarker(markerDirectory: string): boolean { ) } +function hasMcpWorkflowCompletionMarker(markerDirectory: string): boolean { + return ( + existsSync(markerDirectory) && + readdirSync(markerDirectory).some((name) => name.startsWith('mcp-workflow-complete')) + ) +} + +function assertSeededScenarioManifestExists(manifestPath: string): void { + if (!existsSync(manifestPath)) { + throw new Error('Final database invariants require the seeded scenario manifest') + } +} + await main() diff --git a/apps/sim/e2e/scripts/seed-world.ts b/apps/sim/e2e/scripts/seed-world.ts index 79c969ca4c2..5f9768c5db4 100644 --- a/apps/sim/e2e/scripts/seed-world.ts +++ b/apps/sim/e2e/scripts/seed-world.ts @@ -4,6 +4,7 @@ import { invitation, invitationWorkspaceGrant, member, + organization, permissionGroup, permissionGroupMember, permissionGroupWorkspace, @@ -47,6 +48,10 @@ import type { ResolvedScenario, ScenarioSubscription } from '../fixtures/scenari import { expectedUsageLimit, initialSubscriptionStatus } from '../fixtures/scenario-billing' import { validateScenario, validateScenarioSet } from '../fixtures/validate-scenario' import { createSettingsPersonaScenarios } from '../settings/personas' +import { + assertSettingsPrimaryRetentionBaseline, + SETTINGS_PRIMARY_RETENTION_BASELINE, +} from '../support/data-retention' import { writeSyntheticSecretCanary } from '../support/leak-canary' import { assertSafeSeedEnvironment } from '../support/seed-safety' @@ -97,6 +102,7 @@ async function main(): Promise { ownerClients, attemptCounts ) + await postProvisionSettingsPrimaryRetention(world) await lapsePlannedSubscriptions(world) await createGrantsAndPermissionGroups(world, adminClient, ownerClients) await arrangePlatformAdminsAndInvitations(world) @@ -239,6 +245,29 @@ async function createScenarioWorkspaces( } } +async function postProvisionSettingsPrimaryRetention(world: E2EWorld): Promise { + if (world.scenario.definition.namespace.world !== 'settings-primary') return + const enterpriseOrganization = required( + world.records.organizations, + 'enterprise-organization', + 'retention organization' + ) + required(world.records.workspaces, 'enterprise-workspace', 'retention workspace') + const [updated] = await db + .update(organization) + .set({ + dataRetentionSettings: structuredClone(SETTINGS_PRIMARY_RETENTION_BASELINE), + updatedAt: new Date(), + }) + .where(eq(organization.id, enterpriseOrganization.id)) + .returning({ dataRetentionSettings: organization.dataRetentionSettings }) + if (!updated) throw new Error('Unable to post-provision the settings-primary retention baseline') + assertSettingsPrimaryRetentionBaseline( + updated.dataRetentionSettings, + 'Post-provisioned settings-primary retention settings' + ) +} + async function lapsePlannedSubscriptions(world: E2EWorld): Promise { for (const subscription of world.scenario.definition.subscriptions) { if (subscription.status !== 'lapsed' || subscription.billingReference.kind !== 'organization') { @@ -675,6 +704,15 @@ async function assertWorkflowPersonaInvariants(world: E2EWorld): Promise { 'enterprise-workspace', 'workflow workspace' ) + const [retentionOrganization] = await db + .select({ dataRetentionSettings: organization.dataRetentionSettings }) + .from(organization) + .where(eq(organization.id, enterpriseOrganization.id)) + .limit(1) + assertSettingsPrimaryRetentionBaseline( + retentionOrganization?.dataRetentionSettings, + 'Trusted settings-primary retention invariant' + ) const teamMembers = await db .select({ userId: member.userId }) diff --git a/apps/sim/e2e/settings/smoke/authenticated.spec.ts b/apps/sim/e2e/settings/smoke/authenticated.spec.ts index de2e851c1e5..3fd2c483862 100644 --- a/apps/sim/e2e/settings/smoke/authenticated.spec.ts +++ b/apps/sim/e2e/settings/smoke/authenticated.spec.ts @@ -43,7 +43,18 @@ test('billing-enabled signup, login, and settings use real Sim boundaries', asyn await page.getByLabel('Email').fill(email) await page.getByRole('textbox', { name: 'Password' }).fill(password) - await page.getByRole('button', { name: 'Sign in' }).click() + const [signInResponse] = await Promise.all([ + page.waitForResponse((response) => { + const url = new URL(response.url()) + return ( + response.request().method() === 'POST' && + url.origin === new URL(page.url()).origin && + url.pathname === '/api/auth/sign-in/email' + ) + }), + page.getByRole('button', { name: 'Sign in', exact: true }).click(), + ]) + expect(signInResponse.status(), signInResponse.statusText()).toBe(200) await expect(page).toHaveURL(/\/workspace(?:\/|$)/) await page.context().storageState({ path: storageStatePath }) diff --git a/apps/sim/e2e/settings/workflows/contract-integrity.spec.ts b/apps/sim/e2e/settings/workflows/contract-integrity.spec.ts index 5c438b0641c..edd7a053a6a 100644 --- a/apps/sim/e2e/settings/workflows/contract-integrity.spec.ts +++ b/apps/sim/e2e/settings/workflows/contract-integrity.spec.ts @@ -1,6 +1,12 @@ -import { accessGateCases } from '../authorization/contracts' +import { accessGateCases, existingNavigationProofs } from '../authorization/contracts' +import { sectionContracts } from '../navigation/contracts' import { SETTINGS_PERSONA_KEYS } from '../personas' -import { dynamicRestrictionCases, peopleWorkflowCases, workflowPersonaKeys } from './contracts' +import { + dynamicRestrictionCases, + enterpriseIntegrationWorkflowCases, + peopleWorkflowCases, + workflowPersonaKeys, +} from './contracts' import { expect, test } from './workflow-test' test('workflow contracts reference durable personas and authorization proofs', () => { @@ -18,3 +24,96 @@ test('workflow contracts reference durable personas and authorization proofs', ( expect(accessCaseIds).toContain(restriction.existingProofId) } }) + +test('Step 6b lifecycle contracts remain literal and self-contained', () => { + expect(enterpriseIntegrationWorkflowCases).toEqual([ + { + caseId: 'saml-provider-lifecycle', + actor: 'enterpriseOrganizationAdmin', + worldKey: 'settings-primary', + organizationKey: 'enterprise-organization', + routeTemplate: '/organization/{organizationId}/settings/sso', + navigationContractId: 'organization-sso', + navigationProofId: 'step3-enterprise-organization-positive', + authorizationProofId: 'organization-read-member-sso-denied', + lifecycle: [ + { method: 'POST', path: '/api/auth/sso/register', statuses: [200] }, + { + method: 'POST', + path: '/api/auth/sso/providers/{providerRowId}/domain-verification/request', + statuses: [201], + }, + { method: 'PATCH', path: '/api/auth/sso/providers/{providerRowId}', statuses: [200] }, + { method: 'DELETE', path: '/api/auth/sso/providers/{providerRowId}', statuses: [200] }, + ], + safetyBoundary: 'pending-domain-verification-only', + }, + { + caseId: 'organization-retention-and-workspace-override-lifecycle', + actor: 'enterpriseOrganizationAdmin', + worldKey: 'settings-primary', + organizationKey: 'enterprise-organization', + workspaceKey: 'enterprise-workspace', + routeTemplate: '/organization/{organizationId}/settings/data-retention', + effectiveRouteTemplate: '/workspace/{workspaceId}/settings/data-retention', + navigationContractId: 'organization-data-retention', + navigationProofId: 'step3-enterprise-organization-positive', + authorizationProofId: 'organization-read-member-data-retention-denied', + lifecycle: [ + { + method: 'GET', + path: '/api/organizations/{organizationId}/data-retention', + statuses: [200], + }, + { + method: 'PUT', + path: '/api/organizations/{organizationId}/data-retention', + statuses: [200], + }, + ], + safetyBoundary: 'omit-pii-and-restore-exact-configured-snapshot', + }, + { + caseId: 'mcp-server-discovery-lifecycle', + actor: 'enterpriseOrganizationAdmin', + worldKey: 'settings-primary', + workspaceKey: 'enterprise-workspace', + routeTemplate: '/workspace/{workspaceId}/settings/mcp', + navigationContractId: 'workspace-mcp', + navigationProofId: 'step3-enterprise-workspace-positive', + authorizationProofId: 'workspace-permission-group-mcp-denied', + lifecycle: [ + { method: 'POST', path: '/api/mcp/servers/test-connection', statuses: [200] }, + { method: 'POST', path: '/api/mcp/servers', statuses: [201, 200] }, + { method: 'PATCH', path: '/api/mcp/servers/{serverId}', statuses: [200] }, + { method: 'DELETE', path: '/api/mcp/servers', statuses: [200] }, + ], + safetyBoundary: 'allowlisted-fake-discovery-without-auth-or-headers', + }, + ]) +}) + +test('Step 6b lifecycle contracts reference durable navigation and authorization proofs', () => { + const personaKeys = new Set(SETTINGS_PERSONA_KEYS) + const navigationContractIds = new Set(sectionContracts.map(({ contractId }) => contractId)) + const navigationProofIds = new Set(existingNavigationProofs.map(({ proofId }) => proofId)) + const authorizationProofIds = new Set(accessGateCases.map(({ caseId }) => caseId)) + + expect(new Set(enterpriseIntegrationWorkflowCases.map(({ caseId }) => caseId)).size).toBe( + enterpriseIntegrationWorkflowCases.length + ) + for (const workflowCase of enterpriseIntegrationWorkflowCases) { + expect(personaKeys).toContain(workflowCase.actor) + expect(navigationContractIds).toContain(workflowCase.navigationContractId) + expect(navigationProofIds).toContain(workflowCase.navigationProofId) + expect(authorizationProofIds).toContain(workflowCase.authorizationProofId) + expect( + sectionContracts.find(({ contractId }) => contractId === workflowCase.navigationContractId) + ?.pathTemplate + ).toBe(workflowCase.routeTemplate) + expect( + accessGateCases.find(({ caseId }) => caseId === workflowCase.authorizationProofId) + ?.pathTemplate + ).toBe(workflowCase.routeTemplate) + } +}) diff --git a/apps/sim/e2e/settings/workflows/contracts.ts b/apps/sim/e2e/settings/workflows/contracts.ts index 717d93df4e6..5ba46c288ce 100644 --- a/apps/sim/e2e/settings/workflows/contracts.ts +++ b/apps/sim/e2e/settings/workflows/contracts.ts @@ -50,3 +50,69 @@ export const dynamicRestrictionCases = [ ] as const export const workflowPersonaKeys = ['teamWorkflowMember', 'enterpriseWorkflowMember'] as const + +export const enterpriseIntegrationWorkflowCases = [ + { + caseId: 'saml-provider-lifecycle', + actor: 'enterpriseOrganizationAdmin', + worldKey: 'settings-primary', + organizationKey: 'enterprise-organization', + routeTemplate: '/organization/{organizationId}/settings/sso', + navigationContractId: 'organization-sso', + navigationProofId: 'step3-enterprise-organization-positive', + authorizationProofId: 'organization-read-member-sso-denied', + lifecycle: [ + { method: 'POST', path: '/api/auth/sso/register', statuses: [200] }, + { + method: 'POST', + path: '/api/auth/sso/providers/{providerRowId}/domain-verification/request', + statuses: [201], + }, + { method: 'PATCH', path: '/api/auth/sso/providers/{providerRowId}', statuses: [200] }, + { method: 'DELETE', path: '/api/auth/sso/providers/{providerRowId}', statuses: [200] }, + ], + safetyBoundary: 'pending-domain-verification-only', + }, + { + caseId: 'organization-retention-and-workspace-override-lifecycle', + actor: 'enterpriseOrganizationAdmin', + worldKey: 'settings-primary', + organizationKey: 'enterprise-organization', + workspaceKey: 'enterprise-workspace', + routeTemplate: '/organization/{organizationId}/settings/data-retention', + effectiveRouteTemplate: '/workspace/{workspaceId}/settings/data-retention', + navigationContractId: 'organization-data-retention', + navigationProofId: 'step3-enterprise-organization-positive', + authorizationProofId: 'organization-read-member-data-retention-denied', + lifecycle: [ + { + method: 'GET', + path: '/api/organizations/{organizationId}/data-retention', + statuses: [200], + }, + { + method: 'PUT', + path: '/api/organizations/{organizationId}/data-retention', + statuses: [200], + }, + ], + safetyBoundary: 'omit-pii-and-restore-exact-configured-snapshot', + }, + { + caseId: 'mcp-server-discovery-lifecycle', + actor: 'enterpriseOrganizationAdmin', + worldKey: 'settings-primary', + workspaceKey: 'enterprise-workspace', + routeTemplate: '/workspace/{workspaceId}/settings/mcp', + navigationContractId: 'workspace-mcp', + navigationProofId: 'step3-enterprise-workspace-positive', + authorizationProofId: 'workspace-permission-group-mcp-denied', + lifecycle: [ + { method: 'POST', path: '/api/mcp/servers/test-connection', statuses: [200] }, + { method: 'POST', path: '/api/mcp/servers', statuses: [201, 200] }, + { method: 'PATCH', path: '/api/mcp/servers/{serverId}', statuses: [200] }, + { method: 'DELETE', path: '/api/mcp/servers', statuses: [200] }, + ], + safetyBoundary: 'allowlisted-fake-discovery-without-auth-or-headers', + }, +] as const diff --git a/apps/sim/e2e/settings/workflows/data-retention.spec.ts b/apps/sim/e2e/settings/workflows/data-retention.spec.ts new file mode 100644 index 00000000000..479df34d48a --- /dev/null +++ b/apps/sim/e2e/settings/workflows/data-retention.spec.ts @@ -0,0 +1,188 @@ +import { + buildRetentionPutBody, + captureConfiguredRetention, + SETTINGS_PRIMARY_RETENTION_BASELINE, +} from '../../support/data-retention' +import { + expectDataRetentionReady, + getOrganizationRetention, + newPersonaPage, + primaryWorldIds, + restoreOrganizationRetention, + selectLabeledOption, + waitForSameOriginResponse, +} from './helpers' +import { expect, test } from './workflow-test' + +const SAVED_DEFAULTS = { + logRetentionHours: 7 * 24, + softDeleteRetentionHours: 14 * 24, + taskCleanupHours: 60 * 24, +} as const + +test('organization retention and one-field workspace override restore their exact baseline', async ({ + contextForPersona, + personaManifest, + registerCleanup, +}) => { + const ids = primaryWorldIds(personaManifest) + const workspaceName = + personaManifest.worlds['settings-primary'].workspaceIdentities['enterprise-workspace'].name + const { context, page } = await newPersonaPage(contextForPersona, 'enterpriseOrganizationAdmin') + const baseline = captureConfiguredRetention( + await getOrganizationRetention(context.request, ids.enterpriseOrganizationId) + ) + expect(baseline).toEqual(SETTINGS_PRIMARY_RETENTION_BASELINE) + + registerCleanup('restore exact organization retention snapshot', () => + restoreOrganizationRetention(context.request, ids.enterpriseOrganizationId, baseline) + ) + + await page.goto( + `/organization/${encodeURIComponent(ids.enterpriseOrganizationId)}/settings/data-retention` + ) + let retention = await expectDataRetentionReady(page) + await retention.getByRole('button', { name: 'Edit organization retention policy' }).click() + + await selectLabeledOption(retention, 'Organization log retention', '1 day') + await selectLabeledOption(retention, 'Organization soft deletion cleanup', '3 days') + await selectLabeledOption(retention, 'Organization task cleanup', '7 days') + await page.getByRole('button', { name: 'Discard', exact: true }).click() + await expect(retention.getByLabel('Organization log retention')).toHaveText('30 days') + await expect(retention.getByLabel('Organization soft deletion cleanup')).toHaveText('90 days') + await expect(retention.getByLabel('Organization task cleanup')).toHaveText('30 days') + expect( + captureConfiguredRetention( + await getOrganizationRetention(context.request, ids.enterpriseOrganizationId) + ) + ).toEqual(baseline) + + await selectLabeledOption(retention, 'Organization log retention', '7 days') + await selectLabeledOption(retention, 'Organization soft deletion cleanup', '14 days') + await selectLabeledOption(retention, 'Organization task cleanup', '60 days') + const saveDefaultsResponsePromise = waitForSameOriginResponse( + page, + 'PUT', + `/api/organizations/${encodeURIComponent(ids.enterpriseOrganizationId)}/data-retention` + ) + await page.getByRole('button', { name: 'Save', exact: true }).click() + const saveDefaultsResponse = await saveDefaultsResponsePromise + expect(saveDefaultsResponse.status()).toBe(200) + const savedDefaultsBody = saveDefaultsResponse.request().postDataJSON() as Record + expect(savedDefaultsBody).toEqual({ + ...SAVED_DEFAULTS, + retentionOverrides: [], + }) + expect(savedDefaultsBody).not.toHaveProperty('piiRedaction') + expect( + captureConfiguredRetention( + await getOrganizationRetention(context.request, ids.enterpriseOrganizationId) + ) + ).toEqual({ + ...baseline, + ...SAVED_DEFAULTS, + }) + + retention = await expectDataRetentionReady(page) + await page.getByRole('button', { name: 'Add override', exact: true }).click() + await retention.getByRole('button', { name: 'Select workspaces', exact: true }).click() + await page.getByRole('menuitem', { name: workspaceName, exact: true }).click() + await page.keyboard.press('Escape') + await selectLabeledOption(retention, 'Workspace override log retention', '3 days') + await expect(retention.getByLabel('Workspace override soft deletion cleanup')).toHaveText( + 'Inherit from organization' + ) + await expect(retention.getByLabel('Workspace override task cleanup')).toHaveText( + 'Inherit from organization' + ) + + const addOverrideResponsePromise = waitForSameOriginResponse( + page, + 'PUT', + `/api/organizations/${encodeURIComponent(ids.enterpriseOrganizationId)}/data-retention` + ) + await page.getByRole('button', { name: 'Save', exact: true }).click() + const addOverrideResponse = await addOverrideResponsePromise + expect(addOverrideResponse.status()).toBe(200) + const addOverrideBody = addOverrideResponse.request().postDataJSON() as Record + expect(addOverrideBody).toEqual({ + ...SAVED_DEFAULTS, + retentionOverrides: [ + { + workspaceId: ids.enterpriseWorkspaceId, + logRetentionHours: 3 * 24, + }, + ], + }) + expect(addOverrideBody).not.toHaveProperty('piiRedaction') + expect( + (await getOrganizationRetention(context.request, ids.enterpriseOrganizationId)).configured + .retentionOverrides + ).toEqual([ + { + workspaceId: ids.enterpriseWorkspaceId, + logRetentionHours: 3 * 24, + }, + ]) + + retention = await expectDataRetentionReady(page) + const overrideRow = retention.getByRole('button', { + name: `Edit retention policy for ${workspaceName}`, + }) + await expect(overrideRow).toContainText('Log 3d · Soft-delete inherited · Task inherited') + + await page.goto( + `/workspace/${encodeURIComponent(ids.enterpriseWorkspaceId)}/settings/data-retention` + ) + retention = await expectDataRetentionReady(page) + await expect( + retention.getByRole('button', { + name: `Edit retention policy for ${workspaceName}`, + }) + ).toContainText('Log 3d · Soft-delete inherited · Task inherited') + + await retention + .getByRole('button', { name: `Edit retention policy for ${workspaceName}` }) + .click() + await page.getByRole('button', { name: 'Remove override', exact: true }).click() + const removeConfirmation = page.getByRole('dialog', { name: 'Remove override' }) + const removeOverrideResponsePromise = waitForSameOriginResponse( + page, + 'PUT', + `/api/organizations/${encodeURIComponent(ids.enterpriseOrganizationId)}/data-retention` + ) + await removeConfirmation.getByRole('button', { name: 'Remove override', exact: true }).click() + const removeOverrideResponse = await removeOverrideResponsePromise + expect(removeOverrideResponse.status()).toBe(200) + const removeOverrideBody = removeOverrideResponse.request().postDataJSON() as Record< + string, + unknown + > + expect(removeOverrideBody).toEqual({ + ...SAVED_DEFAULTS, + retentionOverrides: [], + }) + expect(removeOverrideBody).not.toHaveProperty('piiRedaction') + + retention = await expectDataRetentionReady(page) + await retention.getByRole('button', { name: 'Edit organization retention policy' }).click() + await selectLabeledOption(retention, 'Organization log retention', '30 days') + await selectLabeledOption(retention, 'Organization soft deletion cleanup', '90 days') + await selectLabeledOption(retention, 'Organization task cleanup', '30 days') + const restoreResponsePromise = waitForSameOriginResponse( + page, + 'PUT', + `/api/organizations/${encodeURIComponent(ids.enterpriseOrganizationId)}/data-retention` + ) + await page.getByRole('button', { name: 'Save', exact: true }).click() + const restoreResponse = await restoreResponsePromise + expect(restoreResponse.status()).toBe(200) + const restoreBody = restoreResponse.request().postDataJSON() as Record + expect(restoreBody).toEqual(buildRetentionPutBody(baseline)) + expect(restoreBody).not.toHaveProperty('piiRedaction') + expect( + captureConfiguredRetention( + await getOrganizationRetention(context.request, ids.enterpriseOrganizationId) + ) + ).toEqual(baseline) +}) diff --git a/apps/sim/e2e/settings/workflows/helpers.ts b/apps/sim/e2e/settings/workflows/helpers.ts index 175c3099260..37512ec5db0 100644 --- a/apps/sim/e2e/settings/workflows/helpers.ts +++ b/apps/sim/e2e/settings/workflows/helpers.ts @@ -1,11 +1,22 @@ import { randomUUID } from 'node:crypto' +import { writeFileSync } from 'node:fs' +import path from 'node:path' import type { APIRequestContext, BrowserContext, Locator, Page, Response } from '@playwright/test' import { z } from 'zod' import { + type ListMcpServersResponse, + listMcpServersContract, + type McpServer, +} from '@/lib/api/contracts/mcp' +import { + type OrganizationDataRetention, + type OrganizationRetentionValues, type OrganizationRoster, + organizationDataRetentionResponseSchema, organizationRosterSchema, type RosterMember, type RosterPendingInvitation, + type UpdateOrganizationDataRetentionBody, } from '@/lib/api/contracts/organization' import type { ScenarioManifest } from '../../fixtures/e2e-world' import { absoluteE2eUrl } from '../navigation/contract-resolver' @@ -30,6 +41,25 @@ const permissionGroupListSchema = z.object({ ), }) +const ssoProviderListSchema = z.object({ + providers: z.array( + z.object({ + id: z.string().optional(), + providerId: z.string().optional(), + domain: z.string().nullable(), + issuer: z.string().nullable().optional(), + samlConfig: z.string().nullable().optional(), + organizationId: z.string().nullable().optional(), + providerType: z.enum(['oidc', 'saml']).optional(), + domainVerified: z.boolean().optional(), + isCreator: z.boolean().optional(), + canManageVerification: z.boolean().optional(), + }) + ), +}) + +export type SsoProviderListEntry = z.infer['providers'][number] + export interface PrimaryWorldIds { teamOrganizationId: string enterpriseOrganizationId: string @@ -66,6 +96,27 @@ export function uniqueWorkflowName(label: string): string { return `e2e-${label}-${randomUUID()}` } +export function workflowResourcePrefix(manifest: ScenarioManifest, label: string): string { + const prefix = manifest.worlds['settings-primary']?.namespace.prefix + if (!prefix) throw new Error('Missing settings-primary workflow namespace') + return `${prefix}-${label}` +} + +export function uniqueRunPrefixedName(manifest: ScenarioManifest, label: string): string { + return `${workflowResourcePrefix(manifest, label)}-${randomUUID()}` +} + +export function uniqueSsoProviderId(manifest: ScenarioManifest): { + providerId: string + providerPrefix: string +} { + const providerPrefix = workflowResourcePrefix(manifest, 'sso').slice(0, 35).replace(/-+$/, '') + return { + providerId: `${providerPrefix}-${randomUUID().replace(/-/g, '').slice(0, 8)}`, + providerPrefix, + } +} + export async function newPersonaPage( contextForPersona: (personaKey: string) => Promise, personaKey: string @@ -98,16 +149,48 @@ export async function expectAccessControlReady(page: Page): Promise { return region } +export async function expectSsoReady(page: Page): Promise { + const region = page.getByRole('region', { name: 'SSO settings' }) + await expect(region).toHaveAttribute('aria-busy', 'false') + await expect(region).toHaveAttribute('data-sso-state', 'ready') + return region +} + +export async function expectDataRetentionReady(page: Page): Promise { + const region = page.getByRole('region', { name: 'Data retention settings' }) + await expect(region).toHaveAttribute('aria-busy', 'false') + await expect(region).toHaveAttribute('data-retention-state', 'ready') + return region +} + +export async function expectMcpReady(page: Page): Promise { + const region = page.getByRole('region', { name: 'MCP servers data' }) + await expect(region).toHaveAttribute('aria-busy', 'false') + await expect(region).toHaveAttribute('data-mcp-state', 'ready') + return region +} + export function waitForSameOriginResponse( page: Page, method: string, - pathname: string + pathname: string, + expectedSearchParams?: Record ): Promise { const origin = new URL(absoluteE2eUrl('/')).origin return page.waitForResponse((response) => { const url = new URL(response.url()) + if ( + url.origin !== origin || + url.pathname !== pathname || + response.request().method() !== method + ) { + return false + } + if (!expectedSearchParams) return true + const expectedEntries = Object.entries(expectedSearchParams) return ( - url.origin === origin && url.pathname === pathname && response.request().method() === method + url.searchParams.size === expectedEntries.length && + expectedEntries.every(([key, value]) => url.searchParams.get(key) === value) ) }) } @@ -128,6 +211,144 @@ export async function selectDropdownOption( await scope.page().getByRole('menuitem', { name: nextLabel, exact: true }).click() } +export async function selectLabeledOption( + scope: Locator, + controlName: string, + optionName: string +): Promise { + await scope.getByLabel(controlName, { exact: true }).click() + await scope.page().getByRole('menuitem', { name: optionName, exact: true }).click() +} + +export async function listSsoProviders( + request: APIRequestContext, + organizationId: string +): Promise { + const response = await request.get('/api/auth/sso/providers', { + params: { organizationId }, + }) + expect(response.status()).toBe(200) + return ssoProviderListSchema.parse(await response.json()).providers +} + +export async function deleteRunPrefixedSsoProviders( + request: APIRequestContext, + organizationId: string, + providerPrefix: string +): Promise { + const providers = await listSsoProviders(request, organizationId) + for (const provider of providers) { + if (!provider.providerId?.startsWith(providerPrefix) || !provider.id) continue + const response = await request.delete( + `/api/auth/sso/providers/${encodeURIComponent(provider.id)}` + ) + if (response.status() !== 200 && response.status() !== 404) { + throw new Error(`SSO provider cleanup failed with ${response.status()}`) + } + } + expect( + (await listSsoProviders(request, organizationId)).some(({ providerId }) => + providerId?.startsWith(providerPrefix) + ) + ).toBe(false) +} + +export async function getOrganizationRetention( + request: APIRequestContext, + organizationId: string +): Promise { + const response = await request.get( + `/api/organizations/${encodeURIComponent(organizationId)}/data-retention` + ) + expect(response.status()).toBe(200) + return organizationDataRetentionResponseSchema.parse(await response.json()).data +} + +export async function putOrganizationRetention( + request: APIRequestContext, + organizationId: string, + body: UpdateOrganizationDataRetentionBody +): Promise { + expect(body).not.toHaveProperty('piiRedaction') + const response = await request.put( + `/api/organizations/${encodeURIComponent(organizationId)}/data-retention`, + { data: body } + ) + expect(response.status()).toBe(200) + return organizationDataRetentionResponseSchema.parse(await response.json()).data +} + +export async function restoreOrganizationRetention( + request: APIRequestContext, + organizationId: string, + snapshot: OrganizationRetentionValues +): Promise { + if (snapshot.retentionOverrides === null) { + throw new Error('E2E retention restoration requires a concrete override snapshot') + } + const body: UpdateOrganizationDataRetentionBody = { + logRetentionHours: snapshot.logRetentionHours, + softDeleteRetentionHours: snapshot.softDeleteRetentionHours, + taskCleanupHours: snapshot.taskCleanupHours, + retentionOverrides: structuredClone(snapshot.retentionOverrides), + } + const restored = await putOrganizationRetention(request, organizationId, body) + expect(restored.configured).toEqual(snapshot) +} + +export async function listMcpServers( + request: APIRequestContext, + workspaceId: string +): Promise { + const response = await request.get('/api/mcp/servers', { params: { workspaceId } }) + expect(response.status()).toBe(200) + const parsed = listMcpServersContract.response.schema.parse( + await response.json() + ) as ListMcpServersResponse + return parsed.data.servers +} + +export async function deleteMcpServer( + request: APIRequestContext, + workspaceId: string, + serverId: string +): Promise { + const response = await request.delete('/api/mcp/servers', { + params: { workspaceId, serverId }, + }) + if (response.status() !== 200 && response.status() !== 404) { + throw new Error(`MCP server cleanup failed with ${response.status()}`) + } +} + +export async function deleteRunPrefixedMcpServers( + request: APIRequestContext, + workspaceId: string, + namePrefix: string +): Promise { + for (const server of await listMcpServers(request, workspaceId)) { + if (server.name.startsWith(namePrefix)) { + await deleteMcpServer(request, workspaceId, server.id) + } + } + expect( + (await listMcpServers(request, workspaceId)).some(({ name }) => name.startsWith(namePrefix)) + ).toBe(false) +} + +export function writeMcpWorkflowCompleteMarker(): void { + const markerDirectory = process.env.E2E_MARKER_DIR + const runId = process.env.E2E_RUN_ID + if (!markerDirectory || !runId) { + throw new Error('Missing MCP workflow marker environment') + } + writeFileSync( + path.join(markerDirectory, 'mcp-workflow-complete.json'), + `${JSON.stringify({ runId, completedAt: new Date().toISOString() })}\n`, + { mode: 0o600 } + ) +} + export async function getOrganizationRoster( request: APIRequestContext, organizationId: string diff --git a/apps/sim/e2e/settings/workflows/mcp.spec.ts b/apps/sim/e2e/settings/workflows/mcp.spec.ts new file mode 100644 index 00000000000..e4a61cf4dfb --- /dev/null +++ b/apps/sim/e2e/settings/workflows/mcp.spec.ts @@ -0,0 +1,210 @@ +import { + deleteRunPrefixedMcpServers, + expectMcpReady, + listMcpServers, + newPersonaPage, + primaryWorldIds, + uniqueRunPrefixedName, + waitForSameOriginResponse, + workflowResourcePrefix, + writeMcpWorkflowCompleteMarker, +} from './helpers' +import { expect, test } from './workflow-test' + +test('MCP allowlist, discovery, edit, and delete lifecycle uses the real fake server', async ({ + contextForPersona, + personaManifest, + registerCleanup, +}) => { + const ids = primaryWorldIds(personaManifest) + const namePrefix = workflowResourcePrefix(personaManifest, 'mcp') + const serverName = uniqueRunPrefixedName(personaManifest, 'mcp') + const editedServerName = `${serverName}-edited` + const blockedUrl = `https://blocked-${serverName.slice(-12)}.invalid/mcp` + const mcpServerUrl = process.env.E2E_MCP_SERVER_URL + if (!mcpServerUrl) throw new Error('Missing Playwright-only E2E MCP server URL') + const { context, page } = await newPersonaPage(contextForPersona, 'enterpriseOrganizationAdmin') + + registerCleanup('remove run-prefixed MCP servers', () => + deleteRunPrefixedMcpServers(context.request, ids.enterpriseWorkspaceId, namePrefix) + ) + await deleteRunPrefixedMcpServers(context.request, ids.enterpriseWorkspaceId, namePrefix) + + await page.goto(`/workspace/${encodeURIComponent(ids.enterpriseWorkspaceId)}/settings/mcp`) + let mcp = await expectMcpReady(page) + const blockedMutationRequests: string[] = [] + const recordBlockedMutation = (request: import('@playwright/test').Request) => { + const url = new URL(request.url()) + if ( + (request.method() === 'POST' && url.pathname === '/api/mcp/servers/test-connection') || + (request.method() === 'POST' && url.pathname === '/api/mcp/servers') + ) { + blockedMutationRequests.push(`${request.method()} ${url.pathname}`) + } + } + page.on('request', recordBlockedMutation) + await page.getByRole('button', { name: 'Add server', exact: true }).click() + let dialog = page.getByRole('dialog', { name: 'Add MCP server' }) + await dialog.getByRole('textbox', { name: 'Server Name' }).fill(`${serverName}-blocked`) + await dialog.getByRole('textbox', { name: 'Server URL' }).fill(blockedUrl) + await expect( + dialog.getByText('Domain not permitted by server policy', { exact: true }) + ).toBeVisible() + await expect(dialog.getByRole('button', { name: 'Add server', exact: true })).toBeDisabled() + expect(blockedMutationRequests).toEqual([]) + await dialog.getByRole('button', { name: 'Cancel', exact: true }).click() + page.off('request', recordBlockedMutation) + + mcp = await expectMcpReady(page) + await page.getByRole('button', { name: 'Add server', exact: true }).click() + dialog = page.getByRole('dialog', { name: 'Add MCP server' }) + await dialog.getByRole('textbox', { name: 'Server Name' }).fill(serverName) + await dialog.getByRole('textbox', { name: 'Server URL' }).fill(mcpServerUrl) + + const initialTestResponsePromise = waitForSameOriginResponse( + page, + 'POST', + '/api/mcp/servers/test-connection' + ) + const createResponsePromise = waitForSameOriginResponse(page, 'POST', '/api/mcp/servers') + await dialog.getByRole('button', { name: 'Add server', exact: true }).click() + const initialTestResponse = await initialTestResponsePromise + expect(initialTestResponse.status()).toBe(200) + const initialTestBody = initialTestResponse.request().postDataJSON() as Record + expect(initialTestBody).toEqual({ + name: serverName, + transport: 'streamable-http', + url: mcpServerUrl, + headers: {}, + timeout: 30000, + workspaceId: ids.enterpriseWorkspaceId, + }) + expect(initialTestBody).not.toHaveProperty('oauthClientId') + expect(initialTestBody).not.toHaveProperty('oauthClientSecret') + + const createResponse = await createResponsePromise + expect([200, 201]).toContain(createResponse.status()) + const createRequestBody = createResponse.request().postDataJSON() as Record + expect(createRequestBody).toEqual({ + name: serverName, + transport: 'streamable-http', + url: mcpServerUrl, + headers: {}, + timeout: 30000, + authType: 'none', + enabled: true, + workspaceId: ids.enterpriseWorkspaceId, + }) + const createResponseBody = (await createResponse.json()) as { + success?: boolean + data?: { serverId?: string; updated?: boolean } + } + expect(createResponseBody.success).toBe(true) + if (createResponse.status() === 200) expect(createResponseBody.data?.updated).toBe(true) + else expect(createResponseBody.data?.updated).not.toBe(true) + + const created = (await listMcpServers(context.request, ids.enterpriseWorkspaceId)).find( + (server) => server.name === serverName + ) + expect(created).toMatchObject({ + name: serverName, + workspaceId: ids.enterpriseWorkspaceId, + url: mcpServerUrl, + headers: {}, + authType: 'none', + connectionStatus: 'connected', + }) + if (!created) throw new Error('Created MCP server was not recoverable') + + mcp = await expectMcpReady(page) + const serverRow = mcp.getByRole('group', { name: `MCP server ${serverName}` }) + await expect(serverRow).toContainText('1 tool') + await serverRow.getByRole('button', { name: `Actions for ${serverName}` }).click() + await page.getByRole('menuitem', { name: 'Details', exact: true }).click() + mcp = await expectMcpReady(page) + const tool = mcp.getByRole('button', { name: /e2e_lookup/ }) + await expect(tool).toContainText('e2e_lookup') + await tool.click() + await expect(mcp.getByText('query', { exact: true })).toBeVisible() + await expect(mcp.getByText('string', { exact: true })).toBeVisible() + await expect(mcp.getByText('required', { exact: true })).toBeVisible() + await expect(mcp.getByText('Fixture query to look up.', { exact: true })).toBeVisible() + + await page.getByRole('button', { name: 'Edit', exact: true }).click() + dialog = page.getByRole('dialog', { name: 'Edit MCP server' }) + await dialog.getByRole('textbox', { name: 'Server Name' }).fill(editedServerName) + await expect(dialog.getByRole('textbox', { name: 'Server URL' })).toHaveValue(mcpServerUrl) + const editTestResponsePromise = waitForSameOriginResponse( + page, + 'POST', + '/api/mcp/servers/test-connection' + ) + const updateResponsePromise = waitForSameOriginResponse( + page, + 'PATCH', + `/api/mcp/servers/${encodeURIComponent(created.id)}`, + { workspaceId: ids.enterpriseWorkspaceId } + ) + await dialog.getByRole('button', { name: 'Save', exact: true }).click() + const editTestResponse = await editTestResponsePromise + expect(editTestResponse.status()).toBe(200) + const editTestBody = editTestResponse.request().postDataJSON() as Record + expect(editTestBody).toEqual({ + name: editedServerName, + transport: 'streamable-http', + url: mcpServerUrl, + headers: {}, + timeout: 30000, + workspaceId: ids.enterpriseWorkspaceId, + }) + + const updateResponse = await updateResponsePromise + expect(updateResponse.status()).toBe(200) + const updateBody = updateResponse.request().postDataJSON() as Record + expect(updateBody).toEqual({ + name: editedServerName, + transport: 'streamable-http', + url: mcpServerUrl, + headers: {}, + timeout: 30000, + authType: 'none', + enabled: true, + }) + expect(updateBody).not.toHaveProperty('oauthClientId') + expect(updateBody).not.toHaveProperty('oauthClientSecret') + expect( + (await listMcpServers(context.request, ids.enterpriseWorkspaceId)).find( + (server) => server.id === created.id + ) + ).toMatchObject({ + name: editedServerName, + url: mcpServerUrl, + headers: {}, + authType: 'none', + }) + + mcp = await expectMcpReady(page) + await page + .getByRole('button', { name: 'MCP tools', exact: true }) + .and(page.locator('button:not([aria-current])')) + .click() + mcp = await expectMcpReady(page) + const editedRow = mcp.getByRole('group', { name: `MCP server ${editedServerName}` }) + await editedRow.getByRole('button', { name: `Actions for ${editedServerName}` }).click() + await page.getByRole('menuitem', { name: 'Delete', exact: true }).click() + const confirmation = page.getByRole('dialog', { name: 'Delete MCP server' }) + const deleteResponsePromise = waitForSameOriginResponse(page, 'DELETE', '/api/mcp/servers', { + workspaceId: ids.enterpriseWorkspaceId, + serverId: created.id, + }) + await confirmation.getByRole('button', { name: 'Delete', exact: true }).click() + expect((await deleteResponsePromise).status()).toBe(200) + expect( + (await listMcpServers(context.request, ids.enterpriseWorkspaceId)).some( + (server) => server.id === created.id + ) + ).toBe(false) + await expect(editedRow).toHaveCount(0) + + writeMcpWorkflowCompleteMarker() +}) diff --git a/apps/sim/e2e/settings/workflows/sso.spec.ts b/apps/sim/e2e/settings/workflows/sso.spec.ts new file mode 100644 index 00000000000..286135e191f --- /dev/null +++ b/apps/sim/e2e/settings/workflows/sso.spec.ts @@ -0,0 +1,210 @@ +import { rootCertificates } from 'node:tls' +import type { Response } from '@playwright/test' +import { + deleteRunPrefixedSsoProviders, + expectSsoReady, + listSsoProviders, + newPersonaPage, + primaryWorldIds, + uniqueSsoProviderId, + waitForSameOriginResponse, +} from './helpers' +import { expect, test } from './workflow-test' + +test('SAML provider lifecycle stays pending and uses scoped management APIs', async ({ + contextForPersona, + personaManifest, + registerCleanup, +}) => { + const ids = primaryWorldIds(personaManifest) + const { context, page } = await newPersonaPage(contextForPersona, 'enterpriseOrganizationAdmin') + const { providerId, providerPrefix } = uniqueSsoProviderId(personaManifest) + const domain = `${providerId}.example.com` + const issuer = `https://issuer-${providerId}.invalid` + const entryPoint = `https://login-${providerId}.invalid/saml` + const updatedIssuer = `https://updated-${providerId}.invalid` + + registerCleanup('remove run-prefixed SSO providers', () => + deleteRunPrefixedSsoProviders(context.request, ids.enterpriseOrganizationId, providerPrefix) + ) + await deleteRunPrefixedSsoProviders(context.request, ids.enterpriseOrganizationId, providerPrefix) + + await page.goto(`/organization/${encodeURIComponent(ids.enterpriseOrganizationId)}/settings/sso`) + let sso = await expectSsoReady(page) + await sso.getByLabel('Provider Type', { exact: true }).click() + await page.getByRole('menuitem', { name: 'SAML', exact: true }).click() + await sso.getByLabel('Provider ID', { exact: true }).fill(providerId) + await sso.getByLabel('Issuer URL', { exact: true }).click() + await sso.getByLabel('Issuer URL', { exact: true }).fill(issuer) + await sso.getByLabel('Domain', { exact: true }).click() + await sso.getByLabel('Domain', { exact: true }).fill(domain) + await sso.getByLabel('Entry Point URL', { exact: true }).fill(entryPoint) + const publicCertificate = rootCertificates[0] + if (!publicCertificate) throw new Error('Node did not expose a public root certificate') + await sso + .getByLabel('Identity Provider Certificate', { exact: true }) + .evaluate((element, certificate) => { + const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set + if (!setter) throw new Error('Textarea value setter is unavailable') + setter.call(element, certificate) + element.dispatchEvent(new Event('input', { bubbles: true })) + }, publicCertificate) + + const createResponsePromise = waitForSameOriginResponse(page, 'POST', '/api/auth/sso/register') + const createResponse = await (async () => { + try { + await page.getByRole('button', { name: 'Save', exact: true }).click() + return await createResponsePromise + } finally { + const certificateInput = page.getByLabel('Identity Provider Certificate', { exact: true }) + if ((await certificateInput.count()) > 0) { + await certificateInput.evaluate((element) => { + const setter = Object.getOwnPropertyDescriptor( + HTMLTextAreaElement.prototype, + 'value' + )?.set + setter?.call(element, '') + }) + } + } + })() + expect(createResponse.status()).toBe(200) + const createRequestBody = createResponse.request().postDataJSON() as Record + const { cert: createCertificate, ...createBody } = createRequestBody + expect(Object.keys(createRequestBody).sort()).toEqual( + [ + 'cert', + 'domain', + 'entryPoint', + 'issuer', + 'mapping', + 'orgId', + 'providerId', + 'providerType', + 'wantAssertionsSigned', + ].sort() + ) + expect(createBody).toMatchObject({ + providerType: 'saml', + providerId, + orgId: ids.enterpriseOrganizationId, + issuer, + domain, + entryPoint, + wantAssertionsSigned: true, + }) + expect(typeof createCertificate).toBe('string') + + sso = await expectSsoReady(page) + await expect(sso.getByLabel('SSO provider status')).toHaveText('Pending verification') + const created = (await listSsoProviders(context.request, ids.enterpriseOrganizationId)).find( + (provider) => provider.providerId === providerId + ) + expect(created).toMatchObject({ + providerId, + providerType: 'saml', + domain, + issuer, + domainVerified: false, + organizationId: ids.enterpriseOrganizationId, + }) + if (!created?.id) throw new Error('Created SSO provider row was not recoverable') + + try { + const expectedInstructionsPath = `/api/auth/sso/providers/${encodeURIComponent(created.id)}/domain-verification/request` + const instructionsResponsePromise = page.waitForResponse((response) => { + const url = new URL(response.url()) + return ( + response.request().method() === 'POST' && + url.origin === new URL(page.url()).origin && + url.pathname.endsWith('/domain-verification/request') + ) + }) + await sso.getByRole('button', { name: 'Show DNS instructions', exact: true }).click() + const instructionsResponse = await instructionsResponsePromise + expect(new URL(instructionsResponse.url()).pathname).toBe(expectedInstructionsPath) + expect(instructionsResponse.status()).toBe(201) + await expect(sso.getByLabel('DNS verification instructions')).toBeVisible() + await expect( + sso.getByText('Add a TXT record with this name and value:', { exact: true }) + ).toBeVisible() + } finally { + const instructions = page.getByLabel('DNS verification instructions') + if ((await instructions.count()) > 0) { + await instructions.evaluate((element) => element.remove()) + } + await page.reload() + } + + sso = await expectSsoReady(page) + await page.getByRole('button', { name: 'Edit', exact: true }).click() + await expect(sso.getByLabel('Provider Type', { exact: true })).toBeDisabled() + await expect(sso.getByLabel('Provider ID', { exact: true })).toBeDisabled() + await sso.getByLabel('Issuer URL', { exact: true }).click() + await sso.getByLabel('Issuer URL', { exact: true }).fill(updatedIssuer) + await page.getByRole('button', { name: 'Discard', exact: true }).click() + await expect(sso.getByText(issuer, { exact: true })).toBeVisible() + expect( + (await listSsoProviders(context.request, ids.enterpriseOrganizationId)).find( + (provider) => provider.id === created.id + )?.issuer + ).toBe(issuer) + + await page.getByRole('button', { name: 'Edit', exact: true }).click() + await sso.getByLabel('Issuer URL', { exact: true }).click() + await sso.getByLabel('Issuer URL', { exact: true }).fill(updatedIssuer) + const updateResponsePromise = waitForSameOriginResponse( + page, + 'PATCH', + `/api/auth/sso/providers/${encodeURIComponent(created.id)}` + ) + let updateResponse: Response | undefined + try { + await page.getByRole('button', { name: 'Update', exact: true }).click() + updateResponse = await updateResponsePromise + } finally { + const certificateInput = page.getByLabel('Identity Provider Certificate', { exact: true }) + if ((await certificateInput.count()) > 0) { + await certificateInput.evaluate((element) => { + const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set + setter?.call(element, '') + }) + } + } + if (!updateResponse) throw new Error('SSO update response was not observed') + expect(updateResponse.status()).toBe(200) + const updateRequestBody = updateResponse.request().postDataJSON() as Record + const { cert: updateCertificate, ...updateBody } = updateRequestBody + expect(typeof updateCertificate).toBe('string') + expect(updateBody).toMatchObject({ + issuer: updatedIssuer, + domain, + entryPoint, + wantAssertionsSigned: true, + }) + expect(updateBody).not.toHaveProperty('providerId') + expect(updateBody).not.toHaveProperty('providerType') + expect(updateBody).not.toHaveProperty('orgId') + await expect((await expectSsoReady(page)).getByText(updatedIssuer, { exact: true })).toBeVisible() + expect( + (await listSsoProviders(context.request, ids.enterpriseOrganizationId)).find( + (provider) => provider.id === created.id + )?.issuer + ).toBe(updatedIssuer) + + sso = await expectSsoReady(page) + await page.getByRole('button', { name: 'Remove', exact: true }).click() + const confirmation = page.getByRole('dialog', { name: 'Remove SSO provider' }) + const deleteResponsePromise = waitForSameOriginResponse( + page, + 'DELETE', + `/api/auth/sso/providers/${encodeURIComponent(created.id)}` + ) + await confirmation.getByRole('button', { name: 'Remove provider', exact: true }).click() + expect((await deleteResponsePromise).status()).toBe(200) + expect( + (await listSsoProviders(context.request, ids.enterpriseOrganizationId)).some( + (provider) => provider.id === created.id + ) + ).toBe(false) +}) diff --git a/apps/sim/e2e/settings/workflows/workflow-test.ts b/apps/sim/e2e/settings/workflows/workflow-test.ts index 026dacc1457..5a4e7457448 100644 --- a/apps/sim/e2e/settings/workflows/workflow-test.ts +++ b/apps/sim/e2e/settings/workflows/workflow-test.ts @@ -21,7 +21,7 @@ function assertWorkflowArtifactPolicy(testInfo: TestInfo): void { const { trace, screenshot, video } = testInfo.project.use if (trace !== 'off' || screenshot !== 'only-on-failure' || video !== 'off') { throw new Error( - 'People workflows must disable trace/video while retaining failure-only screenshots' + 'Settings workflows must disable trace/video while retaining failure-only screenshots' ) } } diff --git a/apps/sim/e2e/support/data-retention.ts b/apps/sim/e2e/support/data-retention.ts new file mode 100644 index 00000000000..a59b4e52a2c --- /dev/null +++ b/apps/sim/e2e/support/data-retention.ts @@ -0,0 +1,139 @@ +import type { + OrganizationDataRetention, + OrganizationRetentionValues, + UpdateOrganizationDataRetentionBody, +} from '@/lib/api/contracts/organization' +import type { RetentionOverride } from '@/lib/api/contracts/primitives' + +export const SETTINGS_PRIMARY_RETENTION_BASELINE = { + logRetentionHours: 30 * 24, + softDeleteRetentionHours: 90 * 24, + taskCleanupHours: 30 * 24, + piiRedaction: null, + retentionOverrides: [], +} satisfies OrganizationRetentionValues + +export const INHERITED_RETENTION_VALUE = 'inherit' +export const FOREVER_RETENTION_VALUE = 'never' + +export type RetentionDayValue = + | typeof INHERITED_RETENTION_VALUE + | typeof FOREVER_RETENTION_VALUE + | `${number}` + +export interface RetentionOverrideDayValues { + logRetention: RetentionDayValue + softDeleteRetention: RetentionDayValue + taskCleanup: RetentionDayValue +} + +type RetentionUpdateFields = Pick< + UpdateOrganizationDataRetentionBody, + 'logRetentionHours' | 'softDeleteRetentionHours' | 'taskCleanupHours' | 'retentionOverrides' +> + +export function captureConfiguredRetention( + retention: Pick +): OrganizationRetentionValues { + return structuredClone(retention.configured) +} + +/** + * Builds the complete E2E retention PUT while deliberately omitting PII. + * The E2E profile keeps PII redaction disabled, and including the key would + * turn an unrelated retention update into a production 403. + */ +export function buildRetentionPutBody( + snapshot: OrganizationRetentionValues, + changes: Partial = {} +): UpdateOrganizationDataRetentionBody { + const merged = { ...snapshot, ...structuredClone(changes) } + const body: UpdateOrganizationDataRetentionBody = { + logRetentionHours: merged.logRetentionHours, + softDeleteRetentionHours: merged.softDeleteRetentionHours, + taskCleanupHours: merged.taskCleanupHours, + } + if (merged.retentionOverrides !== null) { + body.retentionOverrides = structuredClone(merged.retentionOverrides) + } + return body +} + +export function retentionHoursToDayValue( + hours: number | null | undefined, + allowInherited: boolean +): RetentionDayValue { + if (hours === undefined) { + if (allowInherited) return INHERITED_RETENTION_VALUE + throw new Error('Organization retention hours cannot be inherited') + } + if (hours === null) return FOREVER_RETENTION_VALUE + if (!Number.isInteger(hours) || hours % 24 !== 0) { + throw new Error(`Retention hours must be day-aligned, received ${hours}`) + } + return String(hours / 24) as `${number}` +} + +export function retentionDayValueToHours( + value: RetentionDayValue, + allowInherited: boolean +): number | null | undefined { + if (value === INHERITED_RETENTION_VALUE) { + if (allowInherited) return undefined + throw new Error('Organization retention cannot inherit a value') + } + if (value === FOREVER_RETENTION_VALUE) return null + const days = Number(value) + if (!Number.isInteger(days) || days < 1) { + throw new Error(`Invalid retention day value: ${value}`) + } + return days * 24 +} + +export function retentionOverrideToDayValues( + override: RetentionOverride +): RetentionOverrideDayValues { + return { + logRetention: retentionHoursToDayValue(override.logRetentionHours, true), + softDeleteRetention: retentionHoursToDayValue(override.softDeleteRetentionHours, true), + taskCleanup: retentionHoursToDayValue(override.taskCleanupHours, true), + } +} + +export function buildRetentionOverride( + workspaceId: string, + values: RetentionOverrideDayValues +): RetentionOverride { + const override: RetentionOverride = { workspaceId } + const fields = { + logRetentionHours: retentionDayValueToHours(values.logRetention, true), + softDeleteRetentionHours: retentionDayValueToHours(values.softDeleteRetention, true), + taskCleanupHours: retentionDayValueToHours(values.taskCleanup, true), + } + if (fields.logRetentionHours !== undefined) { + override.logRetentionHours = fields.logRetentionHours + } + if (fields.softDeleteRetentionHours !== undefined) { + override.softDeleteRetentionHours = fields.softDeleteRetentionHours + } + if (fields.taskCleanupHours !== undefined) { + override.taskCleanupHours = fields.taskCleanupHours + } + return override +} + +export function assertSettingsPrimaryRetentionBaseline(actual: unknown, label: string): void { + if (!actual || typeof actual !== 'object' || Array.isArray(actual)) { + throw new Error(`${label} is not a retention settings object`) + } + const record = actual as Record + const expected = SETTINGS_PRIMARY_RETENTION_BASELINE as Record + const actualKeys = Object.keys(record).sort() + const expectedKeys = Object.keys(expected).sort() + if ( + JSON.stringify(actualKeys) !== JSON.stringify(expectedKeys) || + expectedKeys.some((key) => JSON.stringify(record[key]) !== JSON.stringify(expected[key])) + ) { + throw new Error(`${label} does not match the exact settings-primary retention baseline`) + } +} diff --git a/apps/sim/e2e/support/deployment-profile.ts b/apps/sim/e2e/support/deployment-profile.ts index ba63d1a89f5..f36dcc7078b 100644 --- a/apps/sim/e2e/support/deployment-profile.ts +++ b/apps/sim/e2e/support/deployment-profile.ts @@ -4,6 +4,7 @@ import type { E2eRuntimeSecrets } from './runtime-secrets' export const E2E_PROFILE = 'hosted-billing-chromium' export const E2E_HOST = 'e2e.sim.ai' +export const E2E_MCP_HOST = 'mcp.e2e.sim.ai' export const E2E_ORIGIN = `http://${E2E_HOST}:3000` export const E2E_SOCKET_ORIGIN = `http://${E2E_HOST}:3002` @@ -29,6 +30,10 @@ const APP_REQUIRED_KEYS = [ 'ADMIN_API_KEY', 'BILLING_ENABLED', 'NEXT_PUBLIC_BILLING_ENABLED', + 'SSO_ENABLED', + 'SSO_DOMAIN_VERIFICATION_ENABLED', + 'NEXT_PUBLIC_SSO_ENABLED', + 'ALLOWED_MCP_DOMAINS', 'STRIPE_SECRET_KEY', 'STRIPE_API_BASE_URL', 'TELEMETRY_ENDPOINT', @@ -75,10 +80,15 @@ const ALLOWED_SENSITIVE_KEYS = new Set([ 'STRIPE_WEBHOOK_SECRET', ]) +export function isHostedProfileSensitiveKey(key: string): boolean { + return ALLOWED_SENSITIVE_KEYS.has(key) +} + export interface HostedBillingProfileOptions { runId: string databaseUrl: string stripeApiBaseUrl: string + mcpServerUrl: string runtimeHomeDirectory: string setupHomeDirectory: string authCaptureHomeDirectory: string @@ -106,6 +116,7 @@ export function createHostedBillingProfile({ runId, databaseUrl, stripeApiBaseUrl, + mcpServerUrl, runtimeHomeDirectory, setupHomeDirectory, authCaptureHomeDirectory, @@ -114,6 +125,7 @@ export function createHostedBillingProfile({ runtimeSecrets, ci, }: HostedBillingProfileOptions): HostedBillingProfile { + validateMcpServerUrl(mcpServerUrl) const values: Record = { NODE_ENV: 'production', NODE_OPTIONS: '--no-warnings --max-old-space-size=8192 --dns-result-order=ipv4first', @@ -140,6 +152,10 @@ export function createHostedBillingProfile({ ADMIN_API_KEY: runtimeSecrets.adminApiKey, BILLING_ENABLED: 'true', NEXT_PUBLIC_BILLING_ENABLED: 'true', + SSO_ENABLED: 'true', + SSO_DOMAIN_VERIFICATION_ENABLED: 'true', + NEXT_PUBLIC_SSO_ENABLED: 'true', + ALLOWED_MCP_DOMAINS: E2E_MCP_HOST, EMAIL_VERIFICATION_ENABLED: 'false', EMAIL_PASSWORD_SIGNUP_ENABLED: 'true', NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: 'true', @@ -291,6 +307,7 @@ export function createHostedBillingProfile({ 'E2E_BASE_URL', 'CI', ]), + E2E_MCP_SERVER_URL: mcpServerUrl, HOME: playwrightHomeDirectory, }, [ @@ -300,6 +317,7 @@ export function createHostedBillingProfile({ 'E2E_PROFILE', 'E2E_RUN_ID', 'E2E_BASE_URL', + 'E2E_MCP_SERVER_URL', ], false ), @@ -347,3 +365,17 @@ function validateProfileValues(values: Record): void { throw new Error('E2E Stripe API must use numeric IPv4 loopback 127.0.0.1') } } + +function validateMcpServerUrl(value: string): void { + const url = new URL(value) + if ( + url.protocol !== 'http:' || + url.hostname !== E2E_MCP_HOST || + url.pathname !== '/mcp' || + url.search || + url.hash || + !url.port + ) { + throw new Error(`E2E MCP server URL must be http://${E2E_MCP_HOST}:/mcp`) + } +} diff --git a/apps/sim/e2e/support/hosts.ts b/apps/sim/e2e/support/hosts.ts index 81e6127755c..6c07bbbd0f3 100644 --- a/apps/sim/e2e/support/hosts.ts +++ b/apps/sim/e2e/support/hosts.ts @@ -1,6 +1,8 @@ import { promises as dns } from 'node:dns' import { isIP } from 'node:net' -import { E2E_HOST } from './deployment-profile' +import { E2E_HOST, E2E_MCP_HOST } from './deployment-profile' + +export const E2E_HOSTS = [E2E_HOST, E2E_MCP_HOST] as const export function isLoopbackAddress(address: string): boolean { if (address === '::1' || address === '0:0:0:0:0:0:0:1') return true @@ -32,10 +34,21 @@ export async function assertE2eHostResolvesToLoopback(hostname = E2E_HOST): Prom return addresses } +export async function assertE2eHostsResolveToLoopback( + hostnames: readonly string[] = E2E_HOSTS +): Promise> { + const resolved = await Promise.all( + hostnames.map( + async (hostname) => [hostname, await assertE2eHostResolvesToLoopback(hostname)] as const + ) + ) + return Object.fromEntries(resolved) +} + function getHostMappingError(hostname: string, addresses: string[]): string { const observed = addresses.length > 0 ? addresses.join(', ') : 'no addresses' return [ `${hostname} must resolve only to loopback and include IPv4 127.0.0.1; observed ${observed}.`, - `Add it once with: echo "127.0.0.1 ${hostname}" | sudo tee -a /etc/hosts`, + `Add the required E2E mappings once with: echo "127.0.0.1 ${E2E_HOST} ${E2E_MCP_HOST}" | sudo tee -a /etc/hosts`, ].join(' ') } diff --git a/apps/sim/e2e/support/mcp-requests.ts b/apps/sim/e2e/support/mcp-requests.ts new file mode 100644 index 00000000000..35ae0df08cd --- /dev/null +++ b/apps/sim/e2e/support/mcp-requests.ts @@ -0,0 +1,58 @@ +import { writeFileSync } from 'node:fs' +import path from 'node:path' +import type { McpFakeRequestRecord } from '../fakes/mcp/server' + +export const MCP_REQUEST_LOG_NAME = 'mcp-requests.json' + +export function writeMcpRequestLog( + logsDirectory: string, + records: readonly McpFakeRequestRecord[] +): void { + writeFileSync(path.join(logsDirectory, MCP_REQUEST_LOG_NAME), JSON.stringify(records, null, 2)) +} + +export function assertValidMcpFakeTraffic( + records: readonly McpFakeRequestRecord[], + workflowComplete: boolean +): void { + const unexpected = records.filter((record) => record.unexpected) + if (unexpected.length > 0) { + throw new Error( + `MCP fake received unsupported requests: ${unexpected + .map( + ({ method, path, rpcMethod }) => `${method} ${path}${rpcMethod ? ` (${rpcMethod})` : ''}` + ) + .join(', ')}` + ) + } + if (!workflowComplete) return + + const sessions = new Set(records.map((record) => record.session).filter(Boolean)) + for (const session of sessions) { + const sessionRecords = records.filter((record) => record.session === session) + const initializeIndex = sessionRecords.findIndex( + (record) => record.rpcMethod === 'initialize' && record.status === 200 + ) + const initializedIndex = sessionRecords.findIndex( + (record, index) => + index > initializeIndex && + record.rpcMethod === 'notifications/initialized' && + record.status === 202 + ) + const toolsListIndex = sessionRecords.findIndex( + (record, index) => + index > initializedIndex && record.rpcMethod === 'tools/list' && record.status === 200 + ) + if ( + initializeIndex >= 0 && + initializedIndex > initializeIndex && + toolsListIndex > initializedIndex + ) { + return + } + } + + throw new Error( + 'Completed MCP workflow did not produce initialize → initialized → tools/list in one session' + ) +} diff --git a/apps/sim/e2e/support/probes.ts b/apps/sim/e2e/support/probes.ts index 4dbc0b7140c..cc9535233bf 100644 --- a/apps/sim/e2e/support/probes.ts +++ b/apps/sim/e2e/support/probes.ts @@ -1,5 +1,6 @@ import postgres from 'postgres' import { readScenarioManifest } from '../fixtures/e2e-world' +import { assertSettingsPrimaryRetentionBaseline } from './data-retention' export async function assertAdminApiBoundary(origin: string, adminKey: string): Promise { const endpoint = `${origin}/api/v1/admin/users?limit=1&offset=0` @@ -79,3 +80,31 @@ export async function assertManifestWorkspaceIdentities( await sql.end() } } + +export async function assertSettingsPrimaryRetentionRestored( + databaseUrl: string, + manifestPath: string +): Promise { + const manifest = readScenarioManifest(manifestPath) + const organizationId = + manifest.worlds['settings-primary']?.organizationIds['enterprise-organization'] + if (!organizationId) { + throw new Error('Settings-primary Enterprise organization is missing from the manifest') + } + + const sql = postgres(databaseUrl, { max: 1, connect_timeout: 10 }) + try { + const [row] = await sql>` + SELECT data_retention_settings AS "dataRetentionSettings" + FROM organization + WHERE id = ${organizationId} + LIMIT 1 + ` + assertSettingsPrimaryRetentionBaseline( + row?.dataRetentionSettings, + 'Post-Playwright settings-primary retention probe' + ) + } finally { + await sql.end() + } +} diff --git a/apps/sim/ee/data-retention/components/data-retention-e2e-helpers.test.ts b/apps/sim/ee/data-retention/components/data-retention-e2e-helpers.test.ts new file mode 100644 index 00000000000..4d4307cdad8 --- /dev/null +++ b/apps/sim/ee/data-retention/components/data-retention-e2e-helpers.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest' +import type { OrganizationDataRetention } from '@/lib/api/contracts/organization' +import { + buildRetentionOverride, + buildRetentionPutBody, + captureConfiguredRetention, + retentionHoursToDayValue, + retentionOverrideToDayValues, + SETTINGS_PRIMARY_RETENTION_BASELINE, +} from '@/e2e/support/data-retention' + +describe('data retention E2E helpers', () => { + it('captures a detached full configured snapshot', () => { + const configured = { + logRetentionHours: 721, + softDeleteRetentionHours: null, + taskCleanupHours: 2160, + piiRedaction: null, + retentionOverrides: [ + { + workspaceId: 'workspace-1', + logRetentionHours: 336, + }, + ], + } + const snapshot = captureConfiguredRetention({ + configured, + } as Pick) + + configured.retentionOverrides[0].logRetentionHours = 720 + expect(snapshot).toEqual({ + logRetentionHours: 721, + softDeleteRetentionHours: null, + taskCleanupHours: 2160, + piiRedaction: null, + retentionOverrides: [{ workspaceId: 'workspace-1', logRetentionHours: 336 }], + }) + expect(buildRetentionPutBody(snapshot)).toEqual({ + logRetentionHours: 721, + softDeleteRetentionHours: null, + taskCleanupHours: 2160, + retentionOverrides: [{ workspaceId: 'workspace-1', logRetentionHours: 336 }], + }) + }) + + it('preserves unrelated fields and omits PII from every E2E PUT', () => { + const snapshot = { + logRetentionHours: 720, + softDeleteRetentionHours: 2160, + taskCleanupHours: 720, + piiRedaction: { rules: [] }, + retentionOverrides: [ + { + workspaceId: 'workspace-1', + logRetentionHours: 168, + }, + ], + } satisfies OrganizationDataRetention['configured'] + + const body = buildRetentionPutBody(snapshot, { logRetentionHours: 1440 }) + + expect(body).toEqual({ + logRetentionHours: 1440, + softDeleteRetentionHours: 2160, + taskCleanupHours: 720, + retentionOverrides: [{ workspaceId: 'workspace-1', logRetentionHours: 168 }], + }) + expect(body).not.toHaveProperty('piiRedaction') + }) + + it('round-trips a concrete override while leaving inherited fields absent', () => { + const override = buildRetentionOverride('workspace-1', { + logRetention: '14', + softDeleteRetention: 'inherit', + taskCleanup: 'inherit', + }) + + expect(override).toEqual({ workspaceId: 'workspace-1', logRetentionHours: 336 }) + expect(retentionOverrideToDayValues(override)).toEqual({ + logRetention: '14', + softDeleteRetention: 'inherit', + taskCleanup: 'inherit', + }) + }) + + it('constructs an exact baseline restoration body without PII', () => { + expect(buildRetentionPutBody(SETTINGS_PRIMARY_RETENTION_BASELINE)).toEqual({ + logRetentionHours: 720, + softDeleteRetentionHours: 2160, + taskCleanupHours: 720, + retentionOverrides: [], + }) + }) + + it('rejects lossy conversion of non-day-aligned hours', () => { + expect(() => retentionHoursToDayValue(25, false)).toThrow('Retention hours must be day-aligned') + }) +}) diff --git a/apps/sim/ee/data-retention/components/data-retention-settings.tsx b/apps/sim/ee/data-retention/components/data-retention-settings.tsx index 666088d3a69..90400ee93fb 100644 --- a/apps/sim/ee/data-retention/components/data-retention-settings.tsx +++ b/apps/sim/ee/data-retention/components/data-retention-settings.tsx @@ -20,7 +20,6 @@ import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { ArrowRight, Plus } from 'lucide-react' import { CustomPatternsEditor } from '@/components/pii/custom-patterns-editor' -import type { UpdateOrganizationDataRetentionBody } from '@/lib/api/contracts/organization' import type { RetentionOverride } from '@/lib/api/contracts/primitives' import { isBillingEnabled } from '@/lib/core/config/env-flags' import { @@ -47,6 +46,10 @@ import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/comp import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard' +import { + buildDataRetentionUpdateSettings, + resolveDataRetentionState, +} from '@/ee/data-retention/components/data-retention-state' import { useOrganizationRetention, useUpdateOrganizationRetention, @@ -228,13 +231,19 @@ function dayValueLabel(days: string): string { } interface RetentionSelectProps { + ariaLabel: string value: string onChange: (value: string) => void /** Prepend an "Inherit from organization" option (workspace-override fields). */ allowInherit?: boolean } -function RetentionSelect({ value, onChange, allowInherit = false }: RetentionSelectProps) { +function RetentionSelect({ + ariaLabel, + value, + onChange, + allowInherit = false, +}: RetentionSelectProps) { const base = DAY_OPTIONS.map((o) => ({ value: o.value, label: o.label })) const withInherit = allowInherit ? [{ value: INHERIT, label: 'Inherit from organization' }, ...base] @@ -244,7 +253,15 @@ function RetentionSelect({ value, onChange, allowInherit = false }: RetentionSel ? withInherit : [...withInherit, { value, label: `${value} days (custom)` }] - return + return ( + + ) } interface EntityCheckboxGridProps { @@ -420,6 +437,7 @@ interface PolicyDetailProps { isNew: boolean changed: boolean isSaving: boolean + actionsEnabled: boolean piiEnabled: boolean piiGranularEnabled: boolean canRemove: boolean @@ -436,6 +454,7 @@ function PolicyDetail({ isNew, changed, isSaving, + actionsEnabled, piiEnabled, piiGranularEnabled, canRemove, @@ -485,7 +504,7 @@ function PolicyDetail({ saving: isSaving, onSave, onDiscard, - saveDisabled: !isOrg && draft.workspaceIds.length === 0, + saveDisabled: !actionsEnabled || (!isOrg && draft.workspaceIds.length === 0), }), ...(canRemove ? [ @@ -493,7 +512,7 @@ function PolicyDetail({ text: 'Remove override', variant: 'destructive', onSelect: () => setShowRemoveConfirm(true), - disabled: isSaving, + disabled: !actionsEnabled || isSaving, } satisfies SettingsAction, ] : []), @@ -525,6 +544,7 @@ function PolicyDetail({
Log retention onChange({ ...draft, logDays })} @@ -533,6 +553,7 @@ function PolicyDetail({
Soft deletion cleanup onChange({ ...draft, softDeleteDays })} @@ -541,6 +562,7 @@ function PolicyDetail({
Task cleanup onChange({ ...draft, taskCleanupDays })} @@ -664,9 +686,20 @@ interface DataRetentionSettingsProps { } export function DataRetentionSettings({ organizationId: orgId }: DataRetentionSettingsProps) { - const { data, isLoading: retentionLoading } = useOrganizationRetention(orgId) + const retentionQuery = useOrganizationRetention(orgId) + const { data } = retentionQuery const updateMutation = useUpdateOrganizationRetention() - const { data: workspaces } = useWorkspacesQuery(Boolean(orgId)) + const workspacesQuery = useWorkspacesQuery(Boolean(orgId)) + const workspaces = workspacesQuery.data + const dataState = resolveDataRetentionState({ + retentionSuccess: retentionQuery.isSuccess, + retentionError: retentionQuery.isError, + retentionPlaceholder: retentionQuery.isPlaceholderData, + workspacesSuccess: workspacesQuery.isSuccess, + workspacesError: workspacesQuery.isError, + workspacesPlaceholder: workspacesQuery.isPlaceholderData, + }) + const actionsEnabled = dataState === 'ready' const workspaceOptions = (workspaces ?? []) .filter((w) => w.organizationId === orgId) .map((w) => ({ value: w.id, label: w.name })) @@ -686,7 +719,7 @@ export function DataRetentionSettings({ organizationId: orgId }: DataRetentionSe const hydratedOrgRef = useRef(null) useEffect(() => { - if (!data || !orgId || hydratedOrgRef.current === orgId) return + if (dataState !== 'ready' || !data || !orgId || hydratedOrgRef.current === orgId) return setLogDays(hoursToDisplayDays(data.effective.logRetentionHours)) setSoftDeleteDays(hoursToDisplayDays(data.effective.softDeleteRetentionHours)) setTaskCleanupDays(hoursToDisplayDays(data.effective.taskCleanupHours)) @@ -707,13 +740,17 @@ export function DataRetentionSettings({ organizationId: orgId }: DataRetentionSe ) setOverrides(data.configured.retentionOverrides ?? []) hydratedOrgRef.current = orgId - }, [data, orgId]) + }, [data, dataState, orgId]) const editingChanged = editing !== null && normalizePolicyDraft(editing.draft) !== normalizePolicyDraft(editing.original) const guard = useSettingsUnsavedGuard({ isDirty: editingChanged }) + useEffect(() => { + if (dataState !== 'ready') guard.setShowUnsavedModal(false) + }, [dataState, guard.setShowUnsavedModal]) + const overrideWorkspaceIds = Array.from( new Set([ ...overrides.map((o) => o.workspaceId), @@ -757,11 +794,7 @@ export function DataRetentionSettings({ organizationId: orgId }: DataRetentionSe return parts.join(' · ') } - /** - * Persist a full snapshot of org hours + PII rules + retention overrides in - * one PUT. The route replaces each provided key, so always sending the whole - * state keeps the three editable surfaces consistent. - */ + /** Persist the complete editable snapshot, including PII only when enabled. */ async function persistSnapshot(next: { logDays: string softDeleteDays: string @@ -770,29 +803,32 @@ export function DataRetentionSettings({ organizationId: orgId }: DataRetentionSe piiOverrides: PiiOverride[] overrides: RetentionOverride[] }) { - if (!orgId) return - const settings: UpdateOrganizationDataRetentionBody = { - logRetentionHours: daysToHours(next.logDays), - softDeleteRetentionHours: daysToHours(next.softDeleteDays), - taskCleanupHours: daysToHours(next.taskCleanupDays), - retentionOverrides: next.overrides, + if (!orgId || dataState !== 'ready') { + throw new Error('Data retention settings are not ready') } - if (piiEnabled) { - const rules: { id: string; workspaceId: string | null; stages: PiiStages }[] = - next.piiOverrides.map((p) => ({ - id: p.id, - workspaceId: p.workspaceId, - stages: withSyncedEnabled(p.stages), - })) - if (next.defaultPii) { - rules.unshift({ - id: next.defaultPii.id, - workspaceId: null, - stages: withSyncedEnabled(next.defaultPii.stages), - }) - } - settings.piiRedaction = { rules } + const rules: { id: string; workspaceId: string | null; stages: PiiStages }[] = + next.piiOverrides.map((p) => ({ + id: p.id, + workspaceId: p.workspaceId, + stages: withSyncedEnabled(p.stages), + })) + if (next.defaultPii) { + rules.unshift({ + id: next.defaultPii.id, + workspaceId: null, + stages: withSyncedEnabled(next.defaultPii.stages), + }) } + const settings = buildDataRetentionUpdateSettings( + { + logRetentionHours: daysToHours(next.logDays), + softDeleteRetentionHours: daysToHours(next.softDeleteDays), + taskCleanupHours: daysToHours(next.taskCleanupDays), + retentionOverrides: next.overrides, + piiRedaction: { rules }, + }, + piiEnabled + ) await updateMutation.mutateAsync({ orgId, settings }) setLogDays(next.logDays) setSoftDeleteDays(next.softDeleteDays) @@ -859,7 +895,7 @@ export function DataRetentionSettings({ organizationId: orgId }: DataRetentionSe } async function savePolicy() { - if (!editing) return + if (!editing || dataState !== 'ready') return const draft = editing.draft try { if (draft.isOrgDefault) { @@ -912,7 +948,7 @@ export function DataRetentionSettings({ organizationId: orgId }: DataRetentionSe } async function removeCurrentOverride() { - if (!editing || editing.draft.isOrgDefault) return + if (!editing || editing.draft.isOrgDefault || dataState !== 'ready') return const idSet = new Set(editing.original.workspaceIds) try { await persistSnapshot({ @@ -929,26 +965,37 @@ export function DataRetentionSettings({ organizationId: orgId }: DataRetentionSe } } - if (retentionLoading) return null + if (dataState === 'loading') { + return
+ } - if (!data) { - return Failed to load data retention settings. + if (dataState === 'error' || !data) { + return ( +
+ Failed to load data retention settings. +
+ ) } if (isBillingEnabled && !data.isEnterprise) { return ( - Data retention is available on Enterprise plans only. +
+ + Data retention is available on Enterprise plans only. + +
) } return ( - <> +
{editing ? ( @@ -975,6 +1022,8 @@ export function DataRetentionSettings({ organizationId: orgId }: DataRetentionSe
) } diff --git a/apps/sim/ee/data-retention/components/data-retention-state.test.ts b/apps/sim/ee/data-retention/components/data-retention-state.test.ts new file mode 100644 index 00000000000..29c25cf00ba --- /dev/null +++ b/apps/sim/ee/data-retention/components/data-retention-state.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { + buildDataRetentionUpdateSettings, + type DataRetentionQueryReadiness, + resolveDataRetentionState, +} from '@/ee/data-retention/components/data-retention-state' + +const ready = { + retentionSuccess: true, + retentionError: false, + retentionPlaceholder: false, + workspacesSuccess: true, + workspacesError: false, + workspacesPlaceholder: false, +} satisfies DataRetentionQueryReadiness + +describe('resolveDataRetentionState', () => { + it('requires successful non-placeholder retention and workspace queries', () => { + expect(resolveDataRetentionState({ ...ready, retentionSuccess: false })).toBe('loading') + expect(resolveDataRetentionState({ ...ready, workspacesSuccess: false })).toBe('loading') + expect(resolveDataRetentionState({ ...ready, workspacesPlaceholder: true })).toBe('loading') + }) + + it('gives errors precedence over stale or placeholder data', () => { + expect( + resolveDataRetentionState({ + ...ready, + retentionSuccess: false, + retentionPlaceholder: true, + workspacesError: true, + }) + ).toBe('error') + }) + + it('reports ready only when both query boundaries are usable', () => { + expect(resolveDataRetentionState(ready)).toBe('ready') + }) +}) + +describe('buildDataRetentionUpdateSettings', () => { + const values = { + logRetentionHours: 720, + softDeleteRetentionHours: 2160, + taskCleanupHours: 720, + retentionOverrides: [], + piiRedaction: { rules: [] }, + } + + it('omits the PII key entirely while the feature is disabled', () => { + const settings = buildDataRetentionUpdateSettings(values, false) + + expect(settings).toEqual({ + logRetentionHours: 720, + softDeleteRetentionHours: 2160, + taskCleanupHours: 720, + retentionOverrides: [], + }) + expect(settings).not.toHaveProperty('piiRedaction') + }) + + it('includes PII only while the feature is enabled', () => { + expect(buildDataRetentionUpdateSettings(values, true)).toHaveProperty('piiRedaction', { + rules: [], + }) + }) +}) diff --git a/apps/sim/ee/data-retention/components/data-retention-state.ts b/apps/sim/ee/data-retention/components/data-retention-state.ts new file mode 100644 index 00000000000..3c370816ed4 --- /dev/null +++ b/apps/sim/ee/data-retention/components/data-retention-state.ts @@ -0,0 +1,50 @@ +import type { UpdateOrganizationDataRetentionBody } from '@/lib/api/contracts/organization' +import type { PiiRedactionSettings, RetentionOverride } from '@/lib/api/contracts/primitives' + +export type DataRetentionState = 'loading' | 'error' | 'ready' + +export interface DataRetentionQueryReadiness { + retentionSuccess: boolean + retentionError: boolean + retentionPlaceholder: boolean + workspacesSuccess: boolean + workspacesError: boolean + workspacesPlaceholder: boolean +} + +export function resolveDataRetentionState( + readiness: DataRetentionQueryReadiness +): DataRetentionState { + if (readiness.retentionError || readiness.workspacesError) return 'error' + if ( + !readiness.retentionSuccess || + readiness.retentionPlaceholder || + !readiness.workspacesSuccess || + readiness.workspacesPlaceholder + ) { + return 'loading' + } + return 'ready' +} + +interface RetentionUpdateValues { + logRetentionHours: number | null + softDeleteRetentionHours: number | null + taskCleanupHours: number | null + retentionOverrides: RetentionOverride[] + piiRedaction: PiiRedactionSettings +} + +export function buildDataRetentionUpdateSettings( + values: RetentionUpdateValues, + piiEnabled: boolean +): UpdateOrganizationDataRetentionBody { + const settings: UpdateOrganizationDataRetentionBody = { + logRetentionHours: values.logRetentionHours, + softDeleteRetentionHours: values.softDeleteRetentionHours, + taskCleanupHours: values.taskCleanupHours, + retentionOverrides: values.retentionOverrides, + } + if (piiEnabled) settings.piiRedaction = values.piiRedaction + return settings +} diff --git a/apps/sim/ee/sso/components/sso-settings.test.tsx b/apps/sim/ee/sso/components/sso-settings.test.tsx index d9f4217280d..a607e8b815d 100644 --- a/apps/sim/ee/sso/components/sso-settings.test.tsx +++ b/apps/sim/ee/sso/components/sso-settings.test.tsx @@ -5,13 +5,21 @@ import { act, type ChangeEventHandler, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockUseConfigureSSO, mockUseOrganizationBilling, mockUseSession, mockUseSSOProviders } = - vi.hoisted(() => ({ - mockUseConfigureSSO: vi.fn(), - mockUseOrganizationBilling: vi.fn(), - mockUseSession: vi.fn(), - mockUseSSOProviders: vi.fn(), - })) +const { + mockCreateMutate, + mockDeleteMutate, + mockUpdateMutate, + mockUseOrganizationBilling, + mockUseSession, + mockUseSSOProviders, +} = vi.hoisted(() => ({ + mockCreateMutate: vi.fn(), + mockDeleteMutate: vi.fn(), + mockUpdateMutate: vi.fn(), + mockUseOrganizationBilling: vi.fn(), + mockUseSession: vi.fn(), + mockUseSSOProviders: vi.fn(), +})) vi.mock('@sim/emcn', () => ({ Button: ({ children, ...props }: { children?: ReactNode }) => ( @@ -19,7 +27,16 @@ vi.mock('@sim/emcn', () => ({ {children} ), - ChipCombobox: () =>
, + ChipCombobox: ({ value, disabled }: { value?: string; disabled?: boolean }) => ( + + ), + ChipConfirmModal: ({ + open, + confirm, + }: { + open?: boolean + confirm: { label: string; onClick: () => void } + }) => (open ? : null), ChipInput: ({ value, onChange, @@ -27,7 +44,9 @@ vi.mock('@sim/emcn', () => ({ value?: string onChange?: ChangeEventHandler }) => , - ChipSelect: () =>
, + ChipSelect: ({ disabled }: { disabled?: boolean }) => ( + - } - /> -

- Configure this in your identity provider -

- -
- + return ( +
+ +
+ {!canConfigureProvider ? ( +

+ This organization no longer has an Enterprise plan. The existing SSO provider is + read-only; only provider removal remains available. +

+ ) : null} + +

+ {existingProvider.domainVerified ? 'Active' : 'Pending verification'} +

+
+ + +

{existingProvider.providerId}

+
+ + +

+ {existingProvider.providerType.toUpperCase()} +

+
+ + +

{existingProvider.domain}

+
+ + +

+ {existingProvider.issuer} +

+
+ + + copyToClipboard(providerCallbackUrl)} + className='size-6 p-0 text-[var(--text-icon)] hover:text-[var(--text-primary)]' + aria-label='Copy callback URL' + > + {copied ? ( + + ) : ( + + )} + + } + /> +

+ Configure this in your identity provider +

+
+ + {canConfigureProvider && + !existingProvider.domainVerified && + existingProvider.canManageVerification ? ( + +
+ {verificationDetails ? ( +
+

+ Add a TXT record with this name and value: +

+

+ Name: {verificationDetails.recordName} +

+

+ Value: {verificationDetails.recordValue} +

+
+ ) : null} +
+ + +
+
+
+ ) : null} +
+
+ +
) } return ( -
+ handleInputChange('providerType', value as 'oidc' | 'saml') } @@ -549,7 +730,9 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) { > handleInputChange('providerId', value)} + inputProps={{ 'aria-label': 'Provider ID' }} options={SSO_TRUSTED_PROVIDERS.map((id) => ({ label: id, value: id, @@ -566,6 +749,7 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) { } > 0 ? errors.domain.join(' ') : undefined} > 0 ? errors.scopes.join(' ') : undefined} > 0 ? errors.cert.join(' ') : undefined} > ({ requestJson: mockRequestJson, })) -import { listSsoProvidersContract } from '@/lib/api/contracts/auth' -import { useSSOProviders } from '@/ee/sso/hooks/sso' +import { listSsoProvidersContract, updateSsoProviderContract } from '@/lib/api/contracts/auth' +import { useSSOProviders, useUpdateSSOProvider } from '@/ee/sso/hooks/sso' interface SsoProvidersResponse { providers: Array<{ @@ -53,6 +53,32 @@ function SsoProbe({ organizationId }: { organizationId: string }) { ) } +function UpdateProbe() { + const update = useUpdateSSOProvider() + return ( + + ) +} + function renderSso(organizationId: string) { act(() => { root.render( @@ -119,4 +145,37 @@ describe('useSSOProviders identity transitions', () => { expect.objectContaining({ query: { organizationId: 'org-b' } }) ) }) + + it('uses the PATCH contract and invalidates provider and organization caches', async () => { + mockRequestJson.mockResolvedValue({ + success: true, + providerId: 'provider-a', + providerType: 'oidc', + domainVerified: false, + message: 'updated', + }) + const invalidate = vi.spyOn(queryClient, 'invalidateQueries') + act(() => { + root.render( + + + + ) + }) + + act(() => { + container.querySelector('button')?.click() + }) + await flushQueries() + + expect(mockRequestJson).toHaveBeenCalledWith( + updateSsoProviderContract, + expect.objectContaining({ params: { id: 'row-1' } }) + ) + expect(invalidate).toHaveBeenCalledWith({ queryKey: ['sso', 'providers'] }) + expect(invalidate).toHaveBeenCalledWith({ + queryKey: ['organizations', 'detail', 'org-a'], + }) + expect(invalidate).toHaveBeenCalledWith({ queryKey: ['organizations', 'list'] }) + }) }) diff --git a/apps/sim/ee/sso/hooks/sso.ts b/apps/sim/ee/sso/hooks/sso.ts index 725efcf1cc1..1088a0a7a22 100644 --- a/apps/sim/ee/sso/hooks/sso.ts +++ b/apps/sim/ee/sso/hooks/sso.ts @@ -3,15 +3,17 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { requestJson } from '@/lib/api/client/request' import { + deleteSsoProviderContract, listSsoProvidersContract, + requestSsoDomainVerificationContract, type SsoRegistrationBody, + type SsoUpdateBody, ssoRegistrationContract, + updateSsoProviderContract, + verifySsoDomainContract, } from '@/lib/api/contracts/auth' import { organizationKeys } from '@/hooks/queries/organization' -/** - * Query key factories for SSO-related queries - */ export const ssoKeys = { all: ['sso'] as const, providers: () => [...ssoKeys.all, 'providers'] as const, @@ -19,9 +21,6 @@ export const ssoKeys = { [...ssoKeys.providers(), organizationId ?? ''] as const, } -/** - * Fetch SSO providers - */ async function fetchSSOProviders(signal: AbortSignal, organizationId?: string) { return requestJson(listSsoProvidersContract, { query: organizationId ? { organizationId } : {}, @@ -29,9 +28,6 @@ async function fetchSSOProviders(signal: AbortSignal, organizationId?: string) { }) } -/** - * Hook to fetch SSO providers - */ interface UseSSOProvidersOptions { enabled?: boolean organizationId?: string @@ -46,27 +42,88 @@ export function useSSOProviders({ enabled = true, organizationId }: UseSSOProvid }) } -/** - * Configure SSO provider mutation - */ -type ConfigureSSOParams = Record +function invalidateSSOQueries( + queryClient: ReturnType, + organizationId: string +) { + queryClient.invalidateQueries({ queryKey: ssoKeys.providers() }) + queryClient.invalidateQueries({ queryKey: organizationKeys.detail(organizationId) }) + queryClient.invalidateQueries({ queryKey: organizationKeys.lists() }) +} -export function useConfigureSSO() { +export function useCreateSSOProvider() { const queryClient = useQueryClient() - return useMutation({ - mutationFn: (config: ConfigureSSOParams) => + mutationFn: (body: SsoRegistrationBody) => requestJson(ssoRegistrationContract, { - body: config as SsoRegistrationBody, + body, }), onSettled: (_data, _error, variables) => { - queryClient.invalidateQueries({ queryKey: ssoKeys.providers() }) + invalidateSSOQueries(queryClient, variables.orgId) + }, + }) +} - const orgId = typeof variables.orgId === 'string' ? variables.orgId : undefined - if (orgId) { - queryClient.invalidateQueries({ queryKey: organizationKeys.detail(orgId) }) - queryClient.invalidateQueries({ queryKey: organizationKeys.lists() }) - } +interface UpdateSSOProviderVariables { + id: string + organizationId: string + body: SsoUpdateBody +} + +export function useUpdateSSOProvider() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ id, body }: UpdateSSOProviderVariables) => + requestJson(updateSsoProviderContract, { + params: { id }, + body, + }), + onSettled: (_data, _error, variables) => { + invalidateSSOQueries(queryClient, variables.organizationId) + }, + }) +} + +interface ProviderActionVariables { + id: string + organizationId: string +} + +export function useDeleteSSOProvider() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ id }: ProviderActionVariables) => + requestJson(deleteSsoProviderContract, { + params: { id }, + }), + onSettled: (_data, _error, variables) => { + invalidateSSOQueries(queryClient, variables.organizationId) + }, + }) +} + +export function useRequestSSODomainVerification() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ id }: ProviderActionVariables) => + requestJson(requestSsoDomainVerificationContract, { + params: { id }, + }), + onSettled: (_data, _error, variables) => { + invalidateSSOQueries(queryClient, variables.organizationId) + }, + }) +} + +export function useVerifySSODomain() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ id }: ProviderActionVariables) => + requestJson(verifySsoDomainContract, { + params: { id }, + }), + onSettled: (_data, _error, variables) => { + invalidateSSOQueries(queryClient, variables.organizationId) }, }) } diff --git a/apps/sim/lib/api/contracts/auth.test.ts b/apps/sim/lib/api/contracts/auth.test.ts new file mode 100644 index 00000000000..b3c7cfedfbe --- /dev/null +++ b/apps/sim/lib/api/contracts/auth.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { + deleteSsoProviderContract, + requestSsoDomainVerificationContract, + updateSsoProviderContract, + verifySsoDomainContract, +} from './auth' + +describe('SSO route contracts', () => { + it.each([ + updateSsoProviderContract, + deleteSsoProviderContract, + requestSsoDomainVerificationContract, + verifySsoDomainContract, + ])('uses Next.js-style dynamic parameters for $method $path', (contract) => { + expect(contract.path).toContain('[id]') + expect(contract.path).not.toContain(':id') + }) +}) diff --git a/apps/sim/lib/api/contracts/auth.ts b/apps/sim/lib/api/contracts/auth.ts index 62966d70326..b7227c012dc 100644 --- a/apps/sim/lib/api/contracts/auth.ts +++ b/apps/sim/lib/api/contracts/auth.ts @@ -27,54 +27,92 @@ const ssoMappingSchema = z image: 'picture', }) +const ssoProviderIdSchema = z + .string() + .min(1, 'Provider ID is required') + .max(44, 'Provider ID must be 44 characters or fewer') + .regex(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/, 'Use lowercase letters, numbers, and dashes') + +const webUrlSchema = (message: string) => + z + .string() + .url(message) + .refine( + (value) => URL.canParse(value) && ['http:', 'https:'].includes(new URL(value).protocol), + message + ) + +const ssoCommonConfigurationSchema = { + issuer: webUrlSchema('Issuer must be a valid HTTP(S) URL'), + domain: z.string().min(1, 'Domain is required').max(253, 'Domain is too long'), + mapping: ssoMappingSchema, +} + +const ssoOidcConfigurationSchema = { + ...ssoCommonConfigurationSchema, + clientId: z.string().min(1, 'Client ID is required for OIDC'), + clientSecret: z.string().min(1, 'Client Secret is required for OIDC'), + scopes: z + .union([ + z.string().transform((s) => + s + .split(',') + .map((value) => value.trim()) + .filter((value) => value !== '') + ), + z.array(z.string()), + ]) + .default(['openid', 'profile', 'email']), + pkce: z.boolean().default(true), + authorizationEndpoint: webUrlSchema('Authorization endpoint must be HTTP(S)').optional(), + tokenEndpoint: webUrlSchema('Token endpoint must be HTTP(S)').optional(), + userInfoEndpoint: webUrlSchema('User info endpoint must be HTTP(S)').optional(), + skipUserInfoEndpoint: z.boolean().default(false), + jwksEndpoint: webUrlSchema('JWKS endpoint must be HTTP(S)').optional(), +} + +const ssoSamlConfigurationSchema = { + ...ssoCommonConfigurationSchema, + entryPoint: webUrlSchema('Entry point must be a valid HTTP(S) URL for SAML'), + cert: z.string().min(1, 'Certificate is required for SAML'), + callbackUrl: webUrlSchema('Callback URL must be HTTP(S)').optional(), + audience: z.string().optional(), + wantAssertionsSigned: z.boolean().optional(), + signatureAlgorithm: z.string().optional(), + digestAlgorithm: z.string().optional(), + identifierFormat: z.string().optional(), + idpMetadata: z.string().optional(), +} + export const ssoRegistrationBodySchema = z.discriminatedUnion('providerType', [ - z.object({ - providerType: z.literal('oidc').default('oidc'), - providerId: z.string().min(1, 'Provider ID is required'), - issuer: z.string().url('Issuer must be a valid URL'), - domain: z.string().min(1, 'Domain is required'), - orgId: z.string().optional(), - mapping: ssoMappingSchema, - clientId: z.string().min(1, 'Client ID is required for OIDC'), - clientSecret: z.string().min(1, 'Client Secret is required for OIDC'), - scopes: z - .union([ - z.string().transform((s) => - s - .split(',') - .map((value) => value.trim()) - .filter((value) => value !== '') - ), - z.array(z.string()), - ]) - .default(['openid', 'profile', 'email']), - pkce: z.boolean().default(true), - authorizationEndpoint: z.string().url().optional(), - tokenEndpoint: z.string().url().optional(), - userInfoEndpoint: z.string().url().optional(), - skipUserInfoEndpoint: z.boolean().default(false), - jwksEndpoint: z.string().url().optional(), - }), - z.object({ - providerType: z.literal('saml'), - providerId: z.string().min(1, 'Provider ID is required'), - issuer: z.string().url('Issuer must be a valid URL'), - domain: z.string().min(1, 'Domain is required'), - orgId: z.string().optional(), - mapping: ssoMappingSchema, - entryPoint: z.string().url('Entry point must be a valid URL for SAML'), - cert: z.string().min(1, 'Certificate is required for SAML'), - callbackUrl: z.string().url().optional(), - audience: z.string().optional(), - wantAssertionsSigned: z.boolean().optional(), - signatureAlgorithm: z.string().optional(), - digestAlgorithm: z.string().optional(), - identifierFormat: z.string().optional(), - idpMetadata: z.string().optional(), - }), + z + .object({ + providerType: z.literal('oidc').default('oidc'), + providerId: ssoProviderIdSchema, + orgId: z.string().min(1, 'Organization ID is required'), + ...ssoOidcConfigurationSchema, + }) + .strict(), + z + .object({ + providerType: z.literal('saml'), + providerId: ssoProviderIdSchema, + orgId: z.string().min(1, 'Organization ID is required'), + ...ssoSamlConfigurationSchema, + }) + .strict(), ]) export type SsoRegistrationBody = z.input +export type SsoRegistrationData = z.output + +const ssoMutationResponseSchema = z.object({ + success: z.literal(true), + providerId: z.string(), + providerType: z.enum(['oidc', 'saml']), + domainVerified: z.boolean(), + message: z.string(), +}) export const ssoRegistrationContract = defineRouteContract({ method: 'POST', @@ -82,12 +120,40 @@ export const ssoRegistrationContract = defineRouteContract({ body: ssoRegistrationBodySchema, response: { mode: 'json', - schema: z.object({ - success: z.literal(true), - providerId: z.string(), - providerType: z.enum(['oidc', 'saml']), - message: z.string(), - }), + schema: ssoMutationResponseSchema, + }, +}) + +export const ssoProviderParamsSchema = z.object({ + id: z.string().min(1, 'Provider row ID is required'), +}) + +export const ssoUpdateBodySchema = z.union([ + z.object(ssoOidcConfigurationSchema).strict(), + z.object(ssoSamlConfigurationSchema).strict(), +]) + +export type SsoUpdateBody = z.input +export type SsoUpdateData = z.output + +export const updateSsoProviderContract = defineRouteContract({ + method: 'PATCH', + path: '/api/auth/sso/providers/[id]', + params: ssoProviderParamsSchema, + body: ssoUpdateBodySchema, + response: { + mode: 'json', + schema: ssoMutationResponseSchema, + }, +}) + +export const deleteSsoProviderContract = defineRouteContract({ + method: 'DELETE', + path: '/api/auth/sso/providers/[id]', + params: ssoProviderParamsSchema, + response: { + mode: 'json', + schema: z.object({ success: z.literal(true) }), }, }) @@ -98,9 +164,11 @@ const ssoProviderListEntrySchema = z.object({ issuer: z.string().nullable().optional(), oidcConfig: z.string().nullable().optional(), samlConfig: z.string().nullable().optional(), - userId: z.string().nullable().optional(), organizationId: z.string().nullable().optional(), providerType: z.enum(['oidc', 'saml']).optional(), + domainVerified: z.boolean().optional(), + isCreator: z.boolean().optional(), + canManageVerification: z.boolean().optional(), }) export const listSsoProvidersContract = defineRouteContract({ @@ -115,6 +183,32 @@ export const listSsoProvidersContract = defineRouteContract({ }, }) +export type SsoProviderListResponse = ContractJsonResponse + +export const requestSsoDomainVerificationContract = defineRouteContract({ + method: 'POST', + path: '/api/auth/sso/providers/[id]/domain-verification/request', + params: ssoProviderParamsSchema, + response: { + mode: 'json', + status: 201, + schema: z.object({ + recordName: z.string(), + recordValue: z.string(), + }), + }, +}) + +export const verifySsoDomainContract = defineRouteContract({ + method: 'POST', + path: '/api/auth/sso/providers/[id]/domain-verification/verify', + params: ssoProviderParamsSchema, + response: { + mode: 'json', + schema: z.object({ success: z.literal(true) }), + }, +}) + export const getAuthProvidersContract = defineRouteContract({ method: 'GET', path: '/api/auth/providers', diff --git a/apps/sim/lib/auth/auth-client.ts b/apps/sim/lib/auth/auth-client.ts index b1ca36b84d2..df556764b9b 100644 --- a/apps/sim/lib/auth/auth-client.ts +++ b/apps/sim/lib/auth/auth-client.ts @@ -10,6 +10,7 @@ import { } from 'better-auth/client/plugins' import { createAuthClient } from 'better-auth/react' import type { auth } from '@/lib/auth' +import { SSO_DOMAIN_VERIFICATION_OPTIONS } from '@/lib/auth/sso/config' import { env } from '@/lib/core/config/env' import { isBillingEnabled, isOrganizationsEnabled } from '@/lib/core/config/env-flags' import { getBaseUrl, getBrowserOrigin } from '@/lib/core/utils/urls' @@ -34,7 +35,7 @@ export const client = createAuthClient({ ] : []), ...(isOrganizationsEnabled ? [organizationClient()] : []), - ...(env.NEXT_PUBLIC_SSO_ENABLED ? [ssoClient()] : []), + ...(env.NEXT_PUBLIC_SSO_ENABLED ? [ssoClient(SSO_DOMAIN_VERIFICATION_OPTIONS)] : []), ], }) diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 91397efb157..90df4f59d1e 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -34,6 +34,11 @@ import { import { getAccessControlConfig, isEmailBlockedByAccessControl } from '@/lib/auth/access-control' import { createAnonymousSession, ensureAnonymousUserExists } from '@/lib/auth/anonymous' import { getRequestedSignInProviderId, isSignInProviderAllowed } from '@/lib/auth/constants' +import { + getAccountLinkingTrustedProviders, + getSsoServerSecurityOptions, + SSO_DISABLED_PATHS, +} from '@/lib/auth/sso/config' import { guardSubscriptionPlanWrites } from '@/lib/auth/stripe-adapter-guard' import { sendPlanWelcomeEmail } from '@/lib/billing' import { @@ -72,7 +77,7 @@ import { handleSubscriptionCreated, handleSubscriptionDeleted, } from '@/lib/billing/webhooks/subscription' -import { env } from '@/lib/core/config/env' +import { env, isTruthy } from '@/lib/core/config/env' import { isAuthDisabled, isBillingEnabled, @@ -170,8 +175,8 @@ const additionalTrustedOrigins = parseOriginList(env.TRUSTED_ORIGINS, (value) => * matches an existing account's email. Includes `SSO_PROVIDER_ID` when it is set * in the app environment, plus any IDs from `SSO_TRUSTED_PROVIDER_IDS`. Empty when * SSO is disabled, so `trustedProviders` is unchanged for non-SSO deployments. - * Resolved once at startup; `trustEmailVerified` on the SSO plugin handles IdPs - * that assert `email_verified` live, so this is only needed for IdPs that omit it. + * Resolved once at startup. The SSO plugin does not trust IdP email-verification + * claims, so adding an ID here is an explicit operator-controlled linking grant. */ const additionalTrustedSsoProviders = isSsoEnabled ? [env.SSO_PROVIDER_ID, ...(env.SSO_TRUSTED_PROVIDER_IDS?.split(',') ?? [])] @@ -203,9 +208,22 @@ const usesGuardedE2eAuthRateLimit = export const auth = betterAuth({ baseURL: getBaseUrl(), + disabledPaths: [...SSO_DISABLED_PATHS], // Full browser contracts intentionally exercise many isolated sessions from loopback. // Keep Better Auth's limiter enabled while raising only the hermetic profile's ceiling. - ...(usesGuardedE2eAuthRateLimit ? { rateLimit: { max: 10_000 } } : {}), + // Better Auth's built-in sign-in/sign-up rules override the base maximum, so they + // require explicit custom rules for the shared loopback IP used by Playwright. + ...(usesGuardedE2eAuthRateLimit + ? { + rateLimit: { + max: 10_000, + customRules: { + '/sign-in/*': { window: 10, max: 10_000 }, + '/sign-up/*': { window: 10, max: 10_000 }, + }, + }, + } + : {}), trustedOrigins: [ getBaseUrl(), ...(env.NEXT_PUBLIC_SOCKET_URL ? [env.NEXT_PUBLIC_SOCKET_URL] : []), @@ -698,13 +716,7 @@ export const auth = betterAuth({ * nOAuth account takeover. Microsoft sign-in still works — it just links * to an existing account only when the IdP asserts a verified email. */ - trustedProviders: [ - 'google', - 'github', - 'email-password', - ...SSO_TRUSTED_PROVIDERS, - ...additionalTrustedSsoProviders, - ], + trustedProviders: getAccountLinkingTrustedProviders(additionalTrustedSsoProviders), }, }, socialProviders: { @@ -3194,12 +3206,7 @@ export const auth = betterAuth({ ...(env.SSO_ENABLED ? [ sso({ - /** - * Honor the IdP's verified-email claim. Without this the SSO plugin - * forces `emailVerified: false`, blocking automatic linking of an SSO - * login to an existing same-email account (Better Auth "account not linked"). - */ - trustEmailVerified: true, + ...getSsoServerSecurityOptions(isTruthy(env.SSO_DOMAIN_VERIFICATION_ENABLED)), organizationProvisioning: { disabled: false, defaultRole: 'member', diff --git a/apps/sim/lib/auth/sso/config.test.ts b/apps/sim/lib/auth/sso/config.test.ts new file mode 100644 index 00000000000..dbd2952c948 --- /dev/null +++ b/apps/sim/lib/auth/sso/config.test.ts @@ -0,0 +1,67 @@ +/** + * @vitest-environment node + */ +import { sso } from '@better-auth/sso' +import { betterAuth } from 'better-auth' +import { describe, expect, it } from 'vitest' +import { + getAccountLinkingTrustedProviders, + getSsoServerSecurityOptions, + SSO_DISABLED_PATHS, + SSO_DOMAIN_VERIFICATION_OPTIONS, + SSO_SERVER_SECURITY_OPTIONS, +} from '@/lib/auth/sso/config' + +describe('Better Auth SSO boundary', () => { + it.each(SSO_DISABLED_PATHS)('blocks raw HTTP access to %s', async (path) => { + const auth = betterAuth({ + baseURL: 'http://localhost:3000', + secret: 'test-secret-that-is-long-enough-for-better-auth', + disabledPaths: [...SSO_DISABLED_PATHS], + plugins: [sso(SSO_SERVER_SECURITY_OPTIONS)], + }) + + const response = await auth.handler( + new Request(`http://localhost:3000/api/auth${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ providerId: 'provider' }), + }) + ) + expect(response.status).toBe(404) + }) + + it('keeps internal domain verification APIs available', () => { + const auth = betterAuth({ + secret: 'test-secret-that-is-long-enough-for-better-auth', + disabledPaths: [...SSO_DISABLED_PATHS], + plugins: [sso(SSO_DOMAIN_VERIFICATION_OPTIONS)], + }) + + expect(auth.api.requestDomainVerification).toBeTypeOf('function') + expect(auth.api.verifyDomain).toBeTypeOf('function') + expect(auth.api.updateSSOProvider).toBeTypeOf('function') + expect(auth.api.deleteSSOProvider).toBeTypeOf('function') + }) + + it('requires verified domains without trusting IdP email flags', () => { + expect(SSO_SERVER_SECURITY_OPTIONS).toEqual({ + domainVerification: { enabled: true }, + trustEmailVerified: false, + disableImplicitSignUp: false, + }) + expect(getSsoServerSecurityOptions(false)).toMatchObject({ + domainVerification: { enabled: false }, + trustEmailVerified: true, + }) + }) + + it('trusts fixed first-party and operator-controlled provider IDs only', () => { + expect(getAccountLinkingTrustedProviders(['operator-saml'])).toEqual([ + 'google', + 'github', + 'email-password', + 'operator-saml', + ]) + }) +}) diff --git a/apps/sim/lib/auth/sso/config.ts b/apps/sim/lib/auth/sso/config.ts new file mode 100644 index 00000000000..55a3300c00d --- /dev/null +++ b/apps/sim/lib/auth/sso/config.ts @@ -0,0 +1,43 @@ +export const SSO_DISABLED_PATHS = [ + '/sso/register', + '/sso/update-provider', + '/sso/delete-provider', + '/sso/request-domain-verification', + '/sso/verify-domain', +] as const + +export const SSO_RESERVED_PROVIDER_IDS = ['google', 'github', 'email-password'] as const + +export const SSO_DOMAIN_VERIFICATION_OPTIONS = { + domainVerification: { + enabled: true, + }, +} as const + +/** + * Better Auth conditionally exposes verification methods from a literal-true + * option. Callers guard those methods with the same runtime flag. + */ +export function getSsoServerSecurityOptions(domainVerificationEnabled: boolean) { + return { + domainVerification: { + enabled: domainVerificationEnabled as true, + }, + // Preserve pre-migration linking behavior until verified-domain enforcement + // is activated. Once active, the verified provider domain is the trust + // boundary instead of an IdP-controlled email_verified claim. + trustEmailVerified: !domainVerificationEnabled, + /** + * Better Auth 1.6.13 does not honor requestSignUp in the SAML callback. + * Verified-domain gating is therefore the trust boundary for JIT + * provisioning until SAML supports the same explicit opt-in as OIDC. + */ + disableImplicitSignUp: false, + } as const +} + +export const SSO_SERVER_SECURITY_OPTIONS = getSsoServerSecurityOptions(true) + +export function getAccountLinkingTrustedProviders(operatorSsoProviderIds: string[]): string[] { + return [...SSO_RESERVED_PROVIDER_IDS, ...operatorSsoProviderIds] +} diff --git a/apps/sim/lib/auth/sso/domain.test.ts b/apps/sim/lib/auth/sso/domain.test.ts index 333a6576b5b..f01918daee2 100644 --- a/apps/sim/lib/auth/sso/domain.test.ts +++ b/apps/sim/lib/auth/sso/domain.test.ts @@ -9,31 +9,34 @@ describe('normalizeSSODomain', () => { expect(normalizeSSODomain(' Company.COM ')).toBe('company.com') }) - it('strips protocol, path, query, and port', () => { - expect(normalizeSSODomain('https://company.com/sso?x=1')).toBe('company.com') - expect(normalizeSSODomain('company.com:8443')).toBe('company.com') + it('drops a trailing dot', () => { + expect(normalizeSSODomain('company.com.')).toBe('company.com') }) - it('strips wildcard, leading @, and email local part', () => { - expect(normalizeSSODomain('*.company.com')).toBe('company.com') - expect(normalizeSSODomain('@company.com')).toBe('company.com') - expect(normalizeSSODomain('user@company.com')).toBe('company.com') + it('treats casing variants as the same domain', () => { + expect(normalizeSSODomain('Company.COM')).toBe(normalizeSSODomain('company.com')) }) - it('drops a trailing dot', () => { - expect(normalizeSSODomain('company.com.')).toBe('company.com') + it('accepts registrable domains and subdomains', () => { + expect(normalizeSSODomain('login.company.co.uk')).toBe('login.company.co.uk') }) - it('treats casing and formatting variants as the same domain', () => { - expect(normalizeSSODomain('Company.COM')).toBe(normalizeSSODomain('company.com')) - expect(normalizeSSODomain('user@Company.com')).toBe(normalizeSSODomain('company.com')) + it('rejects transformed values and comma lists', () => { + expect(normalizeSSODomain('https://company.com/sso')).toBeNull() + expect(normalizeSSODomain('company.com:8443')).toBeNull() + expect(normalizeSSODomain('*.company.com')).toBeNull() + expect(normalizeSSODomain('@company.com')).toBeNull() + expect(normalizeSSODomain('user@company.com')).toBeNull() + expect(normalizeSSODomain('company.com,subsidiary.com')).toBeNull() }) - it('rejects values that are not registrable domains', () => { + it('rejects values that are not registrable or use unknown suffixes', () => { expect(normalizeSSODomain('')).toBeNull() expect(normalizeSSODomain('localhost')).toBeNull() expect(normalizeSSODomain('not a domain')).toBeNull() expect(normalizeSSODomain('company')).toBeNull() + expect(normalizeSSODomain('company.invalid')).toBeNull() + expect(normalizeSSODomain('com')).toBeNull() }) it('rejects bare IP addresses and numeric TLDs', () => { diff --git a/apps/sim/lib/auth/sso/domain.ts b/apps/sim/lib/auth/sso/domain.ts index 30f6470b156..399f804b20e 100644 --- a/apps/sim/lib/auth/sso/domain.ts +++ b/apps/sim/lib/auth/sso/domain.ts @@ -1,28 +1,35 @@ +import { domainToASCII } from 'node:url' +import { parse } from 'tldts' + /** - * Normalizes a user-supplied SSO email domain to a canonical, comparable form: - * strips protocol, path, query, port, a leading wildcard/`@`, an email local - * part, and a trailing dot, then lowercases. Returns `null` for inputs that are - * not a registrable domain (e.g. `example.com`), which callers treat as invalid. + * Normalizes one user-supplied SSO email domain to a canonical form. Inputs that + * are URLs, email addresses, comma lists, IPs, public suffixes, or domains below + * an unknown suffix are rejected. */ export function normalizeSSODomain(input: string): string | null { if (typeof input !== 'string') return null - let value = input.trim().toLowerCase() - if (!value) return null - - value = value.replace(/^[a-z][a-z0-9+.-]*:\/\//, '') - value = value.replace(/^\*\./, '').replace(/^@/, '') - value = value.split('/')[0] - value = value.split('?')[0] - value = value.split('@').pop() ?? value - value = value.split(':')[0] - value = value.replace(/\.$/, '') + const trimmed = input.trim().replace(/\.$/, '') + if (!trimmed || trimmed.includes(',')) return null + if (/[:/@*?\s]/.test(trimmed)) return null - if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(value)) return null + const value = domainToASCII(trimmed).toLowerCase() + if (!value) return null - const labels = value.split('.') - if (labels.some((label) => label.length === 0 || label.length > 63)) return null - if (/^\d+$/.test(labels[labels.length - 1])) return null + const result = parse(value, { + allowPrivateDomains: true, + validateHostname: true, + }) + if ( + result.isIp || + !result.domain || + !result.publicSuffix || + (!result.isIcann && !result.isPrivate) || + result.hostname !== value || + result.publicSuffix === value + ) { + return null + } return value } diff --git a/apps/sim/lib/auth/sso/management.test.ts b/apps/sim/lib/auth/sso/management.test.ts new file mode 100644 index 00000000000..8fb09a6d462 --- /dev/null +++ b/apps/sim/lib/auth/sso/management.test.ts @@ -0,0 +1,310 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockSecureFetchWithPinnedIP, mockValidateUrlWithDNS } = vi.hoisted(() => ({ + mockSecureFetchWithPinnedIP: vi.fn(), + mockValidateUrlWithDNS: vi.fn(), +})) + +vi.mock('@/lib/core/security/input-validation.server', () => ({ + secureFetchWithPinnedIP: mockSecureFetchWithPinnedIP, + validateUrlWithDNS: mockValidateUrlWithDNS, +})) + +vi.mock('@/lib/core/utils/urls', () => ({ + getBaseUrl: () => 'https://app.example.com', +})) + +vi.mock('@/lib/billing', () => ({ + isOrganizationOnEnterprisePlan: vi.fn(), +})) + +import { + buildSSOProviderConfiguration, + domainsOverlap, + requireNormalizedSSODomain, + SSO_PROVIDER_ID_MAX_LENGTH, + SSOManagementError, + ssoManagementErrorResponse, + validateSSOProviderId, +} from '@/lib/auth/sso/management' + +const SAML_BODY = { + providerType: 'saml' as const, + providerId: 'acme-saml', + orgId: 'org-1', + issuer: 'https://idp.example.com', + domain: 'acme.com', + mapping: { + id: 'name-id', + email: 'email', + name: 'name', + image: 'picture', + }, + entryPoint: 'https://idp.example.com/sso', + cert: '-----BEGIN CERTIFICATE-----\nPUBLIC\n-----END CERTIFICATE-----', + wantAssertionsSigned: true, +} + +const OIDC_BODY = { + providerType: 'oidc' as const, + providerId: 'acme-oidc', + orgId: 'org-1', + issuer: 'https://idp.example.com', + domain: 'acme.com', + clientId: 'client-id', + clientSecret: 'client-secret', + scopes: ['openid', 'profile', 'email', 'offline_access'], + pkce: true, + mapping: { + id: 'sub', + email: 'email', + name: 'name', + image: 'picture', + }, + authorizationEndpoint: 'https://idp.example.com/authorize', + tokenEndpoint: 'https://idp.example.com/token', + userInfoEndpoint: 'https://idp.example.com/userinfo', + jwksEndpoint: 'https://idp.example.com/jwks', + skipUserInfoEndpoint: false, +} + +const LEGACY_GENERATED_IDP_METADATA = ` + + + + + + PUBLIC + + + + + + +` + +describe('SSO management helpers', () => { + beforeEach(() => { + vi.clearAllMocks() + mockValidateUrlWithDNS.mockResolvedValue({ isValid: true, resolvedIP: '192.0.2.1' }) + mockSecureFetchWithPinnedIP.mockRejectedValue(new Error('Discovery unavailable')) + }) + + it('detects exact and bidirectional suffix overlap', () => { + expect(domainsOverlap('acme.com', 'acme.com')).toBe(true) + expect(domainsOverlap('login.acme.com', 'acme.com')).toBe(true) + expect(domainsOverlap('acme.com', 'login.acme.com')).toBe(true) + expect(domainsOverlap('LOGIN.CORP', 'login.corp')).toBe(true) + expect(domainsOverlap('AUTH.LOGIN.CORP', 'login.corp')).toBe(true) + expect(domainsOverlap('login.corp', 'AUTH.LOGIN.CORP')).toBe(true) + expect(domainsOverlap('notacme.com', 'acme.com')).toBe(false) + }) + + it('enforces the Better Auth verification identifier DNS-label limit', () => { + expect(() => validateSSOProviderId('a'.repeat(SSO_PROVIDER_ID_MAX_LENGTH))).not.toThrow() + expect(() => validateSSOProviderId('a'.repeat(SSO_PROVIDER_ID_MAX_LENGTH + 1))).toThrow( + SSOManagementError + ) + }) + + it.each(['google', 'github', 'email-password'])( + 'rejects the globally trusted built-in provider ID %s', + (providerId) => { + expect(() => validateSSOProviderId(providerId)).toThrow( + expect.objectContaining({ code: 'SSO_PROVIDER_ID_RESERVED' }) + ) + } + ) + + it('preserves an unchanged legacy internal domain while requiring registrable replacements', () => { + expect(requireNormalizedSSODomain('LOGIN.CORP', 'login.corp')).toBe('login.corp') + expect(() => requireNormalizedSSODomain('other.corp', 'login.corp')).toThrow(SSOManagementError) + }) + + it('builds explicit OIDC configuration when optional discovery is unavailable', async () => { + const result = await buildSSOProviderConfiguration(OIDC_BODY, { + providerId: OIDC_BODY.providerId, + organizationId: OIDC_BODY.orgId, + }) + + if (!('oidcConfig' in result)) throw new Error('Expected OIDC configuration') + expect(result.oidcConfig).toMatchObject({ + authorizationEndpoint: OIDC_BODY.authorizationEndpoint, + tokenEndpoint: OIDC_BODY.tokenEndpoint, + userInfoEndpoint: OIDC_BODY.userInfoEndpoint, + jwksEndpoint: OIDC_BODY.jwksEndpoint, + tokenEndpointAuthentication: 'client_secret_post', + skipDiscovery: true, + scopes: ['openid', 'profile', 'email'], + }) + }) + + it('rejects a required discovered endpoint that fails SSRF validation', async () => { + mockSecureFetchWithPinnedIP.mockResolvedValue({ + ok: true, + json: async () => ({ + authorization_endpoint: 'http://169.254.169.254/authorize', + token_endpoint: OIDC_BODY.tokenEndpoint, + jwks_uri: OIDC_BODY.jwksEndpoint, + }), + }) + mockValidateUrlWithDNS.mockImplementation(async (url: string) => + url.includes('169.254.169.254') + ? { isValid: false, error: 'resolves to a private IP address' } + : { isValid: true, resolvedIP: '192.0.2.1' } + ) + + await expect( + buildSSOProviderConfiguration( + { ...OIDC_BODY, authorizationEndpoint: undefined }, + { providerId: OIDC_BODY.providerId, organizationId: OIDC_BODY.orgId } + ) + ).rejects.toMatchObject({ status: 400 }) + }) + + it('does not validate or retain a userinfo endpoint when ID-token claims are requested', async () => { + mockValidateUrlWithDNS.mockImplementation(async (_url: string, label: string) => { + if (label.toLowerCase().includes('userinfo')) { + return { isValid: false, error: 'resolves to a private IP address' } + } + return { isValid: true, resolvedIP: '192.0.2.1' } + }) + + const result = await buildSSOProviderConfiguration( + { ...OIDC_BODY, skipUserInfoEndpoint: true }, + { providerId: OIDC_BODY.providerId, organizationId: OIDC_BODY.orgId } + ) + if (!('oidcConfig' in result)) throw new Error('Expected OIDC configuration') + expect(result.oidcConfig.userInfoEndpoint).toBeUndefined() + }) + + it('uses discovery auth metadata without validating unused discovered endpoints', async () => { + mockSecureFetchWithPinnedIP.mockResolvedValue({ + ok: true, + json: async () => ({ + authorization_endpoint: 'http://169.254.169.254/unused', + token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'], + }), + }) + mockValidateUrlWithDNS.mockImplementation(async (url: string) => { + if (url.includes('169.254.169.254')) { + return { isValid: false, error: 'resolves to a private IP address' } + } + return { isValid: true, resolvedIP: '192.0.2.1' } + }) + + const result = await buildSSOProviderConfiguration(OIDC_BODY, { + providerId: OIDC_BODY.providerId, + organizationId: OIDC_BODY.orgId, + }) + if (!('oidcConfig' in result)) throw new Error('Expected OIDC configuration') + expect(result.oidcConfig.authorizationEndpoint).toBe(OIDC_BODY.authorizationEndpoint) + expect(result.oidcConfig.tokenEndpointAuthentication).toBe('client_secret_post') + }) + + it('builds complete SAML metadata without OIDC DNS or fetch work', async () => { + const result = await buildSSOProviderConfiguration(SAML_BODY, { + providerId: SAML_BODY.providerId, + organizationId: SAML_BODY.orgId, + }) + + expect(mockValidateUrlWithDNS).not.toHaveBeenCalled() + expect(mockSecureFetchWithPinnedIP).not.toHaveBeenCalled() + expect(result).toMatchObject({ + providerId: 'acme-saml', + domain: 'acme.com', + organizationId: 'org-1', + samlConfig: { + callbackUrl: 'https://app.example.com/api/auth/sso/saml2/callback/acme-saml', + idpMetadata: { metadata: expect.stringContaining('https://idp.example.com/sso') }, + spMetadata: { + metadata: expect.stringContaining( + 'https://app.example.com/api/auth/sso/saml2/callback/acme-saml' + ), + }, + }, + }) + if (!('samlConfig' in result)) throw new Error('Expected SAML configuration') + expect(result.samlConfig.spMetadata.metadata).toContain('entityID="https://app.example.com"') + expect(result.samlConfig.spMetadata.metadata).not.toContain('/sp/metadata?') + }) + + it('rebuilds generated IdP metadata when the certificate rotates', async () => { + const original = await buildSSOProviderConfiguration(SAML_BODY, { + providerId: SAML_BODY.providerId, + organizationId: SAML_BODY.orgId, + }) + if (!('samlConfig' in original)) throw new Error('Expected SAML configuration') + + const rotated = await buildSSOProviderConfiguration( + { + ...SAML_BODY, + cert: '-----BEGIN CERTIFICATE-----\nROTATED\n-----END CERTIFICATE-----', + idpMetadata: LEGACY_GENERATED_IDP_METADATA, + }, + { + providerId: SAML_BODY.providerId, + organizationId: SAML_BODY.orgId, + existingConfig: JSON.stringify({ + ...original.samlConfig, + idpMetadata: { metadata: LEGACY_GENERATED_IDP_METADATA }, + }), + existingIssuer: SAML_BODY.issuer, + } + ) + if (!('samlConfig' in rotated)) throw new Error('Expected SAML configuration') + + expect(rotated.samlConfig.idpMetadata.metadata).toContain('ROTATED') + expect(rotated.samlConfig.idpMetadata.metadata).not.toContain('PUBLIC') + }) + + it('preserves explicitly supplied IdP metadata during certificate rotation', async () => { + const customMetadata = '' + const original = await buildSSOProviderConfiguration( + { ...SAML_BODY, idpMetadata: customMetadata }, + { + providerId: SAML_BODY.providerId, + organizationId: SAML_BODY.orgId, + } + ) + if (!('samlConfig' in original)) throw new Error('Expected SAML configuration') + + const rotated = await buildSSOProviderConfiguration( + { + ...SAML_BODY, + cert: '-----BEGIN CERTIFICATE-----\nROTATED\n-----END CERTIFICATE-----', + idpMetadata: customMetadata, + }, + { + providerId: SAML_BODY.providerId, + organizationId: SAML_BODY.orgId, + existingConfig: JSON.stringify(original.samlConfig), + existingIssuer: SAML_BODY.issuer, + } + ) + if (!('samlConfig' in rotated)) throw new Error('Expected SAML configuration') + + expect(rotated.samlConfig.idpMetadata.metadata).toBe(customMetadata) + }) + + it('rejects a custom SAML callback on another origin', async () => { + await expect( + buildSSOProviderConfiguration( + { ...SAML_BODY, callbackUrl: 'https://attacker.example/callback' }, + { + providerId: SAML_BODY.providerId, + organizationId: SAML_BODY.orgId, + } + ) + ).rejects.toMatchObject({ code: 'SSO_CALLBACK_URL_INVALID', status: 400 }) + }) + + it('maps SQLSTATE 23505 to a stable conflict response', async () => { + const response = ssoManagementErrorResponse({ code: '23505' }) + expect(response?.status).toBe(409) + await expect(response?.json()).resolves.toMatchObject({ code: 'SSO_CONFLICT' }) + }) +}) diff --git a/apps/sim/lib/auth/sso/management.ts b/apps/sim/lib/auth/sso/management.ts new file mode 100644 index 00000000000..c002933a53f --- /dev/null +++ b/apps/sim/lib/auth/sso/management.ts @@ -0,0 +1,595 @@ +import { account, db, member, ssoProvider } from '@sim/db' +import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors' +import { APIError } from 'better-auth/api' +import { and, eq } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import type { SsoRegistrationData, SsoUpdateData } from '@/lib/api/contracts/auth' +import { SSO_RESERVED_PROVIDER_IDS } from '@/lib/auth/sso/config' +import { normalizeSSODomain } from '@/lib/auth/sso/domain' +import { isOrganizationOnEnterprisePlan } from '@/lib/billing' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' +import { REDACTED_MARKER } from '@/lib/core/security/redaction' +import { getBaseUrl } from '@/lib/core/utils/urls' + +const DNS_LABEL_MAX_LENGTH = 63 +const DOMAIN_VERIFICATION_TOKEN_PREFIX = 'better-auth-token' +const DOMAIN_VERIFICATION_RECORD_PREFIX = `_${DOMAIN_VERIFICATION_TOKEN_PREFIX}-` +export const SSO_PROVIDER_ID_MAX_LENGTH = + DNS_LABEL_MAX_LENGTH - DOMAIN_VERIFICATION_RECORD_PREFIX.length + +const OIDC_DISCOVERY_TIMEOUT_MS = 10_000 + +type TokenEndpointAuthMethod = 'client_secret_basic' | 'client_secret_post' + +export interface SSOProviderRecord { + id: string + issuer: string + domain: string + domainVerified: boolean + oidcConfig: string | null + samlConfig: string | null + userId: string + providerId: string + organizationId: string | null +} + +export class SSOManagementError extends Error { + constructor( + message: string, + readonly status: number, + readonly code?: string + ) { + super(message) + this.name = 'SSOManagementError' + } +} + +export function collectAuthHeaders(request: NextRequest): Record { + const headers: Record = {} + request.headers.forEach((value, key) => { + headers[key] = value + }) + return headers +} + +export function getDomainVerificationRecordName(providerId: string, domain: string): string { + return `${DOMAIN_VERIFICATION_RECORD_PREFIX}${providerId}.${domain}` +} + +export function getDomainVerificationRecordValue(providerId: string, token: string): string { + return `${DOMAIN_VERIFICATION_RECORD_PREFIX}${providerId}=${token}` +} + +export function validateSSOProviderId(providerId: string): void { + if (SSO_RESERVED_PROVIDER_IDS.some((reservedId) => reservedId === providerId)) { + throw new SSOManagementError( + 'Provider ID is reserved by a built-in authentication provider', + 400, + 'SSO_PROVIDER_ID_RESERVED' + ) + } + if ( + providerId.length > SSO_PROVIDER_ID_MAX_LENGTH || + !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(providerId) + ) { + throw new SSOManagementError( + `Provider ID must be a lowercase DNS label no longer than ${SSO_PROVIDER_ID_MAX_LENGTH} characters`, + 400, + 'SSO_PROVIDER_ID_INVALID' + ) + } +} + +export function requireNormalizedSSODomain(input: string, existingDomain?: string): string { + const domain = normalizeSSODomain(input) + if (domain) return domain + + const normalizedInput = input.trim().toLowerCase().replace(/\.$/, '') + if ( + existingDomain && + normalizedInput === existingDomain.trim().toLowerCase().replace(/\.$/, '') + ) { + return existingDomain + } + + throw new SSOManagementError( + 'Enter one registrable domain like company.com', + 400, + 'SSO_DOMAIN_INVALID' + ) +} + +export function domainsOverlap(left: string, right: string): boolean { + const normalizedLeft = left.toLowerCase() + const normalizedRight = right.toLowerCase() + return ( + normalizedLeft === normalizedRight || + normalizedLeft.endsWith(`.${normalizedRight}`) || + normalizedRight.endsWith(`.${normalizedLeft}`) + ) +} + +export async function authorizeOrganizationSSOAdmin( + userId: string, + organizationId: string +): Promise { + const [membership] = await db + .select({ role: member.role }) + .from(member) + .where(and(eq(member.userId, userId), eq(member.organizationId, organizationId))) + .limit(1) + + if (!membership || (membership.role !== 'owner' && membership.role !== 'admin')) { + throw new SSOManagementError('Forbidden', 403, 'SSO_FORBIDDEN') + } +} + +export async function authorizeOrganizationSSO( + userId: string, + organizationId: string +): Promise { + await authorizeOrganizationSSOAdmin(userId, organizationId) + if (!(await isOrganizationOnEnterprisePlan(organizationId))) { + throw new SSOManagementError('SSO requires an Enterprise plan', 403, 'SSO_ENTERPRISE_REQUIRED') + } +} + +export async function getManagedSSOProvider( + rowId: string, + userId: string, + options: { requireCreator?: boolean; requireEnterprise?: boolean } = {} +): Promise { + const [provider] = await db + .select({ + id: ssoProvider.id, + issuer: ssoProvider.issuer, + domain: ssoProvider.domain, + domainVerified: ssoProvider.domainVerified, + oidcConfig: ssoProvider.oidcConfig, + samlConfig: ssoProvider.samlConfig, + userId: ssoProvider.userId, + providerId: ssoProvider.providerId, + organizationId: ssoProvider.organizationId, + }) + .from(ssoProvider) + .where(eq(ssoProvider.id, rowId)) + .limit(1) + + if (!provider?.organizationId) { + throw new SSOManagementError('SSO provider not found', 404, 'SSO_PROVIDER_NOT_FOUND') + } + + if (options.requireEnterprise === false) { + await authorizeOrganizationSSOAdmin(userId, provider.organizationId) + } else { + await authorizeOrganizationSSO(userId, provider.organizationId) + } + + if (options.requireCreator && provider.userId !== userId) { + throw new SSOManagementError( + 'Only the administrator who created this provider can verify its domain', + 403, + 'SSO_CREATOR_REQUIRED' + ) + } + + return provider +} + +export async function assertSSOProviderHasNoAccountLinks(providerId: string): Promise { + const [linkedAccount] = await db + .select({ id: account.id }) + .from(account) + .where(eq(account.providerId, providerId)) + .limit(1) + if (linkedAccount) { + throw new SSOManagementError( + 'This provider has linked user accounts. Complete the account-link and session migration before changing its identity or deleting it.', + 409, + 'SSO_PROVIDER_HAS_LINKED_ACCOUNTS' + ) + } +} + +export async function assertSSOProviderAvailable(input: { + providerId: string + domain: string + organizationId: string + excludeRowId?: string +}): Promise { + const providers = await db + .select({ + id: ssoProvider.id, + providerId: ssoProvider.providerId, + domain: ssoProvider.domain, + organizationId: ssoProvider.organizationId, + }) + .from(ssoProvider) + + for (const provider of providers) { + if (provider.id === input.excludeRowId) continue + if (provider.providerId === input.providerId) { + throw new SSOManagementError( + 'This provider ID is already in use', + 409, + 'SSO_PROVIDER_ID_CONFLICT' + ) + } + if (provider.organizationId === input.organizationId) { + throw new SSOManagementError( + 'This organization already has an SSO provider', + 409, + 'SSO_ORGANIZATION_PROVIDER_CONFLICT' + ) + } + if (domainsOverlap(provider.domain, input.domain)) { + throw new SSOManagementError( + 'This domain overlaps an SSO domain registered by another organization', + 409, + 'SSO_DOMAIN_ALREADY_REGISTERED' + ) + } + } +} + +function selectTokenEndpointAuthMethod( + supportedMethods: unknown, + existing?: TokenEndpointAuthMethod +): TokenEndpointAuthMethod { + if (existing) return existing + if (!Array.isArray(supportedMethods) || supportedMethods.length === 0) { + return 'client_secret_post' + } + if (supportedMethods.includes('client_secret_post')) return 'client_secret_post' + if (supportedMethods.includes('client_secret_basic')) return 'client_secret_basic' + return 'client_secret_post' +} + +async function fetchOIDCDiscoveryDocument( + discoveryUrl: string +): Promise<{ ok: true; discovery: Record } | { ok: false; error: string }> { + const validation = await validateUrlWithDNS(discoveryUrl, 'OIDC discovery URL') + if (!validation.isValid || !validation.resolvedIP) { + return { ok: false, error: validation.error ?? 'SSRF validation failed' } + } + + try { + const response = await secureFetchWithPinnedIP(discoveryUrl, validation.resolvedIP, { + headers: { Accept: 'application/json' }, + timeout: OIDC_DISCOVERY_TIMEOUT_MS, + }) + if (!response.ok) { + return { ok: false, error: `Discovery request failed with status ${response.status}` } + } + return { ok: true, discovery: (await response.json()) as Record } + } catch (error) { + return { ok: false, error: getErrorMessage(error, 'Unknown error') } + } +} + +interface OIDCConfig { + clientId: string + clientSecret: string + scopes: string[] + pkce: boolean + authorizationEndpoint?: string + tokenEndpoint?: string + userInfoEndpoint?: string + jwksEndpoint?: string + tokenEndpointAuthentication?: TokenEndpointAuthMethod + skipDiscovery: true + mapping: SsoRegistrationData['mapping'] +} + +async function buildOIDCConfig( + body: Extract | SsoUpdateData, + existingConfig?: string | null +): Promise { + if (!('clientId' in body)) { + throw new SSOManagementError('OIDC configuration is required', 400) + } + + let clientSecret = body.clientSecret + if (clientSecret === REDACTED_MARKER) { + if (!existingConfig) { + throw new SSOManagementError('Re-enter the client secret', 400) + } + try { + const parsed = JSON.parse(existingConfig) as { clientSecret?: string } + if (!parsed.clientSecret) throw new Error('Missing client secret') + clientSecret = parsed.clientSecret + } catch { + throw new SSOManagementError('Re-enter the client secret', 400) + } + } + + const config: OIDCConfig = { + clientId: body.clientId, + clientSecret, + scopes: body.scopes.filter((scope) => scope !== 'offline_access'), + pkce: body.pkce, + mapping: body.mapping, + authorizationEndpoint: body.authorizationEndpoint, + tokenEndpoint: body.tokenEndpoint, + userInfoEndpoint: body.skipUserInfoEndpoint ? undefined : body.userInfoEndpoint, + jwksEndpoint: body.jwksEndpoint, + skipDiscovery: true, + } + + const explicitEndpoints = { + authorizationEndpoint: config.authorizationEndpoint, + tokenEndpoint: config.tokenEndpoint, + jwksEndpoint: config.jwksEndpoint, + ...(body.skipUserInfoEndpoint ? {} : { userInfoEndpoint: config.userInfoEndpoint }), + } + for (const [name, endpoint] of Object.entries(explicitEndpoints)) { + if (!endpoint) continue + const validation = await validateUrlWithDNS(endpoint, `OIDC ${name}`) + if (!validation.isValid) { + throw new SSOManagementError( + `OIDC ${name} failed security validation: ${validation.error}`, + 400 + ) + } + } + + const needsDiscovery = + !config.authorizationEndpoint || !config.tokenEndpoint || !config.jwksEndpoint + const discoveryUrl = `${body.issuer.replace(/\/$/, '')}/.well-known/openid-configuration` + const discoveryResult = await fetchOIDCDiscoveryDocument(discoveryUrl) + if (needsDiscovery && !discoveryResult.ok) { + throw new SSOManagementError( + `Failed to fetch OIDC discovery document: ${discoveryResult.error}. Provide all endpoints explicitly or verify the issuer URL.`, + 400 + ) + } + + if (discoveryResult.ok) { + const discovery = discoveryResult.discovery + const discoveredEndpoints = { + ...(!config.authorizationEndpoint + ? { authorizationEndpoint: discovery.authorization_endpoint } + : {}), + ...(!config.tokenEndpoint ? { tokenEndpoint: discovery.token_endpoint } : {}), + ...(!config.jwksEndpoint ? { jwksEndpoint: discovery.jwks_uri } : {}), + ...(!body.skipUserInfoEndpoint && !config.userInfoEndpoint + ? { userInfoEndpoint: discovery.userinfo_endpoint } + : {}), + } + for (const [name, endpoint] of Object.entries(discoveredEndpoints)) { + if (typeof endpoint !== 'string') continue + const validation = await validateUrlWithDNS(endpoint, `OIDC ${name}`) + if (!validation.isValid) { + throw new SSOManagementError( + `Discovered OIDC ${name} failed security validation: ${validation.error}`, + 400 + ) + } + } + + config.authorizationEndpoint ||= + typeof discovery.authorization_endpoint === 'string' + ? discovery.authorization_endpoint + : undefined + config.tokenEndpoint ||= + typeof discovery.token_endpoint === 'string' ? discovery.token_endpoint : undefined + config.jwksEndpoint ||= typeof discovery.jwks_uri === 'string' ? discovery.jwks_uri : undefined + if (!body.skipUserInfoEndpoint) { + config.userInfoEndpoint ||= + typeof discovery.userinfo_endpoint === 'string' ? discovery.userinfo_endpoint : undefined + } + config.tokenEndpointAuthentication = selectTokenEndpointAuthMethod( + discovery.token_endpoint_auth_methods_supported + ) + } else { + config.tokenEndpointAuthentication = selectTokenEndpointAuthMethod(undefined) + } + + const missing = [ + !config.authorizationEndpoint ? 'authorizationEndpoint' : null, + !config.tokenEndpoint ? 'tokenEndpoint' : null, + !config.jwksEndpoint ? 'jwksEndpoint' : null, + ].filter((value): value is string => Boolean(value)) + if (missing.length > 0) { + throw new SSOManagementError(`Missing required OIDC endpoints: ${missing.join(', ')}`, 400) + } + + return config +} + +function escapeXml(value: string): string { + return value.replace(/[<>&"']/g, (character) => { + const entities: Record = { + '<': '<', + '>': '>', + '&': '&', + '"': '"', + "'": ''', + } + return entities[character] + }) +} + +interface SAMLConfig { + entryPoint: string + cert: string + callbackUrl: string + audience?: string + wantAssertionsSigned?: boolean + signatureAlgorithm?: string + digestAlgorithm?: string + identifierFormat?: string + mapping: SsoRegistrationData['mapping'] + spMetadata: { metadata: string } + idpMetadata: { metadata: string } +} + +function certificateBody(certificate: string): string { + return certificate + .replace(/-----BEGIN CERTIFICATE-----/g, '') + .replace(/-----END CERTIFICATE-----/g, '') + .replace(/\s/g, '') +} + +function buildGeneratedIdpMetadata( + issuer: string, + entryPoint: string, + certificate: string +): string { + return ` + + + + + + ${certificateBody(certificate)} + + + + + + +` +} + +function buildSAMLConfig( + body: Extract | SsoUpdateData, + providerId: string, + existingConfig?: string | null, + existingIssuer?: string +): SAMLConfig { + if (!('entryPoint' in body)) { + throw new SSOManagementError('SAML configuration is required', 400) + } + + const baseUrl = getBaseUrl() + const callbackUrl = body.callbackUrl || `${baseUrl}/api/auth/sso/saml2/callback/${providerId}` + if (body.callbackUrl && new URL(callbackUrl).origin !== new URL(baseUrl).origin) { + throw new SSOManagementError( + 'SAML callback URL must use the application origin', + 400, + 'SSO_CALLBACK_URL_INVALID' + ) + } + const entityId = baseUrl + const spMetadata = ` + + + + +` + let suppliedIdpMetadata = body.idpMetadata + if (suppliedIdpMetadata && existingConfig && existingIssuer) { + try { + const existing = JSON.parse(existingConfig) as { + entryPoint?: string + cert?: string + idpMetadata?: { metadata?: string } | string + } + const existingMetadata = + typeof existing.idpMetadata === 'string' + ? existing.idpMetadata + : existing.idpMetadata?.metadata + const generatedExistingMetadata = + existing.entryPoint && existing.cert + ? buildGeneratedIdpMetadata(existingIssuer, existing.entryPoint, existing.cert) + : undefined + if ( + suppliedIdpMetadata === existingMetadata && + existingMetadata === generatedExistingMetadata + ) { + suppliedIdpMetadata = undefined + } + } catch { + throw new SSOManagementError('Stored SAML configuration is invalid', 500) + } + } + const idpMetadata = + suppliedIdpMetadata || buildGeneratedIdpMetadata(body.issuer, body.entryPoint, body.cert) + + return { + entryPoint: body.entryPoint, + cert: body.cert, + callbackUrl, + audience: body.audience, + wantAssertionsSigned: body.wantAssertionsSigned, + signatureAlgorithm: body.signatureAlgorithm, + digestAlgorithm: body.digestAlgorithm, + identifierFormat: body.identifierFormat, + mapping: body.mapping, + spMetadata: { metadata: spMetadata }, + idpMetadata: { metadata: idpMetadata }, + } +} + +export async function buildSSOProviderConfiguration( + body: SsoRegistrationData | SsoUpdateData, + input: { + providerId: string + organizationId?: string + existingConfig?: string | null + existingIssuer?: string + existingDomain?: string + } +) { + const domain = requireNormalizedSSODomain(body.domain, input.existingDomain) + const common = { + providerId: input.providerId, + issuer: body.issuer, + domain, + ...(input.organizationId ? { organizationId: input.organizationId } : {}), + } + + if ('entryPoint' in body) { + return { + ...common, + samlConfig: buildSAMLConfig( + body, + input.providerId, + input.existingConfig, + input.existingIssuer + ), + } + } + + return { + ...common, + oidcConfig: await buildOIDCConfig(body, input.existingConfig), + } +} + +export function ssoManagementErrorResponse(error: unknown): NextResponse | null { + if (error instanceof SSOManagementError) { + return NextResponse.json( + { error: error.message, ...(error.code ? { code: error.code } : {}) }, + { status: error.status } + ) + } + if (getPostgresErrorCode(error) === '23505') { + return NextResponse.json( + { + error: 'The provider ID, domain, or organization already has an SSO provider', + code: 'SSO_CONFLICT', + }, + { status: 409 } + ) + } + if (error instanceof APIError) { + const code = + typeof error.body === 'object' && + error.body !== null && + 'code' in error.body && + typeof error.body.code === 'string' + ? error.body.code + : undefined + return NextResponse.json( + { error: error.message, ...(code ? { code } : {}) }, + { status: error.statusCode } + ) + } + return null +} diff --git a/apps/sim/lib/auth/sso/provider-operation-intent.test.ts b/apps/sim/lib/auth/sso/provider-operation-intent.test.ts new file mode 100644 index 00000000000..c70088df4c5 --- /dev/null +++ b/apps/sim/lib/auth/sso/provider-operation-intent.test.ts @@ -0,0 +1,153 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const state = vi.hoisted(() => { + const verificationTable = { + id: 'verification.id', + identifier: 'verification.identifier', + expiresAt: 'verification.expiresAt', + } + const ssoProviderTable = { + id: 'ssoProvider.id', + providerId: 'ssoProvider.providerId', + } + return { + deleteCalls: 0, + intents: [] as Array<{ id: string }>, + lockDepth: 0, + providerExists: true, + verificationTable, + ssoProviderTable, + withLock: vi.fn(), + } +}) + +function rowsBuilder(rows: Array<{ id: string }>) { + const builder = Promise.resolve(rows) as Promise> & { + limit: () => Promise> + } + builder.limit = () => Promise.resolve(rows.slice(0, 1)) + return builder +} + +vi.mock('@sim/db', () => ({ + account: {}, + member: {}, + SSO_CALLBACK_INTENT_PREFIX: 'sso-callback-intent:', + SSO_DOMAIN_VERIFICATION_INTENT_PREFIX: 'sso-domain-verification-intent:', + ssoProvider: state.ssoProviderTable, + verification: state.verificationTable, + withSSOProviderMutationLock: state.withLock, + db: { + delete: () => ({ + where: async () => { + state.deleteCalls += 1 + if (state.deleteCalls > 1) state.intents = [] + }, + }), + insert: () => ({ + values: async (value: { id: string }) => { + state.intents.push({ id: value.id }) + }, + }), + select: () => ({ + from: (table: unknown) => ({ + where: () => + rowsBuilder( + table === state.ssoProviderTable + ? state.providerExists + ? [{ id: 'provider-row' }] + : [] + : state.intents + ), + }), + }), + }, +})) + +vi.mock('@sim/utils/id', () => ({ generateId: () => 'intent-1' })) +vi.mock('@sim/logger', () => ({ + createLogger: () => ({ error: vi.fn() }), +})) +vi.mock('@/lib/billing', () => ({ isOrganizationOnEnterprisePlan: vi.fn() })) +vi.mock('@/lib/core/security/input-validation.server', () => ({ + secureFetchWithPinnedIP: vi.fn(), + validateUrlWithDNS: vi.fn(), +})) +vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://app.example.com' })) + +import { + assertNoActiveSSOProviderOperations, + withSSOCallbackIntent, + withSSODomainVerificationIntent, +} from '@/lib/auth/sso/provider-operation-intent' + +describe('SSO provider operation intents', () => { + beforeEach(() => { + vi.clearAllMocks() + state.deleteCalls = 0 + state.intents = [] + state.lockDepth = 0 + state.providerExists = true + state.withLock.mockImplementation(async (callback: () => Promise) => { + state.lockDepth += 1 + try { + return await callback() + } finally { + state.lockDepth -= 1 + } + }) + }) + + it('releases the mutation lock before callback work and removes the intent afterward', async () => { + await withSSOCallbackIntent('acme', async () => { + expect(state.lockDepth).toBe(0) + expect(state.intents).toEqual([{ id: 'intent-1' }]) + }) + + expect(state.intents).toEqual([]) + expect(state.withLock).toHaveBeenCalledOnce() + }) + + it('does not register a callback intent for an unknown provider', async () => { + state.providerExists = false + + await withSSOCallbackIntent('missing', async () => { + expect(state.intents).toEqual([]) + }) + }) + + it('releases the mutation lock before DNS verification work', async () => { + await withSSODomainVerificationIntent({ id: 'provider-row', providerId: 'acme' }, async () => { + expect(state.lockDepth).toBe(0) + expect(state.intents).toEqual([{ id: 'intent-1' }]) + }) + + expect(state.intents).toEqual([]) + }) + + it('rejects domain verification if the expected provider disappeared', async () => { + state.providerExists = false + + await expect( + withSSODomainVerificationIntent( + { id: 'provider-row', providerId: 'acme' }, + async () => undefined + ) + ).rejects.toMatchObject({ + code: 'SSO_PROVIDER_CHANGED', + status: 409, + }) + }) + + it('blocks identity mutations while an unexpired operation intent exists', async () => { + state.intents = [{ id: 'active-intent' }] + + await expect(assertNoActiveSSOProviderOperations('acme')).rejects.toMatchObject({ + code: 'SSO_OPERATION_IN_PROGRESS', + status: 409, + }) + }) +}) diff --git a/apps/sim/lib/auth/sso/provider-operation-intent.ts b/apps/sim/lib/auth/sso/provider-operation-intent.ts new file mode 100644 index 00000000000..15a752fc7c9 --- /dev/null +++ b/apps/sim/lib/auth/sso/provider-operation-intent.ts @@ -0,0 +1,156 @@ +import { + db, + SSO_CALLBACK_INTENT_PREFIX, + SSO_DOMAIN_VERIFICATION_INTENT_PREFIX, + ssoProvider, + verification, + withSSOProviderMutationLock, +} from '@sim/db' +import { createLogger } from '@sim/logger' +import { generateId } from '@sim/utils/id' +import { and, eq, gt, inArray, like, lte, or } from 'drizzle-orm' +import { SSOManagementError } from '@/lib/auth/sso/management' + +const logger = createLogger('SSOProviderOperationIntent') +const SSO_PROVIDER_OPERATION_INTENT_TTL_MS = 10 * 60_000 + +type ProviderReference = { id: string; providerId: string } + +function operationIntentIdentifier(prefix: string, providerId: string): string { + return `${prefix}${providerId}` +} + +async function removeExpiredOperationIntents(now: Date): Promise { + await db + .delete(verification) + .where( + and( + or( + like(verification.identifier, `${SSO_CALLBACK_INTENT_PREFIX}%`), + like(verification.identifier, `${SSO_DOMAIN_VERIFICATION_INTENT_PREFIX}%`) + ), + lte(verification.expiresAt, now) + ) + ) +} + +async function insertOperationIntent( + prefix: string, + providerId: string, + intentId: string, + expiresAt: Date +): Promise { + await db.insert(verification).values({ + id: intentId, + identifier: operationIntentIdentifier(prefix, providerId), + value: intentId, + expiresAt, + }) +} + +async function removeOperationIntent(intentId: string, providerId: string): Promise { + try { + await db.delete(verification).where(eq(verification.id, intentId)) + } catch (error) { + logger.error('Failed to remove SSO provider operation intent', { + intentId, + providerId, + error, + }) + } +} + +export async function assertNoActiveSSOProviderOperations(providerId: string): Promise { + const [activeIntent] = await db + .select({ id: verification.id }) + .from(verification) + .where( + and( + inArray(verification.identifier, [ + operationIntentIdentifier(SSO_CALLBACK_INTENT_PREFIX, providerId), + operationIntentIdentifier(SSO_DOMAIN_VERIFICATION_INTENT_PREFIX, providerId), + ]), + gt(verification.expiresAt, new Date()) + ) + ) + .limit(1) + + if (activeIntent) { + throw new SSOManagementError( + 'An SSO operation is currently completing for this provider. Try again shortly.', + 409, + 'SSO_OPERATION_IN_PROGRESS' + ) + } +} + +export async function withSSOCallbackIntent( + providerId: string, + callback: () => Promise +): Promise { + const intentId = generateId() + const now = new Date() + const expiresAt = new Date(now.getTime() + SSO_PROVIDER_OPERATION_INTENT_TTL_MS) + const registered = await withSSOProviderMutationLock(async () => { + await removeExpiredOperationIntents(now) + + const [provider] = await db + .select({ id: ssoProvider.id }) + .from(ssoProvider) + .where(eq(ssoProvider.providerId, providerId)) + .limit(1) + if (!provider) return false + + await insertOperationIntent(SSO_CALLBACK_INTENT_PREFIX, providerId, intentId, expiresAt) + return true + }) + + try { + return await callback() + } finally { + if (registered) await removeOperationIntent(intentId, providerId) + } +} + +export async function withSSODomainVerificationIntent( + expectedProvider: ProviderReference, + callback: () => Promise +): Promise { + const intentId = generateId() + const now = new Date() + const expiresAt = new Date(now.getTime() + SSO_PROVIDER_OPERATION_INTENT_TTL_MS) + await withSSOProviderMutationLock(async () => { + await removeExpiredOperationIntents(now) + + const [provider] = await db + .select({ id: ssoProvider.id }) + .from(ssoProvider) + .where( + and( + eq(ssoProvider.id, expectedProvider.id), + eq(ssoProvider.providerId, expectedProvider.providerId) + ) + ) + .limit(1) + if (!provider) { + throw new SSOManagementError( + 'The SSO provider changed while domain verification was starting. Reload and try again.', + 409, + 'SSO_PROVIDER_CHANGED' + ) + } + + await insertOperationIntent( + SSO_DOMAIN_VERIFICATION_INTENT_PREFIX, + expectedProvider.providerId, + intentId, + expiresAt + ) + }) + + try { + return await callback() + } finally { + await removeOperationIntent(intentId, expectedProvider.providerId) + } +} diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index bbf32ad6ace..df6564e5bcd 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -479,6 +479,7 @@ export const env = createEnv({ // SSO Configuration (for script-based registration) SSO_ENABLED: z.boolean().optional(), // Enable SSO functionality + SSO_DOMAIN_VERIFICATION_ENABLED: z.boolean().optional(), // Enable verified-domain enforcement after the schema migration and provider audit SSO_PROVIDER_TYPE: z.enum(['oidc', 'saml']).optional(), // [REQUIRED] SSO provider type SSO_PROVIDER_ID: z.string().optional(), // [REQUIRED] SSO provider ID SSO_ISSUER: z.string().optional(), // [REQUIRED] SSO issuer URL diff --git a/bun.lock b/bun.lock index ad03b8e5f25..29d04010627 100644 --- a/bun.lock +++ b/bun.lock @@ -383,6 +383,7 @@ "@sim/utils": "workspace:*", "drizzle-orm": "^0.45.2", "postgres": "^3.4.5", + "tldts": "7.0.30", "zod": "4.3.6", }, "devDependencies": { diff --git a/packages/db/db.ts b/packages/db/db.ts index a122948995e..0eb2d6d6d57 100644 --- a/packages/db/db.ts +++ b/packages/db/db.ts @@ -2,6 +2,7 @@ import { drizzle } from 'drizzle-orm/postgres-js' import postgres from 'postgres' import { resolveDbUrl } from './connection-url' import * as schema from './schema' +import { SSO_PROVIDER_MUTATION_LOCK_KEY } from './sso-lock' import { instrumentPoolClient } from './tx-tripwire' /** @@ -47,6 +48,49 @@ const postgresClient = instrumentPoolClient( export const db = drizzle(postgresClient, { schema }) +let ssoProviderMutationTail = Promise.resolve() + +/** + * Serializes SSO provider create/update mutations across app processes. + * + * Parent/child domain overlap cannot be represented by a normal unique index, + * so callers must re-check availability while holding this lock and keep it + * through the Better Auth write. The lock uses a reserved connection and a + * transaction-scoped advisory lock so it remains safe behind transaction-mode + * poolers and is released automatically if the callback or process fails. + * + * The per-process queue prevents concurrent requests from reserving multiple + * primary-pool connections while they wait for the same cross-process lock. + */ +export async function withSSOProviderMutationLock(callback: () => Promise): Promise { + const predecessor = ssoProviderMutationTail + let releaseLocalLock!: () => void + ssoProviderMutationTail = new Promise((resolve) => { + releaseLocalLock = resolve + }) + + await predecessor + try { + const reserved = await postgresClient.reserve() + try { + await reserved.unsafe('BEGIN') + try { + await reserved.unsafe(`SELECT pg_advisory_xact_lock(${SSO_PROVIDER_MUTATION_LOCK_KEY})`) + const result = await callback() + await reserved.unsafe('COMMIT') + return result + } catch (error) { + await reserved.unsafe('ROLLBACK') + throw error + } + } finally { + reserved.release() + } + } finally { + releaseLocalLock() + } +} + /** * Opt-in read-replica client for reads that tolerate bounded staleness and have * no read-your-writes dependency (logs, exports, dashboard aggregations). Never diff --git a/packages/db/index.ts b/packages/db/index.ts index a472c56fa07..5291334a31a 100644 --- a/packages/db/index.ts +++ b/packages/db/index.ts @@ -1,5 +1,7 @@ export * from './connection-url' export * from './db' export * from './schema' +export * from './sso-domain' +export * from './sso-lock' export * from './triggers' export { instrumentPoolClient, runOutsideTransactionContext } from './tx-tripwire' diff --git a/packages/db/migrations/0266_zippy_the_phantom.sql b/packages/db/migrations/0266_zippy_the_phantom.sql new file mode 100644 index 00000000000..66b53edc691 --- /dev/null +++ b/packages/db/migrations/0266_zippy_the_phantom.sql @@ -0,0 +1,119 @@ +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM "sso_provider" + WHERE length("provider_id") > 44 + OR "provider_id" !~ '^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$' + ) THEN + RAISE EXCEPTION 'SSO migration blocked: provider IDs must be lowercase DNS labels no longer than 44 characters'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM "sso_provider" + WHERE "provider_id" IN ('google', 'github', 'email-password') + ) THEN + RAISE EXCEPTION 'SSO migration blocked: provider IDs reserved by built-in authentication providers require manual remediation'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM "sso_provider" + WHERE "domain" <> lower(btrim("domain")) + OR "domain" LIKE '%,%' + OR "domain" !~ '^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z](?:[a-z0-9-]{0,61}[a-z0-9])$' + ) THEN + RAISE EXCEPTION 'SSO migration blocked: domains must be one normalized registrable hostname; audit public-suffix ownership before rollout'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM "sso_provider" + WHERE "organization_id" IS NULL + ) THEN + RAISE EXCEPTION 'SSO migration blocked: user-scoped providers must be assigned to an audited organization or removed'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM "sso_provider" + GROUP BY "provider_id" + HAVING count(*) > 1 + ) THEN + RAISE EXCEPTION 'SSO migration blocked: duplicate provider IDs require manual remediation'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM "sso_provider" + GROUP BY lower("domain") + HAVING count(*) > 1 + ) THEN + RAISE EXCEPTION 'SSO migration blocked: case-insensitive duplicate domains require manual remediation'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM "sso_provider" AS parent_provider + JOIN "sso_provider" AS child_provider ON parent_provider."id" < child_provider."id" + WHERE lower(parent_provider."domain") LIKE '%.' || lower(child_provider."domain") + OR lower(child_provider."domain") LIKE '%.' || lower(parent_provider."domain") + ) THEN + RAISE EXCEPTION 'SSO migration blocked: parent/child domain overlaps require manual remediation'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM "sso_provider" + GROUP BY "organization_id" + HAVING count(*) > 1 + ) THEN + RAISE EXCEPTION 'SSO migration blocked: organizations with multiple providers require manual remediation'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM pg_catalog.pg_class AS index_class + JOIN pg_catalog.pg_index AS index_state ON index_state.indexrelid = index_class.oid + JOIN pg_catalog.pg_namespace AS index_namespace ON index_namespace.oid = index_class.relnamespace + WHERE index_namespace.nspname = current_schema() + AND index_class.relname IN ( + 'sso_provider_provider_id_unique', + 'sso_provider_domain_lower_unique', + 'sso_provider_organization_id_unique' + ) + AND NOT index_state.indisvalid + ) THEN + RAISE EXCEPTION 'SSO migration blocked: an invalid partial unique index must be removed before replay'; + END IF; +END $$;--> statement-breakpoint +ALTER TABLE "sso_provider" ADD COLUMN IF NOT EXISTS "domain_verified" boolean DEFAULT false NOT NULL;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "sso_provider" ADD CONSTRAINT "sso_provider_provider_id_format_check" CHECK (length("provider_id") <= 44 AND "provider_id" ~ '^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$') NOT VALID; +EXCEPTION WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "sso_provider" ADD CONSTRAINT "sso_provider_provider_id_not_reserved_check" CHECK ("provider_id" NOT IN ('google', 'github', 'email-password')) NOT VALID; +EXCEPTION WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "sso_provider" ADD CONSTRAINT "sso_provider_domain_format_check" CHECK ("domain" = lower(btrim("domain")) AND "domain" NOT LIKE '%,%' AND "domain" ~ '^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z](?:[a-z0-9-]{0,61}[a-z0-9])$') NOT VALID; +EXCEPTION WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "sso_provider" ADD CONSTRAINT "sso_provider_organization_required_check" CHECK ("organization_id" IS NOT NULL) NOT VALID; +EXCEPTION WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +ALTER TABLE "sso_provider" VALIDATE CONSTRAINT "sso_provider_provider_id_format_check";--> statement-breakpoint +ALTER TABLE "sso_provider" VALIDATE CONSTRAINT "sso_provider_provider_id_not_reserved_check";--> statement-breakpoint +ALTER TABLE "sso_provider" VALIDATE CONSTRAINT "sso_provider_domain_format_check";--> statement-breakpoint +ALTER TABLE "sso_provider" VALIDATE CONSTRAINT "sso_provider_organization_required_check";--> statement-breakpoint +COMMIT;--> statement-breakpoint +SET lock_timeout = 0;--> statement-breakpoint +CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "sso_provider_provider_id_unique" ON "sso_provider" USING btree ("provider_id");--> statement-breakpoint +CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "sso_provider_domain_lower_unique" ON "sso_provider" USING btree (lower("domain"));--> statement-breakpoint +CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "sso_provider_organization_id_unique" ON "sso_provider" USING btree ("organization_id") WHERE "organization_id" IS NOT NULL;--> statement-breakpoint +DROP INDEX CONCURRENTLY IF EXISTS "sso_provider_provider_id_idx";--> statement-breakpoint +DROP INDEX CONCURRENTLY IF EXISTS "sso_provider_domain_idx";--> statement-breakpoint +SET lock_timeout = '5s'; \ No newline at end of file diff --git a/packages/db/migrations/meta/0266_snapshot.json b/packages/db/migrations/meta/0266_snapshot.json new file mode 100644 index 00000000000..1ecde1ea55d --- /dev/null +++ b/packages/db/migrations/meta/0266_snapshot.json @@ -0,0 +1,17433 @@ +{ + "id": "38f2231b-db45-45b4-8605-b6753f65c87f", + "prevId": "7f5792f2-30c5-4a53-936f-3133d5378649", + "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": "'[]'" + }, + "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 + }, + "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": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_deleted_partial_idx": { + "name": "copilot_chats_user_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_chats\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_user_created_at_idx": { + "name": "copilot_messages_user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"role\" = 'user' AND \"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_block": { + "name": "custom_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_block_organization_id_idx": { + "name": "custom_block_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_workflow_id_idx": { + "name": "custom_block_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_organization_type_unique": { + "name": "custom_block_organization_type_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_block_organization_id_organization_id_fk": { + "name": "custom_block_organization_id_organization_id_fk", + "tableFrom": "custom_block", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_workflow_id_workflow_id_fk": { + "name": "custom_block_workflow_id_workflow_id_fk", + "tableFrom": "custom_block", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_created_by_user_id_fk": { + "name": "custom_block_created_by_user_id_fk", + "tableFrom": "custom_block", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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": {} + } + }, + "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.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 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_id_idx": { + "name": "kcsl_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mothership_settings_workspace_id_idx": { + "name": "mothership_settings_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "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.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.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": false + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.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 + }, + "domain_verified": { + "name": "domain_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sso_provider_provider_id_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_lower_unique": { + "name": "sso_provider_domain_lower_unique", + "columns": [ + { + "expression": "lower(\"domain\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "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": {} + }, + "sso_provider_organization_id_unique": { + "name": "sso_provider_organization_id_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"sso_provider\".\"organization_id\" IS NOT NULL", + "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": { + "sso_provider_provider_id_format_check": { + "name": "sso_provider_provider_id_format_check", + "value": "length(\"sso_provider\".\"provider_id\") <= 44 AND \"sso_provider\".\"provider_id\" ~ '^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$'" + }, + "sso_provider_provider_id_not_reserved_check": { + "name": "sso_provider_provider_id_not_reserved_check", + "value": "\"sso_provider\".\"provider_id\" NOT IN ('google', 'github', 'email-password')" + }, + "sso_provider_domain_format_check": { + "name": "sso_provider_domain_format_check", + "value": "\"sso_provider\".\"domain\" = lower(btrim(\"sso_provider\".\"domain\")) AND \"sso_provider\".\"domain\" NOT LIKE '%,%' AND \"sso_provider\".\"domain\" ~ '^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z](?:[a-z0-9-]{0,61}[a-z0-9])$'" + }, + "sso_provider_organization_required_check": { + "name": "sso_provider_organization_required_check", + "value": "\"sso_provider\".\"organization_id\" IS NOT NULL" + } + }, + "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.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_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 + }, + "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 + }, + "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_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_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_status": { + "name": "registration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_generation": { + "name": "registration_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config_fingerprint": { + "name": "config_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prepared_at": { + "name": "prepared_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_key": { + "name": "routing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_routing_key_active_idx": { + "name": "webhook_routing_key_active_idx", + "columns": [ + { + "expression": "routing_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NULL AND \"webhook\".\"routing_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_tiktok_credential_id_idx": { + "name": "webhook_tiktok_credential_id_idx", + "columns": [ + { + "expression": "((\"provider_config\")::jsonb ->> 'credentialId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"provider\" = 'tiktok' AND \"webhook\".\"is_active\" = true AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_active_registration_unique": { + "name": "webhook_active_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'active' AND \"webhook\".\"block_id\" IS NOT NULL AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_candidate_registration_unique": { + "name": "webhook_candidate_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'candidate' AND \"webhook\".\"block_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_registration_status_generation_idx": { + "name": "webhook_registration_status_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_registration_status_check": { + "name": "webhook_registration_status_check", + "value": "\"webhook\".\"registration_status\" IS NULL OR \"webhook\".\"registration_status\" IN ('active', 'candidate', 'retired', 'orphaned')" + }, + "webhook_registration_generation_check": { + "name": "webhook_registration_generation_check", + "value": "\"webhook\".\"registration_generation\" IS NULL OR \"webhook\".\"registration_generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.webhook_path_claim": { + "name": "webhook_path_claim", + "schema": "", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_path_claim_workflow_idx": { + "name": "webhook_path_claim_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_path_claim_workflow_id_workflow_id_fk": { + "name": "webhook_path_claim_workflow_id_workflow_id_fk", + "tableFrom": "webhook_path_claim", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_path_claim_generation_check": { + "name": "webhook_path_claim_generation_check", + "value": "\"webhook_path_claim\".\"generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fork_sync_excluded": { + "name": "fork_sync_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_workflow_folder_id_fk": { + "name": "workflow_folder_id_workflow_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "workflow_folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_operation": { + "name": "workflow_deployment_operation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_active_version_id": { + "name": "previous_active_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preparing'" + }, + "component_readiness": { + "name": "component_readiness", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_deployment_operation_workflow_generation_unique": { + "name": "workflow_deployment_operation_workflow_generation_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_idempotency_unique": { + "name": "workflow_deployment_operation_workflow_idempotency_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_in_flight_unique": { + "name": "workflow_deployment_operation_workflow_in_flight_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_status_idx": { + "name": "workflow_deployment_operation_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_deployment_version_idx": { + "name": "workflow_deployment_operation_deployment_version_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_version_generation_idx": { + "name": "workflow_deployment_operation_workflow_version_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_operation_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_operation_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["previous_active_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_deployment_operation_action_check": { + "name": "workflow_deployment_operation_action_check", + "value": "\"workflow_deployment_operation\".\"action\" IN ('deploy', 'activate')" + }, + "workflow_deployment_operation_status_check": { + "name": "workflow_deployment_operation_status_check", + "value": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating', 'active', 'failed', 'superseded')" + }, + "workflow_deployment_operation_generation_check": { + "name": "workflow_deployment_operation_generation_check", + "value": "\"workflow_deployment_operation\".\"generation\" > 0" + }, + "workflow_deployment_operation_protocol_version_check": { + "name": "workflow_deployment_operation_protocol_version_check", + "value": "\"workflow_deployment_operation\".\"protocol_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_id_desc_idx": { + "name": "workflow_execution_logs_workspace_started_at_id_desc_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_completed_ended_at_idx": { + "name": "workflow_execution_logs_completed_ended_at_idx", + "columns": [ + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'completed' AND \"workflow_execution_logs\".\"level\" = 'info' AND \"workflow_execution_logs\".\"ended_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_folder": { + "name": "workflow_folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'#6B7280'" + }, + "is_expanded": { + "name": "is_expanded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_folder_user_idx": { + "name": "workflow_folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_parent_idx": { + "name": "workflow_folder_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_parent_sort_idx": { + "name": "workflow_folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_archived_at_idx": { + "name": "workflow_folder_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_archived_partial_idx": { + "name": "workflow_folder_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_folder\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_folder_user_id_user_id_fk": { + "name": "workflow_folder_user_id_user_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_workspace_id_workspace_id_fk": { + "name": "workflow_folder_workspace_id_workspace_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "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 + }, + "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 + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "organization_assigned_at": { + "name": "organization_assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "forked_from_workspace_id": { + "name": "forked_from_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_forked_from_workspace_id_idx": { + "name": "workspace_forked_from_workspace_id_idx", + "columns": [ + { + "expression": "forked_from_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_forked_from_workspace_id_workspace_id_fk": { + "name": "workspace_forked_from_workspace_id_workspace_id_fk", + "tableFrom": "workspace", + "tableTo": "workspace", + "columnsFrom": ["forked_from_workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_storage_used_bytes_non_negative": { + "name": "workspace_storage_used_bytes_non_negative", + "value": "\"workspace\".\"storage_used_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_workspace_provider_idx": { + "name": "workspace_byok_workspace_provider_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_key_idx": { + "name": "workspace_file_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_folders": { + "name": "workspace_file_folders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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": { + "workspace_file_folders_workspace_parent_idx": { + "name": "workspace_file_folders_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_parent_sort_idx": { + "name": "workspace_file_folders_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": {} + }, + "workspace_file_folders_deleted_at_idx": { + "name": "workspace_file_folders_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_deleted_partial_idx": { + "name": "workspace_file_folders_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_folders\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_parent_name_active_unique": { + "name": "workspace_file_folders_workspace_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "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": "\"workspace_file_folders\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_folders_user_id_user_id_fk": { + "name": "workspace_file_folders_user_id_user_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_workspace_id_workspace_id_fk": { + "name": "workspace_file_folders_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_parent_id_workspace_file_folders_id_fk": { + "name": "workspace_file_folders_parent_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace_file_folders", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "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_workspace_file_folders_id_fk": { + "name": "workspace_files_folder_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace_file_folders", + "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 + } + }, + "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.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": ["oauth", "env_workspace", "env_personal", "service_account"] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment" + ] + }, + "public.workspace_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": ["push", "pull"] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "mcp_server", + "workflow_mcp_server", + "custom_tool", + "skill" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index acf8becd8dd..ddea6ce3e58 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1856,6 +1856,13 @@ "when": 1784743254570, "tag": "0265_milky_gunslinger", "breakpoints": true + }, + { + "idx": 266, + "version": "7", + "when": 1784759628537, + "tag": "0266_zippy_the_phantom", + "breakpoints": true } ] } diff --git a/packages/db/package.json b/packages/db/package.json index 62e398b5b19..7e6a051dfb2 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -21,6 +21,7 @@ "scripts": { "db:push": "bunx drizzle-kit push --config=./drizzle.config.ts", "db:migrate": "bun --env-file=.env run ./scripts/migrate.ts", + "db:audit-sso-providers": "bun --env-file=.env run ./scripts/audit-sso-providers.ts", "db:reconcile-workspace-storage": "bun --env-file=.env run ./scripts/reconcile-workspace-storage.ts", "db:studio": "bunx drizzle-kit studio --config=./drizzle.config.ts", "test": "vitest run", @@ -35,6 +36,7 @@ "@sim/utils": "workspace:*", "drizzle-orm": "^0.45.2", "postgres": "^3.4.5", + "tldts": "7.0.30", "zod": "4.3.6" }, "devDependencies": { diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 75f815398e5..360ce52bee7 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -2959,6 +2959,7 @@ export const ssoProvider = pgTable( id: text('id').primaryKey(), issuer: text('issuer').notNull(), domain: text('domain').notNull(), + domainVerified: boolean('domain_verified').notNull().default(false), oidcConfig: text('oidc_config'), samlConfig: text('saml_config'), userId: text('user_id') @@ -2970,10 +2971,31 @@ export const ssoProvider = pgTable( }), }, (table) => ({ - providerIdIdx: index('sso_provider_provider_id_idx').on(table.providerId), - domainIdx: index('sso_provider_domain_idx').on(table.domain), + providerIdUnique: uniqueIndex('sso_provider_provider_id_unique').on(table.providerId), + domainLowerUnique: uniqueIndex('sso_provider_domain_lower_unique').on( + sql`lower(${table.domain})` + ), userIdIdx: index('sso_provider_user_id_idx').on(table.userId), organizationIdIdx: index('sso_provider_organization_id_idx').on(table.organizationId), + organizationIdUnique: uniqueIndex('sso_provider_organization_id_unique') + .on(table.organizationId) + .where(sql`${table.organizationId} IS NOT NULL`), + providerIdFormat: check( + 'sso_provider_provider_id_format_check', + sql`length(${table.providerId}) <= 44 AND ${table.providerId} ~ '^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$'` + ), + providerIdNotReserved: check( + 'sso_provider_provider_id_not_reserved_check', + sql`${table.providerId} NOT IN ('google', 'github', 'email-password')` + ), + domainFormat: check( + 'sso_provider_domain_format_check', + sql`${table.domain} = lower(btrim(${table.domain})) AND ${table.domain} NOT LIKE '%,%' AND ${table.domain} ~ '^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z](?:[a-z0-9-]{0,61}[a-z0-9])$'` + ), + organizationRequired: check( + 'sso_provider_organization_required_check', + sql`${table.organizationId} IS NOT NULL` + ), }) ) diff --git a/packages/db/scripts/audit-sso-providers.ts b/packages/db/scripts/audit-sso-providers.ts new file mode 100644 index 00000000000..f3adaeff334 --- /dev/null +++ b/packages/db/scripts/audit-sso-providers.ts @@ -0,0 +1,113 @@ +#!/usr/bin/env bun + +import { drizzle } from 'drizzle-orm/postgres-js' +import postgres from 'postgres' +import { parse as parseDomain } from 'tldts' +import { account, session, ssoProvider } from '../schema' +import { ssoDomainsOverlap } from '../sso-domain' + +const connectionString = process.env.POSTGRES_URL ?? process.env.DATABASE_URL +if (!connectionString) { + throw new Error('POSTGRES_URL or DATABASE_URL is required') +} + +const RESERVED_PROVIDER_IDS = new Set(['google', 'github', 'email-password']) + +function isValidProviderId(value: string): boolean { + return ( + value.length <= 44 && + /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(value) && + !RESERVED_PROVIDER_IDS.has(value) + ) +} + +function isRegistrableDomain(value: string): boolean { + const parsed = parseDomain(value, { allowPrivateDomains: true, validateHostname: true }) + return ( + value === value.trim().toLowerCase() && + !value.includes(',') && + parsed.hostname === value && + Boolean(parsed.publicSuffix) && + parsed.publicSuffix !== value + ) +} + +const client = postgres(connectionString, { + prepare: false, + max: 1, + onnotice: () => {}, +}) + +try { + const db = drizzle(client) + const [providers, accountLinks, activeSessions] = await Promise.all([ + db.select().from(ssoProvider), + db.select({ providerId: account.providerId, userId: account.userId }).from(account), + db.select({ userId: session.userId, expiresAt: session.expiresAt }).from(session), + ]) + const findings: string[] = [] + const approvedProviderIds = new Set( + (process.env.SSO_AUDIT_APPROVED_PROVIDER_IDS ?? '') + .split(',') + .map((value) => value.trim()) + .filter(Boolean) + ) + const now = Date.now() + + for (const provider of providers) { + if (!provider.organizationId) { + findings.push(`${provider.providerId}: missing organization ownership`) + } + if (!isValidProviderId(provider.providerId)) { + findings.push(`${provider.providerId}: invalid provider ID`) + } + if (!isRegistrableDomain(provider.domain)) { + findings.push(`${provider.providerId}: '${provider.domain}' is not a registrable domain`) + } + + const linkedUserIds = new Set( + accountLinks + .filter((link) => link.providerId === provider.providerId) + .map((link) => link.userId) + ) + const sessionCount = activeSessions.filter( + (candidate) => linkedUserIds.has(candidate.userId) && candidate.expiresAt.getTime() > now + ).length + if (linkedUserIds.size > 0) { + console.log( + `${provider.providerId}: ${linkedUserIds.size} linked user(s), ${sessionCount} active session(s)` + ) + if (!approvedProviderIds.has(provider.providerId)) { + findings.push( + `${provider.providerId}: linked users/sessions require an explicit retain-or-migrate decision in SSO_AUDIT_APPROVED_PROVIDER_IDS` + ) + } + } + } + + for (let leftIndex = 0; leftIndex < providers.length; leftIndex += 1) { + for (let rightIndex = leftIndex + 1; rightIndex < providers.length; rightIndex += 1) { + const left = providers[leftIndex] + const right = providers[rightIndex] + if (left.providerId === right.providerId) { + findings.push(`duplicate provider ID: ${left.providerId}`) + } + if (left.organizationId && left.organizationId === right.organizationId) { + findings.push(`organization ${left.organizationId} owns multiple providers`) + } + if (ssoDomainsOverlap(left.domain, right.domain)) { + findings.push(`overlapping domains: ${left.domain} and ${right.domain}`) + } + } + } + + if (findings.length > 0) { + console.error('SSO provider audit failed:') + for (const finding of [...new Set(findings)]) console.error(`- ${finding}`) + process.exitCode = 1 + } else { + console.log(`SSO provider audit passed (${providers.length} provider rows checked).`) + } +} finally { + await client.end({ timeout: 5 }) +} diff --git a/packages/db/scripts/deregister-sso-provider.ts b/packages/db/scripts/deregister-sso-provider.ts index 41df8750aa2..b381672bd0c 100644 --- a/packages/db/scripts/deregister-sso-provider.ts +++ b/packages/db/scripts/deregister-sso-provider.ts @@ -3,21 +3,28 @@ /** * Deregister SSO Provider Script * - * This script removes an SSO provider from the database for a specific user. + * This script removes one audited organization SSO provider when it has no + * Better Auth account links. * * Usage: bun run packages/db/scripts/deregister-sso-provider.ts * * Required Environment Variables: * DATABASE_URL=your-database-url - * SSO_USER_EMAIL=user@domain.com (user whose SSO provider to remove) - * SSO_PROVIDER_ID=provider-id (optional, if not provided will remove all providers for user) + * SSO_USER_EMAIL=user@domain.com (must be an organization owner/admin) + * SSO_ORGANIZATION_ID=organization-id + * SSO_PROVIDER_ID=provider-id */ import { getErrorMessage } from '@sim/utils/errors' -import { and, eq } from 'drizzle-orm' +import { and, eq, gt, inArray, sql } from 'drizzle-orm' import { drizzle } from 'drizzle-orm/postgres-js' import postgres from 'postgres' -import { ssoProvider, user } from '../schema' +import { account, member, ssoProvider, user, verification } from '../schema' +import { + SSO_CALLBACK_INTENT_PREFIX, + SSO_DOMAIN_VERIFICATION_INTENT_PREFIX, + SSO_PROVIDER_MUTATION_LOCK_KEY, +} from '../sso-lock' const logger = { info: (message: string, meta?: any) => { @@ -75,15 +82,15 @@ async function getUser(email: string): Promise<{ id: string; email: string } | n async function deregisterSSOProvider(): Promise { try { const userEmail = process.env.SSO_USER_EMAIL - if (!userEmail) { - logger.error('❌ SSO_USER_EMAIL environment variable is required') + const organizationId = process.env.SSO_ORGANIZATION_ID?.trim() + const specificProviderId = process.env.SSO_PROVIDER_ID?.trim() + if (!userEmail || !organizationId || !specificProviderId) { + logger.error('❌ SSO_USER_EMAIL, SSO_ORGANIZATION_ID, and SSO_PROVIDER_ID are all required') logger.error('') logger.error('Example usage:') logger.error( - ' SSO_USER_EMAIL=admin@company.com bun run packages/db/scripts/deregister-sso-provider.ts' + ' SSO_USER_EMAIL=admin@company.com SSO_ORGANIZATION_ID=org-id SSO_PROVIDER_ID=provider-id bun run packages/db/scripts/deregister-sso-provider.ts' ) - logger.error('') - logger.error('Optional: SSO_PROVIDER_ID=provider-id (to remove specific provider)') return false } @@ -94,46 +101,71 @@ async function deregisterSSOProvider(): Promise { logger.info(`Found user: ${targetUser.email} (ID: ${targetUser.id})`) - const providers = await db - .select() - .from(ssoProvider) - .where(eq(ssoProvider.userId, targetUser.id)) - - if (providers.length === 0) { - logger.warn(`No SSO providers found for user: ${targetUser.email}`) + const memberships = await db + .select({ role: member.role }) + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, targetUser.id))) + if (!memberships.some(({ role }) => role === 'owner' || role === 'admin')) { + logger.error('SSO_USER_EMAIL must be an owner or admin of SSO_ORGANIZATION_ID') return false } - logger.info(`Found ${providers.length} SSO provider(s) for user ${targetUser.email}`) - for (const provider of providers) { - logger.info(` - Provider ID: ${provider.providerId}, Domain: ${provider.domain}`) - } - - const specificProviderId = process.env.SSO_PROVIDER_ID + const deleted = await db.transaction(async (tx) => { + await tx.execute(sql`SELECT pg_advisory_xact_lock(${SSO_PROVIDER_MUTATION_LOCK_KEY}::bigint)`) + const providers = await tx + .select() + .from(ssoProvider) + .where( + and( + eq(ssoProvider.organizationId, organizationId), + eq(ssoProvider.providerId, specificProviderId) + ) + ) - if (specificProviderId) { - const providerToDelete = providers.find((p) => p.providerId === specificProviderId) - if (!providerToDelete) { - logger.error(`Provider '${specificProviderId}' not found for user ${targetUser.email}`) + if (providers.length === 0) { + logger.warn( + `Provider '${specificProviderId}' was not found in organization '${organizationId}'` + ) return false } - await db - .delete(ssoProvider) + const [activeCallback] = await tx + .select({ id: verification.id }) + .from(verification) .where( - and(eq(ssoProvider.userId, targetUser.id), eq(ssoProvider.providerId, specificProviderId)) + and( + inArray(verification.identifier, [ + `${SSO_CALLBACK_INTENT_PREFIX}${specificProviderId}`, + `${SSO_DOMAIN_VERIFICATION_INTENT_PREFIX}${specificProviderId}`, + ]), + gt(verification.expiresAt, new Date()) + ) ) + .limit(1) + if (activeCallback) { + logger.error('Refusing to delete a provider while an SSO operation is in progress') + return false + } - logger.info( - `✅ Successfully deleted SSO provider '${specificProviderId}' for user ${targetUser.email}` - ) - } else { - await db.delete(ssoProvider).where(eq(ssoProvider.userId, targetUser.id)) + const linkedAccounts = await tx + .select({ id: account.id }) + .from(account) + .where(eq(account.providerId, specificProviderId)) + .limit(1) + if (linkedAccounts.length > 0) { + logger.error( + 'Refusing to delete a provider with Better Auth account links; migrate links and sessions first' + ) + return false + } - logger.info( - `✅ Successfully deleted all ${providers.length} SSO provider(s) for user ${targetUser.email}` - ) - } + await tx.delete(ssoProvider).where(eq(ssoProvider.id, providers[0].id)) + return true + }) + if (!deleted) return false + logger.info( + `✅ Successfully deleted SSO provider '${specificProviderId}' from organization '${organizationId}'` + ) return true } catch (error) { diff --git a/packages/db/scripts/register-sso-provider.ts b/packages/db/scripts/register-sso-provider.ts index a52c6fa45e1..e677b636f4e 100644 --- a/packages/db/scripts/register-sso-provider.ts +++ b/packages/db/scripts/register-sso-provider.ts @@ -1,11 +1,11 @@ #!/usr/bin/env bun /** - * Direct Database SSO Registration Script (Better Auth Best Practice) + * Audited Direct Database SSO Registration Script * * This script bypasses the authentication requirement by directly inserting - * SSO provider records into the database, following the exact same logic - * as Better Auth's registerSSOProvider endpoint. + * SSO provider records into the database while enforcing Sim's organization, + * domain, provider-ID, verification-reset, and account-link invariants. * * Usage: bun run packages/db/scripts/register-sso-provider.ts * @@ -16,6 +16,7 @@ * SSO_ISSUER=https://your-idp-url * SSO_DOMAIN=your-email-domain.com * SSO_USER_EMAIL=admin@yourdomain.com (must be existing user) + * SSO_ORGANIZATION_ID=your-organization-id (user must be an owner/admin) * * OIDC Providers: * SSO_OIDC_CLIENT_ID=your_client_id @@ -40,10 +41,13 @@ import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { eq } from 'drizzle-orm' +import { and, eq, sql } from 'drizzle-orm' import { drizzle } from 'drizzle-orm/postgres-js' import postgres from 'postgres' -import { ssoProvider, user } from '../schema' +import { parse as parseDomain } from 'tldts' +import { member, organization, ssoProvider, user } from '../schema' +import { ssoDomainsOverlap } from '../sso-domain' +import { SSO_PROVIDER_MUTATION_LOCK_KEY } from '../sso-lock' interface SSOMapping { id: string @@ -162,7 +166,30 @@ interface SSOProviderData { samlConfig?: string userId: string providerId: string - organizationId?: string + organizationId: string + domainVerified: boolean +} + +const RESERVED_PROVIDER_IDS = new Set(['google', 'github', 'email-password']) + +function normalizeProviderId(value: string): string | null { + const normalized = value.trim().toLowerCase() + return normalized.length <= 44 && + /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(normalized) && + !RESERVED_PROVIDER_IDS.has(normalized) + ? normalized + : null +} + +function normalizeDomain(value: string): string | null { + const normalized = value.trim().toLowerCase().replace(/\.$/, '') + const parsed = parseDomain(normalized, { allowPrivateDomains: true, validateHostname: true }) + return parsed.hostname === normalized && + parsed.publicSuffix && + parsed.publicSuffix !== normalized && + !normalized.includes(',') + ? normalized + : null } function buildSSOConfigFromEnv(): SSOProviderConfig | null { @@ -172,16 +199,23 @@ function buildSSOConfigFromEnv(): SSOProviderConfig | null { const providerId = process.env.SSO_PROVIDER_ID const issuer = process.env.SSO_ISSUER const domain = process.env.SSO_DOMAIN - const providerType = process.env.SSO_PROVIDER_TYPE as 'oidc' | 'saml' - - if (!providerId || !issuer || !domain || !providerType) { + const providerType = process.env.SSO_PROVIDER_TYPE + + const normalizedProviderId = providerId ? normalizeProviderId(providerId) : null + const normalizedDomain = domain ? normalizeDomain(domain) : null + if ( + !normalizedProviderId || + !issuer || + !normalizedDomain || + (providerType !== 'oidc' && providerType !== 'saml') + ) { return null } const config: SSOProviderConfig = { - providerId, + providerId: normalizedProviderId, issuer, - domain, + domain: normalizedDomain, providerType, } @@ -339,6 +373,7 @@ function getExampleEnvVars( SSO_PROVIDER_ID: provider || (providerType === 'oidc' ? 'okta' : 'adfs'), SSO_DOMAIN: 'yourcompany.com', SSO_USER_EMAIL: 'admin@yourcompany.com', + SSO_ORGANIZATION_ID: 'your-organization-id', } if (providerType === 'oidc') { @@ -410,6 +445,34 @@ async function getAdminUser(): Promise<{ id: string; email: string } | null> { } } +async function getAuditedOrganization(userId: string): Promise { + const organizationId = process.env.SSO_ORGANIZATION_ID?.trim() + if (!organizationId) { + logger.error('SSO_ORGANIZATION_ID is required; user-scoped SSO providers are unsupported') + return null + } + + const [organizationRow, membership] = await Promise.all([ + db + .select({ id: organization.id }) + .from(organization) + .where(eq(organization.id, organizationId)), + db + .select({ role: member.role }) + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, userId))), + ]) + if (organizationRow.length === 0) { + logger.error(`Organization '${organizationId}' does not exist`) + return null + } + if (!membership.some(({ role }) => role === 'owner' || role === 'admin')) { + logger.error('SSO_USER_EMAIL must be an owner or admin of SSO_ORGANIZATION_ID') + return null + } + return organizationId +} + async function registerSSOProvider(): Promise { try { const ssoConfig = buildSSOConfigFromEnv() @@ -438,6 +501,10 @@ async function registerSSOProvider(): Promise { if (!adminUser) { return false } + const organizationId = await getAuditedOrganization(adminUser.id) + if (!organizationId) { + return false + } logger.info('Registering SSO provider directly in database...', { providerId: ssoConfig.providerId, @@ -447,7 +514,8 @@ async function registerSSOProvider(): Promise { }) try { - new URL(ssoConfig.issuer) + const issuer = new URL(ssoConfig.issuer) + if (!['http:', 'https:'].includes(issuer.protocol)) throw new Error('Unsupported protocol') } catch { logger.error('Invalid issuer. Must be a valid URL:', ssoConfig.issuer) return false @@ -560,23 +628,14 @@ async function registerSSOProvider(): Promise { } } - const existingProviders = await db - .select() - .from(ssoProvider) - .where(eq(ssoProvider.providerId, ssoConfig.providerId)) - - if (existingProviders.length > 0) { - logger.warn(`SSO provider with ID '${ssoConfig.providerId}' already exists`) - logger.info('Updating existing provider...') - } - const providerData: SSOProviderData = { id: generateId(), issuer: ssoConfig.issuer, domain: ssoConfig.domain, userId: adminUser.id, providerId: ssoConfig.providerId, - organizationId: process.env.SSO_ORGANIZATION_ID || undefined, + organizationId, + domainVerified: false, } if (ssoConfig.providerType === 'oidc' && ssoConfig.oidcConfig) { @@ -625,21 +684,32 @@ async function registerSSOProvider(): Promise { providerData.samlConfig = JSON.stringify(samlConfig) } - if (existingProviders.length > 0) { - await db - .update(ssoProvider) - .set({ - issuer: providerData.issuer, - domain: providerData.domain, - oidcConfig: providerData.oidcConfig, - samlConfig: providerData.samlConfig, - userId: providerData.userId, - organizationId: providerData.organizationId, - }) - .where(eq(ssoProvider.providerId, ssoConfig.providerId)) - } else { - await db.insert(ssoProvider).values(providerData) - } + const inserted = await db.transaction(async (tx) => { + await tx.execute(sql`SELECT pg_advisory_xact_lock(${SSO_PROVIDER_MUTATION_LOCK_KEY}::bigint)`) + const allProviders = await tx.select().from(ssoProvider) + if (allProviders.some((provider) => provider.providerId === ssoConfig.providerId)) { + logger.error( + 'The provider ID already exists. Direct script updates are intentionally unsupported; use the guarded Settings API.' + ) + return false + } + if ( + allProviders.some( + (provider) => + provider.organizationId === organizationId || + ssoDomainsOverlap(provider.domain, ssoConfig.domain) + ) + ) { + logger.error( + 'The organization already has a provider or its domain overlaps another provider' + ) + return false + } + + await tx.insert(ssoProvider).values(providerData) + return true + }) + if (!inserted) return false logger.info('✅ SSO provider registered successfully in database!', { providerId: ssoConfig.providerId, @@ -648,7 +718,13 @@ async function registerSSOProvider(): Promise { id: providerData.id, }) - logger.info('🔗 Users can now sign in using SSO') + if (process.env.SSO_DOMAIN_VERIFICATION_ENABLED === 'true') { + logger.info('🔐 Provider created pending DNS domain verification in the Settings UI') + } else { + logger.info( + '🔗 Users can now sign in using SSO (domain verification enforcement is disabled)' + ) + } const baseUrl = process.env.NEXT_PUBLIC_APP_URL || process.env.BETTER_AUTH_URL || 'https://your-domain.com' @@ -678,10 +754,10 @@ async function registerSSOProvider(): Promise { } async function main() { - console.log('🔐 Direct Database SSO Registration Script (Better Auth Best Practice)') + console.log('🔐 Audited Direct Database SSO Registration Script') console.log('====================================================================') console.log('This script directly inserts SSO provider records into the database.') - console.log("It follows Better Auth's exact registerSSOProvider logic.\n") + console.log('It enforces the same organization and identity invariants as Sim management APIs.\n') const success = await registerSSOProvider() diff --git a/packages/db/sso-domain.test.ts b/packages/db/sso-domain.test.ts new file mode 100644 index 00000000000..8fea89a91a9 --- /dev/null +++ b/packages/db/sso-domain.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { ssoDomainsOverlap } from './sso-domain' + +describe('ssoDomainsOverlap', () => { + it.each([ + ['acme.com', 'acme.com'], + ['ACME.COM', 'acme.com'], + ['LOGIN.ACME.COM', 'acme.com'], + ['acme.com', 'LOGIN.ACME.COM'], + ])('detects exact and parent/child overlap between %s and %s', (left, right) => { + expect(ssoDomainsOverlap(left, right)).toBe(true) + }) + + it('does not treat a label suffix as a subdomain', () => { + expect(ssoDomainsOverlap('notacme.com', 'acme.com')).toBe(false) + }) +}) diff --git a/packages/db/sso-domain.ts b/packages/db/sso-domain.ts new file mode 100644 index 00000000000..0f8b37ecf00 --- /dev/null +++ b/packages/db/sso-domain.ts @@ -0,0 +1,9 @@ +export function ssoDomainsOverlap(left: string, right: string): boolean { + const normalizedLeft = left.toLowerCase() + const normalizedRight = right.toLowerCase() + return ( + normalizedLeft === normalizedRight || + normalizedLeft.endsWith(`.${normalizedRight}`) || + normalizedRight.endsWith(`.${normalizedLeft}`) + ) +} diff --git a/packages/db/sso-lock.ts b/packages/db/sso-lock.ts new file mode 100644 index 00000000000..4b9a01fc4f4 --- /dev/null +++ b/packages/db/sso-lock.ts @@ -0,0 +1,3 @@ +export const SSO_PROVIDER_MUTATION_LOCK_KEY = 4_961_002_271n +export const SSO_CALLBACK_INTENT_PREFIX = 'sso-callback-intent:' +export const SSO_DOMAIN_VERIFICATION_INTENT_PREFIX = 'sso-domain-verification-intent:' diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 14a54b13f51..8289bba83d8 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: 966, - zodRoutes: 966, + totalRoutes: 969, + zodRoutes: 969, nonZodRoutes: 0, } as const