-
Notifications
You must be signed in to change notification settings - Fork 3.7k
feat(netlify): add Netlify block with deploys, env vars, and deploy triggers #4518
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: staging
Are you sure you want to change the base?
Changes from 3 commits
d6b44f0
36c8db9
287fbb1
34cb274
a5a8100
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,135 @@ | ||
| import crypto from 'crypto' | ||
| import { createLogger } from '@sim/logger' | ||
| import { safeCompare } from '@sim/security/compare' | ||
| import { NextResponse } from 'next/server' | ||
| import type { | ||
| AuthContext, | ||
| EventMatchContext, | ||
| FormatInputContext, | ||
| FormatInputResult, | ||
| WebhookProviderHandler, | ||
| } from '@/lib/webhooks/providers/types' | ||
|
|
||
| const logger = createLogger('WebhookProvider:Netlify') | ||
|
|
||
| /** | ||
| * Verifies a Netlify outgoing webhook JWT signature (HS256, iss=netlify). | ||
| * The token's `sha256` claim must equal the SHA-256 hex digest of the raw body. | ||
| */ | ||
| function verifyNetlifyJwt(token: string, secret: string, rawBody: string): boolean { | ||
| const parts = token.split('.') | ||
| if (parts.length !== 3) return false | ||
| const [headerB64, payloadB64, signatureB64] = parts | ||
|
|
||
| const expectedSignature = crypto | ||
| .createHmac('sha256', secret) | ||
| .update(`${headerB64}.${payloadB64}`) | ||
| .digest('base64url') | ||
|
|
||
| if (!safeCompare(expectedSignature, signatureB64)) { | ||
| return false | ||
| } | ||
|
|
||
| let payload: Record<string, unknown> | ||
| try { | ||
| payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString('utf8')) as Record< | ||
| string, | ||
| unknown | ||
| > | ||
| } catch { | ||
| return false | ||
| } | ||
|
|
||
| if (payload.iss !== 'netlify') return false | ||
|
|
||
| const bodyHash = crypto.createHash('sha256').update(rawBody, 'utf8').digest('hex') | ||
| if (typeof payload.sha256 !== 'string') return false | ||
| return safeCompare(payload.sha256, bodyHash) | ||
| } | ||
|
|
||
| export const netlifyHandler: WebhookProviderHandler = { | ||
| verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext): NextResponse | null { | ||
| const secret = (providerConfig.signatureSecret as string | undefined)?.trim() | ||
| if (!secret) { | ||
| logger.warn(`[${requestId}] Netlify signature secret missing; rejecting delivery`) | ||
| return new NextResponse( | ||
| 'Unauthorized - Netlify signature secret is not configured. Set the JWS secret token on this trigger.', | ||
| { status: 401 } | ||
| ) | ||
| } | ||
|
|
||
| const signature = request.headers.get('x-webhook-signature') | ||
| if (!signature) { | ||
| logger.warn(`[${requestId}] Netlify webhook missing X-Webhook-Signature header`) | ||
| return new NextResponse('Unauthorized - Missing Netlify signature', { status: 401 }) | ||
| } | ||
|
|
||
| if (!verifyNetlifyJwt(signature, secret, rawBody)) { | ||
| logger.warn(`[${requestId}] Netlify signature verification failed`) | ||
| return new NextResponse('Unauthorized - Invalid Netlify signature', { status: 401 }) | ||
| } | ||
|
|
||
| return null | ||
| }, | ||
|
|
||
| async matchEvent({ webhook, workflow, body, requestId, providerConfig }: EventMatchContext) { | ||
| const triggerId = providerConfig.triggerId as string | undefined | ||
| if (!triggerId) return true | ||
|
|
||
| const { isNetlifyEventMatch } = await import('@/triggers/netlify/utils') | ||
| const obj = body as Record<string, unknown> | ||
| const state = typeof obj.state === 'string' ? obj.state : undefined | ||
|
|
||
| if (!isNetlifyEventMatch(triggerId, state)) { | ||
| logger.debug(`[${requestId}] Netlify event mismatch for trigger ${triggerId}. Skipping.`, { | ||
| webhookId: webhook.id, | ||
| workflowId: workflow.id, | ||
| triggerId, | ||
| state, | ||
| }) | ||
| return false | ||
| } | ||
|
|
||
| return true | ||
| }, | ||
|
|
||
| extractIdempotencyId(body: unknown) { | ||
| const obj = body as Record<string, unknown> | undefined | ||
| const id = obj?.id | ||
| if (id === undefined || id === null || id === '') { | ||
| return null | ||
| } | ||
| const state = typeof obj?.state === 'string' ? obj.state : '' | ||
| const locked = obj?.locked === true ? '1' : '0' | ||
| return `netlify:${String(id)}:${state}:${locked}` | ||
| }, | ||
|
|
||
| async formatInput(ctx: FormatInputContext): Promise<FormatInputResult> { | ||
| const body = ctx.body as Record<string, unknown> | ||
|
|
||
| const str = (v: unknown): string => (v == null ? '' : String(v)) | ||
|
|
||
| return { | ||
| input: { | ||
| id: str(body.id), | ||
| siteId: str(body.site_id), | ||
| state: str(body.state), | ||
| name: str(body.name), | ||
| url: str(body.url), | ||
| deployUrl: str(body.deploy_url), | ||
| deploySslUrl: str(body.deploy_ssl_url), | ||
| adminUrl: str(body.admin_url), | ||
| branch: str(body.branch), | ||
| context: str(body.context), | ||
| commitRef: str(body.commit_ref), | ||
| commitUrl: str(body.commit_url), | ||
| title: str(body.title), | ||
| errorMessage: str(body.error_message), | ||
| createdAt: str(body.created_at), | ||
| updatedAt: str(body.updated_at), | ||
| publishedAt: str(body.published_at), | ||
| payload: body, | ||
| }, | ||
| } | ||
| }, | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| import type { NetlifyCancelDeployParams, NetlifyCancelDeployResponse } from '@/tools/netlify/types' | ||
| import type { ToolConfig } from '@/tools/types' | ||
|
|
||
| interface NetlifyApiDeploy { | ||
| id?: string | ||
| site_id?: string | ||
| state?: string | ||
| name?: string | ||
| url?: string | ||
| deploy_url?: string | ||
| deploy_ssl_url?: string | ||
| admin_url?: string | ||
| branch?: string | ||
| context?: string | ||
| commit_ref?: string | ||
| commit_url?: string | ||
| error_message?: string | ||
| created_at?: string | ||
| updated_at?: string | ||
| published_at?: string | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Duplicated
|
||
|
|
||
| export const netlifyCancelDeployTool: ToolConfig< | ||
| NetlifyCancelDeployParams, | ||
| NetlifyCancelDeployResponse | ||
| > = { | ||
| id: 'netlify_cancel_deploy', | ||
| name: 'Netlify Cancel Deploy', | ||
| description: 'Cancel an in-progress Netlify deploy', | ||
| version: '1.0.0', | ||
|
|
||
| params: { | ||
| apiKey: { | ||
| type: 'string', | ||
| required: true, | ||
| visibility: 'user-only', | ||
| description: 'Netlify Personal Access Token', | ||
| }, | ||
| deployId: { | ||
| type: 'string', | ||
| required: true, | ||
| visibility: 'user-or-llm', | ||
| description: 'Deploy ID to cancel', | ||
| }, | ||
| }, | ||
|
|
||
| request: { | ||
| url: (params: NetlifyCancelDeployParams) => | ||
| `https://api.netlify.com/api/v1/deploys/${encodeURIComponent(params.deployId.trim())}/cancel`, | ||
| method: 'POST', | ||
| headers: (params: NetlifyCancelDeployParams) => ({ | ||
| Authorization: `Bearer ${params.apiKey}`, | ||
| 'Content-Type': 'application/json', | ||
| }), | ||
| }, | ||
|
|
||
| transformResponse: async (response: Response) => { | ||
| const d = (await response.json()) as NetlifyApiDeploy | ||
|
|
||
| return { | ||
| success: true, | ||
| output: { | ||
| id: d.id ?? '', | ||
| siteId: d.site_id ?? null, | ||
| state: d.state ?? 'error', | ||
| name: d.name ?? null, | ||
| url: d.url ?? null, | ||
| deployUrl: d.deploy_url ?? null, | ||
| deploySslUrl: d.deploy_ssl_url ?? null, | ||
| adminUrl: d.admin_url ?? null, | ||
| branch: d.branch ?? null, | ||
| context: d.context ?? null, | ||
| commitRef: d.commit_ref ?? null, | ||
| commitUrl: d.commit_url ?? null, | ||
| errorMessage: d.error_message ?? null, | ||
| createdAt: d.created_at ?? null, | ||
| updatedAt: d.updated_at ?? null, | ||
| publishedAt: d.published_at ?? null, | ||
| }, | ||
| } | ||
| }, | ||
|
|
||
| outputs: { | ||
| id: { type: 'string', description: 'Deploy ID' }, | ||
| siteId: { type: 'string', description: 'Site ID', optional: true }, | ||
| state: { type: 'string', description: 'Deploy state after cancellation' }, | ||
| name: { type: 'string', description: 'Site name', optional: true }, | ||
| url: { type: 'string', description: 'Site URL', optional: true }, | ||
| deployUrl: { type: 'string', description: 'Unique deploy URL', optional: true }, | ||
| deploySslUrl: { type: 'string', description: 'Unique deploy HTTPS URL', optional: true }, | ||
| adminUrl: { type: 'string', description: 'Netlify admin URL', optional: true }, | ||
| branch: { type: 'string', description: 'Git branch', optional: true }, | ||
| context: { type: 'string', description: 'Deploy context', optional: true }, | ||
| commitRef: { type: 'string', description: 'Git commit SHA', optional: true }, | ||
| commitUrl: { type: 'string', description: 'Git commit URL', optional: true }, | ||
| errorMessage: { type: 'string', description: 'Error message if failed', optional: true }, | ||
| createdAt: { type: 'string', description: 'Creation timestamp', optional: true }, | ||
| updatedAt: { type: 'string', description: 'Last update timestamp', optional: true }, | ||
| publishedAt: { type: 'string', description: 'Publish timestamp', optional: true }, | ||
| }, | ||
| } | ||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
expclaim validationverifyNetlifyJwtvalidates the HMAC signature and thesha256body-hash but never checks theexpclaim. Netlify JWTs are short-lived (typically valid for ~30 seconds after delivery). Without an expiry check, a previously-captured valid token can be replayed indefinitely — the signature and body-hash will still pass because neither changes between replays. TheextractIdempotencyIdguard limits duplicate workflow executions for the same deploy ID, but a replay with the same JWT + body would bypass signature freshness entirely.