diff --git a/apps/docs/content/docs/platform/self-hosting/background-jobs.mdx b/apps/docs/content/docs/platform/self-hosting/background-jobs.mdx index e5aa393b91c..c2663178d2f 100644 --- a/apps/docs/content/docs/platform/self-hosting/background-jobs.mdx +++ b/apps/docs/content/docs/platform/self-hosting/background-jobs.mdx @@ -49,6 +49,7 @@ Point cron at an **internal** address where possible (the in-cluster Service, or | Workspace file search dispatch | `/api/cron/workspace-file-search-dispatch` | `*/1 * * * *` | Dispatches indexing work for workspace file search | | Connector sync | `/api/knowledge/connectors/sync` | `*/5 * * * *` | Knowledge base connector syncs | | Connector member sync | `/api/knowledge/connectors/member-sync` | `*/5 * * * *` | Per-member access sync for permission-aware connectors | +| Connector directory sync | `/api/knowledge/connectors/directory-sync` | `*/5 * * * *` | Refreshes the directory groups administrator-mode connectors mirror, so a membership change takes effect without waiting for a content sync | | Workspace events poll | `/api/workspace-events/poll` | `*/15 * * * *` | Workspace event triggers | | Table row TTL cleanup | `/api/cron/cleanup-table-row-ttl` | `*/15 * * * *` | Deletes table rows whose TTL column has expired | | Data drains | `/api/cron/run-data-drains` | `0 * * * *` | Enterprise data drains | diff --git a/apps/sim/app/api/knowledge/connectors/directory-sync/route.test.ts b/apps/sim/app/api/knowledge/connectors/directory-sync/route.test.ts new file mode 100644 index 00000000000..4048c35577e --- /dev/null +++ b/apps/sim/app/api/knowledge/connectors/directory-sync/route.test.ts @@ -0,0 +1,76 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockVerifyCronAuth, mockConnectorRows, mockDispatch } = vi.hoisted(() => ({ + mockVerifyCronAuth: vi.fn(() => null), + mockConnectorRows: vi.fn(), + mockDispatch: vi.fn(), +})) + +vi.mock('@/lib/auth/internal', () => ({ verifyCronAuth: mockVerifyCronAuth })) +vi.mock('@/lib/knowledge/connectors/directory-queue', () => ({ + dispatchDirectorySync: mockDispatch, +})) +vi.mock('@sim/db', () => ({ + db: { + select: () => ({ + from: () => ({ + innerJoin: () => ({ + where: () => ({ orderBy: () => ({ limit: () => mockConnectorRows() }) }), + }), + }), + }), + }, +})) + +import { GET } from '@/app/api/knowledge/connectors/directory-sync/route' + +function connector(overrides: Record = {}) { + return { id: 'connector-1', ...overrides } +} + +async function run() { + const response = await GET(createMockRequest('GET')) + return response.json() +} + +describe('connector directory sync scheduler', () => { + beforeEach(() => { + vi.clearAllMocks() + mockVerifyCronAuth.mockReturnValue(null) + mockDispatch.mockResolvedValue(undefined) + }) + + /** + * Every eligible connector is offered under one tick time; the tenant-level + * freshness check in the refresh, not the scheduler, decides which walk. + */ + it('dispatches a refresh for every admin-mode connector under the same tick', async () => { + mockConnectorRows.mockResolvedValue([connector(), connector({ id: 'connector-2' })]) + + await expect(run()).resolves.toMatchObject({ considered: 2, dispatched: 2, failed: 0 }) + expect(mockDispatch).toHaveBeenCalledTimes(2) + const [, first] = mockDispatch.mock.calls[0] + const [, second] = mockDispatch.mock.calls[1] + expect(first.tickAt).toBe(second.tickAt) + }) + + it('contains a dispatch failure to the connector that caused it', async () => { + mockConnectorRows.mockResolvedValue([connector(), connector({ id: 'connector-2' })]) + mockDispatch.mockRejectedValueOnce(new Error('queue unreachable')) + + await expect(run()).resolves.toMatchObject({ dispatched: 1, failed: 1 }) + }) + + it('refuses an unauthenticated tick', async () => { + mockVerifyCronAuth.mockReturnValue(new Response('nope', { status: 401 })) + + const response = await GET(createMockRequest('GET')) + + expect(response.status).toBe(401) + expect(mockConnectorRows).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/knowledge/connectors/directory-sync/route.ts b/apps/sim/app/api/knowledge/connectors/directory-sync/route.ts new file mode 100644 index 00000000000..cf11790a382 --- /dev/null +++ b/apps/sim/app/api/knowledge/connectors/directory-sync/route.ts @@ -0,0 +1,83 @@ +import { db } from '@sim/db' +import { knowledgeBase, knowledgeConnector } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { and, asc, eq, inArray, isNotNull, isNull } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { verifyCronAuth } from '@/lib/auth/internal' +import { mapWithConcurrency } from '@/lib/core/utils/concurrency' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { MIRRORING_ACCESS_MODES } from '@/lib/knowledge/connectors/access-modes' +import { dispatchDirectorySync } from '@/lib/knowledge/connectors/directory-queue' +import { RUNNABLE_CONNECTOR_STATUSES } from '@/lib/knowledge/connectors/sync-lock' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('ConnectorDirectorySyncSchedulerAPI') + +/** Connectors offered per tick, and how many dispatches are in flight at once. */ +const MAX_DIRECTORIES_PER_TICK = 200 +const DISPATCH_CONCURRENCY = 8 + +/** + * Refreshes the external directories that admin-mode connectors mirror. + * + * Group membership decides who can read an already-indexed document, so it has + * to move on its own clock: someone leaving a group should lose access in + * minutes, not on whatever schedule the corpus happens to be re-crawled on. The + * admin crawl refreshes the directory too — so a crawl can never publish grants + * against membership nobody has read — but that is a floor, not the cadence. + * + * Every eligible connector is offered each tick, and + * `syncExternalDirectoryGroups` decides whether its directory is actually due: + * a tenant is the credential's own site or domain, which the row does not + * carry, so connectors sharing one cost a refresh and a skip rather than a + * refresh each. The walk itself runs in the background, like every other + * connector job, because a large domain takes longer than a scheduler request + * lives. + */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + const tickAt = new Date() + logger.info(`[${requestId}] Connector directory sync scheduler triggered`) + + const authError = verifyCronAuth(request, 'Connector directory sync scheduler') + if (authError) return authError + + const connectors = await db + .select({ id: knowledgeConnector.id }) + .from(knowledgeConnector) + .innerJoin(knowledgeBase, eq(knowledgeConnector.knowledgeBaseId, knowledgeBase.id)) + .where( + and( + inArray(knowledgeConnector.accessMode, MIRRORING_ACCESS_MODES), + inArray(knowledgeConnector.status, RUNNABLE_CONNECTOR_STATUSES), + isNull(knowledgeConnector.archivedAt), + isNull(knowledgeConnector.deletedAt), + isNull(knowledgeBase.deletedAt), + isNotNull(knowledgeBase.workspaceId) + ) + ) + .orderBy(asc(knowledgeConnector.createdAt)) + .limit(MAX_DIRECTORIES_PER_TICK) + + let dispatched = 0 + let failed = 0 + await mapWithConcurrency(connectors, DISPATCH_CONCURRENCY, async ({ id: connectorId }) => { + try { + await dispatchDirectorySync(connectorId, { requestId, tickAt }) + dispatched += 1 + } catch (error) { + failed += 1 + logger.error(`[${requestId}] Failed to dispatch a directory refresh`, { + connectorId, + error: getErrorMessage(error), + }) + } + }) + + const summary = { considered: connectors.length, dispatched, failed } + logger.info(`[${requestId}] Connector directory sync scheduler finished`, summary) + return Response.json({ success: true, ...summary }) +}) diff --git a/apps/sim/app/api/knowledge/connectors/member-sync/route.ts b/apps/sim/app/api/knowledge/connectors/member-sync/route.ts index 076344f81dd..5b65a397f65 100644 --- a/apps/sim/app/api/knowledge/connectors/member-sync/route.ts +++ b/apps/sim/app/api/knowledge/connectors/member-sync/route.ts @@ -20,6 +20,7 @@ import { MAX_CONSECUTIVE_FAILURES, MEMBER_SYNC_STALE_LOCK_TTL_MS, } from '@/lib/knowledge/connectors/sync-limits' +import { RUNNABLE_CONNECTOR_STATUSES } from '@/lib/knowledge/connectors/sync-lock' export const dynamic = 'force-dynamic' @@ -168,7 +169,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { .where( and( eq(knowledgeConnector.accessMode, 'members'), - inArray(knowledgeConnector.status, ['active', 'error']), + inArray(knowledgeConnector.status, RUNNABLE_CONNECTOR_STATUSES), inArray(knowledgeConnector.memberSyncStatus, QUEUEABLE_MEMBER_SYNC_STATUSES), lte(knowledgeConnector.nextMemberSyncAt, now), isNull(knowledgeConnector.archivedAt), diff --git a/apps/sim/app/api/knowledge/connectors/sync/route.ts b/apps/sim/app/api/knowledge/connectors/sync/route.ts index 7d3d5afcbd6..8ea5880fdb2 100644 --- a/apps/sim/app/api/knowledge/connectors/sync/route.ts +++ b/apps/sim/app/api/knowledge/connectors/sync/route.ts @@ -8,6 +8,7 @@ import { resolveSystemBillingAttribution } from '@/lib/billing/core/billing-attr import { mapWithConcurrency } from '@/lib/core/utils/concurrency' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { CONTENT_ENGINE_ACCESS_MODES } from '@/lib/knowledge/connectors/access-modes' import { dispatchSync } from '@/lib/knowledge/connectors/queue' import { CONNECTOR_AUTO_DISABLED_ERROR, @@ -16,6 +17,7 @@ import { CONNECTOR_SYNC_STALE_LOCK_TTL_MS, MAX_CONSECUTIVE_FAILURES, } from '@/lib/knowledge/connectors/sync-limits' +import { RUNNABLE_CONNECTOR_STATUSES } from '@/lib/knowledge/connectors/sync-lock' export const dynamic = 'force-dynamic' @@ -303,8 +305,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { .innerJoin(knowledgeBase, eq(knowledgeConnector.knowledgeBaseId, knowledgeBase.id)) .where( and( - inArray(knowledgeConnector.status, ['active', 'error']), - eq(knowledgeConnector.accessMode, 'workspace'), + inArray(knowledgeConnector.status, RUNNABLE_CONNECTOR_STATUSES), + inArray(knowledgeConnector.accessMode, CONTENT_ENGINE_ACCESS_MODES), lte(knowledgeConnector.nextSyncAt, now), isNull(knowledgeConnector.archivedAt), isNull(knowledgeConnector.deletedAt), diff --git a/apps/sim/app/api/v1/admin/dashboard/actor.ts b/apps/sim/app/api/v1/admin/dashboard/actor.ts index c3237cd200a..57b0cb0d912 100644 --- a/apps/sim/app/api/v1/admin/dashboard/actor.ts +++ b/apps/sim/app/api/v1/admin/dashboard/actor.ts @@ -1,16 +1,18 @@ import { db } from '@sim/db' -import { user } from '@sim/db/schema' -import { eq, or } from 'drizzle-orm' +import { foldedEmail, user } from '@sim/db/schema' +import { normalizeEmail } from '@sim/utils/string' +import { eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import type { AdminMutationActor } from '@/lib/admin/dashboard' export async function getAdminAuditActor(request: NextRequest): Promise { - const email = request.headers.get('x-admin-email')?.trim().toLowerCase() + const rawEmail = request.headers.get('x-admin-email') + const email = rawEmail ? normalizeEmail(rawEmail) : '' if (!email) return { id: null, name: 'Admin API', email: null } const [admin] = await db .select({ id: user.id, name: user.name, email: user.email }) .from(user) - .where(or(eq(user.email, email), eq(user.normalizedEmail, email))) + .where(eq(foldedEmail(user.email), email)) .limit(1) return admin ?? { id: null, name: 'Admin Panel', email } } diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx index ef75dfa1fbd..73d9b6b13a1 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx @@ -43,7 +43,7 @@ import { import { MaxBadge } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/max-badge' import { useConnectorConfigFields } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields' import { - memberCapFieldIds, + derivedAclCapFieldIds, useConnectorMemberGroupOptions, } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-member-group-options' import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' @@ -95,6 +95,7 @@ export function AddConnectorModal({ const { ownerBilling, features } = useWorkspaceHostContext() const { canAdmin } = useUserPermissionsContext() const memberAccessAvailable = features?.knowledgeMemberAccess === true + const mirroredAccessAvailable = features?.knowledgeSourceMirroredAccess === true const { mutate: createConnector, isPending: isCreating } = useCreateConnector() const hasMaxAccess = hasWorkspaceMaxConnectorAccess(ownerBilling) @@ -111,7 +112,7 @@ export function AddConnectorModal({ const membersChoiceOpen = isMembersMode && groupOptions.needsChoice && !access.credentialGroupOptionId const hiddenCapFieldIds = useMemo( - () => memberCapFieldIds(connectorConfig, access.accessMode), + () => derivedAclCapFieldIds(connectorConfig, access.accessMode), [connectorConfig, access.accessMode] ) /** True when the connector declares its key optional (public sources need none). */ @@ -126,7 +127,7 @@ export function AddConnectorModal({ ) const { - data: rawCredentials = [], + data: credentials = [], isLoading: credentialsLoading, refetch: refetchCredentials, } = useOAuthCredentials(connectorProviderId ?? undefined, { @@ -134,20 +135,6 @@ export function AddConnectorModal({ workspaceId, }) - /** - * The credential list also returns the provider's service accounts, but - * `ConnectorAuthConfig` has no service-account mode: the sync engine resolves - * connector tokens through `refreshAccessTokenIfNeeded`, which passes no scopes - * and drops the `cloudId`/`domain`/`authStyle` a service account resolves with. - * Offering them here would surface credentials no connector can authenticate - * with, so — like a workflow picker that has not opted in via - * `allowServiceAccounts` — list OAuth accounts only. - */ - const credentials = useMemo( - () => rawCredentials.filter((cred) => cred.type !== 'service_account'), - [rawCredentials] - ) - useCredentialRefreshTriggers(refetchCredentials, connectorProviderId ?? '', workspaceId) const effectiveCredentialId = @@ -261,7 +248,7 @@ export function AddConnectorModal({ credentialGroupId: access.credentialGroupId, credentialGroupOptionId: access.credentialGroupOptionId, } - : { credentialId: effectiveCredentialId! }), + : { accessMode: access.accessMode, credentialId: effectiveCredentialId! }), sourceConfig: finalSourceConfig, syncIntervalMinutes: syncInterval, }, @@ -347,13 +334,15 @@ export function AddConnectorModal({ ) : connectorConfig ? ( <> - {!isApiKeyMode && memberAccessAvailable && ( + {!isApiKeyMode && (memberAccessAvailable || mirroredAccessAvailable) && ( )} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.tsx index e8a2fa4cdef..a2e2b58d5bd 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.tsx @@ -2,6 +2,8 @@ import type { ReactNode } from 'react' import { ButtonGroup, ButtonGroupItem, ChipCombobox, ChipModalField } from '@sim/emcn' +import type { ConnectorAccessMode } from '@/lib/api/contracts/knowledge/connectors' +import { isConnectorAccessMode } from '@/lib/knowledge/connectors/access-modes' import { type ConnectorMemberGroupOptions, decodeConnectorMemberGroupOption, @@ -11,7 +13,7 @@ import type { ConnectorMeta } from '@/connectors/types' /** What the caller chose; `members` may name the option the connector crawls with. */ export interface ConnectorAccessSelection { - accessMode: 'workspace' | 'members' + accessMode: ConnectorAccessMode credentialGroupId?: string credentialGroupOptionId?: string } @@ -22,11 +24,13 @@ interface ConnectorAccessFieldProps { onChange: (value: ConnectorAccessSelection) => void /** From `useConnectorMemberGroupOptions`; shared with the modal so both agree on what is required. */ groupOptions: ConnectorMemberGroupOptions - /** Only an admin may put a connector into members mode. */ + /** Only an admin may move a connector out of workspace mode. */ canAdmin: boolean disabled?: boolean /** Whether per-member access may be chosen; false leaves only the way back to workspace access. */ allowMembers?: boolean + /** Whether administrator access may be chosen; it needs a connector that mirrors source permissions. */ + allowAdmin?: boolean /** * Whether the connector already syncs per member, so any matching group may * be chosen, not only when several make the choice necessary. @@ -37,12 +41,33 @@ interface ConnectorAccessFieldProps { } /** - * The Access section of a connector's settings: sync as the workspace, or - * crawl once per member so each person sees only what the source lets them - * read. Per-member access needs nothing from the admin: a Credential Group is - * found or created for the connector's provider, everyone in the workspace is + * Each mode decides who can read the indexed documents, which the three labels + * cannot say on their own. + */ +function accessHint( + mode: ConnectorAccessMode, + sourceName: string, + allowMembers: boolean +): string | undefined { + if (mode === 'members') { + return `Everyone in the workspace is invited by email to connect their ${sourceName} account when the first sync starts. Each member sees only the documents their own account can open; scheduled, API, and chat runs see workspace-visible documents only.` + } + if (mode === 'admin') { + return `Indexed once as an administrator, keeping each document's own permissions. People see only what ${sourceName} already lets them open; scheduled, API, and chat runs see workspace-visible documents only.` + } + return allowMembers ? undefined : 'Per-member access is turned off for this workspace.' +} + +/** + * The Access section of a connector's settings: sync as the workspace; crawl + * once per member so each person sees only what the source lets them read; or + * crawl once as an administrator and mirror each document's own permissions. + * Per-member access needs nothing from the admin: a Credential Group is found + * or created for the connector's provider, everyone in the workspace is * invited, and each person connects their own account. Only a workspace with - * several matching groups is asked which one to use. + * several matching groups is asked which one to use. Administrator access + * needs a connector that can read the source's permissions, and the + * administrator it crawls as is part of the connector's own config. */ export function ConnectorAccessField({ connectorConfig, @@ -52,23 +77,38 @@ export function ConnectorAccessField({ canAdmin, disabled = false, allowMembers = true, + allowAdmin = false, canRebind = false, footer, }: ConnectorAccessFieldProps) { - if (!groupOptions.supported) return null + /** + * Per-member access needs a Credential Group for the provider; administrator + * access does not, so a provider without one still offers it. + */ + const membersSupported = groupOptions.supported + const showAdmin = allowAdmin && Boolean(connectorConfig.mirrorsSourceAcls) + if (!membersSupported && !showAdmin) return null + + /** One ordered list, rendered by both the read-only and the editable branch. */ + const modes: { mode: ConnectorAccessMode; label: string; shown: boolean }[] = [ + { mode: 'workspace', label: 'Workspace', shown: true }, + { mode: 'members', label: 'Per member', shown: membersSupported }, + { mode: 'admin', label: 'Mirror source', shown: showAdmin }, + ] + const modeItems = (isDisabled: (mode: ConnectorAccessMode) => boolean) => + modes + .filter((entry) => entry.shown) + .map((entry) => ( + + {entry.label} + + )) if (!canAdmin) { - if (value.accessMode !== 'members') return null + if (value.accessMode === 'workspace') return null return ( - - - Workspace - - - Per member - - + {modeItems(() => true)} ) } @@ -85,27 +125,16 @@ export function ConnectorAccessField({ type='custom' title='Access' error={error?.message} - hint={ - value.accessMode === 'members' - ? `Everyone in the workspace is invited by email to connect their ${connectorConfig.name} account when the first sync starts. Each member sees only the documents their own account can open; scheduled, API, and chat runs see workspace-visible documents only.` - : allowMembers - ? undefined - : 'Per-member access is turned off for this workspace.' - } + hint={accessHint(value.accessMode, connectorConfig.name, allowMembers)} >
- onChange(mode === 'members' ? { accessMode: 'members' } : { accessMode: 'workspace' }) - } + onValueChange={(mode) => { + if (isConnectorAccessMode(mode)) onChange({ accessMode: mode }) + }} > - - Workspace - - - Per member - + {modeItems((mode) => disabled || (mode === 'members' && !allowMembers))} {value.accessMode === 'members' && showPicker && ( diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx index db5acdc2c56..6e9bc920337 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx @@ -20,6 +20,8 @@ import { import { RefreshCw, SquareArrowUpRight } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { useParams } from 'next/navigation' +import type { ConnectorAccessMode } from '@/lib/api/contracts/knowledge/connectors' +import { isContentEngineAccessMode } from '@/lib/knowledge/connectors/access-modes' import { getProviderIdFromServiceId, type OAuthProvider } from '@/lib/oauth' import { ConnectorAccessField, @@ -38,7 +40,7 @@ import type { } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields' import { useConnectorConfigFields } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields' import { - memberCapFieldIds, + derivedAclCapFieldIds, useConnectorMemberGroupOptions, } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-member-group-options' import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' @@ -58,6 +60,21 @@ import { useOAuthCredentials } from '@/hooks/queries/oauth/oauth-credentials' const logger = createLogger('EditConnectorModal') +/** What the apply button says, and what it warns will happen, per mode. */ +const SWITCH_LABEL: Record = { + workspace: 'Switch to workspace access', + members: 'Switch to per-member access', + admin: 'Switch to mirrored access', +} + +const SWITCH_NOTICE: Record = { + workspace: 'Every workspace member can read every synced document once the next sync completes.', + members: + 'Everyone in the workspace is invited to connect their account. Documents stay hidden until members connect and sync; listing caps are cleared.', + admin: + 'Documents stay hidden until the next sync mirrors their permissions from the source; listing caps are cleared.', +} + /** Keys injected by the sync engine or modal state — not user-editable */ const INTERNAL_CONFIG_KEYS = new Set(['tagSlotMapping', 'disabledTagIds', '_canonicalModes']) @@ -72,12 +89,18 @@ function currentAccess(connector: ConnectorData): ConnectorAccessSelection { credentialGroupOptionId: connector.credentialGroupOptionId ?? undefined, } } + /** + * Named rather than folded into the fallback: an unrecognised mode showing as + * Workspace would tell an admin their documents are visible to everyone when + * they are not, and would silently rewrite the mode on the next save. + */ + if (connector.accessMode === 'admin') return { accessMode: 'admin' } return { accessMode: 'workspace' } } function accessChanged(current: ConnectorAccessSelection, next: ConnectorAccessSelection): boolean { if (current.accessMode !== next.accessMode) return true - if (next.accessMode === 'workspace') return false + if (next.accessMode !== 'members') return false return ( current.credentialGroupId !== next.credentialGroupId || current.credentialGroupOptionId !== next.credentialGroupOptionId @@ -239,24 +262,30 @@ export function EditConnectorModal({ const { mutate: updateAccess, isPending: isSwitchingAccess } = useUpdateConnectorAccess() const isSaving = isSavingSettings || isSwitchingAccess /** - * The field shows where the flag is on. A connector already syncing per - * member keeps it where the flag has since been turned off, so an admin can - * still bring it back to workspace mode; per-member cannot be re-chosen. + * The field shows where either flag is on. A connector already in a + * non-workspace mode keeps it where its flag has since been turned off, so + * an admin can still bring it back to workspace mode; that mode cannot be + * re-chosen. */ const memberAccessAvailable = features?.knowledgeMemberAccess === true - const showAccessField = memberAccessAvailable || connector.accessMode === 'members' + const mirroredAccessAvailable = features?.knowledgeSourceMirroredAccess === true + const persistedAccess = currentAccess(connector) + const showAccessField = + memberAccessAvailable || mirroredAccessAvailable || persistedAccess.accessMode !== 'workspace' const hasMaxAccess = hasWorkspaceMaxConnectorAccess(ownerBilling) - const accessDirty = accessChanged(currentAccess(connector), access) + const accessDirty = accessChanged(persistedAccess, access) const groupOptions = useConnectorMemberGroupOptions({ workspaceId, connectorConfig, enabled: canAdmin && memberAccessAvailable, }) - /** Leaving members mode needs the credential the connector syncs as from then on. */ + /** Leaving members mode for a mode that syncs with one credential needs that credential. */ const needsWorkspaceCredential = - accessDirty && access.accessMode === 'workspace' && connector.accessMode === 'members' + accessDirty && + isContentEngineAccessMode(access.accessMode) && + persistedAccess.accessMode === 'members' const accessComplete = !accessDirty || (access.accessMode === 'members' @@ -265,7 +294,7 @@ export function EditConnectorModal({ /** A disabled member sync is re-enabled by applying the current binding again. */ const canReenableMemberSync = !accessDirty && connector.accessMode === 'members' && connector.memberSyncStatus === 'disabled' - const hiddenCapFieldIds = memberCapFieldIds(connectorConfig, access.accessMode) + const hiddenCapFieldIds = derivedAclCapFieldIds(connectorConfig, access.accessMode) const persistedCanonicalModes = useMemo( () => readPersistedCanonicalModes(connector.sourceConfig), @@ -352,8 +381,8 @@ export function EditConnectorModal({ credentialGroupOptionId: access.credentialGroupOptionId, } : { - accessMode: 'workspace', - credentialId: workspaceCredentialId ?? undefined, + accessMode: access.accessMode, + credentialId: workspaceCredentialId ?? connector.credentialId ?? undefined, }, }, { @@ -396,7 +425,7 @@ export function EditConnectorModal({ {activeTab === 'settings' ? ( @@ -467,6 +497,7 @@ interface SettingsTabProps { canAdmin: boolean showAccessField: boolean allowMembers: boolean + allowAdmin: boolean groupOptions: ReturnType canReenableMemberSync: boolean accessDirty: boolean @@ -500,6 +531,7 @@ function SettingsTab({ canAdmin, showAccessField, allowMembers, + allowAdmin, groupOptions, canReenableMemberSync, accessDirty, @@ -528,12 +560,10 @@ function SettingsTab({ const selectorCredentialId = syncsPerMember ? browseCredentialId : credentialId const credentialOptions = useMemo( () => - rawCredentials - .filter((credential) => credential.type !== 'service_account') - .map((credential) => ({ - label: credential.name || credential.provider, - value: credential.id, - })), + rawCredentials.map((credential) => ({ + label: credential.name || credential.provider, + value: credential.id, + })), [rawCredentials] ) @@ -546,6 +576,7 @@ function SettingsTab({ onChange={onAccessChange} canAdmin={canAdmin} allowMembers={allowMembers} + allowAdmin={allowAdmin} canRebind={persistedAccessMode === 'members'} groupOptions={groupOptions} disabled={isSaving} @@ -591,9 +622,7 @@ function SettingsTab({ ? 'Switching…' : isRebind ? 'Change credential group' - : access.accessMode === 'members' - ? 'Switch to per-member access' - : 'Switch to workspace access'} + : SWITCH_LABEL[access.accessMode]}
) : undefined diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-member-group-options.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-member-group-options.ts index b3a37ee395e..f9fddf191d4 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-member-group-options.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-member-group-options.ts @@ -2,12 +2,14 @@ import { useMemo } from 'react' import type { ComboboxOption } from '@sim/emcn' +import type { ConnectorAccessMode } from '@/lib/api/contracts/knowledge/connectors' import { type CredentialGroupProvider, findCredentialGroupProviderFromProviderId, getCredentialGroupProviderId, isCredentialGroupProvider, } from '@/lib/credential-groups/providers' +import { aclIsDerived } from '@/lib/knowledge/connectors/access-modes' import type { ConnectorMeta } from '@/connectors/types' import { useCredentialGroups } from '@/hooks/queries/credential-groups' @@ -39,12 +41,12 @@ export function connectorMemberGroupProvider( } /** The config fields a per-member connector hides: its listing caps, which the server clears. */ -export function memberCapFieldIds( +export function derivedAclCapFieldIds( connectorConfig: ConnectorMeta | null, - accessMode: 'workspace' | 'members' + accessMode: ConnectorAccessMode ): ReadonlySet { return new Set( - accessMode === 'members' ? (connectorConfig?.permissionScopedListing?.capFieldIds ?? []) : [] + aclIsDerived(accessMode) ? (connectorConfig?.permissionScopedListing?.capFieldIds ?? []) : [] ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-base-selector/knowledge-base-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-base-selector/knowledge-base-selector.tsx index deb7b7494a8..41cfdf3b395 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-base-selector/knowledge-base-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-base-selector/knowledge-base-selector.tsx @@ -184,7 +184,9 @@ export function KnowledgeBaseSelector({ const label = subBlock.placeholder || (isMultiSelect ? 'Select knowledge bases' : 'Select knowledge base') - const hasMemberScopedSelection = selectedKnowledgeBases.some((kb) => kb.hasMemberScopedConnector) + const hasMemberScopedSelection = selectedKnowledgeBases.some( + (kb) => kb.hasPermissionScopedConnector + ) return (
diff --git a/apps/sim/background/knowledge-connector-directory-sync.ts b/apps/sim/background/knowledge-connector-directory-sync.ts new file mode 100644 index 00000000000..8767e2bbffb --- /dev/null +++ b/apps/sim/background/knowledge-connector-directory-sync.ts @@ -0,0 +1,42 @@ +import { createLogger } from '@sim/logger' +import { task } from '@trigger.dev/sdk' +import { + assertDirectorySyncPayload, + DIRECTORY_SYNC_TASK_ID, + type DirectorySyncPayload, +} from '@/lib/knowledge/connectors/directory-queue' +import { refreshConnectorDirectory } from '@/lib/knowledge/connectors/external-group-sync' + +const logger = createLogger('TriggerKnowledgeConnectorDirectorySync') + +/** A full directory walk: one Admin SDK call per group, sequential, on a large domain. */ +const DIRECTORY_SYNC_MAX_DURATION_SECONDS = 30 * 60 + +export async function executeDirectorySyncJob(payload: unknown) { + const { connectorId, requestId } = assertDirectorySyncPayload(payload) + logger.info(`[${requestId}] Starting directory refresh: ${connectorId}`) + const outcome = await refreshConnectorDirectory(connectorId, requestId) + logger.info(`[${requestId}] Directory refresh finished`, { connectorId, outcome }) + return { outcome } +} + +export const knowledgeConnectorDirectorySync = task({ + id: DIRECTORY_SYNC_TASK_ID, + maxDuration: DIRECTORY_SYNC_MAX_DURATION_SECONDS, + retry: { + maxAttempts: 2, + factor: 2, + minTimeoutInMs: 5000, + maxTimeoutInMs: 30000, + }, + /** + * Two at a time: a walk is bounded by the provider's rate limit, not by + * CPU, and one tenant's directory is refreshed by whichever run reaches it + * first — the rest see it fresh and skip. + */ + queue: { + concurrencyLimit: 2, + name: 'connector-directory-sync-queue', + }, + run: async (payload: DirectorySyncPayload) => executeDirectorySyncJob(payload), +}) diff --git a/apps/sim/connectors/confluence/confluence.test.ts b/apps/sim/connectors/confluence/confluence.test.ts index 383a8ca8164..991bfbcac5e 100644 --- a/apps/sim/connectors/confluence/confluence.test.ts +++ b/apps/sim/connectors/confluence/confluence.test.ts @@ -10,11 +10,11 @@ import { buildLastModifiedClause, confluenceConnector, escapeCql, - extractCursor, isCurrentContent, preserveConfluenceCallouts, readIncludedLabels, } from '@/connectors/confluence/confluence' +import { extractCursor } from '@/connectors/confluence/cursor' import { htmlToPlainText } from '@/connectors/utils' describe('escapeCql', () => { @@ -471,3 +471,125 @@ describe('confluence incremental CQL listing', () => { expect(cqlOfCall(1)).toBe(cqlOfCall(0)) }) }) + +describe('confluence mirrored permissions', () => { + const fetchMock = + vi.fn<(input: string | URL | Request, init?: RequestInit) => Promise>() + + function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) + } + + /** A two-space site where each space is readable by one different person. */ + function site() { + fetchMock.mockImplementation(async (input) => { + const url = new URL(String(input)) + const path = url.pathname + if (path.endsWith('/api/v2/spaces')) { + const key = url.searchParams.get('keys') + return jsonResponse({ results: [{ id: key === 'ENG' ? '1' : '2', key }] }) + } + if (path.endsWith('/spaces/1/permissions')) { + return jsonResponse({ + results: [ + { + principal: { type: 'user', id: 'acc-eng' }, + operation: { key: 'read', targetType: 'space' }, + }, + ], + }) + } + if (path.endsWith('/spaces/2/permissions')) { + return jsonResponse({ + results: [ + { + principal: { type: 'user', id: 'acc-hr' }, + operation: { key: 'read', targetType: 'space' }, + }, + ], + }) + } + if (path.includes('/restriction/byOperation/read')) { + return jsonResponse({ restrictions: { user: { results: [] }, group: { results: [] } } }) + } + if (path.endsWith('/ancestors')) return jsonResponse({ results: [] }) + if (path.endsWith('/rest/api/user/bulk')) { + return jsonResponse({ + results: [ + { accountId: 'acc-eng', email: 'eng@corp.com' }, + { accountId: 'acc-hr', email: 'hr@corp.com' }, + ], + }) + } + return jsonResponse({ error: `unexpected ${path}` }, 500) + }) + } + + function page(externalId: string, spaceKey: string, contentType = 'page') { + return { + externalId, + title: externalId, + content: '', + mimeType: 'text/plain', + contentHash: externalId, + metadata: { spaceKey, contentType }, + } + } + + beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + /** + * The bug this pins: a connector over two spaces once pooled every space's + * readers and gave the pool to every unrestricted page, so a reader of one + * space could read the other's pages. + */ + it("gives an unrestricted page its own space's readers, never another space's", async () => { + site() + + const acls = await confluenceConnector.getDocumentAcls?.( + 'token', + { domain: 'example.atlassian.net', spaceKey: ['ENG', 'HR'] }, + [page('eng-page', 'ENG'), page('hr-post', 'HR', 'blogpost')], + { cloudId: 'cloud-1' } + ) + + expect(acls).toEqual({ + 'eng-page': ['u:eng@corp.com'], + 'hr-post': ['u:hr@corp.com'], + }) + /** A blog post has no ancestors and is never asked for them. */ + const asked = fetchMock.mock.calls.map(([input]) => new URL(String(input)).pathname) + expect(asked.some((path) => path.includes('/blogposts/hr-post/ancestors'))).toBe(false) + expect(asked.some((path) => path.includes('/pages/eng-page/ancestors'))).toBe(true) + }) + + it('omits a page whose permissions could not be read and still answers for the rest', async () => { + site() + const healthy = fetchMock.getMockImplementation()! + fetchMock.mockImplementation(async (input, init) => { + if (String(input).includes('/content/broken/restriction')) { + return jsonResponse({ error: 'nope' }, 404) + } + return healthy(input, init) + }) + + const acls = await confluenceConnector.getDocumentAcls?.( + 'token', + { domain: 'example.atlassian.net', spaceKey: 'ENG' }, + [page('eng-page', 'ENG'), page('broken', 'ENG')], + { cloudId: 'cloud-1' } + ) + + expect(acls).toEqual({ 'eng-page': ['u:eng@corp.com'] }) + }) +}) diff --git a/apps/sim/connectors/confluence/confluence.ts b/apps/sim/connectors/confluence/confluence.ts index 6ad3f97e199..a628cb8c07f 100644 --- a/apps/sim/connectors/confluence/confluence.ts +++ b/apps/sim/connectors/confluence/confluence.ts @@ -1,12 +1,31 @@ import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' +import { getErrorMessage } from '@sim/utils/errors' import * as cheerio from 'cheerio' import { AtlassianSiteNotAccessibleError, AtlassianSiteNotMatchedError, } from '@/lib/atlassian/discovery' -import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' +import { mapWithConcurrency } from '@/lib/core/utils/concurrency' +import { + type ConfluencePrincipal, + type ConfluenceRestriction, + confluencePageAcl, +} from '@/lib/knowledge/access/confluence-permissions' +import { + fetchWithRetry, + type RetryOptions, + VALIDATE_RETRY_OPTIONS, +} from '@/lib/knowledge/documents/utils' +import { extractCursor } from '@/connectors/confluence/cursor' import { confluenceConnectorMeta } from '@/connectors/confluence/meta' +import { + describeContent, + getReadRestriction, + listAncestorIds, + listSpaceReadPrincipals, + openConfluenceDirectory, + resolveUserEmails, +} from '@/connectors/confluence/permissions' import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' import { htmlToPlainText, joinTagArray, parseMultiValue, parseTagDate } from '@/connectors/utils' import { getConfluenceCloudId, normalizeConfluenceDomainHost } from '@/tools/confluence/utils' @@ -219,21 +238,6 @@ export function readIncludedLabels(page: Record): string[] { return results.map((label) => String(label.name ?? '')).filter(Boolean) } -/** - * Extracts the `cursor` query value from a relative `_links.next` URL. Both the - * v2 endpoints and the v1 CQL search return the next page as a relative path - * carrying an opaque cursor, so the value has to be parsed back out rather than - * derived. - */ -export function extractCursor(nextLink: unknown): string | undefined { - if (typeof nextLink !== 'string' || !nextLink) return undefined - try { - return new URL(nextLink, 'https://placeholder').searchParams.get('cursor') || undefined - } catch { - return undefined - } -} - /** * Body representation marker embedded in the contentHash. Bumping this * invalidates every previously-synced Confluence document so a one-time @@ -254,6 +258,10 @@ function pageToStub( page: Record, options: { spaceId?: unknown + /** The space's key, which the permission pass resolves the page's space from. */ + spaceKey?: string + /** `page` or `blogpost`; only a page has ancestors to inherit restrictions from. */ + contentType?: string labels?: string[] sourceUrl?: string } = {} @@ -273,6 +281,8 @@ function pageToStub( contentHash: `confluence:${CONTENT_REPRESENTATION}:${page.id}:${versionKey}`, metadata: { spaceId: options.spaceId, + spaceKey: options.spaceKey, + contentType: options.contentType, status: page.status, version: versionNumber, labels: options.labels ?? [], @@ -291,13 +301,220 @@ function cqlResultToStub(item: Record, domain: string): Externa const labelResults = (labelsWrapper?.results || []) as Record[] const labels = labelResults.map((l) => l.name as string) + const spaceKey = (item.space as Record)?.key return pageToStub(item, { - spaceId: (item.space as Record)?.key, + spaceId: spaceKey, + spaceKey: typeof spaceKey === 'string' ? spaceKey : undefined, + contentType: typeof item.type === 'string' ? item.type : undefined, labels, sourceUrl: links?.webui ? `https://${domain}/wiki${links.webui}` : undefined, }) } +/** + * The site's cloud id, memoised on the run so it is discovered once per sync + * rather than once per call — and taken from the credential where a service + * account already carries it, since its API token cannot call + * `accessible-resources` to discover one. + */ +async function resolveCloudId( + accessToken: string, + sourceConfig: Record, + syncContext?: Record, + retryOptions?: RetryOptions +): Promise { + const cached = syncContext?.cloudId + if (typeof cached === 'string' && cached) return cached + const domain = normalizeConfluenceDomainHost(sourceConfig.domain as string) + const cloudId = await getConfluenceCloudId(domain, accessToken, retryOptions) + if (syncContext) syncContext.cloudId = cloudId + return cloudId +} + +/** + * The provider segment of every Confluence group token. Fixed, and baked into + * stored ACLs, so it must never change. + */ +const CONFLUENCE_ACL_PROVIDER_ID = 'confluence' + +/** + * One in-flight promise per key, so a value fetched for one page is shared by + * every other page that needs it — an ancestor's restriction is consulted by + * all its descendants, and a space's readers by every page in it. + */ +function memoizeAsync(load: (key: K) => Promise): (key: K) => Promise { + const cache = new Map>() + return (key: K) => { + let pending = cache.get(key) + if (!pending) { + pending = load(key) + cache.set(key, pending) + } + return pending + } +} + +/** Pages whose restrictions are resolved at once. Bounded to keep a crawl responsive. */ +const ACL_CONCURRENCY = 8 + +/** Where a listed piece of content lives, as the permission pass needs it. */ +interface ContentLocation { + spaceId: string + contentType: string +} + +/** + * Resolves who may read each listed page. + * + * Confluence reports a page's restrictions only when asked for that page, so + * unlike Drive this cannot ride along with the listing. Three things are cached + * for the run: each space's read principals (one call per space), each page's + * restriction, and every account id's address — an ancestor's restriction is + * consulted by many of its descendants, and one person is usually named on + * several pages. + * + * A page falls back to *its own* space's readers, never the union of every + * configured space: a connector over two spaces must not let a reader of one + * into the unrestricted pages of the other. + * + * A page whose restrictions could not be read this run is omitted, which the + * engine stores as readable by nobody, and the rest of the batch still + * resolves — the same per-document containment Drive has. + */ +async function resolveConfluenceAcls( + accessToken: string, + sourceConfig: Record, + documents: readonly ExternalDocument[], + syncContext?: Record +): Promise> { + const cloudId = await resolveCloudId(accessToken, sourceConfig, syncContext) + + const spaceIdForKey = memoizeAsync((spaceKey: string) => + resolveSpaceId(cloudId, accessToken, spaceKey) + ) + const spacePrincipalsFor = memoizeAsync((spaceId: string) => + listSpaceReadPrincipals(cloudId, accessToken, spaceId) + ) + const readRestriction = memoizeAsync((contentId: string) => + getReadRestriction(cloudId, accessToken, contentId) + ) + + /** The listing usually says where a page lives; anything it did not describe is asked. */ + const locate = async (doc: ExternalDocument): Promise => { + const spaceKey = doc.metadata?.spaceKey + const contentType = doc.metadata?.contentType + if (typeof spaceKey === 'string' && spaceKey) { + return { + spaceId: await spaceIdForKey(spaceKey), + contentType: typeof contentType === 'string' ? contentType : 'page', + } + } + return describeContent(cloudId, accessToken, doc.externalId) + } + + /** One entry per page whose permissions this run could read in full. */ + const resolved = new Map() + let unreadable = 0 + await mapWithConcurrency(documents, ACL_CONCURRENCY, async (doc) => { + const externalId = doc.externalId + try { + const location = await locate(doc) + if (!location) { + unreadable += 1 + return + } + const own = await readRestriction(externalId) + /** + * A page carrying its own restriction decides on the spot; only an + * unrestricted page needs its ancestry, which is the expensive lookup. + * A blog post has no ancestors to inherit from. + */ + const chain: ConfluenceRestriction[] = [own] + if (own === null && location.contentType !== 'blogpost') { + for (const ancestorId of await listAncestorIds(cloudId, accessToken, externalId)) { + const restriction = await readRestriction(ancestorId) + chain.push(restriction) + if (restriction !== null) break + } + } + await spacePrincipalsFor(location.spaceId) + resolved.set(externalId, { spaceId: location.spaceId, chain }) + } catch (error) { + unreadable += 1 + logger.warn("Could not read a page's permissions; it stays readable by nobody", { + cloudId, + externalId, + error: getErrorMessage(error), + }) + } + }) + + /** + * Addresses are resolved once for every account named anywhere and written + * onto the principals in place: a space's own principals appear on every page + * that inherits them, and each restriction object is shared by every chain + * that walked through it. + */ + const accountIds = new Set() + /** Every space named by a resolved page settled before that page was recorded. */ + const principalsBySpace = new Map() + for (const { spaceId, chain } of resolved.values()) { + if (!principalsBySpace.has(spaceId)) { + principalsBySpace.set(spaceId, await spacePrincipalsFor(spaceId)) + } + for (const restriction of chain) { + for (const principal of restriction ?? []) { + if (principal.kind === 'user') accountIds.add(principal.id) + } + } + } + for (const principals of principalsBySpace.values()) { + for (const principal of principals) { + if (principal.kind === 'user') accountIds.add(principal.id) + } + } + const emails = await resolveUserEmails(cloudId, accessToken, [...accountIds]) + const withEmail = (principals: ConfluencePrincipal[]): void => { + for (const principal of principals) { + /** A restriction sometimes discloses the address itself; a lookup miss must not erase it. */ + if (principal.kind === 'user') principal.email = emails.get(principal.id) ?? principal.email + } + } + for (const principals of principalsBySpace.values()) withEmail(principals) + for (const { chain } of resolved.values()) { + for (const restriction of chain) { + if (restriction !== null) withEmail(restriction) + } + } + + const acls: Record = {} + let unattributed = 0 + for (const [externalId, { spaceId, chain }] of resolved) { + const result = confluencePageAcl({ + spacePrincipals: principalsBySpace.get(spaceId) ?? [], + restrictionChain: chain, + providerId: CONFLUENCE_ACL_PROVIDER_ID, + tenantId: cloudId, + }) + acls[externalId] = result.acl + unattributed += result.unattributedUsers + } + + if (unreadable > 0) { + logger.warn('Some Confluence pages had unreadable permissions and stay readable by nobody', { + cloudId, + unreadable, + }) + } + if (unattributed > 0) { + logger.warn('Confluence withheld addresses for some granted users; those grants were dropped', { + cloudId, + unattributed, + }) + } + return acls +} + export const confluenceConnector: ConnectorConfig = { ...confluenceConnectorMeta, @@ -318,11 +535,7 @@ export const confluenceConnector: ConnectorConfig = { throw new Error('At least one space key is required') } - let cloudId = syncContext?.cloudId as string | undefined - if (!cloudId) { - cloudId = await getConfluenceCloudId(domain, accessToken) - if (syncContext) syncContext.cloudId = cloudId - } + const cloudId = await resolveCloudId(accessToken, sourceConfig, syncContext) /** * Route through CQL when a label filter is set, when multiple spaces are @@ -379,6 +592,15 @@ export const confluenceConnector: ConnectorConfig = { ) }, + getDocumentAcls: resolveConfluenceAcls, + + openDirectory: async (accessToken, sourceConfig, syncContext) => + openConfluenceDirectory( + CONFLUENCE_ACL_PROVIDER_ID, + await resolveCloudId(accessToken, sourceConfig, syncContext), + accessToken + ), + getDocument: async ( accessToken: string, sourceConfig: Record, @@ -386,11 +608,7 @@ export const confluenceConnector: ConnectorConfig = { syncContext?: Record ): Promise => { const domain = normalizeConfluenceDomainHost(sourceConfig.domain as string) - let cloudId = syncContext?.cloudId as string | undefined - if (!cloudId) { - cloudId = await getConfluenceCloudId(domain, accessToken) - if (syncContext) syncContext.cloudId = cloudId - } + const cloudId = await resolveCloudId(accessToken, sourceConfig, syncContext) /** * Fetch the `view` representation rather than `storage`. Storage format only @@ -443,7 +661,8 @@ export const confluenceConnector: ConnectorConfig = { validateConfig: async ( accessToken: string, - sourceConfig: Record + sourceConfig: Record, + syncContext?: Record ): Promise<{ valid: boolean; error?: string }> => { const domain = sourceConfig.domain as string const spaceKeys = parseMultiValue(sourceConfig.spaceKey) @@ -458,7 +677,12 @@ export const confluenceConnector: ConnectorConfig = { } try { - const cloudId = await getConfluenceCloudId(domain, accessToken, VALIDATE_RETRY_OPTIONS) + const cloudId = await resolveCloudId( + accessToken, + sourceConfig, + syncContext, + VALIDATE_RETRY_OPTIONS + ) const params = new URLSearchParams() for (const key of spaceKeys) params.append('keys', key) params.append('limit', String(Math.max(spaceKeys.length, 1))) @@ -489,7 +713,7 @@ export const confluenceConnector: ConnectorConfig = { } return { valid: true } } catch (error) { - return { valid: false, error: toError(error).message || 'Failed to validate configuration' } + return { valid: false, error: getErrorMessage(error, 'Failed to validate configuration') } } }, @@ -578,6 +802,8 @@ async function listDocumentsV2( const links = page._links as Record | undefined return pageToStub(page, { spaceId: page.spaceId, + spaceKey, + contentType, sourceUrl: links?.webui ? `https://${domain}/wiki${links.webui}` : undefined, }) }) diff --git a/apps/sim/connectors/confluence/cursor.ts b/apps/sim/connectors/confluence/cursor.ts new file mode 100644 index 00000000000..dae7f2146b0 --- /dev/null +++ b/apps/sim/connectors/confluence/cursor.ts @@ -0,0 +1,18 @@ +/** + * Extracts the `cursor` query value from a relative `_links.next` URL. Both the + * v2 endpoints and the v1 CQL search return the next page as a relative path + * carrying an opaque cursor, so the value has to be parsed back out rather than + * derived. + * + * A leaf of its own because both the content listing and the permission + * listings page the same way, and a second parser that read a link slightly + * differently would silently stop paginating. + */ +export function extractCursor(nextLink: unknown): string | undefined { + if (typeof nextLink !== 'string' || !nextLink) return undefined + try { + return new URL(nextLink, 'https://placeholder').searchParams.get('cursor') || undefined + } catch { + return undefined + } +} diff --git a/apps/sim/connectors/confluence/meta.ts b/apps/sim/connectors/confluence/meta.ts index ed114e242ea..ced4366157f 100644 --- a/apps/sim/connectors/confluence/meta.ts +++ b/apps/sim/connectors/confluence/meta.ts @@ -20,6 +20,26 @@ export const confluenceConnectorMeta: ConnectorMeta = { 'search:confluence', 'offline_access', ], + /** + * Mirroring adds the three reads an ACL needs and nothing else: a space's + * permissions, a page's restrictions, and the addresses behind the account + * ids both return. `read:confluence-user` is what decides whether a person + * can be granted access individually at all — without it every principal is + * an opaque account id that no Sim reader can be matched to. + */ + serviceAccountScopes: [ + 'read:confluence-content.all', + 'read:page:confluence', + 'read:blogpost:confluence', + 'read:space:confluence', + 'read:label:confluence', + 'search:confluence', + 'read:confluence-space.summary', + 'read:confluence-user', + 'read:user:confluence', + 'read:email-address:confluence', + 'read:group:confluence', + ], }, /** @@ -34,6 +54,14 @@ export const confluenceConnectorMeta: ConnectorMeta = { /** CQL search under a member's token returns only content that member may view. */ permissionScopedListing: { capFieldIds: ['maxPages'] }, + /** + * Space permissions and page restrictions are both readable, so one crawl + * under an administrative credential can mirror them. Unlike Drive they come + * back per page rather than with the listing, which is what + * `getDocumentAcls` exists for. + */ + mirrorsSourceAcls: true, + configFields: [ { id: 'domain', diff --git a/apps/sim/connectors/confluence/permissions.test.ts b/apps/sim/connectors/confluence/permissions.test.ts new file mode 100644 index 00000000000..2c2253393f5 --- /dev/null +++ b/apps/sim/connectors/confluence/permissions.test.ts @@ -0,0 +1,246 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + getReadRestriction, + listAncestorIds, + listGroupMemberEmails, + listSpaceReadPrincipals, + resolveUserEmails, +} from '@/connectors/confluence/permissions' + +const mockFetch = vi.fn() +const CLOUD = 'cloud-1' + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) +} + +beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', mockFetch) +}) + +describe('listSpaceReadPrincipals', () => { + it('keeps only the permission that grants reading the space', () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ + results: [ + { + principal: { type: 'user', id: 'acc-1' }, + operation: { key: 'read', targetType: 'space' }, + }, + { + principal: { type: 'group', id: 'grp-1' }, + operation: { key: 'read', targetType: 'space' }, + }, + { + principal: { type: 'user', id: 'acc-2' }, + operation: { key: 'delete', targetType: 'page' }, + }, + { + principal: { type: 'user', id: 'acc-3' }, + operation: { key: 'read', targetType: 'page' }, + }, + ], + }) + ) + + return expect(listSpaceReadPrincipals(CLOUD, 'token', 'space-1')).resolves.toEqual([ + { kind: 'user', id: 'acc-1' }, + { kind: 'group', id: 'grp-1' }, + ]) + }) + + /** + * A space open to anonymous users is the Confluence equivalent of an open + * Drive share, and gets the same treatment: not mapped, so not searchable. + */ + it('grants nothing for an access-class principal such as anonymous', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ + results: [ + { + principal: { type: 'ACCESS_CLASS', id: 'anonymous-users' }, + operation: { key: 'read', targetType: 'space' }, + }, + ], + }) + ) + + await expect(listSpaceReadPrincipals(CLOUD, 'token', 'space-1')).resolves.toEqual([]) + }) + + it('follows the cursor rather than reporting the first page as the whole space', async () => { + mockFetch + .mockResolvedValueOnce( + jsonResponse({ + results: [ + { + principal: { type: 'user', id: 'acc-1' }, + operation: { key: 'read', targetType: 'space' }, + }, + ], + _links: { next: '/wiki/api/v2/spaces/1/permissions?cursor=abc' }, + }) + ) + .mockResolvedValueOnce( + jsonResponse({ + results: [ + { + principal: { type: 'user', id: 'acc-2' }, + operation: { key: 'read', targetType: 'space' }, + }, + ], + }) + ) + + await expect(listSpaceReadPrincipals(CLOUD, 'token', 'space-1')).resolves.toHaveLength(2) + expect(String(mockFetch.mock.calls[1][0])).toContain('cursor=abc') + }) + + it('throws rather than returning a space it could not read in full', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ message: 'nope' }, 403)) + + await expect(listSpaceReadPrincipals(CLOUD, 'token', 'space-1')).rejects.toThrow('403') + }) +}) + +describe('getReadRestriction', () => { + /** + * The distinction the whole ancestor walk rests on: empty means the page is + * unrestricted and inherits, not that it is restricted to nobody. + */ + it('reports an unrestricted page as inheriting, not as restricted to nobody', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ restrictions: { user: { results: [] }, group: { results: [] } } }) + ) + + await expect(getReadRestriction(CLOUD, 'token', 'page-1')).resolves.toBeNull() + }) + + it('carries the address Confluence disclosed alongside the account id', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ + restrictions: { + user: { results: [{ accountId: 'acc-1', email: 'alice@corp.com' }] }, + group: { results: [{ id: 'grp-1' }] }, + }, + }) + ) + + await expect(getReadRestriction(CLOUD, 'token', 'page-1')).resolves.toEqual([ + { kind: 'user', id: 'acc-1', email: 'alice@corp.com' }, + { kind: 'group', id: 'grp-1' }, + ]) + }) + + it('keeps a withheld address as absent rather than inventing one', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ + restrictions: { user: { results: [{ accountId: 'acc-1', email: null }] }, group: {} }, + }) + ) + + await expect(getReadRestriction(CLOUD, 'token', 'page-1')).resolves.toEqual([ + { kind: 'user', id: 'acc-1', email: null }, + ]) + }) +}) + +describe('listAncestorIds', () => { + /** The closest parent decides, and Confluence returns ancestors root-first. */ + it('returns ancestors closest parent first', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ results: [{ id: 'root' }, { id: 'section' }, { id: 'parent' }] }) + ) + + await expect(listAncestorIds(CLOUD, 'token', 'page-1')).resolves.toEqual([ + 'parent', + 'section', + 'root', + ]) + expect(String(mockFetch.mock.calls[0][0])).toContain('/api/v2/pages/page-1/ancestors') + }) + + it('reports a top-level page as having no ancestors', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({})) + + await expect(listAncestorIds(CLOUD, 'token', 'page-1')).resolves.toEqual([]) + }) +}) + +describe('resolveUserEmails', () => { + it('folds addresses so they match the tokens a reader holds', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ results: [{ accountId: 'acc-1', email: 'Alice@Corp.com' }] }) + ) + + const emails = await resolveUserEmails(CLOUD, 'token', ['acc-1']) + + expect(emails.get('acc-1')).toBe('alice@corp.com') + }) + + it('omits an account whose address the site withholds', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ + results: [ + { accountId: 'acc-1', email: null }, + { accountId: 'acc-2', email: 'bob@corp.com' }, + ], + }) + ) + + const emails = await resolveUserEmails(CLOUD, 'token', ['acc-1', 'acc-2']) + + expect([...emails.keys()]).toEqual(['acc-2']) + }) + + it('survives a failed batch rather than losing the whole corpus', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ message: 'nope' }, 500)) + + await expect(resolveUserEmails(CLOUD, 'token', ['acc-1'])).resolves.toEqual(new Map()) + }) +}) + +describe('listGroupMemberEmails', () => { + const GROUP = { id: 'grp-1' } + + it('reports a fully resolved group as complete', async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse({ results: [{ accountId: 'acc-1' }] })) + .mockResolvedValueOnce( + jsonResponse({ results: [{ accountId: 'acc-1', email: 'alice@corp.com' }] }) + ) + + await expect(listGroupMemberEmails(CLOUD, 'token', GROUP)).resolves.toEqual({ + group: GROUP, + memberEmails: ['alice@corp.com'], + complete: true, + }) + }) + + /** + * A group is only usable as a grant if everyone in it can be named. Reporting + * a partial membership as complete would let it replace a stored one and + * revoke whoever the site withheld. + */ + it('reports a group with a withheld member as incomplete', async () => { + mockFetch + .mockResolvedValueOnce( + jsonResponse({ results: [{ accountId: 'acc-1' }, { accountId: 'acc-2' }] }) + ) + .mockResolvedValueOnce( + jsonResponse({ results: [{ accountId: 'acc-1', email: 'alice@corp.com' }] }) + ) + + await expect(listGroupMemberEmails(CLOUD, 'token', GROUP)).resolves.toMatchObject({ + memberEmails: ['alice@corp.com'], + complete: false, + }) + }) +}) diff --git a/apps/sim/connectors/confluence/permissions.ts b/apps/sim/connectors/confluence/permissions.ts new file mode 100644 index 00000000000..fda702d6dd7 --- /dev/null +++ b/apps/sim/connectors/confluence/permissions.ts @@ -0,0 +1,385 @@ +import { createLogger } from '@sim/logger' +import { chunkArray } from '@sim/utils/helpers' +import { normalizeEmail } from '@sim/utils/string' +import type { + ConfluencePrincipal, + ConfluenceRestriction, +} from '@/lib/knowledge/access/confluence-permissions' +import { fetchWithRetry } from '@/lib/knowledge/documents/utils' +import { extractCursor } from '@/connectors/confluence/cursor' +import type { + ConnectorDirectory, + ConnectorDirectoryGroup, + ConnectorDirectoryMembership, +} from '@/connectors/types' + +const logger = createLogger('ConfluencePermissions') + +const PAGE_SIZE = 250 + +/** Guards against a site that keeps paginating; far above any real space. */ +const MAX_PAGES = 100 + +function apiBase(cloudId: string): string { + return `https://api.atlassian.com/ex/confluence/${cloudId}/wiki` +} + +/** + * A GET with the same transient-error retry every other Confluence call gets. + * With `allowNotFound`, a 404 resolves to null instead of throwing. + */ +async function getJson(url: string, accessToken: string): Promise +async function getJson( + url: string, + accessToken: string, + options: { allowNotFound: true } +): Promise +async function getJson( + url: string, + accessToken: string, + options?: { allowNotFound: true } +): Promise { + const response = await fetchWithRetry(url, { + method: 'GET', + headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' }, + }) + if (response.status === 404 && options?.allowNotFound) return null + if (!response.ok) { + throw new Error(`Confluence request failed: ${response.status} ${response.statusText}`) + } + return (await response.json()) as T +} + +/** + * Drains a v2 collection by following `_links.next`, the only termination + * Confluence documents. The requested page size is a ceiling the server may + * lower, so a page shorter than it proves nothing. + */ +async function drainV2(url: string, accessToken: string, what: string): Promise { + const items: T[] = [] + let cursor: string | undefined + for (let page = 0; page < MAX_PAGES; page += 1) { + const query = new URLSearchParams({ limit: String(PAGE_SIZE) }) + if (cursor) query.set('cursor', cursor) + const body = await getJson<{ results?: T[]; _links?: { next?: string } }>( + `${url}?${query.toString()}`, + accessToken + ) + items.push(...(body.results ?? [])) + cursor = extractCursor(body._links?.next) + if (!cursor) return items + } + throw new Error(`Confluence ${what} exceeded ${MAX_PAGES} pages`) +} + +/** + * Drains a v1 offset collection. The v1 envelope echoes `size` and `limit` + * and links the next page; a page is the last when no next link follows it. + */ +async function drainV1(url: string, accessToken: string, what: string): Promise { + const items: T[] = [] + let start = 0 + for (let page = 0; page < MAX_PAGES; page += 1) { + const body = await getJson<{ + results?: T[] + size?: number + _links?: { next?: string } + }>(`${url}?start=${start}&limit=${PAGE_SIZE}`, accessToken) + const results = body.results ?? [] + items.push(...results) + if (!body._links?.next || results.length === 0) return items + start += body.size || results.length + } + throw new Error(`Confluence ${what} exceeded ${MAX_PAGES} pages`) +} + +interface SpacePermissionEntry { + principal?: { type?: string; id?: string } + operation?: { key?: string; targetType?: string } +} + +interface SpaceRoleAssignment { + principal?: { principalType?: string; principalId?: string } +} + +/** + * Who may read a space. + * + * Only `read` on the space itself counts; the same endpoint reports create, + * delete and administer permissions, and a person who may delete a page they + * cannot read is not a thing Confluence models — but reading the operation key + * is how we avoid granting on one. + * + * A site on space roles reports the grant against a *role* rather than a + * person or group, and names who holds each role separately. Every space role + * includes viewing the space — Confluence will not define one without it — so + * every assignment of a role is a read grant, and the assignments are the + * principals. + * + * `anonymous` and access-class principals ("all licensed users") are not + * mapped. A space open to everyone on the site is the Confluence equivalent + * of an open Drive share and gets the same treatment: not searchable, because + * a space left open is far more often an oversight than an intention. They are + * counted so a site whose spaces are all open can be recognised from the log. + */ +export async function listSpaceReadPrincipals( + cloudId: string, + accessToken: string, + spaceId: string +): Promise { + const entries = await drainV2( + `${apiBase(cloudId)}/api/v2/spaces/${encodeURIComponent(spaceId)}/permissions`, + accessToken, + 'space permissions' + ) + + const principals: ConfluencePrincipal[] = [] + let grantedToRole = false + let unmapped = 0 + for (const entry of entries) { + if (entry.operation?.key !== 'read' || entry.operation.targetType !== 'space') continue + const id = entry.principal?.id + const type = entry.principal?.type?.toLowerCase() + if (type === 'role') { + grantedToRole = true + continue + } + if (!id || (type !== 'user' && type !== 'group')) { + unmapped += 1 + continue + } + principals.push({ kind: type, id }) + } + + if (grantedToRole) { + const assignments = await drainV2( + `${apiBase(cloudId)}/api/v2/spaces/${encodeURIComponent(spaceId)}/role-assignments`, + accessToken, + 'space role assignments' + ) + for (const assignment of assignments) { + const id = assignment.principal?.principalId + const type = assignment.principal?.principalType?.toLowerCase() + if (!id) continue + if (type === 'user' || type === 'group') principals.push({ kind: type, id }) + else unmapped += 1 + } + } + + if (unmapped > 0) { + logger.info( + 'Confluence space grants to anonymous or access-class principals were not mirrored', + { + cloudId, + spaceId, + unmapped, + } + ) + } + return principals +} + +interface RestrictionResponse { + restrictions?: { + user?: { results?: { accountId?: string; email?: string | null }[]; size?: number } + group?: { results?: { id?: string }[]; size?: number } + } +} + +/** + * A page's own read restriction, or `null` when it has none. + * + * Confluence reports an unrestricted page as empty user and group lists, and + * offers no way to restrict a page to nobody — restricting always names at + * least the person doing it. So empty means inherit, and the distinction the + * ACL mapper draws between `null` and `[]` is defensive rather than reachable + * from the product. + * + * The user and group lists page independently under one `start`; a + * restriction naming more people than one page holds is read until both lists + * come back short. + */ +export async function getReadRestriction( + cloudId: string, + accessToken: string, + contentId: string +): Promise { + const principals: ConfluencePrincipal[] = [] + for (let page = 0; page < MAX_PAGES; page += 1) { + const body = await getJson( + `${apiBase(cloudId)}/rest/api/content/${encodeURIComponent(contentId)}/restriction/byOperation/read?expand=restrictions.user,restrictions.group&start=${page * PAGE_SIZE}&limit=${PAGE_SIZE}`, + accessToken + ) + const users = body.restrictions?.user?.results ?? [] + const groups = body.restrictions?.group?.results ?? [] + for (const user of users) { + if (user.accountId) principals.push({ kind: 'user', id: user.accountId, email: user.email }) + } + for (const group of groups) { + if (group.id) principals.push({ kind: 'group', id: group.id }) + } + if (users.length < PAGE_SIZE && groups.length < PAGE_SIZE) { + return principals.length === 0 ? null : principals + } + } + throw new Error(`Confluence restriction on ${contentId} exceeded ${MAX_PAGES} pages`) +} + +/** + * A page's ancestors, closest parent first — the order the restriction chain is + * resolved in. + * + * The v2 ancestors collection is the one Confluence still serves; the v1 + * content expansion this used to read was removed. It returns root first, so + * the order is reversed here. Blog posts have no ancestors and are never asked. + */ +export async function listAncestorIds( + cloudId: string, + accessToken: string, + pageId: string +): Promise { + const ancestors = await drainV2<{ id?: string }>( + `${apiBase(cloudId)}/api/v2/pages/${encodeURIComponent(pageId)}/ancestors`, + accessToken, + 'page ancestors' + ) + return ancestors + .map((ancestor) => ancestor.id) + .filter((id): id is string => Boolean(id)) + .reverse() +} + +/** The space a piece of content lives in, for content the listing did not describe. */ +export async function describeContent( + cloudId: string, + accessToken: string, + contentId: string +): Promise<{ spaceId: string; contentType: 'page' | 'blogpost' } | null> { + for (const contentType of ['page', 'blogpost'] as const) { + const collection = `${contentType}s` + const body = await getJson<{ spaceId?: string | number }>( + `${apiBase(cloudId)}/api/v2/${collection}/${encodeURIComponent(contentId)}`, + accessToken, + { allowNotFound: true } + ) + if (body?.spaceId !== undefined) return { spaceId: String(body.spaceId), contentType } + } + return null +} + +interface BulkUserEntry { + accountId?: string + email?: string | null +} + +/** + * Resolves account ids to email addresses, which is the only identifier a Sim + * reader can be matched by. + * + * Confluence Cloud withholds an address whose owner's profile visibility hides + * it, and returns the account with a null email rather than failing. Those + * people cannot be granted access individually, and the same withholding + * applies when they are reached through a group — so the caller counts them, + * and a group whose membership cannot be named in full is left on its last + * complete enumeration rather than replaced. + */ +export async function resolveUserEmails( + cloudId: string, + accessToken: string, + accountIds: readonly string[] +): Promise> { + const emails = new Map() + const unique = [...new Set(accountIds)] + /** `bulk` accepts repeated accountId params; 90 keeps the URL well inside limits. */ + const BULK_SIZE = 90 + + for (const batch of chunkArray(unique, BULK_SIZE)) { + const query = new URLSearchParams() + for (const accountId of batch) query.append('accountId', accountId) + + try { + const body = await getJson<{ results?: BulkUserEntry[] }>( + `${apiBase(cloudId)}/rest/api/user/bulk?${query.toString()}`, + accessToken + ) + for (const entry of body.results ?? []) { + const email = entry.email ? normalizeEmail(entry.email) : '' + if (entry.accountId && email) emails.set(entry.accountId, email) + } + } catch (error) { + /** + * A batch that fails leaves its people unattributed, which hides the + * pages they were named on. Not fatal: the rest of the corpus still + * resolves, and the next run retries. + */ + logger.warn('Could not resolve a batch of Confluence account ids to addresses', { + cloudId, + accountIds: batch.length, + }) + } + } + return emails +} + +/** Every group on the site, by the id its permissions and restrictions name. */ +async function listSiteGroups( + cloudId: string, + accessToken: string +): Promise { + const raw = await drainV1<{ id?: string }>( + `${apiBase(cloudId)}/rest/api/group`, + accessToken, + 'group listing' + ) + const groups: ConnectorDirectoryGroup[] = [] + for (const group of raw) { + if (group.id) groups.push({ id: group.id }) + } + return groups +} + +/** + * The people in one group, as addresses. + * + * Confluence groups do not nest, so there is no walk to do — the whole + * membership is one paginated listing. What it returns is account ids, so the + * addresses come from the same bulk resolution the ACL path uses. + * + * A member whose address the site withholds is reported by leaving the + * membership incomplete rather than by dropping them quietly: a group is only + * usable as a grant if we can name everyone in it, and a partial membership + * that replaced a stored one would revoke whoever was withheld. + */ +export async function listGroupMemberEmails( + cloudId: string, + accessToken: string, + group: ConnectorDirectoryGroup +): Promise { + const members = await drainV1<{ accountId?: string }>( + `${apiBase(cloudId)}/rest/api/group/${encodeURIComponent(group.id)}/membersByGroupId`, + accessToken, + 'group membership' + ) + const accountIds = [...new Set(members.flatMap((m) => (m.accountId ? [m.accountId] : [])))] + const emails = await resolveUserEmails(cloudId, accessToken, accountIds) + + return { + group, + memberEmails: [...new Set(emails.values())], + complete: emails.size === accountIds.length, + } +} + +/** The Confluence site as a directory, keyed by its cloud id. */ +export function openConfluenceDirectory( + providerId: string, + cloudId: string, + accessToken: string +): ConnectorDirectory { + return { + providerId, + tenantId: cloudId, + listGroups: () => listSiteGroups(cloudId, accessToken), + listGroupMembers: (group) => listGroupMemberEmails(cloudId, accessToken, group), + } +} diff --git a/apps/sim/connectors/google-drive/directory.test.ts b/apps/sim/connectors/google-drive/directory.test.ts new file mode 100644 index 00000000000..41e0dd02184 --- /dev/null +++ b/apps/sim/connectors/google-drive/directory.test.ts @@ -0,0 +1,239 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { listDomainGroups, openGoogleDirectory } from '@/connectors/google-drive/directory' + +const mockFetch = vi.fn() + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) +} + +/** Routes each request by the group id in its path, so nesting can be described declaratively. */ +function directory(members: Record, groups: unknown[] = []) { + mockFetch.mockImplementation(async (url: string) => { + const path = new URL(String(url)).pathname + const match = path.match(/\/groups\/([^/]+)\/members$/) + if (match) { + const key = decodeURIComponent(match[1]) + return jsonResponse({ members: members[key] ?? [] }) + } + if (path.endsWith('/customer/my_customer/domains')) { + return jsonResponse({ + domains: [ + { domainName: 'Corp.com', domainAliases: [{ domainAliasName: 'corp.io' }] }, + { domainName: 'sub.corp.com' }, + ], + }) + } + return jsonResponse({ groups }) + }) +} + +const USER = (email: string) => ({ email, type: 'USER', status: 'ACTIVE' }) +const NESTED = (email: string) => ({ email, type: 'GROUP' }) + +describe('listDomainGroups', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', mockFetch) + }) + + it('folds group emails so they match the tokens a crawl writes', async () => { + directory({}, [{ email: 'Eng@Corp.com', name: 'Engineering' }]) + + await expect(listDomainGroups('token')).resolves.toEqual([{ id: 'eng@corp.com' }]) + }) + + it('drops a group with no email, which is the only identifier a grant carries', async () => { + directory({}, [{ name: 'Nameless' }, { email: 'eng@corp.com' }]) + + await expect(listDomainGroups('token')).resolves.toEqual([{ id: 'eng@corp.com' }]) + }) + + it('follows pagination rather than reporting the first page as the whole directory', async () => { + mockFetch + .mockResolvedValueOnce( + jsonResponse({ groups: [{ email: 'a@corp.com' }], nextPageToken: 'p2' }) + ) + .mockResolvedValueOnce(jsonResponse({ groups: [{ email: 'b@corp.com' }] })) + + await expect(listDomainGroups('token')).resolves.toHaveLength(2) + }) + + it('throws rather than returning a truncated directory', async () => { + mockFetch.mockReset() + mockFetch.mockResolvedValueOnce(jsonResponse({ error: { message: 'forbidden' } }, 403)) + + await expect(listDomainGroups('token')).rejects.toThrow() + }) +}) + +describe('the membership a directory reports', () => { + const GROUP = { id: 'eng@corp.com' } + + /** The walk is reached the way the sync reaches it, through the directory. */ + function membersOf(group: { id: string }) { + const dir = openGoogleDirectory('google-drive', 'token', 'admin@corp.com') + if (!dir) throw new Error('the administrator names no domain') + return dir.listGroupMembers(group) + } + + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', mockFetch) + }) + + it('reports the people in a flat group, case-folded', async () => { + directory({ 'eng@corp.com': [USER('Alice@Corp.com'), USER('bob@corp.com')] }) + + await expect(membersOf(GROUP)).resolves.toEqual({ + group: GROUP, + memberEmails: ['alice@corp.com', 'bob@corp.com'], + complete: true, + }) + }) + + /** + * The deviation from Onyx that matters here: they read one level, so a person + * who belongs only through a subgroup gets nothing despite the source + * granting them access. + */ + it('follows nested groups to the people inside them', async () => { + directory({ + 'eng@corp.com': [USER('alice@corp.com'), NESTED('backend@corp.com')], + 'backend@corp.com': [USER('bob@corp.com'), NESTED('platform@corp.com')], + 'platform@corp.com': [USER('carol@corp.com')], + }) + + const { memberEmails, complete } = await membersOf(GROUP) + + expect(memberEmails.sort()).toEqual(['alice@corp.com', 'bob@corp.com', 'carol@corp.com']) + expect(complete).toBe(true) + }) + + it('terminates on a directory that nests a group inside itself', async () => { + directory({ + 'eng@corp.com': [USER('alice@corp.com'), NESTED('backend@corp.com')], + 'backend@corp.com': [USER('bob@corp.com'), NESTED('eng@corp.com')], + }) + + const { memberEmails, complete } = await membersOf(GROUP) + + expect(memberEmails.sort()).toEqual(['alice@corp.com', 'bob@corp.com']) + expect(complete).toBe(true) + }) + + it('reports an incomplete walk rather than a truncated membership', async () => { + const members: Record = {} + for (let depth = 0; depth <= 32; depth += 1) { + members[`g${depth}@corp.com`] = [USER(`u${depth}@corp.com`), NESTED(`g${depth + 1}@corp.com`)] + } + directory(members) + + const { complete } = await membersOf({ id: 'g0@corp.com' }) + + expect(complete).toBe(false) + }) + + it('excludes a member the directory does not currently count as active', async () => { + directory({ + 'eng@corp.com': [ + USER('alice@corp.com'), + { email: 'suspended@corp.com', type: 'USER', status: 'SUSPENDED' }, + ], + }) + + await expect(membersOf(GROUP)).resolves.toMatchObject({ + memberEmails: ['alice@corp.com'], + }) + }) + + it('throws when a group cannot be read, so its membership is left alone', async () => { + directory({}) + mockFetch.mockImplementationOnce(async () => jsonResponse({ error: { message: 'gone' } }, 404)) + + await expect(membersOf(GROUP)).rejects.toThrow() + }) + + /** A directory that hiccups must not cost a group its membership; transient errors are retried. */ + it('retries a transient directory error before giving up', async () => { + directory({ 'eng@corp.com': [USER('alice@corp.com')] }) + const healthy = mockFetch.getMockImplementation()! + let firstMemberRead = true + mockFetch.mockImplementation(async (url: string, init?: RequestInit) => { + if (String(url).includes('/members') && firstMemberRead) { + firstMemberRead = false + return jsonResponse( + { error: { errors: [{ reason: 'backendError' }], message: 'try again' } }, + 503 + ) + } + return healthy(url, init) + }) + + await expect(membersOf(GROUP)).resolves.toMatchObject({ + memberEmails: ['alice@corp.com'], + complete: true, + }) + }) +}) + +describe('openGoogleDirectory', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', mockFetch) + }) + + it('lists one synthetic group per domain the customer owns, after the real groups', async () => { + directory({}, [{ email: 'eng@corp.com' }]) + const dir = openGoogleDirectory('google-drive', 'token', 'admin@corp.com') + + await expect(dir?.listGroups()).resolves.toEqual([ + { id: 'eng@corp.com' }, + { id: 'domain:corp.com' }, + { id: 'domain:corp.io' }, + { id: 'domain:sub.corp.com' }, + ]) + }) + + /** The wildcard is what a reader at that domain matches; nobody is enumerated. */ + it('answers a synthetic domain group with its wildcard member and no directory call', async () => { + directory({}) + const dir = openGoogleDirectory('google-drive', 'token', 'admin@corp.com') + + await expect(dir?.listGroupMembers({ id: 'domain:corp.com' })).resolves.toEqual({ + group: { id: 'domain:corp.com' }, + memberEmails: ['*@corp.com'], + complete: true, + }) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('stores a CUSTOMER member as the wildcard of every domain the customer owns', async () => { + directory({ 'all@corp.com': [{ type: 'CUSTOMER', status: 'ACTIVE' }, USER('bob@corp.com')] }) + const dir = openGoogleDirectory('google-drive', 'token', 'admin@corp.com') + + const { memberEmails, complete } = await dir!.listGroupMembers({ id: 'all@corp.com' }) + + expect(complete).toBe(true) + expect(memberEmails.sort()).toEqual([ + '*@corp.com', + '*@corp.io', + '*@sub.corp.com', + 'bob@corp.com', + ]) + }) + + it('carries the provider and tenant every token of the directory names', () => { + expect(openGoogleDirectory('google-drive', 'token', 'Admin@Corp.com')).toMatchObject({ + providerId: 'google-drive', + tenantId: 'corp.com', + }) + expect(openGoogleDirectory('google-drive', 'token', undefined)).toBeNull() + }) +}) diff --git a/apps/sim/connectors/google-drive/directory.ts b/apps/sim/connectors/google-drive/directory.ts new file mode 100644 index 00000000000..55d61dc22db --- /dev/null +++ b/apps/sim/connectors/google-drive/directory.ts @@ -0,0 +1,257 @@ +import { createLogger } from '@sim/logger' +import { normalizeEmail } from '@sim/utils/string' +import { + domainGroupId, + domainMemberWildcard, + domainOfGroupId, + emailDomain, + normalizeDomain, +} from '@/lib/knowledge/access/external-groups' +import { canonicalGroupId } from '@/lib/knowledge/access/tokens' +import { drainGooglePagedList } from '@/lib/oauth/google-pagination' +import { fetchGoogleDriveWithRetry } from '@/connectors/google-drive/google-drive-errors' +import type { + ConnectorDirectory, + ConnectorDirectoryGroup, + ConnectorDirectoryMembership, +} from '@/connectors/types' + +const logger = createLogger('GoogleDirectory') + +const DIRECTORY_BASE = 'https://admin.googleapis.com/admin/directory/v1' +const PAGE_SIZE = 200 + +/** Guards against a directory that keeps paginating; far above any real domain. */ +const MAX_PAGES = 200 + +/** + * How deep nested groups are followed when flattening membership. + * + * A directory can nest groups arbitrarily and can contain cycles, so the walk + * needs both a visited set and a depth bound. Onyx does not recurse at all, + * which silently drops everyone who is a member only through a subgroup; a + * bounded walk covers every real directory while still terminating. + */ +const MAX_GROUP_NESTING_DEPTH = 10 + +/** + * The Workspace domain an administrator's address belongs to, or undefined + * when the address is blank. + * + * This is the tenant of every group token a Drive crawl writes and of every + * group the directory sync stores, so it is derived in exactly one place: a + * crawl and a directory that spelled it differently would produce grants + * nothing ever resolves. + */ +export function googleWorkspaceDomain(adminEmail: unknown): string | undefined { + if (typeof adminEmail !== 'string') return undefined + return emailDomain(normalizeEmail(adminEmail)) || undefined +} + +function directoryFetch(url: string, accessToken: string): Promise { + return fetchGoogleDriveWithRetry(url, { + method: 'GET', + headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' }, + }) +} + +async function getJson(url: string, accessToken: string): Promise { + const response = await directoryFetch(url, accessToken) + return (await response.json()) as T +} + +/** + * Reads a paginated Admin SDK collection, following `nextPageToken`, with the + * same transient-error retry every Drive call gets. + * + * Throws rather than returning what it managed to read: every caller here is + * building a membership set that is only meaningful in full, and a truncated + * one would look like a group that lost members. + */ +async function listAll( + url: string, + accessToken: string, + itemsKey: 'groups' | 'members', + params: Record +): Promise { + const { items, truncated } = await drainGooglePagedList>({ + buildUrl: (pageToken) => { + const query = new URLSearchParams({ ...params, maxResults: String(PAGE_SIZE) }) + if (pageToken) query.set('pageToken', pageToken) + return `${url}?${query.toString()}` + }, + fetch: (pageUrl) => directoryFetch(pageUrl, accessToken), + parseError: (response) => response.json().catch(() => null), + getItems: (body) => body[itemsKey] as T[] | undefined, + getNextPageToken: (body) => body.nextPageToken as string | undefined, + maxPages: MAX_PAGES, + label: `Google Directory ${itemsKey}`, + }) + if (truncated) { + throw new Error(`Google Directory listing exceeded ${MAX_PAGES} pages (${itemsKey})`) + } + return items +} + +interface RawGroup { + email?: string +} + +interface RawMember { + email?: string + type?: string + status?: string +} + +interface RawDomain { + domainName?: string + domainAliases?: { domainAliasName?: string }[] +} + +/** + * Every domain the Workspace customer owns, aliases included. + * + * A Drive `domain` share names one of these, and a `CUSTOMER` group member is + * everyone on all of them, so each becomes a synthetic group whose one member + * is the domain wildcard. The customer's domains are the one thing here read + * without pagination: the endpoint returns them all at once. + */ +async function listCustomerDomains(accessToken: string): Promise { + const body = await getJson<{ domains?: RawDomain[] }>( + `${DIRECTORY_BASE}/customer/my_customer/domains`, + accessToken + ) + const domains = new Set() + for (const domain of body.domains ?? []) { + if (domain.domainName) domains.add(normalizeDomain(domain.domainName)) + for (const alias of domain.domainAliases ?? []) { + if (alias.domainAliasName) domains.add(normalizeDomain(alias.domainAliasName)) + } + } + return [...domains].filter(Boolean) +} + +/** + * Every group in the Workspace customer the administrator belongs to. + * + * `customer=my_customer` rather than `domain=`: a Workspace customer routinely + * owns several domains, and a grant to a group on a secondary domain would + * otherwise name a group the directory never enumerated — readable by nobody. + */ +export async function listDomainGroups(accessToken: string): Promise { + const raw = await listAll(`${DIRECTORY_BASE}/groups`, accessToken, 'groups', { + customer: 'my_customer', + }) + const groups: ConnectorDirectoryGroup[] = [] + for (const group of raw) { + const id = group.email ? canonicalGroupId(group.email) : '' + if (!id) continue + groups.push({ id }) + } + return groups +} + +/** + * Every person in a group, following nested groups to their members. + * + * Onyx reads one level and stops, so a person who belongs only through a + * subgroup silently gets nothing even though the source grants them access. + * Nesting is real in large directories, so the walk follows it — bounded by + * {@link MAX_GROUP_NESTING_DEPTH} and a visited set, because a directory may + * contain cycles and will happily report one. + * + * A member whose status is not `ACTIVE` is skipped: a suspended or pending + * member is one the source is not currently granting access to. A `CUSTOMER` + * member is everyone in the Workspace, stored as one wildcard per domain the + * customer owns. + */ +async function listGroupMembers( + group: ConnectorDirectoryGroup, + customerDomains: readonly string[], + membersOf: (groupId: string) => Promise +): Promise { + const memberEmails = new Set() + const visited = new Set([group.id]) + let complete = true + + async function walk(groupId: string, depth: number): Promise { + if (depth > MAX_GROUP_NESTING_DEPTH) { + complete = false + logger.warn('Stopped flattening a group at the nesting cap', { groupId, root: group.id }) + return + } + + for (const member of await membersOf(groupId)) { + if (member.status && member.status.toUpperCase() !== 'ACTIVE') continue + const type = member.type?.toUpperCase() + + if (type === 'CUSTOMER') { + for (const domain of customerDomains) memberEmails.add(domainMemberWildcard(domain)) + continue + } + const email = member.email ? normalizeEmail(member.email) : '' + if (!email) continue + if (type === 'GROUP') { + if (visited.has(email)) continue + visited.add(email) + await walk(email, depth + 1) + continue + } + memberEmails.add(email) + } + } + + await walk(group.id, 0) + return { group, memberEmails: [...memberEmails], complete } +} + +/** + * The Workspace customer the crawl is looking at, as a directory: its real + * groups, plus one synthetic group per domain it owns standing for "everyone + * at that domain", which is what a Drive domain share grants to. + */ +export function openGoogleDirectory( + providerId: string, + accessToken: string, + adminEmail: unknown +): ConnectorDirectory | null { + /** Direct members per group, so a subgroup nested under many parents is read once. */ + const directMembers = new Map>() + const membersOf = (groupId: string): Promise => { + let pending = directMembers.get(groupId) + if (!pending) { + pending = listAll( + `${DIRECTORY_BASE}/groups/${encodeURIComponent(groupId)}/members`, + accessToken, + 'members', + {} + ) + directMembers.set(groupId, pending) + } + return pending + } + const tenantId = googleWorkspaceDomain(adminEmail) + if (!tenantId) return null + + let domains: Promise | undefined + const customerDomains = (): Promise => { + domains ??= listCustomerDomains(accessToken) + return domains + } + + return { + providerId, + tenantId, + listGroups: async () => { + const [groups, owned] = await Promise.all([listDomainGroups(accessToken), customerDomains()]) + return [...groups, ...owned.map((domain) => ({ id: domainGroupId(domain) }))] + }, + listGroupMembers: async (group) => { + const domain = domainOfGroupId(group.id) + if (domain) { + return { group, memberEmails: [domainMemberWildcard(domain)], complete: true } + } + return listGroupMembers(group, await customerDomains(), membersOf) + }, + } +} diff --git a/apps/sim/connectors/google-drive/google-drive-errors.ts b/apps/sim/connectors/google-drive/google-drive-errors.ts index 76f9449542a..28c2de91548 100644 --- a/apps/sim/connectors/google-drive/google-drive-errors.ts +++ b/apps/sim/connectors/google-drive/google-drive-errors.ts @@ -1,3 +1,10 @@ +import { + attachRetryHeaders, + isRetryableError, + type RetryOptions, + resolveRetryDelayMs, + retryWithExponentialBackoff, +} from '@/lib/knowledge/documents/utils' import { readBodyWithLimit } from '@/connectors/utils' const GOOGLE_ERROR_BODY_MAX_BYTES = 64 * 1024 @@ -141,3 +148,35 @@ export async function readGoogleDriveApiError(response: Response): Promise { + return retryWithExponentialBackoff( + async () => { + const response = await fetch(url, options) + if (response.ok) return response + + const error = await readGoogleDriveApiError(response) + attachRetryHeaders(error, response.headers) + const waitMs = resolveRetryDelayMs(response.headers) + if (waitMs !== undefined) error.retryAfterMs = waitMs + throw error + }, + { + ...retryOptions, + retryCondition: (error) => + error instanceof GoogleDriveApiError + ? error.kind === 'transient' || isRetryableError(error) + : (retryOptions.retryCondition?.(error) ?? isRetryableError(error)), + } + ) +} diff --git a/apps/sim/connectors/google-drive/google-drive.test.ts b/apps/sim/connectors/google-drive/google-drive.test.ts index a1a474bc016..5a5706a4f88 100644 --- a/apps/sim/connectors/google-drive/google-drive.test.ts +++ b/apps/sim/connectors/google-drive/google-drive.test.ts @@ -18,9 +18,18 @@ import { GoogleDriveApiError, readGoogleDriveApiError, } from '@/connectors/google-drive/google-drive-errors' +import type { ExternalDocument } from '@/connectors/types' import { CONNECTOR_MAX_FILE_BYTES } from '@/connectors/utils' const FILE_ID = 'drive-file-1' +/** The listed document the ACL hook is asked about; only its id matters to Drive. */ +const FILE_DOC: ExternalDocument = { + externalId: FILE_ID, + title: 'File', + content: '', + mimeType: 'text/plain', + contentHash: 'h', +} const GOOGLE_DOCUMENT_MIME_TYPE = 'application/vnd.google-apps.document' const GOOGLE_SPREADSHEET_MIME_TYPE = 'application/vnd.google-apps.spreadsheet' @@ -613,3 +622,167 @@ describe('Google Drive change feed', () => { expect(googleDriveConnector.isChangeCursorInvalidError!(new Error('other'))).toBe(false) }) }) + +describe('mirroring Drive permissions onto listed documents', () => { + const ADMIN = { adminEmail: 'admin@corp.com' } + + function fileListResponse(files: unknown[]): Response { + return jsonResponse({ kind: 'drive#fileList', files }) + } + + function driveFile(overrides: Record) { + return { + id: FILE_ID, + name: 'Plan', + mimeType: GOOGLE_DOCUMENT_MIME_TYPE, + modifiedTime: '2026-01-01T00:00:00Z', + ...overrides, + } + } + + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', mockFetch) + }) + + /** The engine seeds this on every mirroring run; without it a crawl reads no permissions. */ + const MIRRORING = { mirrorsSourceAcls: true } + + async function listWith(file: Record, sourceConfig: Record) { + mockFetch.mockResolvedValueOnce(fileListResponse([file])) + const result = await googleDriveConnector.listDocuments('token', sourceConfig, undefined, { + ...MIRRORING, + }) + return result.documents[0] + } + + it('asks Drive for the permissions it needs to mirror', async () => { + mockFetch.mockResolvedValueOnce(fileListResponse([])) + await googleDriveConnector.listDocuments('token', ADMIN, undefined, { ...MIRRORING }) + + const url = String(mockFetch.mock.calls[0][0]) + expect(decodeURIComponent(url)).toContain('permissions(id,type,emailAddress,domain,role,') + }) + + /** A crawl that is not mirroring must not pull a permission array per file and discard it. */ + it('leaves permissions out of the field mask when the run does not mirror', async () => { + mockFetch.mockResolvedValueOnce(fileListResponse([])) + await googleDriveConnector.listDocuments('token', ADMIN, undefined, {}) + + expect(decodeURIComponent(String(mockFetch.mock.calls[0][0]))).not.toContain('permissions(') + }) + + it('tags each document with who may read it', async () => { + const doc = await listWith( + driveFile({ + permissions: [ + { id: 'p1', type: 'user', emailAddress: 'Alice@corp.com' }, + { id: 'p2', type: 'group', emailAddress: 'eng@corp.com' }, + ], + }), + ADMIN + ) + + expect(doc.acl).toEqual(['g:google-drive:corp.com:eng@corp.com', 'u:alice@corp.com']) + }) + + /** + * The tenant is baked into every stored group token, so it has to come from + * the administrator's own domain rather than anything a file happens to carry. + */ + it('names the group directory after the administrator the crawl runs as', async () => { + const doc = await listWith( + driveFile({ permissions: [{ id: 'p1', type: 'group', emailAddress: 'eng@other.com' }] }), + { adminEmail: 'Admin@Corp.com' } + ) + + expect(doc.acl).toEqual(['g:google-drive:corp.com:eng@other.com']) + }) + + it('mirrors no ACL at all when no administrator is configured', async () => { + const doc = await listWith( + driveFile({ permissions: [{ id: 'p1', type: 'user', emailAddress: 'alice@corp.com' }] }), + {} + ) + + expect(doc.acl).toBeUndefined() + }) + + it('keeps an openly shared file out of search until the admin opts in', async () => { + const shared = driveFile({ permissions: [{ id: 'p1', type: 'domain', domain: 'corp.com' }] }) + + await expect(listWith(shared, ADMIN)).resolves.toMatchObject({ acl: ['link'] }) + await expect(listWith(shared, { ...ADMIN, openSharing: 'domain' })).resolves.toMatchObject({ + acl: ['g:google-drive:corp.com:domain:corp.com'], + }) + }) + + it('never makes a link-only share findable, even with open sharing on', async () => { + const doc = await listWith( + driveFile({ permissions: [{ id: 'p1', type: 'anyone', allowFileDiscovery: false }] }), + { ...ADMIN, openSharing: 'anyone' } + ) + + expect(doc.acl).toEqual(['link']) + }) + + /** + * Drive does not populate `permissions` for a file on a shared drive; the + * only source is `permissions.list`. A listing that left the ACL unset must + * therefore be answered by the fallback, not treated as readable by nobody. + */ + it('resolves a file the listing could not describe through permissions.list', async () => { + const doc = await listWith(driveFile({}), ADMIN) + expect(doc.acl).toBeUndefined() + + mockFetch.mockResolvedValueOnce( + jsonResponse({ + permissions: [ + { id: 'p1', type: 'user', emailAddress: 'alice@corp.com' }, + { id: 'p2', type: 'group', emailAddress: 'eng@corp.com' }, + ], + }) + ) + + await expect( + googleDriveConnector.getDocumentAcls?.('token', ADMIN, [FILE_DOC], { ...MIRRORING }) + ).resolves.toEqual({ + [FILE_ID]: ['g:google-drive:corp.com:eng@corp.com', 'u:alice@corp.com'], + }) + const url = String(mockFetch.mock.calls[1][0]) + expect(url).toContain(`/files/${FILE_ID}/permissions`) + expect(url).toContain('supportsAllDrives=true') + }) + + it('follows the permission list across pages', async () => { + mockFetch + .mockResolvedValueOnce( + jsonResponse({ + permissions: [{ id: 'p1', type: 'user', emailAddress: 'alice@corp.com' }], + nextPageToken: 'p2', + }) + ) + .mockResolvedValueOnce( + jsonResponse({ permissions: [{ id: 'p2', type: 'user', emailAddress: 'bob@corp.com' }] }) + ) + + await expect( + googleDriveConnector.getDocumentAcls?.('token', ADMIN, [FILE_DOC], { ...MIRRORING }) + ).resolves.toEqual({ [FILE_ID]: ['u:alice@corp.com', 'u:bob@corp.com'] }) + }) + + it('omits a file whose permissions could not be read, so it stays hidden', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ error: 'nope' }, 403)) + + await expect( + googleDriveConnector.getDocumentAcls?.('token', ADMIN, [FILE_DOC], { ...MIRRORING }) + ).resolves.toEqual({}) + }) + + it('answers nothing for a crawl that mirrors no permissions', async () => { + await expect( + googleDriveConnector.getDocumentAcls?.('token', {}, [FILE_DOC], { ...MIRRORING }) + ).resolves.toEqual({}) + expect(mockFetch).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/connectors/google-drive/google-drive.ts b/apps/sim/connectors/google-drive/google-drive.ts index b10af7fa341..3b63097193e 100644 --- a/apps/sim/connectors/google-drive/google-drive.ts +++ b/apps/sim/connectors/google-drive/google-drive.ts @@ -1,19 +1,24 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { isPlainRecord } from '@sim/utils/object' +import { mapWithConcurrency } from '@/lib/core/utils/concurrency' import { - attachRetryHeaders, - isRetryableError, - type RetryOptions, - resolveRetryDelayMs, - retryWithExponentialBackoff, - VALIDATE_RETRY_OPTIONS, -} from '@/lib/knowledge/documents/utils' + type DrivePermission, + driveFileAcl, + type OpenSharingPolicy, +} from '@/lib/knowledge/access/drive-permissions' +import { VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' +import { drainGooglePagedList } from '@/lib/oauth/google-pagination' +import { googleWorkspaceDomain, openGoogleDirectory } from '@/connectors/google-drive/directory' import { + fetchGoogleDriveWithRetry, GoogleDriveApiError, - readGoogleDriveApiError, } from '@/connectors/google-drive/google-drive-errors' -import { googleDriveConnectorMeta } from '@/connectors/google-drive/meta' +import { + GOOGLE_DRIVE_ADMIN_EMAIL_FIELD_ID, + GOOGLE_DRIVE_OPEN_SHARING_FIELD_ID, + googleDriveConnectorMeta, +} from '@/connectors/google-drive/meta' import type { ConnectorConfig, ExternalChange, @@ -85,33 +90,6 @@ function isSupportedTextFile(mimeType: string): boolean { return SUPPORTED_TEXT_MIME_TYPES.some((t) => mimeType.startsWith(t)) } -/** Retries Google errors whose structured body identifies a transient rejection. */ -async function fetchGoogleDriveWithRetry( - url: string, - options: RequestInit, - retryOptions: RetryOptions = {} -): Promise { - return retryWithExponentialBackoff( - async () => { - const response = await fetch(url, options) - if (response.ok) return response - - const error = await readGoogleDriveApiError(response) - attachRetryHeaders(error, response.headers) - const waitMs = resolveRetryDelayMs(response.headers) - if (waitMs !== undefined) error.retryAfterMs = waitMs - throw error - }, - { - ...retryOptions, - retryCondition: (error) => - error instanceof GoogleDriveApiError - ? error.kind === 'transient' || isRetryableError(error) - : (retryOptions.retryCondition?.(error) ?? isRetryableError(error)), - } - ) -} - async function exportGoogleWorkspaceFile( accessToken: string, fileId: string, @@ -210,6 +188,12 @@ interface DriveFile { starred?: boolean trashed?: boolean parents?: string[] + /** + * Absent for a file on a shared drive, and for any file the impersonated + * administrator cannot share: Drive serves those only through + * `permissions.list`, which {@link resolveDriveAcls} calls for them. + */ + permissions?: DrivePermission[] } interface DriveChange { @@ -432,7 +416,144 @@ function buildQuery(sourceConfig: Record, lastSyncAt?: Date): s return parts.join(' and ') } -function fileToStub(file: DriveFile): ExternalDocument { +/** + * The provider segment of every group token a Drive crawl writes. Fixed, and + * baked into stored ACLs, so it must never change. + */ +const GOOGLE_DRIVE_ACL_PROVIDER_ID = 'google-drive' + +interface DriveAclContext { + providerId: string + tenantId: string + policy: OpenSharingPolicy +} + +/** + * The context an admin-mode crawl needs to name the principals on a file: which + * directory a group belongs to, and how far the admin has opted into open + * sharing being searchable. + * + * The tenant is the impersonated administrator's Workspace domain, derived by + * the same function the directory sync uses so the two can never disagree. + * Null when no administrator is configured, which is every crawl that is not + * mirroring permissions. + */ +function driveAclContext( + sourceConfig: Record, + syncContext?: Record +): DriveAclContext | null { + /** The engine says whether this run mirrors; the admin says whose eyes it crawls through. */ + if (syncContext && syncContext.mirrorsSourceAcls !== true) return null + const domain = googleWorkspaceDomain(sourceConfig[GOOGLE_DRIVE_ADMIN_EMAIL_FIELD_ID]) + if (!domain) return null + const openSharing = sourceConfig[GOOGLE_DRIVE_OPEN_SHARING_FIELD_ID] + return { + providerId: GOOGLE_DRIVE_ACL_PROVIDER_ID, + tenantId: domain, + policy: { + domain: openSharing === 'domain' || openSharing === 'anyone', + anyone: openSharing === 'anyone', + }, + } +} + +/** + * The file's mirrored ACL from its listing, or undefined when the listing + * cannot speak for it and {@link resolveDriveAcls} must. + * + * Drive leaves `permissions` unpopulated for a file on a shared drive, and for + * any file the requesting user cannot share. Those go to `permissions.list`, + * the one endpoint that answers for every file. + */ +function fileAcl(file: DriveFile, context: DriveAclContext | null): string[] | undefined { + if (!context || !file.permissions) return undefined + return driveFileAcl({ ...context, permissions: file.permissions }) +} + +/** Files whose permissions are fetched at once. Bounded to keep a crawl responsive. */ +const PERMISSION_FETCH_CONCURRENCY = 8 + +/** Guards against a file that keeps paginating; far above any real permission list. */ +const MAX_PERMISSION_PAGES = 50 + +/** The permission fields the ACL mapper reads, and nothing more. */ +const DRIVE_PERMISSION_FIELDS = 'id,type,emailAddress,domain,role,allowFileDiscovery,deleted' + +/** + * A file's full permission list, from the one endpoint that serves it for every + * file — including those on a shared drive, whose listing carries none. + * + * Throws rather than returning a partial list: a file mirrored under the + * permissions that happened to arrive is a file whose missing grants nobody + * verified. + */ +async function listFilePermissions( + accessToken: string, + fileId: string +): Promise { + const { items, truncated } = await drainGooglePagedList< + DrivePermission, + { permissions?: DrivePermission[]; nextPageToken?: string } + >({ + buildUrl: (pageToken) => { + const query = new URLSearchParams({ + fields: `nextPageToken,permissions(${DRIVE_PERMISSION_FIELDS})`, + pageSize: '100', + supportsAllDrives: 'true', + }) + if (pageToken) query.set('pageToken', pageToken) + return `https://www.googleapis.com/drive/v3/files/${encodeURIComponent(fileId)}/permissions?${query.toString()}` + }, + fetch: (url) => + fetchGoogleDriveWithRetry(url, { + method: 'GET', + headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' }, + }), + parseError: (response) => response.json().catch(() => null), + getItems: (body) => body.permissions, + getNextPageToken: (body) => body.nextPageToken, + maxPages: MAX_PERMISSION_PAGES, + label: 'Google Drive permissions', + }) + if (truncated) { + throw new Error(`Google Drive permissions exceeded ${MAX_PERMISSION_PAGES} pages`) + } + return items +} + +/** + * The ACLs of files whose listing could not describe them — every file on a + * shared drive, whose listing carries no permissions at all. + * + * A file whose permissions cannot be read is omitted, which leaves it readable + * by nobody until a run can read them: the failure is logged per file and the + * rest of the batch still resolves. + */ +async function resolveDriveAcls( + accessToken: string, + sourceConfig: Record, + externalIds: string[], + syncContext?: Record +): Promise> { + const context = driveAclContext(sourceConfig, syncContext) + if (!context) return {} + + const acls: Record = {} + await mapWithConcurrency(externalIds, PERMISSION_FETCH_CONCURRENCY, async (fileId) => { + try { + const permissions = await listFilePermissions(accessToken, fileId) + acls[fileId] = driveFileAcl({ ...context, permissions }) + } catch (error) { + logger.warn("Could not read a file's permissions; it stays readable by nobody", { + fileId, + ...googleDriveErrorLogFields(error), + }) + } + }) + return acls +} + +function fileToStub(file: DriveFile, acl?: string[]): ExternalDocument { /** * Sheets moved from a first-sheet-only CSV export to the complete XLSX source. * The namespace forces one rehydration for existing rows whose old hash would @@ -446,6 +567,7 @@ function fileToStub(file: DriveFile): ExternalDocument { title: file.name || 'Untitled', content: '', contentDeferred: true, + ...(acl ? { acl } : {}), mimeType: 'text/plain', sourceUrl: file.webViewLink || `https://drive.google.com/file/d/${file.id}/view`, contentHash: `${hashNamespace}:${file.id}:${file.modifiedTime ?? ''}`, @@ -483,12 +605,18 @@ export const googleDriveConnector: ConnectorConfig = { const remaining = maxFiles > 0 ? maxFiles - previouslyFetched : 0 const effectivePageSize = maxFiles > 0 ? Math.min(pageSize, remaining) : pageSize + const aclContext = driveAclContext(sourceConfig, syncContext) const queryParams = new URLSearchParams({ q: query, pageSize: String(effectivePageSize), orderBy: 'modifiedTime desc', - fields: - 'kind,nextPageToken,incompleteSearch,files(id,name,mimeType,modifiedTime,createdTime,webViewLink,owners,size,starred,parents)', + /** + * Permissions ride along only where the run mirrors them. Every other + * crawl would pull a permission array per file and discard it. + */ + fields: `kind,nextPageToken,incompleteSearch,files(id,name,mimeType,modifiedTime,createdTime,webViewLink,owners,size,starred,parents${ + aclContext ? `,permissions(${DRIVE_PERMISSION_FIELDS})` : '' + })`, supportsAllDrives: 'true', includeItemsFromAllDrives: 'true', }) @@ -529,7 +657,11 @@ export const googleDriveConnector: ConnectorConfig = { const pageDocuments = files .filter((f) => isGoogleWorkspaceFile(f.mimeType) || isSupportedTextFile(f.mimeType)) .map((f) => - stubOrSkipBySize(fileToStub(f), Number(f.size) || undefined, CONNECTOR_MAX_FILE_BYTES) + stubOrSkipBySize( + fileToStub(f, fileAcl(f, aclContext)), + Number(f.size) || undefined, + CONNECTOR_MAX_FILE_BYTES + ) ) const page = takeIndexableWithinCap( @@ -563,6 +695,21 @@ export const googleDriveConnector: ConnectorConfig = { } }, + openDirectory: async (accessToken, sourceConfig) => + openGoogleDirectory( + GOOGLE_DRIVE_ACL_PROVIDER_ID, + accessToken, + sourceConfig[GOOGLE_DRIVE_ADMIN_EMAIL_FIELD_ID] + ), + + getDocumentAcls: (accessToken, sourceConfig, documents, syncContext) => + resolveDriveAcls( + accessToken, + sourceConfig, + documents.map((doc) => doc.externalId), + syncContext + ), + getDocument: async ( accessToken: string, sourceConfig: Record, diff --git a/apps/sim/connectors/google-drive/meta.ts b/apps/sim/connectors/google-drive/meta.ts index 4b0ccd2cf25..de0683e8fc5 100644 --- a/apps/sim/connectors/google-drive/meta.ts +++ b/apps/sim/connectors/google-drive/meta.ts @@ -1,6 +1,11 @@ import { GoogleDriveIcon } from '@/components/icons' import type { ConnectorMeta } from '@/connectors/types' +/** The config field naming the administrator a service account crawls as. */ +export const GOOGLE_DRIVE_ADMIN_EMAIL_FIELD_ID = 'adminEmail' +/** The config field saying how far open shares are searchable. */ +export const GOOGLE_DRIVE_OPEN_SHARING_FIELD_ID = 'openSharing' + export const googleDriveConnectorMeta: ConnectorMeta = { id: 'google_drive', name: 'Google Drive', @@ -12,12 +17,51 @@ export const googleDriveConnectorMeta: ConnectorMeta = { mode: 'oauth', provider: 'google-drive', requiredScopes: ['https://www.googleapis.com/auth/drive'], + /** + * Read-only, and narrower than the interactive scope above: a crawl under + * domain-wide delegation reads every file in the domain, so it should never + * hold write access. The directory scopes are what let group grants be + * resolved to the people in them. + */ + serviceAccountScopes: [ + 'https://www.googleapis.com/auth/drive.readonly', + 'https://www.googleapis.com/auth/drive.metadata.readonly', + 'https://www.googleapis.com/auth/admin.directory.group.readonly', + 'https://www.googleapis.com/auth/admin.directory.user.readonly', + 'https://www.googleapis.com/auth/admin.directory.domain.readonly', + ], + serviceAccountSubjectFieldId: GOOGLE_DRIVE_ADMIN_EMAIL_FIELD_ID, }, /** `files.list` under a member's token returns only what that member can open. */ permissionScopedListing: { capFieldIds: ['maxFiles'] }, + /** `files.list` reports each file's own permissions, so one crawl can mirror them. */ + mirrorsSourceAcls: true, + configFields: [ + { + id: GOOGLE_DRIVE_ADMIN_EMAIL_FIELD_ID, + title: 'Crawl as', + type: 'short-input', + required: false, + placeholder: 'admin@yourcompany.com', + description: + 'A Google Workspace administrator the service account acts as. Required to mirror Drive permissions; leave blank when syncing with your own Google account.', + }, + { + id: GOOGLE_DRIVE_OPEN_SHARING_FIELD_ID, + title: 'Openly shared files', + type: 'dropdown', + required: false, + description: + 'Files shared beyond named people and groups. Kept out of search by default, because a domain-wide or public share is more often an accident than an intention. Never applies to link-only shares, which stay unsearchable.', + options: [ + { label: 'Keep out of search', id: 'none' }, + { label: 'Anyone in the domain can find', id: 'domain' }, + { label: 'Anyone can find', id: 'anyone' }, + ], + }, { id: 'folderSelector', title: 'Folders', diff --git a/apps/sim/connectors/types.ts b/apps/sim/connectors/types.ts index a185a701db6..69929f6df0c 100644 --- a/apps/sim/connectors/types.ts +++ b/apps/sim/connectors/types.ts @@ -7,7 +7,38 @@ import type { SelectorKey } from '@/lib/selectors/manifest' * API key connectors store an encrypted key in the `encryptedApiKey` column. */ export type ConnectorAuthConfig = - | { mode: 'oauth'; provider: OAuthService; requiredScopes?: string[] } + | { + mode: 'oauth' + provider: OAuthService + requiredScopes?: string[] + /** + * Scopes to mint with when the connector's credential is a service + * account rather than a person's OAuth account. + * + * A service account authorizes through a signed JWT that names its own + * scopes, so it has no granted-scope list to inherit from + * {@link requiredScopes} — that set describes an interactive consent + * screen, and a provider may accept scopes there that it refuses in a + * two-legged grant. Defaults to `requiredScopes` where the two sets + * genuinely coincide, which is the common case. + */ + serviceAccountScopes?: string[] + /** + * The config field naming the person a service-account credential acts + * as, through domain-wide delegation. + * + * A service account owns nothing in a Workspace domain, so a crawl under + * one sees an empty corpus until it impersonates somebody; the source's + * whole content and permission model is only visible through an + * administrator's eyes. + * + * The subject belongs to the connector, not the credential. One + * `google-service-account` credential matches every Google service, so a + * subject stored on it would silently apply to a workflow reading that + * person's mail as well as to this crawl reading their Drive. + */ + serviceAccountSubjectFieldId?: string + } | { mode: 'apiKey' label?: string @@ -21,6 +52,42 @@ export type ConnectorAuthConfig = optional?: boolean } +/** A group in an external directory, named as the connector's ACLs name it. */ +export interface ConnectorDirectoryGroup { + /** The identifier a `g:` token carries — a group email, a group id. */ + id: string +} + +export interface ConnectorDirectoryMembership { + group: ConnectorDirectoryGroup + /** Case-folded addresses of every person in the group, nesting flattened. */ + memberEmails: string[] + /** + * False when the walk could not be completed. A partial membership must never + * replace a stored one: cutting a group down to the part that happened to + * enumerate silently revokes everyone in the part that did not. + */ + complete: boolean +} + +/** + * One directory, opened for the length of a sync. Implementations throw rather + * than returning a partial listing — a truncated directory read as complete + * would delete every group past the cut-off. + */ +export interface ConnectorDirectory { + /** + * The provider segment of every group token this directory's groups produce + * — the same constant the connector's ACL mapper writes, so a stored token + * and the group row it names can never spell the provider differently. + */ + providerId: string + /** The tenant segment of every group token this directory's groups produce. */ + tenantId: string + listGroups: () => Promise + listGroupMembers: (group: ConnectorDirectoryGroup) => Promise +} + /** * A single document fetched from an external source. */ @@ -84,6 +151,19 @@ export interface ExternalDocument { * stale indexed content and persists the skipped state as authoritative. */ skippedExistingDisposition?: 'replace' + /** + * Who may read this document, mirrored from the source's own permissions. + * Only meaningful for a connector whose meta sets + * {@link ConnectorMeta.mirrorsSourceAcls}, and only used by an admin-mode + * crawl; every other mode derives access some other way and ignores it. + * + * Deliberately applied by a pass of its own rather than by the write that + * stores the document. A document whose content is unchanged is never + * written at all, and a permission change with no content change is the + * common case — so an ACL that rode along with the content write would only + * ever land for documents that happened to be edited. + */ + acl?: readonly string[] /** Additional source-specific metadata */ metadata?: Record } @@ -254,6 +334,21 @@ export interface ConnectorMeta { * whenever the connector crawls per member. */ permissionScopedListing?: { capFieldIds: readonly string[] } + + /** + * Set when the connector's listing reports each document's own permissions, + * so one crawl under an administrative credential can mirror the source's + * access model instead of asking every person to connect their own account. + * + * The connector answers for every document it lists — changed or not, since + * the ACL pass reads the whole listing — either by filling + * {@link ExternalDocument.acl} inline or through + * {@link ConnectorConfig.getDocumentAcls}, using the token vocabulary in + * `lib/knowledge/access/tokens`. A document it lists without an ACL is + * readable by nobody, so a connector must set this only where it can speak + * for every document it returns. + */ + mirrorsSourceAcls?: true } /** @@ -296,10 +391,15 @@ export interface ConnectorConfig extends ConnectorMeta { syncContext?: Record ) => Promise - /** Validate that sourceConfig is correct and accessible (called on save) */ + /** + * Validate that sourceConfig is correct and accessible (called on save). + * `syncContext` is seeded the same way a run's is, so a connector that + * reads its site from the credential need not rediscover it here. + */ validateConfig: ( accessToken: string, - sourceConfig: Record + sourceConfig: Record, + syncContext?: Record ) => Promise<{ valid: boolean; error?: string }> /** @@ -342,6 +442,48 @@ export interface ConnectorConfig extends ConnectorMeta { */ isChangeCursorInvalidError?: (error: unknown) => boolean + /** + * Opens the external directory whose groups this connector's mirrored ACLs + * refer to, or null when it has none reachable. + * + * The connector owns this because only it knows what a tenant is for its + * source — a Workspace domain for Drive, a site's cloud id for Confluence — + * and the tenant is baked into every stored group token, so a wrong guess + * orphans every ACL already written. It also owns the enumeration, so the + * sync orchestration stays provider-agnostic. + */ + openDirectory?: ( + accessToken: string, + sourceConfig: Record, + syncContext?: Record + ) => Promise + + /** + * The ACLs of listed documents the listing itself could not answer for. + * + * Called with exactly the documents whose {@link ExternalDocument.acl} the + * listing left unset, after the listing and once for all of them, so the + * round trips are bounded by what the listing could not carry rather than by + * the page size. The documents come with their listing metadata, which is + * where a connector keeps what the permission lookup needs — the space a + * page lives in, whether it is a page or a blog post. Drive fills the ACL + * inline for most files and lands here only for the ones its listing cannot + * describe — a shared drive's files, whose permissions Drive serves solely + * through `permissions.list`. Confluence carries none inline, so every page + * lands here. + * + * Returns tokens per external id. A document the connector omits is readable + * by nobody: a connector declaring {@link ConnectorMeta.mirrorsSourceAcls} + * promises an answer for every document it listed, and one whose permissions + * it could not read this run is omitted rather than guessed at. + */ + getDocumentAcls?: ( + accessToken: string, + sourceConfig: Record, + documents: readonly ExternalDocument[], + syncContext?: Record + ) => Promise> + /** Map source metadata to semantic tag keys (translated to slots by the sync engine) */ mapTags?: (metadata: Record) => Record } diff --git a/apps/sim/hooks/queries/kb/connectors.ts b/apps/sim/hooks/queries/kb/connectors.ts index 2a8611c21ce..7c18577c865 100644 --- a/apps/sim/hooks/queries/kb/connectors.ts +++ b/apps/sim/hooks/queries/kb/connectors.ts @@ -32,6 +32,7 @@ import { type ViewerConnectorMembership, type WorkspaceMemberConnector, } from '@/lib/api/contracts/knowledge' +import type { ConnectorAccessMode } from '@/lib/api/contracts/knowledge/connectors' import { MAX_KNOWLEDGE_CONNECTOR_DOCUMENT_PAGE_SIZE } from '@/lib/knowledge/constants' import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys' @@ -245,7 +246,7 @@ interface CreateConnectorParams { apiKey?: string sourceConfig: Record syncIntervalMinutes?: number - accessMode?: 'workspace' | 'members' + accessMode?: ConnectorAccessMode credentialGroupId?: string credentialGroupOptionId?: string } diff --git a/apps/sim/lib/api/contracts/knowledge/base.ts b/apps/sim/lib/api/contracts/knowledge/base.ts index 5e154df85ec..6b0e1fdd284 100644 --- a/apps/sim/lib/api/contracts/knowledge/base.ts +++ b/apps/sim/lib/api/contracts/knowledge/base.ts @@ -204,7 +204,7 @@ export const knowledgeBaseDataSchema = z folderId: z.string().nullable(), docCount: z.number().optional(), connectorTypes: z.array(z.string()).optional(), - hasMemberScopedConnector: z.boolean().optional(), + hasPermissionScopedConnector: z.boolean().optional(), }) .passthrough() export type KnowledgeBaseData = z.output diff --git a/apps/sim/lib/api/contracts/knowledge/connectors.ts b/apps/sim/lib/api/contracts/knowledge/connectors.ts index 1e7ffc1213d..223a80b8301 100644 --- a/apps/sim/lib/api/contracts/knowledge/connectors.ts +++ b/apps/sim/lib/api/contracts/knowledge/connectors.ts @@ -6,6 +6,10 @@ import { } from '@/lib/api/contracts/knowledge/shared' import { booleanQueryFlagSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' +import { + CONNECTOR_ACCESS_MODES, + isContentEngineAccessMode, +} from '@/lib/knowledge/connectors/access-modes' import { DEFAULT_KNOWLEDGE_CONNECTOR_DOCUMENT_PAGE_SIZE, MAX_KNOWLEDGE_CONNECTOR_DOCUMENT_MUTATION_ITEMS, @@ -14,16 +18,19 @@ import { import { MEMBER_SYNC_STATUSES } from '@/lib/knowledge/types' /** - * How a connector derives document access. `workspace` syncs as one credential - * and every document is visible to the workspace; `members` crawls once per - * Credential Group member and a document is visible to the members whose crawl - * returned it. `admin` is reserved. + * How a connector derives document access. + * + * `workspace` syncs as one credential and every document is visible to the + * whole workspace. `members` crawls once per Credential Group member, and a + * document is visible to the members whose own crawl returned it. `admin` + * crawls once under an administrative credential and mirrors the source's own + * permissions onto each document. */ -export const connectorAccessModeSchema = z.enum(['workspace', 'members', 'admin']) +export const connectorAccessModeSchema = z.enum(CONNECTOR_ACCESS_MODES) export type ConnectorAccessMode = z.output /** The modes a caller may put a connector into. */ -export const connectorRequestedAccessModeSchema = z.enum(['workspace', 'members']) +export const connectorRequestedAccessModeSchema = connectorAccessModeSchema const connectorAccessBindingShape = { accessMode: connectorRequestedAccessModeSchema.optional().default('workspace'), @@ -35,7 +42,7 @@ const connectorAccessBindingShape = { function requireAccessBinding( value: { - accessMode: 'workspace' | 'members' + accessMode: ConnectorAccessMode credentialGroupId?: string credentialGroupOptionId?: string }, @@ -82,8 +89,9 @@ export const createConnectorBodySchema = z }) /** - * Moves a connector between access modes. Switching to workspace mode needs - * the credential the connector will sync as from then on. + * Moves a connector between access modes. Switching into a mode that syncs + * with one credential — workspace or administrator — names the credential the + * connector will sync as from then on. */ export const updateConnectorAccessBodySchema = z .object({ @@ -101,11 +109,11 @@ export const updateConnectorAccessBodySchema = z message: 'A members-mode connector crawls with member credentials, not a credentialId', }) } - if (value.accessMode === 'workspace' && !value.credentialId) { + if (isContentEngineAccessMode(value.accessMode) && !value.credentialId) { ctx.addIssue({ code: 'custom', path: ['credentialId'], - message: 'Switching to workspace mode needs the credentialId the connector syncs as', + message: `Switching to ${value.accessMode} mode needs the credentialId the connector syncs as`, }) } }) diff --git a/apps/sim/lib/api/contracts/workspaces.ts b/apps/sim/lib/api/contracts/workspaces.ts index 318562ae4bf..627ed6988c7 100644 --- a/apps/sim/lib/api/contracts/workspaces.ts +++ b/apps/sim/lib/api/contracts/workspaces.ts @@ -304,6 +304,8 @@ export const workspaceHostContextSchema = z.object({ credentialGroups: z.boolean(), /** Optional for rolling compatibility with app versions that predate the flag. */ knowledgeMemberAccess: z.boolean().optional(), + /** Optional for rolling compatibility with app versions that predate administrator mode. */ + knowledgeSourceMirroredAccess: z.boolean().optional(), }) .optional(), /** Optional for rolling compatibility with app versions that predate deployment projection. */ diff --git a/apps/sim/lib/auth/sso/application/admit-sso-user.ts b/apps/sim/lib/auth/sso/application/admit-sso-user.ts index 3207660bacc..553c8acf011 100644 --- a/apps/sim/lib/auth/sso/application/admit-sso-user.ts +++ b/apps/sim/lib/auth/sso/application/admit-sso-user.ts @@ -2,6 +2,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { account, + foldedEmail, invitation, member, permissions, @@ -13,7 +14,7 @@ import { import { createLogger } from '@sim/logger' import { normalizeSSODomain } from '@sim/utils/sso-domain' import { normalizeEmail } from '@sim/utils/string' -import { and, desc, eq, gt, inArray, isNull, sql } from 'drizzle-orm' +import { and, desc, eq, gt, inArray, isNull } from 'drizzle-orm' import { applySessionPolicyToNewMember } from '@/lib/auth/session-policy' import { ssoJitAdmissionOperation } from '@/lib/auth/sso/application/operations' import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage' @@ -184,7 +185,7 @@ async function runAdmissionTransaction( eq(invitation.organizationId, provider.organizationId), eq(invitation.status, 'pending'), gt(invitation.expiresAt, new Date()), - sql`lower(trim(${invitation.email})) = ${normalizedEmail}` + eq(foldedEmail(invitation.email), normalizedEmail) ) ) .limit(1), diff --git a/apps/sim/lib/billing/enterprise-owner-claim.test.ts b/apps/sim/lib/billing/enterprise-owner-claim.test.ts index cd34c17b5ae..a0147be23fb 100644 --- a/apps/sim/lib/billing/enterprise-owner-claim.test.ts +++ b/apps/sim/lib/billing/enterprise-owner-claim.test.ts @@ -325,21 +325,11 @@ describe('Enterprise future-owner claims', () => { condition.type === 'eq' && condition.left === user.id && condition.right === 'owner-1' ) ).toBe(true) - const emailScope = updateConditions.find((condition) => condition.type === 'or') - const emailConditions = Array.isArray(emailScope?.conditions) ? emailScope.conditions : [] - expect( - emailConditions.some( - (condition) => - condition?.type === 'eq' && - condition.left === user.normalizedEmail && - condition.right === request.ownerEmail - ) - ).toBe(true) - expect( - emailConditions.filter( - (condition) => condition?.type === 'eq' && condition.right === request.ownerEmail - ) - ).toHaveLength(2) + /** The folded address is one `eq`, not an OR over a dead column and an ad-hoc fold. */ + const emailConditions = updateConditions.filter( + (condition) => condition?.type === 'eq' && condition.right === request.ownerEmail + ) + expect(emailConditions).toHaveLength(1) expect(mocks.createOrganization).not.toHaveBeenCalled() expect(mocks.enqueue).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/billing/enterprise-owner-claim.ts b/apps/sim/lib/billing/enterprise-owner-claim.ts index b2dab33933f..3fd05e5032f 100644 --- a/apps/sim/lib/billing/enterprise-owner-claim.ts +++ b/apps/sim/lib/billing/enterprise-owner-claim.ts @@ -1,6 +1,6 @@ import { AuditAction, AuditResourceType, recordAuditOnce } from '@sim/audit' import { db } from '@sim/db' -import { member, outboxEvent, user, workspace } from '@sim/db/schema' +import { foldedEmail, member, outboxEvent, user, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' import { generateId } from '@sim/utils/id' @@ -297,9 +297,7 @@ async function assertOwnerEmailHasNoAccount(ownerEmail: string): Promise { const [existingUser] = await db .select({ id: user.id }) .from(user) - .where( - or(eq(user.normalizedEmail, ownerEmail), eq(sql`lower(${user.email})`, ownerEmail)) - ) + .where(eq(foldedEmail(user.email), ownerEmail)) .limit(1) if (existingUser) { throw new EnterpriseProvisioningError( @@ -499,12 +497,7 @@ export async function createEnterpriseOwnerClaim( const [accountCreatedDuringReview] = await tx .select({ id: user.id }) .from(user) - .where( - or( - eq(user.normalizedEmail, normalized.ownerEmail), - eq(sql`lower(${user.email})`, normalized.ownerEmail) - ) - ) + .where(eq(foldedEmail(user.email), normalized.ownerEmail)) .limit(1) if (accountCreatedDuringReview) { throw new EnterpriseProvisioningError( @@ -968,13 +961,7 @@ export async function acceptEnterpriseOwnerClaim(params: { .update(user) .set({ emailVerified: true, updatedAt: new Date() }) .where( - and( - eq(user.id, params.userId), - or( - eq(user.normalizedEmail, payload.request.ownerEmail), - eq(sql`lower(trim(${user.email}))`, payload.request.ownerEmail) - ) - ) + and(eq(user.id, params.userId), eq(foldedEmail(user.email), payload.request.ownerEmail)) ) .returning({ id: user.id }) if (!verifiedOwner) { diff --git a/apps/sim/lib/billing/webhooks/enterprise.ts b/apps/sim/lib/billing/webhooks/enterprise.ts index 4cafe2f1b31..df120c9bd8c 100644 --- a/apps/sim/lib/billing/webhooks/enterprise.ts +++ b/apps/sim/lib/billing/webhooks/enterprise.ts @@ -1,9 +1,10 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { organization, outboxEvent, session, subscription, user } from '@sim/db/schema' +import { foldedEmail, organization, outboxEvent, session, subscription, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { isRecordLike } from '@sim/utils/object' +import { normalizeEmail } from '@sim/utils/string' import { and, eq, inArray, sql } from 'drizzle-orm' import type Stripe from 'stripe' import { getEmailSubject, renderEnterpriseSubscriptionEmail } from '@/components/emails' @@ -532,7 +533,7 @@ async function reconcileManualEnterpriseSubscription( requestedByUserId ? eq(user.id, requestedByUserId) : requestedByEmail - ? eq(user.normalizedEmail, requestedByEmail.toLowerCase()) + ? eq(foldedEmail(user.email), normalizeEmail(requestedByEmail)) : eq(user.stripeCustomerId, stripeCustomerId) ) .limit(1) diff --git a/apps/sim/lib/credential-groups/credentials.ts b/apps/sim/lib/credential-groups/credentials.ts index 584e5fc7f80..2e55774400c 100644 --- a/apps/sim/lib/credential-groups/credentials.ts +++ b/apps/sim/lib/credential-groups/credentials.ts @@ -5,9 +5,10 @@ import { credential, credentialGroup, credentialGroupEnrollment, + foldedEmail, user, } from '@sim/db/schema' -import { and, asc, eq, gt, inArray, or, type SQL, sql } from 'drizzle-orm' +import { and, asc, eq, gt, inArray, or, type SQL } from 'drizzle-orm' import { getCredentialGroupProviderId, isCredentialGroupProvider, @@ -125,7 +126,7 @@ export async function loadCredentialGroupEnrollmentAccess( email: credentialGroupEnrollment.email, }) .from(credentialGroupEnrollment) - .innerJoin(user, eq(sql`lower(btrim(${user.email}))`, credentialGroupEnrollment.email)) + .innerJoin(user, eq(foldedEmail(user.email), credentialGroupEnrollment.email)) .where( and( eq(user.id, userId), diff --git a/apps/sim/lib/credentials/environment.ts b/apps/sim/lib/credentials/environment.ts index 569f5663b22..727eeeb68fa 100644 --- a/apps/sim/lib/credentials/environment.ts +++ b/apps/sim/lib/credentials/environment.ts @@ -4,6 +4,7 @@ import { credentialGroup, credentialGroupEnrollment, credentialMember, + foldedEmail, permissions, user, workspace, @@ -872,7 +873,7 @@ export async function getEnrolledManagedOAuthCredentials( eq(credentialGroupEnrollment.id, credential.credentialGroupEnrollmentId) ) .innerJoin(credentialGroup, eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId)) - .innerJoin(user, eq(sql`lower(btrim(${user.email}))`, credentialGroupEnrollment.email)) + .innerJoin(user, eq(foldedEmail(user.email), credentialGroupEnrollment.email)) .where( and( eq(credential.workspaceId, workspaceId), diff --git a/apps/sim/lib/invitations/direct-grant.ts b/apps/sim/lib/invitations/direct-grant.ts index e18b6b19e21..d6efa768185 100644 --- a/apps/sim/lib/invitations/direct-grant.ts +++ b/apps/sim/lib/invitations/direct-grant.ts @@ -1,6 +1,7 @@ import { AuditAction, AuditResourceType, recordAudit, recordAuditOnce } from '@sim/audit' import { db } from '@sim/db' import { + foldedEmail, invitation, invitationWorkspaceGrant, member, @@ -12,7 +13,7 @@ import { permissionSatisfies } from '@sim/platform-authz/workspace' import { generateId } from '@sim/utils/id' import { isRecordLike } from '@sim/utils/object' import { normalizeEmail } from '@sim/utils/string' -import { and, eq, sql } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { acquireOrganizationUserMutationLocks, @@ -94,7 +95,7 @@ async function getPendingWorkspaceInvitationIds( .innerJoin(invitationWorkspaceGrant, eq(invitationWorkspaceGrant.invitationId, invitation.id)) .where( and( - sql`lower(${invitation.email}) = ${normalizedEmail}`, + eq(foldedEmail(invitation.email), normalizedEmail), eq(invitation.status, 'pending'), eq(invitationWorkspaceGrant.workspaceId, workspaceId) ) diff --git a/apps/sim/lib/invitations/workspace-invitations.ts b/apps/sim/lib/invitations/workspace-invitations.ts index c96c3934fbe..96430f14b56 100644 --- a/apps/sim/lib/invitations/workspace-invitations.ts +++ b/apps/sim/lib/invitations/workspace-invitations.ts @@ -1,9 +1,15 @@ import { AuditAction, AuditResourceType, recordAudit, recordAuditOnce } from '@sim/audit' import { db } from '@sim/db' -import { type InvitationMembershipIntent, member, permissions, user } from '@sim/db/schema' +import { + foldedEmail, + type InvitationMembershipIntent, + member, + permissions, + user, +} from '@sim/db/schema' import { isOrgAdminRole, permissionSatisfies } from '@sim/platform-authz/workspace' import { normalizeEmail } from '@sim/utils/string' -import { and, eq, inArray, sql } from 'drizzle-orm' +import { and, eq, inArray } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization' import { @@ -479,7 +485,7 @@ export async function createWorkspaceInvitation({ const existingUser = await db .select({ id: user.id }) .from(user) - .where(sql`lower(${user.email}) = ${normalizedEmail}`) + .where(eq(foldedEmail(user.email), normalizedEmail)) .then((rows) => rows[0]) const existingMembership = existingUser ? await getUserOrganization(existingUser.id) : null diff --git a/apps/sim/lib/knowledge/access/availability.ts b/apps/sim/lib/knowledge/access/availability.ts index 6496b0e4b9d..891642f9062 100644 --- a/apps/sim/lib/knowledge/access/availability.ts +++ b/apps/sim/lib/knowledge/access/availability.ts @@ -2,6 +2,7 @@ import { getWorkspaceOwnerSubscriptionAccess, type WorkspaceOwnerSubscriptionAccess, } from '@/lib/billing/core/workspace-access' +import { isHosted } from '@/lib/core/config/env-flags' import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { OrchestrationError } from '@/lib/core/orchestration/types' import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability' @@ -22,23 +23,75 @@ export interface KnowledgeMemberAccessContext { } /** - * Whether permission-aware knowledge is on for this workspace: the - * `knowledge-member-access` flag, and Credential Groups available to the - * workspace, which members mode enrolls people through. Every gate the - * feature has checks this one function — creating and switching connectors, - * the member engine, the member tokens a reader is granted, and the - * workspace host context the UI reads — so they can never disagree. When it - * turns off, member-scoped documents are hidden on the next read, members-mode - * connectors wait rather than change anything, and search returns to the - * semantic-only default; nothing is deleted. + * What permission-aware knowledge this workspace may use. + * + * Two answers rather than one, because the two ways a document can be + * permission-scoped depend on different things. `admin` mode mirrors a source's + * own ACLs and touches no Credential Group; `members` mode is built entirely + * out of them. Collapsing both into one gate would let an operator turning off + * Credential Groups silently revoke every document an administrator crawl had + * mirrored, from a feature it does not use. + * + * Resolved together so the billing lookup happens once, and returned as a pair + * so a caller cannot check one and act on the other. */ -export async function isKnowledgeMemberAccessAvailable( +export interface KnowledgeAccessAvailability { + /** Source-mirrored ACLs: `admin` connectors, and the `u:`/`g:` tokens that read them. */ + sourceMirrored: boolean + /** Credential-Group enrollments: `members` connectors, and the `s:` tokens that read them. */ + memberScoped: boolean +} + +export async function resolveKnowledgeAccessAvailability( context: KnowledgeMemberAccessContext -): Promise { - if (!(await isFeatureEnabled('knowledge-member-access', context))) return false +): Promise { + if (!(await isFeatureEnabled('knowledge-member-access', context))) { + return { sourceMirrored: false, memberScoped: false } + } const ownerBilling = context.ownerBilling ?? (await getWorkspaceOwnerSubscriptionAccess(context.workspaceId)) - return isCredentialGroupsAvailable({ workspaceId: context.workspaceId, ownerBilling }) + + /** + * Both are enterprise features on Sim Cloud. Credential Groups carry that + * clause already, so mirroring restates it rather than borrowing a gate whose + * other half is about a feature it does not use. + */ + const sourceMirrored = !isHosted || ownerBilling.isEnterprise + return { + sourceMirrored, + memberScoped: await isCredentialGroupsAvailable({ + workspaceId: context.workspaceId, + ownerBilling, + }), + } +} + +/** + * Whether members mode is on for this workspace: the `knowledge-member-access` + * flag, and Credential Groups available to the workspace, which members mode + * enrolls people through. The members-mode gates — creating and switching + * connectors, the member engine, the workspace host context the UI reads — + * check this; the reader's tokens come from `resolveKnowledgeAccessAvailability` + * directly, which this is the `memberScoped` half of, so they can never + * disagree. When it turns off, member-scoped documents are hidden on the next + * read, members-mode connectors wait rather than change anything, and search + * returns to the semantic-only default; nothing is deleted. + */ +export async function isKnowledgeMemberAccessAvailable( + context: KnowledgeMemberAccessContext +): Promise { + return (await resolveKnowledgeAccessAvailability(context)).memberScoped +} + +/** Refuses with the one message every source-mirroring gate uses when the feature is off. */ +export async function requireSourceMirroredAccessAvailable( + context: KnowledgeMemberAccessContext +): Promise { + if ((await resolveKnowledgeAccessAvailability(context)).sourceMirrored) return + throw new OrchestrationError( + 'validation', + 'Administrator access is not available for this workspace' + ) } /** Refuses with the one message every members-mode gate uses when the feature is off for the workspace. */ diff --git a/apps/sim/lib/knowledge/access/confluence-permissions.test.ts b/apps/sim/lib/knowledge/access/confluence-permissions.test.ts new file mode 100644 index 00000000000..dedba97989f --- /dev/null +++ b/apps/sim/lib/knowledge/access/confluence-permissions.test.ts @@ -0,0 +1,108 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + type ConfluencePrincipal, + type ConfluenceRestriction, + confluencePageAcl, +} from '@/lib/knowledge/access/confluence-permissions' +import { ACCESS_TOKEN_PATTERN } from '@/lib/knowledge/access/tokens' + +const PROVIDER = 'confluence' +const TENANT = 'cloud-1' + +const user = (id: string, email?: string | null): ConfluencePrincipal => ({ + kind: 'user', + id, + email, +}) +const group = (id: string): ConfluencePrincipal => ({ kind: 'group', id }) + +function acl( + spacePrincipals: ConfluencePrincipal[], + restrictionChain: ConfluenceRestriction[] = [] +) { + return confluencePageAcl({ + spacePrincipals, + restrictionChain, + providerId: PROVIDER, + tenantId: TENANT, + }) +} + +describe('confluencePageAcl', () => { + it('falls back to the space when no page in the chain is restricted', () => { + expect(acl([user('a', 'Alice@corp.com'), group('g-eng')], [null, null]).acl).toEqual([ + `g:${PROVIDER}:${TENANT}:g-eng`, + 'u:alice@corp.com', + ]) + }) + + /** + * A restriction replaces the space's permissions rather than narrowing them. + * Space members not on the restriction lose access, which is the point. + */ + it("lets a page's own restriction replace the space's permissions", () => { + const result = acl( + [user('a', 'alice@corp.com'), user('b', 'bob@corp.com')], + [[user('b', 'bob@corp.com')]] + ) + + expect(result.acl).toEqual(['u:bob@corp.com']) + }) + + it('takes the closest restricted ancestor when the page itself is unrestricted', () => { + const result = acl( + [user('a', 'alice@corp.com')], + [null, [user('b', 'bob@corp.com')], [user('c', 'carol@corp.com')]] + ) + + expect(result.acl).toEqual(['u:bob@corp.com']) + }) + + /** + * `null` means "no restriction, inherit"; an empty list means "restricted to + * nobody". Collapsing them would publish every deliberately-locked page. + */ + it('distinguishes an unrestricted page from one restricted to nobody', () => { + expect(acl([user('a', 'alice@corp.com')], [null]).acl).toEqual(['u:alice@corp.com']) + expect(acl([user('a', 'alice@corp.com')], [[]]).acl).toEqual(['link']) + }) + + it('identifies a group by its id, which survives a rename', () => { + expect(acl([group('g-eng')]).acl).toEqual([`g:${PROVIDER}:${TENANT}:g-eng`]) + }) + + describe('a person Confluence will not name', () => { + it('drops the grant and reports it rather than guessing', () => { + const result = acl([user('a', null), user('b', 'bob@corp.com')]) + + expect(result.acl).toEqual(['u:bob@corp.com']) + expect(result.unattributedUsers).toBe(1) + }) + + it('hides a page whose every grant was withheld, rather than showing it', () => { + const result = acl([user('a', null), user('b', undefined)]) + + expect(result.acl).toEqual(['link']) + expect(result.unattributedUsers).toBe(2) + }) + + it('counts only the grant that won, not the ones it replaced', () => { + const result = acl([user('a', null), user('b', null)], [[user('c', 'carol@corp.com')]]) + + expect(result.acl).toEqual(['u:carol@corp.com']) + expect(result.unattributedUsers).toBe(0) + }) + }) + + it('hides a page in a space nobody may read', () => { + expect(acl([]).acl).toEqual(['link']) + }) + + it('only ever emits tokens the document ACL constraint accepts', () => { + const { acl: tokens } = acl([user('a', 'alice@corp.com'), group('g-eng')]) + for (const token of tokens) expect(token).toMatch(ACCESS_TOKEN_PATTERN) + }) +}) diff --git a/apps/sim/lib/knowledge/access/confluence-permissions.ts b/apps/sim/lib/knowledge/access/confluence-permissions.ts new file mode 100644 index 00000000000..6369ad4e473 --- /dev/null +++ b/apps/sim/lib/knowledge/access/confluence-permissions.ts @@ -0,0 +1,105 @@ +import { groupToken, sortAccessTokens, userToken } from '@/lib/knowledge/access/tokens' +import { LINK_ACCESS_TOKEN } from '@/lib/knowledge/access/types' + +/** + * A principal Confluence names on a space permission or a page restriction. + * + * Confluence identifies both kinds by opaque id — an Atlassian account id for a + * person, a group id for a group — never by email or name. A person's email is + * resolved separately and may be withheld entirely; a group's id needs no + * resolution at all. + */ +export interface ConfluencePrincipal { + kind: 'user' | 'group' + id: string + /** + * The person's address, where Confluence disclosed it. Absent for a group, + * and absent for a person whose profile hides it. + */ + email?: string | null +} + +/** + * A page's own read restriction, or `null` when it has none. + * + * `null` is load-bearing and distinct from an empty list: no restriction means + * "inherit", while a restriction naming nobody means the page is readable by + * nobody. Collapsing the two would publish every unrestricted page under its + * space's ACL *and* every deliberately-locked one under nothing. + */ +export type ConfluenceRestriction = ConfluencePrincipal[] | null + +export interface ConfluenceAclInput { + /** Who may read the space, from its permissions. The fallback for every page in it. */ + spacePrincipals: readonly ConfluencePrincipal[] + /** + * The page's own restriction, then its ancestors' from closest parent + * outward. The first entry that is not `null` decides; if none is, the space + * decides. + */ + restrictionChain: readonly ConfluenceRestriction[] + /** The provider segment of every group token, matching the directory sync. */ + providerId: string + /** The Confluence site — its cloud id, which is unique and never renamed. */ + tenantId: string +} + +export interface ConfluenceAclResult { + acl: string[] + /** + * People named on the winning grant whose email Confluence withheld, so the + * grant could not be attributed. Reported rather than silently dropped: on a + * site that hides every profile it is the difference between "this page is + * restricted to two people" and "this page is readable by nobody". + */ + unattributedUsers: number +} + +/** + * The ACL of one Confluence page, blog post, or attachment. + * + * A read restriction **replaces** the space's permissions rather than narrowing + * them, which is Onyx's rule and the one Confluence's own UI leads people to + * expect. Real Confluence access is the intersection — space permission *and* + * restriction — so this over-grants in exactly one case: somebody named on a + * page restriction who cannot view the space at all. That is a misconfiguration + * in the source, and it errs toward a page they were deliberately named on. + * + * Representing the true intersection would mean expanding both principal sets + * to member addresses and intersecting them, which our group tables could do + * and Onyx's cannot — but it emits one token per member, so a five-thousand + * person space would carry five-thousand-token ACLs on every restricted page. + * The union keeps ACLs short, which is what keeps the read predicate fast. + */ +export function confluencePageAcl(input: ConfluenceAclInput): ConfluenceAclResult { + const winning = input.restrictionChain.find((entry) => entry !== null) ?? input.spacePrincipals + + const tokens = new Set() + let unattributedUsers = 0 + for (const principal of winning) { + if (principal.kind === 'group') { + /** + * The group's id, not its name. Onyx uses names because its membership + * sync is keyed by name; ours is keyed by whatever the permissions API + * returns, and that is the id — so using it costs no extra lookup per + * group and survives a rename, which a name-keyed ACL would not. + */ + const token = groupToken({ + providerId: input.providerId, + tenantId: input.tenantId, + groupId: principal.id, + }) + if (token) tokens.add(token) + continue + } + + const token = userToken(principal.email) + if (token) tokens.add(token) + else unattributedUsers += 1 + } + + return { + acl: tokens.size === 0 ? [LINK_ACCESS_TOKEN] : sortAccessTokens(tokens), + unattributedUsers, + } +} diff --git a/apps/sim/lib/knowledge/access/drive-permissions.test.ts b/apps/sim/lib/knowledge/access/drive-permissions.test.ts new file mode 100644 index 00000000000..a27b42a6304 --- /dev/null +++ b/apps/sim/lib/knowledge/access/drive-permissions.test.ts @@ -0,0 +1,149 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + type DrivePermission, + driveFileAcl, + type OpenSharingPolicy, +} from '@/lib/knowledge/access/drive-permissions' +import { domainGroupId } from '@/lib/knowledge/access/external-groups' +import { ACCESS_TOKEN_PATTERN } from '@/lib/knowledge/access/tokens' + +const PROVIDER = 'google-drive' +const TENANT = 'C01abcdef' +const OPEN: OpenSharingPolicy = { domain: true, anyone: true } + +const CLOSED_OPEN_SHARING = { domain: false, anyone: false } as const + +function acl(permissions: DrivePermission[], policy: OpenSharingPolicy = CLOSED_OPEN_SHARING) { + return driveFileAcl({ permissions, providerId: PROVIDER, tenantId: TENANT, policy }) +} + +describe('driveFileAcl', () => { + it('grants a named person their own token, case-folded', () => { + expect(acl([{ type: 'user', emailAddress: 'Alice@Corp.com' }])).toEqual(['u:alice@corp.com']) + }) + + it('grants a group by its email, which is the only identifier Drive returns', () => { + expect(acl([{ type: 'group', emailAddress: 'Sales@corp.com' }])).toEqual([ + `g:${PROVIDER}:${TENANT}:sales@corp.com`, + ]) + }) + + it('keeps every grant on a file shared several ways', () => { + expect( + acl([ + { type: 'user', emailAddress: 'alice@corp.com' }, + { type: 'user', emailAddress: 'bob@corp.com' }, + { type: 'group', emailAddress: 'sales@corp.com' }, + ]) + ).toEqual([`g:${PROVIDER}:${TENANT}:sales@corp.com`, 'u:alice@corp.com', 'u:bob@corp.com']) + }) + + it('drops the grant of a deleted account rather than minting a token for a recycled address', () => { + expect( + acl([ + { type: 'user', emailAddress: 'gone@corp.com', deleted: true }, + { type: 'user', emailAddress: 'alice@corp.com' }, + ]) + ).toEqual(['u:alice@corp.com']) + }) + + describe('open sharing is closed by default', () => { + it('drops a whole-domain share', () => { + expect(acl([{ type: 'domain', domain: 'corp.com' }])).toEqual(['link']) + }) + + it('drops a discoverable anyone share', () => { + expect(acl([{ type: 'anyone' }])).toEqual(['link']) + }) + + it('leaves the file readable by whoever was named on it', () => { + expect( + acl([ + { type: 'user', emailAddress: 'alice@corp.com' }, + { type: 'domain', domain: 'corp.com' }, + ]) + ).toEqual(['u:alice@corp.com']) + }) + }) + + describe('open sharing, once an admin opts in', () => { + it('grants a whole-domain share to a synthetic domain group', () => { + expect(acl([{ type: 'domain', domain: 'Corp.com' }], OPEN)).toEqual([ + `g:${PROVIDER}:${TENANT}:domain:corp.com`, + ]) + }) + + it('grants a discoverable anyone share to everyone', () => { + expect(acl([{ type: 'anyone' }], OPEN)).toEqual(['pub']) + expect(acl([{ type: 'anyone', allowFileDiscovery: true }], OPEN)).toEqual(['pub']) + }) + + /** + * The deviation from Onyx that matters most: their file path makes an + * `anyone` grant public without consulting `allowFileDiscovery`, so a file + * anyone ever shared by link becomes fully searchable. + */ + it('still refuses a link-only anyone share', () => { + expect(acl([{ type: 'anyone', allowFileDiscovery: false }], OPEN)).toEqual(['link']) + }) + + it('still refuses a link-only domain share', () => { + expect( + acl([{ type: 'domain', domain: 'corp.com', allowFileDiscovery: false }], OPEN) + ).toEqual(['link']) + }) + }) + + describe('grants that cannot be attributed', () => { + it('drops a principal with no email rather than guessing', () => { + expect(acl([{ type: 'user', emailAddress: null }])).toEqual(['link']) + expect(acl([{ type: 'group', emailAddress: '' }])).toEqual(['link']) + }) + + it('drops a domain share naming no domain', () => { + expect(acl([{ type: 'domain', domain: null }], OPEN)).toEqual(['link']) + }) + + it('ignores a permission type it does not understand', () => { + expect( + acl([{ type: 'someFutureType' }, { type: 'user', emailAddress: 'alice@corp.com' }]) + ).toEqual(['u:alice@corp.com']) + }) + + it('resolves a file with no permissions at all to link, not to nobody', () => { + expect(acl([])).toEqual(['link']) + }) + }) + + it('emits sorted, de-duplicated tokens so two crawls agree byte for byte', () => { + expect( + acl([ + { type: 'user', emailAddress: 'zoe@corp.com' }, + { type: 'user', emailAddress: 'alice@corp.com' }, + { type: 'user', emailAddress: 'ALICE@corp.com' }, + ]) + ).toEqual(['u:alice@corp.com', 'u:zoe@corp.com']) + }) + + it('only ever emits tokens the document ACL constraint accepts', () => { + const tokens = acl( + [ + { type: 'user', emailAddress: 'alice@corp.com' }, + { type: 'group', emailAddress: 'sales@corp.com' }, + { type: 'domain', domain: 'corp.com' }, + { type: 'anyone' }, + ], + OPEN + ) + for (const token of tokens) expect(token).toMatch(ACCESS_TOKEN_PATTERN) + }) +}) + +describe('domainGroupId', () => { + it('case-folds so one domain is one group', () => { + expect(domainGroupId(' Corp.COM ')).toBe('domain:corp.com') + }) +}) diff --git a/apps/sim/lib/knowledge/access/drive-permissions.ts b/apps/sim/lib/knowledge/access/drive-permissions.ts new file mode 100644 index 00000000000..b9fb5c2f755 --- /dev/null +++ b/apps/sim/lib/knowledge/access/drive-permissions.ts @@ -0,0 +1,126 @@ +import { domainGroupId } from '@/lib/knowledge/access/external-groups' +import { groupToken, sortAccessTokens, userToken } from '@/lib/knowledge/access/tokens' +import { LINK_ACCESS_TOKEN, PUBLIC_ACCESS_TOKEN } from '@/lib/knowledge/access/types' + +/** + * One entry of a Drive file's `permissions[]`, narrowed to the fields that + * decide who may read it. + * + * @see https://developers.google.com/workspace/drive/api/reference/rest/v3/permissions + */ +export interface DrivePermission { + type?: string + emailAddress?: string | null + domain?: string | null + /** + * Whether an `anyone` or `domain` grant makes the file *findable*, as opposed + * to merely openable by someone already holding its link. Absent means true, + * which is what the Drive API documents. + */ + allowFileDiscovery?: boolean | null + /** Whether the account behind a `user` grant has been deleted. */ + deleted?: boolean | null +} + +/** + * Whether a source's open shares are searchable in Sim. Off by default, and per + * connector: an admin turns it on knowing their domain's sharing hygiene. + * + * Glean's default, reached through Onyx's mechanism. Glean hides a file shared + * to the whole domain — or to anyone with the link — from search unless an + * admin opts in, because in a large domain those shares are usually accidental + * and their contents are exactly what nobody meant to publish. + */ +export interface OpenSharingPolicy { + /** Grant a `domain` share to everyone in that domain. */ + domain: boolean + /** Grant a discoverable `anyone` share to everyone. */ + anyone: boolean +} + +export interface DriveAclInput { + permissions: readonly DrivePermission[] + /** The provider segment of every group token this file produces. */ + providerId: string + /** The Google Workspace customer the crawl runs against. */ + tenantId: string | null + policy: OpenSharingPolicy +} + +/** + * The ACL of one Drive file, from the permissions the listing returned. + * + * Inheritance needs no resolving here. A grant that descends from a folder or + * from shared-drive membership arrives in `permissions[]` as an ordinary + * principal, so it maps like any other; what is *not* resolved is group + * membership, which belongs to the directory sync. That keeps the crawl one + * pass over files. + * + * A file whose every grant is unrepresentable — an `anyone` share that is + * link-only, a principal with no email — resolves to a single `link` token + * rather than an empty array, so "hidden on purpose" stays distinguishable + * from "hidden because we failed". + */ +export function driveFileAcl(input: DriveAclInput): string[] { + const { permissions, providerId, tenantId, policy } = input + const tokens = new Set() + + for (const permission of permissions) { + /** + * A deleted account's grant is a grant to nobody — and to whoever is later + * provisioned with the recycled address, if it were minted. + */ + if (permission.deleted) continue + switch (permission.type) { + case 'user': { + const token = userToken(permission.emailAddress) + if (token) tokens.add(token) + break + } + case 'group': { + /** + * Drive names a group by its email and never returns a group id, and + * the Directory API lists groups by email too, so the email is the one + * identifier the writer and the reader can both see without a lookup. + */ + const token = groupToken({ + providerId, + tenantId, + groupId: permission.emailAddress ?? '', + }) + if (token) tokens.add(token) + break + } + case 'domain': { + if (!policy.domain || !isDiscoverable(permission) || !permission.domain) break + const token = groupToken({ + providerId, + tenantId, + groupId: domainGroupId(permission.domain), + }) + if (token) tokens.add(token) + break + } + case 'anyone': { + /** + * `allowFileDiscovery: false` is "anyone with the link", which Drive + * excludes from its own search. Treating it as public — as Onyx's file + * path does, though its folder path checks the flag — publishes every + * document anyone ever pasted a link to. + */ + if (policy.anyone && isDiscoverable(permission)) tokens.add(PUBLIC_ACCESS_TOKEN) + break + } + default: + break + } + } + + if (tokens.size === 0) return [LINK_ACCESS_TOKEN] + return sortAccessTokens(tokens) +} + +/** Drive omits `allowFileDiscovery` when the grant is discoverable. */ +function isDiscoverable(permission: DrivePermission): boolean { + return permission.allowFileDiscovery !== false +} diff --git a/apps/sim/lib/knowledge/access/external-groups.ts b/apps/sim/lib/knowledge/access/external-groups.ts new file mode 100644 index 00000000000..c6dd7030427 --- /dev/null +++ b/apps/sim/lib/knowledge/access/external-groups.ts @@ -0,0 +1,69 @@ +/** + * How long a mirrored directory group keeps granting access after its + * membership was last confirmed. + * + * The directory sync never overwrites a membership it failed to read in full, + * so a transient outage revokes nobody — which is the behaviour we want, and is + * exactly why an age bound is required. Without one, a sync that stopped + * running altogether would keep granting indefinitely from membership nobody + * has checked since. + * + * A day is deliberately generous against the sync's own cadence, so a cron + * outage or a rate-limited directory has room to recover before anyone loses + * access, while a genuinely abandoned sync stops granting within a day. + */ +export const EXTERNAL_GROUP_STALE_AFTER_MS = 24 * 60 * 60 * 1000 + +/** + * How often a workspace's directory groups are re-enumerated. + * + * Permissions move faster than content and cost far less to read, so they sync + * on their own clock rather than riding the content sync. One interval for + * every provider: the directories mirrored so far are all cheap to list, and a + * per-provider cadence is a knob nothing has needed yet. + */ +export const EXTERNAL_GROUP_SYNC_INTERVAL_MS = 5 * 60 * 1000 + +/** A domain as tokens and group rows spell it. */ +export function normalizeDomain(domain: string): string { + return domain.trim().toLowerCase() +} + +const DOMAIN_GROUP_PREFIX = 'domain:' + +/** + * The synthetic group standing for "everyone in this domain". + * + * A domain share is an ordinary group grant at the read side: one token + * shape, and membership decided by {@link domainMemberWildcard} rather than by + * a second predicate. The prefix cannot collide with a real group, whose id + * is an address or an opaque id, never a bare `domain:` label. + */ +export function domainGroupId(domain: string): string { + return `${DOMAIN_GROUP_PREFIX}${normalizeDomain(domain)}` +} + +/** The domain a synthetic domain group stands for, or null for a real group. */ +export function domainOfGroupId(groupId: string): string | null { + if (!groupId.startsWith(DOMAIN_GROUP_PREFIX)) return null + return groupId.slice(DOMAIN_GROUP_PREFIX.length) || null +} + +/** + * The member row standing for everyone whose address is on a domain. + * + * A source can grant to "everyone at corp.com" — a Drive domain share, a + * Workspace group whose member is the whole customer — and nobody wants to + * enumerate a company to store that. The group is stored with one member, this + * wildcard, and a reader matches it by their own address's domain. A real + * address can never look like it: no provider issues a local part of `*`. + */ +export function domainMemberWildcard(domain: string): string { + return `*@${domain}` +} + +/** The domain of a folded address; empty when the address has none. */ +export function emailDomain(email: string): string { + const at = email.lastIndexOf('@') + return at === -1 ? '' : email.slice(at + 1) +} diff --git a/apps/sim/lib/knowledge/access/scope.test.ts b/apps/sim/lib/knowledge/access/scope.test.ts index 759817878f3..419e866be71 100644 --- a/apps/sim/lib/knowledge/access/scope.test.ts +++ b/apps/sim/lib/knowledge/access/scope.test.ts @@ -5,13 +5,13 @@ import type { Principal } from '@sim/auth/principal' import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockMemberAccessAvailable, mockCheckWorkspaceAccess } = vi.hoisted(() => ({ - mockMemberAccessAvailable: vi.fn(async () => true), +const { mockAvailability, mockCheckWorkspaceAccess } = vi.hoisted(() => ({ + mockAvailability: vi.fn(async () => ({ memberScoped: true, sourceMirrored: true })), mockCheckWorkspaceAccess: vi.fn(async () => ({ hasAccess: true })), })) vi.mock('@/lib/knowledge/access/availability', () => ({ - isKnowledgeMemberAccessAvailable: mockMemberAccessAvailable, + resolveKnowledgeAccessAvailability: mockAvailability, })) vi.mock('@/lib/workspaces/permissions/utils', () => ({ checkWorkspaceAccess: mockCheckWorkspaceAccess, @@ -53,6 +53,44 @@ describe('resolveKnowledgeAccessScope', () => { expect(dbChainMockFns.leftJoin).toHaveBeenCalledTimes(3) }) + /** + * `user_email_lower_unique` makes this state unreachable. The guard exists so + * access control does not depend on the constraint still being there. + */ + it('binds no identity token when another account folds to the same address', async () => { + queueSubjects([ + { + emailIsAmbiguous: true, + providerId: 'confluence', + providerTenantId: null, + providerSubjectId: '557058:abc', + }, + ] as never) + + await expect(resolveKnowledgeAccessScope(SESSION, WORKSPACE)).resolves.toEqual({ + kind: 'user', + userId: 'user-1', + tokens: ['pub', 'ws'], + }) + }) + + it('binds normally when the address identifies exactly one account', async () => { + queueSubjects([ + { + emailIsAmbiguous: false, + providerId: 'confluence', + providerTenantId: null, + providerSubjectId: '557058:abc', + }, + ] as never) + + await expect(resolveKnowledgeAccessScope(SESSION, WORKSPACE)).resolves.toEqual({ + kind: 'user', + userId: 'user-1', + tokens: ['pub', 's:confluence:-:557058:abc', 'ws'], + }) + }) + it('grants no member token to someone who is no longer in the workspace', async () => { mockCheckWorkspaceAccess.mockResolvedValueOnce({ hasAccess: false }) await expect(resolveKnowledgeAccessScope(SESSION, WORKSPACE)).resolves.toEqual({ @@ -63,8 +101,8 @@ describe('resolveKnowledgeAccessScope', () => { expect(dbChainMockFns.select).not.toHaveBeenCalled() }) - it('grants no member token where per-member access is off, whatever the person holds', async () => { - mockMemberAccessAvailable.mockResolvedValueOnce(false) + it('grants no identity token where permission-aware knowledge is off, whatever the person holds', async () => { + mockAvailability.mockResolvedValueOnce({ memberScoped: false, sourceMirrored: false }) queueSubjects([ { providerId: 'google-drive', providerTenantId: 'acme.com', providerSubjectId: '42' }, ]) @@ -219,3 +257,173 @@ describe('createKnowledgeAccessProvider', () => { await expect(provider.get()).resolves.toMatchObject({ kind: 'user', tokens: ['pub', 'ws'] }) }) }) + +describe('tokens mirrored from a source directory', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + function queueGroups(rows: Array>) { + queueTableRows(schemaMock.knowledgeExternalGroupMember, rows) + } + + it('gives a person their own address and every group it belongs to', async () => { + queueSubjects([ + { + email: 'alice@corp.com', + providerId: null, + providerTenantId: null, + providerSubjectId: null, + }, + ]) + queueGroups([ + { providerId: 'google-drive', tenantId: 'corp.com', externalGroupId: 'eng@corp.com' }, + { providerId: 'google-drive', tenantId: 'corp.com', externalGroupId: 'all@corp.com' }, + ]) + + await expect(resolveKnowledgeAccessScope(SESSION, WORKSPACE)).resolves.toEqual({ + kind: 'user', + userId: 'user-1', + tokens: [ + 'g:google-drive:corp.com:all@corp.com', + 'g:google-drive:corp.com:eng@corp.com', + 'pub', + 'u:alice@corp.com', + 'ws', + ], + }) + }) + + /** + * A domain share is stored as a group with one wildcard member; a reader at + * that domain holds the group's token without ever being enumerated. + */ + it('gives a person the groups their domain wildcard is a member of', async () => { + queueSubjects([ + { + email: 'alice@corp.com', + providerId: null, + providerTenantId: null, + providerSubjectId: null, + }, + ]) + queueGroups([ + { providerId: 'google-drive', tenantId: 'corp.com', externalGroupId: 'domain:corp.com' }, + ]) + + await expect(resolveKnowledgeAccessScope(SESSION, WORKSPACE)).resolves.toMatchObject({ + tokens: expect.arrayContaining(['g:google-drive:corp.com:domain:corp.com']), + }) + expect(dbChainMockFns.where).toHaveBeenCalled() + }) + + it('still gives a person their own address when they are in no group', async () => { + queueSubjects([ + { + email: 'alice@corp.com', + providerId: null, + providerTenantId: null, + providerSubjectId: null, + }, + ]) + queueGroups([]) + + await expect(resolveKnowledgeAccessScope(SESSION, WORKSPACE)).resolves.toEqual({ + kind: 'user', + userId: 'user-1', + tokens: ['pub', 'u:alice@corp.com', 'ws'], + }) + }) + + it('binds nothing to an address two accounts share, groups included', async () => { + queueSubjects([ + { + emailIsAmbiguous: true, + email: 'alice@corp.com', + providerId: null, + providerTenantId: null, + providerSubjectId: null, + }, + ] as never) + + await expect(resolveKnowledgeAccessScope(SESSION, WORKSPACE)).resolves.toEqual({ + kind: 'user', + userId: 'user-1', + tokens: ['pub', 'ws'], + }) + expect(dbChainMockFns.innerJoin).not.toHaveBeenCalled() + }) + + it('skips a malformed group rather than failing the read', async () => { + queueSubjects([ + { + email: 'alice@corp.com', + providerId: null, + providerTenantId: null, + providerSubjectId: null, + }, + ]) + queueGroups([ + { providerId: 'a:b', tenantId: 'corp.com', externalGroupId: 'eng@corp.com' }, + { providerId: 'google-drive', tenantId: 'corp.com', externalGroupId: 'eng@corp.com' }, + ]) + + await expect(resolveKnowledgeAccessScope(SESSION, WORKSPACE)).resolves.toEqual({ + kind: 'user', + userId: 'user-1', + tokens: ['g:google-drive:corp.com:eng@corp.com', 'pub', 'u:alice@corp.com', 'ws'], + }) + }) +}) + +describe('each token family is gated by the feature it depends on', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + /** + * Admin mode mirrors a source's own ACLs and touches no Credential Group, so + * an operator turning Credential Groups off must not silently revoke every + * document an administrator crawl mirrored. + */ + it('keeps mirrored grants when Credential Groups are unavailable', async () => { + mockAvailability.mockResolvedValueOnce({ memberScoped: false, sourceMirrored: true }) + queueTableRows(schemaMock.user, [ + { + email: 'alice@corp.com', + providerId: 'confluence', + providerTenantId: null, + providerSubjectId: '557058:abc', + }, + ]) + queueTableRows(schemaMock.knowledgeExternalGroupMember, [ + { providerId: 'google-drive', tenantId: 'corp.com', externalGroupId: 'eng@corp.com' }, + ]) + + await expect(resolveKnowledgeAccessScope(SESSION, WORKSPACE)).resolves.toEqual({ + kind: 'user', + userId: 'user-1', + tokens: ['g:google-drive:corp.com:eng@corp.com', 'pub', 'u:alice@corp.com', 'ws'], + }) + }) + + it('keeps member grants when source mirroring is unavailable', async () => { + mockAvailability.mockResolvedValueOnce({ memberScoped: true, sourceMirrored: false }) + queueTableRows(schemaMock.user, [ + { + email: 'alice@corp.com', + providerId: 'confluence', + providerTenantId: null, + providerSubjectId: '557058:abc', + }, + ]) + + await expect(resolveKnowledgeAccessScope(SESSION, WORKSPACE)).resolves.toEqual({ + kind: 'user', + userId: 'user-1', + tokens: ['pub', 's:confluence:-:557058:abc', 'ws'], + }) + }) +}) diff --git a/apps/sim/lib/knowledge/access/scope.ts b/apps/sim/lib/knowledge/access/scope.ts index 3cae9340d40..10f45ce6972 100644 --- a/apps/sim/lib/knowledge/access/scope.ts +++ b/apps/sim/lib/knowledge/access/scope.ts @@ -1,13 +1,31 @@ import { type Principal, resolvePrincipalSubject } from '@sim/auth/principal' import { db } from '@sim/db' -import { credential, credentialGroup, credentialGroupEnrollment, user } from '@sim/db/schema' +import { + credential, + credentialGroup, + credentialGroupEnrollment, + foldedEmail, + knowledgeExternalGroup, + knowledgeExternalGroupMember, + user, +} from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { and, eq, inArray, sql } from 'drizzle-orm' +import { and, eq, gte, inArray, sql } from 'drizzle-orm' import { OrchestrationError } from '@/lib/core/orchestration/types' import { LIVE_ENROLLMENT_STATUSES } from '@/lib/credential-groups/credentials' -import { isKnowledgeMemberAccessAvailable } from '@/lib/knowledge/access/availability' -import { sortAccessTokens, subjectToken } from '@/lib/knowledge/access/tokens' +import { resolveKnowledgeAccessAvailability } from '@/lib/knowledge/access/availability' +import { + domainMemberWildcard, + EXTERNAL_GROUP_STALE_AFTER_MS, + emailDomain, +} from '@/lib/knowledge/access/external-groups' +import { + groupToken, + sortAccessTokens, + subjectToken, + userToken, +} from '@/lib/knowledge/access/tokens' import { type KnowledgeAccessProvider, type KnowledgeAccessScope, @@ -23,7 +41,77 @@ export const WORKSPACE_ACCESS_SCOPE: WorkspaceAccessScope = Object.freeze({ tokens: WORKSPACE_ACCESS_TOKENS, }) -/** Enrollment states under which a credential-group membership counts as live. */ +/** + * Whether some other account folds to this one's address. + * + * `user.email` is unique byte-for-byte only, and a small number of historical + * accounts collide once folded. Until those are merged and the index promoted + * to UNIQUE, this check is what keeps either account from reading the other's + * documents — and it stays afterwards, so access control never quietly depends + * on a constraint still being there. One probe of `user_email_lower_idx` per + * read; it plans as an index scan, not a table scan. + */ +const emailHeldByAnotherAccount = sql`EXISTS ( + SELECT 1 FROM ${user} AS other + WHERE other.id <> ${user.id} + AND lower(btrim(other.email)) = ${foldedEmail(user.email)} +)` + +/** + * The `g:` tokens a person holds in a workspace, from the external directory + * groups a crawl has mirrored. + * + * A group whose membership has not been confirmed within + * {@link EXTERNAL_GROUP_STALE_AFTER_MS} grants nothing. A failed enumeration + * never overwrites what it could not read, which is what keeps a transient + * directory outage from revoking anyone — but that same property means a sync + * that stopped running entirely would otherwise keep granting forever, from + * membership nobody has checked since. The age bound is the ratchet: an outage + * is survivable, an abandoned sync is not. + */ +async function loadExternalGroupTokens(email: string, workspaceId: string): Promise { + /** + * A query of its own rather than a fourth join on the credential query in + * `loadUserAccessTokens`: + * that one already fans out per managed credential, and joining groups onto + * it would multiply the two — every credential row repeated for every group. + * Two indexed reads cost less than one cross product. + */ + const freshEnough = new Date(Date.now() - EXTERNAL_GROUP_STALE_AFTER_MS) + const rows = await db + .select({ + providerId: knowledgeExternalGroup.providerId, + tenantId: knowledgeExternalGroup.tenantId, + externalGroupId: knowledgeExternalGroup.externalGroupId, + }) + .from(knowledgeExternalGroupMember) + .innerJoin( + knowledgeExternalGroup, + eq(knowledgeExternalGroup.id, knowledgeExternalGroupMember.groupId) + ) + .where( + and( + /** Their own address, and the wildcard standing for everyone at its domain. */ + inArray(knowledgeExternalGroupMember.email, [ + email, + domainMemberWildcard(emailDomain(email)), + ]), + eq(knowledgeExternalGroup.workspaceId, workspaceId), + gte(knowledgeExternalGroup.lastSyncedAt, freshEnough) + ) + ) + + const tokens: string[] = [] + for (const row of rows) { + const token = groupToken({ + providerId: row.providerId, + tenantId: row.tenantId, + groupId: row.externalGroupId, + }) + if (token) tokens.push(token) + } + return tokens +} export interface KnowledgeAccessScopeContext { /** Undefined only for a legacy personal knowledge base, which cannot own connectors. */ @@ -31,12 +119,13 @@ export interface KnowledgeAccessScopeContext { } /** - * The tokens a person holds in a workspace: the workspace pair plus one `s:` - * token per active managed credential bound to them through a credential-group - * enrollment. The person must be email-verified — the enrollment binding is by + * The tokens a person holds in a workspace: the workspace pair, one `s:` token + * per active managed credential bound to them through a credential-group + * enrollment, their own `u:` address, and a `g:` token per directory group it + * belongs to. The person must be email-verified — every binding here is by * email, and an unverified address must not inherit grants made to whoever - * really owns it. Nothing here is cached: revoking or suspending a credential - * is visible on the next read. + * really owns it. Nothing here is cached: revoking a credential or leaving a + * group is visible on the next read. */ async function loadUserAccessTokens( userId: string, @@ -52,18 +141,21 @@ async function loadUserAccessTokens( const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) if (!workspaceAccess.hasAccess) return [...WORKSPACE_ACCESS_TOKENS] /** - * A member token only counts where permission-aware knowledge is on, so - * turning the feature off hides every member-scoped document at once — on + * An identity token only counts where permission-aware knowledge is on, so + * turning the feature off hides every permission-scoped document at once — on * the next read, before any run has suspended anyone — rather than leaving - * enrolled members reading them until a run happens to land. Read first, so - * a workspace without the feature never pays for the enrollment join. + * people reading them until a run happens to land. Read first, so a workspace + * without the feature never pays for the joins below. */ - if (!(await isKnowledgeMemberAccessAvailable({ workspaceId }))) { + const availability = await resolveKnowledgeAccessAvailability({ workspaceId }) + if (!availability.memberScoped && !availability.sourceMirrored) { return [...WORKSPACE_ACCESS_TOKENS] } const rows = await db .select({ + emailIsAmbiguous: emailHeldByAnotherAccount, + email: foldedEmail(user.email), providerId: credential.providerId, providerTenantId: credential.providerTenantId, providerSubjectId: credential.providerSubjectId, @@ -72,10 +164,7 @@ async function loadUserAccessTokens( .leftJoin( credentialGroupEnrollment, and( - eq( - credentialGroupEnrollment.email, - sql`COALESCE(${user.normalizedEmail}, lower(btrim(${user.email})))` - ), + eq(credentialGroupEnrollment.email, foldedEmail(user.email)), inArray(credentialGroupEnrollment.status, [...LIVE_ENROLLMENT_STATUSES]) ) ) @@ -103,11 +192,25 @@ async function loadUserAccessTokens( ) .where(and(eq(user.id, userId), eq(user.emailVerified, true))) - const subjectTokens = new Set() + /** + * An address two accounts share identifies neither of them, so it binds to + * nothing. Both accounts keep the tokens every workspace member holds and + * lose only what their identity would have granted — the safe direction, and + * the one that cannot hand one person the other's documents. + */ + if (rows.some((row) => row.emailIsAmbiguous)) { + logger.error('Refusing identity-derived access tokens for an ambiguous email address', { + userId, + workspaceId, + }) + return [...WORKSPACE_ACCESS_TOKENS] + } + + const identityTokens = new Set() for (const row of rows) { - if (!row.providerSubjectId) continue + if (!availability.memberScoped || !row.providerSubjectId) continue try { - subjectTokens.add(subjectToken(row)) + identityTokens.add(subjectToken(row)) } catch (error) { logger.warn('Skipping malformed managed credential subject', { userId, @@ -117,7 +220,24 @@ async function loadUserAccessTokens( }) } } - return sortAccessTokens(new Set([...WORKSPACE_ACCESS_TOKENS, ...subjectTokens])) + + /** + * The person's own address, and the directory groups it belongs to. These are + * what an admin-mode crawl mirrors onto documents, so they are how a source's + * own permissions reach the reader. The address is verified — the + * `emailVerified` predicate above is on the same query — so a grant made to + * whoever really owns it cannot be claimed by someone who merely typed it. + */ + const email = availability.sourceMirrored ? rows[0]?.email : undefined + if (email) { + const own = userToken(email) + if (own) identityTokens.add(own) + for (const token of await loadExternalGroupTokens(email, workspaceId)) { + identityTokens.add(token) + } + } + + return sortAccessTokens(new Set([...WORKSPACE_ACCESS_TOKENS, ...identityTokens])) } /** diff --git a/apps/sim/lib/knowledge/access/tokens.test.ts b/apps/sim/lib/knowledge/access/tokens.test.ts index de56395ad7d..d2e39f7227e 100644 --- a/apps/sim/lib/knowledge/access/tokens.test.ts +++ b/apps/sim/lib/knowledge/access/tokens.test.ts @@ -4,9 +4,13 @@ import { describe, expect, it } from 'vitest' import { ACCESS_TOKEN_PATTERN, + groupToken, isAccessToken, + MAX_ACL_TOKENS, sortAccessTokens, subjectToken, + userToken, + validateAcl, } from '@/lib/knowledge/access/tokens' describe('access token shape', () => { @@ -93,3 +97,62 @@ describe('sortAccessTokens', () => { expect(sortAccessTokens(['s:x:-:b', 's:x:-:B'])).toEqual(['s:x:-:B', 's:x:-:b']) }) }) + +describe('userToken', () => { + it('folds case and surrounding whitespace so one person is one token', () => { + expect(userToken(' Alice@Corp.com ')).toBe('u:alice@corp.com') + }) + + it('refuses to invent an identity for a principal with no address', () => { + expect(userToken(null)).toBeNull() + expect(userToken(undefined)).toBeNull() + expect(userToken(' ')).toBeNull() + expect(userToken('not-an-email')).toBeNull() + }) +}) + +describe('groupToken', () => { + it('folds the group identifier, which sources spell inconsistently', () => { + expect( + groupToken({ providerId: 'google-drive', tenantId: 'C01', groupId: ' Sales@Corp.com' }) + ).toBe('g:google-drive:C01:sales@corp.com') + }) + + it('stands in a placeholder for a provider that reports no tenant', () => { + expect(groupToken({ providerId: 'confluence', tenantId: null, groupId: 'engineering' })).toBe( + 'g:confluence:-:engineering' + ) + }) + + it('refuses segments that would be mistaken for the separator', () => { + expect(groupToken({ providerId: 'a:b', tenantId: null, groupId: 'g' })).toBeNull() + expect(groupToken({ providerId: 'p', tenantId: 'T:1', groupId: 'g' })).toBeNull() + }) + + it('refuses a group it cannot name', () => { + expect(groupToken({ providerId: 'p', tenantId: null, groupId: '' })).toBeNull() + }) +}) + +describe('validateAcl', () => { + it('returns the canonical sorted, de-duplicated form', () => { + expect(validateAcl(['ws', 'pub', 'ws'])).toEqual({ valid: true, acl: ['pub', 'ws'] }) + }) + + it('names the token the database would have rejected', () => { + expect(validateAcl(['ws', 'u:NOT-FOLDED@corp.com'])).toEqual({ + valid: false, + reason: 'malformed_token', + sample: 'u:NOT-FOLDED@corp.com', + }) + }) + + it('refuses an ACL past the ceiling, and accepts one exactly at it', () => { + const at = Array.from({ length: MAX_ACL_TOKENS }, (_u, i) => `u:p${i}@corp.com`) + expect(validateAcl(at).valid).toBe(true) + expect(validateAcl([...at, 'u:extra@corp.com'])).toEqual({ + valid: false, + reason: 'too_many_tokens', + }) + }) +}) diff --git a/apps/sim/lib/knowledge/access/tokens.ts b/apps/sim/lib/knowledge/access/tokens.ts index ff4cedfc63b..d1febc8d422 100644 --- a/apps/sim/lib/knowledge/access/tokens.ts +++ b/apps/sim/lib/knowledge/access/tokens.ts @@ -1,3 +1,4 @@ +import { normalizeEmail } from '@sim/utils/string' import { WORKSPACE_ACCESS_TOKEN } from '@/lib/knowledge/access/types' /** @@ -18,6 +19,47 @@ export const WORKSPACE_ACL: readonly string[] = Object.freeze([WORKSPACE_ACCESS_ /** The ACL of a document nobody may read. */ export const EMPTY_ACL: readonly string[] = Object.freeze([]) +/** + * The most tokens one document's ACL may carry — a bug detector, not a tuned + * capacity. + * + * With group tokens a legitimate document names at most tens of principals. An + * ACL in the thousands means a connector expanded a group to its members, which + * is the exact failure group tokens exist to prevent, and which costs every + * other document in the workspace: the GIN index holds one entry per array + * element per row, and every read overlaps the caller's set against it. The + * number is a generous ceiling above anything real; a document past it fails + * closed rather than being stored with an ACL truncated to fit. + */ +export const MAX_ACL_TOKENS = 5000 + +export type AclRejection = 'malformed_token' | 'too_many_tokens' + +export type AclValidation = + | { valid: true; acl: string[] } + | { valid: false; reason: AclRejection; sample?: string } + +/** + * Accepts an ACL a connector produced, in the canonical sorted, de-duplicated + * form the database stores. + * + * The token shapes mirror `doc_acl_token_shape_check`, so a malformed token is + * caught here — where the offending document can be named and reported — rather + * than as a constraint violation that fails the whole batch it happened to + * share a statement with. + */ +export function validateAcl(tokens: Iterable): AclValidation { + const acl = sortAccessTokens(tokens) + const malformed = acl.find((token) => !isAccessToken(token)) + if (malformed !== undefined) { + return { valid: false, reason: 'malformed_token', sample: malformed } + } + if (acl.length > MAX_ACL_TOKENS) { + return { valid: false, reason: 'too_many_tokens' } + } + return { valid: true, acl } +} + export function isAccessToken(value: string): boolean { return ACCESS_TOKEN_PATTERN.test(value) } @@ -50,6 +92,60 @@ export function subjectToken(credential: SubjectCredential): string { return token } +/** + * The identity token of a person by their email address, as the source spells + * it. Case-folded and trimmed on both sides — the writer mirroring a source ACL + * and the reader resolving their own scope — so `Alice@Corp.com` in Drive and + * `alice@corp.com` in Sim are one person. + * + * Returns null for anything that is not an email: a source may name a principal + * with no address (a deleted account, a service identity), and a grant we + * cannot attribute to a person must be dropped rather than guessed at. + */ +export function userToken(email: string | null | undefined): string | null { + const normalized = email ? normalizeEmail(email) : '' + if (!normalized) return null + const token = `u:${normalized}` + return isAccessToken(token) ? token : null +} + +export interface GroupIdentity { + providerId: string + /** The provider's tenant, or {@link NO_TENANT_SEGMENT} where it reports none. */ + tenantId: string | null + /** + * The group in whichever identifier both the crawl and the directory sync + * can see — a group email on Drive, a group id on Confluence. Whatever the + * source's permissions API returns is what the directory is keyed by, so no + * lookup ever stands between a grant and the membership that resolves it. + */ + groupId: string +} + +/** + * A group identifier in the one form every writer and reader agrees on. + * + * Both the crawl that writes a `g:` token and the directory sync that stores + * the group's membership pass through this, so the two can never disagree about + * case or whitespace — Drive spells a group email however it was typed, and a + * grant that folds differently from its membership row grants nobody. The fold + * is the same one addresses get, because a Drive group *is* an address. + */ +export function canonicalGroupId(groupId: string): string { + return normalizeEmail(groupId) +} + +/** The token of a group grant. */ +export function groupToken(group: GroupIdentity): string | null { + const { providerId } = group + const tenant = group.tenantId || NO_TENANT_SEGMENT + const groupId = canonicalGroupId(group.groupId ?? '') + if (!providerId || !groupId) return null + if (providerId.includes(':') || tenant.includes(':')) return null + const token = `g:${providerId}:${tenant}:${groupId}` + return isAccessToken(token) ? token : null +} + /** * Canonical ordering for every ACL and token set: code-unit order, never * locale-aware, so two writers produce byte-identical arrays and Postgres array diff --git a/apps/sim/lib/knowledge/access/types.ts b/apps/sim/lib/knowledge/access/types.ts index 2d3346d6b91..fdfcba69a6f 100644 --- a/apps/sim/lib/knowledge/access/types.ts +++ b/apps/sim/lib/knowledge/access/types.ts @@ -4,6 +4,17 @@ export const WORKSPACE_ACCESS_TOKEN = 'ws' as const /** Held by every principal; a document the source itself makes public. */ export const PUBLIC_ACCESS_TOKEN = 'pub' as const +/** + * Held by nobody. The ACL of a document the source shares only with whoever + * has its link — reachable by anyone holding the URL, findable by no one. + * + * It is a token rather than an empty ACL so the distinction survives: a + * document nobody may read and a document the source deliberately hid from + * search are different facts, and only the second is worth explaining to an + * admin wondering why a file they can open does not appear. + */ +export const LINK_ACCESS_TOKEN = 'link' as const + /** * The token set of a caller with no person behind it — a workspace API key, a * scheduled or webhook run, chat, MCP — and the base every person's set @@ -19,7 +30,11 @@ export interface WorkspaceAccessScope { export interface UserAccessScope { kind: 'user' userId: string - /** `pub`, `ws`, and one `s:` token per active managed credential the person holds here. */ + /** + * `pub`, `ws`, one `s:` token per active managed credential the person holds + * here, their own `u:` token, and one `g:` token per mirrored directory group + * they belong to. + */ tokens: readonly string[] } diff --git a/apps/sim/lib/knowledge/application/connector-access.ts b/apps/sim/lib/knowledge/application/connector-access.ts index 3451da3499c..550763c52b7 100644 --- a/apps/sim/lib/knowledge/application/connector-access.ts +++ b/apps/sim/lib/knowledge/application/connector-access.ts @@ -16,8 +16,14 @@ import { } from '@/lib/knowledge/application/connectors' import { resolveActiveKnowledgeConnectorContext } from '@/lib/knowledge/application/contexts' import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { + type ConnectorAccessMode, + mirrorsSourceAcls, +} from '@/lib/knowledge/connectors/access-modes' import { createViewerConnectorEnrollmentLink } from '@/lib/knowledge/connectors/member-provisioning' +import { assertConnectorMirrorsSourceAcls } from '@/lib/knowledge/connectors/mirrored-access' import { + type ConnectorAccessTarget, performUpdateKnowledgeConnectorAccess, resolveKnowledgeConnectorMembersBinding, } from '@/lib/knowledge/orchestration/connector-access' @@ -70,7 +76,7 @@ export interface UpdateKnowledgeConnectorAccessInput { knowledgeBaseId: string connectorId: string assertedWorkspaceId?: string - accessMode: 'workspace' | 'members' + accessMode: ConnectorAccessMode credentialGroupId?: string credentialGroupOptionId?: string /** Workspace mode: the credential the connector syncs as from now on. */ @@ -80,8 +86,10 @@ export interface UpdateKnowledgeConnectorAccessInput { } /** - * Moves a connector between workspace and members mode. Admin only: members - * mode lets the connector crawl as every person enrolled in the option. + * Moves a connector between access modes. Admin only: `members` lets the + * connector crawl as every person enrolled in the option, and `admin` lets it + * crawl as an administrator and mirror the source's own permissions — both are + * decisions about whose data the workspace indexes. */ export const updateKnowledgeConnectorAccess = defineAuthorizedKnowledgeUseCase({ operation: knowledgeOperations.updateConnectorAccess, @@ -106,34 +114,42 @@ export const updateKnowledgeConnectorAccess = defineAuthorizedKnowledgeUseCase({ ) } - const target = - input.accessMode === 'members' - ? { - accessMode: 'members' as const, - binding: await resolveKnowledgeConnectorMembersBinding({ - workspaceId, - connectorMeta, - binding: - input.credentialGroupId && input.credentialGroupOptionId - ? { - credentialGroupId: input.credentialGroupId, - credentialGroupOptionId: input.credentialGroupOptionId, - } - : null, - actingUserId, - sourceConfig: connector.sourceConfig as Record, - }), - } - : { - accessMode: 'workspace' as const, - credentialId: await requireUsableCredential({ - credentialId: input.credentialId, - connectorMeta, - workspaceId, - actingUserId, - requestId, - }), - } + const sourceConfig = connector.sourceConfig as Record + + let target: ConnectorAccessTarget + if (input.accessMode === 'members') { + target = { + accessMode: 'members', + binding: await resolveKnowledgeConnectorMembersBinding({ + workspaceId, + connectorMeta, + binding: + input.credentialGroupId && input.credentialGroupOptionId + ? { + credentialGroupId: input.credentialGroupId, + credentialGroupOptionId: input.credentialGroupOptionId, + } + : null, + actingUserId, + sourceConfig, + }), + } + } else { + if (mirrorsSourceAcls(input.accessMode)) { + await assertConnectorMirrorsSourceAcls(connectorMeta, sourceConfig, workspaceId) + } + target = { + accessMode: input.accessMode, + credentialId: await requireUsableCredential({ + credentialId: input.credentialId, + connectorMeta, + sourceConfig, + workspaceId, + actingUserId, + requestId, + }), + } + } const outcome = await performUpdateKnowledgeConnectorAccess({ knowledgeBase: { id: context.knowledgeBaseId, name: context.knowledgeBase.name, workspaceId }, @@ -184,6 +200,7 @@ export const updateKnowledgeConnectorAccess = defineAuthorizedKnowledgeUseCase({ async function requireUsableCredential(input: { credentialId: string | undefined connectorMeta: Pick + sourceConfig: Record workspaceId: string actingUserId: string requestId: string @@ -193,7 +210,10 @@ async function requireUsableCredential(input: { throw new OrchestrationError('validation', 'Only OAuth connectors can change access mode') } if (!input.credentialId) { - throw new OrchestrationError('validation', 'credentialId is required for workspace mode') + throw new OrchestrationError( + 'validation', + 'credentialId is required for a mode that syncs with one credential' + ) } const service = getServiceConfigByServiceId(auth.provider) ?? getServiceConfigByProviderId(auth.provider) @@ -209,6 +229,8 @@ async function requireUsableCredential(input: { actingUserId: input.actingUserId, requestId: input.requestId, service, + auth, + sourceConfig: input.sourceConfig, }) if (!token) { throw new OrchestrationError( diff --git a/apps/sim/lib/knowledge/application/connectors.test.ts b/apps/sim/lib/knowledge/application/connectors.test.ts index 6425cb7bf07..f3050d1af21 100644 --- a/apps/sim/lib/knowledge/application/connectors.test.ts +++ b/apps/sim/lib/knowledge/application/connectors.test.ts @@ -18,7 +18,7 @@ const mocks = vi.hoisted(() => ({ getCredentialActorContext: vi.fn(), canUseCredential: vi.fn(), resolveTokenIdentity: vi.fn(), - refreshToken: vi.fn(), + resolveTokenBundle: vi.fn(), validateConnectorConfig: vi.fn(), recordAudit: vi.fn(), getUserPermissionConfig: vi.fn(), @@ -70,7 +70,7 @@ vi.mock('@/lib/credentials/access', () => ({ })) vi.mock('@/lib/oauth/credential-service', () => ({ - refreshAccessTokenIfNeeded: mocks.refreshToken, + resolveCredentialTokenBundle: mocks.resolveTokenBundle, })) vi.mock('@/lib/permission-groups/resolve.server', () => ({ @@ -80,7 +80,7 @@ vi.mock('@/lib/permission-groups/resolve.server', () => ({ vi.mock('@/connectors/registry.server', () => ({ CONNECTOR_REGISTRY: { confluence: { - auth: { mode: 'oauth' }, + auth: { mode: 'oauth', requiredScopes: ['read:confluence-content.all'] }, validateConfig: mocks.validateConnectorConfig, }, }, @@ -151,7 +151,7 @@ describe('knowledge connector application use cases', () => { access.hasWorkspaceAccess && (Boolean(access.member) || access.isAdmin) ) mocks.resolveTokenIdentity.mockResolvedValue({ kind: 'oauth', userId: 'credential-owner' }) - mocks.refreshToken.mockResolvedValue('access-token') + mocks.resolveTokenBundle.mockResolvedValue({ accessToken: 'access-token' }) mocks.validateConnectorConfig.mockResolvedValue({ valid: true }) mocks.resolveBilling.mockResolvedValue(BILLING) mocks.getUserPermissionConfig.mockResolvedValue(null) @@ -336,12 +336,14 @@ describe('knowledge connector application use cases', () => { ) expect(mocks.getCredentialActorContext).toHaveBeenCalledWith('credential-1', 'shared-user') expect(mocks.resolveTokenIdentity).toHaveBeenCalledWith('credential-1', 'workspace-a') - expect(mocks.refreshToken).toHaveBeenCalledWith( + expect(mocks.resolveTokenBundle).toHaveBeenCalledWith( 'credential-1', 'credential-owner', - expect.any(String) + expect.any(String), + ['read:confluence-content.all'], + undefined ) - expect(mocks.validateConnectorConfig).toHaveBeenCalledWith('access-token', { space: 'ENG' }) + expect(mocks.validateConnectorConfig).toHaveBeenCalledWith('access-token', { space: 'ENG' }, {}) expect(mocks.resolveBilling).toHaveBeenCalledWith('workspace-a') }) @@ -395,7 +397,7 @@ describe('knowledge connector application use cases', () => { expect(mocks.getCredentialActorContext).toHaveBeenCalledWith('credential-1', 'shared-user') expect(mocks.resolveTokenIdentity).not.toHaveBeenCalled() - expect(mocks.refreshToken).not.toHaveBeenCalled() + expect(mocks.resolveTokenBundle).not.toHaveBeenCalled() }) it('rejects source-config revalidation after credential membership is removed', async () => { @@ -457,7 +459,7 @@ describe('knowledge connector application use cases', () => { 'Credential is not available to you in this workspace. Ask a credential administrator to grant access or select another credential.', }) expect(mocks.resolveTokenIdentity).not.toHaveBeenCalled() - expect(mocks.refreshToken).not.toHaveBeenCalled() + expect(mocks.resolveTokenBundle).not.toHaveBeenCalled() expect(mocks.validateConnectorConfig).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/knowledge/application/connectors.ts b/apps/sim/lib/knowledge/application/connectors.ts index 729ce72b0dc..56e5fd9fdc2 100644 --- a/apps/sim/lib/knowledge/application/connectors.ts +++ b/apps/sim/lib/knowledge/application/connectors.ts @@ -11,7 +11,6 @@ import { knowledgeConnectorSyncLog, } from '@sim/db/schema' import { and, asc, count, desc, eq, inArray, isNull, lt, or, sql } from 'drizzle-orm' -import { decryptApiKey } from '@/lib/api-key/crypto' import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' import { requireCurrentHumanRole } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' @@ -36,10 +35,19 @@ import { resolveKnowledgeWorkspaceContext, } from '@/lib/knowledge/application/contexts' import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { + type ConnectorAccessMode, + mirrorsSourceAcls, +} from '@/lib/knowledge/connectors/access-modes' +import { + resolveConnectorAccessToken, + syncContextForToken, +} from '@/lib/knowledge/connectors/access-token' import { resolveViewerConnectorMemberships, type ViewerConnectorMembership, } from '@/lib/knowledge/connectors/member-provisioning' +import { assertConnectorMirrorsSourceAcls } from '@/lib/knowledge/connectors/mirrored-access' import { MEMBER_OBSERVATION_STALE_AFTER_HOURS } from '@/lib/knowledge/connectors/sync-limits' import { DEFAULT_KNOWLEDGE_CONNECTOR_DOCUMENT_PAGE_SIZE, @@ -65,10 +73,10 @@ import type { } from '@/lib/knowledge/orchestration/shared' import { isMemberSyncStatus } from '@/lib/knowledge/types' import { credentialProviderMatchesService, type ServiceProviderIdentity } from '@/lib/oauth' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' import { CAPABILITY_RULES, refuseCapability } from '@/lib/permission-groups/capabilities' import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' import { getConnectorMeta } from '@/connectors/registry' +import type { ConnectorAuthConfig } from '@/connectors/types' interface KnowledgeConnectorApplicationInput { assertedWorkspaceId?: string @@ -95,8 +103,11 @@ export interface CreateKnowledgeConnectorInput extends KnowledgeConnectorApplica apiKey?: string sourceConfig: Record syncIntervalMinutes: number - /** `members` crawls per Credential Group member; admin only. Defaults to `workspace`. */ - accessMode?: 'workspace' | 'members' + /** + * How the connector derives document access; admin only for anything but + * `workspace`. Defaults to `workspace`. + */ + accessMode?: ConnectorAccessMode credentialGroupId?: string credentialGroupOptionId?: string resolveBillingAttribution?(workspaceId: string): Promise @@ -240,14 +251,20 @@ export async function resolveConnectorCredentialAccessToken(input: { actingUserId: string requestId: string service?: ServiceProviderIdentity + /** The connector the credential is being resolved for, so it mints exactly as a sync would. */ + auth: ConnectorAuthConfig + sourceConfig: Record }): Promise { const identity = await resolveAuthorizedConnectorCredentialIdentity(input) if (!identity) return null - return refreshAccessTokenIfNeeded( - input.credentialId, - identity.kind === 'oauth' ? identity.userId : input.actingUserId, - input.requestId - ) + const resolved = await resolveConnectorAccessToken({ + auth: input.auth, + connector: { credentialId: input.credentialId, encryptedApiKey: null }, + userId: identity.kind === 'oauth' ? identity.userId : input.actingUserId, + requestId: input.requestId, + sourceConfig: input.sourceConfig, + }) + return resolved?.accessToken ?? null } async function validateConnectorSourceConfig(input: { @@ -265,19 +282,34 @@ async function validateConnectorSourceConfig(input: { errorCode: 'validation', } } + /** + * A mirroring connector must keep naming whose eyes it crawls through: a + * config edit that blanked the administrator would mint a token with no + * subject and silently stop mirroring on the next run. + */ + if (mirrorsSourceAcls(input.connector.accessMode)) { + try { + await assertConnectorMirrorsSourceAcls(connectorConfig, input.sourceConfig, input.workspaceId) + } catch (error) { + if (error instanceof OrchestrationError) { + return { message: error.message, errorCode: error.code } + } + throw error + } + } - let accessToken: string | null = null + /** + * The user the token resolves under: whoever owns the OAuth account behind + * the credential, since token reads are scoped to `account.userId`. An API + * key and a service account both ignore it. + */ + let tokenUserId = input.actingUserId if (connectorConfig.auth.mode === 'apiKey') { - if (!input.connector.encryptedApiKey) { - if (!connectorConfig.auth.optional) { - return { - message: 'API key not found. Please reconfigure the connector.', - errorCode: 'validation', - } + if (!input.connector.encryptedApiKey && !connectorConfig.auth.optional) { + return { + message: 'API key not found. Please reconfigure the connector.', + errorCode: 'validation', } - accessToken = '' - } else { - accessToken = (await decryptApiKey(input.connector.encryptedApiKey)).decrypted } } else { if (!input.connector.credentialId) { @@ -297,20 +329,28 @@ async function validateConnectorSourceConfig(input: { errorCode: 'validation', } } - accessToken = await refreshAccessTokenIfNeeded( - input.connector.credentialId, - identity.kind === 'oauth' ? identity.userId : input.actingUserId, - input.requestId - ) - if (!accessToken) { - return { - message: 'Failed to refresh access token. Please reconnect your account.', - errorCode: 'unauthorized', - } + if (identity.kind === 'oauth') tokenUserId = identity.userId + } + + const resolved = await resolveConnectorAccessToken({ + auth: connectorConfig.auth, + connector: input.connector, + userId: tokenUserId, + requestId: input.requestId, + sourceConfig: input.sourceConfig, + }) + if (!resolved) { + return { + message: 'Failed to refresh access token. Please reconnect your account.', + errorCode: 'unauthorized', } } - const validation = await connectorConfig.validateConfig(accessToken, input.sourceConfig) + const validation = await connectorConfig.validateConfig( + resolved.accessToken, + input.sourceConfig, + syncContextForToken(resolved) + ) return validation.valid ? null : { message: validation.error || 'Invalid source configuration', errorCode: 'validation' } @@ -570,43 +610,50 @@ export const createKnowledgeConnector = defineAuthorizedKnowledgeUseCase({ workspaceId, input.connectorType ) + const connectorMeta = getConnectorMeta(input.connectorType) + if (!connectorMeta) { + throw new OrchestrationError('validation', `Unknown connector type: ${input.connectorType}`) + } let membersBinding: ResolvedMembersBinding | undefined - if (input.accessMode === 'members') { + if (input.accessMode && input.accessMode !== 'workspace') { /** - * Members mode grants the connector every enrolled member's credential, - * which is an admin decision even though creating a connector is not. + * Every mode but `workspace` decides whose data the workspace indexes — + * members mode grants the connector every enrolled member's credential, + * admin mode indexes a whole source through an administrator's eyes — so + * both take the admin role, even though creating a connector does not. */ const subjectUserId = resolvePrincipalSubjectUserId(principal) if (context.workspaceId === undefined) { throw new OrchestrationError( 'validation', - 'Per-member access needs a workspace knowledge base' + 'Permission-scoped access needs a workspace knowledge base' ) } if (!subjectUserId) { throw new OrchestrationError( 'forbidden', - 'A members-mode connector needs a signed-in admin' + 'Permission-scoped access needs a signed-in admin' ) } await requireCurrentHumanRole(subjectUserId, context, 'admin') - const connectorMeta = getConnectorMeta(input.connectorType) - if (!connectorMeta) { - throw new OrchestrationError('validation', `Unknown connector type: ${input.connectorType}`) + + if (input.accessMode === 'admin') { + await assertConnectorMirrorsSourceAcls(connectorMeta, input.sourceConfig, workspaceId) + } else { + membersBinding = await resolveKnowledgeConnectorMembersBinding({ + workspaceId, + connectorMeta, + binding: + input.credentialGroupId && input.credentialGroupOptionId + ? { + credentialGroupId: input.credentialGroupId, + credentialGroupOptionId: input.credentialGroupOptionId, + } + : null, + actingUserId: subjectUserId, + sourceConfig: input.sourceConfig, + }) } - membersBinding = await resolveKnowledgeConnectorMembersBinding({ - workspaceId, - connectorMeta, - binding: - input.credentialGroupId && input.credentialGroupOptionId - ? { - credentialGroupId: input.credentialGroupId, - credentialGroupOptionId: input.credentialGroupOptionId, - } - : null, - actingUserId: subjectUserId, - sourceConfig: input.sourceConfig, - }) } const outcome = await performCreateKnowledgeConnector({ knowledgeBase: connectorTarget(context), @@ -617,6 +664,7 @@ export const createKnowledgeConnector = defineAuthorizedKnowledgeUseCase({ sourceConfig: membersBinding?.sourceConfig ?? input.sourceConfig, syncIntervalMinutes: input.syncIntervalMinutes, membersBinding, + accessMode: input.accessMode, resolveBillingAttribution: () => input.resolveBillingAttribution?.(workspaceId) ?? resolveKnowledgeBillingAttribution(principal, context), @@ -626,6 +674,8 @@ export const createKnowledgeConnector = defineAuthorizedKnowledgeUseCase({ workspaceId, actingUserId, requestId, + auth: connectorMeta.auth, + sourceConfig: input.sourceConfig, }), userId: actingUserId, source: input.source ?? 'agent', diff --git a/apps/sim/lib/knowledge/application/knowledge-vfs.ts b/apps/sim/lib/knowledge/application/knowledge-vfs.ts index 4a7bf4e06fb..c86f947238e 100644 --- a/apps/sim/lib/knowledge/application/knowledge-vfs.ts +++ b/apps/sim/lib/knowledge/application/knowledge-vfs.ts @@ -61,7 +61,7 @@ async function resolveKnowledgeBaseByVfsName( context: KnowledgeWorkspaceContext, sourceName: string, sourceSegments?: string[] -): Promise> { +): Promise> { if (sourceSegments && sourceSegments.length > 1) { const row = await resolveResourceRowBySegments( knowledgeVfsAdapter, diff --git a/apps/sim/lib/knowledge/connectors/access-modes.test.ts b/apps/sim/lib/knowledge/connectors/access-modes.test.ts new file mode 100644 index 00000000000..ea19508327e --- /dev/null +++ b/apps/sim/lib/knowledge/connectors/access-modes.test.ts @@ -0,0 +1,79 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + aclIsDerived, + CONNECTOR_ACCESS_MODES, + CONTENT_ENGINE_ACCESS_MODES, + isConnectorAccessMode, + isContentEngineAccessMode, + mirrorsSourceAcls, +} from '@/lib/knowledge/connectors/access-modes' + +describe('which engine drives a mode', () => { + /** + * The content engine and the member engine hold mutually exclusive leases, so + * a mode claimed by both — or by neither — is a connector that either never + * runs or runs twice. + */ + it('assigns every mode to exactly one engine', () => { + const contentDriven = CONNECTOR_ACCESS_MODES.filter(isContentEngineAccessMode) + expect(contentDriven).toEqual(['workspace', 'admin']) + expect(CONNECTOR_ACCESS_MODES.filter((mode) => !isContentEngineAccessMode(mode))).toEqual([ + 'members', + ]) + }) + + it('drives admin mode with the content engine, since it is one crawl under one credential', () => { + expect(isContentEngineAccessMode('admin')).toBe(true) + expect(CONTENT_ENGINE_ACCESS_MODES).toContain('admin') + }) + + it('leaves members mode to the member engine', () => { + expect(isContentEngineAccessMode('members')).toBe(false) + }) + + it('refuses a mode it does not know rather than defaulting one in', () => { + expect(isContentEngineAccessMode('something-else')).toBe(false) + }) +}) + +describe('who may read what a sync writes', () => { + it('publishes a workspace-mode document to the workspace', () => { + expect(aclIsDerived('workspace')).toBe(false) + }) + + /** + * Both derived modes are born hidden, because the pass that knows their ACL — + * the observation graph, or the crawl that mirrors source permissions — has + * not run yet. Hidden early is recoverable; visible early is not. + */ + it('hides a document whose ACL a later pass owns', () => { + expect(aclIsDerived('admin')).toBe(true) + expect(aclIsDerived('members')).toBe(true) + }) +}) + +describe('which modes must list the whole corpus every run', () => { + /** + * A permission change moves no content — re-sharing a file does not touch + * its modified time — so an incremental listing cannot carry a revoked grant. + * Only a mode that mirrors ACLs pays for a full listing; the others keep the + * incremental path. + */ + it('requires a full listing only where ACLs are mirrored from the source', () => { + expect(mirrorsSourceAcls('admin')).toBe(true) + expect(mirrorsSourceAcls('workspace')).toBe(false) + expect(mirrorsSourceAcls('members')).toBe(false) + expect(mirrorsSourceAcls('unknown')).toBe(false) + }) +}) + +describe('isConnectorAccessMode', () => { + it('accepts exactly the declared modes', () => { + for (const mode of CONNECTOR_ACCESS_MODES) expect(isConnectorAccessMode(mode)).toBe(true) + expect(isConnectorAccessMode('root')).toBe(false) + expect(isConnectorAccessMode('')).toBe(false) + }) +}) diff --git a/apps/sim/lib/knowledge/connectors/access-modes.ts b/apps/sim/lib/knowledge/connectors/access-modes.ts new file mode 100644 index 00000000000..d78cefbbcfe --- /dev/null +++ b/apps/sim/lib/knowledge/connectors/access-modes.ts @@ -0,0 +1,80 @@ +/** + * How a connector decides who may read the documents it syncs, and which engine + * that implies. A leaf module: the scheduler, the queue, the sync engine and the + * persistence layer all read the same vocabulary, so the modes cannot drift + * apart between the query that dispatches a run and the code that performs it. + */ + +/** + * `workspace` — every synced document is readable by the whole workspace. + * + * `members` — the source is crawled once per credential-group member with that + * member's own token, and a document's ACL is the set of members whose crawl + * returned it. Driven by the member engine. + * + * `admin` — the source is crawled once under an administrative credential and + * each document's ACL is mirrored from the source's own permissions. Driven by + * the content engine, because it is one crawl under one credential. + */ +export const CONNECTOR_ACCESS_MODES = ['workspace', 'members', 'admin'] as const + +export type ConnectorAccessMode = (typeof CONNECTOR_ACCESS_MODES)[number] + +export function isConnectorAccessMode(value: string): value is ConnectorAccessMode { + return CONNECTOR_ACCESS_MODES.some((mode) => mode === value) +} + +/** + * The modes the content engine drives, which are also the modes that sync + * with one stored credential of their own — the one a switch into the mode + * must name, and the one the row keeps. + * + * `admin` belongs here and `members` does not: an admin-mode connector is + * structurally a workspace crawl that ends with a different ACL, while a + * members-mode connector is N crawls plus an observation graph, crawls with + * its members' credentials, and holds none itself. + */ +export const CONTENT_ENGINE_ACCESS_MODES = ['workspace', 'admin'] as const + +export type ContentEngineAccessMode = (typeof CONTENT_ENGINE_ACCESS_MODES)[number] + +export function isContentEngineAccessMode( + accessMode: string +): accessMode is ContentEngineAccessMode { + return CONTENT_ENGINE_ACCESS_MODES.some((mode) => mode === accessMode) +} + +/** + * The modes whose documents carry the source's own permissions, mirrored onto + * each row by the crawl that lists it. + * + * Every run of such a mode lists the whole corpus. An incremental listing + * returns only documents whose content changed, and a permission change moves + * no content: re-sharing a file does not touch its modified time in Drive, and + * restricting a page does not touch its version in Confluence. A revoked grant + * on an unchanged document would otherwise stand until a full sync happened to + * run. Listing everything costs metadata pages only; content is still hydrated + * by hash, so unchanged documents are never re-fetched or re-embedded. + */ +export const MIRRORING_ACCESS_MODES = ['admin'] as const + +export function mirrorsSourceAcls(accessMode: string): boolean { + return MIRRORING_ACCESS_MODES.some((mode) => mode === accessMode) +} + +/** + * Whether this mode's ACL is owned by a pass other than the content sync. + * + * A workspace-mode connector's documents are visible to the whole workspace on + * insert and on every update. `members` and `admin` both derive their ACL from + * something the content sync does not know — who observed the document, or what + * the source's own permissions say — so their documents are born hidden and made + * visible by a separate pass, and a content update never touches the ACL. Born + * hidden is what makes the fail-closed direction the default: a document indexed + * before its ACL is known is invisible, never workspace-wide. It also means a + * listing cap has no place in these modes: a capped listing would hide + * everything past the cap and never see a removal. + */ +export function aclIsDerived(accessMode: ConnectorAccessMode): boolean { + return accessMode !== 'workspace' +} diff --git a/apps/sim/lib/knowledge/connectors/access-token.test.ts b/apps/sim/lib/knowledge/connectors/access-token.test.ts new file mode 100644 index 00000000000..bb3b1f208e1 --- /dev/null +++ b/apps/sim/lib/knowledge/connectors/access-token.test.ts @@ -0,0 +1,247 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockDecryptApiKey, mockResolveTokenBundle } = vi.hoisted(() => ({ + mockDecryptApiKey: vi.fn(), + mockResolveTokenBundle: vi.fn(), +})) + +vi.mock('@/lib/api-key/crypto', () => ({ decryptApiKey: mockDecryptApiKey })) +vi.mock('@/lib/oauth/credential-service', () => ({ + resolveCredentialTokenBundle: mockResolveTokenBundle, +})) + +import { + connectorServiceAccountScopes, + connectorServiceAccountSubject, + resolveConnectorAccessToken, +} from '@/lib/knowledge/connectors/access-token' +import type { ConnectorAuthConfig } from '@/connectors/types' + +const OAUTH_AUTH: ConnectorAuthConfig = { + mode: 'oauth', + provider: 'google-drive', + requiredScopes: ['https://www.googleapis.com/auth/drive'], +} + +const NO_CREDENTIAL = { credentialId: null, encryptedApiKey: null } + +function credentialConnector(credentialId: string) { + return { credentialId, encryptedApiKey: null } +} + +describe('connectorServiceAccountScopes', () => { + it('falls back to the interactive scopes when the sets coincide', () => { + expect(connectorServiceAccountScopes(OAUTH_AUTH)).toEqual([ + 'https://www.googleapis.com/auth/drive', + ]) + }) + + it('prefers the declared domain-wide-delegation set over the consent set', () => { + expect( + connectorServiceAccountScopes({ + ...OAUTH_AUTH, + serviceAccountScopes: ['https://www.googleapis.com/auth/drive.readonly'], + }) + ).toEqual(['https://www.googleapis.com/auth/drive.readonly']) + }) + + it('has no scopes for an API-key connector', () => { + expect(connectorServiceAccountScopes({ mode: 'apiKey' })).toBeUndefined() + }) +}) + +describe('resolveConnectorAccessToken', () => { + beforeEach(() => { + vi.clearAllMocks() + mockDecryptApiKey.mockResolvedValue({ decrypted: 'plaintext-key' }) + mockResolveTokenBundle.mockResolvedValue({ accessToken: 'access-token' }) + }) + + it('decrypts the stored key for an API-key connector', async () => { + await expect( + resolveConnectorAccessToken({ + auth: { mode: 'apiKey' }, + connector: { credentialId: null, encryptedApiKey: 'cipher' }, + userId: 'user-1', + requestId: 'req-1', + sourceConfig: {}, + }) + ).resolves.toEqual({ accessToken: 'plaintext-key' }) + expect(mockResolveTokenBundle).not.toHaveBeenCalled() + }) + + it('resolves an empty token for an optional API-key connector with no key', async () => { + await expect( + resolveConnectorAccessToken({ + auth: { mode: 'apiKey', optional: true }, + connector: NO_CREDENTIAL, + userId: 'user-1', + requestId: 'req-1', + sourceConfig: {}, + }) + ).resolves.toEqual({ accessToken: '' }) + }) + + it('refuses an API-key connector that requires a key it does not have', async () => { + await expect( + resolveConnectorAccessToken({ + auth: { mode: 'apiKey' }, + connector: NO_CREDENTIAL, + userId: 'user-1', + requestId: 'req-1', + sourceConfig: {}, + }) + ).rejects.toThrow('missing encrypted API key') + }) + + it('refuses an OAuth connector with no credential', async () => { + await expect( + resolveConnectorAccessToken({ + auth: OAUTH_AUTH, + connector: NO_CREDENTIAL, + userId: 'user-1', + requestId: 'req-1', + sourceConfig: {}, + }) + ).rejects.toThrow('missing credential ID') + }) + + /** + * The regression this module exists for: a service-account credential mints + * against scopes it is told, and Google's resolver throws outright when the + * caller passes none. + */ + it('passes the connector scopes through so a service account can mint', async () => { + await resolveConnectorAccessToken({ + auth: OAUTH_AUTH, + connector: credentialConnector('credential-1'), + userId: 'credential-owner', + requestId: 'req-1', + sourceConfig: {}, + }) + expect(mockResolveTokenBundle).toHaveBeenCalledWith( + 'credential-1', + 'credential-owner', + 'req-1', + ['https://www.googleapis.com/auth/drive'], + undefined + ) + }) + + it('carries the credential cloud id so the connector skips discovering it', async () => { + mockResolveTokenBundle.mockResolvedValue({ accessToken: 'access-token', cloudId: 'cloud-1' }) + await expect( + resolveConnectorAccessToken({ + auth: { mode: 'oauth', provider: 'confluence' }, + connector: credentialConnector('credential-1'), + userId: 'credential-owner', + requestId: 'req-1', + sourceConfig: {}, + }) + ).resolves.toEqual({ accessToken: 'access-token', cloudId: 'cloud-1' }) + }) + + it('omits the cloud id rather than carrying an empty one', async () => { + await expect( + resolveConnectorAccessToken({ + auth: OAUTH_AUTH, + connector: credentialConnector('credential-1'), + userId: 'credential-owner', + requestId: 'req-1', + sourceConfig: {}, + }) + ).resolves.toEqual({ accessToken: 'access-token' }) + }) + + it.each([ + ['no bundle', null], + ['a bundle with no token', { accessToken: '' }], + ])('reports %s as no token rather than throwing', async (_label, bundle) => { + mockResolveTokenBundle.mockResolvedValue(bundle) + await expect( + resolveConnectorAccessToken({ + auth: OAUTH_AUTH, + connector: credentialConnector('credential-1'), + userId: 'credential-owner', + requestId: 'req-1', + sourceConfig: {}, + }) + ).resolves.toBeNull() + }) +}) + +describe('connectorServiceAccountSubject', () => { + const withSubject: ConnectorAuthConfig = { + ...OAUTH_AUTH, + serviceAccountSubjectFieldId: 'adminEmail', + } + + it('reads the administrator from the field the connector names', () => { + expect(connectorServiceAccountSubject(withSubject, { adminEmail: 'Admin@Corp.com ' })).toBe( + 'admin@corp.com' + ) + }) + + it('has no subject when the connector names no field', () => { + expect( + connectorServiceAccountSubject(OAUTH_AUTH, { adminEmail: 'admin@corp.com' }) + ).toBeUndefined() + }) + + it('treats a blank or missing value as no subject', () => { + expect(connectorServiceAccountSubject(withSubject, {})).toBeUndefined() + expect(connectorServiceAccountSubject(withSubject, { adminEmail: ' ' })).toBeUndefined() + expect(connectorServiceAccountSubject(withSubject, { adminEmail: 42 })).toBeUndefined() + }) +}) + +describe('impersonation on the connector path', () => { + const withSubject: ConnectorAuthConfig = { + ...OAUTH_AUTH, + serviceAccountSubjectFieldId: 'adminEmail', + } + + beforeEach(() => { + vi.clearAllMocks() + mockResolveTokenBundle.mockResolvedValue({ accessToken: 'access-token' }) + }) + + it('mints as the administrator the connector is configured to crawl as', async () => { + await expect( + resolveConnectorAccessToken({ + auth: withSubject, + connector: credentialConnector('credential-1'), + userId: 'credential-owner', + requestId: 'req-1', + sourceConfig: { adminEmail: 'admin@corp.com' }, + }) + ).resolves.toEqual({ accessToken: 'access-token' }) + expect(mockResolveTokenBundle).toHaveBeenCalledWith( + 'credential-1', + 'credential-owner', + 'req-1', + OAUTH_AUTH.requiredScopes, + 'admin@corp.com' + ) + }) + + it('impersonates nobody for a connector that names no subject field', async () => { + await resolveConnectorAccessToken({ + auth: OAUTH_AUTH, + connector: credentialConnector('credential-1'), + userId: 'credential-owner', + requestId: 'req-1', + sourceConfig: { adminEmail: 'admin@corp.com' }, + }) + expect(mockResolveTokenBundle).toHaveBeenCalledWith( + 'credential-1', + 'credential-owner', + 'req-1', + OAUTH_AUTH.requiredScopes, + undefined + ) + }) +}) diff --git a/apps/sim/lib/knowledge/connectors/access-token.ts b/apps/sim/lib/knowledge/connectors/access-token.ts new file mode 100644 index 00000000000..b7fcf566fcf --- /dev/null +++ b/apps/sim/lib/knowledge/connectors/access-token.ts @@ -0,0 +1,142 @@ +import { normalizeEmail } from '@sim/utils/string' +import { decryptApiKey } from '@/lib/api-key/crypto' +import { resolveCredentialTokenIdentity } from '@/lib/credentials/access' +import { resolveCredentialTokenBundle } from '@/lib/oauth/credential-service' +import type { ConnectorAuthConfig } from '@/connectors/types' + +/** + * What a connector authenticates to its source with: the access token, plus the + * provider metadata that comes back alongside it. + * + * The metadata matters only for service-account credentials. A person's OAuth + * account is discovered from the token itself — Confluence, for instance, + * resolves its cloud id from `accessible-resources` with the bearer token in + * hand. A service account carries an API token that cannot make that call, so + * the site it is bound to is recorded on the credential and travels with the + * token or not at all. + */ +export interface ConnectorAccessToken { + accessToken: string + /** Atlassian only — the Confluence/Jira cloud id the credential is bound to. */ + cloudId?: string +} + +/** + * The scopes to mint a service-account token with for this connector, or + * `undefined` when the connector is not OAuth-backed. + * + * A provider whose two-legged grant accepts the same scopes as its consent + * screen needs no `serviceAccountScopes` of its own; declaring one is how a + * connector says the two sets differ. + */ +export function connectorServiceAccountScopes(auth: ConnectorAuthConfig): string[] | undefined { + if (auth.mode !== 'oauth') return undefined + return auth.serviceAccountScopes ?? auth.requiredScopes +} + +/** + * The person a service-account credential should act as for this connector, or + * `undefined` when the connector names no subject field or the field is blank. + * + * Trimmed and case-folded, so the same administrator spelled two ways is one + * subject — and so the value can be read back as the source's tenant without + * each caller re-normalising it. + */ +export function connectorServiceAccountSubject( + auth: ConnectorAuthConfig, + sourceConfig: Record +): string | undefined { + if (auth.mode !== 'oauth' || !auth.serviceAccountSubjectFieldId) return undefined + const raw = sourceConfig[auth.serviceAccountSubjectFieldId] + if (typeof raw !== 'string') return undefined + const subject = normalizeEmail(raw) + return subject.length > 0 ? subject : undefined +} + +/** + * Resolves the token a connector syncs with, from its declared auth mode and + * the credential or key it holds. + * + * `userId` must own the OAuth account behind the credential — not the knowledge + * base owner. Workspace-scoped credentials are routinely authorized by a + * different member, and token reads are scoped to `account.userId`. A service + * account mints its own token and ignores the argument entirely. + * + * Returns `null` when an OAuth credential has no resolvable token, which is a + * reconnect prompt rather than a fault. Throws only when the connector row and + * its declared auth mode disagree, which is a bug or a corrupted row. + */ +export async function resolveConnectorAccessToken(params: { + auth: ConnectorAuthConfig + connector: { credentialId: string | null; encryptedApiKey: string | null } + userId: string + requestId: string + /** + * Where a service-account credential's impersonation subject lives. Always + * passed on a path that has it: a Drive service account crawling without its + * subject sees an empty domain, so a token minted that way validates or syncs + * against nothing. + */ + sourceConfig: Record +}): Promise { + const { auth, connector, userId, requestId } = params + + if (auth.mode === 'apiKey') { + if (!connector.encryptedApiKey) { + if (auth.optional) return { accessToken: '' } + throw new Error('API key connector is missing encrypted API key') + } + const { decrypted } = await decryptApiKey(connector.encryptedApiKey) + return { accessToken: decrypted } + } + + if (!connector.credentialId) { + throw new Error('OAuth connector is missing credential ID') + } + + const subject = connectorServiceAccountSubject(auth, params.sourceConfig) + const bundle = await resolveCredentialTokenBundle( + connector.credentialId, + userId, + requestId, + connectorServiceAccountScopes(auth), + subject + ) + if (!bundle?.accessToken) return null + + return { + accessToken: bundle.accessToken, + ...(bundle.cloudId ? { cloudId: bundle.cloudId } : {}), + } +} + +/** + * What a run's `syncContext` is seeded with from the token: the site a + * service account already knows, so a connector never has to discover with a + * token that cannot. Every path that opens a connector with a token — the + * content engine, the directory refresh, config validation — seeds the same + * way, so a connector behaves identically on all of them. + */ +/** + * The user a connector's token resolves under. + * + * Token reads are scoped to the account's owner, who is routinely not the + * knowledge base owner: the OAuth account behind a shared credential belongs + * to whoever connected it. A service account mints its own token and ignores + * the argument, so the fallback stands. Null when the credential is no longer + * usable from the workspace, which every caller treats as "reconnect". + */ +export async function resolveConnectorTokenUserId(input: { + credentialId: string | null + workspaceId: string + fallbackUserId: string +}): Promise { + if (!input.credentialId) return input.fallbackUserId + const identity = await resolveCredentialTokenIdentity(input.credentialId, input.workspaceId) + if (!identity) return null + return identity.kind === 'oauth' ? identity.userId : input.fallbackUserId +} + +export function syncContextForToken(token: ConnectorAccessToken): Record { + return token.cloudId ? { cloudId: token.cloudId } : {} +} diff --git a/apps/sim/lib/knowledge/connectors/directory-queue.ts b/apps/sim/lib/knowledge/connectors/directory-queue.ts new file mode 100644 index 00000000000..6840d9f5036 --- /dev/null +++ b/apps/sim/lib/knowledge/connectors/directory-queue.ts @@ -0,0 +1,74 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' +import { idempotencyKeys, tasks } from '@trigger.dev/sdk' +import { resolveTriggerRegion } from '@/lib/core/async-jobs/region' +import { EXTERNAL_GROUP_SYNC_INTERVAL_MS } from '@/lib/knowledge/access/external-groups' +import { refreshConnectorDirectory } from '@/lib/knowledge/connectors/external-group-sync' +import { isTriggerAvailable } from '@/lib/knowledge/documents/service' + +const logger = createLogger('KnowledgeConnectorDirectoryQueue') + +export const DIRECTORY_SYNC_TASK_ID = 'knowledge-connector-directory-sync' + +/** Which sync interval an instant falls in; every tick and retry within it shares the value. */ +function syncIntervalIndex(at: Date): number { + return Math.floor(at.getTime() / EXTERNAL_GROUP_SYNC_INTERVAL_MS) +} + +export interface DirectorySyncPayload { + connectorId: string + requestId: string +} + +export function assertDirectorySyncPayload(value: unknown): DirectorySyncPayload { + if (!isRecordLike(value)) throw new Error('Directory sync payload must be an object') + const { connectorId, requestId } = value + if (typeof connectorId !== 'string' || connectorId.length === 0) { + throw new Error('Directory sync payload requires a connector ID') + } + if (typeof requestId !== 'string' || requestId.length === 0) { + throw new Error('Directory sync payload requires a request ID') + } + return { connectorId, requestId } +} + +/** + * Hands a connector's directory refresh to the background, the way the + * content and member schedulers hand off their runs. + * + * A directory walk is one Admin SDK call per group and can run for minutes on + * a large domain, which is longer than any scheduler's request lives: done + * inline, the cron wrapper's timeout would retry a request that was still + * running and stack walks of the same directory. The idempotency key is the + * sync interval the tick falls in, so a wrapper retry — which lands seconds + * later, in the same interval — dispatches nothing new. Without Trigger.dev + * the refresh runs in-process and the request returns without waiting for it. + */ +export async function dispatchDirectorySync( + connectorId: string, + options: { requestId: string; tickAt: Date } +): Promise { + const payload: DirectorySyncPayload = { connectorId, requestId: options.requestId } + + if (isTriggerAvailable()) { + const idempotencyKey = await idempotencyKeys.create( + `${DIRECTORY_SYNC_TASK_ID}:${connectorId}:${syncIntervalIndex(options.tickAt)}`, + { scope: 'global' } + ) + await tasks.trigger(DIRECTORY_SYNC_TASK_ID, payload, { + idempotencyKey, + tags: [`connector:${connectorId}`], + region: await resolveTriggerRegion(), + }) + return + } + + refreshConnectorDirectory(payload.connectorId, payload.requestId).catch((error) => { + logger.error('Directory refresh failed', { + connectorId, + requestId: payload.requestId, + error: getErrorMessage(error), + }) + }) +} diff --git a/apps/sim/lib/knowledge/connectors/external-group-sync.test.ts b/apps/sim/lib/knowledge/connectors/external-group-sync.test.ts new file mode 100644 index 00000000000..61ad4cbfa87 --- /dev/null +++ b/apps/sim/lib/knowledge/connectors/external-group-sync.test.ts @@ -0,0 +1,182 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ConnectorDirectory } from '@/connectors/types' + +const { mockResolveTokenUserId, mockResolveToken, mockOpenDirectory } = vi.hoisted(() => ({ + mockResolveTokenUserId: vi.fn(), + mockResolveToken: vi.fn(), + mockOpenDirectory: vi.fn(), +})) + +vi.mock('@/lib/knowledge/connectors/access-token', () => ({ + resolveConnectorAccessToken: mockResolveToken, + resolveConnectorTokenUserId: mockResolveTokenUserId, + syncContextForToken: (token: { cloudId?: string }) => + token.cloudId ? { cloudId: token.cloudId } : {}, +})) +vi.mock('@/connectors/registry.server', () => ({ + CONNECTOR_REGISTRY: { + google_drive: { + id: 'google_drive', + auth: { mode: 'oauth', provider: 'google-drive' }, + openDirectory: mockOpenDirectory, + }, + notion: { id: 'notion', auth: { mode: 'oauth', provider: 'notion' } }, + }, +})) + +import { + refreshConnectorDirectory, + syncExternalDirectoryGroups, +} from '@/lib/knowledge/connectors/external-group-sync' + +function directory(overrides: Partial = {}): ConnectorDirectory { + return { + providerId: 'google-drive', + tenantId: 'corp.com', + listGroups: vi.fn(async () => [{ id: 'eng@corp.com' }, { id: 'all@corp.com' }]), + listGroupMembers: vi.fn(async (group) => ({ + group, + memberEmails: ['alice@corp.com'], + complete: true, + })), + ...overrides, + } +} + +describe('syncExternalDirectoryGroups', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + dbChainMockFns.returning.mockResolvedValue([{ id: 'group-row' }]) + }) + + it('skips a directory walked within the sync interval', async () => { + queueTableRows(schemaMock.knowledgeExternalGroup, [{ id: 'group-row' }]) + const dir = directory() + + await expect( + syncExternalDirectoryGroups({ workspaceId: 'ws-1', directory: dir }) + ).resolves.toMatchObject({ skipped: true }) + expect(dir.listGroups).not.toHaveBeenCalled() + }) + + it('replaces membership only from a complete enumeration, keeping the rest last-known-good', async () => { + queueTableRows(schemaMock.knowledgeExternalGroup, []) + const dir = directory({ + listGroupMembers: vi.fn(async (group) => + group.id === 'eng@corp.com' + ? { group, memberEmails: ['alice@corp.com'], complete: true } + : { group, memberEmails: ['bob@corp.com'], complete: false } + ), + }) + + await expect( + syncExternalDirectoryGroups({ workspaceId: 'ws-1', directory: dir }) + ).resolves.toMatchObject({ refreshed: 1, keptStale: 1, skipped: false }) + /** One transaction per group whose membership was replaced. */ + expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(1) + }) + + it('keeps a group whose enumeration threw, without failing the directory', async () => { + queueTableRows(schemaMock.knowledgeExternalGroup, []) + const dir = directory({ + listGroupMembers: vi.fn(async (group) => { + if (group.id === 'all@corp.com') throw new Error('403') + return { group, memberEmails: ['alice@corp.com'], complete: true } + }), + }) + + await expect( + syncExternalDirectoryGroups({ workspaceId: 'ws-1', directory: dir }) + ).resolves.toMatchObject({ refreshed: 1, keptStale: 1 }) + }) + + /** + * A truncated group listing must not prune: every directory throws rather + * than returning a partial page, and the sync fails with it. + */ + it('fails the directory when the group listing itself fails, pruning nothing', async () => { + queueTableRows(schemaMock.knowledgeExternalGroup, []) + const dir = directory({ listGroups: vi.fn(async () => Promise.reject(new Error('429'))) }) + + await expect( + syncExternalDirectoryGroups({ workspaceId: 'ws-1', directory: dir }) + ).rejects.toThrow('429') + expect(dbChainMockFns.delete).not.toHaveBeenCalled() + }) +}) + +describe('refreshConnectorDirectory', () => { + function connectorRow(overrides: Record = {}) { + return { + id: 'connector-1', + connectorType: 'google_drive', + accessMode: 'admin', + credentialId: 'credential-1', + encryptedApiKey: null, + sourceConfig: { adminEmail: 'admin@corp.com' }, + workspaceId: 'ws-1', + knowledgeBaseOwnerId: 'owner-1', + ...overrides, + } + } + + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockResolveTokenUserId.mockResolvedValue('owner-1') + mockResolveToken.mockResolvedValue({ accessToken: 'token', cloudId: 'cloud-1' }) + mockOpenDirectory.mockResolvedValue(null) + }) + + /** + * Token reads are scoped to the credential's own account owner, not the + * knowledge base owner, who is routinely a different member. + */ + it('resolves the token as the credential owner for an OAuth credential', async () => { + queueTableRows(schemaMock.knowledgeConnector, [connectorRow()]) + mockResolveTokenUserId.mockResolvedValue('credential-owner') + + await expect(refreshConnectorDirectory('connector-1', 'req-1')).resolves.toBe('refreshed') + expect(mockResolveToken).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'credential-owner' }) + ) + }) + + it('opens the directory with the site the token already knows', async () => { + queueTableRows(schemaMock.knowledgeConnector, [connectorRow()]) + + await refreshConnectorDirectory('connector-1', 'req-1') + + expect(mockOpenDirectory).toHaveBeenCalledWith( + 'token', + { adminEmail: 'admin@corp.com' }, + { cloudId: 'cloud-1' } + ) + }) + + it('reports a connector whose credential no longer resolves rather than failing', async () => { + queueTableRows(schemaMock.knowledgeConnector, [connectorRow()]) + mockResolveTokenUserId.mockResolvedValue(null) + + await expect(refreshConnectorDirectory('connector-1', 'req-1')).resolves.toBe('unusable') + expect(mockOpenDirectory).not.toHaveBeenCalled() + }) + + it('skips a connector whose source has no directory to read', async () => { + queueTableRows(schemaMock.knowledgeConnector, [connectorRow({ connectorType: 'notion' })]) + + await expect(refreshConnectorDirectory('connector-1', 'req-1')).resolves.toBe('skipped') + expect(mockResolveToken).not.toHaveBeenCalled() + }) + + it('skips a connector that has since left administrator mode', async () => { + queueTableRows(schemaMock.knowledgeConnector, [connectorRow({ accessMode: 'workspace' })]) + + await expect(refreshConnectorDirectory('connector-1', 'req-1')).resolves.toBe('skipped') + }) +}) diff --git a/apps/sim/lib/knowledge/connectors/external-group-sync.ts b/apps/sim/lib/knowledge/connectors/external-group-sync.ts new file mode 100644 index 00000000000..eebc947d68e --- /dev/null +++ b/apps/sim/lib/knowledge/connectors/external-group-sync.ts @@ -0,0 +1,354 @@ +import { db } from '@sim/db' +import { + knowledgeBase, + knowledgeConnector, + knowledgeExternalGroup, + knowledgeExternalGroupMember, +} from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { chunkArray } from '@sim/utils/helpers' +import { generateId } from '@sim/utils/id' +import { and, eq, gte, isNull, notInArray } from 'drizzle-orm' +import { EXTERNAL_GROUP_SYNC_INTERVAL_MS } from '@/lib/knowledge/access/external-groups' +import { canonicalGroupId } from '@/lib/knowledge/access/tokens' +import { mirrorsSourceAcls } from '@/lib/knowledge/connectors/access-modes' +import { + resolveConnectorAccessToken, + resolveConnectorTokenUserId, + syncContextForToken, +} from '@/lib/knowledge/connectors/access-token' +import { CONNECTOR_REGISTRY } from '@/connectors/registry.server' +import type { + ConnectorConfig, + ConnectorDirectory, + ConnectorDirectoryGroup, +} from '@/connectors/types' + +const logger = createLogger('ExternalGroupSync') + +/** Member rows written per statement while replacing a group's membership. */ +const MEMBER_WRITE_BATCH_SIZE = 500 + +interface DirectorySyncResult { + /** Groups whose membership was replaced from a complete enumeration. */ + refreshed: number + /** Groups left on their previous membership because this run could not read them in full. */ + keptStale: number + /** Groups the directory no longer has, removed along with their membership. */ + pruned: number + /** True when the sync was skipped because the directory was read recently enough. */ + skipped: boolean +} + +/** + * Refreshes the external directory groups of one workspace, for one provider + * and tenant. + * + * The unit of work is a group, not the directory: a group whose membership + * enumerates completely is replaced, and one that does not is left exactly as + * it was. That is the whole difference from Onyx, whose group sync marks every + * row stale, upserts whatever the source returned, and sweeps the rest — clean + * until the directory half-fails, at which point it revokes access from real + * members whose rows simply were not returned that run. Here a directory outage + * costs freshness and nothing else, and + * `EXTERNAL_GROUP_STALE_AFTER_MS` is what stops that patience becoming + * permanent. + */ +export async function syncExternalDirectoryGroups(input: { + workspaceId: string + directory: ConnectorDirectory +}): Promise { + const { workspaceId, directory } = input + const { providerId, tenantId } = directory + + if (await directoryReadRecently(workspaceId, providerId, tenantId)) { + return { refreshed: 0, keptStale: 0, pruned: 0, skipped: true } + } + + const groups = await directory.listGroups() + logger.info('Enumerating directory groups', { + workspaceId, + providerId, + tenantId, + groups: groups.length, + }) + + let refreshed = 0 + let keptStale = 0 + for (const group of groups) { + const groupId = await upsertGroup({ workspaceId, providerId, tenantId, group }) + try { + const membership = await directory.listGroupMembers(group) + if (!membership.complete) { + keptStale += 1 + logger.warn('Keeping last-known-good membership for a partially enumerated group', { + workspaceId, + providerId, + externalGroupId: group.id, + }) + continue + } + await replaceGroupMembers(groupId, membership.memberEmails) + refreshed += 1 + } catch (error) { + keptStale += 1 + logger.warn('Keeping last-known-good membership for a group that failed to enumerate', { + workspaceId, + providerId, + externalGroupId: group.id, + error: getErrorMessage(error), + }) + } + } + + const pruned = await pruneRemovedGroups({ + workspaceId, + providerId, + tenantId, + keep: groups.map((group) => canonicalGroupId(group.id)), + }) + + return { refreshed, keptStale, pruned, skipped: false } +} + +/** + * Whether this directory was walked within the sync interval. + * + * Keyed off the *most* recently confirmed group: a walk confirms every group + * it can read, so one confirmed within the interval means the walk ran then. + * Keying off the least recent would make one group the service account can + * never read — a permanent 403 — keep the whole directory due forever, and + * re-walk it every tick. That group still stays on its last-known-good + * membership and ages out on the read side like any other. No groups at all + * is a directory that has never been read, not a fresh one. + */ +async function directoryReadRecently( + workspaceId: string, + providerId: string, + tenantId: string +): Promise { + const freshEnough = new Date(Date.now() - EXTERNAL_GROUP_SYNC_INTERVAL_MS) + const [recent] = await db + .select({ id: knowledgeExternalGroup.id }) + .from(knowledgeExternalGroup) + .where( + and( + eq(knowledgeExternalGroup.workspaceId, workspaceId), + eq(knowledgeExternalGroup.providerId, providerId), + eq(knowledgeExternalGroup.tenantId, tenantId), + gte(knowledgeExternalGroup.lastSyncedAt, freshEnough) + ) + ) + .limit(1) + return Boolean(recent) +} + +async function upsertGroup(input: { + workspaceId: string + providerId: string + tenantId: string + group: ConnectorDirectoryGroup +}): Promise { + const { workspaceId, providerId, tenantId, group } = input + const [row] = await db + .insert(knowledgeExternalGroup) + .values({ + id: generateId(), + workspaceId, + providerId, + tenantId, + externalGroupId: canonicalGroupId(group.id), + }) + .onConflictDoUpdate({ + target: [ + knowledgeExternalGroup.workspaceId, + knowledgeExternalGroup.providerId, + knowledgeExternalGroup.tenantId, + knowledgeExternalGroup.externalGroupId, + ], + set: { updatedAt: new Date() }, + }) + .returning({ id: knowledgeExternalGroup.id }) + return row.id +} + +/** + * Replaces a group's membership with a complete enumeration, and marks it + * confirmed. + * + * One transaction, so a reader never sees a group mid-rewrite — briefly empty + * would mean briefly revoked for everyone in it. `lastSyncedAt` moves only + * here, on the path that had the whole membership in hand. + */ +async function replaceGroupMembers(groupId: string, emails: string[]): Promise { + const unique = [...new Set(emails)] + const now = new Date() + await db.transaction(async (tx) => { + await tx + .delete(knowledgeExternalGroupMember) + .where(eq(knowledgeExternalGroupMember.groupId, groupId)) + + for (const batch of chunkArray(unique, MEMBER_WRITE_BATCH_SIZE)) { + await tx + .insert(knowledgeExternalGroupMember) + .values(batch.map((email) => ({ groupId, email }))) + } + + await tx + .update(knowledgeExternalGroup) + .set({ lastSyncedAt: now, updatedAt: now }) + .where(eq(knowledgeExternalGroup.id, groupId)) + }) +} + +/** + * Removes groups this directory no longer has, cascading their membership. + * + * Only ever called with the result of a complete `listGroups`, which every + * directory implements to throw rather than return a partial page — deleting + * groups because a listing was truncated would revoke everyone in them. + */ +async function pruneRemovedGroups(input: { + workspaceId: string + providerId: string + tenantId: string + keep: readonly string[] +}): Promise { + const removed = await db + .delete(knowledgeExternalGroup) + .where( + and( + eq(knowledgeExternalGroup.workspaceId, input.workspaceId), + eq(knowledgeExternalGroup.providerId, input.providerId), + eq(knowledgeExternalGroup.tenantId, input.tenantId), + ...(input.keep.length > 0 + ? [notInArray(knowledgeExternalGroup.externalGroupId, [...input.keep])] + : []) + ) + ) + .returning({ id: knowledgeExternalGroup.id }) + return removed.length +} + +/** + * Refreshes the directory groups the mirrored ACLs refer to. + * + * A `g:` token grants nobody until the directory says who is in that group, so + * the refresh runs in the same pass that writes the tokens — a crawl can never + * publish grants against membership this workspace has never read. + * + * It is rate-limited on its own clock rather than the connector's, so a + * frequently-syncing connector does not re-read the whole directory every run. + * A failure is logged rather than thrown: last-known-good membership is still + * serving reads, and failing the content sync over it would strand the + * documents as well as the groups. + */ +export async function refreshMirroredDirectory(input: { + workspaceId: string + connectorConfig: ConnectorConfig + sourceConfig: Record + syncContext: Record + accessToken: string +}): Promise { + const { workspaceId, connectorConfig } = input + if (!connectorConfig.openDirectory) return + + try { + const directory = await connectorConfig.openDirectory( + input.accessToken, + input.sourceConfig, + input.syncContext + ) + if (!directory) { + logger.warn('Skipping directory refresh: the connector names no directory', { + workspaceId, + connector: connectorConfig.id, + }) + return + } + const result = await syncExternalDirectoryGroups({ workspaceId, directory }) + logger.info('Refreshed mirrored directory groups', { + workspaceId, + tenantId: directory.tenantId, + ...result, + }) + } catch (error) { + logger.error('Directory refresh failed; serving last-known-good group membership', { + workspaceId, + connector: connectorConfig.id, + error: getErrorMessage(error), + }) + } +} + +type ConnectorDirectoryRefreshOutcome = 'refreshed' | 'skipped' | 'unusable' + +/** + * Refreshes the directory one admin-mode connector mirrors, from its row. + * + * The scheduler's unit of work, run in the background. Resolves the credential + * the connector syncs as — the credential's own account owner, not the + * knowledge base owner, since token reads are scoped to `account.userId` and a + * service account ignores the argument entirely — and opens the directory with + * the same context a content sync would. + */ +export async function refreshConnectorDirectory( + connectorId: string, + requestId: string +): Promise { + const [connector] = await db + .select({ + id: knowledgeConnector.id, + connectorType: knowledgeConnector.connectorType, + accessMode: knowledgeConnector.accessMode, + credentialId: knowledgeConnector.credentialId, + encryptedApiKey: knowledgeConnector.encryptedApiKey, + sourceConfig: knowledgeConnector.sourceConfig, + workspaceId: knowledgeBase.workspaceId, + knowledgeBaseOwnerId: knowledgeBase.userId, + }) + .from(knowledgeConnector) + .innerJoin(knowledgeBase, eq(knowledgeConnector.knowledgeBaseId, knowledgeBase.id)) + .where( + and( + eq(knowledgeConnector.id, connectorId), + isNull(knowledgeConnector.archivedAt), + isNull(knowledgeConnector.deletedAt), + isNull(knowledgeBase.deletedAt) + ) + ) + .limit(1) + if (!connector || !mirrorsSourceAcls(connector.accessMode) || !connector.workspaceId) { + return 'skipped' + } + + const connectorConfig = CONNECTOR_REGISTRY[connector.connectorType] + if (!connectorConfig?.openDirectory) return 'skipped' + + const credentialUserId = await resolveConnectorTokenUserId({ + credentialId: connector.credentialId, + workspaceId: connector.workspaceId, + fallbackUserId: connector.knowledgeBaseOwnerId, + }) + if (!credentialUserId) return 'unusable' + + const sourceConfig = connector.sourceConfig as Record + const token = await resolveConnectorAccessToken({ + auth: connectorConfig.auth, + connector, + userId: credentialUserId, + requestId, + sourceConfig, + }) + if (!token) return 'unusable' + + await refreshMirroredDirectory({ + workspaceId: connector.workspaceId, + connectorConfig, + sourceConfig, + syncContext: syncContextForToken(token), + accessToken: token.accessToken, + }) + return 'refreshed' +} diff --git a/apps/sim/lib/knowledge/connectors/member-queue.ts b/apps/sim/lib/knowledge/connectors/member-queue.ts index ea04f785163..254ba16594a 100644 --- a/apps/sim/lib/knowledge/connectors/member-queue.ts +++ b/apps/sim/lib/knowledge/connectors/member-queue.ts @@ -20,6 +20,7 @@ import { import { connectorIsLive, MEMBER_LOCKABLE_CONNECTOR_STATUSES, + RUNNABLE_CONNECTOR_STATUSES, } from '@/lib/knowledge/connectors/sync-lock' import { isTriggerAvailable } from '@/lib/knowledge/documents/service' @@ -369,7 +370,7 @@ export async function dispatchMemberSyncsForCredentialOption(input: { isNull(knowledgeBase.deletedAt), eq(knowledgeConnector.accessMode, 'members'), eq(knowledgeConnector.credentialGroupOptionId, input.credentialGroupOptionId), - inArray(knowledgeConnector.status, ['active', 'error']), + inArray(knowledgeConnector.status, RUNNABLE_CONNECTOR_STATUSES), isNull(knowledgeConnector.archivedAt), isNull(knowledgeConnector.deletedAt) ) diff --git a/apps/sim/lib/knowledge/connectors/mirrored-access.test.ts b/apps/sim/lib/knowledge/connectors/mirrored-access.test.ts new file mode 100644 index 00000000000..46561db83fd --- /dev/null +++ b/apps/sim/lib/knowledge/connectors/mirrored-access.test.ts @@ -0,0 +1,72 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockRequireSourceMirrored } = vi.hoisted(() => ({ + mockRequireSourceMirrored: vi.fn(async () => undefined), +})) + +vi.mock('@/lib/knowledge/access/availability', () => ({ + requireSourceMirroredAccessAvailable: mockRequireSourceMirrored, +})) + +import { assertConnectorMirrorsSourceAcls } from '@/lib/knowledge/connectors/mirrored-access' +import type { ConnectorMeta } from '@/connectors/types' + +const impersonating: ConnectorMeta = { + id: 'google_drive', + name: 'Google Drive', + description: '', + version: '1', + icon: () => null, + auth: { mode: 'oauth', provider: 'google-drive', serviceAccountSubjectFieldId: 'adminEmail' }, + configFields: [], + mirrorsSourceAcls: true, +} + +const tokenBacked: ConnectorMeta = { + ...impersonating, + id: 'confluence', + name: 'Confluence', + auth: { mode: 'oauth', provider: 'confluence' }, +} + +describe('assertConnectorMirrorsSourceAcls', () => { + beforeEach(() => vi.clearAllMocks()) + + it('refuses a connector that cannot mirror permissions at all', async () => { + await expect( + assertConnectorMirrorsSourceAcls({ ...tokenBacked, mirrorsSourceAcls: undefined }, {}, 'ws-1') + ).rejects.toThrow('has no administrator mode') + }) + + it('refuses an impersonating connector with nobody to crawl as', async () => { + await expect(assertConnectorMirrorsSourceAcls(impersonating, {}, 'ws-1')).rejects.toThrow( + 'needs the administrator to crawl as' + ) + }) + + it('accepts an impersonating connector once an administrator is named', async () => { + await expect( + assertConnectorMirrorsSourceAcls(impersonating, { adminEmail: 'admin@corp.com' }, 'ws-1') + ).resolves.toBeUndefined() + }) + + /** + * The bug this pins: a connector whose service account holds an API token + * impersonates nobody and has no subject to require. Demanding one made + * administrator mode unreachable for every such source. + */ + it('accepts a token-backed connector that names no subject field', async () => { + await expect(assertConnectorMirrorsSourceAcls(tokenBacked, {}, 'ws-1')).resolves.toBeUndefined() + }) + + it('refuses when the workspace is not entitled, before anything else', async () => { + mockRequireSourceMirrored.mockRejectedValueOnce(new Error('not available')) + + await expect( + assertConnectorMirrorsSourceAcls(impersonating, { adminEmail: 'admin@corp.com' }, 'ws-1') + ).rejects.toThrow('not available') + }) +}) diff --git a/apps/sim/lib/knowledge/connectors/mirrored-access.ts b/apps/sim/lib/knowledge/connectors/mirrored-access.ts new file mode 100644 index 00000000000..348312232a3 --- /dev/null +++ b/apps/sim/lib/knowledge/connectors/mirrored-access.ts @@ -0,0 +1,42 @@ +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { requireSourceMirroredAccessAvailable } from '@/lib/knowledge/access/availability' +import { connectorServiceAccountSubject } from '@/lib/knowledge/connectors/access-token' +import type { ConnectorMeta } from '@/connectors/types' + +/** + * Refuses admin mode on a connector that cannot mirror the source's + * permissions, or that has not been told whose eyes to crawl through. + * + * Both are refusals rather than warnings because the alternative is a corpus + * indexed with no ACL at all: every document readable by nobody, which looks + * exactly like a broken sync. Failing at the moment the mode is chosen — or + * the config edited — says what is missing while the person doing it can + * still supply it. A leaf module so creation, the mode switch and the config + * update all refuse exactly the same things. + */ +export async function assertConnectorMirrorsSourceAcls( + connectorMeta: Pick, + sourceConfig: Record, + workspaceId: string +): Promise { + await requireSourceMirroredAccessAvailable({ workspaceId }) + if (!connectorMeta.mirrorsSourceAcls) { + throw new OrchestrationError( + 'validation', + `${connectorMeta.name} cannot mirror source permissions, so it has no administrator mode` + ) + } + /** + * Only a connector that impersonates needs a subject. A Drive service account + * sees nothing until it acts as an administrator; an Atlassian one holds an + * API token that already speaks for the site. + */ + const { auth } = connectorMeta + const impersonates = auth.mode === 'oauth' && Boolean(auth.serviceAccountSubjectFieldId) + if (impersonates && !connectorServiceAccountSubject(auth, sourceConfig)) { + throw new OrchestrationError( + 'validation', + `${connectorMeta.name} needs the administrator to crawl as before it can mirror permissions` + ) + } +} diff --git a/apps/sim/lib/knowledge/connectors/mirrored-acls.test.ts b/apps/sim/lib/knowledge/connectors/mirrored-acls.test.ts new file mode 100644 index 00000000000..df8766d9a5f --- /dev/null +++ b/apps/sim/lib/knowledge/connectors/mirrored-acls.test.ts @@ -0,0 +1,89 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + hideUnlistedDocuments, + mergeMirroredAcls, + unansweredByListing, +} from '@/lib/knowledge/connectors/mirrored-acls' +import type { ExternalDocument } from '@/connectors/types' + +function doc(externalId: string, acl?: readonly string[]): ExternalDocument { + return { + externalId, + title: externalId, + content: '', + mimeType: 'text/plain', + contentHash: 'h', + acl, + } +} + +describe('unansweredByListing', () => { + it('names exactly the documents the listing left without an ACL', () => { + expect( + unansweredByListing([doc('a', ['u:alice@corp.com']), doc('b'), doc('c', []), doc('d')]).map( + (d) => d.externalId + ) + ).toEqual(['b', 'd']) + }) +}) + +describe('mergeMirroredAcls', () => { + it("keeps the listing's answer where it gave one", () => { + const { acls, unattributed } = mergeMirroredAcls([doc('a', ['u:alice@corp.com'])], { + a: ['u:bob@corp.com'], + }) + + expect(acls.get('a')).toEqual(['u:alice@corp.com']) + expect(unattributed).toBe(0) + }) + + it('fills what the listing could not from the fetch', () => { + const { acls, unattributed } = mergeMirroredAcls([doc('a'), doc('b', ['pub'])], { + a: ['u:alice@corp.com'], + }) + + expect(acls.get('a')).toEqual(['u:alice@corp.com']) + expect(acls.get('b')).toEqual(['pub']) + expect(unattributed).toBe(0) + }) + + /** + * A document nobody answered for is hidden and counted — never skipped, + * because skipping would leave it under an ACL this run did not verify. + */ + it('hides and counts a document neither source answered for', () => { + const { acls, unattributed } = mergeMirroredAcls([doc('a'), doc('b')], { a: ['pub'] }) + + expect(acls.get('b')).toEqual([]) + expect(unattributed).toBe(1) + }) + + it('treats an explicitly empty inline ACL as an answer, not a gap', () => { + const { acls, unattributed } = mergeMirroredAcls([doc('a', [])], { a: ['pub'] }) + + expect(acls.get('a')).toEqual([]) + expect(unattributed).toBe(0) + }) + + it('answers for every listed document, in listing order', () => { + const { acls } = mergeMirroredAcls([doc('z', ['pub']), doc('a')], {}) + + expect([...acls.keys()]).toEqual(['z', 'a']) + }) +}) + +describe('hideUnlistedDocuments', () => { + it('hides every owned document the listing did not name and leaves the listed ones alone', () => { + const acls = new Map([['a', ['u:alice@corp.com']]]) + + const hidden = hideUnlistedDocuments(acls, ['a', 'b', null, 'c']) + + expect(hidden).toBe(2) + expect(acls.get('a')).toEqual(['u:alice@corp.com']) + expect(acls.get('b')).toEqual([]) + expect(acls.get('c')).toEqual([]) + }) +}) diff --git a/apps/sim/lib/knowledge/connectors/mirrored-acls.ts b/apps/sim/lib/knowledge/connectors/mirrored-acls.ts new file mode 100644 index 00000000000..19b00764f93 --- /dev/null +++ b/apps/sim/lib/knowledge/connectors/mirrored-acls.ts @@ -0,0 +1,59 @@ +import { EMPTY_ACL } from '@/lib/knowledge/access/tokens' +import type { ExternalDocument } from '@/connectors/types' + +export interface MirroredAcls { + /** Every listed document's ACL, keyed by external id; readable by nobody where neither source answered. */ + acls: Map + /** Listed documents neither the listing nor the fetch could speak for. */ + unattributed: number +} + +/** The listed documents whose ACL the listing left unset. */ +export function unansweredByListing(externalDocs: readonly ExternalDocument[]): ExternalDocument[] { + return externalDocs.filter((doc) => !doc.acl) +} + +/** + * One ACL per listed document, from the two places a connector may answer: + * inline on the listing, or fetched afterwards for the ids the listing could + * not describe. + * + * The listing's answer wins where it exists, because it is the cheaper one and + * was taken from the same page the document came from. A document neither + * answered for is readable by nobody rather than skipped — leaving its previous + * ACL in place would keep serving it under permissions this run failed to + * verify — and is counted, because a connector that declares it mirrors ACLs is + * promising an answer for everything it lists. + */ +export function mergeMirroredAcls( + externalDocs: readonly ExternalDocument[], + fetched: Readonly> +): MirroredAcls { + const acls = new Map() + let unattributed = 0 + for (const doc of externalDocs) { + const acl = doc.acl ?? fetched[doc.externalId] + if (!acl) unattributed += 1 + acls.set(doc.externalId, acl ?? EMPTY_ACL) + } + return { acls, unattributed } +} + +/** + * Hides every owned document the listing did not name, and returns how many. + * + * The listing is the only evidence this run has of who may read what; a + * document absent from it keeps no ACL this run can vouch for. + */ +export function hideUnlistedDocuments( + acls: Map, + ownedExternalIds: readonly (string | null)[] +): number { + let hidden = 0 + for (const externalId of ownedExternalIds) { + if (!externalId || acls.has(externalId)) continue + acls.set(externalId, EMPTY_ACL) + hidden += 1 + } + return hidden +} diff --git a/apps/sim/lib/knowledge/connectors/queue.test.ts b/apps/sim/lib/knowledge/connectors/queue.test.ts index f7e7d0ec807..f88fa61964e 100644 --- a/apps/sim/lib/knowledge/connectors/queue.test.ts +++ b/apps/sim/lib/knowledge/connectors/queue.test.ts @@ -102,6 +102,54 @@ describe('connector sync queue', () => { resetDbChainMock() }) + /** + * The bug this pins: the queue once refused anything but workspace mode, so + * every admin-mode connector the scheduler selected was dropped before the + * queue entry was taken, and the content engine's admin branch never ran. + */ + it('dispatches an admin-mode connector, which the content engine drives', async () => { + resetDbChainMock() + queueTableRows(schemaMock.knowledgeConnector, [ + { + knowledgeBaseId: 'knowledge-base-1', + connectorStatus: 'active', + connectorAccessMode: 'admin', + connectorArchivedAt: null, + connectorDeletedAt: null, + connectorNextSyncAt: NEXT_SYNC_AT, + workspaceId: 'workspace-paid', + kbDeletedAt: null, + }, + ]) + dbChainMockFns.returning.mockResolvedValue([{ id: 'connector-1' }]) + + await expect( + dispatchSync('connector-1', { billingAttribution: BILLING_ATTRIBUTION, requestId: 'r' }) + ).resolves.toEqual({ queued: true }) + expect(mockTrigger).toHaveBeenCalledTimes(1) + }) + + it('refuses a members-mode connector, which the member engine drives', async () => { + resetDbChainMock() + queueTableRows(schemaMock.knowledgeConnector, [ + { + knowledgeBaseId: 'knowledge-base-1', + connectorStatus: 'active', + connectorAccessMode: 'members', + connectorArchivedAt: null, + connectorDeletedAt: null, + connectorNextSyncAt: NEXT_SYNC_AT, + workspaceId: 'workspace-paid', + kbDeletedAt: null, + }, + ]) + + await expect( + dispatchSync('connector-1', { billingAttribution: BILLING_ATTRIBUTION, requestId: 'r' }) + ).resolves.toMatchObject({ queued: false }) + expect(mockTrigger).not.toHaveBeenCalled() + }) + it('preserves the actor and immutable workspace payer in the queued payload', async () => { await dispatchSync('connector-1', { billingAttribution: BILLING_ATTRIBUTION, diff --git a/apps/sim/lib/knowledge/connectors/queue.ts b/apps/sim/lib/knowledge/connectors/queue.ts index 4d4de0e463c..b74ebb59515 100644 --- a/apps/sim/lib/knowledge/connectors/queue.ts +++ b/apps/sim/lib/knowledge/connectors/queue.ts @@ -11,6 +11,10 @@ import { type BillingAttributionSnapshot, } from '@/lib/billing/core/billing-attribution' import { resolveTriggerRegion } from '@/lib/core/async-jobs/region' +import { + CONTENT_ENGINE_ACCESS_MODES, + isContentEngineAccessMode, +} from '@/lib/knowledge/connectors/access-modes' import { executeSync, isConnectorRunnableStatus } from '@/lib/knowledge/connectors/sync-engine' import { connectorIsLive, LOCKABLE_CONNECTOR_STATUSES } from '@/lib/knowledge/connectors/sync-lock' import { isTriggerAvailable } from '@/lib/knowledge/documents/service' @@ -161,7 +165,7 @@ async function markSyncPending(connectorId: string): Promise { .where( and( eq(knowledgeConnector.id, connectorId), - eq(knowledgeConnector.accessMode, 'workspace'), + inArray(knowledgeConnector.accessMode, CONTENT_ENGINE_ACCESS_MODES), inArray(knowledgeConnector.status, LOCKABLE_CONNECTOR_STATUSES), isNull(knowledgeConnector.syncLockToken), connectorIsLive() @@ -347,7 +351,7 @@ export async function dispatchSync( }) return { queued: false, reason: 'Connector has been archived or deleted' } } - if (row.connectorAccessMode !== 'workspace') { + if (!isContentEngineAccessMode(row.connectorAccessMode)) { logger.info('Skipping sync dispatch: connector syncs per member', { connectorId, requestId }) return { queued: false, diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts index d57a57ba7fa..99c8b3fb979 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts @@ -3257,7 +3257,7 @@ describe('executeSync heartbeats during the listing phase', () => { queueTableRows(schemaMock.knowledgeBase, [{ userId: 'u-1', workspaceId: 'ws-1' }]) // The lock CAS; every later `.returning()` falls through to the empty default, // which is what makes the heartbeat below report a lost lock. - dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'c-1' }]) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'c-1', accessMode: 'workspace' }]) } it('beats between pages and abandons the run when the lock was reclaimed', async () => { @@ -3579,7 +3579,7 @@ describe('executeSync hard-delete reconciliation', () => { const { hardDeleteDocuments } = await import('@/lib/knowledge/documents/service') primeReconciliation() vi.mocked(hardDeleteDocuments).mockResolvedValue(0) - dbChainMockFns.returning.mockResolvedValue([{ id: 'c-1' }]) + dbChainMockFns.returning.mockResolvedValue([{ id: 'c-1', accessMode: 'workspace' }]) await executeSync('c-1', { billingAttribution: { workspaceId: 'ws-1' } as never, @@ -3733,7 +3733,7 @@ describe('executeSync terminal exits under a lost lock', () => { function primeLockedRun() { queueTableRows(schemaMock.knowledgeConnector, [CONNECTOR]) queueTableRows(schemaMock.knowledgeBase, [{ userId: 'u-1', workspaceId: 'ws-1' }]) - dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'c-1' }]) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'c-1', accessMode: 'workspace' }]) } it('skips the success state write when the terminal knowledge-base lock is refused', async () => { diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.ts b/apps/sim/lib/knowledge/connectors/sync-engine.ts index 06c76c14ad0..ba6d2ffb769 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.ts @@ -10,12 +10,29 @@ import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { randomInt } from '@sim/utils/random' import { and, asc, eq, exists, gt, inArray, isNotNull, isNull, sql } from 'drizzle-orm' -import { decryptApiKey } from '@/lib/api-key/crypto' import { assertBillingAttributionSnapshot, type BillingAttributionSnapshot, } from '@/lib/billing/core/billing-attribution' -import { resolveCredentialTokenIdentity } from '@/lib/credentials/access' +import { EMPTY_ACL } from '@/lib/knowledge/access/tokens' +import { + CONTENT_ENGINE_ACCESS_MODES, + isContentEngineAccessMode, + mirrorsSourceAcls, +} from '@/lib/knowledge/connectors/access-modes' +import { + type ConnectorAccessToken, + resolveConnectorAccessToken, + resolveConnectorTokenUserId, + syncContextForToken, +} from '@/lib/knowledge/connectors/access-token' +import { refreshMirroredDirectory } from '@/lib/knowledge/connectors/external-group-sync' +import { rewriteConnectorAcls } from '@/lib/knowledge/connectors/member-observations' +import { + hideUnlistedDocuments, + mergeMirroredAcls, + unansweredByListing, +} from '@/lib/knowledge/connectors/mirrored-acls' import { CONNECTOR_AUTO_DISABLED_ERROR, CONNECTOR_FAILURE_BACKOFF_CAP_MINUTES, @@ -27,11 +44,13 @@ import { createContentSyncLease, holdsSyncLockToken, LOCKABLE_CONNECTOR_STATUSES, + RUNNABLE_CONNECTOR_STATUSES, SyncLockLostException, stillHoldsSyncLock, } from '@/lib/knowledge/connectors/sync-lock' import { type KnowledgeBaseOwner, + persistDocumentAcls, restoreWorkspaceDocumentAcls, } from '@/lib/knowledge/connectors/sync-persistence' import { @@ -50,9 +69,13 @@ import { } from '@/lib/knowledge/connectors/sync-primitives' import { hardDeleteDocuments } from '@/lib/knowledge/documents/service' import { getRetryAfterMs, isRateLimitError } from '@/lib/knowledge/documents/utils' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' import { CONNECTOR_REGISTRY } from '@/connectors/registry.server' -import type { ConnectorAuthConfig, SyncResult } from '@/connectors/types' +import type { + ConnectorAuthConfig, + ConnectorConfig, + ExternalDocument, + SyncResult, +} from '@/connectors/types' const logger = createLogger('ConnectorSyncEngine') @@ -64,7 +87,73 @@ export { worstCaseProcessingMinutes, } from '@/lib/knowledge/documents/types' -const RUNNABLE_CONNECTOR_STATUSES = ['active', 'error'] as const +/** + * Writes the ACLs an admin-mode listing mirrored from the source. + * + * Reads the whole listing, not just the documents whose content changed: a + * membership or sharing change moves no content, so restricting this to changed + * documents would let a revoked grant stay readable until somebody happened to + * edit the file. + * + * A listed document the connector could not speak for gets an empty ACL, which + * hides it. That is the safe direction and it is visible — a connector + * declaring {@link ConnectorMeta.mirrorsSourceAcls} is promising an ACL for + * every document it lists, so a missing one is a bug in the connector rather + * than an expected state to paper over. + */ +async function applySourceMirroredAcls(input: { + connectorId: string + connectorConfig: ConnectorConfig + sourceConfig: Record + syncContext: Record + accessToken: string + externalDocs: readonly ExternalDocument[] + /** External ids of every live document the connector owns, listed this run or not. */ + ownedExternalIds: readonly (string | null)[] +}): Promise { + const { connectorId, connectorConfig, externalDocs } = input + + /** + * Whatever the listing could not answer is asked for once, in one batch. Its + * failure is deliberately not caught: an ACL pass that resolved nothing would + * hide the entire corpus, which is far worse than leaving the previous ACLs in + * place until the next run. + */ + const unanswered = unansweredByListing(externalDocs) + const fetched = + unanswered.length > 0 && connectorConfig.getDocumentAcls + ? await connectorConfig.getDocumentAcls( + input.accessToken, + input.sourceConfig, + unanswered, + input.syncContext + ) + : {} + const { acls, unattributed } = mergeMirroredAcls(externalDocs, fetched) + const listed = acls.size + /** + * A document this run did not list has no ACL this run can vouch for, so it + * is hidden rather than left under the last one it was given. Deletion + * reconciliation decides separately, and later, whether it is gone; a held + * reconciliation keeps the row, but never keeps it readable. + */ + const unlisted = hideUnlistedDocuments(acls, input.ownedExternalIds) + + const written = await persistDocumentAcls(connectorId, acls) + logger.info('Mirrored source permissions onto connector documents', { + connectorId, + listed, + ...written, + ...(unlisted > 0 ? { unlisted } : {}), + ...(unattributed > 0 ? { unattributed } : {}), + }) + if (unattributed > 0) { + logger.error('Connector listed documents without an ACL; they are readable by nobody', { + connectorId, + unattributed, + }) + } +} /** Whether an automatic connector sync may begin from this persisted state. */ export function isConnectorRunnableStatus(status: string): boolean { @@ -270,7 +359,7 @@ export async function completeSuccessfulSync( reconciliationHoldNotice, result.docsFailed === 0 ), - /** Restored above, under this same lock. */ + /** Restored above under this same lock, or hidden by the admin pass before the ACLs it wrote. */ accessRewritePending: false, }) .where(stillHoldsSyncLock(connectorId, syncLogId)) @@ -497,50 +586,34 @@ export function buildSyncSuccessUpdate( } /** - * Resolves an access token for a connector based on its auth mode. - * OAuth connectors refresh via the credential system; API key connectors - * decrypt the key stored in the dedicated `encryptedApiKey` column. - * - * `userId` must be the user who owns the credential's OAuth account — not the - * knowledge base owner. Workspace-scoped credentials are routinely authorized by - * a different member, and token reads are scoped to `account.userId`. + * Resolves the token a connector syncs with, failing loudly where the shared + * resolver reports "no token" — a sync has no reconnect prompt to fall back to. */ async function resolveAccessToken( connector: { credentialId: string | null; encryptedApiKey: string | null }, connectorConfig: { auth: ConnectorAuthConfig }, - userId: string -): Promise { - if (connectorConfig.auth.mode === 'apiKey') { - if (!connector.encryptedApiKey) { - if (connectorConfig.auth.optional) { - return '' - } - throw new Error('API key connector is missing encrypted API key') - } - const { decrypted } = await decryptApiKey(connector.encryptedApiKey) - return decrypted - } - - if (!connector.credentialId) { - throw new Error('OAuth connector is missing credential ID') - } - + userId: string, + sourceConfig: Record +): Promise { const requestId = `sync-${connector.credentialId}` - const token = await refreshAccessTokenIfNeeded(connector.credentialId, userId, requestId) + const resolved = await resolveConnectorAccessToken({ + auth: connectorConfig.auth, + connector, + userId, + requestId, + sourceConfig, + }) - if (!token) { - logger.error(`[${requestId}] refreshAccessTokenIfNeeded returned null`, { + if (!resolved) { + logger.error(`[${requestId}] Connector credential resolved no access token`, { credentialId: connector.credentialId, userId, authMode: connectorConfig.auth.mode, - authProvider: connectorConfig.auth.provider, }) - throw new Error( - `Failed to obtain access token for credential ${connector.credentialId} (provider: ${connectorConfig.auth.provider})` - ) + throw new Error(`Failed to obtain access token for credential ${connector.credentialId}`) } - return token + return resolved } /** @@ -603,8 +676,8 @@ export async function executeSync( * lease is mutually exclusive with this one. Refused before any write so a * stale queue entry can never run a workspace-wide crawl over it. */ - if (connectorBeforeLock.accessMode !== 'workspace') { - logger.info('Skipping sync: connector does not sync as the workspace', { + if (!isContentEngineAccessMode(connectorBeforeLock.accessMode)) { + logger.info('Skipping sync: connector is not driven by the content engine', { connectorId, accessMode: connectorBeforeLock.accessMode, }) @@ -683,7 +756,7 @@ export async function executeSync( .set(buildSyncLockAcquisition(syncLogId, new Date())) .where( and( - eq(knowledgeConnector.accessMode, 'workspace'), + inArray(knowledgeConnector.accessMode, CONTENT_ENGINE_ACCESS_MODES), eq(knowledgeConnector.id, connectorId), inArray(knowledgeConnector.status, LOCKABLE_CONNECTOR_STATUSES), /** @@ -751,6 +824,11 @@ export async function executeSync( * and conflicts instead of letting this worker process stale configuration. */ const connector = lockResult[0] + /** The lock CAS only takes a content-engine row; this is the type's word for the same fact. */ + if (!isContentEngineAccessMode(connector.accessMode)) { + throw new Error(`Connector ${connectorId} left the content engine's modes while locked`) + } + const mirrored = mirrorsSourceAcls(connector.accessMode) const sourceConfig = connector.sourceConfig as Record const syncStartedAt = new Date() const lease = createContentSyncLease(connectorId, syncLogId) @@ -769,32 +847,48 @@ export async function executeSync( * resolves no token at all. Resolved once here rather than inside * `resolveAccessToken` so per-page refreshes don't repeat the lookup. */ - let credentialUserId = userId - if (connectorConfig.auth.mode === 'oauth' && connector.credentialId) { - const identity = await resolveCredentialTokenIdentity( - connector.credentialId, - kbOwner.workspaceId + const credentialUserId = await resolveConnectorTokenUserId({ + credentialId: connector.credentialId, + workspaceId: kbOwner.workspaceId, + fallbackUserId: userId, + }) + if (!credentialUserId) { + throw new Error( + `Credential ${connector.credentialId} is not usable from workspace ${kbOwner.workspaceId} — reconnect the credential` ) - if (!identity) { - throw new Error( - `Credential ${connector.credentialId} is not usable from workspace ${kbOwner.workspaceId} — reconnect the credential` - ) - } - // Service accounts mint their own token and ignore the acting user. - if (identity.kind === 'oauth') { - credentialUserId = identity.userId - } } - let accessToken = await resolveAccessToken(connector, connectorConfig, credentialUserId) + let credentialToken = await resolveAccessToken( + connector, + connectorConfig, + credentialUserId, + sourceConfig + ) /** Re-resolves the token for every OAuth call after the first, so a long run outlives a short-lived token. */ const refreshOAuthToken = async (): Promise => { if (connectorConfig.auth.mode === 'oauth') { - accessToken = await resolveAccessToken(connector, connectorConfig, credentialUserId) + credentialToken = await resolveAccessToken( + connector, + connectorConfig, + credentialUserId, + sourceConfig + ) } } - const syncContext: Record = { syncRunId: generateId() } + /** + * A credential that already knows its cloud id seeds the same `syncContext` + * slot the connector would otherwise memoise it into. Confluence discovers + * it by calling `accessible-resources` with a bearer token; an Atlassian + * service account holds an API token that cannot make that call, so for it + * the seed is the only source. Connectors need no service-account branch. + */ + const syncContext: Record = { + syncRunId: generateId(), + ...syncContextForToken(credentialToken), + /** Tells a connector to carry permissions with its listing; without it, none are read. */ + ...(mirrored ? { mirrorsSourceAcls: true } : {}), + } // Shared cutoff for both the tombstone-retry bound below and the stuck-document // retry near the end of this sync — same RETRY_WINDOW_DAYS window, one computation. @@ -839,14 +933,16 @@ export async function executeSync( * be unchanged itself yet transclude a page that changed), and an incremental * listing would omit those unchanged containers, so they'd never be re-fetched. */ - const isIncremental = shouldRunIncrementalSync( - connectorConfig.supportsIncrementalSync, - connector.syncMode, - options?.fullSync, - options?.rehydrate, - hasTombstonedDocs, - connector.lastSyncAt - ) + const isIncremental = + !mirrored && + shouldRunIncrementalSync( + connectorConfig.supportsIncrementalSync, + connector.syncMode, + options?.fullSync, + options?.rehydrate, + hasTombstonedDocs, + connector.lastSyncAt + ) const lastSyncAt = isIncremental && connector.lastSyncAt ? new Date(connector.lastSyncAt) : undefined @@ -863,6 +959,41 @@ export async function executeSync( (options?.rehydrate || options?.fullSync) && connectorConfig.rehydrateOnFullSync ) + /** Resolved once the listing is done; see the mirrored branch after the content pass. */ + let directoryRefreshed: Promise = Promise.resolve() + if (mirrored) { + /** + * A switch into this mode hides every document before it flips, and one + * whose rewrite outgrew its request budget leaves the rest for the next + * run. It has to be finished *before this run lists anything*: the + * documents it did not reach are still readable by the whole workspace, + * and the completion write below clears the flag on the strength of this + * pass having left none under the mode the connector came from. The + * workspace-mode equivalent runs at completion instead, because restoring + * is safe to do last; hiding is not. + */ + if (connector.accessRewritePending) { + await rewriteConnectorAcls(connectorId, EMPTY_ACL, { + beforeBatch: lease.beatIfDue, + lease, + }) + } + /** + * Started before the listing and awaited before the ACLs are written: a + * group grant this crawl writes must never point at membership nobody + * has read, and the scheduler's refresh is a cadence, not a guarantee. + * The walk overlaps the listing rather than delaying it; it never + * throws, so nothing here is left unobserved. + */ + directoryRefreshed = refreshMirroredDirectory({ + workspaceId: kbOwner.workspaceId, + connectorConfig, + sourceConfig, + syncContext, + accessToken: credentialToken.accessToken, + }) + } + const listing = await runListingPass({ connectorId, connectorConfig, @@ -872,7 +1003,7 @@ export async function executeSync( beforePage: lease.beatIfDue, getAccessToken: async (pageNum) => { if (pageNum > 0) await refreshOAuthToken() - return accessToken + return credentialToken.accessToken }, }) const externalDocs = listing.documents @@ -887,10 +1018,15 @@ export async function executeSync( */ syncContext.listingCapped = true syncContext.listingTruncated = true - logger.warn('Pagination ended before source exhaustion; skipping deletion reconciliation', { - connectorId, - docsSoFar: externalDocs.length, - }) + logger.warn( + mirrored + ? 'Pagination ended before source exhaustion; skipping deletion reconciliation and hiding the unlisted rest of the corpus' + : 'Pagination ended before source exhaustion; skipping deletion reconciliation', + { + connectorId, + docsSoFar: externalDocs.length, + } + ) } logger.info(`Fetched ${externalDocs.length} documents from ${connectorConfig.name}`, { @@ -915,12 +1051,35 @@ export async function executeSync( hydration: { beforeHydration: refreshOAuthToken, getDocument: (externalId) => - connectorConfig.getDocument(accessToken, sourceConfig, externalId, syncContext), + connectorConfig.getDocument( + credentialToken.accessToken, + sourceConfig, + externalId, + syncContext + ), }, lease, - documentAccess: 'workspace', + documentAccess: connector.accessMode, }) + /** + * After the content pass, so a document inserted by this run — born hidden + * — is present to be made readable, and before reconciliation, so a + * revoked grant lands even on a run that removes nothing. + */ + if (mirrored) { + await directoryRefreshed + await applySourceMirroredAcls({ + connectorId, + connectorConfig, + sourceConfig, + syncContext, + accessToken: credentialToken.accessToken, + externalDocs, + ownedExternalIds: corpus.existingDocs.map((doc) => doc.externalId), + }) + } + const reconciliationHoldNotice = await reconcileDeletions({ connectorId, connector, diff --git a/apps/sim/lib/knowledge/connectors/sync-lock.ts b/apps/sim/lib/knowledge/connectors/sync-lock.ts index 4cd4bb13ef6..d083d5a4b50 100644 --- a/apps/sim/lib/knowledge/connectors/sync-lock.ts +++ b/apps/sim/lib/knowledge/connectors/sync-lock.ts @@ -66,6 +66,9 @@ export function holdsSyncLockToken(connectorId: string, syncLockToken: string) { ) } +/** Connector statuses a scheduler may start an automatic run from. */ +export const RUNNABLE_CONNECTOR_STATUSES = ['active', 'error'] as const + /** * The statuses a run may take the lock from. * diff --git a/apps/sim/lib/knowledge/connectors/sync-persistence.test.ts b/apps/sim/lib/knowledge/connectors/sync-persistence.test.ts new file mode 100644 index 00000000000..e2359a7d329 --- /dev/null +++ b/apps/sim/lib/knowledge/connectors/sync-persistence.test.ts @@ -0,0 +1,153 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/knowledge/documents/service', () => ({ hardDeleteDocuments: vi.fn() })) +vi.mock('@/lib/uploads', () => ({ StorageService: {} })) +vi.mock('@/lib/uploads/core/storage-service', () => ({ deleteFile: vi.fn() })) +vi.mock('@/lib/uploads/server/metadata', () => ({ deleteFileMetadata: vi.fn() })) +vi.mock('@/connectors/registry.server', () => ({ CONNECTOR_REGISTRY: {} })) + +import { MAX_ACL_TOKENS } from '@/lib/knowledge/access/tokens' +import { persistDocumentAcls } from '@/lib/knowledge/connectors/sync-persistence' + +const CONNECTOR = 'connector-1' + +/** Each `update(...).where(...)` chain ends in `returning()`; one row per changed document. */ +function queueUpdatedCounts(...counts: number[]) { + for (const count of counts) { + dbChainMockFns.returning.mockResolvedValueOnce( + Array.from({ length: count }, (_unused, index) => ({ id: `doc-${index}` })) + ) + } +} + +describe('persistDocumentAcls', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + /** + * The rule this function exists to enforce: an ACL change must not look like + * a content change. `processingStatus: 'pending'` is the sole trigger of + * re-embedding, so assigning anything but `acl` here would re-embed the whole + * corpus every time somebody joined a group. + */ + it('assigns the ACL and nothing else, so no document is re-embedded', async () => { + queueUpdatedCounts(1) + + await persistDocumentAcls(CONNECTOR, new Map([['file-1', ['u:alice@corp.com']]])) + + expect(dbChainMockFns.update).toHaveBeenCalledWith(schemaMock.document) + expect(dbChainMockFns.set).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ acl: ['u:alice@corp.com'] }) + }) + + it('reports how many documents actually changed', async () => { + queueUpdatedCounts(2) + + await expect( + persistDocumentAcls( + CONNECTOR, + new Map([ + ['file-1', ['u:alice@corp.com']], + ['file-2', ['u:alice@corp.com']], + ]) + ) + ).resolves.toEqual({ updated: 2, rejected: 0 }) + }) + + /** + * Files under one folder overwhelmingly share an ACL, so grouping is what + * keeps a crawl of thousands to a handful of statements. + */ + it('writes one statement per distinct ACL, not per document', async () => { + queueUpdatedCounts(2, 1) + + await persistDocumentAcls( + CONNECTOR, + new Map([ + ['file-1', ['u:alice@corp.com']], + ['file-2', ['u:alice@corp.com']], + ['file-3', ['u:bob@corp.com']], + ]) + ) + + expect(dbChainMockFns.set).toHaveBeenCalledTimes(2) + expect(dbChainMockFns.set).toHaveBeenNthCalledWith(1, { acl: ['u:alice@corp.com'] }) + expect(dbChainMockFns.set).toHaveBeenNthCalledWith(2, { acl: ['u:bob@corp.com'] }) + }) + + it('groups ACLs that differ only in order or duplication', async () => { + queueUpdatedCounts(2) + + await persistDocumentAcls( + CONNECTOR, + new Map([ + ['file-1', ['u:bob@corp.com', 'u:alice@corp.com']], + ['file-2', ['u:alice@corp.com', 'u:bob@corp.com', 'u:alice@corp.com']], + ]) + ) + + expect(dbChainMockFns.set).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ + acl: ['u:alice@corp.com', 'u:bob@corp.com'], + }) + }) + + describe('an ACL we cannot store', () => { + it('hides a document whose ACL carries a malformed token', async () => { + queueUpdatedCounts(1) + + await expect( + persistDocumentAcls(CONNECTOR, new Map([['file-1', ['u:NOT-FOLDED@corp.com']]])) + ).resolves.toEqual({ updated: 1, rejected: 1 }) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ acl: [] }) + }) + + it('hides a document whose ACL exceeds the ceiling', async () => { + queueUpdatedCounts(1) + const huge = Array.from({ length: MAX_ACL_TOKENS + 1 }, (_u, i) => `u:p${i}@corp.com`) + + await expect(persistDocumentAcls(CONNECTOR, new Map([['file-1', huge]]))).resolves.toEqual({ + updated: 1, + rejected: 1, + }) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ acl: [] }) + }) + + it('stores an ACL exactly at the ceiling', async () => { + queueUpdatedCounts(1) + const atLimit = Array.from({ length: MAX_ACL_TOKENS }, (_u, i) => `u:p${i}@corp.com`) + + await expect(persistDocumentAcls(CONNECTOR, new Map([['file-1', atLimit]]))).resolves.toEqual( + { updated: 1, rejected: 0 } + ) + }) + + it('still writes the documents whose ACLs are fine', async () => { + queueUpdatedCounts(1, 1) + + await expect( + persistDocumentAcls( + CONNECTOR, + new Map([ + ['file-1', ['u:MIXED@corp.com']], + ['file-2', ['u:alice@corp.com']], + ]) + ) + ).resolves.toEqual({ updated: 2, rejected: 1 }) + }) + }) + + it('does nothing when there is nothing to write', async () => { + await expect(persistDocumentAcls(CONNECTOR, new Map())).resolves.toEqual({ + updated: 0, + rejected: 0, + }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/knowledge/connectors/sync-persistence.ts b/apps/sim/lib/knowledge/connectors/sync-persistence.ts index 95106d2786e..2c322ff01ff 100644 --- a/apps/sim/lib/knowledge/connectors/sync-persistence.ts +++ b/apps/sim/lib/knowledge/connectors/sync-persistence.ts @@ -2,12 +2,19 @@ import { db } from '@sim/db' import { document, embedding, knowledgeBase, knowledgeConnector } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { chunkArray } from '@sim/utils/helpers' import { generateId } from '@sim/utils/id' -import { and, eq, exists, isNull, sql } from 'drizzle-orm' +import { and, eq, exists, inArray, isNull, sql } from 'drizzle-orm' import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' import type { DbOrTx } from '@/lib/db/types' import { textArrayLiteral } from '@/lib/knowledge/access/predicate' -import { EMPTY_ACL, WORKSPACE_ACL } from '@/lib/knowledge/access/tokens' +import { + EMPTY_ACL, + MAX_ACL_TOKENS, + validateAcl, + WORKSPACE_ACL, +} from '@/lib/knowledge/access/tokens' +import { aclIsDerived, type ConnectorAccessMode } from '@/lib/knowledge/connectors/access-modes' import { resolveSourceModifiedAt } from '@/lib/knowledge/connectors/source-modified-at' import { assertSyncLeaseHeldInTx, type SyncWriteLease } from '@/lib/knowledge/connectors/sync-lock' import type { DocumentData } from '@/lib/knowledge/documents/service' @@ -21,21 +28,12 @@ import type { DocumentTags, ExternalDocument } from '@/connectors/types' const logger = createLogger('ConnectorSyncPersistence') -/** - * Who may read a document the sync writes. A workspace-mode connector's - * documents are visible to the whole workspace on insert and on every update. - * A members-mode connector's documents are born hidden and only the member - * engine's ACL materialisation, which knows who observed them, makes them - * visible; an update never touches the ACL. - */ -export type SyncDocumentAccess = 'workspace' | 'members' - -function insertedDocumentAcl(access: SyncDocumentAccess): string[] { - return [...(access === 'members' ? EMPTY_ACL : WORKSPACE_ACL)] +function insertedDocumentAcl(access: ConnectorAccessMode): string[] { + return [...(aclIsDerived(access) ? EMPTY_ACL : WORKSPACE_ACL)] } -function updatedDocumentAcl(access: SyncDocumentAccess): { acl?: string[] } { - return access === 'members' ? {} : { acl: [...WORKSPACE_ACL] } +function updatedDocumentAcl(access: ConnectorAccessMode): { acl?: string[] } { + return aclIsDerived(access) ? {} : { acl: [...WORKSPACE_ACL] } } /** @@ -73,6 +71,88 @@ export async function restoreWorkspaceDocumentAcls( return restored.length } +/** + * Documents whose ACL is rewritten per statement. Documents are grouped by + * identical ACL first — files under one folder overwhelmingly share theirs — so + * a crawl of thousands usually resolves to a handful of statements. + */ +const ACL_WRITE_BATCH_SIZE = 500 + +export interface DocumentAclWriteResult { + /** Documents whose stored ACL actually changed. */ + updated: number + /** Documents whose ACL the source could not express; stored as readable by nobody. */ + rejected: number +} + +/** + * Writes the ACLs an admin-mode crawl mirrored from the source, and nothing + * else. + * + * This deliberately does not go through the document update path. That path + * sets `processingStatus: 'pending'`, which is the sole trigger of + * re-embedding — and a permission change with no content change is the entire + * point of mirroring ACLs, so routing it there would re-embed a corpus every + * time somebody joined a group. Only `acl` is assigned here; `contentHash`, + * `processingStatus`, `chunkCount` and the embedding rows are untouched. + * + * `IS DISTINCT FROM` keeps a re-run that changes nothing from writing anything, + * so the pass is cheap to run often — which is what lets permissions sync on a + * faster clock than content. + * + * An ACL the source expressed but we cannot store — malformed, or past + * {@link MAX_ACL_TOKENS} — is stored as readable by nobody rather than skipped: + * leaving the previous ACL in place would keep serving a document under + * permissions we just failed to verify. + */ +export async function persistDocumentAcls( + connectorId: string, + acls: ReadonlyMap, + executor: DbOrTx = db +): Promise { + const byAcl = new Map() + let rejected = 0 + + for (const [externalId, tokens] of acls) { + const validation = validateAcl(tokens) + if (!validation.valid) { + rejected += 1 + logger.error('Storing a connector document as readable by nobody: unusable ACL', { + connectorId, + externalId, + reason: validation.reason, + ...(validation.sample ? { sample: validation.sample } : {}), + tokenCount: tokens.length, + }) + } + const acl = validation.valid ? validation.acl : [...EMPTY_ACL] + const key = acl.join('\n') + const group = byAcl.get(key) + if (group) group.externalIds.push(externalId) + else byAcl.set(key, { acl, externalIds: [externalId] }) + } + + let updated = 0 + for (const { acl, externalIds } of byAcl.values()) { + for (const batch of chunkArray(externalIds, ACL_WRITE_BATCH_SIZE)) { + const rows = await executor + .update(document) + .set({ acl }) + .where( + and( + eq(document.connectorId, connectorId), + inArray(document.externalId, batch), + sql`${document.acl} IS DISTINCT FROM ${textArrayLiteral(acl)}` + ) + ) + .returning({ id: document.id }) + updated += rows.length + } + } + + return { updated, rejected } +} + const MAX_SAFE_TITLE_LENGTH = 200 /** Sanitizes a document title for use in S3 storage keys. */ @@ -201,7 +281,7 @@ function buildSkippedDocumentRow( connectorType: string, extDoc: ExternalDocument, sourceConfig: Record | undefined, - access: SyncDocumentAccess + access: ConnectorAccessMode ) { const reason = extDoc.skippedReason ?? 'Document was skipped during sync' const tagValues = extDoc.metadata @@ -263,7 +343,7 @@ export async function persistSkippedDocuments( extDoc: ExternalDocument }>, sourceConfig: Record | undefined, - access: SyncDocumentAccess, + access: ConnectorAccessMode, lease: SyncWriteLease ): Promise { if (skipOps.length === 0) { @@ -426,7 +506,7 @@ export async function addDocument( extDoc: ExternalDocument, kbOwner: KnowledgeBaseOwner, sourceConfig: Record | undefined, - access: SyncDocumentAccess, + access: ConnectorAccessMode, lease: SyncWriteLease ): Promise { const documentId = generateId() @@ -526,7 +606,7 @@ export async function updateDocument( extDoc: ExternalDocument, kbOwner: KnowledgeBaseOwner, sourceConfig: Record | undefined, - access: SyncDocumentAccess, + access: ConnectorAccessMode, lease: SyncWriteLease ): Promise { const existingRows = await db diff --git a/apps/sim/lib/knowledge/connectors/sync-primitives.ts b/apps/sim/lib/knowledge/connectors/sync-primitives.ts index 6e7c6b3a052..bfbdf5c5dd9 100644 --- a/apps/sim/lib/knowledge/connectors/sync-primitives.ts +++ b/apps/sim/lib/knowledge/connectors/sync-primitives.ts @@ -12,6 +12,7 @@ import { generateId } from '@sim/utils/id' import { and, asc, desc, eq, gt, inArray, isNotNull, isNull, lt, ne, or, sql } from 'drizzle-orm' import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' import { env, envNumber } from '@/lib/core/config/env' +import type { ConnectorAccessMode } from '@/lib/knowledge/connectors/access-modes' import { SyncLockLostException, type SyncRunLease } from '@/lib/knowledge/connectors/sync-lock' import { addDocument, @@ -19,7 +20,6 @@ import { type PersistedDocument, persistSkippedDocuments, persistSkippedRetryHashes, - type SyncDocumentAccess, updateDocument, } from '@/lib/knowledge/connectors/sync-persistence' import { DOCUMENT_PROCESSING_STALE_THRESHOLD_MS } from '@/lib/knowledge/documents/processing-timeouts.server' @@ -1508,7 +1508,7 @@ export interface ProcessDocOpsInput { */ onBatchPersisted?: (persisted: readonly PersistedDocument[]) => Promise /** Who may read the documents this pass writes. */ - documentAccess: SyncDocumentAccess + documentAccess: ConnectorAccessMode } /** diff --git a/apps/sim/lib/knowledge/orchestration/connector-access.test.ts b/apps/sim/lib/knowledge/orchestration/connector-access.test.ts index 58eabc3ae2e..8c8a9e6c30c 100644 --- a/apps/sim/lib/knowledge/orchestration/connector-access.test.ts +++ b/apps/sim/lib/knowledge/orchestration/connector-access.test.ts @@ -449,6 +449,69 @@ describe('performUpdateKnowledgeConnectorAccess', () => { expect(mocks.dispatchMemberSync).not.toHaveBeenCalled() }) + /** + * The bug this pins: administrator mode hides on entry, and a flip that + * landed before the rewrite showed every workspace-visible document under a + * mode whose reader expects source ACLs. The rewrite must precede the flip, + * exactly as it does for members mode. + */ + it('hides the documents before flipping to administrator mode, then queues a content sync', async () => { + queueTableRows(schemaMock.knowledgeConnector, [WORKSPACE_CONNECTOR]) + dbChainMockFns.returning + .mockResolvedValueOnce([{ ...WORKSPACE_CONNECTOR, status: 'syncing', syncLockToken: 's-1' }]) + .mockResolvedValueOnce([{ id: 'c-1' }]) + .mockResolvedValueOnce([ + { ...WORKSPACE_CONNECTOR, accessMode: 'admin', credentialId: 'cred-2' }, + ]) + + const outcome = await switchTo({ accessMode: 'admin', credentialId: 'cred-2' }) + + expect(outcome).toMatchObject({ success: true, changed: true }) + expect(mocks.rewriteAcls).toHaveBeenCalledWith( + 'c-1', + [], + expect.objectContaining({ + lease: expect.objectContaining({ stillHeld: expect.any(Function) }), + }) + ) + const flipIndex = dbChainMockFns.set.mock.calls.findIndex( + ([values]) => (values as Record).accessMode !== undefined + ) + const flippedAt = dbChainMockFns.set.mock.invocationCallOrder[flipIndex] + const rewrittenAt = mocks.rewriteAcls.mock.invocationCallOrder[0] + expect(rewrittenAt).toBeLessThan(flippedAt) + expect(setCallWith('accessMode')).toMatchObject({ + accessMode: 'admin', + credentialId: 'cred-2', + accessRewritePending: false, + lastSyncAt: null, + nextSyncAt: expect.any(Date), + }) + expect(mocks.dispatchSync).toHaveBeenCalledWith( + 'c-1', + expect.objectContaining({ requireRunnable: true }) + ) + expect(mocks.dispatchMemberSync).not.toHaveBeenCalled() + }) + + it('flips to administrator mode with the rewrite pending when it outgrows the request budget', async () => { + queueTableRows(schemaMock.knowledgeConnector, [WORKSPACE_CONNECTOR]) + mocks.rewriteAcls.mockResolvedValue(false) + dbChainMockFns.returning + .mockResolvedValueOnce([{ ...WORKSPACE_CONNECTOR, status: 'syncing', syncLockToken: 's-1' }]) + .mockResolvedValueOnce([{ id: 'c-1' }]) + .mockResolvedValueOnce([ + { ...WORKSPACE_CONNECTOR, accessMode: 'admin', credentialId: 'cred-2' }, + ]) + + await switchTo({ accessMode: 'admin', credentialId: 'cred-2' }) + + expect(setCallWith('accessMode')).toMatchObject({ + accessMode: 'admin', + accessRewritePending: true, + }) + }) + it('changes a workspace credential without the lease, drops the watermark, and queues a full sync', async () => { queueTableRows(schemaMock.knowledgeConnector, [ { ...WORKSPACE_CONNECTOR, lastSyncAt: new Date('2026-08-01T00:00:00Z') }, diff --git a/apps/sim/lib/knowledge/orchestration/connector-access.ts b/apps/sim/lib/knowledge/orchestration/connector-access.ts index 5cfbd6773da..1632a646ad9 100644 --- a/apps/sim/lib/knowledge/orchestration/connector-access.ts +++ b/apps/sim/lib/knowledge/orchestration/connector-access.ts @@ -10,6 +10,11 @@ import { generateRequestId } from '@/lib/core/utils/request' import { loadCredentialGroupCredentialListContext } from '@/lib/credential-groups/credentials' import { requireKnowledgeMemberAccessAvailable } from '@/lib/knowledge/access/availability' import { EMPTY_ACL, WORKSPACE_ACL } from '@/lib/knowledge/access/tokens' +import { + aclIsDerived, + type ConnectorAccessMode, + type ContentEngineAccessMode, +} from '@/lib/knowledge/connectors/access-modes' import { grantKnowledgeConnectorCredentialAccess, revokeKnowledgeConnectorCredentialAccess, @@ -178,12 +183,19 @@ async function releaseSwitchLease( return row ?? null } +/** + * The mode a connector is being moved into, with what that mode needs to run: + * a Credential Group binding for `members`, a stored credential for the modes + * that sync as one. + */ +export type ConnectorAccessTarget = + | { accessMode: 'members'; binding: ResolvedMembersBinding } + | { accessMode: ContentEngineAccessMode; credentialId: string } + export interface PerformUpdateKnowledgeConnectorAccessParams extends KnowledgeOperationContext { knowledgeBase: { id: string; name: string; workspaceId: string } connectorId: string - target: - | { accessMode: 'members'; binding: ResolvedMembersBinding } - | { accessMode: 'workspace'; credentialId: string } + target: ConnectorAccessTarget resolveBillingAttribution: () => Promise } @@ -225,10 +237,10 @@ export async function performUpdateKnowledgeConnectorAccess( const unchanged = target.accessMode === existing.accessMode && - (target.accessMode === 'workspace' - ? target.credentialId === existing.credentialId - : target.binding.credentialGroupId === existing.credentialGroupId && - target.binding.credentialGroupOptionId === existing.credentialGroupOptionId) + (target.accessMode === 'members' + ? target.binding.credentialGroupId === existing.credentialGroupId && + target.binding.credentialGroupOptionId === existing.credentialGroupOptionId + : target.credentialId === existing.credentialId) if (unchanged) { /** * Re-applying the current binding on a connector whose member sync was @@ -267,14 +279,19 @@ export async function performUpdateKnowledgeConnectorAccess( } /** - * Staying in workspace mode with a different credential moves no document's - * visibility, so the lease is not taken. It does change what the source - * shows: the new credential may see a different corpus, and only a full - * listing reconciles that, so the incremental watermark is dropped and a sync - * queued. The write refuses while a sync owns the row, whose terminal write - * would otherwise put the watermark straight back. + * Staying in the same credential-backed mode with a different credential + * moves no document's visibility, so the lease is not taken. It does change + * what the source shows: the new credential may see a different corpus, and + * only a full listing reconciles that, so the incremental watermark is + * dropped and a sync queued. The write refuses while a sync owns the row, + * whose terminal write would otherwise put the watermark straight back. */ - if (target.accessMode === 'workspace' && existing.accessMode === 'workspace') { + /** + * Both credential-backed modes change the same way — swap the credential, + * keep the documents — so they share this fast path; `members` has no + * credential of its own, its members are the credentials. + */ + if (target.accessMode !== 'members' && target.accessMode === existing.accessMode) { const now = new Date() const [updated] = await db .update(knowledgeConnector) @@ -438,11 +455,27 @@ export async function performUpdateKnowledgeConnectorAccess( } /** - * The flip lands first, still under the lease and with the rewrite marked - * pending, so an interruption anywhere after it leaves a workspace-mode - * connector whose next content sync finishes the rewrite; documents are - * hidden until then, never shown under the wrong mode. + * Entering a mode whose ACL is derived (admin) hides every document, and + * does so *before* the flip, as the members path does: an interruption + * leaves the connector in the mode it came from with some documents + * hidden, which that mode's next run restores. Entering workspace mode + * publishes every document, and does so *after* the flip, so no document + * is shown under the mode it is leaving. Either way, documents are hidden + * until the rewrite lands, never shown under the wrong mode, and a rewrite + * that outgrows the request budget is marked pending for the content + * engine to finish before it lists anything. A derived-ACL mode also has + * no listing cap, so the flip clears them. */ + const hidesOnEntry = aclIsDerived(target.accessMode) + const rewriteEntryAcls = () => + rewriteConnectorAcls(connectorId, hidesOnEntry ? EMPTY_ACL : WORKSPACE_ACL, { + deadlineAt: deadlineAt, + lease: { stillHeld: () => switchLeaseHeld(connectorId, switchId) }, + }) + let rewritten = false + if (hidesOnEntry) rewritten = await rewriteEntryAcls() + const { CONNECTOR_META_REGISTRY } = await import('@/connectors/registry') + const connectorMeta = CONNECTOR_META_REGISTRY[existing.connectorType] const flippedAt = new Date() await db.transaction(async (tx) => { await tx @@ -451,11 +484,19 @@ export async function performUpdateKnowledgeConnectorAccess( const [row] = await tx .update(knowledgeConnector) .set({ - accessMode: 'workspace', + accessMode: target.accessMode, credentialId: target.credentialId, credentialGroupId: null, credentialGroupOptionId: null, - accessRewritePending: true, + ...(hidesOnEntry && connectorMeta + ? { + sourceConfig: stripListingCapFields( + connectorMeta, + existing.sourceConfig as Record + ), + } + : {}), + accessRewritePending: !rewritten, /** * The next content sync must list everything and reconcile: the * union of every member's documents may hold documents the @@ -474,10 +515,7 @@ export async function performUpdateKnowledgeConnectorAccess( .returning({ id: knowledgeConnector.id }) if (!row) throw new SwitchLeaseLostError() }) - const rewritten = await rewriteConnectorAcls(connectorId, WORKSPACE_ACL, { - deadlineAt: deadlineAt, - lease: { stillHeld: () => switchLeaseHeld(connectorId, switchId) }, - }) + if (!hidesOnEntry) rewritten = await rewriteEntryAcls() if (existing.credentialGroupId) { await revokeKnowledgeConnectorCredentialAccess( { workspaceId: kb.workspaceId, credentialGroupId: existing.credentialGroupId, connectorId }, @@ -493,7 +531,7 @@ export async function performUpdateKnowledgeConnectorAccess( accessRewritePending: !rewritten, }) if (!updated) throw new SwitchLeaseLostError() - logger.info(`[${requestId}] Switched connector ${connectorId} to workspace mode`, { + logger.info(`[${requestId}] Switched connector ${connectorId} to ${target.accessMode} mode`, { rewritten, }) const { encryptedApiKey: _secret, ...connector } = updated diff --git a/apps/sim/lib/knowledge/orchestration/connectors.test.ts b/apps/sim/lib/knowledge/orchestration/connectors.test.ts index fb0bb804e28..769aaf33b53 100644 --- a/apps/sim/lib/knowledge/orchestration/connectors.test.ts +++ b/apps/sim/lib/knowledge/orchestration/connectors.test.ts @@ -52,6 +52,7 @@ vi.mock('@/lib/knowledge/connectors/member-access', () => ({ grantKnowledgeConnectorCredentialAccess: mockGrant, revokeKnowledgeConnectorCredentialAccess: mockRevoke, findListingCapViolation: vi.fn(() => null), + stripListingCapFields: (_meta: unknown, sourceConfig: Record) => sourceConfig, })) vi.mock('@/lib/knowledge/documents/service', () => ({ deleteDocumentStorageFiles: vi.fn().mockResolvedValue(undefined), @@ -173,7 +174,9 @@ describe('performDeleteKnowledgeConnector', () => { afterAll(resetDbChainMock) it('reports the documents it kept, so the caller cannot claim otherwise', async () => { - dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-1', connectorType: 'notion' }]) + dbChainMockFns.limit.mockResolvedValueOnce([ + { id: 'conn-1', connectorType: 'notion', accessMode: 'workspace' }, + ]) queueTableRows(document, [ { id: 'doc-1', fileUrl: '/a.txt' }, { id: 'doc-2', fileUrl: '/b.txt' }, @@ -198,7 +201,9 @@ describe('performDeleteKnowledgeConnector', () => { }) it('reports the documents it deleted when asked to delete them', async () => { - dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-1', connectorType: 'notion' }]) + dbChainMockFns.limit.mockResolvedValueOnce([ + { id: 'conn-1', connectorType: 'notion', accessMode: 'workspace' }, + ]) queueTableRows(document, [{ id: 'doc-1', fileUrl: '/a.txt' }]) dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'conn-1' }]) @@ -226,7 +231,9 @@ describe('performDeleteKnowledgeConnector', () => { }) it('returns authoritative delete counts without legacy audit or analytics when disabled', async () => { - dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-1', connectorType: 'notion' }]) + dbChainMockFns.limit.mockResolvedValueOnce([ + { id: 'conn-1', connectorType: 'notion', accessMode: 'workspace' }, + ]) queueTableRows(document, [{ id: 'doc-1', fileUrl: '/a.txt' }]) dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'conn-1' }]) @@ -268,7 +275,9 @@ describe('performUpdateKnowledgeConnector', () => { }) it('classifies a sub-hourly interval on an unentitled workspace as forbidden', async () => { - dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-1', connectorType: 'notion' }]) + dbChainMockFns.limit.mockResolvedValueOnce([ + { id: 'conn-1', connectorType: 'notion', accessMode: 'workspace' }, + ]) mockHasWorkspaceLiveSyncAccess.mockResolvedValue(false) const outcome = await performUpdateKnowledgeConnector({ @@ -284,7 +293,9 @@ describe('performUpdateKnowledgeConnector', () => { }) it('leaves a caller-supplied validator to reject a bad source config', async () => { - dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-1', connectorType: 'notion' }]) + dbChainMockFns.limit.mockResolvedValueOnce([ + { id: 'conn-1', connectorType: 'notion', accessMode: 'workspace' }, + ]) const outcome = await performUpdateKnowledgeConnector({ ...ACTOR, @@ -306,7 +317,9 @@ describe('performUpdateKnowledgeConnector', () => { }) it('preserves the failure class the validator chose', async () => { - dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-1', connectorType: 'notion' }]) + dbChainMockFns.limit.mockResolvedValueOnce([ + { id: 'conn-1', connectorType: 'notion', accessMode: 'workspace' }, + ]) // A stale stored credential kept the route's 401; collapsing every // rejection to `validation` had flattened it (and the 409) into a 400. @@ -326,7 +339,9 @@ describe('performUpdateKnowledgeConnector', () => { }) it('clears the failure counters when a paused connector is resumed', async () => { - dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-1', connectorType: 'notion' }]) + dbChainMockFns.limit.mockResolvedValueOnce([ + { id: 'conn-1', connectorType: 'notion', accessMode: 'workspace' }, + ]) dbChainMockFns.returning.mockResolvedValueOnce([ { id: 'conn-1', connectorType: 'notion', status: 'active' }, ]) @@ -392,7 +407,9 @@ describe('performUpdateKnowledgeConnector', () => { }) it('leaves semantic audit to an authorized application caller when requested', async () => { - dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-1', connectorType: 'notion' }]) + dbChainMockFns.limit.mockResolvedValueOnce([ + { id: 'conn-1', connectorType: 'notion', accessMode: 'workspace' }, + ]) dbChainMockFns.returning.mockResolvedValueOnce([ { id: 'conn-1', connectorType: 'notion', status: 'paused' }, ]) diff --git a/apps/sim/lib/knowledge/orchestration/connectors.ts b/apps/sim/lib/knowledge/orchestration/connectors.ts index 8c5596e220f..338340992aa 100644 --- a/apps/sim/lib/knowledge/orchestration/connectors.ts +++ b/apps/sim/lib/knowledge/orchestration/connectors.ts @@ -18,6 +18,11 @@ import { hasWorkspaceLiveSyncAccess } from '@/lib/billing/core/subscription' import { OrchestrationError, type OrchestrationErrorCode } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import type { DbOrTx } from '@/lib/db/types' +import { + aclIsDerived, + type ConnectorAccessMode, + isContentEngineAccessMode, +} from '@/lib/knowledge/connectors/access-modes' import { findListingCapViolation, grantKnowledgeConnectorCredentialAccess, @@ -147,6 +152,11 @@ export interface PerformCreateKnowledgeConnectorParams extends KnowledgeOperatio * connector is granted the option's credentials before its row exists. */ membersBinding?: ConnectorMembersBinding + /** + * How the connector derives document access. `members` is implied by + * `membersBinding`; `admin` has no binding of its own, so it must be named. + */ + accessMode?: ConnectorAccessMode /** * Resolves the payer the sync is billed to. A thunk so a request rejected by * a guard never pays for the lookup, and so the payer is read at the moment @@ -193,6 +203,7 @@ export async function performCreateKnowledgeConnector( sourceConfig, syncIntervalMinutes, membersBinding, + accessMode = 'workspace', resolveBillingAttribution, resolveAccessToken, request, @@ -232,6 +243,8 @@ export async function performCreateKnowledgeConnector( } const capViolation = findListingCapViolation(connectorConfig, sourceConfig) if (capViolation) return fail(capViolation, 'validation') + } else if (!isContentEngineAccessMode(accessMode)) { + return fail(`Unsupported access mode: ${accessMode}`, 'validation') } else if (connectorConfig.auth.mode === 'apiKey') { if (!apiKey && !connectorConfig.auth.optional) { return fail('API key is required', 'validation') @@ -269,7 +282,10 @@ export async function performCreateKnowledgeConnector( resolvedEncryptedApiKey = (await encryptApiKey(apiKey)).encrypted } - let finalSourceConfig: Record = { ...sourceConfig } + /** A derived-ACL mode has no listing cap; see `aclIsDerived`. */ + let finalSourceConfig: Record = aclIsDerived(accessMode) + ? stripListingCapFields(connectorConfig, sourceConfig) + : { ...sourceConfig } const tagSlotMapping: Record = {} let newTagSlots: Record = {} @@ -420,7 +436,12 @@ export async function performCreateKnowledgeConnector( credentialGroupOptionId: membersBinding.credentialGroupOptionId, nextMemberSyncAt: now, } - : {}), + : /** + * An admin-mode connector is a content-engine connector like a + * workspace one; only its documents' ACLs differ, and those are + * written by its own crawl. Nothing else here changes. + */ + { accessMode }), createdAt: now, updatedAt: now, }) @@ -664,23 +685,26 @@ export async function performUpdateKnowledgeConnector( let sourceConfigToStore = updates.sourceConfig if (updates.sourceConfig !== undefined) { - if (existing.accessMode === 'members') { - /** - * A members-mode connector has no credential to validate the source - * with; the next member run does that per member. The listing caps are - * what a save can refuse. - */ + const accessMode = existing.accessMode as ConnectorAccessMode + let nextSourceConfig = updates.sourceConfig + if (aclIsDerived(accessMode)) { + /** A derived-ACL mode has no listing cap; a save may refuse one, never store one. */ const { CONNECTOR_REGISTRY } = await import('@/connectors/registry.server') const connectorConfig = CONNECTOR_REGISTRY[existing.connectorType] - const capViolation = connectorConfig - ? findListingCapViolation(connectorConfig, updates.sourceConfig) - : null - if (capViolation) return fail(capViolation, 'validation') if (connectorConfig) { - sourceConfigToStore = stripListingCapFields(connectorConfig, updates.sourceConfig) + const capViolation = findListingCapViolation(connectorConfig, nextSourceConfig) + if (capViolation) return fail(capViolation, 'validation') + nextSourceConfig = stripListingCapFields(connectorConfig, nextSourceConfig) } - } else if (validateSourceConfig) { - const rejection = await validateSourceConfig(existing, updates.sourceConfig) + } + sourceConfigToStore = nextSourceConfig + /** + * A members-mode connector has no credential to validate the source with; + * the next member run does that per member. Every other mode validates + * with the credential it syncs as. + */ + if (isContentEngineAccessMode(accessMode) && validateSourceConfig) { + const rejection = await validateSourceConfig(existing, nextSourceConfig) if (rejection) { return fail(rejection.message, rejection.errorCode) } @@ -905,13 +929,15 @@ export async function performDeleteKnowledgeConnector( return fail('Connector not found', 'not_found') } /** - * A members-mode document's visibility is its observers; detached from the - * connector it would keep an ACL nothing maintains, or become hidden to - * everyone. Neither is a standalone entry anyone asked for. + * A derived ACL is maintained by the connector's sync — observers in + * members mode, the source's own grants in administrator mode. Detached from + * the connector a document would keep an ACL nothing maintains: a person the + * source un-shares from keeps reading, forever. Not a standalone entry + * anyone asked for. */ - if (existing.accessMode === 'members' && !deleteDocuments) { + if (existing.accessMode !== 'workspace' && !deleteDocuments) { return fail( - 'Documents of a connector that syncs per member cannot be kept; delete them with the connector', + 'Documents of a connector whose access is derived from the source cannot be kept; delete them with the connector', 'conflict' ) } diff --git a/apps/sim/lib/knowledge/service.ts b/apps/sim/lib/knowledge/service.ts index 0688c24755a..4dcbc29a7d9 100644 --- a/apps/sim/lib/knowledge/service.ts +++ b/apps/sim/lib/knowledge/service.ts @@ -30,6 +30,7 @@ import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateRestoreName } from '@/lib/core/utils/restore-name' import { findActiveFolder, resolveRestoredFolderId } from '@/lib/folders/queries' import { isKnowledgeMemberAccessAvailable } from '@/lib/knowledge/access/availability' +import { mirrorsSourceAcls } from '@/lib/knowledge/connectors/access-modes' import type { ChunkingConfig, CreateKnowledgeBaseData, @@ -178,7 +179,9 @@ async function readKnowledgeBaseRows( where: SQL | undefined, orderBy: SQL[], limit?: number -): Promise>> { +): Promise< + Array> +> { const query = db .select({ id: knowledgeBase.id, @@ -221,7 +224,7 @@ async function readKnowledgeBaseRows( async function attachConnectorTypes( knowledgeBases: Array< - Omit + Omit > ): Promise { const kbIds = knowledgeBases.map((kb) => kb.id) @@ -245,11 +248,14 @@ async function attachConnectorTypes( const connectorTypesByKb = new Map() const memberScopedKbIds = new Set() + /** Mirrored ACLs scope documents whether or not the feature is on: off, they read as hidden, never as workspace-visible. */ + const mirroredKbIds = new Set() for (const row of connectorRows) { const types = connectorTypesByKb.get(row.knowledgeBaseId) ?? [] if (!types.includes(row.connectorType)) types.push(row.connectorType) connectorTypesByKb.set(row.knowledgeBaseId, types) if (row.accessMode === 'members') memberScopedKbIds.add(row.knowledgeBaseId) + if (mirrorsSourceAcls(row.accessMode)) mirroredKbIds.add(row.knowledgeBaseId) } /** * A members-mode connector only scopes documents where the feature is on; @@ -270,7 +276,7 @@ async function attachConnectorTypes( return knowledgeBases.map((kb) => ({ ...kb, connectorTypes: connectorTypesByKb.get(kb.id) ?? [], - hasMemberScopedConnector: memberScopedKbIds.has(kb.id), + hasPermissionScopedConnector: memberScopedKbIds.has(kb.id) || mirroredKbIds.has(kb.id), })) } @@ -284,7 +290,7 @@ async function readWorkspaceKnowledgeBaseRows( scope: KnowledgeBaseScope, options?: GetKnowledgeBasesOptions ): Promise<{ - data: Array> + data: Array> nextCursorKeys: CursorKey[] | null }> { const { @@ -348,7 +354,9 @@ export async function getWorkspaceKnowledgeBases( async function readLegacyPersonalKnowledgeBaseRows( userId: string, scope: KnowledgeBaseScope -): Promise>> { +): Promise< + Array> +> { const rows = await readKnowledgeBaseRows( and( knowledgeBaseScopeCondition(scope), @@ -404,7 +412,9 @@ export async function listWorkspaceAndLegacyKnowledgeBases( export async function findActiveKnowledgeBasesByExactName( workspaceId: string, name: string -): Promise>> { +): Promise< + Array> +> { return readKnowledgeBaseRows( and( eq(knowledgeBase.workspaceId, workspaceId), @@ -507,7 +517,7 @@ export async function createAuthorizedKnowledgeBase( folderId, docCount: 0, connectorTypes: [], - hasMemberScopedConnector: false, + hasPermissionScopedConnector: false, } } @@ -1029,7 +1039,7 @@ export async function getKnowledgeBaseById( chunkingConfig: result[0].chunkingConfig as ChunkingConfig, docCount: Number(result[0].docCount), connectorTypes: [], - hasMemberScopedConnector: false, + hasPermissionScopedConnector: false, } } diff --git a/apps/sim/lib/knowledge/types.ts b/apps/sim/lib/knowledge/types.ts index 7a7d22666a4..a62c3f596be 100644 --- a/apps/sim/lib/knowledge/types.ts +++ b/apps/sim/lib/knowledge/types.ts @@ -31,7 +31,7 @@ export interface KnowledgeBaseWithCounts { docCount: number connectorTypes: string[] /** True when a live connector syncs per member, so what a run retrieves depends on who triggers it. */ - hasMemberScopedConnector: boolean + hasPermissionScopedConnector: boolean } export interface CreateKnowledgeBaseData { @@ -123,7 +123,7 @@ export interface KnowledgeBaseData { folderId: string | null docCount?: number connectorTypes?: string[] - hasMemberScopedConnector?: boolean + hasPermissionScopedConnector?: boolean } export interface DocumentData { diff --git a/apps/sim/lib/oauth/credential-service.ts b/apps/sim/lib/oauth/credential-service.ts index 0962cf8cb56..fb5ac8ed7d6 100644 --- a/apps/sim/lib/oauth/credential-service.ts +++ b/apps/sim/lib/oauth/credential-service.ts @@ -235,7 +235,7 @@ export async function getServiceAccountToken( } : { iss: keyData.client_email, - sub: impersonateEmail || '(none)', + hasSubject: Boolean(impersonateEmail), scopes: filteredScopes.join(' '), aud: tokenUri, } diff --git a/apps/sim/lib/oauth/utils.test.ts b/apps/sim/lib/oauth/utils.test.ts index ab0e34a9273..f29943a1fce 100644 --- a/apps/sim/lib/oauth/utils.test.ts +++ b/apps/sim/lib/oauth/utils.test.ts @@ -729,6 +729,16 @@ describe('getMissingRequiredScopes', () => { expect(missing).toEqual(['read', 'write']) }) + it.concurrent( + 'should report nothing missing for a service account, which grants no scopes', + () => { + const credential = { type: 'service_account', scopes: undefined } + const missing = getMissingRequiredScopes(credential, ['read', 'write']) + + expect(missing).toEqual([]) + } + ) + it.concurrent('should return all required scopes when credential has undefined scopes', () => { const missing = getMissingRequiredScopes({ scopes: undefined }, ['read', 'write']) diff --git a/apps/sim/lib/oauth/utils.ts b/apps/sim/lib/oauth/utils.ts index 78068eac613..bdc420d9eac 100644 --- a/apps/sim/lib/oauth/utils.ts +++ b/apps/sim/lib/oauth/utils.ts @@ -755,13 +755,21 @@ const IGNORED_SCOPES = new Set([ * as they are not returned in the token response's scope list even when granted. */ export function getMissingRequiredScopes( - credential: { scopes?: string[] } | undefined, + credential: { scopes?: string[]; type?: string } | undefined, requiredScopes: string[] = [] ): string[] { if (!credential) { return requiredScopes.filter((s) => !IGNORED_SCOPES.has(s)) } + /** + * A service account names its scopes in the JWT it signs for each request, so + * it has no granted-scope list to compare against — `scopes` is always null. + * Measuring it against `requiredScopes` reports every scope missing and + * prompts a reconnect that would grant nothing. + */ + if (credential.type === 'service_account') return [] + const granted = new Set(credential.scopes || []) const missing: string[] = [] diff --git a/apps/sim/lib/workspaces/admin-move.ts b/apps/sim/lib/workspaces/admin-move.ts index 2a662d07d2e..1571faf8af3 100644 --- a/apps/sim/lib/workspaces/admin-move.ts +++ b/apps/sim/lib/workspaces/admin-move.ts @@ -1,6 +1,7 @@ import { AuditAction, AuditResourceType, recordAudit, recordAuditOnce } from '@sim/audit' import { db } from '@sim/db' import { + foldedEmail, invitation, invitationWorkspaceGrant, member, @@ -2270,8 +2271,8 @@ async function getProjectedDestinationPendingSeatCount(params: { .innerJoin(user, eq(user.id, member.userId)) .where( or( - ...incomingInternalEmails.map( - (email) => sql`lower(btrim(${user.email})) = ${normalizeEmail(email)}` + ...incomingInternalEmails.map((email) => + eq(foldedEmail(user.email), normalizeEmail(email)) ) ) ), diff --git a/apps/sim/lib/workspaces/host-context.ts b/apps/sim/lib/workspaces/host-context.ts index 10b4877459c..0742770648d 100644 --- a/apps/sim/lib/workspaces/host-context.ts +++ b/apps/sim/lib/workspaces/host-context.ts @@ -3,7 +3,7 @@ import type { WorkspaceHostContext } from '@/lib/api/contracts/workspaces' import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access' import { resolveDeploymentShape } from '@/lib/core/config/deployment-shape' import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability' -import { isKnowledgeMemberAccessAvailable } from '@/lib/knowledge/access/availability' +import { resolveKnowledgeAccessAvailability } from '@/lib/knowledge/access/availability' import { getOrganizationSettingsAccess } from '@/lib/organizations/settings-access' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' @@ -30,9 +30,9 @@ async function resolveWorkspaceHostContextForViewer( ? getOrganizationSettingsAccess(hostOrganizationId, userId) : Promise.resolve({ role: null, isMember: false, isAdmin: false }), ]) - const [credentialGroupsAvailable, knowledgeMemberAccessAvailable] = await Promise.all([ + const [credentialGroupsAvailable, knowledgeAccess] = await Promise.all([ isCredentialGroupsAvailable({ workspaceId, ownerBilling }), - isKnowledgeMemberAccessAvailable({ workspaceId, ownerBilling }), + resolveKnowledgeAccessAvailability({ workspaceId, ownerBilling }), ]) return { @@ -53,7 +53,8 @@ async function resolveWorkspaceHostContextForViewer( }, features: { credentialGroups: credentialGroupsAvailable, - knowledgeMemberAccess: knowledgeMemberAccessAvailable, + knowledgeMemberAccess: knowledgeAccess.memberScoped, + knowledgeSourceMirroredAccess: knowledgeAccess.sourceMirrored, }, deployment: resolveDeploymentShape(), } diff --git a/docker/crontab b/docker/crontab index 39a2eeccf2d..e8667cb8e1f 100644 --- a/docker/crontab +++ b/docker/crontab @@ -36,6 +36,7 @@ SHELL=/bin/sh # Knowledge base connector syncs */5 * * * * curl -fsS -m 120 -o /dev/null -H "Authorization: Bearer $CRON_SECRET" "$SIM_URL/api/knowledge/connectors/sync" */5 * * * * curl -fsS -m 120 -o /dev/null -H "Authorization: Bearer $CRON_SECRET" "$SIM_URL/api/knowledge/connectors/member-sync" +*/5 * * * * curl -fsS -m 120 -o /dev/null -H "Authorization: Bearer $CRON_SECRET" "$SIM_URL/api/knowledge/connectors/directory-sync" # Workspace event triggers */15 * * * * curl -fsS -m 60 -o /dev/null -H "Authorization: Bearer $CRON_SECRET" "$SIM_URL/api/workspace-events/poll" diff --git a/helm/sim/values.yaml b/helm/sim/values.yaml index 9324fa59c1a..1c5f47ecd3a 100644 --- a/helm/sim/values.yaml +++ b/helm/sim/values.yaml @@ -1483,6 +1483,15 @@ cronjobs: successfulJobsHistoryLimit: 3 failedJobsHistoryLimit: 1 + connectorDirectorySync: + enabled: true + name: connector-directory-sync + schedule: "*/5 * * * *" + path: "/api/knowledge/connectors/directory-sync" + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 1 + runDataDrains: enabled: true name: run-data-drains diff --git a/packages/db/migrations/0321_permission_mirrored_knowledge.sql b/packages/db/migrations/0321_permission_mirrored_knowledge.sql new file mode 100644 index 00000000000..35ad9e854ac --- /dev/null +++ b/packages/db/migrations/0321_permission_mirrored_knowledge.sql @@ -0,0 +1,62 @@ +-- Administrator-mode knowledge connectors: mirrored source permissions. +-- +-- Two new tables hold the external directory groups an administrator crawl mirrors onto document +-- ACLs, and who belongs to them. Groups are scoped by workspace, provider and tenant so two +-- connectors over one directory resolve it once; membership is keyed by case-folded email, because +-- a directory reports addresses and most members of a granted group have no Sim account yet. +-- +-- `user` gains an index on the case-folded address, `lower(btrim(email))`. Every identity binding +-- by email compares that expression — credential-group enrollments, the `u:` document token, and +-- the ambiguity check access resolution runs on every read — so without the index each is a +-- sequential scan of `user`. It is deliberately not UNIQUE: a small number of historical accounts +-- collide once folded, and access resolution refuses to bind an ambiguous address rather than let +-- either read the other's documents. Promoting it to UNIQUE is a follow-up once those are merged. +-- +-- Transaction shape: the new (empty) tables run inside the runner's batch transaction. The +-- embedded COMMIT then ends it so the index on the hot `user` table can build CONCURRENTLY without +-- write-blocking it. A failure after the COMMIT replays this whole file against tables that are +-- already committed, so every statement here is idempotent: IF NOT EXISTS on tables and indexes, +-- pg_constraint lookups around the foreign keys, and the DROP / IF NOT EXISTS pair on the +-- concurrent build. +CREATE TABLE IF NOT EXISTS "knowledge_external_group" ( + "id" text PRIMARY KEY NOT NULL, + "workspace_id" text NOT NULL, + "provider_id" text NOT NULL, + "tenant_id" text NOT NULL, + "external_group_id" text NOT NULL, + "last_synced_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "knowledge_external_group_member" ( + "group_id" text NOT NULL, + "email" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "knowledge_external_group_member_group_id_email_pk" PRIMARY KEY("group_id","email") +); +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'keg_workspace_fk') THEN + ALTER TABLE "knowledge_external_group" ADD CONSTRAINT "keg_workspace_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'kegm_group_fk') THEN + ALTER TABLE "knowledge_external_group_member" ADD CONSTRAINT "kegm_group_fk" FOREIGN KEY ("group_id") REFERENCES "public"."knowledge_external_group"("id") ON DELETE cascade ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "keg_identity_unique" ON "knowledge_external_group" USING btree ("workspace_id","provider_id","tenant_id","external_group_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "keg_workspace_synced_idx" ON "knowledge_external_group" USING btree ("workspace_id","last_synced_at" NULLS FIRST); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "kegm_email_idx" ON "knowledge_external_group_member" USING btree ("email"); +--> statement-breakpoint +COMMIT;--> statement-breakpoint +SET lock_timeout = 0;--> statement-breakpoint +-- migration-safe: replay removes an invalid build left by an earlier attempt; concurrent operations preserve writes. +DROP INDEX CONCURRENTLY IF EXISTS "user_email_lower_idx";--> statement-breakpoint +CREATE INDEX CONCURRENTLY IF NOT EXISTS "user_email_lower_idx" ON "user" USING btree (lower(btrim("email")));--> statement-breakpoint +SET lock_timeout = '5s'; diff --git a/packages/db/migrations/meta/0321_snapshot.json b/packages/db/migrations/meta/0321_snapshot.json new file mode 100644 index 00000000000..e0a1ad49832 --- /dev/null +++ b/packages/db/migrations/meta/0321_snapshot.json @@ -0,0 +1,21849 @@ +{ + "id": "dc200fd3-9292-44da-971d-a357896f27ee", + "prevId": "5a6e3414-7fa3-4206-8e48-73d5f0ded1ce", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_unreconciled_terminal_idx": { + "name": "async_jobs_schedule_unreconciled_terminal_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" IN ('completed', 'failed', 'cancelled') AND COALESCE(\"async_jobs\".\"metadata\" ->> 'scheduleReconciled', 'false') <> 'true'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_work_status": { + "name": "background_work_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "background_work_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_work_status_value", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_work_status_workspace_status_idx": { + "name": "background_work_status_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_workflow_status_idx": { + "name": "background_work_status_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_child_ws_idx": { + "name": "background_work_status_meta_child_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'childWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_other_ws_idx": { + "name": "background_work_status_meta_other_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'otherWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "background_work_status_workspace_id_workspace_id_fk": { + "name": "background_work_status_workspace_id_workspace_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "background_work_status_workflow_id_workflow_id_fk": { + "name": "background_work_status_workflow_id_workflow_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "include_thinking": { + "name": "include_thinking", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_tool_calls": { + "name": "include_tool_calls", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permission_decision": { + "name": "permission_decision", + "type": "copilot_tool_permission_decision", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "permission_decided_at": { + "name": "permission_decided_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "auto_allowed_tools": { + "name": "auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_deleted_partial_idx": { + "name": "copilot_chats_user_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_chats\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_user_created_at_idx": { + "name": "copilot_messages_user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"role\" = 'user' AND \"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unredacted": { + "name": "unredacted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_app_id": { + "name": "authorization_app_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_enrollment_id": { + "name": "credential_group_enrollment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_option_id": { + "name": "credential_group_option_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_oauth_scope_version": { + "name": "managed_oauth_scope_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "provider_subject_id": { + "name": "provider_subject_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_oauth_status": { + "name": "managed_oauth_status", + "type": "managed_oauth_credential_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "granted_scopes": { + "name": "granted_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "provider_metadata": { + "name": "provider_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "encrypted_oauth_token_set": { + "name": "encrypted_oauth_token_set", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_tools": { + "name": "mcp_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "mcp_tools_refreshed_at": { + "name": "mcp_tools_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "granted_at": { + "name": "granted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_idx": { + "name": "credential_group_enrollment_idx", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_mcp_server_idx": { + "name": "credential_mcp_server_idx", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_option_unique": { + "name": "credential_group_option_unique", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credential_group_option_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential\".\"type\" = 'managed_oauth'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_managed_mcp_enrollment_server_unique": { + "name": "credential_managed_mcp_enrollment_server_unique", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential\".\"type\" = 'managed_mcp'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_credential_group_enrollment_id_credential_group_enrollment_id_fk": { + "name": "credential_credential_group_enrollment_id_credential_group_enrollment_id_fk", + "tableFrom": "credential", + "tableTo": "credential_group_enrollment", + "columnsFrom": ["credential_group_enrollment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_mcp_server_id_mcp_servers_id_fk": { + "name": "credential_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "credential", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_managed_oauth_source_check": { + "name": "credential_managed_oauth_source_check", + "value": "(type::text <> 'managed_oauth') OR (\n account_id IS NULL\n AND provider_id IS NOT NULL\n AND authorization_app_id IS NOT NULL\n AND provider_subject_id IS NOT NULL\n AND managed_oauth_status IS NOT NULL\n AND granted_scopes IS NOT NULL\n AND cardinality(granted_scopes) > 0\n AND encrypted_oauth_token_set IS NOT NULL\n AND granted_at IS NOT NULL\n )" + }, + "credential_managed_oauth_group_binding_check": { + "name": "credential_managed_oauth_group_binding_check", + "value": "(type::text <> 'managed_oauth') OR (\n credential_group_enrollment_id IS NOT NULL\n AND credential_group_option_id IS NOT NULL\n AND managed_oauth_scope_version IS NOT NULL\n AND managed_oauth_scope_version > 0\n )" + }, + "credential_managed_mcp_source_check": { + "name": "credential_managed_mcp_source_check", + "value": "(type::text <> 'managed_mcp') OR (\n id LIKE 'mcp-cg-%'\n AND account_id IS NULL\n AND provider_id IS NULL\n AND authorization_app_id IS NULL\n AND credential_group_enrollment_id IS NOT NULL\n AND credential_group_option_id IS NULL\n AND mcp_server_id IS NOT NULL\n AND managed_oauth_status IS NOT NULL\n AND (managed_oauth_status <> 'active' OR (\n encrypted_oauth_token_set IS NOT NULL\n AND mcp_tools IS NOT NULL\n ))\n AND granted_at IS NOT NULL\n AND managed_oauth_scope_version IS NULL\n AND provider_subject_id IS NULL\n AND provider_tenant_id IS NULL\n AND granted_scopes IS NULL\n AND provider_metadata IS NULL\n AND created_by IS NULL\n AND env_key IS NULL\n AND env_owner_user_id IS NULL\n AND encrypted_service_account_key IS NULL\n AND unredacted = false\n )" + }, + "credential_creator_source_check": { + "name": "credential_creator_source_check", + "value": "(type::text = 'managed_mcp') OR created_by IS NOT NULL" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_group": { + "name": "credential_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_id": { + "name": "public_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "encrypted_provider_configuration": { + "name": "encrypted_provider_configuration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "credential_group_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_group_public_id_unique": { + "name": "credential_group_public_id_unique", + "columns": [ + { + "expression": "public_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_workspace_status_idx": { + "name": "credential_group_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_workspace_name_unique": { + "name": "credential_group_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"name\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_group_workspace_id_workspace_id_fk": { + "name": "credential_group_workspace_id_workspace_id_fk", + "tableFrom": "credential_group", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_created_by_user_id_fk": { + "name": "credential_group_created_by_user_id_fk", + "tableFrom": "credential_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_group_enrollment": { + "name": "credential_group_enrollment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_group_id": { + "name": "credential_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "credential_group_enrollment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'invited'" + }, + "invitation_token_hash": { + "name": "invitation_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invitation_expires_at": { + "name": "invitation_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "invited_at": { + "name": "invited_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_delivery_error": { + "name": "last_delivery_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_group_enrollment_group_email_unique": { + "name": "credential_group_enrollment_group_email_unique", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_invitation_token_hash_unique": { + "name": "credential_group_enrollment_invitation_token_hash_unique", + "columns": [ + { + "expression": "invitation_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_group_status_idx": { + "name": "credential_group_enrollment_group_status_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_group_invited_at_id_idx": { + "name": "credential_group_enrollment_group_invited_at_id_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invited_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_group_enrollment_credential_group_id_credential_group_id_fk": { + "name": "credential_group_enrollment_credential_group_id_credential_group_id_fk", + "tableFrom": "credential_group_enrollment", + "tableTo": "credential_group", + "columnsFrom": ["credential_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_enrollment_created_by_user_id_fk": { + "name": "credential_group_enrollment_created_by_user_id_fk", + "tableFrom": "credential_group_enrollment", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_group_enrollment_normalized_email_check": { + "name": "credential_group_enrollment_normalized_email_check", + "value": "\"credential_group_enrollment\".\"email\" = lower(btrim(\"credential_group_enrollment\".\"email\")) AND length(\"credential_group_enrollment\".\"email\") BETWEEN 3 AND 320" + }, + "credential_group_enrollment_invitation_token_hash_length_check": { + "name": "credential_group_enrollment_invitation_token_hash_length_check", + "value": "length(\"credential_group_enrollment\".\"invitation_token_hash\") = 64" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_block": { + "name": "custom_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "trace_child_runs": { + "name": "trace_child_runs", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_block_organization_id_idx": { + "name": "custom_block_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_workflow_id_idx": { + "name": "custom_block_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_organization_type_unique": { + "name": "custom_block_organization_type_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_block_organization_id_organization_id_fk": { + "name": "custom_block_organization_id_organization_id_fk", + "tableFrom": "custom_block", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_workflow_id_workflow_id_fk": { + "name": "custom_block_workflow_id_workflow_id_fk", + "tableFrom": "custom_block", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_created_by_user_id_fk": { + "name": "custom_block_created_by_user_id_fk", + "tableFrom": "custom_block", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_attempts": { + "name": "processing_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_queued_at": { + "name": "processing_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_queue_token": { + "name": "processing_queue_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_deferred_until": { + "name": "processing_deferred_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acl": { + "name": "acl", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{ws}'::text[]" + }, + "source_modified_at": { + "name": "source_modified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_acl_gin_idx": { + "name": "doc_acl_gin_idx", + "columns": [ + { + "expression": "acl", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "array_ops" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_active_kb_token_count_idx": { + "name": "doc_active_kb_token_count_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "token_count", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"user_excluded\" = false AND \"document\".\"archived_at\" IS NULL AND \"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "doc_acl_token_shape_check": { + "name": "doc_acl_token_shape_check", + "value": "array_position(\"document\".\"acl\", NULL) IS NULL AND (cardinality(\"document\".\"acl\") = 0 OR (cardinality(\"document\".\"acl\") = array_length(string_to_array(array_to_string(\"document\".\"acl\", E'\\n'), E'\\n'), 1) AND array_to_string(\"document\".\"acl\", E'\\n') ~ '^((ws|pub|link|u:[^\\nA-Z]+@[^\\nA-Z]+|[gs]:[^\\n:]+:[^\\n:]+:[^\\n]+)(\\n(ws|pub|link|u:[^\\nA-Z]+@[^\\nA-Z]+|[gs]:[^\\n:]+:[^\\n:]+:[^\\n]+))*)$'))" + } + }, + "isRLSEnabled": false + }, + "public.document_secret_provenance": { + "name": "document_secret_provenance", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "document_secret_provenance_document_id_document_id_fk": { + "name": "document_secret_provenance_document_id_document_id_fk", + "tableFrom": "document_secret_provenance", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "document_secret_provenance_status_check": { + "name": "document_secret_provenance_status_check", + "value": "\"document_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.embedding_secret_provenance": { + "name": "embedding_secret_provenance", + "schema": "", + "columns": { + "embedding_id": { + "name": "embedding_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "embedding_secret_provenance_embedding_id_embedding_id_fk": { + "name": "embedding_secret_provenance_embedding_id_embedding_id_fk", + "tableFrom": "embedding_secret_provenance", + "tableTo": "embedding", + "columnsFrom": ["embedding_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_secret_provenance_status_check": { + "name": "embedding_secret_provenance_status_check", + "value": "\"embedding_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_references_workflow_id_idx": { + "name": "execution_large_value_references_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": ["key", "execution_id", "source"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_workflow_id_idx": { + "name": "execution_large_values_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.folder": { + "name": "folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "folder_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "folder_user_idx": { + "name": "folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_idx": { + "name": "folder_workspace_resource_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_parent_sort_idx": { + "name": "folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_deleted_at_idx": { + "name": "folder_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_deleted_partial_idx": { + "name": "folder_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"folder\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_name_active_unique": { + "name": "folder_workspace_resource_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"folder\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "folder_user_id_user_id_fk": { + "name": "folder_user_id_user_id_fk", + "tableFrom": "folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_workspace_id_workspace_id_fk": { + "name": "folder_workspace_id_workspace_id_fk", + "tableFrom": "folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_parent_id_folder_id_fk": { + "name": "folder_parent_id_folder_id_fk", + "tableFrom": "folder", + "tableTo": "folder", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "invitation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "membership_intent": { + "name": "membership_intent", + "type": "invitation_membership_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_status_idx": { + "name": "invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_pending_email_org_unique": { + "name": "invitation_pending_email_org_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"invitation\".\"status\" = 'pending' AND \"invitation\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitation_token_unique": { + "name": "invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_workspace_grant": { + "name": "invitation_workspace_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_workspace_grant_unique": { + "name": "invitation_workspace_grant_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_workspace_grant_workspace_id_idx": { + "name": "invitation_workspace_grant_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_grant_invitation_id_invitation_id_fk": { + "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "invitation", + "columnsFrom": ["invitation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_workspace_grant_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_ended_at_id_idx": { + "name": "job_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workflow_schedule", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_folder_id_idx": { + "name": "kb_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_folder_id_folder_id_fk": { + "name": "knowledge_base_folder_id_folder_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "access_mode": { + "name": "access_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workspace'" + }, + "credential_group_id": { + "name": "credential_group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_option_id": { + "name": "credential_group_option_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_sync_status": { + "name": "member_sync_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "member_sync_lock_token": { + "name": "member_sync_lock_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_sync_lock_lease_at": { + "name": "member_sync_lock_lease_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_member_sync_at": { + "name": "next_member_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_member_sync_at": { + "name": "last_member_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_member_sync_error": { + "name": "last_member_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_sync_consecutive_failures": { + "name": "member_sync_consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "access_rewrite_pending": { + "name": "access_rewrite_pending", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sync_lock_token": { + "name": "sync_lock_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_lock_lease_at": { + "name": "sync_lock_lease_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_member_sync_due_idx": { + "name": "kc_member_sync_due_idx", + "columns": [ + { + "expression": "member_sync_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_member_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"access_mode\" = 'members' AND \"knowledge_connector\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_connector_credential_group_id_credential_group_id_fk": { + "name": "knowledge_connector_credential_group_id_credential_group_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "credential_group", + "columnsFrom": ["credential_group_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kc_access_mode_check": { + "name": "kc_access_mode_check", + "value": "\"knowledge_connector\".\"access_mode\" IN ('workspace', 'members', 'admin')" + }, + "kc_member_sync_status_check": { + "name": "kc_member_sync_status_check", + "value": "\"knowledge_connector\".\"member_sync_status\" IN ('idle', 'pending', 'running', 'error', 'disabled')" + }, + "kc_sync_lock_exclusive_check": { + "name": "kc_sync_lock_exclusive_check", + "value": "NOT (\"knowledge_connector\".\"sync_lock_token\" IS NOT NULL AND \"knowledge_connector\".\"member_sync_lock_token\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_connector_member": { + "name": "knowledge_connector_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_token": { + "name": "subject_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_started_at": { + "name": "last_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_complete_listing_at": { + "name": "last_complete_listing_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_listed_count": { + "name": "last_listed_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_synced_through": { + "name": "member_synced_through", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "change_cursor": { + "name": "change_cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kcm_connector_credential_unique": { + "name": "kcm_connector_credential_unique", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcm_connector_queue_idx": { + "name": "kcm_connector_queue_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "first" + }, + { + "expression": "last_started_at", + "isExpression": false, + "asc": true, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcm_credential_idx": { + "name": "kcm_credential_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_member_workspace_id_workspace_id_fk": { + "name": "knowledge_connector_member_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_connector_member", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_connector_member_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_member_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_member", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_connector_member_credential_id_credential_id_fk": { + "name": "knowledge_connector_member_credential_id_credential_id_fk", + "tableFrom": "knowledge_connector_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kcm_status_check": { + "name": "kcm_status_check", + "value": "\"knowledge_connector_member\".\"status\" IN ('active', 'suspended', 'disabled')" + }, + "kcm_subject_token_shape_check": { + "name": "kcm_subject_token_shape_check", + "value": "\"knowledge_connector_member\".\"subject_token\" ~ '^s:[^:]+:[^:]+:.+$'" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_connector_member_sync_log": { + "name": "knowledge_connector_member_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "members_claimed": { + "name": "members_claimed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "members_completed": { + "name": "members_completed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "members_incomplete": { + "name": "members_incomplete", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "members_failed": { + "name": "members_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_listed": { + "name": "docs_listed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_hydrated_once": { + "name": "docs_hydrated_once", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "observations_added": { + "name": "observations_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "observations_removed": { + "name": "observations_removed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_tombstoned": { + "name": "docs_tombstoned", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_resurrected": { + "name": "docs_resurrected", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_purged": { + "name": "docs_purged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "credentials_audited": { + "name": "credentials_audited", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcmsl_connector_started_at_idx": { + "name": "kcmsl_connector_started_at_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcmsl_started_at_partial_idx": { + "name": "kcmsl_started_at_partial_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector_member_sync_log\".\"status\" = 'started'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_member_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_member_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_member_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kcmsl_status_check": { + "name": "kcmsl_status_check", + "value": "\"knowledge_connector_member_sync_log\".\"status\" IN ('started', 'completed', 'failed')" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_skipped": { + "name": "docs_skipped", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_started_at_idx": { + "name": "kcsl_connector_started_at_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcsl_started_at_partial_idx": { + "name": "kcsl_started_at_partial_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector_sync_log\".\"status\" = 'started'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_document_observation": { + "name": "knowledge_document_observation", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "kdo_member_idx": { + "name": "kdo_member_idx", + "columns": [ + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_document_observation_document_id_document_id_fk": { + "name": "knowledge_document_observation_document_id_document_id_fk", + "tableFrom": "knowledge_document_observation", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_document_observation_member_id_knowledge_connector_member_id_fk": { + "name": "knowledge_document_observation_member_id_knowledge_connector_member_id_fk", + "tableFrom": "knowledge_document_observation", + "tableTo": "knowledge_connector_member", + "columnsFrom": ["member_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "knowledge_document_observation_document_id_member_id_pk": { + "name": "knowledge_document_observation_document_id_member_id_pk", + "columns": ["document_id", "member_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_external_group": { + "name": "knowledge_external_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_group_id": { + "name": "external_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "keg_identity_unique": { + "name": "keg_identity_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "keg_workspace_synced_idx": { + "name": "keg_workspace_synced_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_synced_at", + "isExpression": false, + "asc": true, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "keg_workspace_fk": { + "name": "keg_workspace_fk", + "tableFrom": "knowledge_external_group", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_external_group_member": { + "name": "knowledge_external_group_member", + "schema": "", + "columns": { + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kegm_email_idx": { + "name": "kegm_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kegm_group_fk": { + "name": "kegm_group_fk", + "tableFrom": "knowledge_external_group_member", + "tableTo": "knowledge_external_group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "knowledge_external_group_member_group_id_email_pk": { + "name": "knowledge_external_group_member_group_id_email_pk", + "columns": ["group_id", "email"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_group_id": { + "name": "credential_group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_connector_id": { + "name": "managed_connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_credential_group_idx": { + "name": "mcp_servers_credential_group_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_credential_group_managed_connector_unique": { + "name": "mcp_servers_credential_group_managed_connector_unique", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "managed_connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"mcp_servers\".\"credential_group_id\" IS NOT NULL AND \"mcp_servers\".\"managed_connector_id\" IS NOT NULL AND \"mcp_servers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_credential_group_id_credential_group_id_fk": { + "name": "mcp_servers_credential_group_id_credential_group_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "credential_group", + "columnsFrom": ["credential_group_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_servers_credential_group_managed_connector_check": { + "name": "mcp_servers_credential_group_managed_connector_check", + "value": "\"mcp_servers\".\"credential_group_id\" IS NULL OR \"mcp_servers\".\"managed_connector_id\" IS NOT NULL" + }, + "mcp_servers_managed_connector_oauth_check": { + "name": "mcp_servers_managed_connector_oauth_check", + "value": "\"mcp_servers\".\"managed_connector_id\" IS NULL OR \"mcp_servers\".\"auth_type\" = 'oauth'" + } + }, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory_secret_provenance": { + "name": "memory_secret_provenance", + "schema": "", + "columns": { + "memory_id": { + "name": "memory_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "memory_secret_provenance_memory_id_memory_id_fk": { + "name": "memory_secret_provenance_memory_id_memory_id_fk", + "tableFrom": "memory_secret_provenance", + "tableTo": "memory", + "columnsFrom": ["memory_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "memory_secret_provenance_status_check": { + "name": "memory_secret_provenance_status_check", + "value": "\"memory_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "session_policy_settings": { + "name": "session_policy_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "security_policy_version": { + "name": "security_policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_byok_keys": { + "name": "organization_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_byok_organization_provider_idx": { + "name": "organization_byok_organization_provider_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_byok_keys_organization_id_organization_id_fk": { + "name": "organization_byok_keys_organization_id_organization_id_fk", + "tableFrom": "organization_byok_keys", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_byok_keys_created_by_user_id_fk": { + "name": "organization_byok_keys_created_by_user_id_fk", + "tableFrom": "organization_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_member_usage_limit": { + "name": "organization_member_usage_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_member_usage_limit_org_user_unique": { + "name": "org_member_usage_limit_org_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_member_usage_limit_organization_id_idx": { + "name": "org_member_usage_limit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_member_usage_limit_organization_id_organization_id_fk": { + "name": "organization_member_usage_limit_organization_id_organization_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["set_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_type_created_idx": { + "name": "outbox_event_type_created_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "automatic_resume_retry_count": { + "name": "automatic_resume_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_name_unique": { + "name": "permission_group_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_workspace": { + "name": "permission_group_workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pinned_item": { + "name": "pinned_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pinned_item_user_workspace_idx": { + "name": "pinned_item_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_resource_idx": { + "name": "pinned_item_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_user_resource_unique": { + "name": "pinned_item_user_resource_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pinned_item_user_id_user_id_fk": { + "name": "pinned_item_user_id_user_id_fk", + "tableFrom": "pinned_item", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pinned_item_workspace_id_workspace_id_fk": { + "name": "pinned_item_workspace_id_workspace_id_fk", + "tableFrom": "pinned_item", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.public_share": { + "name": "public_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "public_share_token_unique": { + "name": "public_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_unique": { + "name": "public_share_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resource_policy": { + "name": "resource_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "document": { + "name": "document", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "resource_policy_resource_unique": { + "name": "resource_policy_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resource_policy_workspace_id_idx": { + "name": "resource_policy_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resource_policy_workspace_id_workspace_id_fk": { + "name": "resource_policy_workspace_id_workspace_id_fk", + "tableFrom": "resource_policy", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "resource_policy_created_by_user_id_fk": { + "name": "resource_policy_created_by_user_id_fk", + "tableFrom": "resource_policy", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "resource_policy_updated_by_user_id_fk": { + "name": "resource_policy_updated_by_user_id_fk", + "tableFrom": "resource_policy", + "tableTo": "user", + "columnsFrom": ["updated_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandbox_image": { + "name": "sandbox_image", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec": { + "name": "spec", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "sandbox_image_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "image_ref": { + "name": "image_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_image_id": { + "name": "provider_image_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_id": { + "name": "build_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "materialization_generation": { + "name": "materialization_generation", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_detail": { + "name": "error_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sandbox_image_provider_spec_unique": { + "name": "sandbox_image_provider_spec_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_status_idx": { + "name": "sandbox_image_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_last_used_idx": { + "name": "sandbox_image_last_used_idx", + "columns": [ + { + "expression": "last_used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.secret_usage": { + "name": "secret_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_name": { + "name": "secret_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_scope": { + "name": "secret_scope", + "type": "secret_usage_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "secret_owner_user_id": { + "name": "secret_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "source": { + "name": "source", + "type": "secret_usage_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "usage_date": { + "name": "usage_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "use_count": { + "name": "use_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_execution_id": { + "name": "last_execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_trigger": { + "name": "last_trigger", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "secret_usage_bucket_unique": { + "name": "secret_usage_bucket_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "usage_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "secret_usage_secret_recent_idx": { + "name": "secret_usage_secret_recent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_used_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "secret_usage_workspace_id_workspace_id_fk": { + "name": "secret_usage_workspace_id_workspace_id_fk", + "tableFrom": "secret_usage", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auto_focus_on_click": { + "name": "auto_focus_on_click", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_member": { + "name": "skill_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_member_user_id_idx": { + "name": "skill_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skill_member_unique": { + "name": "skill_member_unique", + "columns": [ + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_member_skill_id_skill_id_fk": { + "name": "skill_member_skill_id_skill_id_fk", + "tableFrom": "skill_member", + "tableTo": "skill", + "columnsFrom": ["skill_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_user_id_user_id_fk": { + "name": "skill_member_user_id_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_invited_by_user_id_fk": { + "name": "skill_member_invited_by_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_domain": { + "name": "sso_domain", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sso_domain_organization_id_idx": { + "name": "sso_domain_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_domain_idx": { + "name": "sso_domain_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_org_domain_unique": { + "name": "sso_domain_org_domain_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_verified_unique": { + "name": "sso_domain_verified_unique", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'verified'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_domain_organization_id_organization_id_fk": { + "name": "sso_domain_organization_id_organization_id_fk", + "tableFrom": "sso_domain", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_domain_created_by_user_id_fk": { + "name": "sso_domain_created_by_user_id_fk", + "tableFrom": "sso_domain", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain_verified": { + "name": "domain_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "jit_provisioning_enabled": { + "name": "jit_provisioning_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "sso_provider_provider_id_unique": { + "name": "sso_provider_provider_id_unique", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "last_closed_period_start": { + "name": "last_closed_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscription_cycle_close_lagging_idx": { + "name": "subscription_cycle_close_lagging_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"subscription\".\"status\" in ('active', 'past_due') and \"subscription\".\"period_start\" is not null and (\"subscription\".\"last_closed_period_start\" is null or \"subscription\".\"last_closed_period_start\" < \"subscription\".\"period_start\")", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_jobs": { + "name": "table_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "capability_governed_user_id": { + "name": "capability_governed_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enrichment_details": { + "name": "enrichment_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_capability_governed_user_id_user_id_fk": { + "name": "table_row_executions_capability_governed_user_id_user_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user", + "columnsFrom": ["capability_governed_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capability_governed_user_id": { + "name": "capability_governed_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_governed_active_idx": { + "name": "table_run_dispatches_governed_active_idx", + "columns": [ + { + "expression": "capability_governed_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_run_dispatches\".\"status\" IN ('pending', 'dispatching')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "table_run_dispatches_capability_governed_user_id_user_id_fk": { + "name": "table_run_dispatches_capability_governed_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["capability_governed_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_views": { + "name": "table_views", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_views_table_created_idx": { + "name": "table_views_table_created_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_views_workspace_created_idx": { + "name": "table_views_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_views_table_default_unique": { + "name": "table_views_table_default_unique", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_views_table_id_user_table_definitions_id_fk": { + "name": "table_views_table_id_user_table_definitions_id_fk", + "tableFrom": "table_views", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_workspace_id_workspace_id_fk": { + "name": "table_views_workspace_id_workspace_id_fk", + "tableFrom": "table_views", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_created_by_user_id_fk": { + "name": "table_views_created_by_user_id_fk", + "tableFrom": "table_views", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.upload_session": { + "name": "upload_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "purpose": { + "name": "purpose", + "type": "upload_session_purpose", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "upload_session_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "storage_context": { + "name": "storage_context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "final_key": { + "name": "final_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_provider": { + "name": "storage_provider", + "type": "upload_session_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider_upload_id": { + "name": "provider_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_object_version": { + "name": "provider_object_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "part_size": { + "name": "part_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "part_count": { + "name": "part_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "upload_session_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'uploading'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "processing_lease_id": { + "name": "processing_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_lease_expires_at": { + "name": "processing_lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_file_id": { + "name": "completed_file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "upload_session_token_hash_unique": { + "name": "upload_session_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upload_session_final_key_unique": { + "name": "upload_session_final_key_unique", + "columns": [ + { + "expression": "final_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upload_session_status_expires_at_idx": { + "name": "upload_session_status_expires_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_period_cost_idx": { + "name": "usage_log_billing_period_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_created_at_cost_idx": { + "name": "usage_log_billing_entity_created_at_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_email_lower_idx": { + "name": "user_email_lower_idx", + "columns": [ + { + "expression": "lower(btrim(\"email\"))", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_version": { + "name": "rows_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "schema_locked": { + "name": "schema_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "insert_locked": { + "name": "insert_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "update_locked": { + "name": "update_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "delete_locked": { + "name": "delete_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_folder_id_idx": { + "name": "user_table_def_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_folder_id_folder_id_fk": { + "name": "user_table_definitions_folder_id_folder_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_row_secret_provenance": { + "name": "user_table_row_secret_provenance", + "schema": "", + "columns": { + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_table_row_secret_provenance_row_id_user_table_rows_id_fk": { + "name": "user_table_row_secret_provenance_row_id_user_table_rows_id_fk", + "tableFrom": "user_table_row_secret_provenance", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_table_row_secret_provenance_status_check": { + "name": "user_table_row_secret_provenance_status_check", + "value": "\"user_table_row_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_created_id_idx": { + "name": "user_table_rows_table_created_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_status": { + "name": "registration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_generation": { + "name": "registration_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config_fingerprint": { + "name": "config_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prepared_at": { + "name": "prepared_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_key": { + "name": "routing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_routing_key_active_idx": { + "name": "webhook_routing_key_active_idx", + "columns": [ + { + "expression": "routing_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NULL AND \"webhook\".\"routing_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_active_registration_unique": { + "name": "webhook_active_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'active' AND \"webhook\".\"block_id\" IS NOT NULL AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_candidate_registration_unique": { + "name": "webhook_candidate_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'candidate' AND \"webhook\".\"block_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_registration_status_generation_idx": { + "name": "webhook_registration_status_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_registration_status_check": { + "name": "webhook_registration_status_check", + "value": "\"webhook\".\"registration_status\" IS NULL OR \"webhook\".\"registration_status\" IN ('active', 'candidate', 'retired', 'orphaned')" + }, + "webhook_registration_generation_check": { + "name": "webhook_registration_generation_check", + "value": "\"webhook\".\"registration_generation\" IS NULL OR \"webhook\".\"registration_generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.webhook_path_claim": { + "name": "webhook_path_claim", + "schema": "", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_path_claim_workflow_idx": { + "name": "webhook_path_claim_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_path_claim_workflow_id_workflow_id_fk": { + "name": "webhook_path_claim_workflow_id_workflow_id_fk", + "tableFrom": "webhook_path_claim", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_path_claim_generation_check": { + "name": "webhook_path_claim_generation_check", + "value": "\"webhook_path_claim\".\"generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fork_sync_excluded": { + "name": "fork_sync_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_active_workspace_sort_idx": { + "name": "workflow_active_workspace_sort_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_folder_id_fk": { + "name": "workflow_folder_id_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "error_enabled": { + "name": "error_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "retry": { + "name": "retry", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_operation": { + "name": "workflow_deployment_operation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_active_version_id": { + "name": "previous_active_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preparing'" + }, + "component_readiness": { + "name": "component_readiness", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_deployment_operation_workflow_generation_unique": { + "name": "workflow_deployment_operation_workflow_generation_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_idempotency_unique": { + "name": "workflow_deployment_operation_workflow_idempotency_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_in_flight_unique": { + "name": "workflow_deployment_operation_workflow_in_flight_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_status_idx": { + "name": "workflow_deployment_operation_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_deployment_version_idx": { + "name": "workflow_deployment_operation_deployment_version_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_version_generation_idx": { + "name": "workflow_deployment_operation_workflow_version_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_operation_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_operation_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["previous_active_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_deployment_operation_action_check": { + "name": "workflow_deployment_operation_action_check", + "value": "\"workflow_deployment_operation\".\"action\" IN ('deploy', 'activate')" + }, + "workflow_deployment_operation_status_check": { + "name": "workflow_deployment_operation_status_check", + "value": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating', 'active', 'failed', 'superseded')" + }, + "workflow_deployment_operation_generation_check": { + "name": "workflow_deployment_operation_generation_check", + "value": "\"workflow_deployment_operation\".\"generation\" > 0" + }, + "workflow_deployment_operation_protocol_version_check": { + "name": "workflow_deployment_operation_protocol_version_check", + "value": "\"workflow_deployment_operation\".\"protocol_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "execution_deadline_at": { + "name": "execution_deadline_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_id_desc_idx": { + "name": "workflow_execution_logs_workspace_started_at_id_desc_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_deadline_idx": { + "name": "workflow_execution_logs_running_deadline_idx", + "columns": [ + { + "expression": "execution_deadline_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'running' AND \"workflow_execution_logs\".\"execution_deadline_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_redacting_started_at_idx": { + "name": "workflow_execution_logs_redacting_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'redacting'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_redacting_deadline_idx": { + "name": "workflow_execution_logs_redacting_deadline_idx", + "columns": [ + { + "expression": "execution_deadline_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'redacting' AND \"workflow_execution_logs\".\"execution_deadline_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_completed_ended_at_idx": { + "name": "workflow_execution_logs_completed_ended_at_idx", + "columns": [ + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'completed' AND \"workflow_execution_logs\".\"level\" = 'info' AND \"workflow_execution_logs\".\"ended_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_workspace_deleted_partial_idx": { + "name": "workflow_mcp_server_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "parameter_description_overrides": { + "name": "parameter_description_overrides", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_operation_id": { + "name": "deployment_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_scope": { + "name": "secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "mounted_secrets": { + "name": "mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "excluded_dates": { + "name": "excluded_dates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk": { + "name": "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_operation", + "columnsFrom": ["deployment_operation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_secret_scope": { + "name": "inbox_secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "inbox_mounted_secrets": { + "name": "inbox_mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "organization_assigned_at": { + "name": "organization_assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "forked_from_workspace_id": { + "name": "forked_from_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_forked_from_workspace_id_idx": { + "name": "workspace_forked_from_workspace_id_idx", + "columns": [ + { + "expression": "forked_from_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_inbox_provider_id_idx": { + "name": "workspace_inbox_provider_id_idx", + "columns": [ + { + "expression": "inbox_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace\".\"inbox_provider_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_forked_from_workspace_id_workspace_id_fk": { + "name": "workspace_forked_from_workspace_id_workspace_id_fk", + "tableFrom": "workspace", + "tableTo": "workspace", + "columnsFrom": ["forked_from_workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_storage_used_bytes_non_negative": { + "name": "workspace_storage_used_bytes_non_negative", + "value": "\"workspace\".\"storage_used_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_workspace_provider_idx": { + "name": "workspace_byok_workspace_provider_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_collab_state": { + "name": "workspace_file_collab_state", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "doc_state": { + "name": "doc_state", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_collab_state_file_id_workspace_files_id_fk": { + "name": "workspace_file_collab_state_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_collab_state", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_backfill": { + "name": "workspace_file_search_backfill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "after_workspace_id": { + "name": "after_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "after_file_id": { + "name": "after_file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_dispatch_queue": { + "name": "workspace_file_search_dispatch_queue", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enqueued_at": { + "name": "enqueued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_dispatched_at": { + "name": "last_dispatched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_search_dispatch_queue_schedule_idx": { + "name": "workspace_file_search_dispatch_queue_schedule_idx", + "columns": [ + { + "expression": "last_dispatched_at", + "isExpression": false, + "asc": true, + "nulls": "first" + }, + { + "expression": "enqueued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_queue_workspace_fk": { + "name": "workspace_file_search_queue_workspace_fk", + "tableFrom": "workspace_file_search_dispatch_queue", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_index": { + "name": "workspace_file_search_index", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_content_updated_at": { + "name": "source_content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "workspace_file_search_index_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "partial": { + "name": "partial", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "line_count": { + "name": "line_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "indexed_bytes": { + "name": "indexed_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "dispatched_at": { + "name": "dispatched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_search_index_workspace_status_idx": { + "name": "workspace_file_search_index_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_index_pending_dispatch_idx": { + "name": "workspace_file_search_index_pending_dispatch_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_index\".\"status\" = 'pending' AND \"workspace_file_search_index\".\"dispatched_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_index_active_dispatch_idx": { + "name": "workspace_file_search_index_active_dispatch_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dispatched_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_index\".\"status\" = 'pending' AND \"workspace_file_search_index\".\"dispatched_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_index_file_fk": { + "name": "workspace_file_search_index_file_fk", + "tableFrom": "workspace_file_search_index", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_search_index_workspace_fk": { + "name": "workspace_file_search_index_workspace_fk", + "tableFrom": "workspace_file_search_index", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_file_search_index_pk": { + "name": "workspace_file_search_index_pk", + "columns": ["file_id", "source_content_updated_at"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_segment": { + "name": "workspace_file_search_segment", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_content_updated_at": { + "name": "source_content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "line_number": { + "name": "line_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "segment_number": { + "name": "segment_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "segment_start": { + "name": "segment_start", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "line_length": { + "name": "line_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "workspace_file_search_segment_workspace_revision_idx": { + "name": "workspace_file_search_segment_workspace_revision_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_segment_workspace_content_trgm_idx": { + "name": "workspace_file_search_segment_workspace_content_trgm_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "content", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_segment_file_fk": { + "name": "workspace_file_search_segment_file_fk", + "tableFrom": "workspace_file_search_segment", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_search_segment_workspace_fk": { + "name": "workspace_file_search_segment_workspace_fk", + "tableFrom": "workspace_file_search_segment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_file_search_segment_pk": { + "name": "workspace_file_search_segment_pk", + "columns": ["file_id", "source_content_updated_at", "line_number", "segment_number"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_secret_provenance": { + "name": "workspace_file_secret_provenance", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_secret_provenance_file_id_workspace_files_id_fk": { + "name": "workspace_file_secret_provenance_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_secret_provenance", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_file_secret_provenance_status_check": { + "name": "workspace_file_secret_provenance_status_check", + "value": "\"workspace_file_secret_provenance\".\"status\" IN ('exact', 'unknown', 'unrecorded')" + } + }, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_folder_id_folder_id_fk": { + "name": "workspace_files_folder_id_folder_id_fk", + "tableFrom": "workspace_files", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_block_map": { + "name": "workspace_fork_block_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_workflow_id": { + "name": "parent_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_block_id": { + "name": "parent_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_workflow_id": { + "name": "child_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_block_id": { + "name": "child_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_block_map_child_ws_parent_unique": { + "name": "workspace_fork_block_map_child_ws_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_unique": { + "name": "workspace_fork_block_map_child_ws_child_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_parent_wf_idx": { + "name": "workspace_fork_block_map_child_ws_parent_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_wf_idx": { + "name": "workspace_fork_block_map_child_ws_child_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_block_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_block_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_block_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_dependent_value": { + "name": "workspace_fork_dependent_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_block_key": { + "name": "sub_block_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_dependent_value_child_ws_wf_idx": { + "name": "workspace_fork_dependent_value_child_ws_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_dependent_value_field_unique": { + "name": "workspace_fork_dependent_value_field_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sub_block_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_dependent_value", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_promote_run": { + "name": "workspace_fork_promote_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workspace_id": { + "name": "target_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "workspace_fork_promote_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_promote_run_child_ws_target_unique": { + "name": "workspace_fork_promote_run_child_ws_target_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_promote_run_target_ws_idx": { + "name": "workspace_fork_promote_run_target_ws_idx", + "columns": [ + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_promote_run_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_promote_run_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_promote_run_created_by_user_id_fk": { + "name": "workspace_fork_promote_run_created_by_user_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_resource_map": { + "name": "workspace_fork_resource_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "workspace_fork_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_resource_id": { + "name": "parent_resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_resource_id": { + "name": "child_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_resource_map_child_ws_idx": { + "name": "workspace_fork_resource_map_child_ws_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_ws_type_idx": { + "name": "workspace_fork_resource_map_child_ws_type_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_type_parent_unique": { + "name": "workspace_fork_resource_map_child_type_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_resource_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_resource_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_resource_map_created_by_user_id_fk": { + "name": "workspace_fork_resource_map_created_by_user_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_sandbox": { + "name": "workspace_sandbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "sandbox_language", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "dependencies": { + "name": "dependencies", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "cli_tools": { + "name": "cli_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "system_packages": { + "name": "system_packages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_sandbox_workspace_name_unique": { + "name": "workspace_sandbox_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_workspace_idx": { + "name": "workspace_sandbox_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_spec_hash_idx": { + "name": "workspace_sandbox_spec_hash_idx", + "columns": [ + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_sandbox_workspace_id_workspace_id_fk": { + "name": "workspace_sandbox_workspace_id_workspace_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_sandbox_created_by_user_id_fk": { + "name": "workspace_sandbox_created_by_user_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.background_work_kind": { + "name": "background_work_kind", + "schema": "public", + "values": ["deployment_side_effects", "fork_content_copy", "fork_sync", "fork_rollback"] + }, + "public.background_work_status_value": { + "name": "background_work_status_value", + "schema": "public", + "values": ["pending", "processing", "completed", "completed_with_warnings", "failed"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.copilot_tool_permission_decision": { + "name": "copilot_tool_permission_decision", + "schema": "public", + "values": ["allow", "allow_chat", "always_allow", "skip"] + }, + "public.credential_group_enrollment_status": { + "name": "credential_group_enrollment_status", + "schema": "public", + "values": ["invited", "delivery_failed", "in_progress", "completed", "revoked"] + }, + "public.credential_group_status": { + "name": "credential_group_status", + "schema": "public", + "values": ["active", "disabled"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": [ + "oauth", + "managed_oauth", + "managed_mcp", + "env_workspace", + "env_personal", + "service_account" + ] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.folder_resource_type": { + "name": "folder_resource_type", + "schema": "public", + "values": ["workflow", "file", "knowledge_base", "table"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.managed_oauth_credential_status": { + "name": "managed_oauth_credential_status", + "schema": "public", + "values": ["active", "needs_reauth", "revoked"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.sandbox_image_status": { + "name": "sandbox_image_status", + "schema": "public", + "values": ["pending", "building", "ready", "failed"] + }, + "public.sandbox_language": { + "name": "sandbox_language", + "schema": "public", + "values": ["javascript", "python"] + }, + "public.secret_usage_scope": { + "name": "secret_usage_scope", + "schema": "public", + "values": ["workspace", "personal"] + }, + "public.secret_usage_source": { + "name": "secret_usage_source", + "schema": "public", + "values": ["workflow", "copilot", "mcp"] + }, + "public.upload_session_method": { + "name": "upload_session_method", + "schema": "public", + "values": ["put", "multipart"] + }, + "public.upload_session_provider": { + "name": "upload_session_provider", + "schema": "public", + "values": ["local", "s3", "blob", "gcs"] + }, + "public.upload_session_purpose": { + "name": "upload_session_purpose", + "schema": "public", + "values": [ + "workspace_file", + "table_import", + "knowledge_document", + "profile_picture", + "workspace_logo", + "mothership_attachment", + "execution_attachment" + ] + }, + "public.upload_session_status": { + "name": "upload_session_status", + "schema": "public", + "values": [ + "uploading", + "completing", + "finalizing", + "completed", + "aborting", + "aborted", + "failed", + "expired" + ] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool", "model_unbilled"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment", + "voice-output", + "api-tool" + ] + }, + "public.workspace_file_search_index_status": { + "name": "workspace_file_search_index_status", + "schema": "public", + "values": ["pending", "ready", "skipped", "failed"] + }, + "public.workspace_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": ["push", "pull"] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "file_folder", + "mcp_server", + "workflow_mcp_server", + "custom_block", + "custom_tool", + "skill" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 60137713f9d..b3e0b5433ae 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -2241,6 +2241,13 @@ "when": 1788384579773, "tag": "0320_striped_shatterstar", "breakpoints": true + }, + { + "idx": 321, + "version": "7", + "when": 1788497185932, + "tag": "0321_permission_mirrored_knowledge", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 1edd8fe7394..f7174f37754 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -44,21 +44,63 @@ export const bytea = customType<{ }, }) -export const user = pgTable('user', { - id: text('id').primaryKey(), - name: text('name').notNull(), - email: text('email').notNull().unique(), - normalizedEmail: text('normalized_email').unique(), - emailVerified: boolean('email_verified').notNull(), - image: text('image'), - createdAt: timestamp('created_at').notNull(), - updatedAt: timestamp('updated_at').notNull(), - stripeCustomerId: text('stripe_customer_id'), - role: text('role').default('user'), - banned: boolean('banned').default(false), - banReason: text('ban_reason'), - banExpires: timestamp('ban_expires'), -}) +/** + * An email address reduced to the identity it names, in SQL. The one expression + * every comparison of an address by identity must use — and the exact expression + * `user_email_lower_idx` indexes, so a predicate written any other way silently + * becomes a sequential scan. The TypeScript twin is `normalizeEmail` in + * `@sim/utils/string`; the two must agree, and both are trim-and-lowercase. + */ +export function foldedEmail(column: AnyPgColumn | SQL): SQL { + return sql`lower(btrim(${column}))` +} + +export const user = pgTable( + 'user', + { + id: text('id').primaryKey(), + name: text('name').notNull(), + /** + * Unique byte-for-byte only. The identity an address names is + * `foldedEmail(email)`, which `user_email_lower_idx` indexes. + */ + email: text('email').notNull().unique(), + /** + * Written by a signup plugin that was removed; populated for roughly a + * fifth of accounts, with a different normalisation (Gmail dot and tag + * stripping) that must never be used for identity. Nothing reads it. + * + * Kept for one release rather than dropped with its readers: Better Auth + * selects every schema column, so dropping it while the previous release + * is still serving would break sign-in. + */ + normalizedEmail: text('normalized_email').unique(), + emailVerified: boolean('email_verified').notNull(), + image: text('image'), + createdAt: timestamp('created_at').notNull(), + updatedAt: timestamp('updated_at').notNull(), + stripeCustomerId: text('stripe_customer_id'), + role: text('role').default('user'), + banned: boolean('banned').default(false), + banReason: text('ban_reason'), + banExpires: timestamp('ban_expires'), + }, + (table) => ({ + /** + * The folded address, which is how every identity binding by email + * compares — credential-group enrollments, the `u:` document access token, + * the ambiguity check access resolution runs on every read. Without it + * each of those is a sequential scan of `user`. + * + * Not unique. `email` is unique byte-for-byte only, and a small number of + * historical accounts collide once folded; access resolution refuses to + * bind an ambiguous address rather than let either account read the + * other's documents. Follow-up, after those accounts are merged: promote to + * UNIQUE so the state cannot arise at all. + */ + emailLowerIdx: index('user_email_lower_idx').on(foldedEmail(table.email)), + }) +) export const session = pgTable( 'session', @@ -4934,6 +4976,104 @@ export const knowledgeDocumentObservation = pgTable( }) ) +/** + * A group in an external directory, as an admin-mode crawl names it. + * + * Scoped by workspace, provider and tenant rather than by connector: two Drive + * connectors over the same Google Workspace domain grant the same groups, and + * resolving that domain's directory once per connector would multiply the + * Admin SDK traffic by the number of knowledge bases. + * + * `externalGroupId` is whatever the source's permissions API names a group by — + * a group email in Drive, a group id in Confluence — canonicalised by + * `canonicalGroupId`, exactly as `groupToken` spells it. Keying the directory + * by the same identifier the grant carries is what lets a token resolve to + * membership with no lookup in between. + */ +export const knowledgeExternalGroup = pgTable( + 'knowledge_external_group', + { + id: text('id').primaryKey(), + workspaceId: text('workspace_id').notNull(), + /** Matches the provider segment of the `g:` token, e.g. `google-drive`. */ + providerId: text('provider_id').notNull(), + /** The directory this group belongs to: a Workspace domain for Google, a site's cloud id for Confluence. */ + tenantId: text('tenant_id').notNull(), + externalGroupId: text('external_group_id').notNull(), + /** + * When this group's membership was last enumerated in full, and the only + * thing that decides whether it still grants access. + * + * A failed or partial enumeration writes nothing at all — not the + * membership, not this column — which is what makes a transient directory + * outage harmless. It is also why the column has to exist: without an age + * bound, a group whose sync stopped running would keep granting forever + * from membership nobody has checked since. A group unconfirmed for longer + * than `EXTERNAL_GROUP_STALE_AFTER_MS` grants nothing. + */ + lastSyncedAt: timestamp('last_synced_at'), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + }, + (table) => ({ + /** Named explicitly: drizzle's derived name exceeds Postgres's 63-character limit and would be silently truncated. */ + workspaceFk: foreignKey({ + name: 'keg_workspace_fk', + columns: [table.workspaceId], + foreignColumns: [workspace.id], + }).onDelete('cascade'), + identityUnique: uniqueIndex('keg_identity_unique').on( + table.workspaceId, + table.providerId, + table.tenantId, + table.externalGroupId + ), + /** The read path's freshness filter: a workspace's groups confirmed within the staleness window. */ + workspaceSyncedIdx: index('keg_workspace_synced_idx').on( + table.workspaceId, + table.lastSyncedAt.asc().nullsFirst() + ), + }) +) + +/** + * One person's membership of one external group, by case-folded email. + * + * Email rather than a Sim user id, for two reasons: a directory reports + * addresses, not Sim accounts, and most members of a granted group have no Sim + * account at all. Storing the address means a person who joins Sim later + * inherits their existing group grants on their first read, with no backfill. + * + * Nested groups are flattened here. A person in a subgroup of a granted group + * has access in the source, so they have a row here; the directory sync walks + * the nesting and writes the transitive closure. + */ +export const knowledgeExternalGroupMember = pgTable( + 'knowledge_external_group_member', + { + groupId: text('group_id').notNull(), + /** + * Case-folded, matching `lower(btrim(user.email))`. A row may also be the + * wildcard `*@`, standing for everyone at that domain; readers + * match it by their own address's domain. Never list this column as + * people without accounting for the wildcard. + */ + email: text('email').notNull(), + createdAt: timestamp('created_at').notNull().defaultNow(), + }, + (table) => ({ + pk: primaryKey({ columns: [table.groupId, table.email] }), + /** Named explicitly: drizzle's derived name exceeds Postgres's 63-character limit and would be silently truncated. */ + groupFk: foreignKey({ + name: 'kegm_group_fk', + columns: [table.groupId], + foreignColumns: [knowledgeExternalGroup.id], + }).onDelete('cascade'), + /** The read path: every group one address belongs to. */ + emailIdx: index('kegm_email_idx').on(table.email), + }) +) + /** * Audit trail for members-mode runs; the content sync log is untouched. The * row id doubles as the run's lease token so the scheduler can tell an diff --git a/packages/testing/src/mocks/schema.mock.ts b/packages/testing/src/mocks/schema.mock.ts index 10d142de09a..d9b6c46acd6 100644 --- a/packages/testing/src/mocks/schema.mock.ts +++ b/packages/testing/src/mocks/schema.mock.ts @@ -140,6 +140,12 @@ const workspaceFileSearchSegmentMock = { } export const schemaMock = { + /** + * The schema's folded-address expression. Returns the column it wraps so a + * predicate built on it still names the column, and assertions on condition + * shape keep working. + */ + foldedEmail: (column: unknown) => column, user: { id: 'user.id', name: 'user.name', @@ -1419,6 +1425,21 @@ export const schemaMock = { docsFailed: 'knowledgeConnectorSyncLog.docsFailed', errorMessage: 'knowledgeConnectorSyncLog.errorMessage', }, + knowledgeExternalGroup: { + id: 'knowledgeExternalGroup.id', + workspaceId: 'knowledgeExternalGroup.workspaceId', + providerId: 'knowledgeExternalGroup.providerId', + tenantId: 'knowledgeExternalGroup.tenantId', + externalGroupId: 'knowledgeExternalGroup.externalGroupId', + lastSyncedAt: 'knowledgeExternalGroup.lastSyncedAt', + createdAt: 'knowledgeExternalGroup.createdAt', + updatedAt: 'knowledgeExternalGroup.updatedAt', + }, + knowledgeExternalGroupMember: { + groupId: 'knowledgeExternalGroupMember.groupId', + email: 'knowledgeExternalGroupMember.email', + createdAt: 'knowledgeExternalGroupMember.createdAt', + }, knowledgeConnectorMember: { id: 'knowledgeConnectorMember.id', workspaceId: 'knowledgeConnectorMember.workspaceId', diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 378d7ce4aa6..4595411719b 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -99,6 +99,7 @@ const INDIRECT_ZOD_ROUTES = new Set([ 'apps/sim/app/api/logs/cleanup/route.ts', 'apps/sim/app/api/knowledge/connectors/sync/route.ts', 'apps/sim/app/api/knowledge/connectors/member-sync/route.ts', + 'apps/sim/app/api/knowledge/connectors/directory-sync/route.ts', 'apps/sim/app/api/webhooks/outbox/process/route.ts', 'apps/sim/app/api/webhooks/cleanup/idempotency/route.ts', // Shared Slack app event ingest. The body is an opaque, HMAC-verified Slack