diff --git a/.agents/skills/add-trigger/SKILL.md b/.agents/skills/add-trigger/SKILL.md index fbf27ef625d..fd6df46e505 100644 --- a/.agents/skills/add-trigger/SKILL.md +++ b/.agents/skills/add-trigger/SKILL.md @@ -3,63 +3,57 @@ name: add-trigger description: Create or update Sim webhook triggers using the generic trigger builder, service-specific setup instructions, outputs, and registry wiring. Use when working in `apps/sim/triggers/{service}/` or adding webhook support to an integration. --- -# Add Trigger Skill +# Add Trigger You are an expert at creating webhook triggers for Sim. You understand the trigger system, the generic `buildTriggerSubBlocks` helper, and how triggers connect to blocks. ## Your Task -When the user asks you to create triggers for a service: 1. Research what webhook events the service supports 2. Create the trigger files using the generic builder -3. Register triggers and connect them to the block +3. Create a provider handler if custom auth, formatting, or subscriptions are needed +4. Register triggers and connect them to the block ## Directory Structure ``` apps/sim/triggers/{service}/ ├── index.ts # Barrel exports -├── utils.ts # Service-specific helpers (trigger options, setup instructions, extra fields) +├── utils.ts # Service-specific helpers (options, instructions, extra fields, outputs) ├── {event_a}.ts # Primary trigger (includes dropdown) ├── {event_b}.ts # Secondary trigger (no dropdown) -├── {event_c}.ts # Secondary trigger (no dropdown) └── webhook.ts # Generic webhook trigger (optional, for "all events") + +apps/sim/lib/webhooks/ +├── provider-subscription-utils.ts # Shared subscription helpers (getProviderConfig, getNotificationUrl) +├── providers/ +│ ├── {service}.ts # Provider handler (auth, formatInput, matchEvent, subscriptions) +│ ├── types.ts # WebhookProviderHandler interface +│ ├── utils.ts # Shared helpers (createHmacVerifier, verifyTokenAuth, skipByEventTypes) +│ └── registry.ts # Handler map + default handler ``` -## Step 1: Create utils.ts +## Step 1: Create `utils.ts` -This file contains service-specific helpers used by all triggers. +This file contains all service-specific helpers used by triggers. ```typescript import type { SubBlockConfig } from '@/blocks/types' import type { TriggerOutput } from '@/triggers/types' -/** - * Dropdown options for the trigger type selector. - * These appear in the primary trigger's dropdown. - */ export const {service}TriggerOptions = [ { label: 'Event A', id: '{service}_event_a' }, { label: 'Event B', id: '{service}_event_b' }, - { label: 'Event C', id: '{service}_event_c' }, - { label: 'Generic Webhook (All Events)', id: '{service}_webhook' }, ] -/** - * Generates HTML setup instructions for the trigger. - * Displayed to users to help them configure webhooks in the external service. - */ export function {service}SetupInstructions(eventType: string): string { const instructions = [ 'Copy the Webhook URL above', 'Go to {Service} Settings > Webhooks', - 'Click Add Webhook', - 'Paste the webhook URL', `Select the ${eventType} event type`, - 'Save the webhook configuration', + 'Paste the webhook URL and save', 'Click "Save" above to activate your trigger', ] - return instructions .map((instruction, index) => `
${index + 1}. ${instruction}
` @@ -67,10 +61,6 @@ export function {service}SetupInstructions(eventType: string): string { .join('') } -/** - * Service-specific extra fields to add to triggers. - * These are inserted between webhookUrl and triggerSave. - */ export function build{Service}ExtraFields(triggerId: string): SubBlockConfig[] { return [ { @@ -78,53 +68,34 @@ export function build{Service}ExtraFields(triggerId: string): SubBlockConfig[] { title: 'Project ID (Optional)', type: 'short-input', placeholder: 'Leave empty for all projects', - description: 'Optionally filter to a specific project', mode: 'trigger', condition: { field: 'selectedTriggerId', value: triggerId }, }, ] } -/** - * Build outputs for this trigger type. - * Outputs define what data is available to downstream blocks. - */ export function build{Service}Outputs(): Record { return { - eventType: { type: 'string', description: 'The type of event that triggered this workflow' }, + eventType: { type: 'string', description: 'The type of event' }, resourceId: { type: 'string', description: 'ID of the affected resource' }, - timestamp: { type: 'string', description: 'When the event occurred (ISO 8601)' }, - // Nested outputs for complex data resource: { id: { type: 'string', description: 'Resource ID' }, name: { type: 'string', description: 'Resource name' }, - status: { type: 'string', description: 'Current status' }, }, - webhook: { type: 'json', description: 'Full webhook payload' }, } } ``` -## Step 2: Create the Primary Trigger +## Step 2: Create Trigger Files -The **primary trigger** is the first one listed. It MUST include `includeDropdown: true` so users can switch between trigger types. +**Primary trigger** — MUST include `includeDropdown: true`: ```typescript import { {Service}Icon } from '@/components/icons' import { buildTriggerSubBlocks } from '@/triggers' -import { - build{Service}ExtraFields, - build{Service}Outputs, - {service}SetupInstructions, - {service}TriggerOptions, -} from '@/triggers/{service}/utils' +import { build{Service}ExtraFields, build{Service}Outputs, {service}SetupInstructions, {service}TriggerOptions } from '@/triggers/{service}/utils' import type { TriggerConfig } from '@/triggers/types' -/** - * {Service} Event A Trigger - * - * This is the PRIMARY trigger - it includes the dropdown for selecting trigger type. - */ export const {service}EventATrigger: TriggerConfig = { id: '{service}_event_a', name: '{Service} Event A', @@ -132,496 +103,222 @@ export const {service}EventATrigger: TriggerConfig = { description: 'Trigger workflow when Event A occurs', version: '1.0.0', icon: {Service}Icon, - subBlocks: buildTriggerSubBlocks({ triggerId: '{service}_event_a', triggerOptions: {service}TriggerOptions, - includeDropdown: true, // PRIMARY TRIGGER - includes dropdown + includeDropdown: true, setupInstructions: {service}SetupInstructions('Event A'), extraFields: build{Service}ExtraFields('{service}_event_a'), }), - outputs: build{Service}Outputs(), - - webhook: { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - }, + webhook: { method: 'POST', headers: { 'Content-Type': 'application/json' } }, } ``` -## Step 3: Create Secondary Triggers - -Secondary triggers do NOT include the dropdown (it's already in the primary trigger). +**Secondary triggers** — NO `includeDropdown` (it's already in the primary): ```typescript -import { {Service}Icon } from '@/components/icons' -import { buildTriggerSubBlocks } from '@/triggers' -import { - build{Service}ExtraFields, - build{Service}Outputs, - {service}SetupInstructions, - {service}TriggerOptions, -} from '@/triggers/{service}/utils' -import type { TriggerConfig } from '@/triggers/types' - -/** - * {Service} Event B Trigger - */ export const {service}EventBTrigger: TriggerConfig = { - id: '{service}_event_b', - name: '{Service} Event B', - provider: '{service}', - description: 'Trigger workflow when Event B occurs', - version: '1.0.0', - icon: {Service}Icon, - - subBlocks: buildTriggerSubBlocks({ - triggerId: '{service}_event_b', - triggerOptions: {service}TriggerOptions, - // NO includeDropdown - secondary trigger - setupInstructions: {service}SetupInstructions('Event B'), - extraFields: build{Service}ExtraFields('{service}_event_b'), - }), - - outputs: build{Service}Outputs(), - - webhook: { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - }, + // Same as above but: id: '{service}_event_b', no includeDropdown } ``` -## Step 4: Create index.ts Barrel Export +## Step 3: Register and Wire + +### `apps/sim/triggers/{service}/index.ts` ```typescript export { {service}EventATrigger } from './event_a' export { {service}EventBTrigger } from './event_b' -export { {service}EventCTrigger } from './event_c' -export { {service}WebhookTrigger } from './webhook' ``` -## Step 5: Register Triggers - -### Trigger Registry (`apps/sim/triggers/registry.ts`) +### `apps/sim/triggers/registry.ts` ```typescript -// Add import -import { - {service}EventATrigger, - {service}EventBTrigger, - {service}EventCTrigger, - {service}WebhookTrigger, -} from '@/triggers/{service}' - -// Add to TRIGGER_REGISTRY +import { {service}EventATrigger, {service}EventBTrigger } from '@/triggers/{service}' + export const TRIGGER_REGISTRY: TriggerRegistry = { - // ... existing triggers ... + // ... existing ... {service}_event_a: {service}EventATrigger, {service}_event_b: {service}EventBTrigger, - {service}_event_c: {service}EventCTrigger, - {service}_webhook: {service}WebhookTrigger, } ``` -## Step 6: Connect Triggers to Block - -In the block file (`apps/sim/blocks/blocks/{service}.ts`): +### Block file (`apps/sim/blocks/blocks/{service}.ts`) ```typescript -import { {Service}Icon } from '@/components/icons' import { getTrigger } from '@/triggers' -import type { BlockConfig } from '@/blocks/types' export const {Service}Block: BlockConfig = { - type: '{service}', - name: '{Service}', - // ... other config ... - - // Enable triggers and list available trigger IDs + // ... triggers: { enabled: true, - available: [ - '{service}_event_a', - '{service}_event_b', - '{service}_event_c', - '{service}_webhook', - ], + available: ['{service}_event_a', '{service}_event_b'], }, - subBlocks: [ - // Regular tool subBlocks first - { id: 'operation', /* ... */ }, - { id: 'credential', /* ... */ }, - // ... other tool fields ... - - // Then spread ALL trigger subBlocks + // Regular tool subBlocks first... ...getTrigger('{service}_event_a').subBlocks, ...getTrigger('{service}_event_b').subBlocks, - ...getTrigger('{service}_event_c').subBlocks, - ...getTrigger('{service}_webhook').subBlocks, ], - - // ... tools config ... } ``` -## Automatic Webhook Registration (Preferred) - -If the service's API supports programmatic webhook creation, implement automatic webhook registration instead of requiring users to manually configure webhooks. This provides a much better user experience. +## Provider Handler -### When to Use Automatic Registration +All provider-specific webhook logic lives in a single handler file: `apps/sim/lib/webhooks/providers/{service}.ts`. -Check the service's API documentation for endpoints like: -- `POST /webhooks` or `POST /hooks` - Create webhook -- `DELETE /webhooks/{id}` - Delete webhook +### When to Create a Handler -Services that support this pattern include: Grain, Lemlist, Calendly, Airtable, Webflow, Typeform, etc. +| Behavior | Method | Examples | +|---|---|---| +| HMAC signature auth | `verifyAuth` via `createHmacVerifier` | Ashby, Jira, Linear, Typeform | +| Custom token auth | `verifyAuth` via `verifyTokenAuth` | Generic, Google Forms | +| Event filtering | `matchEvent` | GitHub, Jira, Attio, HubSpot | +| Idempotency dedup | `extractIdempotencyId` | Slack, Stripe, Linear, Jira | +| Custom input formatting | `formatInput` | Slack, Teams, Attio, Ashby | +| Auto webhook creation | `createSubscription` | Ashby, Grain, Calendly, Airtable | +| Auto webhook deletion | `deleteSubscription` | Ashby, Grain, Calendly, Airtable | +| Challenge/verification | `handleChallenge` | Slack, WhatsApp, Teams | +| Custom success response | `formatSuccessResponse` | Slack, Twilio Voice, Teams | -### Implementation Steps +If none apply, you don't need a handler. The default handler provides bearer token auth. -#### 1. Add API Key to Extra Fields - -Update your `build{Service}ExtraFields` function to include an API key field: +### Example Handler ```typescript -export function build{Service}ExtraFields(triggerId: string): SubBlockConfig[] { - return [ - { - id: 'apiKey', - title: 'API Key', - type: 'short-input', - placeholder: 'Enter your {Service} API key', - description: 'Required to create the webhook in {Service}.', - password: true, - required: true, - mode: 'trigger', - condition: { field: 'selectedTriggerId', value: triggerId }, - }, - // Other optional fields (e.g., campaign filter, project filter) - { - id: 'projectId', - title: 'Project ID (Optional)', - type: 'short-input', - placeholder: 'Leave empty for all projects', - mode: 'trigger', - condition: { field: 'selectedTriggerId', value: triggerId }, - }, - ] +import crypto from 'crypto' +import { createLogger } from '@sim/logger' +import { safeCompare } from '@/lib/core/security/encryption' +import type { EventMatchContext, FormatInputContext, FormatInputResult, WebhookProviderHandler } from '@/lib/webhooks/providers/types' +import { createHmacVerifier } from '@/lib/webhooks/providers/utils' + +const logger = createLogger('WebhookProvider:{Service}') + +function validate{Service}Signature(secret: string, signature: string, body: string): boolean { + if (!secret || !signature || !body) return false + const computed = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') + return safeCompare(computed, signature) } -``` - -#### 2. Update Setup Instructions for Automatic Creation - -Change instructions to indicate automatic webhook creation: - -```typescript -export function {service}SetupInstructions(eventType: string): string { - const instructions = [ - 'Enter your {Service} API Key above.', - 'You can find your API key in {Service} at Settings > API.', - `Click "Save Configuration" to automatically create the webhook in {Service} for ${eventType} events.`, - 'The webhook will be automatically deleted when you remove this trigger.', - ] - return instructions - .map((instruction, index) => - `
${index + 1}. ${instruction}
` - ) - .join('') -} -``` - -#### 3. Add Webhook Creation to API Route - -In `apps/sim/app/api/webhooks/route.ts`, add provider-specific logic after the database save: - -```typescript -// --- {Service} specific logic --- -if (savedWebhook && provider === '{service}') { - logger.info(`[${requestId}] {Service} provider detected. Creating webhook subscription.`) - try { - const result = await create{Service}WebhookSubscription( - { - id: savedWebhook.id, - path: savedWebhook.path, - providerConfig: savedWebhook.providerConfig, - }, - requestId - ) - - if (result) { - // Update the webhook record with the external webhook ID - const updatedConfig = { - ...(savedWebhook.providerConfig as Record), - externalId: result.id, - } - await db - .update(webhook) - .set({ - providerConfig: updatedConfig, - updatedAt: new Date(), - }) - .where(eq(webhook.id, savedWebhook.id)) - - savedWebhook.providerConfig = updatedConfig - logger.info(`[${requestId}] Successfully created {Service} webhook`, { - externalHookId: result.id, - webhookId: savedWebhook.id, - }) - } - } catch (err) { - logger.error( - `[${requestId}] Error creating {Service} webhook subscription, rolling back webhook`, - err - ) - await db.delete(webhook).where(eq(webhook.id, savedWebhook.id)) - return NextResponse.json( - { - error: 'Failed to create webhook in {Service}', - details: err instanceof Error ? err.message : 'Unknown error', - }, - { status: 500 } - ) - } -} -// --- End {Service} specific logic --- -``` - -Then add the helper function at the end of the file: - -```typescript -async function create{Service}WebhookSubscription( - webhookData: any, - requestId: string -): Promise<{ id: string } | undefined> { - try { - const { path, providerConfig } = webhookData - const { apiKey, triggerId, projectId } = providerConfig || {} - - if (!apiKey) { - throw new Error('{Service} API Key is required.') - } - - // Map trigger IDs to service event types - const eventTypeMap: Record = { - {service}_event_a: 'eventA', - {service}_event_b: 'eventB', - {service}_webhook: undefined, // Generic - no filter - } - - const eventType = eventTypeMap[triggerId] - const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` - - const requestBody: Record = { - url: notificationUrl, - } - - if (eventType) { - requestBody.eventType = eventType - } +export const {service}Handler: WebhookProviderHandler = { + verifyAuth: createHmacVerifier({ + configKey: 'webhookSecret', + headerName: 'X-{Service}-Signature', + validateFn: validate{Service}Signature, + providerLabel: '{Service}', + }), - if (projectId) { - requestBody.projectId = projectId + async matchEvent({ body, requestId, providerConfig }: EventMatchContext) { + const triggerId = providerConfig.triggerId as string | undefined + if (triggerId && triggerId !== '{service}_webhook') { + const { is{Service}EventMatch } = await import('@/triggers/{service}/utils') + if (!is{Service}EventMatch(triggerId, body as Record)) return false } + return true + }, - const response = await fetch('https://api.{service}.com/webhooks', { - method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', + async formatInput({ body }: FormatInputContext): Promise { + const b = body as Record + return { + input: { + eventType: b.type, + resourceId: (b.data as Record)?.id || '', + resource: b.data, }, - body: JSON.stringify(requestBody), - }) - - const responseBody = await response.json() - - if (!response.ok) { - const errorMessage = responseBody.message || 'Unknown API error' - let userFriendlyMessage = 'Failed to create webhook in {Service}' - - if (response.status === 401) { - userFriendlyMessage = 'Invalid API Key. Please verify and try again.' - } else if (errorMessage) { - userFriendlyMessage = `{Service} error: ${errorMessage}` - } - - throw new Error(userFriendlyMessage) } + }, - return { id: responseBody.id } - } catch (error: any) { - logger.error(`Exception during {Service} webhook creation`, { error: error.message }) - throw error - } + extractIdempotencyId(body: unknown) { + const obj = body as Record + return obj.id && obj.type ? `${obj.type}:${obj.id}` : null + }, } ``` -#### 4. Add Webhook Deletion to Provider Subscriptions - -In `apps/sim/lib/webhooks/provider-subscriptions.ts`: +### Register the Handler -1. Add a logger: -```typescript -const {service}Logger = createLogger('{Service}Webhook') -``` - -2. Add the delete function: -```typescript -export async function delete{Service}Webhook(webhook: any, requestId: string): Promise { - try { - const config = getProviderConfig(webhook) - const apiKey = config.apiKey as string | undefined - const externalId = config.externalId as string | undefined - - if (!apiKey || !externalId) { - {service}Logger.warn(`[${requestId}] Missing apiKey or externalId, skipping cleanup`) - return - } - - const response = await fetch(`https://api.{service}.com/webhooks/${externalId}`, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${apiKey}`, - }, - }) - - if (!response.ok && response.status !== 404) { - {service}Logger.warn(`[${requestId}] Failed to delete webhook (non-fatal): ${response.status}`) - } else { - {service}Logger.info(`[${requestId}] Successfully deleted webhook ${externalId}`) - } - } catch (error) { - {service}Logger.warn(`[${requestId}] Error deleting webhook (non-fatal)`, error) - } -} -``` +In `apps/sim/lib/webhooks/providers/registry.ts`: -3. Add to `cleanupExternalWebhook`: ```typescript -export async function cleanupExternalWebhook(...): Promise { - // ... existing providers ... - } else if (webhook.provider === '{service}') { - await delete{Service}Webhook(webhook, requestId) - } -} -``` - -### Key Points for Automatic Registration - -- **API Key visibility**: Always use `password: true` for API key fields -- **Error handling**: Roll back the database webhook if external creation fails -- **External ID storage**: Save the external webhook ID in `providerConfig.externalId` -- **Graceful cleanup**: Don't fail webhook deletion if cleanup fails (use non-fatal logging) -- **User-friendly errors**: Map HTTP status codes to helpful error messages +import { {service}Handler } from '@/lib/webhooks/providers/{service}' -## The buildTriggerSubBlocks Helper - -This is the generic helper from `@/triggers` that creates consistent trigger subBlocks. - -### Function Signature - -```typescript -interface BuildTriggerSubBlocksOptions { - triggerId: string // e.g., 'service_event_a' - triggerOptions: Array<{ label: string; id: string }> // Dropdown options - includeDropdown?: boolean // true only for primary trigger - setupInstructions: string // HTML instructions - extraFields?: SubBlockConfig[] // Service-specific fields - webhookPlaceholder?: string // Custom placeholder text +const PROVIDER_HANDLERS: Record = { + // ... existing (alphabetical) ... + {service}: {service}Handler, } - -function buildTriggerSubBlocks(options: BuildTriggerSubBlocksOptions): SubBlockConfig[] ``` -### What It Creates +## Output Alignment (Critical) -The helper creates this structure: -1. **Dropdown** (only if `includeDropdown: true`) - Trigger type selector -2. **Webhook URL** - Read-only field with copy button -3. **Extra Fields** - Your service-specific fields (filters, options, etc.) -4. **Save Button** - Activates the trigger -5. **Instructions** - Setup guide for users +There are two sources of truth that **MUST be aligned**: -All fields automatically have: -- `mode: 'trigger'` - Only shown in trigger mode -- `condition: { field: 'selectedTriggerId', value: triggerId }` - Only shown when this trigger is selected +1. **Trigger `outputs`** — schema defining what fields SHOULD be available (UI tag dropdown) +2. **`formatInput` on the handler** — implementation that transforms raw payload into actual data -## Trigger Outputs & Webhook Input Formatting +If they differ: the tag dropdown shows fields that don't exist, or actual data has fields users can't discover. -### Important: Two Sources of Truth +**Rules for `formatInput`:** +- Return `{ input: { ... } }` where inner keys match trigger `outputs` exactly +- Return `{ input: ..., skip: { message: '...' } }` to skip execution +- No wrapper objects or duplication +- Use `null` for missing optional data -There are two related but separate concerns: +## Automatic Webhook Registration -1. **Trigger `outputs`** - Schema/contract defining what fields SHOULD be available. Used by UI for tag dropdown. -2. **`formatWebhookInput`** - Implementation that transforms raw webhook payload into actual data. Located in `apps/sim/lib/webhooks/utils.server.ts`. +If the service API supports programmatic webhook creation, implement `createSubscription` and `deleteSubscription` on the handler. The orchestration layer calls these automatically — **no code touches `route.ts`, `provider-subscriptions.ts`, or `deploy.ts`**. -**These MUST be aligned.** The fields returned by `formatWebhookInput` should match what's defined in trigger `outputs`. If they differ: -- Tag dropdown shows fields that don't exist (broken variable resolution) -- Or actual data has fields not shown in dropdown (users can't discover them) - -### When to Add a formatWebhookInput Handler +```typescript +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' +import type { DeleteSubscriptionContext, SubscriptionContext, SubscriptionResult } from '@/lib/webhooks/providers/types' -- **Simple providers**: If the raw webhook payload structure already matches your outputs, you don't need a handler. The generic fallback returns `body` directly. -- **Complex providers**: If you need to transform, flatten, extract nested data, compute fields, or handle conditional logic, add a handler. +export const {service}Handler: WebhookProviderHandler = { + async createSubscription(ctx: SubscriptionContext): Promise { + const config = getProviderConfig(ctx.webhook) + const apiKey = config.apiKey as string + if (!apiKey) throw new Error('{Service} API Key is required.') -### Adding a Handler + const res = await fetch('https://api.{service}.com/webhooks', { + method: 'POST', + headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: getNotificationUrl(ctx.webhook) }), + }) -In `apps/sim/lib/webhooks/utils.server.ts`, add a handler block: + if (!res.ok) throw new Error(`{Service} error: ${res.status}`) + const { id } = (await res.json()) as { id: string } + return { providerConfigUpdates: { externalId: id } } + }, -```typescript -if (foundWebhook.provider === '{service}') { - // Transform raw webhook body to match trigger outputs - return { - eventType: body.type, - resourceId: body.data?.id || '', - timestamp: body.created_at, - resource: body.data, - } + async deleteSubscription(ctx: DeleteSubscriptionContext): Promise { + const config = getProviderConfig(ctx.webhook) + const { apiKey, externalId } = config as { apiKey?: string; externalId?: string } + if (!apiKey || !externalId) return + await fetch(`https://api.{service}.com/webhooks/${externalId}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${apiKey}` }, + }).catch(() => {}) + }, } ``` -**Key rules:** -- Return fields that match your trigger `outputs` definition exactly -- No wrapper objects like `webhook: { data: ... }` or `{service}: { ... }` -- No duplication (don't spread body AND add individual fields) -- Use `null` for missing optional data, not empty objects with empty strings +**Key points:** +- Throw from `createSubscription` — orchestration rolls back the DB webhook +- Never throw from `deleteSubscription` — log non-fatally +- Return `{ providerConfigUpdates: { externalId } }` — orchestration merges into `providerConfig` +- Add `apiKey` field to `build{Service}ExtraFields` with `password: true` -### Verify Alignment - -Run the alignment checker: -```bash -bunx scripts/check-trigger-alignment.ts {service} -``` - -## Trigger Outputs +## Trigger Outputs Schema Trigger outputs use the same schema as block outputs (NOT tool outputs). -**Supported:** -- `type` and `description` for simple fields -- Nested object structure for complex data - -**NOT Supported:** -- `optional: true` (tool outputs only) -- `items` property (tool outputs only) +**Supported:** `type` + `description` for leaf fields, nested objects for complex data. +**NOT supported:** `optional: true`, `items` (those are tool-output-only features). ```typescript export function buildOutputs(): Record { return { - // Simple fields eventType: { type: 'string', description: 'Event type' }, timestamp: { type: 'string', description: 'When it occurred' }, - - // Complex data - use type: 'json' payload: { type: 'json', description: 'Full event payload' }, - - // Nested structure resource: { id: { type: 'string', description: 'Resource ID' }, name: { type: 'string', description: 'Resource name' }, @@ -630,79 +327,32 @@ export function buildOutputs(): Record { } ``` -## Generic Webhook Trigger Pattern - -For services with many event types, create a generic webhook that accepts all events: - -```typescript -export const {service}WebhookTrigger: TriggerConfig = { - id: '{service}_webhook', - name: '{Service} Webhook (All Events)', - // ... - - subBlocks: buildTriggerSubBlocks({ - triggerId: '{service}_webhook', - triggerOptions: {service}TriggerOptions, - setupInstructions: {service}SetupInstructions('All Events'), - extraFields: [ - // Event type filter (optional) - { - id: 'eventTypes', - title: 'Event Types', - type: 'dropdown', - multiSelect: true, - options: [ - { label: 'Event A', id: 'event_a' }, - { label: 'Event B', id: 'event_b' }, - ], - placeholder: 'Leave empty for all events', - mode: 'trigger', - condition: { field: 'selectedTriggerId', value: '{service}_webhook' }, - }, - // Plus any other service-specific fields - ...build{Service}ExtraFields('{service}_webhook'), - ], - }), -} -``` - -## Checklist Before Finishing - -### Utils -- [ ] Created `{service}TriggerOptions` array with all trigger IDs -- [ ] Created `{service}SetupInstructions` function with clear steps -- [ ] Created `build{Service}ExtraFields` for service-specific fields -- [ ] Created output builders for each trigger type +## Checklist -### Triggers -- [ ] Primary trigger has `includeDropdown: true` -- [ ] Secondary triggers do NOT have `includeDropdown` +### Trigger Definition +- [ ] Created `utils.ts` with options, instructions, extra fields, and output builders +- [ ] Primary trigger has `includeDropdown: true`; secondary triggers do NOT - [ ] All triggers use `buildTriggerSubBlocks` helper -- [ ] All triggers have proper outputs defined - [ ] Created `index.ts` barrel export ### Registration -- [ ] All triggers imported in `triggers/registry.ts` -- [ ] All triggers added to `TRIGGER_REGISTRY` -- [ ] Block has `triggers.enabled: true` -- [ ] Block has all trigger IDs in `triggers.available` +- [ ] All triggers in `triggers/registry.ts` → `TRIGGER_REGISTRY` +- [ ] Block has `triggers.enabled: true` and lists all trigger IDs in `triggers.available` - [ ] Block spreads all trigger subBlocks: `...getTrigger('id').subBlocks` -### Automatic Webhook Registration (if supported) -- [ ] Added API key field to `build{Service}ExtraFields` with `password: true` -- [ ] Updated setup instructions for automatic webhook creation -- [ ] Added provider-specific logic to `apps/sim/app/api/webhooks/route.ts` -- [ ] Added `create{Service}WebhookSubscription` helper function -- [ ] Added `delete{Service}Webhook` function to `provider-subscriptions.ts` -- [ ] Added provider to `cleanupExternalWebhook` function +### Provider Handler (if needed) +- [ ] Handler file at `apps/sim/lib/webhooks/providers/{service}.ts` +- [ ] Registered in `providers/registry.ts` (alphabetical) +- [ ] Signature validator is a private function inside the handler file +- [ ] `formatInput` output keys match trigger `outputs` exactly +- [ ] Event matching uses dynamic `await import()` for trigger utils -### Webhook Input Formatting -- [ ] Added handler in `apps/sim/lib/webhooks/utils.server.ts` (if custom formatting needed) -- [ ] Handler returns fields matching trigger `outputs` exactly -- [ ] Run `bunx scripts/check-trigger-alignment.ts {service}` to verify alignment +### Auto Registration (if supported) +- [ ] `createSubscription` and `deleteSubscription` on the handler +- [ ] NO changes to `route.ts`, `provider-subscriptions.ts`, or `deploy.ts` +- [ ] API key field uses `password: true` ### Testing -- [ ] Run `bun run type-check` to verify no TypeScript errors -- [ ] Restart dev server to pick up new triggers -- [ ] Test trigger UI shows correctly in the block -- [ ] Test automatic webhook creation works (if applicable) +- [ ] `bun run type-check` passes +- [ ] Manually verify `formatInput` output keys match trigger `outputs` keys +- [ ] Trigger UI shows correctly in the block diff --git a/.agents/skills/validate-trigger/SKILL.md b/.agents/skills/validate-trigger/SKILL.md new file mode 100644 index 00000000000..ff1eb775b44 --- /dev/null +++ b/.agents/skills/validate-trigger/SKILL.md @@ -0,0 +1,212 @@ +--- +name: validate-trigger +description: Audit an existing Sim webhook trigger against the service's webhook API docs and repository conventions, then report and fix issues across trigger definitions, provider handler, output alignment, registration, and security. Use when validating or repairing a trigger under `apps/sim/triggers/{service}/` or `apps/sim/lib/webhooks/providers/{service}.ts`. +--- + +# Validate Trigger + +You are an expert auditor for Sim webhook triggers. Your job is to validate that an existing trigger implementation is correct, complete, secure, and aligned across all layers. + +## Your Task + +1. Read the service's webhook/API documentation (via WebFetch) +2. Read every trigger file, provider handler, and registry entry +3. Cross-reference against the API docs and Sim conventions +4. Report all issues grouped by severity (critical, warning, suggestion) +5. Fix all issues after reporting them + +## Step 1: Gather All Files + +Read **every** file for the trigger — do not skip any: + +``` +apps/sim/triggers/{service}/ # All trigger files, utils.ts, index.ts +apps/sim/lib/webhooks/providers/{service}.ts # Provider handler (if exists) +apps/sim/lib/webhooks/providers/registry.ts # Handler registry +apps/sim/triggers/registry.ts # Trigger registry +apps/sim/blocks/blocks/{service}.ts # Block definition (trigger wiring) +``` + +Also read for reference: +``` +apps/sim/lib/webhooks/providers/types.ts # WebhookProviderHandler interface +apps/sim/lib/webhooks/providers/utils.ts # Shared helpers (createHmacVerifier, etc.) +apps/sim/lib/webhooks/provider-subscription-utils.ts # Subscription helpers +apps/sim/lib/webhooks/processor.ts # Central webhook processor +``` + +## Step 2: Pull API Documentation + +Fetch the service's official webhook documentation. This is the **source of truth** for: +- Webhook event types and payload shapes +- Signature/auth verification method (HMAC algorithm, header names, secret format) +- Challenge/verification handshake requirements +- Webhook subscription API (create/delete endpoints, if applicable) +- Retry behavior and delivery guarantees + +## Step 3: Validate Trigger Definitions + +### utils.ts +- [ ] `{service}TriggerOptions` lists all trigger IDs accurately +- [ ] `{service}SetupInstructions` provides clear, correct steps for the service +- [ ] `build{Service}ExtraFields` includes relevant filter/config fields with correct `condition` +- [ ] Output builders expose all meaningful fields from the webhook payload +- [ ] Output builders do NOT use `optional: true` or `items` (tool-output-only features) +- [ ] Nested output objects correctly model the payload structure + +### Trigger Files +- [ ] Exactly one primary trigger has `includeDropdown: true` +- [ ] All secondary triggers do NOT have `includeDropdown` +- [ ] All triggers use `buildTriggerSubBlocks` helper (not hand-rolled subBlocks) +- [ ] Every trigger's `id` matches the convention `{service}_{event_name}` +- [ ] Every trigger's `provider` matches the service name used in the handler registry +- [ ] `index.ts` barrel exports all triggers + +### Trigger ↔ Provider Alignment (CRITICAL) +- [ ] Every trigger ID referenced in `matchEvent` logic exists in `{service}TriggerOptions` +- [ ] Event matching logic in the provider correctly maps trigger IDs to service event types +- [ ] Event matching logic in `is{Service}EventMatch` (if exists) correctly identifies events per the API docs + +## Step 4: Validate Provider Handler + +### Auth Verification +- [ ] `verifyAuth` correctly validates webhook signatures per the service's documentation +- [ ] HMAC algorithm matches (SHA-1, SHA-256, SHA-512) +- [ ] Signature header name matches the API docs exactly +- [ ] Signature format is handled (raw hex, `sha256=` prefix, base64, etc.) +- [ ] Uses `safeCompare` for timing-safe comparison (no `===`) +- [ ] If `webhookSecret` is required, handler rejects when it's missing (fail-closed) +- [ ] Signature is computed over raw body (not parsed JSON) + +### Event Matching +- [ ] `matchEvent` returns `boolean` (not `NextResponse` or other values) +- [ ] Challenge/verification events are excluded from matching (e.g., `endpoint.url_validation`) +- [ ] When `triggerId` is a generic webhook ID, all events pass through +- [ ] When `triggerId` is specific, only matching events pass +- [ ] Event matching logic uses dynamic `await import()` for trigger utils (avoids circular deps) + +### formatInput (CRITICAL) +- [ ] Every key in the `formatInput` return matches a key in the trigger `outputs` schema +- [ ] Every key in the trigger `outputs` schema is populated by `formatInput` +- [ ] No extra undeclared keys that users can't discover in the UI +- [ ] No wrapper objects (`webhook: { ... }`, `{service}: { ... }`) +- [ ] Nested output paths exist at the correct depth (e.g., `resource.id` actually has `resource: { id: ... }`) +- [ ] `null` is used for missing optional fields (not empty strings or empty objects) +- [ ] Returns `{ input: { ... } }` — not a bare object + +### Idempotency +- [ ] `extractIdempotencyId` returns a stable, unique key per delivery +- [ ] Uses provider-specific delivery IDs when available (e.g., `X-Request-Id`, `Linear-Delivery`, `svix-id`) +- [ ] Falls back to content-based ID (e.g., `${type}:${id}`) when no delivery header exists +- [ ] Does NOT include timestamps in the idempotency key (would break dedup on retries) + +### Challenge Handling (if applicable) +- [ ] `handleChallenge` correctly implements the service's URL verification handshake +- [ ] Returns the expected response format per the API docs +- [ ] Env-backed secrets are resolved via `resolveEnvVarsInObject` if needed + +## Step 5: Validate Automatic Subscription Lifecycle + +If the service supports programmatic webhook creation: + +### createSubscription +- [ ] Calls the correct API endpoint to create a webhook +- [ ] Sends the correct event types/filters +- [ ] Passes the notification URL from `getNotificationUrl(ctx.webhook)` +- [ ] Returns `{ providerConfigUpdates: { externalId } }` with the external webhook ID +- [ ] Throws on failure (orchestration handles rollback) +- [ ] Provides user-friendly error messages (401 → "Invalid API Key", etc.) + +### deleteSubscription +- [ ] Calls the correct API endpoint to delete the webhook +- [ ] Handles 404 gracefully (webhook already deleted) +- [ ] Never throws — catches errors and logs non-fatally +- [ ] Skips gracefully when `apiKey` or `externalId` is missing + +### Orchestration Isolation +- [ ] NO provider-specific logic in `route.ts`, `provider-subscriptions.ts`, or `deploy.ts` +- [ ] All subscription logic lives on the handler (`createSubscription`/`deleteSubscription`) + +## Step 6: Validate Registration and Block Wiring + +### Trigger Registry (`triggers/registry.ts`) +- [ ] All triggers are imported and registered +- [ ] Registry keys match trigger IDs exactly +- [ ] No orphaned entries (triggers that don't exist) + +### Provider Handler Registry (`providers/registry.ts`) +- [ ] Handler is imported and registered (if handler exists) +- [ ] Registry key matches the `provider` field on the trigger configs +- [ ] Entries are in alphabetical order + +### Block Wiring (`blocks/blocks/{service}.ts`) +- [ ] Block has `triggers.enabled: true` +- [ ] `triggers.available` lists all trigger IDs +- [ ] All trigger subBlocks are spread into `subBlocks`: `...getTrigger('id').subBlocks` +- [ ] No trigger IDs in `triggers.available` that aren't in the registry +- [ ] No trigger subBlocks spread that aren't in `triggers.available` + +## Step 7: Validate Security + +- [ ] Webhook secrets are never logged (not even at debug level) +- [ ] Auth verification runs before any event processing +- [ ] No secret comparison uses `===` (must use `safeCompare` or `crypto.timingSafeEqual`) +- [ ] Timestamp/replay protection is reasonable (not too tight for retries, not too loose for security) +- [ ] Raw body is used for signature verification (not re-serialized JSON) + +## Step 8: Report and Fix + +### Report Format + +Group findings by severity: + +**Critical** (runtime errors, security issues, or data loss): +- Wrong HMAC algorithm or header name +- `formatInput` keys don't match trigger `outputs` +- Missing `verifyAuth` when the service sends signed webhooks +- `matchEvent` returns non-boolean values +- Provider-specific logic leaking into shared orchestration files +- Trigger IDs mismatch between trigger files, registry, and block +- `createSubscription` calling wrong API endpoint +- Auth comparison using `===` instead of `safeCompare` + +**Warning** (convention violations or usability issues): +- Missing `extractIdempotencyId` when the service provides delivery IDs +- Timestamps in idempotency keys (breaks dedup on retries) +- Missing challenge handling when the service requires URL verification +- Output schema missing fields that `formatInput` returns (undiscoverable data) +- Overly tight timestamp skew window that rejects legitimate retries +- `matchEvent` not filtering challenge/verification events +- Setup instructions missing important steps + +**Suggestion** (minor improvements): +- More specific output field descriptions +- Additional output fields that could be exposed +- Better error messages in `createSubscription` +- Logging improvements + +### Fix All Issues + +After reporting, fix every **critical** and **warning** issue. Apply **suggestions** where they don't add unnecessary complexity. + +### Validation Output + +After fixing, confirm: +1. `bun run type-check` passes +2. Re-read all modified files to verify fixes are correct +3. Provider handler tests pass (if they exist): `bun test {service}` + +## Checklist Summary + +- [ ] Read all trigger files, provider handler, types, registries, and block +- [ ] Pulled and read official webhook/API documentation +- [ ] Validated trigger definitions: options, instructions, extra fields, outputs +- [ ] Validated primary/secondary trigger distinction (`includeDropdown`) +- [ ] Validated provider handler: auth, matchEvent, formatInput, idempotency +- [ ] Validated output alignment: every `outputs` key ↔ every `formatInput` key +- [ ] Validated subscription lifecycle: createSubscription, deleteSubscription, no shared-file edits +- [ ] Validated registration: trigger registry, handler registry, block wiring +- [ ] Validated security: safe comparison, no secret logging, replay protection +- [ ] Reported all issues grouped by severity +- [ ] Fixed all critical and warning issues +- [ ] `bun run type-check` passes after fixes diff --git a/.claude/commands/add-trigger.md b/.claude/commands/add-trigger.md index d252bf61666..9cbeca68a3e 100644 --- a/.claude/commands/add-trigger.md +++ b/.claude/commands/add-trigger.md @@ -3,63 +3,57 @@ description: Create webhook triggers for a Sim integration using the generic tri argument-hint: --- -# Add Trigger Skill +# Add Trigger You are an expert at creating webhook triggers for Sim. You understand the trigger system, the generic `buildTriggerSubBlocks` helper, and how triggers connect to blocks. ## Your Task -When the user asks you to create triggers for a service: 1. Research what webhook events the service supports 2. Create the trigger files using the generic builder -3. Register triggers and connect them to the block +3. Create a provider handler if custom auth, formatting, or subscriptions are needed +4. Register triggers and connect them to the block ## Directory Structure ``` apps/sim/triggers/{service}/ ├── index.ts # Barrel exports -├── utils.ts # Service-specific helpers (trigger options, setup instructions, extra fields) +├── utils.ts # Service-specific helpers (options, instructions, extra fields, outputs) ├── {event_a}.ts # Primary trigger (includes dropdown) ├── {event_b}.ts # Secondary trigger (no dropdown) -├── {event_c}.ts # Secondary trigger (no dropdown) └── webhook.ts # Generic webhook trigger (optional, for "all events") + +apps/sim/lib/webhooks/ +├── provider-subscription-utils.ts # Shared subscription helpers (getProviderConfig, getNotificationUrl) +├── providers/ +│ ├── {service}.ts # Provider handler (auth, formatInput, matchEvent, subscriptions) +│ ├── types.ts # WebhookProviderHandler interface +│ ├── utils.ts # Shared helpers (createHmacVerifier, verifyTokenAuth, skipByEventTypes) +│ └── registry.ts # Handler map + default handler ``` -## Step 1: Create utils.ts +## Step 1: Create `utils.ts` -This file contains service-specific helpers used by all triggers. +This file contains all service-specific helpers used by triggers. ```typescript import type { SubBlockConfig } from '@/blocks/types' import type { TriggerOutput } from '@/triggers/types' -/** - * Dropdown options for the trigger type selector. - * These appear in the primary trigger's dropdown. - */ export const {service}TriggerOptions = [ { label: 'Event A', id: '{service}_event_a' }, { label: 'Event B', id: '{service}_event_b' }, - { label: 'Event C', id: '{service}_event_c' }, - { label: 'Generic Webhook (All Events)', id: '{service}_webhook' }, ] -/** - * Generates HTML setup instructions for the trigger. - * Displayed to users to help them configure webhooks in the external service. - */ export function {service}SetupInstructions(eventType: string): string { const instructions = [ 'Copy the Webhook URL above', 'Go to {Service} Settings > Webhooks', - 'Click Add Webhook', - 'Paste the webhook URL', `Select the ${eventType} event type`, - 'Save the webhook configuration', + 'Paste the webhook URL and save', 'Click "Save" above to activate your trigger', ] - return instructions .map((instruction, index) => `
${index + 1}. ${instruction}
` @@ -67,10 +61,6 @@ export function {service}SetupInstructions(eventType: string): string { .join('') } -/** - * Service-specific extra fields to add to triggers. - * These are inserted between webhookUrl and triggerSave. - */ export function build{Service}ExtraFields(triggerId: string): SubBlockConfig[] { return [ { @@ -78,53 +68,34 @@ export function build{Service}ExtraFields(triggerId: string): SubBlockConfig[] { title: 'Project ID (Optional)', type: 'short-input', placeholder: 'Leave empty for all projects', - description: 'Optionally filter to a specific project', mode: 'trigger', condition: { field: 'selectedTriggerId', value: triggerId }, }, ] } -/** - * Build outputs for this trigger type. - * Outputs define what data is available to downstream blocks. - */ export function build{Service}Outputs(): Record { return { - eventType: { type: 'string', description: 'The type of event that triggered this workflow' }, + eventType: { type: 'string', description: 'The type of event' }, resourceId: { type: 'string', description: 'ID of the affected resource' }, - timestamp: { type: 'string', description: 'When the event occurred (ISO 8601)' }, - // Nested outputs for complex data resource: { id: { type: 'string', description: 'Resource ID' }, name: { type: 'string', description: 'Resource name' }, - status: { type: 'string', description: 'Current status' }, }, - webhook: { type: 'json', description: 'Full webhook payload' }, } } ``` -## Step 2: Create the Primary Trigger +## Step 2: Create Trigger Files -The **primary trigger** is the first one listed. It MUST include `includeDropdown: true` so users can switch between trigger types. +**Primary trigger** — MUST include `includeDropdown: true`: ```typescript import { {Service}Icon } from '@/components/icons' import { buildTriggerSubBlocks } from '@/triggers' -import { - build{Service}ExtraFields, - build{Service}Outputs, - {service}SetupInstructions, - {service}TriggerOptions, -} from '@/triggers/{service}/utils' +import { build{Service}ExtraFields, build{Service}Outputs, {service}SetupInstructions, {service}TriggerOptions } from '@/triggers/{service}/utils' import type { TriggerConfig } from '@/triggers/types' -/** - * {Service} Event A Trigger - * - * This is the PRIMARY trigger - it includes the dropdown for selecting trigger type. - */ export const {service}EventATrigger: TriggerConfig = { id: '{service}_event_a', name: '{Service} Event A', @@ -132,496 +103,222 @@ export const {service}EventATrigger: TriggerConfig = { description: 'Trigger workflow when Event A occurs', version: '1.0.0', icon: {Service}Icon, - subBlocks: buildTriggerSubBlocks({ triggerId: '{service}_event_a', triggerOptions: {service}TriggerOptions, - includeDropdown: true, // PRIMARY TRIGGER - includes dropdown + includeDropdown: true, setupInstructions: {service}SetupInstructions('Event A'), extraFields: build{Service}ExtraFields('{service}_event_a'), }), - outputs: build{Service}Outputs(), - - webhook: { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - }, + webhook: { method: 'POST', headers: { 'Content-Type': 'application/json' } }, } ``` -## Step 3: Create Secondary Triggers - -Secondary triggers do NOT include the dropdown (it's already in the primary trigger). +**Secondary triggers** — NO `includeDropdown` (it's already in the primary): ```typescript -import { {Service}Icon } from '@/components/icons' -import { buildTriggerSubBlocks } from '@/triggers' -import { - build{Service}ExtraFields, - build{Service}Outputs, - {service}SetupInstructions, - {service}TriggerOptions, -} from '@/triggers/{service}/utils' -import type { TriggerConfig } from '@/triggers/types' - -/** - * {Service} Event B Trigger - */ export const {service}EventBTrigger: TriggerConfig = { - id: '{service}_event_b', - name: '{Service} Event B', - provider: '{service}', - description: 'Trigger workflow when Event B occurs', - version: '1.0.0', - icon: {Service}Icon, - - subBlocks: buildTriggerSubBlocks({ - triggerId: '{service}_event_b', - triggerOptions: {service}TriggerOptions, - // NO includeDropdown - secondary trigger - setupInstructions: {service}SetupInstructions('Event B'), - extraFields: build{Service}ExtraFields('{service}_event_b'), - }), - - outputs: build{Service}Outputs(), - - webhook: { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - }, + // Same as above but: id: '{service}_event_b', no includeDropdown } ``` -## Step 4: Create index.ts Barrel Export +## Step 3: Register and Wire + +### `apps/sim/triggers/{service}/index.ts` ```typescript export { {service}EventATrigger } from './event_a' export { {service}EventBTrigger } from './event_b' -export { {service}EventCTrigger } from './event_c' -export { {service}WebhookTrigger } from './webhook' ``` -## Step 5: Register Triggers - -### Trigger Registry (`apps/sim/triggers/registry.ts`) +### `apps/sim/triggers/registry.ts` ```typescript -// Add import -import { - {service}EventATrigger, - {service}EventBTrigger, - {service}EventCTrigger, - {service}WebhookTrigger, -} from '@/triggers/{service}' - -// Add to TRIGGER_REGISTRY +import { {service}EventATrigger, {service}EventBTrigger } from '@/triggers/{service}' + export const TRIGGER_REGISTRY: TriggerRegistry = { - // ... existing triggers ... + // ... existing ... {service}_event_a: {service}EventATrigger, {service}_event_b: {service}EventBTrigger, - {service}_event_c: {service}EventCTrigger, - {service}_webhook: {service}WebhookTrigger, } ``` -## Step 6: Connect Triggers to Block - -In the block file (`apps/sim/blocks/blocks/{service}.ts`): +### Block file (`apps/sim/blocks/blocks/{service}.ts`) ```typescript -import { {Service}Icon } from '@/components/icons' import { getTrigger } from '@/triggers' -import type { BlockConfig } from '@/blocks/types' export const {Service}Block: BlockConfig = { - type: '{service}', - name: '{Service}', - // ... other config ... - - // Enable triggers and list available trigger IDs + // ... triggers: { enabled: true, - available: [ - '{service}_event_a', - '{service}_event_b', - '{service}_event_c', - '{service}_webhook', - ], + available: ['{service}_event_a', '{service}_event_b'], }, - subBlocks: [ - // Regular tool subBlocks first - { id: 'operation', /* ... */ }, - { id: 'credential', /* ... */ }, - // ... other tool fields ... - - // Then spread ALL trigger subBlocks + // Regular tool subBlocks first... ...getTrigger('{service}_event_a').subBlocks, ...getTrigger('{service}_event_b').subBlocks, - ...getTrigger('{service}_event_c').subBlocks, - ...getTrigger('{service}_webhook').subBlocks, ], - - // ... tools config ... } ``` -## Automatic Webhook Registration (Preferred) - -If the service's API supports programmatic webhook creation, implement automatic webhook registration instead of requiring users to manually configure webhooks. This provides a much better user experience. +## Provider Handler -### When to Use Automatic Registration +All provider-specific webhook logic lives in a single handler file: `apps/sim/lib/webhooks/providers/{service}.ts`. -Check the service's API documentation for endpoints like: -- `POST /webhooks` or `POST /hooks` - Create webhook -- `DELETE /webhooks/{id}` - Delete webhook +### When to Create a Handler -Services that support this pattern include: Grain, Lemlist, Calendly, Airtable, Webflow, Typeform, etc. +| Behavior | Method | Examples | +|---|---|---| +| HMAC signature auth | `verifyAuth` via `createHmacVerifier` | Ashby, Jira, Linear, Typeform | +| Custom token auth | `verifyAuth` via `verifyTokenAuth` | Generic, Google Forms | +| Event filtering | `matchEvent` | GitHub, Jira, Attio, HubSpot | +| Idempotency dedup | `extractIdempotencyId` | Slack, Stripe, Linear, Jira | +| Custom input formatting | `formatInput` | Slack, Teams, Attio, Ashby | +| Auto webhook creation | `createSubscription` | Ashby, Grain, Calendly, Airtable | +| Auto webhook deletion | `deleteSubscription` | Ashby, Grain, Calendly, Airtable | +| Challenge/verification | `handleChallenge` | Slack, WhatsApp, Teams | +| Custom success response | `formatSuccessResponse` | Slack, Twilio Voice, Teams | -### Implementation Steps +If none apply, you don't need a handler. The default handler provides bearer token auth. -#### 1. Add API Key to Extra Fields - -Update your `build{Service}ExtraFields` function to include an API key field: +### Example Handler ```typescript -export function build{Service}ExtraFields(triggerId: string): SubBlockConfig[] { - return [ - { - id: 'apiKey', - title: 'API Key', - type: 'short-input', - placeholder: 'Enter your {Service} API key', - description: 'Required to create the webhook in {Service}.', - password: true, - required: true, - mode: 'trigger', - condition: { field: 'selectedTriggerId', value: triggerId }, - }, - // Other optional fields (e.g., campaign filter, project filter) - { - id: 'projectId', - title: 'Project ID (Optional)', - type: 'short-input', - placeholder: 'Leave empty for all projects', - mode: 'trigger', - condition: { field: 'selectedTriggerId', value: triggerId }, - }, - ] +import crypto from 'crypto' +import { createLogger } from '@sim/logger' +import { safeCompare } from '@/lib/core/security/encryption' +import type { EventMatchContext, FormatInputContext, FormatInputResult, WebhookProviderHandler } from '@/lib/webhooks/providers/types' +import { createHmacVerifier } from '@/lib/webhooks/providers/utils' + +const logger = createLogger('WebhookProvider:{Service}') + +function validate{Service}Signature(secret: string, signature: string, body: string): boolean { + if (!secret || !signature || !body) return false + const computed = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') + return safeCompare(computed, signature) } -``` - -#### 2. Update Setup Instructions for Automatic Creation - -Change instructions to indicate automatic webhook creation: - -```typescript -export function {service}SetupInstructions(eventType: string): string { - const instructions = [ - 'Enter your {Service} API Key above.', - 'You can find your API key in {Service} at Settings > API.', - `Click "Save Configuration" to automatically create the webhook in {Service} for ${eventType} events.`, - 'The webhook will be automatically deleted when you remove this trigger.', - ] - return instructions - .map((instruction, index) => - `
${index + 1}. ${instruction}
` - ) - .join('') -} -``` - -#### 3. Add Webhook Creation to API Route - -In `apps/sim/app/api/webhooks/route.ts`, add provider-specific logic after the database save: - -```typescript -// --- {Service} specific logic --- -if (savedWebhook && provider === '{service}') { - logger.info(`[${requestId}] {Service} provider detected. Creating webhook subscription.`) - try { - const result = await create{Service}WebhookSubscription( - { - id: savedWebhook.id, - path: savedWebhook.path, - providerConfig: savedWebhook.providerConfig, - }, - requestId - ) - - if (result) { - // Update the webhook record with the external webhook ID - const updatedConfig = { - ...(savedWebhook.providerConfig as Record), - externalId: result.id, - } - await db - .update(webhook) - .set({ - providerConfig: updatedConfig, - updatedAt: new Date(), - }) - .where(eq(webhook.id, savedWebhook.id)) - - savedWebhook.providerConfig = updatedConfig - logger.info(`[${requestId}] Successfully created {Service} webhook`, { - externalHookId: result.id, - webhookId: savedWebhook.id, - }) - } - } catch (err) { - logger.error( - `[${requestId}] Error creating {Service} webhook subscription, rolling back webhook`, - err - ) - await db.delete(webhook).where(eq(webhook.id, savedWebhook.id)) - return NextResponse.json( - { - error: 'Failed to create webhook in {Service}', - details: err instanceof Error ? err.message : 'Unknown error', - }, - { status: 500 } - ) - } -} -// --- End {Service} specific logic --- -``` - -Then add the helper function at the end of the file: - -```typescript -async function create{Service}WebhookSubscription( - webhookData: any, - requestId: string -): Promise<{ id: string } | undefined> { - try { - const { path, providerConfig } = webhookData - const { apiKey, triggerId, projectId } = providerConfig || {} - - if (!apiKey) { - throw new Error('{Service} API Key is required.') - } - - // Map trigger IDs to service event types - const eventTypeMap: Record = { - {service}_event_a: 'eventA', - {service}_event_b: 'eventB', - {service}_webhook: undefined, // Generic - no filter - } - - const eventType = eventTypeMap[triggerId] - const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` - - const requestBody: Record = { - url: notificationUrl, - } - - if (eventType) { - requestBody.eventType = eventType - } +export const {service}Handler: WebhookProviderHandler = { + verifyAuth: createHmacVerifier({ + configKey: 'webhookSecret', + headerName: 'X-{Service}-Signature', + validateFn: validate{Service}Signature, + providerLabel: '{Service}', + }), - if (projectId) { - requestBody.projectId = projectId + async matchEvent({ body, requestId, providerConfig }: EventMatchContext) { + const triggerId = providerConfig.triggerId as string | undefined + if (triggerId && triggerId !== '{service}_webhook') { + const { is{Service}EventMatch } = await import('@/triggers/{service}/utils') + if (!is{Service}EventMatch(triggerId, body as Record)) return false } + return true + }, - const response = await fetch('https://api.{service}.com/webhooks', { - method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', + async formatInput({ body }: FormatInputContext): Promise { + const b = body as Record + return { + input: { + eventType: b.type, + resourceId: (b.data as Record)?.id || '', + resource: b.data, }, - body: JSON.stringify(requestBody), - }) - - const responseBody = await response.json() - - if (!response.ok) { - const errorMessage = responseBody.message || 'Unknown API error' - let userFriendlyMessage = 'Failed to create webhook in {Service}' - - if (response.status === 401) { - userFriendlyMessage = 'Invalid API Key. Please verify and try again.' - } else if (errorMessage) { - userFriendlyMessage = `{Service} error: ${errorMessage}` - } - - throw new Error(userFriendlyMessage) } + }, - return { id: responseBody.id } - } catch (error: any) { - logger.error(`Exception during {Service} webhook creation`, { error: error.message }) - throw error - } + extractIdempotencyId(body: unknown) { + const obj = body as Record + return obj.id && obj.type ? `${obj.type}:${obj.id}` : null + }, } ``` -#### 4. Add Webhook Deletion to Provider Subscriptions - -In `apps/sim/lib/webhooks/provider-subscriptions.ts`: +### Register the Handler -1. Add a logger: -```typescript -const {service}Logger = createLogger('{Service}Webhook') -``` - -2. Add the delete function: -```typescript -export async function delete{Service}Webhook(webhook: any, requestId: string): Promise { - try { - const config = getProviderConfig(webhook) - const apiKey = config.apiKey as string | undefined - const externalId = config.externalId as string | undefined - - if (!apiKey || !externalId) { - {service}Logger.warn(`[${requestId}] Missing apiKey or externalId, skipping cleanup`) - return - } - - const response = await fetch(`https://api.{service}.com/webhooks/${externalId}`, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${apiKey}`, - }, - }) - - if (!response.ok && response.status !== 404) { - {service}Logger.warn(`[${requestId}] Failed to delete webhook (non-fatal): ${response.status}`) - } else { - {service}Logger.info(`[${requestId}] Successfully deleted webhook ${externalId}`) - } - } catch (error) { - {service}Logger.warn(`[${requestId}] Error deleting webhook (non-fatal)`, error) - } -} -``` +In `apps/sim/lib/webhooks/providers/registry.ts`: -3. Add to `cleanupExternalWebhook`: ```typescript -export async function cleanupExternalWebhook(...): Promise { - // ... existing providers ... - } else if (webhook.provider === '{service}') { - await delete{Service}Webhook(webhook, requestId) - } -} -``` - -### Key Points for Automatic Registration - -- **API Key visibility**: Always use `password: true` for API key fields -- **Error handling**: Roll back the database webhook if external creation fails -- **External ID storage**: Save the external webhook ID in `providerConfig.externalId` -- **Graceful cleanup**: Don't fail webhook deletion if cleanup fails (use non-fatal logging) -- **User-friendly errors**: Map HTTP status codes to helpful error messages +import { {service}Handler } from '@/lib/webhooks/providers/{service}' -## The buildTriggerSubBlocks Helper - -This is the generic helper from `@/triggers` that creates consistent trigger subBlocks. - -### Function Signature - -```typescript -interface BuildTriggerSubBlocksOptions { - triggerId: string // e.g., 'service_event_a' - triggerOptions: Array<{ label: string; id: string }> // Dropdown options - includeDropdown?: boolean // true only for primary trigger - setupInstructions: string // HTML instructions - extraFields?: SubBlockConfig[] // Service-specific fields - webhookPlaceholder?: string // Custom placeholder text +const PROVIDER_HANDLERS: Record = { + // ... existing (alphabetical) ... + {service}: {service}Handler, } - -function buildTriggerSubBlocks(options: BuildTriggerSubBlocksOptions): SubBlockConfig[] ``` -### What It Creates +## Output Alignment (Critical) -The helper creates this structure: -1. **Dropdown** (only if `includeDropdown: true`) - Trigger type selector -2. **Webhook URL** - Read-only field with copy button -3. **Extra Fields** - Your service-specific fields (filters, options, etc.) -4. **Save Button** - Activates the trigger -5. **Instructions** - Setup guide for users +There are two sources of truth that **MUST be aligned**: -All fields automatically have: -- `mode: 'trigger'` - Only shown in trigger mode -- `condition: { field: 'selectedTriggerId', value: triggerId }` - Only shown when this trigger is selected +1. **Trigger `outputs`** — schema defining what fields SHOULD be available (UI tag dropdown) +2. **`formatInput` on the handler** — implementation that transforms raw payload into actual data -## Trigger Outputs & Webhook Input Formatting +If they differ: the tag dropdown shows fields that don't exist, or actual data has fields users can't discover. -### Important: Two Sources of Truth +**Rules for `formatInput`:** +- Return `{ input: { ... } }` where inner keys match trigger `outputs` exactly +- Return `{ input: ..., skip: { message: '...' } }` to skip execution +- No wrapper objects or duplication +- Use `null` for missing optional data -There are two related but separate concerns: +## Automatic Webhook Registration -1. **Trigger `outputs`** - Schema/contract defining what fields SHOULD be available. Used by UI for tag dropdown. -2. **`formatWebhookInput`** - Implementation that transforms raw webhook payload into actual data. Located in `apps/sim/lib/webhooks/utils.server.ts`. +If the service API supports programmatic webhook creation, implement `createSubscription` and `deleteSubscription` on the handler. The orchestration layer calls these automatically — **no code touches `route.ts`, `provider-subscriptions.ts`, or `deploy.ts`**. -**These MUST be aligned.** The fields returned by `formatWebhookInput` should match what's defined in trigger `outputs`. If they differ: -- Tag dropdown shows fields that don't exist (broken variable resolution) -- Or actual data has fields not shown in dropdown (users can't discover them) - -### When to Add a formatWebhookInput Handler +```typescript +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' +import type { DeleteSubscriptionContext, SubscriptionContext, SubscriptionResult } from '@/lib/webhooks/providers/types' -- **Simple providers**: If the raw webhook payload structure already matches your outputs, you don't need a handler. The generic fallback returns `body` directly. -- **Complex providers**: If you need to transform, flatten, extract nested data, compute fields, or handle conditional logic, add a handler. +export const {service}Handler: WebhookProviderHandler = { + async createSubscription(ctx: SubscriptionContext): Promise { + const config = getProviderConfig(ctx.webhook) + const apiKey = config.apiKey as string + if (!apiKey) throw new Error('{Service} API Key is required.') -### Adding a Handler + const res = await fetch('https://api.{service}.com/webhooks', { + method: 'POST', + headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: getNotificationUrl(ctx.webhook) }), + }) -In `apps/sim/lib/webhooks/utils.server.ts`, add a handler block: + if (!res.ok) throw new Error(`{Service} error: ${res.status}`) + const { id } = (await res.json()) as { id: string } + return { providerConfigUpdates: { externalId: id } } + }, -```typescript -if (foundWebhook.provider === '{service}') { - // Transform raw webhook body to match trigger outputs - return { - eventType: body.type, - resourceId: body.data?.id || '', - timestamp: body.created_at, - resource: body.data, - } + async deleteSubscription(ctx: DeleteSubscriptionContext): Promise { + const config = getProviderConfig(ctx.webhook) + const { apiKey, externalId } = config as { apiKey?: string; externalId?: string } + if (!apiKey || !externalId) return + await fetch(`https://api.{service}.com/webhooks/${externalId}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${apiKey}` }, + }).catch(() => {}) + }, } ``` -**Key rules:** -- Return fields that match your trigger `outputs` definition exactly -- No wrapper objects like `webhook: { data: ... }` or `{service}: { ... }` -- No duplication (don't spread body AND add individual fields) -- Use `null` for missing optional data, not empty objects with empty strings +**Key points:** +- Throw from `createSubscription` — orchestration rolls back the DB webhook +- Never throw from `deleteSubscription` — log non-fatally +- Return `{ providerConfigUpdates: { externalId } }` — orchestration merges into `providerConfig` +- Add `apiKey` field to `build{Service}ExtraFields` with `password: true` -### Verify Alignment - -Run the alignment checker: -```bash -bunx scripts/check-trigger-alignment.ts {service} -``` - -## Trigger Outputs +## Trigger Outputs Schema Trigger outputs use the same schema as block outputs (NOT tool outputs). -**Supported:** -- `type` and `description` for simple fields -- Nested object structure for complex data - -**NOT Supported:** -- `optional: true` (tool outputs only) -- `items` property (tool outputs only) +**Supported:** `type` + `description` for leaf fields, nested objects for complex data. +**NOT supported:** `optional: true`, `items` (those are tool-output-only features). ```typescript export function buildOutputs(): Record { return { - // Simple fields eventType: { type: 'string', description: 'Event type' }, timestamp: { type: 'string', description: 'When it occurred' }, - - // Complex data - use type: 'json' payload: { type: 'json', description: 'Full event payload' }, - - // Nested structure resource: { id: { type: 'string', description: 'Resource ID' }, name: { type: 'string', description: 'Resource name' }, @@ -630,79 +327,32 @@ export function buildOutputs(): Record { } ``` -## Generic Webhook Trigger Pattern - -For services with many event types, create a generic webhook that accepts all events: - -```typescript -export const {service}WebhookTrigger: TriggerConfig = { - id: '{service}_webhook', - name: '{Service} Webhook (All Events)', - // ... - - subBlocks: buildTriggerSubBlocks({ - triggerId: '{service}_webhook', - triggerOptions: {service}TriggerOptions, - setupInstructions: {service}SetupInstructions('All Events'), - extraFields: [ - // Event type filter (optional) - { - id: 'eventTypes', - title: 'Event Types', - type: 'dropdown', - multiSelect: true, - options: [ - { label: 'Event A', id: 'event_a' }, - { label: 'Event B', id: 'event_b' }, - ], - placeholder: 'Leave empty for all events', - mode: 'trigger', - condition: { field: 'selectedTriggerId', value: '{service}_webhook' }, - }, - // Plus any other service-specific fields - ...build{Service}ExtraFields('{service}_webhook'), - ], - }), -} -``` - -## Checklist Before Finishing - -### Utils -- [ ] Created `{service}TriggerOptions` array with all trigger IDs -- [ ] Created `{service}SetupInstructions` function with clear steps -- [ ] Created `build{Service}ExtraFields` for service-specific fields -- [ ] Created output builders for each trigger type +## Checklist -### Triggers -- [ ] Primary trigger has `includeDropdown: true` -- [ ] Secondary triggers do NOT have `includeDropdown` +### Trigger Definition +- [ ] Created `utils.ts` with options, instructions, extra fields, and output builders +- [ ] Primary trigger has `includeDropdown: true`; secondary triggers do NOT - [ ] All triggers use `buildTriggerSubBlocks` helper -- [ ] All triggers have proper outputs defined - [ ] Created `index.ts` barrel export ### Registration -- [ ] All triggers imported in `triggers/registry.ts` -- [ ] All triggers added to `TRIGGER_REGISTRY` -- [ ] Block has `triggers.enabled: true` -- [ ] Block has all trigger IDs in `triggers.available` +- [ ] All triggers in `triggers/registry.ts` → `TRIGGER_REGISTRY` +- [ ] Block has `triggers.enabled: true` and lists all trigger IDs in `triggers.available` - [ ] Block spreads all trigger subBlocks: `...getTrigger('id').subBlocks` -### Automatic Webhook Registration (if supported) -- [ ] Added API key field to `build{Service}ExtraFields` with `password: true` -- [ ] Updated setup instructions for automatic webhook creation -- [ ] Added provider-specific logic to `apps/sim/app/api/webhooks/route.ts` -- [ ] Added `create{Service}WebhookSubscription` helper function -- [ ] Added `delete{Service}Webhook` function to `provider-subscriptions.ts` -- [ ] Added provider to `cleanupExternalWebhook` function +### Provider Handler (if needed) +- [ ] Handler file at `apps/sim/lib/webhooks/providers/{service}.ts` +- [ ] Registered in `providers/registry.ts` (alphabetical) +- [ ] Signature validator is a private function inside the handler file +- [ ] `formatInput` output keys match trigger `outputs` exactly +- [ ] Event matching uses dynamic `await import()` for trigger utils -### Webhook Input Formatting -- [ ] Added handler in `apps/sim/lib/webhooks/utils.server.ts` (if custom formatting needed) -- [ ] Handler returns fields matching trigger `outputs` exactly -- [ ] Run `bunx scripts/check-trigger-alignment.ts {service}` to verify alignment +### Auto Registration (if supported) +- [ ] `createSubscription` and `deleteSubscription` on the handler +- [ ] NO changes to `route.ts`, `provider-subscriptions.ts`, or `deploy.ts` +- [ ] API key field uses `password: true` ### Testing -- [ ] Run `bun run type-check` to verify no TypeScript errors -- [ ] Restart dev server to pick up new triggers -- [ ] Test trigger UI shows correctly in the block -- [ ] Test automatic webhook creation works (if applicable) +- [ ] `bun run type-check` passes +- [ ] Manually verify `formatInput` output keys match trigger `outputs` keys +- [ ] Trigger UI shows correctly in the block diff --git a/.claude/commands/validate-trigger.md b/.claude/commands/validate-trigger.md new file mode 100644 index 00000000000..04bdc63c397 --- /dev/null +++ b/.claude/commands/validate-trigger.md @@ -0,0 +1,212 @@ +--- +description: Validate an existing Sim webhook trigger against provider API docs and repository conventions +argument-hint: [api-docs-url] +--- + +# Validate Trigger + +You are an expert auditor for Sim webhook triggers. Your job is to validate that an existing trigger implementation is correct, complete, secure, and aligned across all layers. + +## Your Task + +1. Read the service's webhook/API documentation (via WebFetch) +2. Read every trigger file, provider handler, and registry entry +3. Cross-reference against the API docs and Sim conventions +4. Report all issues grouped by severity (critical, warning, suggestion) +5. Fix all issues after reporting them + +## Step 1: Gather All Files + +Read **every** file for the trigger — do not skip any: + +``` +apps/sim/triggers/{service}/ # All trigger files, utils.ts, index.ts +apps/sim/lib/webhooks/providers/{service}.ts # Provider handler (if exists) +apps/sim/lib/webhooks/providers/registry.ts # Handler registry +apps/sim/triggers/registry.ts # Trigger registry +apps/sim/blocks/blocks/{service}.ts # Block definition (trigger wiring) +``` + +Also read for reference: +``` +apps/sim/lib/webhooks/providers/types.ts # WebhookProviderHandler interface +apps/sim/lib/webhooks/providers/utils.ts # Shared helpers (createHmacVerifier, etc.) +apps/sim/lib/webhooks/provider-subscription-utils.ts # Subscription helpers +apps/sim/lib/webhooks/processor.ts # Central webhook processor +``` + +## Step 2: Pull API Documentation + +Fetch the service's official webhook documentation. This is the **source of truth** for: +- Webhook event types and payload shapes +- Signature/auth verification method (HMAC algorithm, header names, secret format) +- Challenge/verification handshake requirements +- Webhook subscription API (create/delete endpoints, if applicable) +- Retry behavior and delivery guarantees + +## Step 3: Validate Trigger Definitions + +### utils.ts +- [ ] `{service}TriggerOptions` lists all trigger IDs accurately +- [ ] `{service}SetupInstructions` provides clear, correct steps for the service +- [ ] `build{Service}ExtraFields` includes relevant filter/config fields with correct `condition` +- [ ] Output builders expose all meaningful fields from the webhook payload +- [ ] Output builders do NOT use `optional: true` or `items` (tool-output-only features) +- [ ] Nested output objects correctly model the payload structure + +### Trigger Files +- [ ] Exactly one primary trigger has `includeDropdown: true` +- [ ] All secondary triggers do NOT have `includeDropdown` +- [ ] All triggers use `buildTriggerSubBlocks` helper (not hand-rolled subBlocks) +- [ ] Every trigger's `id` matches the convention `{service}_{event_name}` +- [ ] Every trigger's `provider` matches the service name used in the handler registry +- [ ] `index.ts` barrel exports all triggers + +### Trigger ↔ Provider Alignment (CRITICAL) +- [ ] Every trigger ID referenced in `matchEvent` logic exists in `{service}TriggerOptions` +- [ ] Event matching logic in the provider correctly maps trigger IDs to service event types +- [ ] Event matching logic in `is{Service}EventMatch` (if exists) correctly identifies events per the API docs + +## Step 4: Validate Provider Handler + +### Auth Verification +- [ ] `verifyAuth` correctly validates webhook signatures per the service's documentation +- [ ] HMAC algorithm matches (SHA-1, SHA-256, SHA-512) +- [ ] Signature header name matches the API docs exactly +- [ ] Signature format is handled (raw hex, `sha256=` prefix, base64, etc.) +- [ ] Uses `safeCompare` for timing-safe comparison (no `===`) +- [ ] If `webhookSecret` is required, handler rejects when it's missing (fail-closed) +- [ ] Signature is computed over raw body (not parsed JSON) + +### Event Matching +- [ ] `matchEvent` returns `boolean` (not `NextResponse` or other values) +- [ ] Challenge/verification events are excluded from matching (e.g., `endpoint.url_validation`) +- [ ] When `triggerId` is a generic webhook ID, all events pass through +- [ ] When `triggerId` is specific, only matching events pass +- [ ] Event matching logic uses dynamic `await import()` for trigger utils (avoids circular deps) + +### formatInput (CRITICAL) +- [ ] Every key in the `formatInput` return matches a key in the trigger `outputs` schema +- [ ] Every key in the trigger `outputs` schema is populated by `formatInput` +- [ ] No extra undeclared keys that users can't discover in the UI +- [ ] No wrapper objects (`webhook: { ... }`, `{service}: { ... }`) +- [ ] Nested output paths exist at the correct depth (e.g., `resource.id` actually has `resource: { id: ... }`) +- [ ] `null` is used for missing optional fields (not empty strings or empty objects) +- [ ] Returns `{ input: { ... } }` — not a bare object + +### Idempotency +- [ ] `extractIdempotencyId` returns a stable, unique key per delivery +- [ ] Uses provider-specific delivery IDs when available (e.g., `X-Request-Id`, `Linear-Delivery`, `svix-id`) +- [ ] Falls back to content-based ID (e.g., `${type}:${id}`) when no delivery header exists +- [ ] Does NOT include timestamps in the idempotency key (would break dedup on retries) + +### Challenge Handling (if applicable) +- [ ] `handleChallenge` correctly implements the service's URL verification handshake +- [ ] Returns the expected response format per the API docs +- [ ] Env-backed secrets are resolved via `resolveEnvVarsInObject` if needed + +## Step 5: Validate Automatic Subscription Lifecycle + +If the service supports programmatic webhook creation: + +### createSubscription +- [ ] Calls the correct API endpoint to create a webhook +- [ ] Sends the correct event types/filters +- [ ] Passes the notification URL from `getNotificationUrl(ctx.webhook)` +- [ ] Returns `{ providerConfigUpdates: { externalId } }` with the external webhook ID +- [ ] Throws on failure (orchestration handles rollback) +- [ ] Provides user-friendly error messages (401 → "Invalid API Key", etc.) + +### deleteSubscription +- [ ] Calls the correct API endpoint to delete the webhook +- [ ] Handles 404 gracefully (webhook already deleted) +- [ ] Never throws — catches errors and logs non-fatally +- [ ] Skips gracefully when `apiKey` or `externalId` is missing + +### Orchestration Isolation +- [ ] NO provider-specific logic in `route.ts`, `provider-subscriptions.ts`, or `deploy.ts` +- [ ] All subscription logic lives on the handler (`createSubscription`/`deleteSubscription`) + +## Step 6: Validate Registration and Block Wiring + +### Trigger Registry (`triggers/registry.ts`) +- [ ] All triggers are imported and registered +- [ ] Registry keys match trigger IDs exactly +- [ ] No orphaned entries (triggers that don't exist) + +### Provider Handler Registry (`providers/registry.ts`) +- [ ] Handler is imported and registered (if handler exists) +- [ ] Registry key matches the `provider` field on the trigger configs +- [ ] Entries are in alphabetical order + +### Block Wiring (`blocks/blocks/{service}.ts`) +- [ ] Block has `triggers.enabled: true` +- [ ] `triggers.available` lists all trigger IDs +- [ ] All trigger subBlocks are spread into `subBlocks`: `...getTrigger('id').subBlocks` +- [ ] No trigger IDs in `triggers.available` that aren't in the registry +- [ ] No trigger subBlocks spread that aren't in `triggers.available` + +## Step 7: Validate Security + +- [ ] Webhook secrets are never logged (not even at debug level) +- [ ] Auth verification runs before any event processing +- [ ] No secret comparison uses `===` (must use `safeCompare` or `crypto.timingSafeEqual`) +- [ ] Timestamp/replay protection is reasonable (not too tight for retries, not too loose for security) +- [ ] Raw body is used for signature verification (not re-serialized JSON) + +## Step 8: Report and Fix + +### Report Format + +Group findings by severity: + +**Critical** (runtime errors, security issues, or data loss): +- Wrong HMAC algorithm or header name +- `formatInput` keys don't match trigger `outputs` +- Missing `verifyAuth` when the service sends signed webhooks +- `matchEvent` returns non-boolean values +- Provider-specific logic leaking into shared orchestration files +- Trigger IDs mismatch between trigger files, registry, and block +- `createSubscription` calling wrong API endpoint +- Auth comparison using `===` instead of `safeCompare` + +**Warning** (convention violations or usability issues): +- Missing `extractIdempotencyId` when the service provides delivery IDs +- Timestamps in idempotency keys (breaks dedup on retries) +- Missing challenge handling when the service requires URL verification +- Output schema missing fields that `formatInput` returns (undiscoverable data) +- Overly tight timestamp skew window that rejects legitimate retries +- `matchEvent` not filtering challenge/verification events +- Setup instructions missing important steps + +**Suggestion** (minor improvements): +- More specific output field descriptions +- Additional output fields that could be exposed +- Better error messages in `createSubscription` +- Logging improvements + +### Fix All Issues + +After reporting, fix every **critical** and **warning** issue. Apply **suggestions** where they don't add unnecessary complexity. + +### Validation Output + +After fixing, confirm: +1. `bun run type-check` passes +2. Re-read all modified files to verify fixes are correct +3. Provider handler tests pass (if they exist): `bun test {service}` + +## Checklist Summary + +- [ ] Read all trigger files, provider handler, types, registries, and block +- [ ] Pulled and read official webhook/API documentation +- [ ] Validated trigger definitions: options, instructions, extra fields, outputs +- [ ] Validated primary/secondary trigger distinction (`includeDropdown`) +- [ ] Validated provider handler: auth, matchEvent, formatInput, idempotency +- [ ] Validated output alignment: every `outputs` key ↔ every `formatInput` key +- [ ] Validated subscription lifecycle: createSubscription, deleteSubscription, no shared-file edits +- [ ] Validated registration: trigger registry, handler registry, block wiring +- [ ] Validated security: safe comparison, no secret logging, replay protection +- [ ] Reported all issues grouped by severity +- [ ] Fixed all critical and warning issues +- [ ] `bun run type-check` passes after fixes diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 6a2a66183fb..cbc3b5edd85 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -2132,7 +2132,15 @@ export function Mem0Icon(props: SVGProps) { export function ExtendIcon(props: SVGProps) { return ( - + + + ) { ) } +export function SixtyfourIcon(props: SVGProps) { + return ( + + + + + + + + ) +} + export function SimilarwebIcon(props: SVGProps) { return ( = { langsmith: LangsmithIcon, launchdarkly: LaunchDarklyIcon, lemlist: LemlistIcon, - linear: LinearIcon, + linear_v2: LinearIcon, linkedin: LinkedInIcon, linkup: LinkupIcon, loops: LoopsIcon, @@ -340,6 +341,7 @@ export const blockTypeToIconMap: Record = { sharepoint: MicrosoftSharepointIcon, shopify: ShopifyIcon, similarweb: SimilarwebIcon, + sixtyfour: SixtyfourIcon, slack: SlackIcon, smtp: SmtpIcon, sqs: SQSIcon, diff --git a/apps/docs/content/docs/en/blocks/human-in-the-loop.mdx b/apps/docs/content/docs/en/blocks/human-in-the-loop.mdx index e28f5d3e918..d4e4705e22b 100644 --- a/apps/docs/content/docs/en/blocks/human-in-the-loop.mdx +++ b/apps/docs/content/docs/en/blocks/human-in-the-loop.mdx @@ -93,17 +93,36 @@ Access resume data in downstream blocks using ``. ### REST API - Programmatically resume workflows: + Programmatically resume workflows using the resume endpoint. The `contextId` is available from the block's `resumeEndpoint` output or from the paused execution detail. ```bash - POST /api/workflows/{workflowId}/executions/{executionId}/resume/{blockId} + POST /api/resume/{workflowId}/{executionId}/{contextId} + Content-Type: application/json { - "approved": true, - "comments": "Looks good to proceed" + "input": { + "approved": true, + "comments": "Looks good to proceed" + } } ``` + The response includes a new `executionId` for the resumed execution: + + ```json + { + "status": "started", + "executionId": "", + "message": "Resume execution started." + } + ``` + + To poll execution progress after resuming, connect to the SSE stream: + + ```bash + GET /api/workflows/{workflowId}/executions/{resumeExecutionId}/stream + ``` + Build custom approval UIs or integrate with existing systems. diff --git a/apps/docs/content/docs/en/tools/linear.mdx b/apps/docs/content/docs/en/tools/linear.mdx index 2e6c87677e7..9a23938192e 100644 --- a/apps/docs/content/docs/en/tools/linear.mdx +++ b/apps/docs/content/docs/en/tools/linear.mdx @@ -6,7 +6,7 @@ description: Interact with Linear issues, projects, and more import { BlockInfoCard } from "@/components/ui/block-info-card" diff --git a/apps/docs/content/docs/en/tools/meta.json b/apps/docs/content/docs/en/tools/meta.json index cc194da1f25..a0f99bf6616 100644 --- a/apps/docs/content/docs/en/tools/meta.json +++ b/apps/docs/content/docs/en/tools/meta.json @@ -150,6 +150,7 @@ "sharepoint", "shopify", "similarweb", + "sixtyfour", "slack", "smtp", "sqs", diff --git a/apps/docs/content/docs/en/tools/sixtyfour.mdx b/apps/docs/content/docs/en/tools/sixtyfour.mdx new file mode 100644 index 00000000000..41a55c58463 --- /dev/null +++ b/apps/docs/content/docs/en/tools/sixtyfour.mdx @@ -0,0 +1,128 @@ +--- +title: Sixtyfour AI +description: Enrich leads and companies with AI-powered research +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +## Usage Instructions + +Find emails, phone numbers, and enrich lead or company data with contact information, social profiles, and detailed research using Sixtyfour AI. + + + +## Tools + +### `sixtyfour_find_phone` + +Find phone numbers for a lead using Sixtyfour AI. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sixtyfour API key | +| `name` | string | Yes | Full name of the person | +| `company` | string | No | Company name | +| `linkedinUrl` | string | No | LinkedIn profile URL | +| `domain` | string | No | Company website domain | +| `email` | string | No | Email address | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `name` | string | Name of the person | +| `company` | string | Company name | +| `phone` | string | Phone number\(s\) found | +| `linkedinUrl` | string | LinkedIn profile URL | + +### `sixtyfour_find_email` + +Find email addresses for a lead using Sixtyfour AI. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sixtyfour API key | +| `name` | string | Yes | Full name of the person | +| `company` | string | No | Company name | +| `linkedinUrl` | string | No | LinkedIn profile URL | +| `domain` | string | No | Company website domain | +| `phone` | string | No | Phone number | +| `title` | string | No | Job title | +| `mode` | string | No | Email discovery mode: PROFESSIONAL \(default\) or PERSONAL | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `name` | string | Name of the person | +| `company` | string | Company name | +| `title` | string | Job title | +| `phone` | string | Phone number | +| `linkedinUrl` | string | LinkedIn profile URL | +| `emails` | json | Professional email addresses found | +| ↳ `address` | string | Email address | +| ↳ `status` | string | Validation status \(OK or UNKNOWN\) | +| ↳ `type` | string | Email type \(COMPANY or PERSONAL\) | +| `personalEmails` | json | Personal email addresses found \(only in PERSONAL mode\) | +| ↳ `address` | string | Email address | +| ↳ `status` | string | Validation status \(OK or UNKNOWN\) | +| ↳ `type` | string | Email type \(COMPANY or PERSONAL\) | + +### `sixtyfour_enrich_lead` + +Enrich lead information with contact details, social profiles, and company data using Sixtyfour AI. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sixtyfour API key | +| `leadInfo` | string | Yes | Lead information as JSON object with key-value pairs \(e.g. name, company, title, linkedin\) | +| `struct` | string | Yes | Fields to collect as JSON object. Keys are field names, values are descriptions \(e.g. \{"email": "The individual\'s email address", "phone": "Phone number"\}\) | +| `researchPlan` | string | No | Optional research plan to guide enrichment strategy | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `notes` | string | Research notes about the lead | +| `structuredData` | json | Enriched lead data matching the requested struct fields | +| `references` | json | Source URLs and descriptions used for enrichment | +| `confidenceScore` | number | Quality score for the returned data \(0-10\) | + +### `sixtyfour_enrich_company` + +Enrich company data with additional information and find associated people using Sixtyfour AI. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sixtyfour API key | +| `targetCompany` | string | Yes | Company data as JSON object \(e.g. \{"name": "Acme Inc", "domain": "acme.com"\}\) | +| `struct` | string | Yes | Fields to collect as JSON object. Keys are field names, values are descriptions \(e.g. \{"website": "Company website URL", "num_employees": "Employee count"\}\) | +| `findPeople` | boolean | No | Whether to find people associated with the company | +| `fullOrgChart` | boolean | No | Whether to retrieve the full organizational chart | +| `researchPlan` | string | No | Optional strategy describing how the agent should search for information | +| `peopleFocusPrompt` | string | No | Description of people to find \(roles, responsibilities\) | +| `leadStruct` | string | No | Custom schema for returned lead data as JSON object | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `notes` | string | Research notes about the company | +| `structuredData` | json | Enriched company data matching the requested struct fields | +| `references` | json | Source URLs and descriptions used for enrichment | +| `confidenceScore` | number | Quality score for the returned data \(0-10\) | + + diff --git a/apps/sim/app/(auth)/reset-password/reset-password-form.tsx b/apps/sim/app/(auth)/reset-password/reset-password-form.tsx index 0ff43b0561a..08ec08b65fc 100644 --- a/apps/sim/app/(auth)/reset-password/reset-password-form.tsx +++ b/apps/sim/app/(auth)/reset-password/reset-password-form.tsx @@ -97,49 +97,49 @@ export function SetNewPasswordForm({ }: SetNewPasswordFormProps) { const [password, setPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('') - const [validationMessage, setValidationMessage] = useState('') + const [validationMessages, setValidationMessages] = useState([]) const [showPassword, setShowPassword] = useState(false) const [showConfirmPassword, setShowConfirmPassword] = useState(false) const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() + const errors: string[] = [] + if (password.length < 8) { - setValidationMessage('Password must be at least 8 characters long') - return + errors.push('Password must be at least 8 characters long') } if (password.length > 100) { - setValidationMessage('Password must not exceed 100 characters') - return + errors.push('Password must not exceed 100 characters') } if (!/[A-Z]/.test(password)) { - setValidationMessage('Password must contain at least one uppercase letter') - return + errors.push('Password must contain at least one uppercase letter') } if (!/[a-z]/.test(password)) { - setValidationMessage('Password must contain at least one lowercase letter') - return + errors.push('Password must contain at least one lowercase letter') } if (!/[0-9]/.test(password)) { - setValidationMessage('Password must contain at least one number') - return + errors.push('Password must contain at least one number') } if (!/[^A-Za-z0-9]/.test(password)) { - setValidationMessage('Password must contain at least one special character') - return + errors.push('Password must contain at least one special character') } if (password !== confirmPassword) { - setValidationMessage('Passwords do not match') + errors.push('Passwords do not match') + } + + if (errors.length > 0) { + setValidationMessages(errors) return } - setValidationMessage('') + setValidationMessages([]) onSubmit(password) } @@ -162,7 +162,10 @@ export function SetNewPasswordForm({ onChange={(e) => setPassword(e.target.value)} required placeholder='Enter new password' - className={cn('pr-10', validationMessage && 'border-red-500 focus:border-red-500')} + className={cn( + 'pr-10', + validationMessages.length > 0 && 'border-red-500 focus:border-red-500' + )} />
-

