Files
probo/packages/n8n-node/nodes/ProboTrigger/ProboTrigger.node.ts
Sacha Al Himdani f14d8115b6 Fix n8n ProboTrigger node package review issues
- Log delete failures in deleteSubscription instead of swallowing them
- Correct codex node prefix to @probo/n8n-nodes-probo.proboTrigger
- Replace unsupported codex categories with Development and Utility

Signed-off-by: Sacha Al Himdani <sacha@probo.com>
2026-07-15 18:02:14 +02:00

355 lines
11 KiB
TypeScript

// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// 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<boolean> {
const query = `
mutation DeleteWebhookSubscription($input: DeleteWebhookSubscriptionInput!) {
deleteWebhookSubscription(input: $input) {
deletedWebhookSubscriptionId
}
}
`;
try {
await proboApiRequest.call(this, query, {
input: { webhookSubscriptionId: subscriptionId },
});
} catch (error) {
this.logger.error(`Failed to delete Probo webhook subscription "${subscriptionId}"`, {
subscriptionId,
error,
});
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<boolean> {
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<boolean> {
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<boolean> {
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<IWebhookResponseData> {
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)],
};
}
}