mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-01-30 14:32:04 +00:00
- Removed overly simplistic parenthesis pattern check that flagged valid code
- Pattern /)\s*)\s*{/ was incorrectly flagging valid n8n Code node patterns like:
- .first().json (node data access)
- func()() (function chaining)
- array.map().filter() (method chaining)
- These are all valid JavaScript patterns used in n8n Code nodes
- Only kept check for excessive closing braces at end of code
This eliminates false positives for workflow 85blKFvzQYvZXnLF which uses
valid syntax in Code nodes.
1559 lines
53 KiB
TypeScript
1559 lines
53 KiB
TypeScript
/**
|
|
* Node-Specific Validators
|
|
*
|
|
* Provides detailed validation logic for commonly used n8n nodes.
|
|
* Each validator understands the specific requirements and patterns of its node.
|
|
*/
|
|
|
|
import { ValidationError, ValidationWarning } from './config-validator';
|
|
|
|
export interface NodeValidationContext {
|
|
config: Record<string, any>;
|
|
errors: ValidationError[];
|
|
warnings: ValidationWarning[];
|
|
suggestions: string[];
|
|
autofix: Record<string, any>;
|
|
}
|
|
|
|
export class NodeSpecificValidators {
|
|
/**
|
|
* Validate Slack node configuration with operation awareness
|
|
*/
|
|
static validateSlack(context: NodeValidationContext): void {
|
|
const { config, errors, warnings, suggestions, autofix } = context;
|
|
const { resource, operation } = config;
|
|
|
|
// Message operations
|
|
if (resource === 'message') {
|
|
switch (operation) {
|
|
case 'send':
|
|
this.validateSlackSendMessage(context);
|
|
break;
|
|
case 'update':
|
|
this.validateSlackUpdateMessage(context);
|
|
break;
|
|
case 'delete':
|
|
this.validateSlackDeleteMessage(context);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Channel operations
|
|
else if (resource === 'channel') {
|
|
switch (operation) {
|
|
case 'create':
|
|
this.validateSlackCreateChannel(context);
|
|
break;
|
|
case 'get':
|
|
case 'getAll':
|
|
// These operations have minimal requirements
|
|
break;
|
|
}
|
|
}
|
|
|
|
// User operations
|
|
else if (resource === 'user') {
|
|
if (operation === 'get' && !config.user) {
|
|
errors.push({
|
|
type: 'missing_required',
|
|
property: 'user',
|
|
message: 'User identifier required - use email, user ID, or username',
|
|
fix: 'Set user to an email like "john@example.com" or user ID like "U1234567890"'
|
|
});
|
|
}
|
|
}
|
|
|
|
// Error handling for Slack operations
|
|
if (!config.onError && !config.retryOnFail && !config.continueOnFail) {
|
|
warnings.push({
|
|
type: 'best_practice',
|
|
property: 'errorHandling',
|
|
message: 'Slack API can have rate limits and transient failures',
|
|
suggestion: 'Add onError: "continueRegularOutput" with retryOnFail for resilience'
|
|
});
|
|
autofix.onError = 'continueRegularOutput';
|
|
autofix.retryOnFail = true;
|
|
autofix.maxTries = 2;
|
|
autofix.waitBetweenTries = 3000; // Slack rate limits
|
|
}
|
|
|
|
// Check for deprecated continueOnFail
|
|
if (config.continueOnFail !== undefined) {
|
|
warnings.push({
|
|
type: 'deprecated',
|
|
property: 'continueOnFail',
|
|
message: 'continueOnFail is deprecated. Use onError instead',
|
|
suggestion: 'Replace with onError: "continueRegularOutput"'
|
|
});
|
|
}
|
|
}
|
|
|
|
private static validateSlackSendMessage(context: NodeValidationContext): void {
|
|
const { config, errors, warnings, suggestions, autofix } = context;
|
|
|
|
// Channel is required for sending messages
|
|
if (!config.channel && !config.channelId) {
|
|
errors.push({
|
|
type: 'missing_required',
|
|
property: 'channel',
|
|
message: 'Channel is required to send a message',
|
|
fix: 'Set channel to a channel name (e.g., "#general") or ID (e.g., "C1234567890")'
|
|
});
|
|
}
|
|
|
|
// Message content validation
|
|
if (!config.text && !config.blocks && !config.attachments) {
|
|
errors.push({
|
|
type: 'missing_required',
|
|
property: 'text',
|
|
message: 'Message content is required - provide text, blocks, or attachments',
|
|
fix: 'Add text field with your message content'
|
|
});
|
|
}
|
|
|
|
// Common patterns and suggestions
|
|
if (config.text && config.text.length > 40000) {
|
|
warnings.push({
|
|
type: 'inefficient',
|
|
property: 'text',
|
|
message: 'Message text exceeds Slack\'s 40,000 character limit',
|
|
suggestion: 'Split into multiple messages or use a file upload'
|
|
});
|
|
}
|
|
|
|
// Thread reply validation
|
|
if (config.replyToThread && !config.threadTs) {
|
|
warnings.push({
|
|
type: 'missing_common',
|
|
property: 'threadTs',
|
|
message: 'Thread timestamp required when replying to thread',
|
|
suggestion: 'Set threadTs to the timestamp of the thread parent message'
|
|
});
|
|
}
|
|
|
|
// Mention handling
|
|
if (config.text?.includes('@') && !config.linkNames) {
|
|
suggestions.push('Set linkNames=true to convert @mentions to user links');
|
|
autofix.linkNames = true;
|
|
}
|
|
}
|
|
|
|
private static validateSlackUpdateMessage(context: NodeValidationContext): void {
|
|
const { config, errors } = context;
|
|
|
|
if (!config.ts) {
|
|
errors.push({
|
|
type: 'missing_required',
|
|
property: 'ts',
|
|
message: 'Message timestamp (ts) is required to update a message',
|
|
fix: 'Provide the timestamp of the message to update'
|
|
});
|
|
}
|
|
|
|
if (!config.channel && !config.channelId) {
|
|
errors.push({
|
|
type: 'missing_required',
|
|
property: 'channel',
|
|
message: 'Channel is required to update a message',
|
|
fix: 'Provide the channel where the message exists'
|
|
});
|
|
}
|
|
}
|
|
|
|
private static validateSlackDeleteMessage(context: NodeValidationContext): void {
|
|
const { config, errors, warnings } = context;
|
|
|
|
if (!config.ts) {
|
|
errors.push({
|
|
type: 'missing_required',
|
|
property: 'ts',
|
|
message: 'Message timestamp (ts) is required to delete a message',
|
|
fix: 'Provide the timestamp of the message to delete'
|
|
});
|
|
}
|
|
|
|
if (!config.channel && !config.channelId) {
|
|
errors.push({
|
|
type: 'missing_required',
|
|
property: 'channel',
|
|
message: 'Channel is required to delete a message',
|
|
fix: 'Provide the channel where the message exists'
|
|
});
|
|
}
|
|
|
|
warnings.push({
|
|
type: 'security',
|
|
message: 'Message deletion is permanent and cannot be undone',
|
|
suggestion: 'Consider archiving or updating the message instead if you need to preserve history'
|
|
});
|
|
}
|
|
|
|
private static validateSlackCreateChannel(context: NodeValidationContext): void {
|
|
const { config, errors, warnings } = context;
|
|
|
|
if (!config.name) {
|
|
errors.push({
|
|
type: 'missing_required',
|
|
property: 'name',
|
|
message: 'Channel name is required',
|
|
fix: 'Provide a channel name (lowercase, no spaces, 1-80 characters)'
|
|
});
|
|
} else {
|
|
// Validate channel name format
|
|
const name = config.name;
|
|
if (name.includes(' ')) {
|
|
errors.push({
|
|
type: 'invalid_value',
|
|
property: 'name',
|
|
message: 'Channel names cannot contain spaces',
|
|
fix: 'Use hyphens or underscores instead of spaces'
|
|
});
|
|
}
|
|
if (name !== name.toLowerCase()) {
|
|
errors.push({
|
|
type: 'invalid_value',
|
|
property: 'name',
|
|
message: 'Channel names must be lowercase',
|
|
fix: 'Convert the channel name to lowercase'
|
|
});
|
|
}
|
|
if (name.length > 80) {
|
|
errors.push({
|
|
type: 'invalid_value',
|
|
property: 'name',
|
|
message: 'Channel name exceeds 80 character limit',
|
|
fix: 'Shorten the channel name'
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate Google Sheets node configuration
|
|
*/
|
|
static validateGoogleSheets(context: NodeValidationContext): void {
|
|
const { config, errors, warnings, suggestions } = context;
|
|
const { operation } = config;
|
|
|
|
// Common validations
|
|
if (!config.sheetId && !config.documentId) {
|
|
errors.push({
|
|
type: 'missing_required',
|
|
property: 'sheetId',
|
|
message: 'Spreadsheet ID is required',
|
|
fix: 'Provide the Google Sheets document ID from the URL'
|
|
});
|
|
}
|
|
|
|
// Operation-specific validations
|
|
switch (operation) {
|
|
case 'append':
|
|
this.validateGoogleSheetsAppend(context);
|
|
break;
|
|
case 'read':
|
|
this.validateGoogleSheetsRead(context);
|
|
break;
|
|
case 'update':
|
|
this.validateGoogleSheetsUpdate(context);
|
|
break;
|
|
case 'delete':
|
|
this.validateGoogleSheetsDelete(context);
|
|
break;
|
|
}
|
|
|
|
// Range format validation
|
|
if (config.range) {
|
|
this.validateGoogleSheetsRange(config.range, errors, warnings);
|
|
}
|
|
}
|
|
|
|
private static validateGoogleSheetsAppend(context: NodeValidationContext): void {
|
|
const { config, errors, warnings, autofix } = context;
|
|
|
|
if (!config.range) {
|
|
errors.push({
|
|
type: 'missing_required',
|
|
property: 'range',
|
|
message: 'Range is required for append operation',
|
|
fix: 'Specify range like "Sheet1!A:B" or "Sheet1!A1:B10"'
|
|
});
|
|
}
|
|
|
|
// Check for common append settings
|
|
if (!config.options?.valueInputMode) {
|
|
warnings.push({
|
|
type: 'missing_common',
|
|
property: 'options.valueInputMode',
|
|
message: 'Consider setting valueInputMode for proper data formatting',
|
|
suggestion: 'Use "USER_ENTERED" to parse formulas and dates, or "RAW" for literal values'
|
|
});
|
|
autofix.options = { ...config.options, valueInputMode: 'USER_ENTERED' };
|
|
}
|
|
}
|
|
|
|
private static validateGoogleSheetsRead(context: NodeValidationContext): void {
|
|
const { config, errors, suggestions } = context;
|
|
|
|
if (!config.range) {
|
|
errors.push({
|
|
type: 'missing_required',
|
|
property: 'range',
|
|
message: 'Range is required for read operation',
|
|
fix: 'Specify range like "Sheet1!A:B" or "Sheet1!A1:B10"'
|
|
});
|
|
}
|
|
|
|
// Suggest data structure options
|
|
if (!config.options?.dataStructure) {
|
|
suggestions.push('Consider setting options.dataStructure to "object" for easier data manipulation');
|
|
}
|
|
}
|
|
|
|
private static validateGoogleSheetsUpdate(context: NodeValidationContext): void {
|
|
const { config, errors } = context;
|
|
|
|
if (!config.range) {
|
|
errors.push({
|
|
type: 'missing_required',
|
|
property: 'range',
|
|
message: 'Range is required for update operation',
|
|
fix: 'Specify the exact range to update like "Sheet1!A1:B10"'
|
|
});
|
|
}
|
|
|
|
if (!config.values && !config.rawData) {
|
|
errors.push({
|
|
type: 'missing_required',
|
|
property: 'values',
|
|
message: 'Values are required for update operation',
|
|
fix: 'Provide the data to write to the spreadsheet'
|
|
});
|
|
}
|
|
}
|
|
|
|
private static validateGoogleSheetsDelete(context: NodeValidationContext): void {
|
|
const { config, errors, warnings } = context;
|
|
|
|
if (!config.toDelete) {
|
|
errors.push({
|
|
type: 'missing_required',
|
|
property: 'toDelete',
|
|
message: 'Specify what to delete (rows or columns)',
|
|
fix: 'Set toDelete to "rows" or "columns"'
|
|
});
|
|
}
|
|
|
|
if (config.toDelete === 'rows' && !config.startIndex && config.startIndex !== 0) {
|
|
errors.push({
|
|
type: 'missing_required',
|
|
property: 'startIndex',
|
|
message: 'Start index is required when deleting rows',
|
|
fix: 'Specify the starting row index (0-based)'
|
|
});
|
|
}
|
|
|
|
warnings.push({
|
|
type: 'security',
|
|
message: 'Deletion is permanent. Consider backing up data first',
|
|
suggestion: 'Read the data before deletion to create a backup'
|
|
});
|
|
}
|
|
|
|
private static validateGoogleSheetsRange(
|
|
range: string,
|
|
errors: ValidationError[],
|
|
warnings: ValidationWarning[]
|
|
): void {
|
|
// Check basic format
|
|
if (!range.includes('!')) {
|
|
warnings.push({
|
|
type: 'inefficient',
|
|
property: 'range',
|
|
message: 'Range should include sheet name for clarity',
|
|
suggestion: 'Format: "SheetName!A1:B10" or "SheetName!A:B"'
|
|
});
|
|
}
|
|
|
|
// Check for common mistakes
|
|
if (range.includes(' ') && !range.match(/^'[^']+'/)) {
|
|
errors.push({
|
|
type: 'invalid_value',
|
|
property: 'range',
|
|
message: 'Sheet names with spaces must be quoted',
|
|
fix: 'Use single quotes around sheet name: \'Sheet Name\'!A1:B10'
|
|
});
|
|
}
|
|
|
|
// Validate A1 notation
|
|
const a1Pattern = /^('[^']+'|[^!]+)!([A-Z]+\d*:?[A-Z]*\d*|[A-Z]+:[A-Z]+|\d+:\d+)$/i;
|
|
if (!a1Pattern.test(range)) {
|
|
warnings.push({
|
|
type: 'inefficient',
|
|
property: 'range',
|
|
message: 'Range may not be in valid A1 notation',
|
|
suggestion: 'Examples: "Sheet1!A1:B10", "Sheet1!A:B", "Sheet1!1:10"'
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate OpenAI node configuration
|
|
*/
|
|
static validateOpenAI(context: NodeValidationContext): void {
|
|
const { config, errors, warnings, suggestions, autofix } = context;
|
|
const { resource, operation } = config;
|
|
|
|
if (resource === 'chat' && operation === 'create') {
|
|
// Model validation
|
|
if (!config.model) {
|
|
errors.push({
|
|
type: 'missing_required',
|
|
property: 'model',
|
|
message: 'Model selection is required',
|
|
fix: 'Choose a model like "gpt-4", "gpt-3.5-turbo", etc.'
|
|
});
|
|
} else {
|
|
// Check for deprecated models
|
|
const deprecatedModels = ['text-davinci-003', 'text-davinci-002'];
|
|
if (deprecatedModels.includes(config.model)) {
|
|
warnings.push({
|
|
type: 'deprecated',
|
|
property: 'model',
|
|
message: `Model ${config.model} is deprecated`,
|
|
suggestion: 'Use "gpt-3.5-turbo" or "gpt-4" instead'
|
|
});
|
|
}
|
|
}
|
|
|
|
// Message validation
|
|
if (!config.messages && !config.prompt) {
|
|
errors.push({
|
|
type: 'missing_required',
|
|
property: 'messages',
|
|
message: 'Messages or prompt required for chat completion',
|
|
fix: 'Add messages array or use the prompt field'
|
|
});
|
|
}
|
|
|
|
// Token limit warnings
|
|
if (config.maxTokens && config.maxTokens > 4000) {
|
|
warnings.push({
|
|
type: 'inefficient',
|
|
property: 'maxTokens',
|
|
message: 'High token limit may increase costs significantly',
|
|
suggestion: 'Consider if you really need more than 4000 tokens'
|
|
});
|
|
}
|
|
|
|
// Temperature validation
|
|
if (config.temperature !== undefined) {
|
|
if (config.temperature < 0 || config.temperature > 2) {
|
|
errors.push({
|
|
type: 'invalid_value',
|
|
property: 'temperature',
|
|
message: 'Temperature must be between 0 and 2',
|
|
fix: 'Set temperature between 0 (deterministic) and 2 (creative)'
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Error handling for AI API calls
|
|
if (!config.onError && !config.retryOnFail && !config.continueOnFail) {
|
|
warnings.push({
|
|
type: 'best_practice',
|
|
property: 'errorHandling',
|
|
message: 'AI APIs have rate limits and can return errors',
|
|
suggestion: 'Add onError: "continueRegularOutput" with retryOnFail and longer wait times'
|
|
});
|
|
autofix.onError = 'continueRegularOutput';
|
|
autofix.retryOnFail = true;
|
|
autofix.maxTries = 3;
|
|
autofix.waitBetweenTries = 5000; // Longer wait for rate limits
|
|
autofix.alwaysOutputData = true;
|
|
}
|
|
|
|
// Check for deprecated continueOnFail
|
|
if (config.continueOnFail !== undefined) {
|
|
warnings.push({
|
|
type: 'deprecated',
|
|
property: 'continueOnFail',
|
|
message: 'continueOnFail is deprecated. Use onError instead',
|
|
suggestion: 'Replace with onError: "continueRegularOutput"'
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate MongoDB node configuration
|
|
*/
|
|
static validateMongoDB(context: NodeValidationContext): void {
|
|
const { config, errors, warnings, autofix } = context;
|
|
const { operation } = config;
|
|
|
|
// Collection is always required
|
|
if (!config.collection) {
|
|
errors.push({
|
|
type: 'missing_required',
|
|
property: 'collection',
|
|
message: 'Collection name is required',
|
|
fix: 'Specify the MongoDB collection to work with'
|
|
});
|
|
}
|
|
|
|
switch (operation) {
|
|
case 'find':
|
|
// Query validation
|
|
if (config.query) {
|
|
try {
|
|
JSON.parse(config.query);
|
|
} catch (e) {
|
|
errors.push({
|
|
type: 'invalid_value',
|
|
property: 'query',
|
|
message: 'Query must be valid JSON',
|
|
fix: 'Ensure query is valid JSON like: {"name": "John"}'
|
|
});
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'insert':
|
|
if (!config.fields && !config.documents) {
|
|
errors.push({
|
|
type: 'missing_required',
|
|
property: 'fields',
|
|
message: 'Document data is required for insert',
|
|
fix: 'Provide the data to insert'
|
|
});
|
|
}
|
|
break;
|
|
|
|
case 'update':
|
|
if (!config.query) {
|
|
warnings.push({
|
|
type: 'security',
|
|
message: 'Update without query will affect all documents',
|
|
suggestion: 'Add a query to target specific documents'
|
|
});
|
|
}
|
|
break;
|
|
|
|
case 'delete':
|
|
if (!config.query || config.query === '{}') {
|
|
errors.push({
|
|
type: 'invalid_value',
|
|
property: 'query',
|
|
message: 'Delete without query would remove all documents - this is a critical security issue',
|
|
fix: 'Add a query to specify which documents to delete'
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
|
|
// Error handling for MongoDB operations
|
|
if (!config.onError && !config.retryOnFail && !config.continueOnFail) {
|
|
if (operation === 'find') {
|
|
warnings.push({
|
|
type: 'best_practice',
|
|
property: 'errorHandling',
|
|
message: 'MongoDB queries can fail due to connection issues',
|
|
suggestion: 'Add onError: "continueRegularOutput" with retryOnFail'
|
|
});
|
|
autofix.onError = 'continueRegularOutput';
|
|
autofix.retryOnFail = true;
|
|
autofix.maxTries = 3;
|
|
} else if (['insert', 'update', 'delete'].includes(operation)) {
|
|
warnings.push({
|
|
type: 'best_practice',
|
|
property: 'errorHandling',
|
|
message: 'MongoDB write operations should handle errors carefully',
|
|
suggestion: 'Add onError: "continueErrorOutput" to handle write failures separately'
|
|
});
|
|
autofix.onError = 'continueErrorOutput';
|
|
autofix.retryOnFail = true;
|
|
autofix.maxTries = 2;
|
|
autofix.waitBetweenTries = 1000;
|
|
}
|
|
}
|
|
|
|
// Check for deprecated continueOnFail
|
|
if (config.continueOnFail !== undefined) {
|
|
warnings.push({
|
|
type: 'deprecated',
|
|
property: 'continueOnFail',
|
|
message: 'continueOnFail is deprecated. Use onError instead',
|
|
suggestion: 'Replace with onError: "continueRegularOutput" or "continueErrorOutput"'
|
|
});
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Validate Postgres node configuration
|
|
*/
|
|
static validatePostgres(context: NodeValidationContext): void {
|
|
const { config, errors, warnings, suggestions, autofix } = context;
|
|
const { operation } = config;
|
|
|
|
// Common query validation
|
|
if (['execute', 'select', 'insert', 'update', 'delete'].includes(operation)) {
|
|
this.validateSQLQuery(context, 'postgres');
|
|
}
|
|
|
|
// Operation-specific validation
|
|
switch (operation) {
|
|
case 'insert':
|
|
if (!config.table) {
|
|
errors.push({
|
|
type: 'missing_required',
|
|
property: 'table',
|
|
message: 'Table name is required for insert operation',
|
|
fix: 'Specify the table to insert data into'
|
|
});
|
|
}
|
|
|
|
if (!config.columns && !config.dataMode) {
|
|
warnings.push({
|
|
type: 'missing_common',
|
|
property: 'columns',
|
|
message: 'No columns specified for insert',
|
|
suggestion: 'Define which columns to insert data into'
|
|
});
|
|
}
|
|
break;
|
|
|
|
case 'update':
|
|
if (!config.table) {
|
|
errors.push({
|
|
type: 'missing_required',
|
|
property: 'table',
|
|
message: 'Table name is required for update operation',
|
|
fix: 'Specify the table to update'
|
|
});
|
|
}
|
|
|
|
if (!config.updateKey) {
|
|
warnings.push({
|
|
type: 'missing_common',
|
|
property: 'updateKey',
|
|
message: 'No update key specified',
|
|
suggestion: 'Set updateKey to identify which rows to update (e.g., "id")'
|
|
});
|
|
}
|
|
break;
|
|
|
|
case 'delete':
|
|
if (!config.table) {
|
|
errors.push({
|
|
type: 'missing_required',
|
|
property: 'table',
|
|
message: 'Table name is required for delete operation',
|
|
fix: 'Specify the table to delete from'
|
|
});
|
|
}
|
|
|
|
if (!config.deleteKey) {
|
|
errors.push({
|
|
type: 'missing_required',
|
|
property: 'deleteKey',
|
|
message: 'Delete key is required to identify rows',
|
|
fix: 'Set deleteKey (e.g., "id") to specify which rows to delete'
|
|
});
|
|
}
|
|
break;
|
|
|
|
case 'execute':
|
|
if (!config.query) {
|
|
errors.push({
|
|
type: 'missing_required',
|
|
property: 'query',
|
|
message: 'SQL query is required',
|
|
fix: 'Provide the SQL query to execute'
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
|
|
// Connection pool suggestions
|
|
if (config.connectionTimeout === undefined) {
|
|
suggestions.push('Consider setting connectionTimeout to handle slow connections');
|
|
}
|
|
|
|
// Error handling for database operations
|
|
if (!config.onError && !config.retryOnFail && !config.continueOnFail) {
|
|
if (operation === 'execute' && config.query?.toLowerCase().includes('select')) {
|
|
warnings.push({
|
|
type: 'best_practice',
|
|
property: 'errorHandling',
|
|
message: 'Database reads can fail due to connection issues',
|
|
suggestion: 'Add onError: "continueRegularOutput" and retryOnFail: true'
|
|
});
|
|
autofix.onError = 'continueRegularOutput';
|
|
autofix.retryOnFail = true;
|
|
autofix.maxTries = 3;
|
|
} else if (['insert', 'update', 'delete'].includes(operation)) {
|
|
warnings.push({
|
|
type: 'best_practice',
|
|
property: 'errorHandling',
|
|
message: 'Database writes should handle errors carefully',
|
|
suggestion: 'Add onError: "stopWorkflow" with retryOnFail for transient failures'
|
|
});
|
|
autofix.onError = 'stopWorkflow';
|
|
autofix.retryOnFail = true;
|
|
autofix.maxTries = 2;
|
|
autofix.waitBetweenTries = 2000;
|
|
}
|
|
}
|
|
|
|
// Check for deprecated continueOnFail
|
|
if (config.continueOnFail !== undefined) {
|
|
warnings.push({
|
|
type: 'deprecated',
|
|
property: 'continueOnFail',
|
|
message: 'continueOnFail is deprecated. Use onError instead',
|
|
suggestion: 'Replace with onError: "continueRegularOutput" or "stopWorkflow"'
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate MySQL node configuration
|
|
*/
|
|
static validateMySQL(context: NodeValidationContext): void {
|
|
const { config, errors, warnings, suggestions } = context;
|
|
const { operation } = config;
|
|
|
|
// MySQL uses similar validation to Postgres
|
|
if (['execute', 'insert', 'update', 'delete'].includes(operation)) {
|
|
this.validateSQLQuery(context, 'mysql');
|
|
}
|
|
|
|
// Operation-specific validation (similar to Postgres)
|
|
switch (operation) {
|
|
case 'insert':
|
|
if (!config.table) {
|
|
errors.push({
|
|
type: 'missing_required',
|
|
property: 'table',
|
|
message: 'Table name is required for insert operation',
|
|
fix: 'Specify the table to insert data into'
|
|
});
|
|
}
|
|
break;
|
|
|
|
case 'update':
|
|
if (!config.table) {
|
|
errors.push({
|
|
type: 'missing_required',
|
|
property: 'table',
|
|
message: 'Table name is required for update operation',
|
|
fix: 'Specify the table to update'
|
|
});
|
|
}
|
|
|
|
if (!config.updateKey) {
|
|
warnings.push({
|
|
type: 'missing_common',
|
|
property: 'updateKey',
|
|
message: 'No update key specified',
|
|
suggestion: 'Set updateKey to identify which rows to update'
|
|
});
|
|
}
|
|
break;
|
|
|
|
case 'delete':
|
|
if (!config.table) {
|
|
errors.push({
|
|
type: 'missing_required',
|
|
property: 'table',
|
|
message: 'Table name is required for delete operation',
|
|
fix: 'Specify the table to delete from'
|
|
});
|
|
}
|
|
break;
|
|
|
|
case 'execute':
|
|
if (!config.query) {
|
|
errors.push({
|
|
type: 'missing_required',
|
|
property: 'query',
|
|
message: 'SQL query is required',
|
|
fix: 'Provide the SQL query to execute'
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
|
|
// MySQL-specific warnings
|
|
if (config.timezone === undefined) {
|
|
suggestions.push('Consider setting timezone to ensure consistent date/time handling');
|
|
}
|
|
|
|
// Error handling for MySQL operations (similar to Postgres)
|
|
if (!config.onError && !config.retryOnFail && !config.continueOnFail) {
|
|
if (operation === 'execute' && config.query?.toLowerCase().includes('select')) {
|
|
warnings.push({
|
|
type: 'best_practice',
|
|
property: 'errorHandling',
|
|
message: 'Database queries can fail due to connection issues',
|
|
suggestion: 'Add onError: "continueRegularOutput" and retryOnFail: true'
|
|
});
|
|
} else if (['insert', 'update', 'delete'].includes(operation)) {
|
|
warnings.push({
|
|
type: 'best_practice',
|
|
property: 'errorHandling',
|
|
message: 'Database modifications should handle errors carefully',
|
|
suggestion: 'Add onError: "stopWorkflow" with retryOnFail for transient failures'
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate SQL queries for injection risks and common issues
|
|
*/
|
|
private static validateSQLQuery(
|
|
context: NodeValidationContext,
|
|
dbType: 'postgres' | 'mysql' | 'generic' = 'generic'
|
|
): void {
|
|
const { config, errors, warnings, suggestions } = context;
|
|
const query = config.query || config.deleteQuery || config.updateQuery || '';
|
|
|
|
if (!query) return;
|
|
|
|
const lowerQuery = query.toLowerCase();
|
|
|
|
// SQL injection checks
|
|
if (query.includes('${') || query.includes('{{')) {
|
|
warnings.push({
|
|
type: 'security',
|
|
message: 'Query contains template expressions that might be vulnerable to SQL injection',
|
|
suggestion: 'Use parameterized queries with query parameters instead of string interpolation'
|
|
});
|
|
|
|
suggestions.push('Example: Use "SELECT * FROM users WHERE id = $1" with queryParams: [userId]');
|
|
}
|
|
|
|
// DELETE without WHERE
|
|
if (lowerQuery.includes('delete') && !lowerQuery.includes('where')) {
|
|
errors.push({
|
|
type: 'invalid_value',
|
|
property: 'query',
|
|
message: 'DELETE query without WHERE clause will delete all records',
|
|
fix: 'Add a WHERE clause to specify which records to delete'
|
|
});
|
|
}
|
|
|
|
// UPDATE without WHERE
|
|
if (lowerQuery.includes('update') && !lowerQuery.includes('where')) {
|
|
warnings.push({
|
|
type: 'security',
|
|
message: 'UPDATE query without WHERE clause will update all records',
|
|
suggestion: 'Add a WHERE clause to specify which records to update'
|
|
});
|
|
}
|
|
|
|
// TRUNCATE warning
|
|
if (lowerQuery.includes('truncate')) {
|
|
warnings.push({
|
|
type: 'security',
|
|
message: 'TRUNCATE will remove all data from the table',
|
|
suggestion: 'Consider using DELETE with WHERE clause if you need to keep some data'
|
|
});
|
|
}
|
|
|
|
// DROP warning
|
|
if (lowerQuery.includes('drop')) {
|
|
errors.push({
|
|
type: 'invalid_value',
|
|
property: 'query',
|
|
message: 'DROP operations are extremely dangerous and will permanently delete database objects',
|
|
fix: 'Use this only if you really intend to delete tables/databases permanently'
|
|
});
|
|
}
|
|
|
|
// Performance suggestions
|
|
if (lowerQuery.includes('select *')) {
|
|
suggestions.push('Consider selecting specific columns instead of * for better performance');
|
|
}
|
|
|
|
// Database-specific checks
|
|
if (dbType === 'postgres') {
|
|
// PostgreSQL specific validations
|
|
if (query.includes('$$')) {
|
|
suggestions.push('Dollar-quoted strings detected - ensure they are properly closed');
|
|
}
|
|
} else if (dbType === 'mysql') {
|
|
// MySQL specific validations
|
|
if (query.includes('`')) {
|
|
suggestions.push('Using backticks for identifiers - ensure they are properly paired');
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate HTTP Request node configuration with error handling awareness
|
|
*/
|
|
static validateHttpRequest(context: NodeValidationContext): void {
|
|
const { config, errors, warnings, suggestions, autofix } = context;
|
|
const { method = 'GET', url, sendBody, authentication } = config;
|
|
|
|
// Basic URL validation
|
|
if (!url) {
|
|
errors.push({
|
|
type: 'missing_required',
|
|
property: 'url',
|
|
message: 'URL is required for HTTP requests',
|
|
fix: 'Provide the full URL including protocol (https://...)'
|
|
});
|
|
} else if (!url.startsWith('http://') && !url.startsWith('https://') && !url.includes('{{')) {
|
|
warnings.push({
|
|
type: 'invalid_value',
|
|
property: 'url',
|
|
message: 'URL should start with http:// or https://',
|
|
suggestion: 'Use https:// for secure connections'
|
|
});
|
|
}
|
|
|
|
// Method-specific validation
|
|
if (['POST', 'PUT', 'PATCH'].includes(method) && !sendBody) {
|
|
warnings.push({
|
|
type: 'missing_common',
|
|
property: 'sendBody',
|
|
message: `${method} requests typically include a body`,
|
|
suggestion: 'Set sendBody: true and configure the body content'
|
|
});
|
|
}
|
|
|
|
// Error handling recommendations
|
|
if (!config.retryOnFail && !config.onError && !config.continueOnFail) {
|
|
warnings.push({
|
|
type: 'best_practice',
|
|
property: 'errorHandling',
|
|
message: 'HTTP requests can fail due to network issues or server errors',
|
|
suggestion: 'Add onError: "continueRegularOutput" and retryOnFail: true for resilience'
|
|
});
|
|
|
|
// Auto-fix suggestion for error handling
|
|
autofix.onError = 'continueRegularOutput';
|
|
autofix.retryOnFail = true;
|
|
autofix.maxTries = 3;
|
|
autofix.waitBetweenTries = 1000;
|
|
}
|
|
|
|
// Check for deprecated continueOnFail
|
|
if (config.continueOnFail !== undefined) {
|
|
warnings.push({
|
|
type: 'deprecated',
|
|
property: 'continueOnFail',
|
|
message: 'continueOnFail is deprecated. Use onError instead',
|
|
suggestion: 'Replace with onError: "continueRegularOutput"'
|
|
});
|
|
autofix.onError = config.continueOnFail ? 'continueRegularOutput' : 'stopWorkflow';
|
|
delete autofix.continueOnFail;
|
|
}
|
|
|
|
// Check retry configuration
|
|
if (config.retryOnFail) {
|
|
// Validate retry settings
|
|
if (!['GET', 'HEAD', 'OPTIONS'].includes(method) && (!config.maxTries || config.maxTries > 3)) {
|
|
warnings.push({
|
|
type: 'best_practice',
|
|
property: 'maxTries',
|
|
message: `${method} requests might not be idempotent. Use fewer retries.`,
|
|
suggestion: 'Set maxTries: 2 for non-idempotent operations'
|
|
});
|
|
}
|
|
|
|
// Suggest alwaysOutputData for debugging
|
|
if (!config.alwaysOutputData) {
|
|
suggestions.push('Enable alwaysOutputData to capture error responses for debugging');
|
|
autofix.alwaysOutputData = true;
|
|
}
|
|
}
|
|
|
|
// Authentication warnings
|
|
if (url && url.includes('api') && !authentication) {
|
|
warnings.push({
|
|
type: 'security',
|
|
property: 'authentication',
|
|
message: 'API endpoints typically require authentication',
|
|
suggestion: 'Configure authentication method (Bearer token, API key, etc.)'
|
|
});
|
|
}
|
|
|
|
// Timeout recommendations
|
|
if (!config.timeout) {
|
|
suggestions.push('Consider setting a timeout to prevent hanging requests');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate Webhook node configuration with error handling
|
|
*/
|
|
static validateWebhook(context: NodeValidationContext): void {
|
|
const { config, errors, warnings, suggestions, autofix } = context;
|
|
const { path, httpMethod = 'POST', responseMode } = config;
|
|
|
|
// Path validation
|
|
if (!path) {
|
|
errors.push({
|
|
type: 'missing_required',
|
|
property: 'path',
|
|
message: 'Webhook path is required',
|
|
fix: 'Provide a unique path like "my-webhook" or "github-events"'
|
|
});
|
|
} else if (path.startsWith('/')) {
|
|
warnings.push({
|
|
type: 'invalid_value',
|
|
property: 'path',
|
|
message: 'Webhook path should not start with /',
|
|
suggestion: 'Use "webhook-name" instead of "/webhook-name"'
|
|
});
|
|
}
|
|
|
|
// Error handling for webhooks
|
|
if (!config.onError && !config.continueOnFail) {
|
|
warnings.push({
|
|
type: 'best_practice',
|
|
property: 'onError',
|
|
message: 'Webhooks should always send a response, even on error',
|
|
suggestion: 'Set onError: "continueRegularOutput" to ensure webhook responses'
|
|
});
|
|
autofix.onError = 'continueRegularOutput';
|
|
}
|
|
|
|
// Check for deprecated continueOnFail in webhooks
|
|
if (config.continueOnFail !== undefined) {
|
|
warnings.push({
|
|
type: 'deprecated',
|
|
property: 'continueOnFail',
|
|
message: 'continueOnFail is deprecated. Use onError instead',
|
|
suggestion: 'Replace with onError: "continueRegularOutput"'
|
|
});
|
|
autofix.onError = 'continueRegularOutput';
|
|
delete autofix.continueOnFail;
|
|
}
|
|
|
|
// Response mode validation
|
|
if (responseMode === 'responseNode' && !config.onError && !config.continueOnFail) {
|
|
errors.push({
|
|
type: 'invalid_configuration',
|
|
property: 'responseMode',
|
|
message: 'responseNode mode requires onError: "continueRegularOutput"',
|
|
fix: 'Set onError to ensure response is always sent'
|
|
});
|
|
}
|
|
|
|
// Always output data for debugging
|
|
if (!config.alwaysOutputData) {
|
|
suggestions.push('Enable alwaysOutputData to debug webhook payloads');
|
|
autofix.alwaysOutputData = true;
|
|
}
|
|
|
|
// Security suggestions
|
|
suggestions.push('Consider adding webhook validation (HMAC signature verification)');
|
|
suggestions.push('Implement rate limiting for public webhooks');
|
|
}
|
|
|
|
/**
|
|
* Validate Code node configuration with n8n-specific patterns
|
|
*/
|
|
static validateCode(context: NodeValidationContext): void {
|
|
const { config, errors, warnings, suggestions, autofix } = context;
|
|
const language = config.language || 'javaScript';
|
|
const codeField = language === 'python' ? 'pythonCode' : 'jsCode';
|
|
const code = config[codeField] || '';
|
|
|
|
// Check for empty code
|
|
if (!code || code.trim() === '') {
|
|
errors.push({
|
|
type: 'missing_required',
|
|
property: codeField,
|
|
message: 'Code cannot be empty',
|
|
fix: 'Add your code logic. Start with: return [{json: {result: "success"}}]'
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Language-specific validation
|
|
if (language === 'javaScript') {
|
|
this.validateJavaScriptCode(code, errors, warnings, suggestions);
|
|
} else if (language === 'python') {
|
|
this.validatePythonCode(code, errors, warnings, suggestions);
|
|
}
|
|
|
|
// Check return statement and format
|
|
this.validateReturnStatement(code, language, errors, warnings, suggestions);
|
|
|
|
// Check n8n variable usage
|
|
this.validateN8nVariables(code, language, warnings, suggestions, errors);
|
|
|
|
// Security and best practices
|
|
this.validateCodeSecurity(code, language, warnings);
|
|
|
|
// Error handling recommendations
|
|
if (!config.onError && code.length > 100) {
|
|
warnings.push({
|
|
type: 'best_practice',
|
|
property: 'errorHandling',
|
|
message: 'Code nodes can throw errors - consider error handling',
|
|
suggestion: 'Add onError: "continueRegularOutput" to handle errors gracefully'
|
|
});
|
|
autofix.onError = 'continueRegularOutput';
|
|
}
|
|
|
|
// Mode-specific suggestions
|
|
if (config.mode === 'runOnceForEachItem' && code.includes('items')) {
|
|
warnings.push({
|
|
type: 'best_practice',
|
|
message: 'In "Run Once for Each Item" mode, use $json instead of items array',
|
|
suggestion: 'Access current item data with $json.fieldName'
|
|
});
|
|
}
|
|
|
|
if (!config.mode && code.includes('$json')) {
|
|
warnings.push({
|
|
type: 'best_practice',
|
|
message: '$json only works in "Run Once for Each Item" mode',
|
|
suggestion: 'Either set mode: "runOnceForEachItem" or use items[0].json'
|
|
});
|
|
}
|
|
}
|
|
|
|
private static validateJavaScriptCode(
|
|
code: string,
|
|
errors: ValidationError[],
|
|
warnings: ValidationWarning[],
|
|
suggestions: string[]
|
|
): void {
|
|
// Check for syntax patterns that might fail
|
|
const syntaxPatterns = [
|
|
{ pattern: /const\s+const/, message: 'Duplicate const declaration' },
|
|
{ pattern: /let\s+let/, message: 'Duplicate let declaration' },
|
|
// Removed overly simplistic parenthesis check - it was causing false positives
|
|
// for valid patterns like $('NodeName').first().json or func()()
|
|
// { pattern: /\)\s*\)\s*{/, message: 'Extra closing parenthesis before {' },
|
|
// Only check for multiple closing braces at the very end (more likely to be an error)
|
|
{ pattern: /}\s*}\s*}\s*}$/, message: 'Multiple closing braces at end - check your nesting' }
|
|
];
|
|
|
|
syntaxPatterns.forEach(({ pattern, message }) => {
|
|
if (pattern.test(code)) {
|
|
errors.push({
|
|
type: 'invalid_value',
|
|
property: 'jsCode',
|
|
message: `Syntax error: ${message}`,
|
|
fix: 'Check your JavaScript syntax'
|
|
});
|
|
}
|
|
});
|
|
|
|
// Common async/await issues
|
|
// Check for await inside a non-async function (but top-level await is fine)
|
|
const functionWithAwait = /function\s+\w*\s*\([^)]*\)\s*{[^}]*await/;
|
|
const arrowWithAwait = /\([^)]*\)\s*=>\s*{[^}]*await/;
|
|
|
|
if ((functionWithAwait.test(code) || arrowWithAwait.test(code)) && !code.includes('async')) {
|
|
warnings.push({
|
|
type: 'best_practice',
|
|
message: 'Using await inside a non-async function',
|
|
suggestion: 'Add async keyword to the function, or use top-level await (Code nodes support it)'
|
|
});
|
|
}
|
|
|
|
// Check for common helper usage
|
|
if (code.includes('$helpers.httpRequest')) {
|
|
suggestions.push('$helpers.httpRequest is async - use: const response = await $helpers.httpRequest(...)');
|
|
}
|
|
|
|
if (code.includes('DateTime') && !code.includes('DateTime.')) {
|
|
warnings.push({
|
|
type: 'best_practice',
|
|
message: 'DateTime is from Luxon library',
|
|
suggestion: 'Use DateTime.now() or DateTime.fromISO() for date operations'
|
|
});
|
|
}
|
|
}
|
|
|
|
private static validatePythonCode(
|
|
code: string,
|
|
errors: ValidationError[],
|
|
warnings: ValidationWarning[],
|
|
suggestions: string[]
|
|
): void {
|
|
// Python-specific validation
|
|
const lines = code.split('\n');
|
|
|
|
// Check for tab/space mixing (already done in base validator)
|
|
|
|
// Check for common Python mistakes in n8n context
|
|
if (code.includes('__name__') && code.includes('__main__')) {
|
|
warnings.push({
|
|
type: 'inefficient',
|
|
message: 'if __name__ == "__main__" is not needed in Code nodes',
|
|
suggestion: 'Code node Python runs directly - remove the main check'
|
|
});
|
|
}
|
|
|
|
// Check for unavailable imports
|
|
const unavailableImports = [
|
|
{ module: 'requests', suggestion: 'Use JavaScript Code node with $helpers.httpRequest for HTTP requests' },
|
|
{ module: 'pandas', suggestion: 'Use built-in list/dict operations or JavaScript for data manipulation' },
|
|
{ module: 'numpy', suggestion: 'Use standard Python math operations' },
|
|
{ module: 'pip', suggestion: 'External packages cannot be installed in Code nodes' }
|
|
];
|
|
|
|
unavailableImports.forEach(({ module, suggestion }) => {
|
|
if (code.includes(`import ${module}`) || code.includes(`from ${module}`)) {
|
|
errors.push({
|
|
type: 'invalid_value',
|
|
property: 'pythonCode',
|
|
message: `Module '${module}' is not available in Code nodes`,
|
|
fix: suggestion
|
|
});
|
|
}
|
|
});
|
|
|
|
// Check indentation after colons
|
|
lines.forEach((line, i) => {
|
|
if (line.trim().endsWith(':') && i < lines.length - 1) {
|
|
const nextLine = lines[i + 1];
|
|
if (nextLine.trim() && !nextLine.startsWith(' ') && !nextLine.startsWith('\t')) {
|
|
errors.push({
|
|
type: 'invalid_value',
|
|
property: 'pythonCode',
|
|
message: `Missing indentation after line ${i + 1}`,
|
|
fix: 'Indent the line after the colon'
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
private static validateReturnStatement(
|
|
code: string,
|
|
language: string,
|
|
errors: ValidationError[],
|
|
warnings: ValidationWarning[],
|
|
suggestions: string[]
|
|
): void {
|
|
const hasReturn = /return\s+/.test(code);
|
|
|
|
if (!hasReturn) {
|
|
errors.push({
|
|
type: 'missing_required',
|
|
property: language === 'python' ? 'pythonCode' : 'jsCode',
|
|
message: 'Code must return data for the next node',
|
|
fix: language === 'python'
|
|
? 'Add: return [{"json": {"result": "success"}}]'
|
|
: 'Add: return [{json: {result: "success"}}]'
|
|
});
|
|
return;
|
|
}
|
|
|
|
// JavaScript return format validation
|
|
if (language === 'javaScript') {
|
|
// Check for object return without array
|
|
if (/return\s+{(?!.*\[).*}\s*;?$/s.test(code) && !code.includes('json:')) {
|
|
errors.push({
|
|
type: 'invalid_value',
|
|
property: 'jsCode',
|
|
message: 'Return value must be an array of objects',
|
|
fix: 'Wrap in array: return [{json: yourObject}]'
|
|
});
|
|
}
|
|
|
|
// Check for primitive return
|
|
if (/return\s+(true|false|null|undefined|\d+|['"`])/m.test(code)) {
|
|
errors.push({
|
|
type: 'invalid_value',
|
|
property: 'jsCode',
|
|
message: 'Cannot return primitive values directly',
|
|
fix: 'Return array of objects: return [{json: {value: yourData}}]'
|
|
});
|
|
}
|
|
|
|
// Check for array of non-objects
|
|
if (/return\s+\[[\s\n]*['"`\d]/.test(code)) {
|
|
errors.push({
|
|
type: 'invalid_value',
|
|
property: 'jsCode',
|
|
message: 'Array items must be objects with json property',
|
|
fix: 'Use: return [{json: {value: "data"}}] not return ["data"]'
|
|
});
|
|
}
|
|
|
|
// Suggest proper return format for items
|
|
if (/return\s+items\s*;?$/.test(code) && !code.includes('map')) {
|
|
suggestions.push(
|
|
'Returning items directly is fine if they already have {json: ...} structure. ' +
|
|
'To modify: return items.map(item => ({json: {...item.json, newField: "value"}}))'
|
|
);
|
|
}
|
|
}
|
|
|
|
// Python return format validation
|
|
if (language === 'python') {
|
|
// Check for dict return without list
|
|
if (/return\s+{(?!.*\[).*}$/s.test(code)) {
|
|
errors.push({
|
|
type: 'invalid_value',
|
|
property: 'pythonCode',
|
|
message: 'Return value must be a list of dicts',
|
|
fix: 'Wrap in list: return [{"json": your_dict}]'
|
|
});
|
|
}
|
|
|
|
// Check for primitive return
|
|
if (/return\s+(True|False|None|\d+|['"`])/m.test(code)) {
|
|
errors.push({
|
|
type: 'invalid_value',
|
|
property: 'pythonCode',
|
|
message: 'Cannot return primitive values directly',
|
|
fix: 'Return list of dicts: return [{"json": {"value": your_data}}]'
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
private static validateN8nVariables(
|
|
code: string,
|
|
language: string,
|
|
warnings: ValidationWarning[],
|
|
suggestions: string[],
|
|
errors: ValidationError[]
|
|
): void {
|
|
// Check if code accesses input data
|
|
const inputPatterns = language === 'javaScript'
|
|
? ['items', '$input', '$json', '$node', '$prevNode']
|
|
: ['items', '_input'];
|
|
|
|
const usesInput = inputPatterns.some(pattern => code.includes(pattern));
|
|
|
|
if (!usesInput && code.length > 50) {
|
|
warnings.push({
|
|
type: 'missing_common',
|
|
message: 'Code doesn\'t reference input data',
|
|
suggestion: language === 'javaScript'
|
|
? 'Access input with: items, $input.all(), or $json (single-item mode)'
|
|
: 'Access input with: items variable'
|
|
});
|
|
}
|
|
|
|
// Check for expression syntax in Code nodes
|
|
if (code.includes('{{') && code.includes('}}')) {
|
|
errors.push({
|
|
type: 'invalid_value',
|
|
property: language === 'python' ? 'pythonCode' : 'jsCode',
|
|
message: 'Expression syntax {{...}} is not valid in Code nodes',
|
|
fix: 'Use regular JavaScript/Python syntax without double curly braces'
|
|
});
|
|
}
|
|
|
|
// Check for wrong $node syntax
|
|
if (code.includes('$node[')) {
|
|
warnings.push({
|
|
type: 'invalid_value',
|
|
property: language === 'python' ? 'pythonCode' : 'jsCode',
|
|
message: 'Use $(\'Node Name\') instead of $node[\'Node Name\'] in Code nodes',
|
|
suggestion: 'Replace $node[\'NodeName\'] with $(\'NodeName\')'
|
|
});
|
|
}
|
|
|
|
// Check for expression-only functions
|
|
const expressionOnlyFunctions = ['$now()', '$today()', '$tomorrow()', '.unique()', '.pluck(', '.keys()', '.hash('];
|
|
expressionOnlyFunctions.forEach(func => {
|
|
if (code.includes(func)) {
|
|
warnings.push({
|
|
type: 'invalid_value',
|
|
property: language === 'python' ? 'pythonCode' : 'jsCode',
|
|
message: `${func} is an expression-only function not available in Code nodes`,
|
|
suggestion: 'See Code node documentation for alternatives'
|
|
});
|
|
}
|
|
});
|
|
|
|
// Check for common variable mistakes
|
|
if (language === 'javaScript') {
|
|
// Using $ without proper variable
|
|
if (/\$(?![a-zA-Z])/.test(code) && !code.includes('${')) {
|
|
warnings.push({
|
|
type: 'best_practice',
|
|
message: 'Invalid $ usage detected',
|
|
suggestion: 'n8n variables start with $: $json, $input, $node, $workflow, $execution'
|
|
});
|
|
}
|
|
|
|
// Check for helpers usage
|
|
if (code.includes('helpers.') && !code.includes('$helpers')) {
|
|
warnings.push({
|
|
type: 'invalid_value',
|
|
property: 'jsCode',
|
|
message: 'Use $helpers not helpers',
|
|
suggestion: 'Change helpers. to $helpers.'
|
|
});
|
|
}
|
|
|
|
// Check for $helpers usage without availability check
|
|
if (code.includes('$helpers') && !code.includes('typeof $helpers')) {
|
|
warnings.push({
|
|
type: 'best_practice',
|
|
message: '$helpers availability varies by n8n version',
|
|
suggestion: 'Check availability first: if (typeof $helpers !== "undefined" && $helpers.httpRequest) { ... }'
|
|
});
|
|
}
|
|
|
|
// Suggest available helpers
|
|
if (code.includes('$helpers')) {
|
|
suggestions.push(
|
|
'Common $helpers methods: httpRequest(), prepareBinaryData(). Note: getWorkflowStaticData is a standalone function - use $getWorkflowStaticData() instead'
|
|
);
|
|
}
|
|
|
|
// Check for incorrect getWorkflowStaticData usage
|
|
if (code.includes('$helpers.getWorkflowStaticData')) {
|
|
errors.push({
|
|
type: 'invalid_value',
|
|
property: 'jsCode',
|
|
message: '$helpers.getWorkflowStaticData() will cause "$helpers is not defined" error',
|
|
fix: 'Use $getWorkflowStaticData("global") or $getWorkflowStaticData("node") directly'
|
|
});
|
|
}
|
|
|
|
// Check for wrong JMESPath parameter order
|
|
if (code.includes('$jmespath(') && /\$jmespath\s*\(\s*['"`]/.test(code)) {
|
|
warnings.push({
|
|
type: 'invalid_value',
|
|
property: 'jsCode',
|
|
message: 'Code node $jmespath has reversed parameter order: $jmespath(data, query)',
|
|
suggestion: 'Use: $jmespath(dataObject, "query.path") not $jmespath("query.path", dataObject)'
|
|
});
|
|
}
|
|
|
|
// Check for webhook data access patterns
|
|
if (code.includes('items[0].json') && !code.includes('.json.body')) {
|
|
// Check if previous node reference suggests webhook
|
|
if (code.includes('Webhook') || code.includes('webhook') ||
|
|
code.includes('$("Webhook")') || code.includes("$('Webhook')")) {
|
|
warnings.push({
|
|
type: 'invalid_value',
|
|
property: 'jsCode',
|
|
message: 'Webhook data is nested under .body property',
|
|
suggestion: 'Use items[0].json.body.fieldName instead of items[0].json.fieldName for webhook data'
|
|
});
|
|
}
|
|
// Also check for common webhook field names that suggest webhook data
|
|
else if (/items\[0\]\.json\.(payload|data|command|action|event|message)\b/.test(code)) {
|
|
warnings.push({
|
|
type: 'best_practice',
|
|
message: 'If processing webhook data, remember it\'s nested under .body',
|
|
suggestion: 'Webhook payloads are at items[0].json.body, not items[0].json'
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check for JMESPath filters with unquoted numeric literals (both JS and Python)
|
|
const jmespathFunction = language === 'javaScript' ? '$jmespath' : '_jmespath';
|
|
if (code.includes(jmespathFunction + '(')) {
|
|
// Look for filter expressions with comparison operators and numbers
|
|
const filterPattern = /\[?\?[^[\]]*(?:>=?|<=?|==|!=)\s*(\d+(?:\.\d+)?)\s*\]/g;
|
|
let match;
|
|
|
|
while ((match = filterPattern.exec(code)) !== null) {
|
|
const number = match[1];
|
|
// Check if the number is NOT wrapped in backticks
|
|
const beforeNumber = code.substring(match.index, match.index + match[0].indexOf(number));
|
|
const afterNumber = code.substring(match.index + match[0].indexOf(number) + number.length);
|
|
|
|
if (!beforeNumber.includes('`') || !afterNumber.startsWith('`')) {
|
|
errors.push({
|
|
type: 'invalid_value',
|
|
property: language === 'python' ? 'pythonCode' : 'jsCode',
|
|
message: `JMESPath numeric literal ${number} must be wrapped in backticks`,
|
|
fix: `Change [?field >= ${number}] to [?field >= \`${number}\`]`
|
|
});
|
|
}
|
|
}
|
|
|
|
// Also provide a general suggestion if JMESPath is used
|
|
suggestions.push(
|
|
'JMESPath in n8n requires backticks around numeric literals in filters: [?age >= `18`]'
|
|
);
|
|
}
|
|
}
|
|
|
|
private static validateCodeSecurity(
|
|
code: string,
|
|
language: string,
|
|
warnings: ValidationWarning[]
|
|
): void {
|
|
// Security checks
|
|
const dangerousPatterns = [
|
|
{ pattern: /eval\s*\(/, message: 'Avoid eval() - it\'s a security risk' },
|
|
{ pattern: /Function\s*\(/, message: 'Avoid Function constructor - use regular functions' },
|
|
{ pattern: language === 'python' ? /exec\s*\(/ : /exec\s*\(/, message: 'Avoid exec() - it\'s a security risk' },
|
|
{ pattern: /process\.env/, message: 'Limited environment access in Code nodes' },
|
|
{ pattern: /import\s+\*/, message: 'Avoid import * - be specific about imports' }
|
|
];
|
|
|
|
dangerousPatterns.forEach(({ pattern, message }) => {
|
|
if (pattern.test(code)) {
|
|
warnings.push({
|
|
type: 'security',
|
|
message,
|
|
suggestion: 'Use safer alternatives or built-in functions'
|
|
});
|
|
}
|
|
});
|
|
|
|
// Special handling for require() - it's allowed for built-in modules
|
|
if (code.includes('require(')) {
|
|
// Check if it's requiring a built-in module
|
|
const builtinModules = ['crypto', 'util', 'querystring', 'url', 'buffer'];
|
|
const requirePattern = /require\s*\(\s*['"`](\w+)['"`]\s*\)/g;
|
|
let match;
|
|
|
|
while ((match = requirePattern.exec(code)) !== null) {
|
|
const moduleName = match[1];
|
|
if (!builtinModules.includes(moduleName)) {
|
|
warnings.push({
|
|
type: 'security',
|
|
message: `Cannot require('${moduleName}') - only built-in Node.js modules are available`,
|
|
suggestion: `Available modules: ${builtinModules.join(', ')}`
|
|
});
|
|
}
|
|
}
|
|
|
|
// If require is used without quotes, it might be dynamic
|
|
if (/require\s*\([^'"`]/.test(code)) {
|
|
warnings.push({
|
|
type: 'security',
|
|
message: 'Dynamic require() not supported',
|
|
suggestion: 'Use static require with string literals: require("crypto")'
|
|
});
|
|
}
|
|
}
|
|
|
|
// Check for crypto usage without require
|
|
if ((code.includes('crypto.') || code.includes('randomBytes') || code.includes('randomUUID')) &&
|
|
!code.includes('require') && language === 'javaScript') {
|
|
warnings.push({
|
|
type: 'invalid_value',
|
|
message: 'Using crypto without require statement',
|
|
suggestion: 'Add: const crypto = require("crypto"); at the beginning (ignore editor warnings)'
|
|
});
|
|
}
|
|
|
|
// File system access warning
|
|
if (/\b(fs|path|child_process)\b/.test(code)) {
|
|
warnings.push({
|
|
type: 'security',
|
|
message: 'File system and process access not available in Code nodes',
|
|
suggestion: 'Use other n8n nodes for file operations (e.g., Read/Write Files node)'
|
|
});
|
|
}
|
|
}
|
|
} |