{validationMessage}

+ {validationMessages.map((error, index) => ( +

{error}

+ ))}
)} diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index 1b0bd50c05b..55736900724 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -228,18 +228,6 @@ function SignupFormContent({ emailValidationErrors.length > 0 || errors.length > 0 ) { - if (nameValidationErrors.length > 0) { - setNameErrors([nameValidationErrors[0]]) - setShowNameValidationError(true) - } - if (emailValidationErrors.length > 0) { - setEmailErrors([emailValidationErrors[0]]) - setShowEmailValidationError(true) - } - if (errors.length > 0) { - setPasswordErrors([errors[0]]) - setShowValidationError(true) - } setIsLoading(false) return } @@ -261,6 +249,9 @@ function SignupFormContent({ widget.execute() token = await widget.getResponsePromise() } catch { + captureEvent(posthog, 'signup_failed', { + error_code: 'captcha_client_failure', + }) setFormError('Captcha verification failed. Please try again.') setIsLoading(false) return @@ -284,7 +275,9 @@ function SignupFormContent({ logger.error('Signup error:', ctx.error) const errorMessage: string[] = ['Failed to create account'] + let errorCode = 'unknown' if (ctx.error.code?.includes('USER_ALREADY_EXISTS')) { + errorCode = 'user_already_exists' errorMessage.push( 'An account with this email already exists. Please sign in instead.' ) @@ -293,24 +286,30 @@ function SignupFormContent({ ctx.error.code?.includes('BAD_REQUEST') || ctx.error.message?.includes('Email and password sign up is not enabled') ) { + errorCode = 'signup_disabled' errorMessage.push('Email signup is currently disabled.') setEmailError(errorMessage[0]) } else if (ctx.error.code?.includes('INVALID_EMAIL')) { + errorCode = 'invalid_email' errorMessage.push('Please enter a valid email address.') setEmailError(errorMessage[0]) } else if (ctx.error.code?.includes('PASSWORD_TOO_SHORT')) { + errorCode = 'password_too_short' errorMessage.push('Password must be at least 8 characters long.') setPasswordErrors(errorMessage) setShowValidationError(true) } else if (ctx.error.code?.includes('PASSWORD_TOO_LONG')) { + errorCode = 'password_too_long' errorMessage.push('Password must be less than 128 characters long.') setPasswordErrors(errorMessage) setShowValidationError(true) } else if (ctx.error.code?.includes('network')) { + errorCode = 'network_error' errorMessage.push('Network error. Please check your connection and try again.') setPasswordErrors(errorMessage) setShowValidationError(true) } else if (ctx.error.code?.includes('rate limit')) { + errorCode = 'rate_limited' errorMessage.push('Too many requests. Please wait a moment before trying again.') setPasswordErrors(errorMessage) setShowValidationError(true) @@ -318,6 +317,8 @@ function SignupFormContent({ setPasswordErrors(errorMessage) setShowValidationError(true) } + + captureEvent(posthog, 'signup_failed', { error_code: errorCode }) }, } ) @@ -400,7 +401,7 @@ function SignupFormContent({ />
0 ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]' @@ -438,7 +439,7 @@ function SignupFormContent({ />
0) || (emailError && !showEmailValidationError) ? 'grid-rows-[1fr]' @@ -497,7 +498,7 @@ function SignupFormContent({
0 ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]' diff --git a/apps/sim/app/(landing)/components/demo-request/demo-request-modal.tsx b/apps/sim/app/(landing)/components/demo-request/demo-request-modal.tsx index 01067670a1d..3ed6c53bf67 100644 --- a/apps/sim/app/(landing)/components/demo-request/demo-request-modal.tsx +++ b/apps/sim/app/(landing)/components/demo-request/demo-request-modal.tsx @@ -13,6 +13,7 @@ import { Textarea, } from '@/components/emcn' import { Check } from '@/components/emcn/icons' +import { captureClientEvent } from '@/lib/posthog/client' import { DEMO_REQUEST_COMPANY_SIZE_OPTIONS, type DemoRequestPayload, @@ -163,6 +164,9 @@ export function DemoRequestModal({ children, theme = 'dark' }: DemoRequestModalP } setSubmitSuccess(true) + captureClientEvent('landing_demo_request_submitted', { + company_size: parsed.data.companySize, + }) } catch (error) { setSubmitError( error instanceof Error diff --git a/apps/sim/app/(landing)/components/footer/footer-cta.tsx b/apps/sim/app/(landing)/components/footer/footer-cta.tsx index c1c95a638da..f9af4ac4bcc 100644 --- a/apps/sim/app/(landing)/components/footer/footer-cta.tsx +++ b/apps/sim/app/(landing)/components/footer/footer-cta.tsx @@ -3,7 +3,9 @@ import { useCallback, useRef, useState } from 'react' import { ArrowUp } from 'lucide-react' import Link from 'next/link' +import { captureClientEvent } from '@/lib/posthog/client' import { useLandingSubmit } from '@/app/(landing)/components/landing-preview/components/landing-preview-panel/landing-preview-panel' +import { trackLandingCta } from '@/app/(landing)/landing-analytics' import { useAnimatedPlaceholder } from '@/hooks/use-animated-placeholder' const MAX_HEIGHT = 120 @@ -21,6 +23,7 @@ export function FooterCTA() { const handleSubmit = useCallback(() => { if (isEmpty) return + captureClientEvent('landing_prompt_submitted', {}) landingSubmit(inputValue) }, [isEmpty, inputValue, landingSubmit]) @@ -94,12 +97,22 @@ export function FooterCTA() { target='_blank' rel='noopener noreferrer' className={`${CTA_BUTTON} border-[var(--landing-border-strong)] text-[var(--landing-text)] transition-colors hover:bg-[var(--landing-bg-elevated)]`} + onClick={() => + trackLandingCta({ + label: 'Docs', + section: 'footer_cta', + destination: 'https://docs.sim.ai', + }) + } > Docs + trackLandingCta({ label: 'Get started', section: 'footer_cta', destination: '/signup' }) + } > Get started diff --git a/apps/sim/app/(landing)/components/hero/hero.tsx b/apps/sim/app/(landing)/components/hero/hero.tsx index 775f241c337..7098c4abf71 100644 --- a/apps/sim/app/(landing)/components/hero/hero.tsx +++ b/apps/sim/app/(landing)/components/hero/hero.tsx @@ -3,6 +3,7 @@ import dynamic from 'next/dynamic' import Link from 'next/link' import { DemoRequestModal } from '@/app/(landing)/components/demo-request/demo-request-modal' +import { trackLandingCta } from '@/app/(landing)/landing-analytics' const LandingPreview = dynamic( () => @@ -57,6 +58,9 @@ export default function Hero() { type='button' className={`${CTA_BASE} border-[var(--landing-border-strong)] bg-transparent text-[var(--landing-text)] transition-colors hover:bg-[var(--landing-bg-elevated)]`} aria-label='Get a demo' + onClick={() => + trackLandingCta({ label: 'Get a demo', section: 'hero', destination: 'demo_modal' }) + } > Get a demo @@ -65,6 +69,9 @@ export default function Hero() { href='/signup' className={`${CTA_BASE} gap-2 border-white bg-white text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]`} aria-label='Get started with Sim' + onClick={() => + trackLandingCta({ label: 'Get started', section: 'hero', destination: '/signup' }) + } > Get started diff --git a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-home/landing-preview-home.tsx b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-home/landing-preview-home.tsx index 35cb85c1654..aea260a3cb0 100644 --- a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-home/landing-preview-home.tsx +++ b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-home/landing-preview-home.tsx @@ -5,6 +5,7 @@ import { AnimatePresence, motion } from 'framer-motion' import { ArrowUp, Table } from 'lucide-react' import { Blimp, Checkbox, ChevronDown } from '@/components/emcn' import { TypeBoolean, TypeNumber, TypeText } from '@/components/emcn/icons' +import { captureClientEvent } from '@/lib/posthog/client' import { useLandingSubmit } from '@/app/(landing)/components/landing-preview/components/landing-preview-panel/landing-preview-panel' import { EASE_OUT } from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/workflow-data' import { useAnimatedPlaceholder } from '@/hooks/use-animated-placeholder' @@ -151,6 +152,7 @@ export const LandingPreviewHome = memo(function LandingPreviewHome({ const handleSubmit = useCallback(() => { if (isEmpty) return + captureClientEvent('landing_prompt_submitted', {}) landingSubmit(inputValue) }, [isEmpty, inputValue, landingSubmit]) diff --git a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-panel/landing-preview-panel.tsx b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-panel/landing-preview-panel.tsx index 4ca06238463..ef5929963e7 100644 --- a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-panel/landing-preview-panel.tsx +++ b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-panel/landing-preview-panel.tsx @@ -9,6 +9,7 @@ import { createPortal } from 'react-dom' import { Blimp, BubbleChatPreview, ChevronDown, MoreHorizontal, Play } from '@/components/emcn' import { AgentIcon, HubspotIcon, OpenAIIcon, SalesforceIcon } from '@/components/icons' import { LandingPromptStorage } from '@/lib/core/utils/browser-storage' +import { captureClientEvent } from '@/lib/posthog/client' import { EASE_OUT, type EditorPromptData, @@ -147,6 +148,7 @@ export const LandingPreviewPanel = memo(function LandingPreviewPanel({ const handleSubmit = useCallback(() => { if (isEmpty) return + captureClientEvent('landing_prompt_submitted', {}) landingSubmit(inputValue) }, [isEmpty, inputValue, landingSubmit]) diff --git a/apps/sim/app/(landing)/components/navbar/navbar.tsx b/apps/sim/app/(landing)/components/navbar/navbar.tsx index 0cc2a8c306a..8f595d69078 100644 --- a/apps/sim/app/(landing)/components/navbar/navbar.tsx +++ b/apps/sim/app/(landing)/components/navbar/navbar.tsx @@ -13,6 +13,7 @@ import { } from '@/app/(landing)/components/navbar/components/blog-dropdown' import { DocsDropdown } from '@/app/(landing)/components/navbar/components/docs-dropdown' import { GitHubStars } from '@/app/(landing)/components/navbar/components/github-stars' +import { trackLandingCta } from '@/app/(landing)/landing-analytics' import { getBrandConfig } from '@/ee/whitelabeling' type DropdownId = 'docs' | 'blog' | null @@ -212,6 +213,13 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps href='/workspace' className='inline-flex h-[30px] items-center gap-[7px] rounded-[5px] border border-[var(--white)] bg-[var(--white)] px-[9px] text-[13.5px] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]' aria-label='Go to app' + onClick={() => + trackLandingCta({ + label: 'Go to App', + section: 'navbar', + destination: '/workspace', + }) + } > Go to App @@ -221,6 +229,9 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps href='/login' className='inline-flex h-[30px] items-center rounded-[5px] border border-[var(--landing-border-strong)] px-[9px] text-[13.5px] text-[var(--landing-text)] transition-colors hover:bg-[var(--landing-bg-elevated)]' aria-label='Log in' + onClick={() => + trackLandingCta({ label: 'Log in', section: 'navbar', destination: '/login' }) + } > Log in @@ -228,6 +239,13 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps href='/signup' className='inline-flex h-[30px] items-center gap-[7px] rounded-[5px] border border-[var(--white)] bg-[var(--white)] px-2.5 text-[13.5px] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]' aria-label='Get started with Sim' + onClick={() => + trackLandingCta({ + label: 'Get started', + section: 'navbar', + destination: '/signup', + }) + } > Get started @@ -303,7 +321,14 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps setMobileMenuOpen(false)} + onClick={() => { + trackLandingCta({ + label: 'Go to App', + section: 'navbar', + destination: '/workspace', + }) + setMobileMenuOpen(false) + }} aria-label='Go to app' > Go to App @@ -313,7 +338,10 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps setMobileMenuOpen(false)} + onClick={() => { + trackLandingCta({ label: 'Log in', section: 'navbar', destination: '/login' }) + setMobileMenuOpen(false) + }} aria-label='Log in' > Log in @@ -321,7 +349,14 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps setMobileMenuOpen(false)} + onClick={() => { + trackLandingCta({ + label: 'Get started', + section: 'navbar', + destination: '/signup', + }) + setMobileMenuOpen(false) + }} aria-label='Get started with Sim' > Get started diff --git a/apps/sim/app/(landing)/components/pricing/pricing.tsx b/apps/sim/app/(landing)/components/pricing/pricing.tsx index 455aea124c8..d4d0789467c 100644 --- a/apps/sim/app/(landing)/components/pricing/pricing.tsx +++ b/apps/sim/app/(landing)/components/pricing/pricing.tsx @@ -3,6 +3,7 @@ import Link from 'next/link' import { Badge } from '@/components/emcn' import { DemoRequestModal } from '@/app/(landing)/components/demo-request/demo-request-modal' +import { trackLandingCta } from '@/app/(landing)/landing-analytics' interface PricingTier { id: string @@ -150,6 +151,13 @@ function PricingCard({ tier }: PricingCardProps) { @@ -158,6 +166,13 @@ function PricingCard({ tier }: PricingCardProps) { + trackLandingCta({ + label: tier.cta.label, + section: 'pricing', + destination: tier.cta.href || '/signup', + }) + } > {tier.cta.label} @@ -165,6 +180,13 @@ function PricingCard({ tier }: PricingCardProps) { + trackLandingCta({ + label: tier.cta.label, + section: 'pricing', + destination: tier.cta.href || '/signup', + }) + } > {tier.cta.label} diff --git a/apps/sim/app/(landing)/integrations/data/icon-mapping.ts b/apps/sim/app/(landing)/integrations/data/icon-mapping.ts index ee5f8c95a5b..503242d8c1e 100644 --- a/apps/sim/app/(landing)/integrations/data/icon-mapping.ts +++ b/apps/sim/app/(landing)/integrations/data/icon-mapping.ts @@ -154,6 +154,7 @@ import { SftpIcon, ShopifyIcon, SimilarwebIcon, + SixtyfourIcon, SlackIcon, SmtpIcon, SQSIcon, @@ -283,7 +284,7 @@ export const blockTypeToIconMap: Record = { langsmith: LangsmithIcon, launchdarkly: LaunchDarklyIcon, lemlist: LemlistIcon, - linear: LinearIcon, + linear_v2: LinearIcon, linkedin: LinkedInIcon, linkup: LinkupIcon, loops: LoopsIcon, @@ -340,6 +341,7 @@ export const blockTypeToIconMap: Record = { sharepoint: MicrosoftSharepointIcon, shopify: ShopifyIcon, similarweb: SimilarwebIcon, + sixtyfour: SixtyfourIcon, slack: SlackIcon, smtp: SmtpIcon, sqs: SQSIcon, diff --git a/apps/sim/app/(landing)/integrations/data/integrations.json b/apps/sim/app/(landing)/integrations/data/integrations.json index 9db82b6d349..1aedf79474f 100644 --- a/apps/sim/app/(landing)/integrations/data/integrations.json +++ b/apps/sim/app/(landing)/integrations/data/integrations.json @@ -324,7 +324,7 @@ "longDescription": "Search across your synced data sources using Airweave. Supports semantic search with hybrid, neural, or keyword retrieval strategies. Optionally generate AI-powered answers from search results.", "bgColor": "#6366F1", "iconName": "AirweaveIcon", - "docsUrl": "https://docs.airweave.ai", + "docsUrl": "https://docs.sim.ai/tools/airweave", "operations": [], "operationCount": 0, "triggers": [], @@ -4015,8 +4015,14 @@ } ], "operationCount": 12, - "triggers": [], - "triggerCount": 0, + "triggers": [ + { + "id": "gmail_poller", + "name": "Gmail Email Trigger", + "description": "Triggers when new emails are received in Gmail (requires Gmail credentials)" + } + ], + "triggerCount": 1, "authType": "oauth", "category": "tools", "integrationType": "email", @@ -4106,8 +4112,19 @@ } ], "operationCount": 18, - "triggers": [], - "triggerCount": 0, + "triggers": [ + { + "id": "gong_webhook", + "name": "Gong Webhook", + "description": "Generic webhook trigger for all Gong events" + }, + { + "id": "gong_call_completed", + "name": "Gong Call Completed", + "description": "Trigger workflow when a call is completed and processed in Gong" + } + ], + "triggerCount": 2, "authType": "none", "category": "tools", "integrationType": "sales-intelligence", @@ -5253,8 +5270,49 @@ } ], "operationCount": 11, - "triggers": [], - "triggerCount": 0, + "triggers": [ + { + "id": "greenhouse_candidate_hired", + "name": "Greenhouse Candidate Hired", + "description": "Trigger workflow when a candidate is hired" + }, + { + "id": "greenhouse_new_application", + "name": "Greenhouse New Application", + "description": "Trigger workflow when a new application is submitted" + }, + { + "id": "greenhouse_candidate_stage_change", + "name": "Greenhouse Candidate Stage Change", + "description": "Trigger workflow when a candidate changes interview stages" + }, + { + "id": "greenhouse_candidate_rejected", + "name": "Greenhouse Candidate Rejected", + "description": "Trigger workflow when a candidate is rejected" + }, + { + "id": "greenhouse_offer_created", + "name": "Greenhouse Offer Created", + "description": "Trigger workflow when a new offer is created" + }, + { + "id": "greenhouse_job_created", + "name": "Greenhouse Job Created", + "description": "Trigger workflow when a new job is created" + }, + { + "id": "greenhouse_job_updated", + "name": "Greenhouse Job Updated", + "description": "Trigger workflow when a job is updated" + }, + { + "id": "greenhouse_webhook", + "name": "Greenhouse Webhook (Endpoint Events)", + "description": "Trigger on whichever event types you select for this URL in Greenhouse. Sim does not filter deliveries for this trigger." + } + ], + "triggerCount": 8, "authType": "api-key", "category": "tools", "integrationType": "hr", @@ -5517,6 +5575,11 @@ "name": "HubSpot Contact Deleted", "description": "Trigger workflow when a contact is deleted in HubSpot" }, + { + "id": "hubspot_contact_merged", + "name": "HubSpot Contact Merged", + "description": "Trigger workflow when contacts are merged in HubSpot" + }, { "id": "hubspot_contact_privacy_deleted", "name": "HubSpot Contact Privacy Deleted", @@ -5527,6 +5590,11 @@ "name": "HubSpot Contact Property Changed", "description": "Trigger workflow when any property of a contact is updated in HubSpot" }, + { + "id": "hubspot_contact_restored", + "name": "HubSpot Contact Restored", + "description": "Trigger workflow when a deleted contact is restored in HubSpot" + }, { "id": "hubspot_company_created", "name": "HubSpot Company Created", @@ -5537,11 +5605,21 @@ "name": "HubSpot Company Deleted", "description": "Trigger workflow when a company is deleted in HubSpot" }, + { + "id": "hubspot_company_merged", + "name": "HubSpot Company Merged", + "description": "Trigger workflow when companies are merged in HubSpot" + }, { "id": "hubspot_company_property_changed", "name": "HubSpot Company Property Changed", "description": "Trigger workflow when any property of a company is updated in HubSpot" }, + { + "id": "hubspot_company_restored", + "name": "HubSpot Company Restored", + "description": "Trigger workflow when a deleted company is restored in HubSpot" + }, { "id": "hubspot_conversation_creation", "name": "HubSpot Conversation Creation", @@ -5577,11 +5655,21 @@ "name": "HubSpot Deal Deleted", "description": "Trigger workflow when a deal is deleted in HubSpot" }, + { + "id": "hubspot_deal_merged", + "name": "HubSpot Deal Merged", + "description": "Trigger workflow when deals are merged in HubSpot" + }, { "id": "hubspot_deal_property_changed", "name": "HubSpot Deal Property Changed", "description": "Trigger workflow when any property of a deal is updated in HubSpot" }, + { + "id": "hubspot_deal_restored", + "name": "HubSpot Deal Restored", + "description": "Trigger workflow when a deleted deal is restored in HubSpot" + }, { "id": "hubspot_ticket_created", "name": "HubSpot Ticket Created", @@ -5592,13 +5680,28 @@ "name": "HubSpot Ticket Deleted", "description": "Trigger workflow when a ticket is deleted in HubSpot" }, + { + "id": "hubspot_ticket_merged", + "name": "HubSpot Ticket Merged", + "description": "Trigger workflow when tickets are merged in HubSpot" + }, { "id": "hubspot_ticket_property_changed", "name": "HubSpot Ticket Property Changed", "description": "Trigger workflow when any property of a ticket is updated in HubSpot" + }, + { + "id": "hubspot_ticket_restored", + "name": "HubSpot Ticket Restored", + "description": "Trigger workflow when a deleted ticket is restored in HubSpot" + }, + { + "id": "hubspot_webhook", + "name": "HubSpot Webhook (All Events)", + "description": "Trigger workflow on any HubSpot webhook event" } ], - "triggerCount": 18, + "triggerCount": 27, "authType": "oauth", "category": "tools", "integrationType": "crm", @@ -6077,8 +6180,39 @@ } ], "operationCount": 31, - "triggers": [], - "triggerCount": 0, + "triggers": [ + { + "id": "intercom_conversation_created", + "name": "Intercom Conversation Created", + "description": "Trigger workflow when a new conversation is created in Intercom" + }, + { + "id": "intercom_conversation_reply", + "name": "Intercom Conversation Reply", + "description": "Trigger workflow when someone replies to an Intercom conversation" + }, + { + "id": "intercom_conversation_closed", + "name": "Intercom Conversation Closed", + "description": "Trigger workflow when a conversation is closed in Intercom" + }, + { + "id": "intercom_contact_created", + "name": "Intercom Contact Created", + "description": "Trigger workflow when a new lead is created in Intercom" + }, + { + "id": "intercom_user_created", + "name": "Intercom User Created", + "description": "Trigger workflow when a new user is created in Intercom" + }, + { + "id": "intercom_webhook", + "name": "Intercom Webhook (All Events)", + "description": "Trigger workflow on any Intercom webhook event" + } + ], + "triggerCount": 6, "authType": "api-key", "category": "tools", "integrationType": "customer-support", @@ -6731,7 +6865,7 @@ "tags": ["sales-engagement", "email-marketing", "automation"] }, { - "type": "linear", + "type": "linear_v2", "slug": "linear", "name": "Linear", "description": "Interact with Linear issues, projects, and more", @@ -7056,79 +7190,79 @@ "operationCount": 78, "triggers": [ { - "id": "linear_issue_created", + "id": "linear_issue_created_v2", "name": "Linear Issue Created", "description": "Trigger workflow when a new issue is created in Linear" }, { - "id": "linear_issue_updated", + "id": "linear_issue_updated_v2", "name": "Linear Issue Updated", "description": "Trigger workflow when an issue is updated in Linear" }, { - "id": "linear_issue_removed", + "id": "linear_issue_removed_v2", "name": "Linear Issue Removed", "description": "Trigger workflow when an issue is removed/deleted in Linear" }, { - "id": "linear_comment_created", + "id": "linear_comment_created_v2", "name": "Linear Comment Created", "description": "Trigger workflow when a new comment is created in Linear" }, { - "id": "linear_comment_updated", + "id": "linear_comment_updated_v2", "name": "Linear Comment Updated", "description": "Trigger workflow when a comment is updated in Linear" }, { - "id": "linear_project_created", + "id": "linear_project_created_v2", "name": "Linear Project Created", "description": "Trigger workflow when a new project is created in Linear" }, { - "id": "linear_project_updated", + "id": "linear_project_updated_v2", "name": "Linear Project Updated", "description": "Trigger workflow when a project is updated in Linear" }, { - "id": "linear_cycle_created", + "id": "linear_cycle_created_v2", "name": "Linear Cycle Created", "description": "Trigger workflow when a new cycle is created in Linear" }, { - "id": "linear_cycle_updated", + "id": "linear_cycle_updated_v2", "name": "Linear Cycle Updated", "description": "Trigger workflow when a cycle is updated in Linear" }, { - "id": "linear_label_created", + "id": "linear_label_created_v2", "name": "Linear Label Created", "description": "Trigger workflow when a new label is created in Linear" }, { - "id": "linear_label_updated", + "id": "linear_label_updated_v2", "name": "Linear Label Updated", "description": "Trigger workflow when a label is updated in Linear" }, { - "id": "linear_project_update_created", + "id": "linear_project_update_created_v2", "name": "Linear Project Update Created", "description": "Trigger workflow when a new project update is posted in Linear" }, { - "id": "linear_customer_request_created", + "id": "linear_customer_request_created_v2", "name": "Linear Customer Request Created", "description": "Trigger workflow when a new customer request is created in Linear" }, { - "id": "linear_customer_request_updated", + "id": "linear_customer_request_updated_v2", "name": "Linear Customer Request Updated", "description": "Trigger workflow when a customer request is updated in Linear" }, { - "id": "linear_webhook", + "id": "linear_webhook_v2", "name": "Linear Webhook", - "description": "Trigger workflow from any Linear webhook event" + "description": "Trigger workflow from Linear events you select when creating the webhook in Linear (not guaranteed to be every model or event type)." } ], "triggerCount": 15, @@ -8138,8 +8272,54 @@ "docsUrl": "https://docs.sim.ai/tools/notion", "operations": [], "operationCount": 0, - "triggers": [], - "triggerCount": 0, + "triggers": [ + { + "id": "notion_page_created", + "name": "Notion Page Created", + "description": "Trigger workflow when a new page is created in Notion" + }, + { + "id": "notion_page_properties_updated", + "name": "Notion Page Properties Updated", + "description": "Trigger workflow when page properties are modified in Notion" + }, + { + "id": "notion_page_content_updated", + "name": "Notion Page Content Updated", + "description": "Trigger workflow when page content is changed in Notion" + }, + { + "id": "notion_page_deleted", + "name": "Notion Page Deleted", + "description": "Trigger workflow when a page is deleted in Notion" + }, + { + "id": "notion_database_created", + "name": "Notion Database Created", + "description": "Trigger workflow when a new database is created in Notion" + }, + { + "id": "notion_database_schema_updated", + "name": "Notion Database Schema Updated", + "description": "Trigger workflow when a database schema is modified in Notion" + }, + { + "id": "notion_database_deleted", + "name": "Notion Database Deleted", + "description": "Trigger workflow when a database is deleted in Notion" + }, + { + "id": "notion_comment_created", + "name": "Notion Comment Created", + "description": "Trigger workflow when a comment or suggested edit is added in Notion" + }, + { + "id": "notion_webhook", + "name": "Notion Webhook (All Events)", + "description": "Trigger workflow on any Notion webhook event" + } + ], + "triggerCount": 9, "authType": "oauth", "category": "tools", "integrationType": "documents", @@ -8406,8 +8586,14 @@ } ], "operationCount": 9, - "triggers": [], - "triggerCount": 0, + "triggers": [ + { + "id": "outlook_poller", + "name": "Outlook Email Trigger", + "description": "Triggers when new emails are received in Outlook (requires Microsoft credentials)" + } + ], + "triggerCount": 1, "authType": "oauth", "category": "tools", "integrationType": "email", @@ -9428,8 +9614,49 @@ } ], "operationCount": 8, - "triggers": [], - "triggerCount": 0, + "triggers": [ + { + "id": "resend_email_sent", + "name": "Resend Email Sent", + "description": "Trigger workflow when an email is sent" + }, + { + "id": "resend_email_delivered", + "name": "Resend Email Delivered", + "description": "Trigger workflow when an email is delivered" + }, + { + "id": "resend_email_bounced", + "name": "Resend Email Bounced", + "description": "Trigger workflow when an email bounces" + }, + { + "id": "resend_email_complained", + "name": "Resend Email Complained", + "description": "Trigger workflow when an email is marked as spam" + }, + { + "id": "resend_email_opened", + "name": "Resend Email Opened", + "description": "Trigger workflow when an email is opened" + }, + { + "id": "resend_email_clicked", + "name": "Resend Email Clicked", + "description": "Trigger workflow when a link in an email is clicked" + }, + { + "id": "resend_email_failed", + "name": "Resend Email Failed", + "description": "Trigger workflow when an email fails to send" + }, + { + "id": "resend_webhook", + "name": "Resend Webhook (All Events)", + "description": "Trigger on Resend webhook events we subscribe to (email lifecycle, contacts, domains—see Resend docs). Flattened email fields may be null for non-email events; use data for the full payload." + } + ], + "triggerCount": 8, "authType": "none", "category": "tools", "integrationType": "email", @@ -10175,8 +10402,39 @@ } ], "operationCount": 35, - "triggers": [], - "triggerCount": 0, + "triggers": [ + { + "id": "salesforce_record_created", + "name": "Salesforce Record Created", + "description": "Trigger workflow when a Salesforce record is created" + }, + { + "id": "salesforce_record_updated", + "name": "Salesforce Record Updated", + "description": "Trigger workflow when a Salesforce record is updated" + }, + { + "id": "salesforce_record_deleted", + "name": "Salesforce Record Deleted", + "description": "Trigger workflow when a Salesforce record is deleted" + }, + { + "id": "salesforce_opportunity_stage_changed", + "name": "Salesforce Opportunity Stage Changed", + "description": "Trigger workflow when an opportunity stage changes" + }, + { + "id": "salesforce_case_status_changed", + "name": "Salesforce Case Status Changed", + "description": "Trigger workflow when a case status changes" + }, + { + "id": "salesforce_webhook", + "name": "Salesforce Webhook (All Events)", + "description": "Trigger workflow on any Salesforce webhook event" + } + ], + "triggerCount": 6, "authType": "oauth", "category": "tools", "integrationType": "crm", @@ -10639,6 +10897,41 @@ "integrationType": "analytics", "tags": ["marketing", "data-analytics", "seo"] }, + { + "type": "sixtyfour", + "slug": "sixtyfour-ai", + "name": "Sixtyfour AI", + "description": "Enrich leads and companies with AI-powered research", + "longDescription": "Find emails, phone numbers, and enrich lead or company data with contact information, social profiles, and detailed research using Sixtyfour AI.", + "bgColor": "#000000", + "iconName": "SixtyfourIcon", + "docsUrl": "https://docs.sim.ai/tools/sixtyfour", + "operations": [ + { + "name": "Find Phone", + "description": "Find phone numbers for a lead using Sixtyfour AI." + }, + { + "name": "Find Email", + "description": "Find email addresses for a lead using Sixtyfour AI." + }, + { + "name": "Enrich Lead", + "description": "Enrich lead information with contact details, social profiles, and company data using Sixtyfour AI." + }, + { + "name": "Enrich Company", + "description": "Enrich company data with additional information and find associated people using Sixtyfour AI." + } + ], + "operationCount": 4, + "triggers": [], + "triggerCount": 0, + "authType": "api-key", + "category": "tools", + "integrationType": "sales-intelligence", + "tags": ["enrichment", "sales-engagement"] + }, { "type": "slack", "slug": "slack", @@ -11930,8 +12223,49 @@ } ], "operationCount": 50, - "triggers": [], - "triggerCount": 0, + "triggers": [ + { + "id": "vercel_deployment_created", + "name": "Vercel Deployment Created", + "description": "Trigger workflow when a new deployment is created" + }, + { + "id": "vercel_deployment_ready", + "name": "Vercel Deployment Ready", + "description": "Trigger workflow when a deployment is ready to serve traffic" + }, + { + "id": "vercel_deployment_error", + "name": "Vercel Deployment Error", + "description": "Trigger workflow when a deployment fails" + }, + { + "id": "vercel_deployment_canceled", + "name": "Vercel Deployment Canceled", + "description": "Trigger workflow when a deployment is canceled" + }, + { + "id": "vercel_project_created", + "name": "Vercel Project Created", + "description": "Trigger workflow when a new project is created" + }, + { + "id": "vercel_project_removed", + "name": "Vercel Project Removed", + "description": "Trigger workflow when a project is removed" + }, + { + "id": "vercel_domain_created", + "name": "Vercel Domain Created", + "description": "Trigger workflow when a domain is created" + }, + { + "id": "vercel_webhook", + "name": "Vercel Webhook (Common Events)", + "description": "Trigger on a curated set of common Vercel events (deployments, projects, domains, edge config). Pick a specific trigger to listen to one event type only." + } + ], + "triggerCount": 8, "authType": "api-key", "category": "tools", "integrationType": "developer-tools", @@ -12733,8 +13067,39 @@ } ], "operationCount": 10, - "triggers": [], - "triggerCount": 0, + "triggers": [ + { + "id": "zoom_meeting_started", + "name": "Zoom Meeting Started", + "description": "Trigger workflow when a Zoom meeting starts" + }, + { + "id": "zoom_meeting_ended", + "name": "Zoom Meeting Ended", + "description": "Trigger workflow when a Zoom meeting ends" + }, + { + "id": "zoom_participant_joined", + "name": "Zoom Participant Joined", + "description": "Trigger workflow when a participant joins a Zoom meeting" + }, + { + "id": "zoom_participant_left", + "name": "Zoom Participant Left", + "description": "Trigger workflow when a participant leaves a Zoom meeting" + }, + { + "id": "zoom_recording_completed", + "name": "Zoom Recording Completed", + "description": "Trigger workflow when a Zoom cloud recording is completed" + }, + { + "id": "zoom_webhook", + "name": "Zoom Webhook (All Events)", + "description": "Trigger workflow on any Zoom webhook event" + } + ], + "triggerCount": 6, "authType": "oauth", "category": "tools", "integrationType": "communication", diff --git a/apps/sim/app/(landing)/landing-analytics.tsx b/apps/sim/app/(landing)/landing-analytics.tsx index 10be29e5edd..d79e5faaa52 100644 --- a/apps/sim/app/(landing)/landing-analytics.tsx +++ b/apps/sim/app/(landing)/landing-analytics.tsx @@ -2,7 +2,8 @@ import { useEffect } from 'react' import { usePostHog } from 'posthog-js/react' -import { captureEvent } from '@/lib/posthog/client' +import { captureClientEvent, captureEvent } from '@/lib/posthog/client' +import type { PostHogEventMap } from '@/lib/posthog/events' export function LandingAnalytics() { const posthog = usePostHog() @@ -13,3 +14,11 @@ export function LandingAnalytics() { return null } + +/** + * Fire-and-forget tracker for landing page CTA clicks. + * Uses the non-hook client so it works in any click handler without requiring a PostHog provider ref. + */ +export function trackLandingCta(props: PostHogEventMap['landing_cta_clicked']): void { + captureClientEvent('landing_cta_clicked', props) +} diff --git a/apps/sim/app/api/auth/reset-password/route.test.ts b/apps/sim/app/api/auth/reset-password/route.test.ts index ee969fe9067..5a8bd79f5e0 100644 --- a/apps/sim/app/api/auth/reset-password/route.test.ts +++ b/apps/sim/app/api/auth/reset-password/route.test.ts @@ -68,7 +68,7 @@ describe('Reset Password API Route', () => { it('should handle missing token', async () => { const req = createMockRequest('POST', { - newPassword: 'newSecurePassword123', + newPassword: 'newSecurePassword123!', }) const response = await POST(req) @@ -97,7 +97,7 @@ describe('Reset Password API Route', () => { it('should handle empty token', async () => { const req = createMockRequest('POST', { token: '', - newPassword: 'newSecurePassword123', + newPassword: 'newSecurePassword123!', }) const response = await POST(req) @@ -119,7 +119,11 @@ describe('Reset Password API Route', () => { const data = await response.json() expect(response.status).toBe(400) - expect(data.message).toBe('Password must be at least 8 characters long') + expect(data.message).toContain('Password must be at least 8 characters long') + expect(data.message).toContain('Password must contain at least one uppercase letter') + expect(data.message).toContain('Password must contain at least one lowercase letter') + expect(data.message).toContain('Password must contain at least one number') + expect(data.message).toContain('Password must contain at least one special character') expect(mockResetPassword).not.toHaveBeenCalled() }) diff --git a/apps/sim/app/api/auth/reset-password/route.ts b/apps/sim/app/api/auth/reset-password/route.ts index 1d47be10354..0ec277543c4 100644 --- a/apps/sim/app/api/auth/reset-password/route.ts +++ b/apps/sim/app/api/auth/reset-password/route.ts @@ -26,8 +26,7 @@ export async function POST(request: NextRequest) { const validationResult = resetPasswordSchema.safeParse(body) if (!validationResult.success) { - const firstError = validationResult.error.errors[0] - const errorMessage = firstError?.message || 'Invalid request data' + const errorMessage = validationResult.error.errors.map((e) => e.message).join(' ') logger.warn('Invalid password reset request data', { errors: validationResult.error.format(), diff --git a/apps/sim/app/api/copilot/chat/resources/route.ts b/apps/sim/app/api/copilot/chat/resources/route.ts index 69d7bb204dc..8d528150218 100644 --- a/apps/sim/app/api/copilot/chat/resources/route.ts +++ b/apps/sim/app/api/copilot/chat/resources/route.ts @@ -15,13 +15,19 @@ import type { ChatResource, ResourceType } from '@/lib/copilot/resources' const logger = createLogger('CopilotChatResourcesAPI') -const VALID_RESOURCE_TYPES = new Set(['table', 'file', 'workflow', 'knowledgebase']) -const GENERIC_TITLES = new Set(['Table', 'File', 'Workflow', 'Knowledge Base']) +const VALID_RESOURCE_TYPES = new Set([ + 'table', + 'file', + 'workflow', + 'knowledgebase', + 'folder', +]) +const GENERIC_TITLES = new Set(['Table', 'File', 'Workflow', 'Knowledge Base', 'Folder']) const AddResourceSchema = z.object({ chatId: z.string(), resource: z.object({ - type: z.enum(['table', 'file', 'workflow', 'knowledgebase']), + type: z.enum(['table', 'file', 'workflow', 'knowledgebase', 'folder']), id: z.string(), title: z.string(), }), @@ -29,7 +35,7 @@ const AddResourceSchema = z.object({ const RemoveResourceSchema = z.object({ chatId: z.string(), - resourceType: z.enum(['table', 'file', 'workflow', 'knowledgebase']), + resourceType: z.enum(['table', 'file', 'workflow', 'knowledgebase', 'folder']), resourceId: z.string(), }) @@ -37,7 +43,7 @@ const ReorderResourcesSchema = z.object({ chatId: z.string(), resources: z.array( z.object({ - type: z.enum(['table', 'file', 'workflow', 'knowledgebase']), + type: z.enum(['table', 'file', 'workflow', 'knowledgebase', 'folder']), id: z.string(), title: z.string(), }) diff --git a/apps/sim/app/api/copilot/chat/route.ts b/apps/sim/app/api/copilot/chat/route.ts index bc9f736f52b..21f83737066 100644 --- a/apps/sim/app/api/copilot/chat/route.ts +++ b/apps/sim/app/api/copilot/chat/route.ts @@ -88,6 +88,7 @@ const ChatMessageSchema = z.object({ 'docs', 'table', 'file', + 'folder', ]), label: z.string(), chatId: z.string().optional(), @@ -99,6 +100,7 @@ const ChatMessageSchema = z.object({ executionId: z.string().optional(), tableId: z.string().optional(), fileId: z.string().optional(), + folderId: z.string().optional(), }) ) .optional(), diff --git a/apps/sim/app/api/environment/route.ts b/apps/sim/app/api/environment/route.ts index 34195a055dd..229ba26382f 100644 --- a/apps/sim/app/api/environment/route.ts +++ b/apps/sim/app/api/environment/route.ts @@ -10,7 +10,7 @@ import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' import { generateRequestId } from '@/lib/core/utils/request' import { generateId } from '@/lib/core/utils/uuid' import { syncPersonalEnvCredentialsForUser } from '@/lib/credentials/environment' -import type { EnvironmentVariable } from '@/stores/settings/environment' +import type { EnvironmentVariable } from '@/lib/environment/api' const logger = createLogger('EnvironmentAPI') diff --git a/apps/sim/app/api/folders/[id]/restore/route.ts b/apps/sim/app/api/folders/[id]/restore/route.ts new file mode 100644 index 00000000000..7aa6a9189c9 --- /dev/null +++ b/apps/sim/app/api/folders/[id]/restore/route.ts @@ -0,0 +1,58 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { captureServerEvent } from '@/lib/posthog/server' +import { performRestoreFolder } from '@/lib/workflows/orchestration/folder-lifecycle' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('RestoreFolderAPI') + +export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { id: folderId } = await params + + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const body = await request.json().catch(() => ({})) + const workspaceId = body.workspaceId as string | undefined + + if (!workspaceId) { + return NextResponse.json({ error: 'Workspace ID is required' }, { status: 400 }) + } + + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (permission !== 'admin' && permission !== 'write') { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + + const result = await performRestoreFolder({ + folderId, + workspaceId, + userId: session.user.id, + }) + + if (!result.success) { + return NextResponse.json({ error: result.error }, { status: 400 }) + } + + logger.info(`Restored folder ${folderId}`, { restoredItems: result.restoredItems }) + + captureServerEvent( + session.user.id, + 'folder_restored', + { folder_id: folderId, workspace_id: workspaceId }, + { groups: { workspace: workspaceId } } + ) + + return NextResponse.json({ success: true, restoredItems: result.restoredItems }) + } catch (error) { + logger.error(`Error restoring folder ${folderId}`, error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/folders/route.ts b/apps/sim/app/api/folders/route.ts index bd33a93caf6..98e80f5aa3d 100644 --- a/apps/sim/app/api/folders/route.ts +++ b/apps/sim/app/api/folders/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { workflow, workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, asc, eq, isNull, min } from 'drizzle-orm' +import { and, asc, eq, isNotNull, isNull, min } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' @@ -47,12 +47,16 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Access denied to this workspace' }, { status: 403 }) } - // If user has workspace permissions, fetch ALL folders in the workspace - // This allows shared workspace members to see folders created by other users + const scope = searchParams.get('scope') ?? 'active' + const archivedFilter = + scope === 'archived' + ? isNotNull(workflowFolder.archivedAt) + : isNull(workflowFolder.archivedAt) + const folders = await db .select() .from(workflowFolder) - .where(eq(workflowFolder.workspaceId, workspaceId)) + .where(and(eq(workflowFolder.workspaceId, workspaceId), archivedFilter)) .orderBy(asc(workflowFolder.sortOrder), asc(workflowFolder.createdAt)) return NextResponse.json({ folders }) diff --git a/apps/sim/app/api/mothership/chat/route.ts b/apps/sim/app/api/mothership/chat/route.ts index 9f567244fb3..09dea73a050 100644 --- a/apps/sim/app/api/mothership/chat/route.ts +++ b/apps/sim/app/api/mothership/chat/route.ts @@ -36,7 +36,7 @@ const FileAttachmentSchema = z.object({ }) const ResourceAttachmentSchema = z.object({ - type: z.enum(['workflow', 'table', 'file', 'knowledgebase']), + type: z.enum(['workflow', 'table', 'file', 'knowledgebase', 'folder']), id: z.string().min(1), title: z.string().optional(), active: z.boolean().optional(), @@ -66,6 +66,7 @@ const MothershipMessageSchema = z.object({ 'docs', 'table', 'file', + 'folder', ]), label: z.string(), chatId: z.string().optional(), @@ -77,6 +78,7 @@ const MothershipMessageSchema = z.object({ executionId: z.string().optional(), tableId: z.string().optional(), fileId: z.string().optional(), + folderId: z.string().optional(), }) ) .optional(), @@ -224,6 +226,7 @@ export async function POST(req: NextRequest) { ...(c.knowledgeId && { knowledgeId: c.knowledgeId }), ...(c.tableId && { tableId: c.tableId }), ...(c.fileId && { fileId: c.fileId }), + ...(c.folderId && { folderId: c.folderId }), })), }), } diff --git a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts index 194d9dfcdd0..1a75d8aa598 100644 --- a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts +++ b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { AuthType } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { generateId } from '@/lib/core/utils/uuid' +import { setExecutionMeta } from '@/lib/execution/event-buffer' import { preprocessExecution } from '@/lib/execution/preprocessing' import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager' import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils' @@ -125,14 +126,43 @@ export async function POST( }) } - PauseResumeManager.startResumeExecution({ + await setExecutionMeta(enqueueResult.resumeExecutionId, { + status: 'active', + userId, + workflowId, + }) + + const resumeArgs = { resumeEntryId: enqueueResult.resumeEntryId, resumeExecutionId: enqueueResult.resumeExecutionId, pausedExecution: enqueueResult.pausedExecution, contextId: enqueueResult.contextId, resumeInput: enqueueResult.resumeInput, userId: enqueueResult.userId, - }).catch((error) => { + } + + const isApiCaller = access.auth?.authType === AuthType.API_KEY + + if (isApiCaller) { + const result = await PauseResumeManager.startResumeExecution(resumeArgs) + + return NextResponse.json({ + success: result.success, + status: result.status ?? (result.success ? 'completed' : 'failed'), + executionId: enqueueResult.resumeExecutionId, + output: result.output, + error: result.error, + metadata: result.metadata + ? { + duration: result.metadata.duration, + startTime: result.metadata.startTime, + endTime: result.metadata.endTime, + } + : undefined, + }) + } + + PauseResumeManager.startResumeExecution(resumeArgs).catch((error) => { logger.error('Failed to start resume execution', { workflowId, parentExecutionId: executionId, diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts index ea17b75cefe..c6ef9e992e1 100644 --- a/apps/sim/app/api/webhooks/route.ts +++ b/apps/sim/app/api/webhooks/route.ts @@ -16,13 +16,9 @@ import { createExternalWebhookSubscription, shouldRecreateExternalWebhookSubscription, } from '@/lib/webhooks/provider-subscriptions' +import { getProviderHandler } from '@/lib/webhooks/providers' import { mergeNonUserFields } from '@/lib/webhooks/utils' -import { - configureGmailPolling, - configureOutlookPolling, - configureRssPolling, - syncWebhooksForCredentialSet, -} from '@/lib/webhooks/utils.server' +import { syncWebhooksForCredentialSet } from '@/lib/webhooks/utils.server' import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' import { extractCredentialSetId, isCredentialSetValue } from '@/executor/constants' @@ -348,7 +344,6 @@ export async function POST(request: NextRequest) { workflowRecord.workspaceId || undefined ) - // --- Credential Set Handling --- // For credential sets, we fan out to create one webhook per credential at save time. // This applies to all OAuth-based triggers, not just polling ones. // Check for credentialSetId directly (frontend may already extract it) or credential set value in credential fields @@ -402,16 +397,13 @@ export async function POST(request: NextRequest) { ) } - const needsConfiguration = provider === 'gmail' || provider === 'outlook' + const providerHandler = getProviderHandler(provider) - if (needsConfiguration) { - const configureFunc = - provider === 'gmail' ? configureGmailPolling : configureOutlookPolling + if (providerHandler.configurePolling) { const configureErrors: string[] = [] for (const wh of syncResult.webhooks) { if (wh.isNew) { - // Fetch the webhook data for configuration const webhookRows = await db .select() .from(webhook) @@ -419,7 +411,10 @@ export async function POST(request: NextRequest) { .limit(1) if (webhookRows.length > 0) { - const success = await configureFunc(webhookRows[0], requestId) + const success = await providerHandler.configurePolling({ + webhook: webhookRows[0], + requestId, + }) if (!success) { configureErrors.push( `Failed to configure webhook for credential ${wh.credentialId}` @@ -436,7 +431,6 @@ export async function POST(request: NextRequest) { configureErrors.length > 0 && configureErrors.length === syncResult.webhooks.length ) { - // All configurations failed - roll back logger.error(`[${requestId}] All webhook configurations failed, rolling back`) for (const wh of syncResult.webhooks) { await db.delete(webhook).where(eq(webhook.id, wh.id)) @@ -488,8 +482,6 @@ export async function POST(request: NextRequest) { } } } - // --- End Credential Set Handling --- - let externalSubscriptionCreated = false const createTempWebhookData = (providerConfigOverride = resolvedProviderConfig) => ({ id: targetWebhookId || generateShortId(), @@ -629,115 +621,49 @@ export async function POST(request: NextRequest) { } } - // --- Gmail/Outlook webhook setup (these don't require external subscriptions, configure after DB save) --- - if (savedWebhook && provider === 'gmail') { - logger.info(`[${requestId}] Gmail provider detected. Setting up Gmail webhook configuration.`) - try { - const success = await configureGmailPolling(savedWebhook, requestId) - - if (!success) { - logger.error(`[${requestId}] Failed to configure Gmail polling, rolling back webhook`) - await revertSavedWebhook(savedWebhook, existingWebhook, requestId) - return NextResponse.json( - { - error: 'Failed to configure Gmail polling', - details: 'Please check your Gmail account permissions and try again', - }, - { status: 500 } - ) - } - - logger.info(`[${requestId}] Successfully configured Gmail polling`) - } catch (err) { - logger.error( - `[${requestId}] Error setting up Gmail webhook configuration, rolling back webhook`, - err - ) - await revertSavedWebhook(savedWebhook, existingWebhook, requestId) - return NextResponse.json( - { - error: 'Failed to configure Gmail webhook', - details: err instanceof Error ? err.message : 'Unknown error', - }, - { status: 500 } + if (savedWebhook) { + const pollingHandler = getProviderHandler(provider) + if (pollingHandler.configurePolling) { + logger.info( + `[${requestId}] ${provider} provider detected. Setting up polling configuration.` ) - } - } - // --- End Gmail specific logic --- + try { + const success = await pollingHandler.configurePolling({ + webhook: savedWebhook, + requestId, + }) - // --- Outlook webhook setup --- - if (savedWebhook && provider === 'outlook') { - logger.info( - `[${requestId}] Outlook provider detected. Setting up Outlook webhook configuration.` - ) - try { - const success = await configureOutlookPolling(savedWebhook, requestId) + if (!success) { + logger.error( + `[${requestId}] Failed to configure ${provider} polling, rolling back webhook` + ) + await revertSavedWebhook(savedWebhook, existingWebhook, requestId) + return NextResponse.json( + { + error: `Failed to configure ${provider} polling`, + details: 'Please check your account permissions and try again', + }, + { status: 500 } + ) + } - if (!success) { - logger.error(`[${requestId}] Failed to configure Outlook polling, rolling back webhook`) - await revertSavedWebhook(savedWebhook, existingWebhook, requestId) - return NextResponse.json( - { - error: 'Failed to configure Outlook polling', - details: 'Please check your Outlook account permissions and try again', - }, - { status: 500 } + logger.info(`[${requestId}] Successfully configured ${provider} polling`) + } catch (err) { + logger.error( + `[${requestId}] Error setting up ${provider} webhook configuration, rolling back webhook`, + err ) - } - - logger.info(`[${requestId}] Successfully configured Outlook polling`) - } catch (err) { - logger.error( - `[${requestId}] Error setting up Outlook webhook configuration, rolling back webhook`, - err - ) - await revertSavedWebhook(savedWebhook, existingWebhook, requestId) - return NextResponse.json( - { - error: 'Failed to configure Outlook webhook', - details: err instanceof Error ? err.message : 'Unknown error', - }, - { status: 500 } - ) - } - } - // --- End Outlook specific logic --- - - // --- RSS webhook setup --- - if (savedWebhook && provider === 'rss') { - logger.info(`[${requestId}] RSS provider detected. Setting up RSS webhook configuration.`) - try { - const success = await configureRssPolling(savedWebhook, requestId) - - if (!success) { - logger.error(`[${requestId}] Failed to configure RSS polling, rolling back webhook`) await revertSavedWebhook(savedWebhook, existingWebhook, requestId) return NextResponse.json( { - error: 'Failed to configure RSS polling', - details: 'Please try again', + error: `Failed to configure ${provider} webhook`, + details: err instanceof Error ? err.message : 'Unknown error', }, { status: 500 } ) } - - logger.info(`[${requestId}] Successfully configured RSS polling`) - } catch (err) { - logger.error( - `[${requestId}] Error setting up RSS webhook configuration, rolling back webhook`, - err - ) - await revertSavedWebhook(savedWebhook, existingWebhook, requestId) - return NextResponse.json( - { - error: 'Failed to configure RSS webhook', - details: err instanceof Error ? err.message : 'Unknown error', - }, - { status: 500 } - ) } } - // --- End RSS specific logic --- if (!targetWebhookId && savedWebhook) { try { diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts index 7ee5d198f3f..88073b11cba 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts @@ -97,7 +97,6 @@ const { handleSlackChallengeMock, processWhatsAppDeduplicationMock, processGenericDeduplicationMock, - fetchAndProcessAirtablePayloadsMock, processWebhookMock, executeMock, getWorkspaceBilledAccountUserIdMock, @@ -109,7 +108,6 @@ const { handleSlackChallengeMock: vi.fn().mockReturnValue(null), processWhatsAppDeduplicationMock: vi.fn().mockResolvedValue(null), processGenericDeduplicationMock: vi.fn().mockResolvedValue(null), - fetchAndProcessAirtablePayloadsMock: vi.fn().mockResolvedValue(undefined), processWebhookMock: vi.fn().mockResolvedValue(new Response('Webhook processed', { status: 200 })), executeMock: vi.fn().mockResolvedValue({ success: true, @@ -156,10 +154,8 @@ vi.mock('@/background/logs-webhook-delivery', () => ({ vi.mock('@/lib/webhooks/utils', () => ({ handleWhatsAppVerification: handleWhatsAppVerificationMock, handleSlackChallenge: handleSlackChallengeMock, - verifyProviderWebhook: vi.fn().mockReturnValue(null), processWhatsAppDeduplication: processWhatsAppDeduplicationMock, processGenericDeduplication: processGenericDeduplicationMock, - fetchAndProcessAirtablePayloads: fetchAndProcessAirtablePayloadsMock, processWebhook: processWebhookMock, })) diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.ts index 2c283b72fdb..46ec98d4735 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.ts @@ -76,7 +76,7 @@ async function handleWebhookPost( const { body, rawBody } = parseResult - const challengeResponse = await handleProviderChallenges(body, request, requestId, path) + const challengeResponse = await handleProviderChallenges(body, request, requestId, path, rawBody) if (challengeResponse) { return challengeResponse } @@ -87,7 +87,7 @@ async function handleWebhookPost( if (webhooksForPath.length === 0) { const verificationResponse = await handlePreLookupWebhookVerification( request.method, - body, + body as Record | undefined, requestId, path ) diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index d5b484dfaca..86a4a722eb8 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -41,7 +41,7 @@ import { } from '@/lib/uploads/utils/user-file-base64.server' import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core' import { type ExecutionEvent, encodeSSEEvent } from '@/lib/workflows/executor/execution-events' -import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager' +import { handlePostExecutionPauseState } from '@/lib/workflows/executor/pause-persistence' import { DIRECT_WORKFLOW_JOB_NAME, type QueuedWorkflowExecutionPayload, @@ -903,6 +903,8 @@ async function handleExecutePost( abortSignal: timeoutController.signal, }) + await handlePostExecutionPauseState({ result, workflowId, executionId, loggingSession }) + if ( result.status === 'cancelled' && timeoutController.isTimedOut() && @@ -1359,31 +1361,7 @@ async function handleExecutePost( runFromBlock: resolvedRunFromBlock, }) - if (result.status === 'paused') { - if (!result.snapshotSeed) { - reqLogger.error('Missing snapshot seed for paused execution') - await loggingSession.markAsFailed('Missing snapshot seed for paused execution') - } else { - try { - await PauseResumeManager.persistPauseResult({ - workflowId, - executionId, - pausePoints: result.pausePoints || [], - snapshotSeed: result.snapshotSeed, - executorUserId: result.metadata?.userId, - }) - } catch (pauseError) { - reqLogger.error('Failed to persist pause result', { - error: pauseError instanceof Error ? pauseError.message : String(pauseError), - }) - await loggingSession.markAsFailed( - `Failed to persist pause state: ${pauseError instanceof Error ? pauseError.message : String(pauseError)}` - ) - } - } - } else { - await PauseResumeManager.processQueuedResumes(executionId) - } + await handlePostExecutionPauseState({ result, workflowId, executionId, loggingSession }) if (result.status === 'cancelled') { if (timeoutController.isTimedOut() && timeoutController.timeoutMs) { @@ -1422,25 +1400,42 @@ async function handleExecutePost( return } - sendEvent({ - type: 'execution:completed', - timestamp: new Date().toISOString(), - executionId, - workflowId, - data: { - success: result.success, - output: includeFileBase64 - ? await hydrateUserFilesWithBase64(result.output, { - requestId, - executionId, - maxBytes: base64MaxBytes, - }) - : result.output, - duration: result.metadata?.duration || 0, - startTime: result.metadata?.startTime || startTime.toISOString(), - endTime: result.metadata?.endTime || new Date().toISOString(), - }, - }) + const sseOutput = includeFileBase64 + ? await hydrateUserFilesWithBase64(result.output, { + requestId, + executionId, + maxBytes: base64MaxBytes, + }) + : result.output + + if (result.status === 'paused') { + sendEvent({ + type: 'execution:paused', + timestamp: new Date().toISOString(), + executionId, + workflowId, + data: { + output: sseOutput, + duration: result.metadata?.duration || 0, + startTime: result.metadata?.startTime || startTime.toISOString(), + endTime: result.metadata?.endTime || new Date().toISOString(), + }, + }) + } else { + sendEvent({ + type: 'execution:completed', + timestamp: new Date().toISOString(), + executionId, + workflowId, + data: { + success: result.success, + output: sseOutput, + duration: result.metadata?.duration || 0, + startTime: result.metadata?.startTime || startTime.toISOString(), + endTime: result.metadata?.endTime || new Date().toISOString(), + }, + }) + } finalMetaStatus = 'complete' } catch (error: unknown) { const isTimeout = isTimeoutError(error) || timeoutController.isTimedOut() diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.ts index 0893209c961..ad2f94722d1 100644 --- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.ts +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { checkHybridAuth } from '@/lib/auth/hybrid' +import { getSession } from '@/lib/auth' import { SSE_HEADERS } from '@/lib/core/utils/sse' import { type ExecutionStreamStatus, @@ -29,14 +29,14 @@ export async function GET( const { id: workflowId, executionId } = await params try { - const auth = await checkHybridAuth(req, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({ workflowId, - userId: auth.userId, + userId: session.user.id, action: 'read', }) if (!workflowAuthorization.allowed) { @@ -46,16 +46,6 @@ export async function GET( ) } - if ( - auth.apiKeyType === 'workspace' && - workflowAuthorization.workflow?.workspaceId !== auth.workspaceId - ) { - return NextResponse.json( - { error: 'API key is not authorized for this workspace' }, - { status: 403 } - ) - } - const meta = await getExecutionMeta(executionId) if (!meta) { return NextResponse.json({ error: 'Execution buffer not found or expired' }, { status: 404 }) diff --git a/apps/sim/app/layout.tsx b/apps/sim/app/layout.tsx index c7b35d1c1fa..604639f2b7f 100644 --- a/apps/sim/app/layout.tsx +++ b/apps/sim/app/layout.tsx @@ -6,7 +6,7 @@ import { PostHogProvider } from '@/app/_shell/providers/posthog-provider' import { generateBrandedMetadata, generateThemeCSS } from '@/ee/whitelabeling' import '@/app/_styles/globals.css' import { OneDollarStats } from '@/components/analytics/onedollarstats' -import { isReactGrabEnabled, isReactScanEnabled } from '@/lib/core/config/feature-flags' +import { isHosted, isReactGrabEnabled, isReactScanEnabled } from '@/lib/core/config/feature-flags' import { HydrationErrorHandler } from '@/app/_shell/hydration-error-handler' import { QueryProvider } from '@/app/_shell/providers/query-provider' import { SessionProvider } from '@/app/_shell/providers/session-provider' @@ -25,6 +25,9 @@ export const viewport: Viewport = { export const metadata: Metadata = generateBrandedMetadata() +const GTM_ID = 'GTM-T7PHSRX5' as const +const GA_ID = 'G-DR7YBE70VS' as const + export default function RootLayout({ children }: { children: React.ReactNode }) { const themeCSS = generateThemeCSS() @@ -208,9 +211,54 @@ export default function RootLayout({ children }: { children: React.ReactNode })