Skip to content

Commit d90f828

Browse files
fix(grain): update to stable version of API (#3556)
* fix(grain): update to stable version of API * fix prewebhook lookup * update pending webhook verification infra * add generic webhook test event verification subblock
1 parent a8bbab2 commit d90f828

23 files changed

+796
-195
lines changed

apps/sim/app/api/webhooks/trigger/[path]/route.test.ts

Lines changed: 94 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,32 @@ vi.mock('@/lib/webhooks/processor', () => ({
268268
}
269269
}),
270270
handleProviderChallenges: vi.fn().mockResolvedValue(null),
271+
handlePreLookupWebhookVerification: vi
272+
.fn()
273+
.mockImplementation(
274+
async (
275+
method: string,
276+
body: Record<string, unknown> | undefined,
277+
_requestId: string,
278+
path: string
279+
) => {
280+
if (path !== 'pending-verification-path') {
281+
return null
282+
}
283+
284+
const isVerificationProbe =
285+
method === 'GET' ||
286+
method === 'HEAD' ||
287+
(method === 'POST' && (!body || Object.keys(body).length === 0 || !body.type))
288+
289+
if (!isVerificationProbe) {
290+
return null
291+
}
292+
293+
const { NextResponse } = require('next/server')
294+
return NextResponse.json({ status: 'ok', message: 'Webhook endpoint verified' })
295+
}
296+
),
271297
handleProviderReachabilityTest: vi.fn().mockReturnValue(null),
272298
verifyProviderAuth: vi
273299
.fn()
@@ -353,7 +379,7 @@ vi.mock('@/lib/core/utils/request', () => requestUtilsMock)
353379

354380
process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/test'
355381

356-
import { POST } from '@/app/api/webhooks/trigger/[path]/route'
382+
import { GET, POST } from '@/app/api/webhooks/trigger/[path]/route'
357383

358384
describe('Webhook Trigger API Route', () => {
359385
beforeEach(() => {
@@ -389,7 +415,7 @@ describe('Webhook Trigger API Route', () => {
389415
})
390416

391417
it('should handle 404 for non-existent webhooks', async () => {
392-
const req = createMockRequest('POST', { event: 'test' })
418+
const req = createMockRequest('POST', { type: 'event.test' })
393419

394420
const params = Promise.resolve({ path: 'non-existent-path' })
395421

@@ -401,6 +427,72 @@ describe('Webhook Trigger API Route', () => {
401427
expect(text).toMatch(/not found/i)
402428
})
403429

430+
it('should return 405 for GET requests on unknown webhook paths', async () => {
431+
const req = createMockRequest(
432+
'GET',
433+
undefined,
434+
{},
435+
'http://localhost:3000/api/webhooks/trigger/non-existent-path'
436+
)
437+
438+
const params = Promise.resolve({ path: 'non-existent-path' })
439+
440+
const response = await GET(req as any, { params })
441+
442+
expect(response.status).toBe(405)
443+
})
444+
445+
it('should return 200 for GET verification probes on registered pending paths', async () => {
446+
const req = createMockRequest(
447+
'GET',
448+
undefined,
449+
{},
450+
'http://localhost:3000/api/webhooks/trigger/pending-verification-path'
451+
)
452+
453+
const params = Promise.resolve({ path: 'pending-verification-path' })
454+
455+
const response = await GET(req as any, { params })
456+
457+
expect(response.status).toBe(200)
458+
await expect(response.json()).resolves.toMatchObject({
459+
status: 'ok',
460+
message: 'Webhook endpoint verified',
461+
})
462+
})
463+
464+
it('should return 200 for empty POST verification probes on registered pending paths', async () => {
465+
const req = createMockRequest(
466+
'POST',
467+
undefined,
468+
{},
469+
'http://localhost:3000/api/webhooks/trigger/pending-verification-path'
470+
)
471+
472+
const params = Promise.resolve({ path: 'pending-verification-path' })
473+
474+
const response = await POST(req as any, { params })
475+
476+
expect(response.status).toBe(200)
477+
await expect(response.json()).resolves.toMatchObject({
478+
status: 'ok',
479+
message: 'Webhook endpoint verified',
480+
})
481+
})
482+
483+
it('should return 404 for POST requests without type on unknown webhook paths', async () => {
484+
const req = createMockRequest('POST', { event: 'test' })
485+
486+
const params = Promise.resolve({ path: 'non-existent-path' })
487+
488+
const response = await POST(req as any, { params })
489+
490+
expect(response.status).toBe(404)
491+
492+
const text = await response.text()
493+
expect(text).toMatch(/not found/i)
494+
})
495+
404496
describe('Generic Webhook Authentication', () => {
405497
it('should process generic webhook without authentication', async () => {
406498
testData.webhooks.push({

apps/sim/app/api/webhooks/trigger/[path]/route.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
checkWebhookPreprocessing,
66
findAllWebhooksForPath,
77
handlePreDeploymentVerification,
8+
handlePreLookupWebhookVerification,
89
handleProviderChallenges,
910
handleProviderReachabilityTest,
1011
parseWebhookBody,
@@ -30,7 +31,10 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
3031
return challengeResponse
3132
}
3233

33-
return new NextResponse('Method not allowed', { status: 405 })
34+
return (
35+
(await handlePreLookupWebhookVerification(request.method, undefined, requestId, path)) ||
36+
new NextResponse('Method not allowed', { status: 405 })
37+
)
3438
}
3539

3640
export async function POST(
@@ -64,6 +68,16 @@ export async function POST(
6468
const webhooksForPath = await findAllWebhooksForPath({ requestId, path })
6569

6670
if (webhooksForPath.length === 0) {
71+
const verificationResponse = await handlePreLookupWebhookVerification(
72+
request.method,
73+
body,
74+
requestId,
75+
path
76+
)
77+
if (verificationResponse) {
78+
return verificationResponse
79+
}
80+
6781
logger.warn(`[${requestId}] Webhook or workflow not found for path: ${path}`)
6882
return new NextResponse('Not Found', { status: 404 })
6983
}

apps/sim/blocks/blocks/grain.ts

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export const GrainBlock: BlockConfig = {
2525
{ label: 'List Recordings', id: 'grain_list_recordings' },
2626
{ label: 'Get Recording', id: 'grain_get_recording' },
2727
{ label: 'Get Transcript', id: 'grain_get_transcript' },
28+
{ label: 'List Views', id: 'grain_list_views' },
2829
{ label: 'List Teams', id: 'grain_list_teams' },
2930
{ label: 'List Meeting Types', id: 'grain_list_meeting_types' },
3031
{ label: 'Create Webhook', id: 'grain_create_hook' },
@@ -72,7 +73,7 @@ export const GrainBlock: BlockConfig = {
7273
placeholder: 'ISO8601 timestamp (e.g., 2024-01-01T00:00:00Z)',
7374
condition: {
7475
field: 'operation',
75-
value: ['grain_list_recordings', 'grain_create_hook'],
76+
value: ['grain_list_recordings'],
7677
},
7778
wandConfig: {
7879
enabled: true,
@@ -96,7 +97,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
9697
placeholder: 'ISO8601 timestamp (e.g., 2024-01-01T00:00:00Z)',
9798
condition: {
9899
field: 'operation',
99-
value: ['grain_list_recordings', 'grain_create_hook'],
100+
value: ['grain_list_recordings'],
100101
},
101102
wandConfig: {
102103
enabled: true,
@@ -125,7 +126,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
125126
value: () => '',
126127
condition: {
127128
field: 'operation',
128-
value: ['grain_list_recordings', 'grain_create_hook'],
129+
value: ['grain_list_recordings'],
129130
},
130131
},
131132
// Title search
@@ -162,7 +163,7 @@ Return ONLY the search term - no explanations, no quotes, no extra text.`,
162163
placeholder: 'Filter by team UUID (optional)',
163164
condition: {
164165
field: 'operation',
165-
value: ['grain_list_recordings', 'grain_create_hook'],
166+
value: ['grain_list_recordings'],
166167
},
167168
},
168169
// Meeting type ID filter
@@ -173,7 +174,7 @@ Return ONLY the search term - no explanations, no quotes, no extra text.`,
173174
placeholder: 'Filter by meeting type UUID (optional)',
174175
condition: {
175176
field: 'operation',
176-
value: ['grain_list_recordings', 'grain_create_hook'],
177+
value: ['grain_list_recordings'],
177178
},
178179
},
179180
// Include highlights
@@ -183,7 +184,7 @@ Return ONLY the search term - no explanations, no quotes, no extra text.`,
183184
type: 'switch',
184185
condition: {
185186
field: 'operation',
186-
value: ['grain_list_recordings', 'grain_get_recording', 'grain_create_hook'],
187+
value: ['grain_list_recordings', 'grain_get_recording'],
187188
},
188189
},
189190
// Include participants
@@ -193,7 +194,7 @@ Return ONLY the search term - no explanations, no quotes, no extra text.`,
193194
type: 'switch',
194195
condition: {
195196
field: 'operation',
196-
value: ['grain_list_recordings', 'grain_get_recording', 'grain_create_hook'],
197+
value: ['grain_list_recordings', 'grain_get_recording'],
197198
},
198199
},
199200
// Include AI summary
@@ -203,7 +204,18 @@ Return ONLY the search term - no explanations, no quotes, no extra text.`,
203204
type: 'switch',
204205
condition: {
205206
field: 'operation',
206-
value: ['grain_list_recordings', 'grain_get_recording', 'grain_create_hook'],
207+
value: ['grain_list_recordings', 'grain_get_recording'],
208+
},
209+
},
210+
{
211+
id: 'viewId',
212+
title: 'View ID',
213+
type: 'short-input',
214+
placeholder: 'Enter Grain view UUID',
215+
required: true,
216+
condition: {
217+
field: 'operation',
218+
value: ['grain_create_hook'],
207219
},
208220
},
209221
// Include calendar event (get_recording only)
@@ -271,6 +283,7 @@ Return ONLY the search term - no explanations, no quotes, no extra text.`,
271283
'grain_list_recordings',
272284
'grain_get_recording',
273285
'grain_get_transcript',
286+
'grain_list_views',
274287
'grain_list_teams',
275288
'grain_list_meeting_types',
276289
'grain_create_hook',
@@ -327,24 +340,21 @@ Return ONLY the search term - no explanations, no quotes, no extra text.`,
327340

328341
case 'grain_list_teams':
329342
case 'grain_list_meeting_types':
343+
case 'grain_list_views':
330344
case 'grain_list_hooks':
331345
return baseParams
332346

333347
case 'grain_create_hook':
334348
if (!params.hookUrl?.trim()) {
335349
throw new Error('Webhook URL is required.')
336350
}
351+
if (!params.viewId?.trim()) {
352+
throw new Error('View ID is required.')
353+
}
337354
return {
338355
...baseParams,
339356
hookUrl: params.hookUrl.trim(),
340-
filterBeforeDatetime: params.beforeDatetime || undefined,
341-
filterAfterDatetime: params.afterDatetime || undefined,
342-
filterParticipantScope: params.participantScope || undefined,
343-
filterTeamId: params.teamId || undefined,
344-
filterMeetingTypeId: params.meetingTypeId || undefined,
345-
includeHighlights: params.includeHighlights || false,
346-
includeParticipants: params.includeParticipants || false,
347-
includeAiSummary: params.includeAiSummary || false,
357+
viewId: params.viewId.trim(),
348358
}
349359

350360
case 'grain_delete_hook':
@@ -367,6 +377,7 @@ Return ONLY the search term - no explanations, no quotes, no extra text.`,
367377
apiKey: { type: 'string', description: 'Grain API key (Personal Access Token)' },
368378
recordingId: { type: 'string', description: 'Recording UUID' },
369379
cursor: { type: 'string', description: 'Pagination cursor' },
380+
viewId: { type: 'string', description: 'Grain view UUID for webhook subscriptions' },
370381
beforeDatetime: {
371382
type: 'string',
372383
description: 'Filter recordings before this ISO8601 timestamp',
@@ -416,6 +427,7 @@ Return ONLY the search term - no explanations, no quotes, no extra text.`,
416427
teamsList: { type: 'json', description: 'Array of team objects' },
417428
// Meeting type outputs
418429
meetingTypes: { type: 'json', description: 'Array of meeting type objects' },
430+
views: { type: 'json', description: 'Array of Grain views' },
419431
// Hook outputs
420432
hooks: { type: 'json', description: 'Array of webhook objects' },
421433
hook: { type: 'json', description: 'Created webhook data' },

apps/sim/lib/webhooks/deploy.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { and, eq, inArray } from 'drizzle-orm'
55
import { nanoid } from 'nanoid'
66
import type { NextRequest } from 'next/server'
77
import { getProviderIdFromServiceId } from '@/lib/oauth'
8+
import { PendingWebhookVerificationTracker } from '@/lib/webhooks/pending-verification'
89
import {
910
cleanupExternalWebhook,
1011
createExternalWebhookSubscription,
@@ -580,6 +581,7 @@ export async function saveTriggerWebhooksForDeploy({
580581
updatedProviderConfig: Record<string, unknown>
581582
externalSubscriptionCreated: boolean
582583
}> = []
584+
const pendingVerificationTracker = new PendingWebhookVerificationTracker()
583585

584586
for (const block of blocksNeedingWebhook) {
585587
const config = webhookConfigs.get(block.id)
@@ -595,6 +597,14 @@ export async function saveTriggerWebhooksForDeploy({
595597
}
596598

597599
try {
600+
await pendingVerificationTracker.register({
601+
path: triggerPath,
602+
provider,
603+
workflowId,
604+
blockId: block.id,
605+
metadata: providerConfig,
606+
})
607+
598608
const result = await createExternalWebhookSubscription(
599609
request,
600610
createPayload,
@@ -613,6 +623,7 @@ export async function saveTriggerWebhooksForDeploy({
613623
})
614624
} catch (error: any) {
615625
logger.error(`[${requestId}] Failed to create external subscription for ${block.id}`, error)
626+
await pendingVerificationTracker.clearAll()
616627
for (const sub of createdSubscriptions) {
617628
if (sub.externalSubscriptionCreated) {
618629
try {
@@ -666,6 +677,8 @@ export async function saveTriggerWebhooksForDeploy({
666677
}
667678
})
668679

680+
await pendingVerificationTracker.clearAll()
681+
669682
for (const sub of createdSubscriptions) {
670683
const pollingError = await configurePollingIfNeeded(
671684
sub.provider,
@@ -710,6 +723,7 @@ export async function saveTriggerWebhooksForDeploy({
710723
}
711724
}
712725
} catch (error: any) {
726+
await pendingVerificationTracker.clearAll()
713727
logger.error(`[${requestId}] Failed to insert webhook records`, error)
714728
for (const sub of createdSubscriptions) {
715729
if (sub.externalSubscriptionCreated) {

0 commit comments

Comments
 (0)