diff --git a/apps/sim/blocks/blocks/credential-group.ts b/apps/sim/blocks/blocks/credential-group.ts index ec84791f943..46a9c28f042 100644 --- a/apps/sim/blocks/blocks/credential-group.ts +++ b/apps/sim/blocks/blocks/credential-group.ts @@ -1,4 +1,5 @@ import { GridOffset } from '@sim/emcn/icons' +import { CREDENTIAL_GROUP_EVENT_TRIGGER_ID } from '@/lib/credential-groups/trigger-constants' import { type CanonicalGroup, resolveActiveCanonicalValue, @@ -13,6 +14,7 @@ import { import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' +import { getTrigger } from '@/triggers' const CREDENTIAL_GROUP_CANONICAL_GROUP = { canonicalId: 'credentialGroupId', @@ -256,6 +258,7 @@ export const CredentialGroupBlock: BlockConfig = { placeholder: 'nextCursor from a previous page', condition: { field: 'operation', value: [...LIST_OPERATIONS] }, }, + ...getTrigger(CREDENTIAL_GROUP_EVENT_TRIGGER_ID).subBlocks, ], tools: { access: [] }, inputs: { @@ -345,4 +348,8 @@ export const CredentialGroupBlock: BlockConfig = { condition: { field: 'operation', value: [...LIST_OPERATIONS] }, }, }, + triggers: { + enabled: true, + available: [CREDENTIAL_GROUP_EVENT_TRIGGER_ID], + }, } diff --git a/apps/sim/lib/credential-groups/application/public-enrollment.test.ts b/apps/sim/lib/credential-groups/application/public-enrollment.test.ts index 9fae003f2de..f6cc790ef10 100644 --- a/apps/sim/lib/credential-groups/application/public-enrollment.test.ts +++ b/apps/sim/lib/credential-groups/application/public-enrollment.test.ts @@ -7,6 +7,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ completeEnrollment: vi.fn(), + completeOAuth: vi.fn(), + fireTrigger: vi.fn(), getEnrollment: vi.fn(), getOAuthContext: vi.fn(), startOAuth: vi.fn(), @@ -19,11 +21,17 @@ vi.mock('@/lib/credential-groups/enrollments', () => ({ })) vi.mock('@/lib/credential-groups/oauth', () => ({ - completeCredentialGroupOAuth: vi.fn(), + completeCredentialGroupOAuth: mocks.completeOAuth, startCredentialGroupOAuth: mocks.startOAuth, })) +vi.mock('@/lib/credential-groups/trigger', () => ({ + fireCredentialGroupTrigger: mocks.fireTrigger, +})) + import { + completePublicCredentialGroupEnrollment, + completePublicCredentialGroupOAuth, readPublicCredentialGroupEnrollment, startPublicCredentialGroupOAuth, } from '@/lib/credential-groups/application/public-enrollment' @@ -44,15 +52,43 @@ const identity = { email: principal.email, invitationTokenHash: principal.invitationTokenHash, } +const oauthAttempt = { + state: 'state-1', + provider: 'gmail' as const, + nonceHash: 'nonce-hash', + enrollmentId: principal.enrollmentId, + credentialGroupId: principal.credentialGroupId, + optionId: 'option-1', + authorizationAppId: 'google:client', + scopeVersion: 1, + requiredScopes: ['openid'], + redirectUri: 'https://sim.ai/api/auth/oauth2/callback/google-email', + invitationToken, + createdAt: Date.now(), +} describe('public Credential Group enrollment application operations', () => { beforeEach(() => { vi.clearAllMocks() - mocks.getEnrollment.mockResolvedValue({ status: 'invited', options: [] }) + mocks.getEnrollment.mockResolvedValue({ + status: 'invited', + credentialGroupName: 'Credential Group', + options: [], + }) mocks.getOAuthContext.mockResolvedValue({ enrollmentId: 'enrollment-1', credentialGroupId: 'group-1', - option: { id: 'option-1' }, + credentialGroupName: 'Credential Group', + option: { id: 'option-1', provider: 'gmail' }, + }) + mocks.completeOAuth.mockResolvedValue({ + created: true, + credentialId: 'credential-1', + credentialGroupOptionId: 'option-1', + provider: 'gmail', + providerId: 'google-email', + displayName: 'person@example.com', + enrollmentStatus: 'in_progress', }) mocks.startOAuth.mockResolvedValue('https://accounts.example/authorize') }) @@ -74,7 +110,13 @@ describe('public Credential Group enrollment application operations', () => { const result = await readPublicCredentialGroupEnrollment.execute({ principal, input: {} }) expect(mocks.getEnrollment).toHaveBeenCalledWith(identity) - expect(result).toEqual({ enrollment: { status: 'invited', options: [] } }) + expect(result).toEqual({ + enrollment: { + status: 'invited', + credentialGroupName: 'Credential Group', + options: [], + }, + }) }) it('fails closed when the current invitation no longer resolves', async () => { @@ -104,4 +146,78 @@ describe('public Credential Group enrollment application operations', () => { expect(mocks.getOAuthContext).toHaveBeenCalledWith(identity, 'option-1') expect(result).toEqual({ authorizationUrl: 'https://accounts.example/authorize' }) }) + + it('fires form submitted only for the first completion transition', async () => { + mocks.completeEnrollment.mockResolvedValue({ completed: true, transitioned: true }) + + const result = await completePublicCredentialGroupEnrollment.execute({ + principal, + input: {}, + }) + + expect(result).toEqual({ completed: true }) + expect(mocks.fireTrigger).toHaveBeenCalledWith({ + event: 'form_submitted', + workspaceId: 'workspace-1', + credentialGroupId: 'group-1', + credentialGroupName: 'Credential Group', + enrollmentId: 'enrollment-1', + email: 'person@example.com', + enrollmentStatus: 'completed', + }) + + vi.clearAllMocks() + mocks.getEnrollment.mockResolvedValue({ + status: 'completed', + credentialGroupName: 'Credential Group', + options: [], + }) + mocks.completeEnrollment.mockResolvedValue({ completed: true, transitioned: false }) + + await completePublicCredentialGroupEnrollment.execute({ principal, input: {} }) + + expect(mocks.fireTrigger).not.toHaveBeenCalled() + }) + + it('distinguishes a new credential from a reconnection', async () => { + await completePublicCredentialGroupOAuth.execute({ + principal, + input: { attempt: oauthAttempt, code: 'authorization-code' }, + }) + + expect(mocks.fireTrigger).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'credential_added', + credentialGroupId: 'group-1', + enrollmentId: 'enrollment-1', + credential: expect.objectContaining({ credentialId: 'credential-1' }), + }) + ) + + vi.clearAllMocks() + mocks.getOAuthContext.mockResolvedValue({ + enrollmentId: 'enrollment-1', + credentialGroupId: 'group-1', + credentialGroupName: 'Credential Group', + option: { id: 'option-1', provider: 'gmail' }, + }) + mocks.completeOAuth.mockResolvedValue({ + created: false, + credentialId: 'credential-1', + credentialGroupOptionId: 'option-1', + provider: 'gmail', + providerId: 'google-email', + displayName: 'person@example.com', + enrollmentStatus: 'completed', + }) + + await completePublicCredentialGroupOAuth.execute({ + principal, + input: { attempt: oauthAttempt, code: 'authorization-code' }, + }) + + expect(mocks.fireTrigger).toHaveBeenCalledWith( + expect.objectContaining({ event: 'credential_reconnected', enrollmentStatus: 'completed' }) + ) + }) }) diff --git a/apps/sim/lib/credential-groups/application/public-enrollment.ts b/apps/sim/lib/credential-groups/application/public-enrollment.ts index ccda73d5984..5b3023aad8f 100644 --- a/apps/sim/lib/credential-groups/application/public-enrollment.ts +++ b/apps/sim/lib/credential-groups/application/public-enrollment.ts @@ -15,6 +15,7 @@ import { startCredentialGroupOAuth, } from '@/lib/credential-groups/oauth' import type { CredentialGroupOAuthAttempt } from '@/lib/credential-groups/oauth-state' +import { fireCredentialGroupTrigger } from '@/lib/credential-groups/trigger' interface AuthorizedCredentialGroupEnrollmentUseCaseDefinition { operation: O @@ -125,8 +126,19 @@ export const completePublicCredentialGroupEnrollment = operation: credentialGroupEnrollmentOperations.complete, resolveContext: ({ principal }) => resolvePublicEnrollmentContext(principal), async execute({ context }) { - const completed = await completeAuthorizedCredentialGroupEnrollment(context) - return { completed } + const completion = await completeAuthorizedCredentialGroupEnrollment(context) + if (completion?.transitioned) { + await fireCredentialGroupTrigger({ + event: 'form_submitted', + workspaceId: context.workspaceId, + credentialGroupId: context.credentialGroupId, + credentialGroupName: context.enrollment.credentialGroupName, + enrollmentId: context.enrollmentId, + email: context.email, + enrollmentStatus: 'completed', + }) + } + return { completed: completion?.completed ?? null } }, }) @@ -182,7 +194,23 @@ export const completePublicCredentialGroupOAuth = defineAuthorizedCredentialGrou }) => resolvePublicOAuthContext(principal, input.attempt.optionId), async execute({ principal, input, context }) { requireInvitationToken(principal, input.attempt.invitationToken) - await completeCredentialGroupOAuth(context.oauth, input.attempt, input.code) + const completion = await completeCredentialGroupOAuth(context.oauth, input.attempt, input.code) + await fireCredentialGroupTrigger({ + event: completion.created ? 'credential_added' : 'credential_reconnected', + workspaceId: context.workspaceId, + credentialGroupId: context.credentialGroupId, + credentialGroupName: context.oauth.credentialGroupName, + enrollmentId: context.enrollmentId, + email: context.email, + enrollmentStatus: completion.enrollmentStatus, + credential: { + credentialId: completion.credentialId, + credentialGroupOptionId: completion.credentialGroupOptionId, + provider: completion.provider, + providerId: completion.providerId, + displayName: completion.displayName, + }, + }) return { connectedOptionId: context.oauth.option.id } }, }) diff --git a/apps/sim/lib/credential-groups/enrollments.ts b/apps/sim/lib/credential-groups/enrollments.ts index 3f99519ab2f..f4579c77026 100644 --- a/apps/sim/lib/credential-groups/enrollments.ts +++ b/apps/sim/lib/credential-groups/enrollments.ts @@ -94,6 +94,7 @@ export interface PublicCredentialGroupEnrollment { export interface CredentialGroupOAuthContext { enrollmentId: string credentialGroupId: string + credentialGroupName: string workspaceId: string workspaceName: string workspaceOwnerId: string @@ -111,6 +112,11 @@ export interface PublicCredentialGroupEnrollmentIdentity { invitationTokenHash: string } +export interface CredentialGroupEnrollmentCompletion { + completed: true + transitioned: boolean +} + /** Serializes OAuth grant persistence and administrative revocation for one enrollment. */ export async function lockCredentialGroupEnrollmentLifecycle( executor: DbOrTx, @@ -854,12 +860,16 @@ export async function completeCredentialGroupEnrollment(token: string): Promise< invitationTokenHash: hashInvitationToken(token), }) if (!row) return null - return completeResolvedCredentialGroupEnrollment(row, identityForPublicEnrollmentRow(row)) + const result = await completeResolvedCredentialGroupEnrollment( + row, + identityForPublicEnrollmentRow(row) + ) + return result?.completed ?? null } export async function completeAuthorizedCredentialGroupEnrollment( identity: PublicCredentialGroupEnrollmentIdentity -): Promise { +): Promise { const row = await resolveAuthorizedPublicEnrollmentRow(identity) if (!row) return null return completeResolvedCredentialGroupEnrollment(row, identity) @@ -868,7 +878,7 @@ export async function completeAuthorizedCredentialGroupEnrollment( async function completeResolvedCredentialGroupEnrollment( row: NonNullable>>, identity: PublicCredentialGroupEnrollmentIdentity -): Promise { +): Promise { return db.transaction(async (tx) => { await lockCredentialGroupEnrollmentLifecycle(tx, row.enrollment.id) const now = new Date() @@ -906,9 +916,15 @@ async function completeResolvedCredentialGroupEnrollment( .for('update') if (!group || group.status !== 'active') return null + const transitioned = current.status !== 'completed' + const [completed] = await tx .update(credentialGroupEnrollment) - .set({ status: 'completed', completedAt: now, updatedAt: now }) + .set({ + status: 'completed', + ...(transitioned ? { completedAt: now } : {}), + updatedAt: now, + }) .where( and( eq(credentialGroupEnrollment.id, row.enrollment.id), @@ -917,7 +933,7 @@ async function completeResolvedCredentialGroupEnrollment( ) .returning({ id: credentialGroupEnrollment.id }) if (!completed) throw new Error('Credential group enrollment completion returned no row') - return true + return { completed: true, transitioned } }) } @@ -953,6 +969,7 @@ function credentialGroupOAuthContextFromRow( return { enrollmentId: row.enrollment.id, credentialGroupId: row.groupId, + credentialGroupName: row.groupName, workspaceId: row.workspaceId, workspaceName: row.workspaceName, workspaceOwnerId: row.workspaceOwnerId, diff --git a/apps/sim/lib/credential-groups/oauth.test.ts b/apps/sim/lib/credential-groups/oauth.test.ts index 798a7a951c2..d448f751314 100644 --- a/apps/sim/lib/credential-groups/oauth.test.ts +++ b/apps/sim/lib/credential-groups/oauth.test.ts @@ -46,6 +46,7 @@ const POLICY = { const CONTEXT = { enrollmentId: 'enrollment-1', credentialGroupId: 'group-1', + credentialGroupName: 'Credential Group', workspaceId: 'workspace-1', workspaceName: 'Workspace', workspaceOwnerId: 'owner-1', @@ -122,6 +123,46 @@ describe('credential group OAuth persistence', () => { expect(dbChainMockFns.insert).not.toHaveBeenCalled() }) + it('returns a created event result after inserting a first credential', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ status: 'invited' }]) + queueTableRows(schemaMock.credentialGroup, [GROUP]) + queueTableRows(schemaMock.credential, []) + dbChainMockFns.returning + .mockResolvedValueOnce([{ id: 'credential-1' }]) + .mockResolvedValueOnce([{ id: CONTEXT.enrollmentId }]) + + const result = await completeCredentialGroupOAuth( + CONTEXT, + { + state: 'state-1', + provider: 'gmail', + nonceHash: 'nonce-hash', + enrollmentId: CONTEXT.enrollmentId, + credentialGroupId: CONTEXT.credentialGroupId, + optionId: CONTEXT.option.id, + authorizationAppId: POLICY.authorizationAppId, + scopeVersion: POLICY.scopeVersion, + requiredScopes: POLICY.requiredScopes, + redirectUri: 'https://sim.ai/api/auth/oauth2/callback/google-email', + codeVerifier: 'verifier', + invitationToken: 'invitation-token', + createdAt: Date.now(), + }, + 'authorization-code' + ) + + expect(result).toEqual({ + created: true, + credentialId: 'credential-1', + credentialGroupOptionId: 'option-1', + provider: 'gmail', + providerId: 'google-email', + displayName: 'person@example.com', + enrollmentStatus: 'in_progress', + }) + expect(dbChainMockFns.insert).toHaveBeenCalledWith(schemaMock.credential) + }) + it('preserves completed enrollment state when an account reconnects', async () => { dbChainMockFns.limit.mockResolvedValueOnce([{ status: 'completed' }]) queueTableRows(schemaMock.credentialGroup, [GROUP]) @@ -137,7 +178,7 @@ describe('credential group OAuth persistence', () => { .mockResolvedValueOnce([{ id: 'credential-1' }]) .mockResolvedValueOnce([{ id: CONTEXT.enrollmentId }]) - await completeCredentialGroupOAuth( + const result = await completeCredentialGroupOAuth( { ...CONTEXT, enrollmentStatus: 'completed' }, { state: 'state-1', @@ -162,6 +203,15 @@ describe('credential group OAuth persistence', () => { expect.objectContaining({ status: 'completed', updatedAt: expect.any(Date) }) ) expect(enrollmentUpdate).not.toHaveProperty('completedAt') + expect(result).toEqual({ + created: false, + credentialId: 'credential-1', + credentialGroupOptionId: 'option-1', + provider: 'gmail', + providerId: 'google-email', + displayName: 'person@example.com', + enrollmentStatus: 'completed', + }) }) it('rejects an exchanged grant when the group policy changed before persistence', async () => { diff --git a/apps/sim/lib/credential-groups/oauth.ts b/apps/sim/lib/credential-groups/oauth.ts index 0aea1addc2a..107ba18a767 100644 --- a/apps/sim/lib/credential-groups/oauth.ts +++ b/apps/sim/lib/credential-groups/oauth.ts @@ -21,6 +21,7 @@ import { } from '@/lib/credential-groups/provider-adapter' import { getCredentialGroupProviderAdapter } from '@/lib/credential-groups/provider-registry' import { + type CredentialGroupProvider, getCredentialGroupProviderService, isCredentialGroupProvider, } from '@/lib/credential-groups/providers' @@ -58,6 +59,16 @@ function getOptionAdapter(context: CredentialGroupOAuthContext): CredentialGroup return getCredentialGroupProviderAdapter(context.option.provider) } +export interface CredentialGroupOAuthCompletion { + created: boolean + credentialId: string + credentialGroupOptionId: string + provider: CredentialGroupProvider + providerId: string + displayName: string + enrollmentStatus: 'in_progress' | 'completed' +} + async function assertCurrentPolicy( context: CredentialGroupOAuthContext, adapter: CredentialGroupProviderAdapter, @@ -111,12 +122,12 @@ async function persistGrant( adapter: CredentialGroupProviderAdapter, policy: CredentialGroupProviderPolicy, grant: VerifiedCredentialGroupGrant -): Promise { +): Promise { if (grant.providerId !== policy.providerId) { throw new CredentialGroupOAuthError('Provider returned a credential for another app.', 502) } - await db.transaction(async (tx) => { + return db.transaction(async (tx) => { await lockCredentialGroupEnrollmentLifecycle(tx, context.enrollmentId) await tx.execute( sql`SELECT pg_advisory_xact_lock(hashtextextended(${`credential-group-oauth:${context.enrollmentId}:${context.option.id}`}, 0))` @@ -231,6 +242,7 @@ async function persistGrant( updatedAt: now, } + let credentialId: string if (existing) { const [updated] = await tx .update(credential) @@ -238,6 +250,7 @@ async function persistGrant( .where(eq(credential.id, existing.id)) .returning({ id: credential.id }) if (!updated) throw new Error('Managed OAuth credential update returned no row') + credentialId = updated.id } else { const [inserted] = await tx .insert(credential) @@ -249,12 +262,14 @@ async function persistGrant( }) .returning({ id: credential.id }) if (!inserted) throw new Error('Managed OAuth credential insert returned no row') + credentialId = inserted.id } + const enrollmentStatus = enrollment.status === 'completed' ? 'completed' : 'in_progress' const [updatedEnrollment] = await tx .update(credentialGroupEnrollment) .set({ - status: enrollment.status === 'completed' ? 'completed' : 'in_progress', + status: enrollmentStatus, ...(enrollment.status === 'completed' ? {} : { completedAt: null }), updatedAt: now, }) @@ -268,6 +283,15 @@ async function persistGrant( if (!updatedEnrollment) { throw new CredentialGroupInvitationUnavailableError() } + return { + created: !existing, + credentialId, + credentialGroupOptionId: context.option.id, + provider: adapter.provider, + providerId: policy.providerId, + displayName: grant.displayName, + enrollmentStatus, + } }) } @@ -276,7 +300,7 @@ export async function completeCredentialGroupOAuth( context: CredentialGroupOAuthContext, attempt: CredentialGroupOAuthAttempt, code: string -): Promise { +): Promise { if ( attempt.enrollmentId !== context.enrollmentId || attempt.credentialGroupId !== context.credentialGroupId || @@ -288,5 +312,5 @@ export async function completeCredentialGroupOAuth( const adapter = getOptionAdapter(context) const policy = await assertCurrentPolicy(context, adapter, attempt) const grant = await adapter.exchangeAndVerify({ context, attempt, code, policy }) - await persistGrant(context, adapter, policy, grant) + return persistGrant(context, adapter, policy, grant) } diff --git a/apps/sim/lib/credential-groups/standard-oauth-provider.test.ts b/apps/sim/lib/credential-groups/standard-oauth-provider.test.ts index f1c568f6d3a..0e40b14acc1 100644 --- a/apps/sim/lib/credential-groups/standard-oauth-provider.test.ts +++ b/apps/sim/lib/credential-groups/standard-oauth-provider.test.ts @@ -87,6 +87,7 @@ function buildContext(): CredentialGroupOAuthContext { return { enrollmentId: 'enrollment-1', credentialGroupId: 'group-1', + credentialGroupName: 'Credential Group', workspaceId: 'workspace-1', workspaceName: 'Workspace', workspaceOwnerId: 'owner-1', diff --git a/apps/sim/lib/credential-groups/trigger-constants.ts b/apps/sim/lib/credential-groups/trigger-constants.ts new file mode 100644 index 00000000000..446b2b878b0 --- /dev/null +++ b/apps/sim/lib/credential-groups/trigger-constants.ts @@ -0,0 +1,16 @@ +export const CREDENTIAL_GROUP_TRIGGER_PROVIDER = 'credential-group' + +export const CREDENTIAL_GROUP_EVENT_TRIGGER_ID = 'credential_group_event' + +export const CREDENTIAL_GROUP_TRIGGER_EVENT_TYPES = [ + 'credential_added', + 'credential_reconnected', + 'form_submitted', +] as const + +export type CredentialGroupTriggerEventType = (typeof CREDENTIAL_GROUP_TRIGGER_EVENT_TYPES)[number] + +export const CREDENTIAL_GROUP_CREDENTIAL_EVENT_TYPES = [ + 'credential_added', + 'credential_reconnected', +] as const satisfies readonly CredentialGroupTriggerEventType[] diff --git a/apps/sim/lib/credential-groups/trigger-subscriptions.ts b/apps/sim/lib/credential-groups/trigger-subscriptions.ts new file mode 100644 index 00000000000..9c27581f6ac --- /dev/null +++ b/apps/sim/lib/credential-groups/trigger-subscriptions.ts @@ -0,0 +1,44 @@ +import { db } from '@sim/db' +import { webhook, workflow, workflowDeploymentVersion } from '@sim/db/schema' +import { and, eq, inArray, isNull, or } from 'drizzle-orm' +import { CREDENTIAL_GROUP_TRIGGER_PROVIDER } from '@/lib/credential-groups/trigger-constants' +import { deliverableWebhookPredicate } from '@/lib/webhooks/delivery-predicate' +import type { WebhookRecord, WorkflowRecord } from '@/lib/webhooks/polling/types' + +export interface CredentialGroupTriggerSubscription { + webhook: WebhookRecord + workflow: WorkflowRecord +} + +/** Loads only deployed subscriptions in the source workspace that may read this group. */ +export async function fetchCredentialGroupTriggerSubscriptions( + workspaceId: string, + allowedWorkflowIds: string[] +): Promise { + if (allowedWorkflowIds.length === 0) return [] + return db + .select({ webhook, workflow }) + .from(webhook) + .innerJoin(workflow, eq(webhook.workflowId, workflow.id)) + .leftJoin( + workflowDeploymentVersion, + and( + eq(workflowDeploymentVersion.workflowId, workflow.id), + eq(workflowDeploymentVersion.isActive, true) + ) + ) + .where( + and( + eq(webhook.provider, CREDENTIAL_GROUP_TRIGGER_PROVIDER), + deliverableWebhookPredicate(webhook), + eq(workflow.workspaceId, workspaceId), + inArray(workflow.id, allowedWorkflowIds), + eq(workflow.isDeployed, true), + isNull(workflow.archivedAt), + or( + eq(webhook.deploymentVersionId, workflowDeploymentVersion.id), + and(isNull(workflowDeploymentVersion.id), isNull(webhook.deploymentVersionId)) + ) + ) + ) +} diff --git a/apps/sim/lib/credential-groups/trigger.test.ts b/apps/sim/lib/credential-groups/trigger.test.ts new file mode 100644 index 00000000000..e4ef96731de --- /dev/null +++ b/apps/sim/lib/credential-groups/trigger.test.ts @@ -0,0 +1,141 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + decodePolicy: vi.fn(), + fetchSubscriptions: vi.fn(), + processEvent: vi.fn(), + requirePolicy: vi.fn(), +})) + +vi.mock('@/lib/credential-groups/application/workflow-access-policy', () => ({ + credentialGroupWorkflowAccessPolicyCodec: { + resourceType: 'credential_group', + parse: (value: unknown) => value, + }, + decodeCredentialGroupWorkflowAccessPolicy: mocks.decodePolicy, +})) + +vi.mock('@/lib/resource-policies/repository', () => ({ + requireResourcePolicy: mocks.requirePolicy, +})) + +vi.mock('@/lib/credential-groups/trigger-subscriptions', () => ({ + fetchCredentialGroupTriggerSubscriptions: mocks.fetchSubscriptions, +})) + +vi.mock('@/lib/webhooks/processor', () => ({ + processPolledWebhookEvent: mocks.processEvent, +})) + +import { + buildCredentialGroupTriggerPayload, + fireCredentialGroupTrigger, +} from '@/lib/credential-groups/trigger' + +const EVENT = { + event: 'credential_added' as const, + workspaceId: 'workspace-1', + credentialGroupId: 'group-1', + credentialGroupName: 'Credential Group', + enrollmentId: 'enrollment-1', + email: 'person@example.com', + enrollmentStatus: 'in_progress' as const, + credential: { + credentialId: 'credential-1', + credentialGroupOptionId: 'option-1', + provider: 'gmail' as const, + providerId: 'google-email', + displayName: 'person@example.com', + }, +} + +function subscription(params: { + workflowId: string + workspaceId?: string + eventType?: string + credentialGroupId?: string +}) { + return { + webhook: { + id: `webhook-${params.workflowId}`, + providerConfig: { + triggerId: 'credential_group_event', + credentialGroupId: params.credentialGroupId ?? 'group-1', + eventType: params.eventType ?? 'credential_added', + }, + }, + workflow: { + id: params.workflowId, + workspaceId: params.workspaceId ?? 'workspace-1', + }, + } +} + +describe('Credential Group trigger delivery', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.requirePolicy.mockResolvedValue({ document: {} }) + mocks.decodePolicy.mockReturnValue(['workflow-allowed']) + mocks.processEvent.mockResolvedValue({ success: true }) + }) + + it('delivers only to an allowed workflow watching the exact group and event', async () => { + const allowed = subscription({ workflowId: 'workflow-allowed' }) + mocks.fetchSubscriptions.mockResolvedValue([ + allowed, + subscription({ workflowId: 'workflow-denied' }), + subscription({ workflowId: 'workflow-allowed', eventType: 'form_submitted' }), + subscription({ workflowId: 'workflow-allowed', credentialGroupId: 'group-2' }), + subscription({ workflowId: 'workflow-allowed', workspaceId: 'workspace-2' }), + ]) + + await fireCredentialGroupTrigger(EVENT) + + expect(mocks.processEvent).toHaveBeenCalledOnce() + expect(mocks.processEvent).toHaveBeenCalledWith( + allowed.webhook, + allowed.workflow, + expect.objectContaining({ + event: 'credential_added', + credentialGroupId: 'group-1', + credentialId: 'credential-1', + }), + expect.any(String) + ) + }) + + it('does not scan subscriptions when no workflow has group access', async () => { + mocks.decodePolicy.mockReturnValue([]) + + await fireCredentialGroupTrigger(EVENT) + + expect(mocks.fetchSubscriptions).not.toHaveBeenCalled() + expect(mocks.processEvent).not.toHaveBeenCalled() + }) + + it('uses null credential fields for form submissions', () => { + expect( + buildCredentialGroupTriggerPayload({ + event: 'form_submitted', + workspaceId: 'workspace-1', + credentialGroupId: 'group-1', + credentialGroupName: 'Credential Group', + enrollmentId: 'enrollment-1', + email: 'person@example.com', + enrollmentStatus: 'completed', + }) + ).toEqual( + expect.objectContaining({ + event: 'form_submitted', + credentialId: null, + credentialGroupOptionId: null, + provider: null, + providerId: null, + displayName: null, + }) + ) + }) +}) diff --git a/apps/sim/lib/credential-groups/trigger.ts b/apps/sim/lib/credential-groups/trigger.ts new file mode 100644 index 00000000000..31504ed1526 --- /dev/null +++ b/apps/sim/lib/credential-groups/trigger.ts @@ -0,0 +1,167 @@ +import { createLogger } from '@sim/logger' +import { generateShortId } from '@sim/utils/id' +import { isRecordLike } from '@sim/utils/object' +import { + credentialGroupWorkflowAccessPolicyCodec, + decodeCredentialGroupWorkflowAccessPolicy, +} from '@/lib/credential-groups/application/workflow-access-policy' +import type { CredentialGroupProvider } from '@/lib/credential-groups/providers' +import { + CREDENTIAL_GROUP_EVENT_TRIGGER_ID, + CREDENTIAL_GROUP_TRIGGER_EVENT_TYPES, + type CredentialGroupTriggerEventType, +} from '@/lib/credential-groups/trigger-constants' +import { fetchCredentialGroupTriggerSubscriptions } from '@/lib/credential-groups/trigger-subscriptions' +import { requireResourcePolicy } from '@/lib/resource-policies/repository' + +const logger = createLogger('CredentialGroupTrigger') + +interface CredentialGroupTriggerEventBase { + workspaceId: string + credentialGroupId: string + credentialGroupName: string + enrollmentId: string + email: string + enrollmentStatus: 'in_progress' | 'completed' +} + +interface CredentialGroupTriggerCredential { + credentialId: string + credentialGroupOptionId: string + provider: CredentialGroupProvider + providerId: string + displayName: string +} + +export type CredentialGroupTriggerEvent = + | (CredentialGroupTriggerEventBase & { + event: 'credential_added' | 'credential_reconnected' + credential: CredentialGroupTriggerCredential + }) + | (CredentialGroupTriggerEventBase & { + event: 'form_submitted' + credential?: never + }) + +export interface CredentialGroupTriggerPayload { + event: CredentialGroupTriggerEventType + timestamp: string + credentialGroupId: string + credentialGroupName: string + enrollmentId: string + email: string + enrollmentStatus: 'in_progress' | 'completed' + credentialId: string | null + credentialGroupOptionId: string | null + provider: CredentialGroupProvider | null + providerId: string | null + displayName: string | null +} + +interface CredentialGroupTriggerConfig { + triggerId: typeof CREDENTIAL_GROUP_EVENT_TRIGGER_ID + credentialGroupId: string + eventType: CredentialGroupTriggerEventType +} + +function parseCredentialGroupTriggerConfig(value: unknown): CredentialGroupTriggerConfig { + if (!isRecordLike(value)) throw new Error('Credential Group trigger config must be an object') + if (value.triggerId !== CREDENTIAL_GROUP_EVENT_TRIGGER_ID) { + throw new Error('Credential Group trigger ID is invalid') + } + if ( + typeof value.credentialGroupId !== 'string' || + !value.credentialGroupId.trim() || + value.credentialGroupId !== value.credentialGroupId.trim() + ) { + throw new Error('Credential Group trigger requires a canonical Credential Group ID') + } + if ( + typeof value.eventType !== 'string' || + !(CREDENTIAL_GROUP_TRIGGER_EVENT_TYPES as readonly string[]).includes(value.eventType) + ) { + throw new Error('Credential Group trigger event type is invalid') + } + return { + triggerId: CREDENTIAL_GROUP_EVENT_TRIGGER_ID, + credentialGroupId: value.credentialGroupId, + eventType: value.eventType as CredentialGroupTriggerEventType, + } +} + +export function buildCredentialGroupTriggerPayload( + event: CredentialGroupTriggerEvent +): CredentialGroupTriggerPayload { + const credential = event.event === 'form_submitted' ? null : event.credential + return { + event: event.event, + timestamp: new Date().toISOString(), + credentialGroupId: event.credentialGroupId, + credentialGroupName: event.credentialGroupName, + enrollmentId: event.enrollmentId, + email: event.email, + enrollmentStatus: event.enrollmentStatus, + credentialId: credential?.credentialId ?? null, + credentialGroupOptionId: credential?.credentialGroupOptionId ?? null, + provider: credential?.provider ?? null, + providerId: credential?.providerId ?? null, + displayName: credential?.displayName ?? null, + } +} + +/** + * Fires deployed Credential Group triggers after the source mutation commits. + * Delivery is restricted to workflows explicitly allowed by the group's resource policy. + */ +export async function fireCredentialGroupTrigger( + event: CredentialGroupTriggerEvent +): Promise { + try { + const policy = await requireResourcePolicy({ + workspaceId: event.workspaceId, + resourceType: 'credential_group', + resourceId: event.credentialGroupId, + codec: credentialGroupWorkflowAccessPolicyCodec, + }) + const allowedWorkflowIds = new Set( + decodeCredentialGroupWorkflowAccessPolicy(policy.document, event.credentialGroupId) + ) + if (allowedWorkflowIds.size === 0) return + + const subscriptions = await fetchCredentialGroupTriggerSubscriptions(event.workspaceId, [ + ...allowedWorkflowIds, + ]) + const matchingSubscriptions = subscriptions.filter(({ webhook, workflow }) => { + if (workflow.workspaceId !== event.workspaceId) return false + if (!allowedWorkflowIds.has(workflow.id)) return false + const config = parseCredentialGroupTriggerConfig(webhook.providerConfig) + return ( + config.credentialGroupId === event.credentialGroupId && config.eventType === event.event + ) + }) + if (matchingSubscriptions.length === 0) return + + const payload = buildCredentialGroupTriggerPayload(event) + const { processPolledWebhookEvent } = await import('@/lib/webhooks/processor') + for (const { webhook, workflow } of matchingSubscriptions) { + const requestId = generateShortId() + const result = await processPolledWebhookEvent(webhook, workflow, payload, requestId) + if (!result.success) { + logger.error(`[${requestId}] Failed to fire Credential Group trigger`, { + event: event.event, + credentialGroupId: event.credentialGroupId, + subscriberWorkflowId: workflow.id, + statusCode: result.statusCode, + error: result.error, + }) + } + } + } catch (error) { + logger.error('Failed to emit Credential Group event', { + error, + event: event.event, + credentialGroupId: event.credentialGroupId, + enrollmentId: event.enrollmentId, + }) + } +} diff --git a/apps/sim/lib/webhooks/providers/credential-group.ts b/apps/sim/lib/webhooks/providers/credential-group.ts new file mode 100644 index 00000000000..4fd7d64c18c --- /dev/null +++ b/apps/sim/lib/webhooks/providers/credential-group.ts @@ -0,0 +1,12 @@ +import type { + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' + +export const credentialGroupProviderHandler: WebhookProviderHandler = { + executionMode: 'queue', + async formatInput({ body }: FormatInputContext): Promise { + return { input: body } + }, +} diff --git a/apps/sim/lib/webhooks/providers/registry.ts b/apps/sim/lib/webhooks/providers/registry.ts index 0cad20b8d12..a9531f5c4ea 100644 --- a/apps/sim/lib/webhooks/providers/registry.ts +++ b/apps/sim/lib/webhooks/providers/registry.ts @@ -11,6 +11,7 @@ import { circlebackHandler } from '@/lib/webhooks/providers/circleback' import { clerkHandler } from '@/lib/webhooks/providers/clerk' import { clickupHandler } from '@/lib/webhooks/providers/clickup' import { confluenceHandler } from '@/lib/webhooks/providers/confluence' +import { credentialGroupProviderHandler } from '@/lib/webhooks/providers/credential-group' import { emailBisonHandler } from '@/lib/webhooks/providers/emailbison' import { fathomHandler } from '@/lib/webhooks/providers/fathom' import { firefliesHandler } from '@/lib/webhooks/providers/fireflies' @@ -78,6 +79,7 @@ const PROVIDER_HANDLERS: Record = { clerk: clerkHandler, clickup: clickupHandler, confluence: confluenceHandler, + 'credential-group': credentialGroupProviderHandler, emailbison: emailBisonHandler, fireflies: firefliesHandler, generic: genericHandler, diff --git a/apps/sim/triggers/constants.ts b/apps/sim/triggers/constants.ts index 82277ef90b6..df74354f692 100644 --- a/apps/sim/triggers/constants.ts +++ b/apps/sim/triggers/constants.ts @@ -85,7 +85,7 @@ export function isPollingWebhookProvider(provider: string | null): boolean { * register a path, so the public trigger route must reject deliveries to * them — otherwise anyone with the block ID could forge events. */ -export const INTERNAL_TRIGGER_PROVIDERS = new Set(['sim', 'table']) +export const INTERNAL_TRIGGER_PROVIDERS = new Set(['credential-group', 'sim', 'table']) export function isInternalTriggerProvider(provider: string | null): boolean { return provider !== null && INTERNAL_TRIGGER_PROVIDERS.has(provider) diff --git a/apps/sim/triggers/credential-group/event.test.ts b/apps/sim/triggers/credential-group/event.test.ts new file mode 100644 index 00000000000..354db66ace2 --- /dev/null +++ b/apps/sim/triggers/credential-group/event.test.ts @@ -0,0 +1,36 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { buildCredentialGroupTriggerPayload } from '@/lib/credential-groups/trigger' +import { CREDENTIAL_GROUP_TRIGGER_EVENT_TYPES } from '@/lib/credential-groups/trigger-constants' +import { credentialGroupEventTrigger } from '@/triggers/credential-group/event' + +describe('Credential Group trigger definition', () => { + it('exposes the supported lifecycle events', () => { + const eventType = credentialGroupEventTrigger.subBlocks.find( + (subBlock) => subBlock.id === 'eventType' + ) + const optionIds = Array.isArray(eventType?.options) + ? eventType.options.map((option) => option.id) + : [] + + expect(optionIds).toEqual(CREDENTIAL_GROUP_TRIGGER_EVENT_TYPES) + }) + + it('keeps declared outputs aligned with runtime payload keys', () => { + const payload = buildCredentialGroupTriggerPayload({ + event: 'form_submitted', + workspaceId: 'workspace-1', + credentialGroupId: 'group-1', + credentialGroupName: 'Credential Group', + enrollmentId: 'enrollment-1', + email: 'person@example.com', + enrollmentStatus: 'completed', + }) + + expect(Object.keys(payload).sort()).toEqual( + Object.keys(credentialGroupEventTrigger.outputs).sort() + ) + }) +}) diff --git a/apps/sim/triggers/credential-group/event.ts b/apps/sim/triggers/credential-group/event.ts new file mode 100644 index 00000000000..913dc1991d4 --- /dev/null +++ b/apps/sim/triggers/credential-group/event.ts @@ -0,0 +1,129 @@ +import { GridOffset } from '@sim/emcn/icons' +import { + CREDENTIAL_GROUP_CREDENTIAL_EVENT_TYPES, + CREDENTIAL_GROUP_EVENT_TRIGGER_ID, + CREDENTIAL_GROUP_TRIGGER_PROVIDER, +} from '@/lib/credential-groups/trigger-constants' +import type { TriggerConfig } from '@/triggers/types' + +export const credentialGroupEventTrigger: TriggerConfig = { + id: CREDENTIAL_GROUP_EVENT_TRIGGER_ID, + name: 'Credential Group Event', + provider: CREDENTIAL_GROUP_TRIGGER_PROVIDER, + description: + 'Triggers when a credential is added or reconnected, or when a Credential Group form is submitted', + version: '1.0.0', + icon: GridOffset, + + subBlocks: [ + { + id: 'eventType', + title: 'Event', + type: 'dropdown', + options: [ + { id: 'credential_added', label: 'Credential Added' }, + { id: 'credential_reconnected', label: 'Credential Reconnected' }, + { id: 'form_submitted', label: 'Credential Group Form Submitted' }, + ], + defaultValue: 'credential_added', + description: 'The Credential Group event to trigger on.', + required: true, + mode: 'trigger', + }, + { + id: 'credentialGroup', + title: 'Credential Group', + type: 'dropdown', + selectorKey: 'workspace.credentialGroups', + placeholder: 'Select a Credential Group', + description: 'The Credential Group to monitor.', + required: true, + mode: 'trigger', + canonicalParamId: 'credentialGroupId', + }, + { + id: 'manualCredentialGroup', + title: 'Credential Group ID', + type: 'short-input', + placeholder: 'Enter Credential Group ID', + description: 'The Credential Group to monitor.', + required: true, + mode: 'trigger-advanced', + canonicalParamId: 'credentialGroupId', + }, + { + id: 'triggerInstructions', + title: 'Setup Instructions', + hideFromPreview: true, + type: 'text', + defaultValue: [ + 'Select the Credential Group to monitor', + 'Choose whether to trigger on a new credential, a reconnection, or a submitted form', + 'Grant this workflow access to the Credential Group', + 'Deploy the workflow to start receiving events', + ] + .map( + (instruction, index) => + `
${index + 1}. ${instruction}
` + ) + .join(''), + mode: 'trigger', + }, + ], + + outputs: { + event: { + type: 'string', + description: 'The Credential Group event that fired the trigger', + }, + timestamp: { + type: 'string', + description: 'Event timestamp in ISO format', + }, + credentialGroupId: { + type: 'string', + description: 'Credential Group ID', + }, + credentialGroupName: { + type: 'string', + description: 'Credential Group name', + }, + enrollmentId: { + type: 'string', + description: 'Credential Group enrollment ID', + }, + email: { + type: 'string', + description: 'Enrollment email address', + }, + enrollmentStatus: { + type: 'string', + description: 'Enrollment status after the event', + }, + credentialId: { + type: 'string', + description: 'Managed credential ID', + condition: { field: 'eventType', value: [...CREDENTIAL_GROUP_CREDENTIAL_EVENT_TYPES] }, + }, + credentialGroupOptionId: { + type: 'string', + description: 'Credential Group option ID', + condition: { field: 'eventType', value: [...CREDENTIAL_GROUP_CREDENTIAL_EVENT_TYPES] }, + }, + provider: { + type: 'string', + description: 'Credential Group provider', + condition: { field: 'eventType', value: [...CREDENTIAL_GROUP_CREDENTIAL_EVENT_TYPES] }, + }, + providerId: { + type: 'string', + description: 'OAuth provider ID for the managed credential', + condition: { field: 'eventType', value: [...CREDENTIAL_GROUP_CREDENTIAL_EVENT_TYPES] }, + }, + displayName: { + type: 'string', + description: 'Display name of the connected account', + condition: { field: 'eventType', value: [...CREDENTIAL_GROUP_CREDENTIAL_EVENT_TYPES] }, + }, + }, +} diff --git a/apps/sim/triggers/credential-group/index.ts b/apps/sim/triggers/credential-group/index.ts new file mode 100644 index 00000000000..63d659ef498 --- /dev/null +++ b/apps/sim/triggers/credential-group/index.ts @@ -0,0 +1 @@ +export { credentialGroupEventTrigger } from '@/triggers/credential-group/event' diff --git a/apps/sim/triggers/registry.ts b/apps/sim/triggers/registry.ts index 49e11b01728..4c447635d9e 100644 --- a/apps/sim/triggers/registry.ts +++ b/apps/sim/triggers/registry.ts @@ -162,6 +162,7 @@ import { confluenceUserCreatedTrigger, confluenceWebhookTrigger, } from '@/triggers/confluence' +import { credentialGroupEventTrigger } from '@/triggers/credential-group' import { emailBisonEmailAccountAddedTrigger, emailBisonEmailAccountDisconnectedTrigger, @@ -640,6 +641,7 @@ export const TRIGGER_REGISTRY: TriggerRegistry = { confluence_space_removed: confluenceSpaceRemovedTrigger, confluence_page_permissions_updated: confluencePagePermissionsUpdatedTrigger, confluence_user_created: confluenceUserCreatedTrigger, + credential_group_event: credentialGroupEventTrigger, emailbison_email_sent: emailBisonEmailSentTrigger, emailbison_lead_first_contacted: emailBisonLeadFirstContactedTrigger, emailbison_lead_replied: emailBisonLeadRepliedTrigger,