Skip to content

Commit d6b44f0

Browse files
committed
feat(netlify): add Netlify block with deploys, env vars, and deploy triggers
Adds the Netlify integration with nine tool operations (list sites, list/get/cancel/create deploys, and list/create/update/delete env vars) plus six webhook triggers (deploy created, building, succeeded, failed, locked, unlocked). Tools authenticate via a Netlify Personal Access Token (Bearer). Triggers automatically register an outgoing webhook on the chosen site at deploy time and clean it up on undeploy. Inbound webhook signatures are verified as JWTs (HS256, iss=netlify, sha256 claim of the raw body) without adding a jsonwebtoken dependency.
1 parent eb871fc commit d6b44f0

27 files changed

Lines changed: 2404 additions & 0 deletions

apps/sim/blocks/blocks/netlify.ts

Lines changed: 428 additions & 0 deletions
Large diffs are not rendered by default.

apps/sim/blocks/registry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ import { MongoDBBlock } from '@/blocks/blocks/mongodb'
138138
import { MothershipBlock } from '@/blocks/blocks/mothership'
139139
import { MySQLBlock } from '@/blocks/blocks/mysql'
140140
import { Neo4jBlock } from '@/blocks/blocks/neo4j'
141+
import { NetlifyBlock } from '@/blocks/blocks/netlify'
141142
import { NoteBlock } from '@/blocks/blocks/note'
142143
import { NotionBlock, NotionV2Block } from '@/blocks/blocks/notion'
143144
import { ObsidianBlock } from '@/blocks/blocks/obsidian'
@@ -388,6 +389,7 @@ export const registry: Record<string, BlockConfig> = {
388389
mothership: MothershipBlock,
389390
mysql: MySQLBlock,
390391
neo4j: Neo4jBlock,
392+
netlify: NetlifyBlock,
391393
note: NoteBlock,
392394
notion: NotionBlock,
393395
notion_v2: NotionV2Block,

apps/sim/components/icons.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6727,6 +6727,30 @@ export function VercelIcon(props: SVGProps<SVGSVGElement>) {
67276727
)
67286728
}
67296729

