diff --git a/packages/nodes/src/index.ts b/packages/nodes/src/index.ts index 5c2de45..4f76041 100644 --- a/packages/nodes/src/index.ts +++ b/packages/nodes/src/index.ts @@ -3,6 +3,7 @@ export { conditionalNode, endNode, delayNode, + webhookTriggerNode, ConditionalInputSchema, ConditionalOutputSchema, ConditionSchema, @@ -11,6 +12,8 @@ export { EndOutputSchema, DelayInputSchema, DelayOutputSchema, + WebhookTriggerInputSchema, + WebhookTriggerOutputSchema, } from './logic/index.js' export type { @@ -22,6 +25,8 @@ export type { EndOutput, DelayInput, DelayOutput, + WebhookTriggerInput, + WebhookTriggerOutput, } from './logic/index.js' // Transform nodes @@ -268,6 +273,7 @@ export type { import { conditionalNode } from './logic/index.js' import { endNode } from './logic/index.js' import { delayNode } from './logic/index.js' +import { webhookTriggerNode } from './logic/index.js' import { mapNode, filterNode, sortNode } from './transform/index.js' import { httpRequestNode, breadNode } from './examples/index.js' import { @@ -313,6 +319,7 @@ export const builtInNodes = [ conditionalNode, endNode, delayNode, + webhookTriggerNode, // Transform mapNode, filterNode, diff --git a/packages/nodes/src/logic/__tests__/webhook-trigger.test.ts b/packages/nodes/src/logic/__tests__/webhook-trigger.test.ts new file mode 100644 index 0000000..fa851ca --- /dev/null +++ b/packages/nodes/src/logic/__tests__/webhook-trigger.test.ts @@ -0,0 +1,527 @@ +import { describe, it, expect } from 'vitest' +import { + webhookTriggerNode, + WebhookTriggerInputSchema, + WebhookTriggerOutputSchema, +} from '../webhook-trigger.js' + +/** + * Creates a mock NodeExecutionContext with an optional webhookRequest injected. + */ +function makeContext(webhookRequest?: unknown) { + const variables = { webhookRequest } + return { + userId: 'test-user', + workflowExecutionId: 'test-run', + variables, + resolveNestedPath: (path: string) => + path.split('.').reduce((obj: unknown, key: string) => { + if (obj && typeof obj === 'object') { + return (obj as Record)[key] + } + return undefined + }, variables as unknown), + credentials: {}, + } +} + +// --------------------------------------------------------------------------- +// Metadata +// --------------------------------------------------------------------------- + +describe('webhookTriggerNode - metadata', () => { + it('should have type webhook_trigger', () => { + expect(webhookTriggerNode.type).toBe('webhook_trigger') + }) + + it('should have category logic', () => { + expect(webhookTriggerNode.category).toBe('logic') + }) + + it('should not support rerun', () => { + expect(webhookTriggerNode.capabilities?.supportsRerun).toBe(false) + }) + + it('should not support cancel', () => { + expect(webhookTriggerNode.capabilities?.supportsCancel).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// Input schema validation +// --------------------------------------------------------------------------- + +describe('webhookTriggerNode - input schema validation', () => { + it('should accept minimal valid input (path + method)', () => { + const result = WebhookTriggerInputSchema.safeParse({ + path: '/webhooks/test', + method: 'POST', + }) + expect(result.success).toBe(true) + }) + + it('should reject a path that does not start with /', () => { + const result = WebhookTriggerInputSchema.safeParse({ + path: 'webhooks/test', + method: 'POST', + }) + expect(result.success).toBe(false) + }) + + it('should reject an empty path', () => { + const result = WebhookTriggerInputSchema.safeParse({ + path: '', + method: 'POST', + }) + expect(result.success).toBe(false) + }) + + it('should reject an invalid method like DELETE', () => { + const result = WebhookTriggerInputSchema.safeParse({ + path: '/webhooks/test', + method: 'DELETE', + }) + expect(result.success).toBe(false) + }) + + it('should accept optional responseCode between 100 and 599', () => { + const result = WebhookTriggerInputSchema.safeParse({ + path: '/webhooks/test', + method: 'GET', + responseCode: 204, + }) + expect(result.success).toBe(true) + }) + + it('should reject responseCode 99', () => { + const result = WebhookTriggerInputSchema.safeParse({ + path: '/webhooks/test', + method: 'GET', + responseCode: 99, + }) + expect(result.success).toBe(false) + }) + + it('should reject responseCode 600', () => { + const result = WebhookTriggerInputSchema.safeParse({ + path: '/webhooks/test', + method: 'GET', + responseCode: 600, + }) + expect(result.success).toBe(false) + }) + + it('should accept authentication type none with no credentials', () => { + const result = WebhookTriggerInputSchema.safeParse({ + path: '/webhooks/test', + method: 'POST', + authentication: { type: 'none' }, + }) + expect(result.success).toBe(true) + }) + + it('should accept authentication type basic with credentials', () => { + const result = WebhookTriggerInputSchema.safeParse({ + path: '/webhooks/test', + method: 'POST', + authentication: { + type: 'basic', + credentials: { username: 'admin', password: 'secret' }, + }, + }) + expect(result.success).toBe(true) + }) + + it('should accept authentication type header with credentials', () => { + const result = WebhookTriggerInputSchema.safeParse({ + path: '/webhooks/test', + method: 'POST', + authentication: { + type: 'header', + credentials: { 'x-api-key': 'token123' }, + }, + }) + expect(result.success).toBe(true) + }) +}) + +// --------------------------------------------------------------------------- +// Executor: no webhookRequest in context +// --------------------------------------------------------------------------- + +describe('webhookTriggerNode - executor: no webhookRequest in context', () => { + it('should return success false with descriptive error when webhookRequest is missing', async () => { + const context = makeContext(undefined) + const result = await webhookTriggerNode.executor( + { path: '/test', method: 'POST' }, + context as never, + ) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error).toContain('No webhook request data found') + } + }) +}) + +// --------------------------------------------------------------------------- +// Executor: method mismatch +// --------------------------------------------------------------------------- + +describe('webhookTriggerNode - executor: method mismatch', () => { + it('should return success false when incoming method does not match configured method', async () => { + const context = makeContext({ + method: 'GET', + headers: {}, + body: null, + path: '/test', + query: {}, + }) + const result = await webhookTriggerNode.executor( + { path: '/test', method: 'POST' }, + context as never, + ) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error).toContain('Method mismatch') + } + }) +}) + +// --------------------------------------------------------------------------- +// Executor: auth type none +// --------------------------------------------------------------------------- + +describe('webhookTriggerNode - executor: auth type none', () => { + const baseRequest = { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: { event: 'push' }, + path: '/webhooks/github', + query: { ref: 'main' }, + } + + it('should return success true and authenticated true regardless of headers', async () => { + const context = makeContext(baseRequest) + const result = await webhookTriggerNode.executor( + { path: '/webhooks/github', method: 'POST', authentication: { type: 'none' } }, + context as never, + ) + expect(result.success).toBe(true) + if (result.success) { + expect(result.output.authenticated).toBe(true) + } + }) + + it('should include body, headers, method, path, query, timestamp, authenticated in output', async () => { + const context = makeContext(baseRequest) + const result = await webhookTriggerNode.executor( + { path: '/webhooks/github', method: 'POST' }, + context as never, + ) + expect(result.success).toBe(true) + if (result.success) { + expect(result.output.body).toEqual({ event: 'push' }) + expect(result.output.headers).toEqual({ 'content-type': 'application/json' }) + expect(result.output.method).toBe('POST') + expect(result.output.path).toBe('/webhooks/github') + expect(result.output.query).toEqual({ ref: 'main' }) + expect(typeof result.output.timestamp).toBe('string') + expect(result.output.authenticated).toBe(true) + } + }) +}) + +// --------------------------------------------------------------------------- +// Executor: auth type basic +// --------------------------------------------------------------------------- + +describe('webhookTriggerNode - executor: auth type basic', () => { + const correctCreds = Buffer.from('admin:secret').toString('base64') + + function makeBasicRequest(authHeader?: string) { + return { + method: 'POST', + headers: authHeader ? { authorization: authHeader } : {}, + body: { data: 1 }, + path: '/secure', + query: {}, + } + } + + it('should return success true when Authorization header has correct base64 credentials', async () => { + const context = makeContext(makeBasicRequest(`Basic ${correctCreds}`)) + const result = await webhookTriggerNode.executor( + { + path: '/secure', + method: 'POST', + authentication: { + type: 'basic', + credentials: { username: 'admin', password: 'secret' }, + }, + }, + context as never, + ) + expect(result.success).toBe(true) + if (result.success) { + expect(result.output.authenticated).toBe(true) + } + }) + + it('should return success false when credentials are wrong', async () => { + const wrongCreds = Buffer.from('admin:wrong').toString('base64') + const context = makeContext(makeBasicRequest(`Basic ${wrongCreds}`)) + const result = await webhookTriggerNode.executor( + { + path: '/secure', + method: 'POST', + authentication: { + type: 'basic', + credentials: { username: 'admin', password: 'secret' }, + }, + }, + context as never, + ) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error).toContain('Basic authentication failed') + } + }) + + it('should return success false when Authorization header is missing', async () => { + const context = makeContext(makeBasicRequest()) + const result = await webhookTriggerNode.executor( + { + path: '/secure', + method: 'POST', + authentication: { + type: 'basic', + credentials: { username: 'admin', password: 'secret' }, + }, + }, + context as never, + ) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error).toContain('Basic authentication failed') + } + }) + + it('should return success false when Authorization header is not Basic type', async () => { + const context = makeContext(makeBasicRequest('Bearer some-token')) + const result = await webhookTriggerNode.executor( + { + path: '/secure', + method: 'POST', + authentication: { + type: 'basic', + credentials: { username: 'admin', password: 'secret' }, + }, + }, + context as never, + ) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error).toContain('Basic authentication failed') + } + }) +}) + +// --------------------------------------------------------------------------- +// Executor: auth type header +// --------------------------------------------------------------------------- + +describe('webhookTriggerNode - executor: auth type header', () => { + it('should return success true when all required headers are present and match', async () => { + const context = makeContext({ + method: 'POST', + headers: { 'x-api-key': 'secret123', 'content-type': 'application/json' }, + body: null, + path: '/hook', + query: {}, + }) + const result = await webhookTriggerNode.executor( + { + path: '/hook', + method: 'POST', + authentication: { + type: 'header', + credentials: { 'x-api-key': 'secret123' }, + }, + }, + context as never, + ) + expect(result.success).toBe(true) + if (result.success) { + expect(result.output.authenticated).toBe(true) + } + }) + + it('should return success false when a required header is missing', async () => { + const context = makeContext({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: null, + path: '/hook', + query: {}, + }) + const result = await webhookTriggerNode.executor( + { + path: '/hook', + method: 'POST', + authentication: { + type: 'header', + credentials: { 'x-api-key': 'secret123' }, + }, + }, + context as never, + ) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error).toContain('Header authentication failed') + } + }) + + it('should return success false when a required header has the wrong value', async () => { + const context = makeContext({ + method: 'POST', + headers: { 'x-api-key': 'wrong-value' }, + body: null, + path: '/hook', + query: {}, + }) + const result = await webhookTriggerNode.executor( + { + path: '/hook', + method: 'POST', + authentication: { + type: 'header', + credentials: { 'x-api-key': 'secret123' }, + }, + }, + context as never, + ) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error).toContain('Header authentication failed') + } + }) + + it('should match headers case-insensitively', async () => { + // The executor lowercases the credential key when looking up in request headers. + // The incoming request headers should already be lowercase (per convention). + const context = makeContext({ + method: 'POST', + headers: { 'x-custom-token': 'token-abc' }, + body: null, + path: '/hook', + query: {}, + }) + const result = await webhookTriggerNode.executor( + { + path: '/hook', + method: 'POST', + authentication: { + type: 'header', + // Credential key provided in mixed case — executor must lowercase it + credentials: { 'X-Custom-Token': 'token-abc' }, + }, + }, + context as never, + ) + expect(result.success).toBe(true) + if (result.success) { + expect(result.output.authenticated).toBe(true) + } + }) +}) + +// --------------------------------------------------------------------------- +// Output schema +// --------------------------------------------------------------------------- + +describe('webhookTriggerNode - output schema', () => { + it('should validate a well-formed output successfully', () => { + const result = WebhookTriggerOutputSchema.safeParse({ + body: { event: 'push' }, + headers: { 'content-type': 'application/json' }, + method: 'POST', + path: '/webhooks/github', + query: {}, + timestamp: new Date().toISOString(), + authenticated: true, + responseCode: 200, + responseData: { status: 'ok' }, + }) + expect(result.success).toBe(true) + }) + + it('should reject output missing authenticated field', () => { + const result = WebhookTriggerOutputSchema.safeParse({ + body: null, + headers: {}, + method: 'POST', + path: '/hook', + query: {}, + timestamp: new Date().toISOString(), + responseCode: 200, + // authenticated is missing + }) + expect(result.success).toBe(false) + }) + + it('should reject output missing responseCode field', () => { + const result = WebhookTriggerOutputSchema.safeParse({ + body: null, + headers: {}, + method: 'POST', + path: '/hook', + query: {}, + timestamp: new Date().toISOString(), + authenticated: true, + // responseCode is missing + }) + expect(result.success).toBe(false) + }) + + it('should include responseCode and responseData in executor output', async () => { + const context = makeContext({ + method: 'POST', + path: '/hook', + headers: {}, + body: null, + query: {}, + }) + const result = await webhookTriggerNode.executor( + { + path: '/hook', + method: 'POST', + responseCode: 201, + responseData: { received: true }, + }, + context as never, + ) + expect(result.success).toBe(true) + if (result.success) { + expect(result.output.responseCode).toBe(201) + expect(result.output.responseData).toEqual({ received: true }) + } + }) + + it('should default responseCode to 200 when not configured', async () => { + const context = makeContext({ + method: 'POST', + path: '/hook', + headers: {}, + body: null, + query: {}, + }) + const result = await webhookTriggerNode.executor( + { path: '/hook', method: 'POST' }, + context as never, + ) + expect(result.success).toBe(true) + if (result.success) { + expect(result.output.responseCode).toBe(200) + } + }) +}) diff --git a/packages/nodes/src/logic/index.ts b/packages/nodes/src/logic/index.ts index 30b27e9..a9d4792 100644 --- a/packages/nodes/src/logic/index.ts +++ b/packages/nodes/src/logic/index.ts @@ -19,3 +19,7 @@ export { EndInputSchema, EndOutputSchema } from './end.js'; export { delayNode } from './delay.js'; export type { DelayInput, DelayOutput } from './delay.js'; export { DelayInputSchema, DelayOutputSchema } from './delay.js'; + +export { webhookTriggerNode } from './webhook-trigger.js'; +export { WebhookTriggerInputSchema, WebhookTriggerOutputSchema } from './webhook-trigger.js'; +export type { WebhookTriggerInput, WebhookTriggerOutput } from './webhook-trigger.js'; diff --git a/packages/nodes/src/logic/webhook-trigger.ts b/packages/nodes/src/logic/webhook-trigger.ts new file mode 100644 index 0000000..9811a31 --- /dev/null +++ b/packages/nodes/src/logic/webhook-trigger.ts @@ -0,0 +1,185 @@ +import { z } from 'zod' +import { defineNode } from '@jam-nodes/core' + +/** + * Input schema for webhook trigger node + */ +export const WebhookTriggerInputSchema = z.object({ + path: z.string().min(1).regex(/^\//, 'Path must start with /'), + method: z.enum(['GET', 'POST', 'PUT']), + authentication: z + .object({ + type: z.enum(['none', 'basic', 'header']), + credentials: z.record(z.string()).optional(), + }) + .optional(), + responseCode: z.number().int().min(100).max(599).default(200).optional(), + responseData: z.unknown().optional(), +}) + +export type WebhookTriggerInput = z.infer + +/** + * Output schema for webhook trigger node + */ +export const WebhookTriggerOutputSchema = z.object({ + body: z.unknown(), + headers: z.record(z.string()), + method: z.string(), + path: z.string(), + query: z.record(z.string()), + timestamp: z.string(), + authenticated: z.boolean(), + responseCode: z.number().int().min(100).max(599), + responseData: z.unknown().optional(), +}) + +export type WebhookTriggerOutput = z.infer + +/** + * Webhook trigger node. + * + * A configuration + validation node that processes incoming HTTP webhook payloads. + * The host application is responsible for registering the route and injecting + * the incoming request data into context.variables.webhookRequest before executing. + * + * Supports three authentication types: + * - none: No auth check + * - basic: HTTP Basic Auth via Authorization header + * - header: Custom header key/value validation + * + * @example + * ```typescript + * { + * path: '/webhooks/stripe', + * method: 'POST', + * authentication: { + * type: 'header', + * credentials: { 'x-stripe-signature': 'expected-secret' } + * } + * } + * ``` + */ +export const webhookTriggerNode = defineNode({ + type: 'webhook_trigger', + name: 'Webhook Trigger', + description: 'Receive incoming HTTP webhook payloads with configurable authentication', + category: 'logic', + inputSchema: WebhookTriggerInputSchema, + outputSchema: WebhookTriggerOutputSchema, + estimatedDuration: 0, + capabilities: { + supportsRerun: false, + supportsCancel: false, + }, + executor: async (input, context) => { + try { + // Step 1 — Read incoming request from context + const webhookRequest = context.resolveNestedPath('webhookRequest') as + | { + method?: string + headers?: Record + body?: unknown + path?: string + query?: Record + } + | undefined + + if (!webhookRequest) { + return { + success: false, + error: + 'No webhook request data found in context. Ensure the host application injects webhookRequest into context.variables before executing this node.', + } + } + + // Step 2 — Validate method + if (webhookRequest.method?.toUpperCase() !== input.method) { + return { + success: false, + error: `Method mismatch: expected ${input.method}, received ${webhookRequest.method}`, + } + } + + // Step 3 — Authentication + let authenticated = false + const authType = input.authentication?.type ?? 'none' + + if (authType === 'none') { + authenticated = true + } else if (authType === 'basic') { + const authHeader = webhookRequest.headers?.['authorization'] + if (!authHeader) { + return { + success: false, + error: 'Basic authentication failed: invalid credentials', + } + } + + if (!authHeader.startsWith('Basic ')) { + return { + success: false, + error: 'Basic authentication failed: invalid credentials', + } + } + + const base64 = authHeader.slice(6) + const decoded = Buffer.from(base64, 'base64').toString('utf-8') + const colonIndex = decoded.indexOf(':') + if (colonIndex === -1) { + return { + success: false, + error: 'Basic authentication failed: invalid credentials', + } + } + + const username = decoded.slice(0, colonIndex) + const password = decoded.slice(colonIndex + 1) + const expectedUsername = input.authentication?.credentials?.['username'] + const expectedPassword = input.authentication?.credentials?.['password'] + + if (username !== expectedUsername || password !== expectedPassword) { + return { + success: false, + error: 'Basic authentication failed: invalid credentials', + } + } + + authenticated = true + } else if (authType === 'header') { + const credentials = input.authentication?.credentials ?? {} + for (const [key, expectedValue] of Object.entries(credentials)) { + const actualValue = webhookRequest.headers?.[key.toLowerCase()] + if (actualValue !== expectedValue) { + return { + success: false, + error: 'Header authentication failed: missing or invalid header', + } + } + } + authenticated = true + } + + // Step 4 — Return success + return { + success: true, + output: { + body: webhookRequest.body ?? null, + headers: webhookRequest.headers ?? {}, + method: webhookRequest.method!, + path: webhookRequest.path ?? input.path, + query: webhookRequest.query ?? {}, + timestamp: new Date().toISOString(), + authenticated, + responseCode: input.responseCode ?? 200, + responseData: input.responseData, + }, + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + } + } + }, +})