diff --git a/packages/n8n-node/nodes/Probo/actions/webhook/create.operation.ts b/packages/n8n-node/nodes/Probo/actions/webhook/create.operation.ts index d1576dba1..21e706cf4 100644 --- a/packages/n8n-node/nodes/Probo/actions/webhook/create.operation.ts +++ b/packages/n8n-node/nodes/Probo/actions/webhook/create.operation.ts @@ -14,6 +14,7 @@ import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; import { proboApiRequest } from '../../GenericFunctions'; +import { WEBHOOK_EVENT_OPTIONS } from './events'; export const description: INodeProperties[] = [ { @@ -54,20 +55,7 @@ export const description: INodeProperties[] = [ operation: ['create'], }, }, - options: [ - { name: 'Meeting Created', value: 'MEETING_CREATED' }, - { name: 'Meeting Deleted', value: 'MEETING_DELETED' }, - { name: 'Meeting Updated', value: 'MEETING_UPDATED' }, - { name: 'Obligation Created', value: 'OBLIGATION_CREATED' }, - { name: 'Obligation Deleted', value: 'OBLIGATION_DELETED' }, - { name: 'Obligation Updated', value: 'OBLIGATION_UPDATED' }, - { name: 'Third Party Created', value: 'THIRD_PARTY_CREATED' }, - { name: 'Third Party Deleted', value: 'THIRD_PARTY_DELETED' }, - { name: 'Third Party Updated', value: 'THIRD_PARTY_UPDATED' }, - { name: 'User Created', value: 'USER_CREATED' }, - { name: 'User Deleted', value: 'USER_DELETED' }, - { name: 'User Updated', value: 'USER_UPDATED' }, - ], + options: WEBHOOK_EVENT_OPTIONS, default: [], description: 'The event types to subscribe to', required: true, diff --git a/packages/n8n-node/nodes/Probo/actions/webhook/events.ts b/packages/n8n-node/nodes/Probo/actions/webhook/events.ts new file mode 100644 index 000000000..92b8d96d0 --- /dev/null +++ b/packages/n8n-node/nodes/Probo/actions/webhook/events.ts @@ -0,0 +1,27 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import type { INodePropertyOptions } from 'n8n-workflow'; + +export const WEBHOOK_EVENT_OPTIONS: INodePropertyOptions[] = [ + { name: 'Obligation Created', value: 'OBLIGATION_CREATED' }, + { name: 'Obligation Deleted', value: 'OBLIGATION_DELETED' }, + { name: 'Obligation Updated', value: 'OBLIGATION_UPDATED' }, + { name: 'Third Party Created', value: 'THIRD_PARTY_CREATED' }, + { name: 'Third Party Deleted', value: 'THIRD_PARTY_DELETED' }, + { name: 'Third Party Updated', value: 'THIRD_PARTY_UPDATED' }, + { name: 'User Created', value: 'USER_CREATED' }, + { name: 'User Deleted', value: 'USER_DELETED' }, + { name: 'User Updated', value: 'USER_UPDATED' }, +]; diff --git a/packages/n8n-node/nodes/Probo/actions/webhook/update.operation.ts b/packages/n8n-node/nodes/Probo/actions/webhook/update.operation.ts index 63ed93ebd..5a693e77a 100644 --- a/packages/n8n-node/nodes/Probo/actions/webhook/update.operation.ts +++ b/packages/n8n-node/nodes/Probo/actions/webhook/update.operation.ts @@ -14,6 +14,7 @@ import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; import { proboApiRequest } from '../../GenericFunctions'; +import { WEBHOOK_EVENT_OPTIONS } from './events'; export const description: INodeProperties[] = [ { @@ -53,20 +54,7 @@ export const description: INodeProperties[] = [ operation: ['update'], }, }, - options: [ - { name: 'Meeting Created', value: 'MEETING_CREATED' }, - { name: 'Meeting Deleted', value: 'MEETING_DELETED' }, - { name: 'Meeting Updated', value: 'MEETING_UPDATED' }, - { name: 'Obligation Created', value: 'OBLIGATION_CREATED' }, - { name: 'Obligation Deleted', value: 'OBLIGATION_DELETED' }, - { name: 'Obligation Updated', value: 'OBLIGATION_UPDATED' }, - { name: 'Third Party Created', value: 'THIRD_PARTY_CREATED' }, - { name: 'Third Party Deleted', value: 'THIRD_PARTY_DELETED' }, - { name: 'Third Party Updated', value: 'THIRD_PARTY_UPDATED' }, - { name: 'User Created', value: 'USER_CREATED' }, - { name: 'User Deleted', value: 'USER_DELETED' }, - { name: 'User Updated', value: 'USER_UPDATED' }, - ], + options: WEBHOOK_EVENT_OPTIONS, default: [], description: 'The event types to subscribe to (replaces existing selection)', }, diff --git a/packages/n8n-node/nodes/ProboTrigger/ProboTrigger.node.json b/packages/n8n-node/nodes/ProboTrigger/ProboTrigger.node.json new file mode 100644 index 000000000..3dbc9d6d8 --- /dev/null +++ b/packages/n8n-node/nodes/ProboTrigger/ProboTrigger.node.json @@ -0,0 +1,19 @@ +{ + "node": "n8n-nodes-probo.proboTrigger", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": ["Development", "Developer Tools", "Automation"], + "resources": { + "credentialDocumentation": [ + { + "url": "https://www.probo.com/docs" + } + ], + "primaryDocumentation": [ + { + "url": "https://www.probo.com/docs" + } + ], + "generic": [] + } +} diff --git a/packages/n8n-node/nodes/ProboTrigger/ProboTrigger.node.ts b/packages/n8n-node/nodes/ProboTrigger/ProboTrigger.node.ts new file mode 100644 index 000000000..68d059b02 --- /dev/null +++ b/packages/n8n-node/nodes/ProboTrigger/ProboTrigger.node.ts @@ -0,0 +1,344 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import { createHmac, timingSafeEqual } from 'crypto'; +import { + NodeApiError, + NodeConnectionTypes, + type IDataObject, + type IHookFunctions, + type INodeType, + type INodeTypeDescription, + type IWebhookFunctions, + type IWebhookResponseData, + type JsonObject, +} from 'n8n-workflow'; +import { proboApiRequest } from '../Probo/GenericFunctions'; +import { WEBHOOK_EVENT_OPTIONS } from '../Probo/actions/webhook/events'; + +function extractNode(response: IDataObject): IDataObject | undefined { + const data = response?.data as IDataObject | undefined; + return data?.node as IDataObject | undefined; +} + +function sameEventSet(a: string[], b: string[]): boolean { + if (a.length !== b.length) { + return false; + } + + const set = new Set(a); + return b.every((value) => set.has(value)); +} + +async function deleteSubscription(this: IHookFunctions, subscriptionId: string): Promise { + const query = ` + mutation DeleteWebhookSubscription($input: DeleteWebhookSubscriptionInput!) { + deleteWebhookSubscription(input: $input) { + deletedWebhookSubscriptionId + } + } + `; + + try { + await proboApiRequest.call(this, query, { + input: { webhookSubscriptionId: subscriptionId }, + }); + } catch { + return false; + } + + return true; +} + +export class ProboTrigger implements INodeType { + description: INodeTypeDescription = { + displayName: 'Probo Trigger', + name: 'proboTrigger', + icon: { light: 'file:../../icons/probo-light.svg', dark: 'file:../../icons/probo.svg' }, + group: ['trigger'], + version: 1, + subtitle: '={{$parameter["events"].join(", ")}}', + description: 'Starts a workflow when Probo events occur', + usableAsTool: true, + defaults: { + name: 'Probo Trigger', + }, + inputs: [], + outputs: [NodeConnectionTypes.Main], + credentials: [ + { + name: 'proboApi', + required: true, + }, + ], + webhooks: [ + { + name: 'default', + httpMethod: 'POST', + responseMode: 'onReceived', + path: 'webhook', + }, + ], + properties: [ + { + displayName: 'Organization ID', + name: 'organizationId', + type: 'string', + default: '', + required: true, + description: 'The ID of the organization to receive events from', + }, + { + displayName: 'Events', + name: 'events', + type: 'multiOptions', + options: WEBHOOK_EVENT_OPTIONS, + default: [], + required: true, + description: 'The event types that trigger this workflow', + }, + { + displayName: 'Verify Signature', + name: 'verifySignature', + type: 'boolean', + default: true, + description: + 'Whether to reject deliveries whose HMAC-SHA256 signature does not match the subscription signing secret', + }, + { + displayName: 'Timestamp Tolerance (Seconds)', + name: 'toleranceSeconds', + type: 'number', + default: 300, + typeOptions: { + minValue: 0, + }, + displayOptions: { + show: { + verifySignature: [true], + }, + }, + description: + 'Reject deliveries whose signed timestamp is older or further in the future than this many seconds, preventing replay of captured deliveries. Set to 0 to disable the freshness check.', + }, + ], + }; + + webhookMethods = { + default: { + async checkExists(this: IHookFunctions): Promise { + const webhookData = this.getWorkflowStaticData('node'); + const subscriptionId = webhookData.subscriptionId as string | undefined; + + if (subscriptionId === undefined) { + return false; + } + + const query = ` + query CheckWebhookSubscription($id: ID!) { + node(id: $id) { + ... on WebhookSubscription { + id + endpointUrl + selectedEvents + organization { + id + } + } + } + } + `; + + const response = await proboApiRequest.call(this, query, { id: subscriptionId }); + const node = extractNode(response); + + if (node?.id === undefined) { + delete webhookData.subscriptionId; + delete webhookData.signingSecret; + return false; + } + + const webhookUrl = this.getNodeWebhookUrl('default'); + const organizationId = this.getNodeParameter('organizationId') as string; + const events = this.getNodeParameter('events') as string[]; + const organization = node.organization as IDataObject | undefined; + const remoteEvents = (node.selectedEvents as string[] | undefined) ?? []; + + const urlMatches = webhookUrl === undefined || node.endpointUrl === webhookUrl; + const organizationMatches = organization?.id === organizationId; + const eventsMatch = sameEventSet(remoteEvents, events); + + if (urlMatches && organizationMatches && eventsMatch) { + return true; + } + + // A node parameter changed since the subscription was created. The + // backend cannot move a subscription between organizations, and a stale + // subscription keeps delivering the old event set to the same n8n URL, + // so drop it here and let n8n re-create one matching the current config. + if (!(await deleteSubscription.call(this, subscriptionId))) { + // Keep the existing subscription rather than creating a second one + // that would deliver duplicates to the same URL; a later activation + // retries the cleanup. + return true; + } + + delete webhookData.subscriptionId; + delete webhookData.signingSecret; + + return false; + }, + + async create(this: IHookFunctions): Promise { + const webhookUrl = this.getNodeWebhookUrl('default'); + const organizationId = this.getNodeParameter('organizationId') as string; + const events = this.getNodeParameter('events') as string[]; + + if (webhookUrl === undefined) { + throw new NodeApiError(this.getNode(), { + message: 'Cannot create Probo webhook subscription: no webhook URL available', + } as JsonObject); + } + + const query = ` + mutation CreateWebhookSubscription($input: CreateWebhookSubscriptionInput!) { + createWebhookSubscription(input: $input) { + webhookSubscriptionEdge { + node { + id + signingSecret + } + } + } + } + `; + + const response = await proboApiRequest.call(this, query, { + input: { + organizationId, + endpointUrl: webhookUrl, + selectedEvents: events, + }, + }); + + const data = response?.data as IDataObject | undefined; + const payload = data?.createWebhookSubscription as IDataObject | undefined; + const edge = payload?.webhookSubscriptionEdge as IDataObject | undefined; + const node = edge?.node as IDataObject | undefined; + + if (node?.id === undefined) { + throw new NodeApiError(this.getNode(), { + message: 'Cannot create Probo webhook subscription: unexpected API response', + } as JsonObject); + } + + const webhookData = this.getWorkflowStaticData('node'); + webhookData.subscriptionId = node.id as string; + webhookData.signingSecret = node.signingSecret as string; + + return true; + }, + + async delete(this: IHookFunctions): Promise { + const webhookData = this.getWorkflowStaticData('node'); + const subscriptionId = webhookData.subscriptionId as string | undefined; + + if (subscriptionId === undefined) { + return true; + } + + if (!(await deleteSubscription.call(this, subscriptionId))) { + return false; + } + + delete webhookData.subscriptionId; + delete webhookData.signingSecret; + + return true; + }, + }, + }; + + async webhook(this: IWebhookFunctions): Promise { + const verifySignature = this.getNodeParameter('verifySignature', true) as boolean; + const headers = this.getHeaderData() as IDataObject; + const body = this.getBodyData(); + + if (verifySignature) { + const webhookData = this.getWorkflowStaticData('node'); + const signingSecret = webhookData.signingSecret as string | undefined; + const signature = headers['x-probo-webhook-signature'] as string | undefined; + const timestamp = headers['x-probo-webhook-timestamp'] as string | undefined; + + const rejected = this.getResponseObject(); + + if (signingSecret === undefined || signature === undefined || timestamp === undefined) { + rejected.status(403).json({ message: 'Missing Probo webhook signature' }); + return { noWebhookResponse: true }; + } + + // n8n's webhook pipeline calls parseBody() -> req.readRawBody() before + // invoking this handler for application/json payloads (which Probo always + // sends), so req.rawBody holds the exact bytes the signature was computed + // over. Re-serializing the parsed body would not match Go's json.Marshal + // output, so fail closed if the raw bytes are somehow unavailable. + const request = this.getRequestObject() as unknown as { rawBody?: Buffer }; + const rawBody = request.rawBody; + + if (rawBody === undefined) { + rejected + .status(403) + .json({ message: 'Probo webhook raw body unavailable for signature verification' }); + return { noWebhookResponse: true }; + } + + const hmac = createHmac('sha256', signingSecret); + hmac.update(`${timestamp}:`); + hmac.update(rawBody); + const expected = hmac.digest('hex'); + + const expectedBuffer = Buffer.from(expected, 'hex'); + const receivedBuffer = Buffer.from(signature, 'hex'); + + if ( + expectedBuffer.length !== receivedBuffer.length || + !timingSafeEqual(expectedBuffer, receivedBuffer) + ) { + rejected.status(403).json({ message: 'Invalid Probo webhook signature' }); + return { noWebhookResponse: true }; + } + + // The signature authenticates the timestamp, so a valid request could + // still be replayed verbatim. Reject deliveries outside the tolerance + // window to bound that replay surface. + const toleranceSeconds = this.getNodeParameter('toleranceSeconds', 300) as number; + if (toleranceSeconds > 0) { + const timestampSeconds = Number(timestamp); + const nowSeconds = Date.now() / 1000; + + if ( + !Number.isFinite(timestampSeconds) || + Math.abs(nowSeconds - timestampSeconds) > toleranceSeconds + ) { + rejected.status(403).json({ message: 'Stale Probo webhook timestamp' }); + return { noWebhookResponse: true }; + } + } + } + + return { + workflowData: [this.helpers.returnJsonArray(body as IDataObject)], + }; + } +} diff --git a/packages/n8n-node/package.json b/packages/n8n-node/package.json index 18ac97f96..b4f0d5b34 100644 --- a/packages/n8n-node/package.json +++ b/packages/n8n-node/package.json @@ -25,7 +25,8 @@ "dist/credentials/ProboApi.credentials.js" ], "nodes": [ - "dist/nodes/Probo/Probo.node.js" + "dist/nodes/Probo/Probo.node.js", + "dist/nodes/ProboTrigger/ProboTrigger.node.js" ] }, "license": "MIT", diff --git a/pkg/cmd/webhook/shared/events.go b/pkg/cmd/webhook/shared/events.go index 3ad7f132f..3e8b11e1a 100644 --- a/pkg/cmd/webhook/shared/events.go +++ b/pkg/cmd/webhook/shared/events.go @@ -18,9 +18,6 @@ package shared // create and update commands. Keep in sync with // pkg/coredata/webhook_event_type.go. var ValidEvents = []string{ - "MEETING_CREATED", - "MEETING_UPDATED", - "MEETING_DELETED", "THIRD_PARTY_CREATED", "THIRD_PARTY_UPDATED", "THIRD_PARTY_DELETED",