6730+
export function NetlifyIcon(props: SVGProps<SVGSVGElement>) {
6731+
return (
6732+
<svg
6733+
{...props}
6734+
viewBox='0 0 256 226'
6735+
xmlns='http://www.w3.org/2000/svg'
6736+
preserveAspectRatio='xMidYMid'
6737+
>
6738+
<path
6739+
fill='#fff'
6740+
d='M69.181 188.087h-2.417l-12.065-12.065v-2.417l18.444-18.444h12.778l1.704 1.704v12.778zM54.699 51.628v-2.417l12.065-12.065h2.417L87.625 55.59v12.778l-1.704 1.704H73.143z'
6741+
/>
6742+
<path
6743+
fill='#014847'
6744+
d='M160.906 149.198h-17.552l-1.466-1.466v-41.089c0-7.31-2.873-12.976-11.689-13.174-4.537-.119-9.727 0-15.274.218l-.833.852v53.173l-1.466 1.466H95.074l-1.466-1.466v-70.19l1.466-1.467h39.503c15.354 0 27.795 12.441 27.795 27.795v43.882l-1.466 1.466Z'
6745+
/>
6746+
<path
6747+
fill='#fff'
6748+
d='M71.677 122.889H1.466L0 121.423V103.83l1.466-1.466h70.211l1.466 1.466v17.593zM254.534 122.889h-70.211l-1.466-1.466V103.83l1.466-1.466h70.211L256 103.83v17.593zM117.876 54.124V1.466L119.342 0h17.593l1.466 1.466v52.658l-1.466 1.466h-17.593zM117.876 223.787v-52.658l1.466-1.466h17.593l1.466 1.466v52.658l-1.466 1.465h-17.593z'
6749+
/>
6750+
</svg>
6751+
)
6752+
}
6753+
67306754
export function CloudflareIcon(props: SVGProps<SVGSVGElement>) {
67316755
return (
67326756
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'>
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
import crypto from 'crypto'
2+
import { createLogger } from '@sim/logger'
3+
import { safeCompare } from '@sim/security/compare'
4+
import { NextResponse } from 'next/server'
5+
import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
6+
import type {
7+
AuthContext,
8+
DeleteSubscriptionContext,
9+
EventMatchContext,
10+
FormatInputContext,
11+
FormatInputResult,
12+
SubscriptionContext,
13+
SubscriptionResult,
14+
WebhookProviderHandler,
15+
} from '@/lib/webhooks/providers/types'
16+
17+
const logger = createLogger('WebhookProvider:Netlify')
18+
19+
/**
20+
* Verifies a Netlify outgoing webhook JWT signature (HS256, iss=netlify).
21+
* The token's `sha256` claim must equal the SHA-256 hex digest of the raw body.
22+
*/
23+
function verifyNetlifyJwt(token: string, secret: string, rawBody: string): boolean {
24+
const parts = token.split('.')
25+
if (parts.length !== 3) return false
26+
const [headerB64, payloadB64, signatureB64] = parts
27+
28+
const expectedSignature = crypto
29+
.createHmac('sha256', secret)
30+
.update(`${headerB64}.${payloadB64}`)
31+
.digest('base64url')
32+
33+
if (!safeCompare(expectedSignature, signatureB64)) {
34+
return false
35+
}
36+
37+
let payload: Record<string, unknown>
38+
try {
39+
payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString('utf8')) as Record<
40+
string,
41+
unknown
42+
>
43+
} catch {
44+
return false
45+
}
46+
47+
if (payload.iss !== 'netlify') return false
48+
49+
const bodyHash = crypto.createHash('sha256').update(rawBody, 'utf8').digest('hex')
50+
if (typeof payload.sha256 !== 'string') return false
51+
return safeCompare(payload.sha256, bodyHash)
52+
}
53+
54+
export const netlifyHandler: WebhookProviderHandler = {
55+
verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext): NextResponse | null {
56+
const secret = (providerConfig.webhookSecret as string | undefined)?.trim()
57+
if (!secret) {
58+
logger.warn(`[${requestId}] Netlify webhook secret missing; rejecting delivery`)
59+
return new NextResponse(
60+
'Unauthorized - Netlify webhook signing secret is not configured. Re-save the trigger so a webhook can be registered.',
61+
{ status: 401 }
62+
)
63+
}
64+
65+
const signature = request.headers.get('x-webhook-signature')
66+
if (!signature) {
67+
logger.warn(`[${requestId}] Netlify webhook missing X-Webhook-Signature header`)
68+
return new NextResponse('Unauthorized - Missing Netlify signature', { status: 401 })
69+
}
70+
71+
if (!verifyNetlifyJwt(signature, secret, rawBody)) {
72+
logger.warn(`[${requestId}] Netlify signature verification failed`)
73+
return new NextResponse('Unauthorized - Invalid Netlify signature', { status: 401 })
74+
}
75+
76+
return null
77+
},
78+
79+
async matchEvent({ webhook, workflow, body, requestId, providerConfig }: EventMatchContext) {
80+
const triggerId = providerConfig.triggerId as string | undefined
81+
if (!triggerId) return true
82+
83+
const { isNetlifyEventMatch } = await import('@/triggers/netlify/utils')
84+
const obj = body as Record<string, unknown>
85+
const state = typeof obj.state === 'string' ? obj.state : undefined
86+
87+
if (!isNetlifyEventMatch(triggerId, state)) {
88+
logger.debug(`[${requestId}] Netlify event mismatch for trigger ${triggerId}. Skipping.`, {
89+
webhookId: webhook.id,
90+
workflowId: workflow.id,
91+
triggerId,
92+
state,
93+
})
94+
return false
95+
}
96+
97+
return true
98+
},
99+
100+
extractIdempotencyId(body: unknown) {
101+
const id = (body as Record<string, unknown>)?.id
102+
if (id === undefined || id === null || id === '') {
103+
return null
104+
}
105+
return `netlify:${String(id)}`
106+
},
107+
108+
async createSubscription(ctx: SubscriptionContext): Promise<SubscriptionResult | undefined> {
109+
const { webhook, requestId } = ctx
110+
try {
111+
const providerConfig = getProviderConfig(webhook)
112+
const apiKey = providerConfig.apiKey as string | undefined
113+
const triggerId = providerConfig.triggerId as string | undefined
114+
const siteId = (providerConfig.siteId as string | undefined)?.trim()
115+
116+
if (!apiKey) {
117+
throw new Error(
118+
'Netlify Personal Access Token is required. Provide your access token in the trigger configuration.'
119+
)
120+
}
121+
if (!siteId) {
122+
throw new Error('Netlify Site ID is required to register a deploy webhook.')
123+
}
124+
if (!triggerId) {
125+
throw new Error('Missing trigger ID — re-save the Netlify trigger.')
126+
}
127+
128+
const { NETLIFY_TRIGGER_EVENT_TYPES } = await import('@/triggers/netlify/utils')
129+
const event = NETLIFY_TRIGGER_EVENT_TYPES[triggerId]
130+
if (!event) {
131+
throw new Error(
132+
`Unknown Netlify trigger "${triggerId}". Remove and re-add the Netlify trigger, then save again.`
133+
)
134+
}
135+
136+
const notificationUrl = getNotificationUrl(webhook)
137+
const signingSecret = crypto.randomBytes(32).toString('base64url')
138+
139+
logger.info(`[${requestId}] Creating Netlify webhook`, {
140+
triggerId,
141+
event,
142+
siteId,
143+
webhookId: webhook.id,
144+
})
145+
146+
const apiUrl = `https://api.netlify.com/api/v1/hooks?site_id=${encodeURIComponent(siteId)}`
147+
const requestBody = {
148+
type: 'url',
149+
event,
150+
data: {
151+
url: notificationUrl,
152+
signature_secret: signingSecret,
153+
},
154+
}
155+
156+
const netlifyResponse = await fetch(apiUrl, {
157+
method: 'POST',
158+
headers: {
159+
Authorization: `Bearer ${apiKey}`,
160+
'Content-Type': 'application/json',
161+
},
162+
body: JSON.stringify(requestBody),
163+
})
164+
165+
const responseBody = (await netlifyResponse.json().catch(() => ({}))) as Record<
166+
string,
167+
unknown
168+
>
169+
170+
if (!netlifyResponse.ok) {
171+
const errorMessage =
172+
(responseBody.message as string) ||
173+
(responseBody.error as string) ||
174+
'Unknown Netlify API error'
175+
176+
let userFriendlyMessage = 'Failed to create webhook subscription in Netlify'
177+
if (netlifyResponse.status === 401 || netlifyResponse.status === 403) {
178+
userFriendlyMessage =
179+
'Invalid or insufficient Netlify Personal Access Token. Verify the token has access to this site.'
180+
} else if (netlifyResponse.status === 404) {
181+
userFriendlyMessage = `Netlify site "${siteId}" not found or not accessible with this token.`
182+
} else if (errorMessage && errorMessage !== 'Unknown Netlify API error') {
183+
userFriendlyMessage = `Netlify error: ${errorMessage}`
184+
}
185+
186+
throw new Error(userFriendlyMessage)
187+
}
188+
189+
const externalId = (responseBody.id as string | undefined) ?? undefined
190+
if (!externalId) {
191+
throw new Error('Netlify webhook creation succeeded but no hook ID was returned')
192+
}
193+
194+
logger.info(`[${requestId}] Successfully created Netlify hook ${externalId}`, {
195+
webhookId: webhook.id,
196+
event,
197+
})
198+
199+
return {
200+
providerConfigUpdates: {
201+
externalId,
202+
webhookSecret: signingSecret,
203+
},
204+
}
205+
} catch (error: unknown) {
206+
const err = error as Error
207+
logger.error(`[${requestId}] Exception during Netlify webhook creation`, {
208+
message: err.message,
209+
webhookId: webhook.id,
210+
})
211+
throw error
212+
}
213+
},
214+
215+
async deleteSubscription(ctx: DeleteSubscriptionContext): Promise<void> {
216+
const { webhook, requestId } = ctx
217+
try {
218+
const config = getProviderConfig(webhook)
219+
const apiKey = config.apiKey as string | undefined
220+
const externalId = config.externalId as string | undefined
221+
222+
if (!apiKey || !externalId) {
223+
logger.warn(
224+
`[${requestId}] Missing apiKey or externalId for Netlify webhook deletion ${webhook.id}, skipping cleanup`
225+
)
226+
if (ctx.strict) throw new Error('Missing Netlify webhook deletion credentials')
227+
return
228+
}
229+
230+
const apiUrl = `https://api.netlify.com/api/v1/hooks/${encodeURIComponent(externalId)}`
231+
232+
const response = await fetch(apiUrl, {
233+
method: 'DELETE',
234+
headers: {
235+
Authorization: `Bearer ${apiKey}`,
236+
},
237+
})
238+
239+
if (!response.ok && response.status !== 404) {
240+
logger.warn(
241+
`[${requestId}] Failed to delete Netlify webhook (non-fatal): ${response.status}`
242+
)
243+
if (ctx.strict) throw new Error(`Failed to delete Netlify webhook: ${response.status}`)
244+
} else {
245+
await response.body?.cancel()
246+
logger.info(`[${requestId}] Successfully deleted Netlify hook ${externalId}`)
247+
}
248+
} catch (error) {
249+
logger.warn(`[${requestId}] Error deleting Netlify webhook (non-fatal)`, error)
250+
if (ctx.strict) throw error
251+
}
252+
},
253+
254+
async formatInput(ctx: FormatInputContext): Promise<FormatInputResult> {
255+
const body = ctx.body as Record<string, unknown>
256+
257+
const str = (v: unknown): string => (v == null ? '' : String(v))
258+
259+
return {
260+
input: {
261+
id: str(body.id),
262+
siteId: str(body.site_id),
263+
state: str(body.state),
264+
name: str(body.name),
265+
url: str(body.url),
266+
deployUrl: str(body.deploy_url),
267+
deploySslUrl: str(body.deploy_ssl_url),
268+
adminUrl: str(body.admin_url),
269+
branch: str(body.branch),
270+
context: str(body.context),
271+
commitRef: str(body.commit_ref),
272+
commitUrl: str(body.commit_url),
273+
title: str(body.title),
274+
errorMessage: str(body.error_message),
275+
createdAt: str(body.created_at),
276+
updatedAt: str(body.updated_at),
277+
publishedAt: str(body.published_at),
278+
payload: body,
279+
},
280+
}
281+
},
282+
}

apps/sim/lib/webhooks/providers/registry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { lemlistHandler } from '@/lib/webhooks/providers/lemlist'
2626
import { linearHandler } from '@/lib/webhooks/providers/linear'
2727
import { microsoftTeamsHandler } from '@/lib/webhooks/providers/microsoft-teams'
2828
import { mondayHandler } from '@/lib/webhooks/providers/monday'
29+
import { netlifyHandler } from '@/lib/webhooks/providers/netlify'
2930
import { notionHandler } from '@/lib/webhooks/providers/notion'
3031
import { outlookHandler } from '@/lib/webhooks/providers/outlook'
3132
import { resendHandler } from '@/lib/webhooks/providers/resend'
@@ -88,6 +89,7 @@ const PROVIDER_HANDLERS: Record<string, WebhookProviderHandler> = {
8889
twilio: twilioHandler,
8990
twilio_voice: twilioVoiceHandler,
9091
typeform: typeformHandler,
92+
netlify: netlifyHandler,
9193
vercel: vercelHandler,
9294
webflow: webflowHandler,
9395
whatsapp: whatsappHandler,

0 commit comments

Comments
 (0)