UNPKG

@simonecoelhosfo/optimizely-mcp-server

Version:

Optimizely MCP Server for AI assistants with integrated CLI tools

623 lines 27.4 kB
/** * Pre-Execution Validator * * Validates entity data AFTER template parameter substitution but BEFORE API execution. * This ensures that the final payload sent to the API meets all requirements. */ import { getLogger } from '../../logging/Logger.js'; import { JSONStringValidator, JSFunctionValidator, WeightSumValidator, ArrayValidator, ObjectValidator, EnumValidator } from './validators/index.js'; export class PreExecutionValidator { logger = getLogger(); rules = new Map(); constructor(config) { this.initializeValidationRules(); this.registerModelFriendlyRules(); if (config?.customRules) { this.registerCustomRules(config.customRules); } } /** * Initialize all validation rules based on our comprehensive audit */ initializeValidationRules() { // ===== AUDIENCE VALIDATIONS ===== this.registerRule('audience', 'conditions', [ new JSONStringValidator({ fieldName: 'conditions', mustBeArray: true, firstElementEnum: ['and', 'or', 'not'], allowEmpty: false, elementValidation: (element, index) => { if (index === 0) return { valid: true }; // Already validated by firstElementEnum // Each condition must be an object with required fields based on type if (typeof element !== 'object' || element === null) { return { valid: false, error: `Condition at index ${index} must be an object` }; } if (!element.type) { return { valid: false, error: `Condition at index ${index} missing required field 'type'` }; } // Validate based on condition type switch (element.type) { case 'custom_attribute': if (!element.name || !element.match_type || !element.value) { return { valid: false, error: `Custom attribute condition at index ${index} missing required fields (name, match_type, value)` }; } break; case 'language': case 'location': if (!element.value) { return { valid: false, error: `${element.type} condition at index ${index} missing required field 'value'` }; } break; } return { valid: true }; } }) ]); // ===== PAGE VALIDATIONS ===== this.registerRule('page', 'conditions', [ new JSONStringValidator({ fieldName: 'conditions', mustBeArray: true, firstElementEnum: ['and', 'or', 'not'], allowEmpty: false, elementValidation: (element, index) => { if (index === 0) return { valid: true }; if (typeof element !== 'object' || element === null) { return { valid: false, error: `URL condition at index ${index} must be an object` }; } // Page conditions must have type: "url" if (element.type !== 'url') { return { valid: false, error: `Page condition at index ${index} must have type: "url"` }; } if (!element.match_type || !element.value) { return { valid: false, error: `URL condition at index ${index} missing required fields (match_type, value)` }; } // Validate match_type const validMatchTypes = ['simple', 'exact', 'substring', 'regex']; if (!validMatchTypes.includes(element.match_type)) { return { valid: false, error: `Invalid match_type '${element.match_type}' at index ${index}. Must be one of: ${validMatchTypes.join(', ')}` }; } return { valid: true }; } }) ]); this.registerRule('page', 'activation_code', [ new JSFunctionValidator({ fieldName: 'activation_code', functionPatterns: [ /function\s*\(/, // Regular function /function\s+\w+\s*\(/, // Named function /\(\s*\w*\s*\)\s*=>/ // Arrow function ], validateExecution: false // Don't try to execute, just validate syntax }) ]); // ===== EXPERIMENT VALIDATIONS ===== this.registerRule('experiment', 'audience_conditions', [ new JSONStringValidator({ fieldName: 'audience_conditions', mustBeArray: true, firstElementEnum: ['and', 'or', 'not'], allowSpecialValues: ['everyone'], elementValidation: (element, index) => { if (index === 0) return { valid: true }; // Audience conditions reference audience IDs if (typeof element !== 'object' || element === null) { return { valid: false, error: `Audience reference at index ${index} must be an object` }; } if (!element.audience_id && !element.audience_name) { return { valid: false, error: `Audience reference at index ${index} must have either audience_id or audience_name` }; } return { valid: true }; } }) ]); this.registerRule('experiment', 'metrics', [ new ArrayValidator({ fieldName: 'metrics', minItems: 0, maxItems: 100, itemValidation: (metric, index) => { const errors = []; if (!metric.aggregator) { errors.push(`Metric at index ${index} missing required field 'aggregator'`); } else if (!['unique', 'count', 'sum'].includes(metric.aggregator)) { errors.push(`Metric at index ${index} has invalid aggregator '${metric.aggregator}'`); } if (!metric.scope) { errors.push(`Metric at index ${index} missing required field 'scope'`); } else if (!['session', 'visitor', 'impression'].includes(metric.scope)) { errors.push(`Metric at index ${index} has invalid scope '${metric.scope}'`); } // When aggregator is 'sum', field is required if (metric.aggregator === 'sum' && !metric.field) { errors.push(`Metric at index ${index} with aggregator 'sum' requires 'field'`); } // Must have either event_id or event_key if (!metric.event_id && !metric.event_key) { errors.push(`Metric at index ${index} must have either event_id or event_key`); } return errors.length > 0 ? { valid: false, errors } : { valid: true }; } }) ]); this.registerRule('experiment', 'traffic_allocation', [ new WeightSumValidator({ fieldName: 'traffic_allocation', weightField: 'weight', targetSum: 10000, allowPartialAllocation: false }) ]); this.registerRule('experiment', 'changes', [ new ArrayValidator({ fieldName: 'changes', itemValidation: (change, index) => { if (!change.type) { return { valid: false, errors: [`Change at index ${index} missing required field 'type'`] }; } // Validate based on change type const typeValidation = this.validateChangeType(change, index); if (!typeValidation.valid) { return typeValidation; } return { valid: true }; } }) ]); // ===== CAMPAIGN VALIDATIONS ===== this.registerRule('campaign', 'metrics', [ // Same as experiment metrics new ArrayValidator({ fieldName: 'metrics', itemValidation: (metric, index) => { const errors = []; if (!metric.aggregator) { errors.push(`Metric at index ${index} missing required field 'aggregator'`); } if (!metric.scope) { errors.push(`Metric at index ${index} missing required field 'scope'`); } else if (metric.scope !== 'session') { errors.push(`Campaign metric at index ${index} should use scope: 'session', not '${metric.scope}'`); } return errors.length > 0 ? { valid: false, errors } : { valid: true }; } }) ]); // ===== RULE VALIDATIONS (Feature Experimentation) ===== this.registerRule('rule', 'audience_conditions', [ new JSONStringValidator({ fieldName: 'audience_conditions', mustBeArray: true, firstElementEnum: ['and', 'or', 'not'], allowSpecialValues: ['everyone'] }) ]); this.registerRule('rule', 'traffic_allocation', [ new WeightSumValidator({ fieldName: 'traffic_allocation', weightField: 'weight', targetSum: 10000, identifierField: 'variation_id', allowPartialAllocation: false }) ]); // ===== RULESET VALIDATIONS ===== this.registerRule('ruleset', 'rules', [ new ArrayValidator({ fieldName: 'rules', minItems: 1, itemValidation: (rule, index) => { // Each rule must have proper structure if (!rule.type) { return { valid: false, errors: [`Rule at index ${index} missing required field 'type'`] }; } if (rule.type === 'a/b_test' && rule.traffic_allocation) { // Validate traffic allocation within rule const validator = new WeightSumValidator({ fieldName: 'traffic_allocation', weightField: 'weight', targetSum: 10000 }); const result = validator.validate(rule); if (!result.valid) { const errors = []; if (result.error) { errors.push(`Rule ${index}: ${result.error}`); } if (result.errors) { errors.push(...result.errors.map(e => `Rule ${index}: ${e}`)); } return { valid: false, errors }; } } return { valid: true }; } }) ]); // ===== FLAG VALIDATIONS ===== this.registerRule('flag', 'variations', [ new ArrayValidator({ fieldName: 'variations', minItems: 2, itemValidation: (variation, index) => { const errors = []; if (!variation.key) { errors.push(`Variation at index ${index} missing required field 'key'`); } if (!variation.name) { errors.push(`Variation at index ${index} missing required field 'name'`); } if (variation.value === undefined) { errors.push(`Variation at index ${index} missing required field 'value'`); } return errors.length > 0 ? { valid: false, errors } : { valid: true }; } }) ]); // ===== EXTENSION VALIDATIONS ===== this.registerRule('extension', 'implementation', [ new ObjectValidator({ fieldName: 'implementation', requiredFields: ['js'], optionalFields: ['html', 'css'], fieldValidation: { js: (value) => { if (typeof value !== 'string') { return { valid: false, error: 'JavaScript must be a string' }; } return { valid: true }; }, html: (value) => { if (value !== undefined && typeof value !== 'string') { return { valid: false, error: 'HTML must be a string' }; } return { valid: true }; }, css: (value) => { if (value !== undefined && typeof value !== 'string') { return { valid: false, error: 'CSS must be a string' }; } return { valid: true }; } } }) ]); // ===== PROJECT VALIDATIONS ===== this.registerRule('project', 'platform', [ new EnumValidator({ fieldName: 'platform', allowedValues: ['web', 'custom', 'ios', 'android'], errorMessage: 'Invalid platform value. Use "custom" for Feature Experimentation, "web" for Web Experimentation' }) ]); this.registerRule('project', 'web_snippet', [ new ObjectValidator({ fieldName: 'web_snippet', requiredFields: [], optionalFields: ['enable_force_variation', 'exclude_disabled_experiments', 'exclude_names', 'include_jquery', 'ip_anonymization', 'ip_filter', 'library', 'project_javascript'], allowAdditionalFields: true }) ]); } /** * Register ModelFriendlyTemplate specific validation rules */ registerModelFriendlyRules() { // Complexity acknowledgment validator const complexityValidator = { validate: (data) => { if (!data._acknowledged_complexity) { return { valid: false, error: 'Missing _acknowledged_complexity: true for complex entity' }; } return { valid: true }; } }; // Register for complex entities this.registerRule('audience', '_acknowledged_complexity', [complexityValidator]); this.registerRule('experiment', '_acknowledged_complexity', [complexityValidator]); this.registerRule('campaign', '_acknowledged_complexity', [complexityValidator]); // Platform-specific entity validations this.registerRule('project', 'platform', [ new EnumValidator({ fieldName: 'platform', allowedValues: ['web', 'custom', 'ios', 'android'], errorMessage: 'Invalid platform value. Use "custom" for Feature Experimentation, "web" for Web Experimentation' }) ]); // Key format validation for entities that need keys // FIXED: Allow uppercase letters - API supports mixed case keys const keyFormatValidator = { validate: (data) => { if (data.key && typeof data.key === 'string') { // Allow alphanumeric characters and underscores (mixed case) if (!/^[a-zA-Z0-9_]+$/.test(data.key)) { return { valid: false, error: 'Key must contain only letters, numbers, and underscores' }; } } return { valid: true }; } }; this.registerRule('flag', 'key', [keyFormatValidator]); this.registerRule('audience', 'key', [keyFormatValidator]); this.registerRule('experiment', 'key', [keyFormatValidator]); this.registerRule('campaign', 'key', [keyFormatValidator]); } /** * Validate change object based on its type */ validateChangeType(change, index) { const errors = []; switch (change.type) { case 'custom_css': case 'custom_code': if (!change.value) { errors.push(`${change.type} change at index ${index} missing required field 'value'`); } break; case 'insert_html': case 'insert_image': if (!change.selector) { errors.push(`${change.type} change at index ${index} missing required field 'selector'`); } if (!change.value) { errors.push(`${change.type} change at index ${index} missing required field 'value'`); } break; case 'attribute': if (!change.selector) { errors.push(`Attribute change at index ${index} missing required field 'selector'`); } if (!change.attribute_name) { errors.push(`Attribute change at index ${index} missing required field 'attribute_name'`); } if (change.value === undefined) { errors.push(`Attribute change at index ${index} missing required field 'value'`); } break; case 'redirect': if (!change.destination) { errors.push(`Redirect change at index ${index} missing required field 'destination'`); } break; default: errors.push(`Unknown change type '${change.type}' at index ${index}`); } return errors.length > 0 ? { valid: false, errors } : { valid: true }; } /** * Register a validation rule for a specific entity type and field */ registerRule(entityType, fieldPath, validators) { if (!this.rules.has(entityType)) { this.rules.set(entityType, new Map()); } const entityRules = this.rules.get(entityType); const validatorArray = Array.isArray(validators) ? validators : [validators]; if (entityRules.has(fieldPath)) { // Append to existing validators entityRules.get(fieldPath).push(...validatorArray); } else { entityRules.set(fieldPath, validatorArray); } this.logger.debug({ entityType, fieldPath, validatorCount: validatorArray.length }, 'Registered validation rule'); } /** * Register custom validation rules from configuration */ registerCustomRules(rules) { for (const rule of rules) { this.registerRule(rule.entityType, rule.fieldPath, rule.validators); } } /** * Enhanced validation with ModelFriendlyTemplate checks */ validate(entityType, data, context) { const errors = []; const warnings = []; this.logger.debug({ entityType, dataKeys: Object.keys(data || {}), skipFields: context?.skipFields, platform: context?.platform, validateModelFriendly: context?.validateModelFriendly }, 'Starting pre-execution validation'); // Get validation rules for this entity type const entityRules = this.rules.get(entityType); if (!entityRules || entityRules.size === 0) { this.logger.warn({ entityType }, 'No validation rules found for entity type'); return { valid: true, errors: [], warnings: ['No validation rules defined for this entity type'] }; } // Apply each validation rule for (const [fieldPath, validators] of entityRules) { // Skip if field is in skip list if (context?.skipFields?.includes(fieldPath)) { continue; } // Get field value (supports nested paths) const fieldValue = this.getFieldValue(data, fieldPath); // Skip validation if field is not present (validators handle required fields) if (fieldValue === undefined) { continue; } // Run all validators for this field for (const validator of validators) { const result = validator.validate(data, { entityType, fieldPath, platform: context?.platform, additionalContext: context?.additionalContext }); if (!result.valid) { if (result.error) { errors.push(result.error); } if (result.errors) { errors.push(...result.errors); } } if (result.warnings) { warnings.push(...result.warnings); } } } // Additional ModelFriendlyTemplate validation if (context?.validateModelFriendly !== false) { const modelFriendlyResult = this.validateModelFriendlyCompliance(entityType, data, context?.platform); errors.push(...modelFriendlyResult.errors || []); warnings.push(...modelFriendlyResult.warnings || []); } const validationResult = { valid: errors.length === 0, errors, warnings }; this.logger.info({ entityType, valid: validationResult.valid, errorCount: errors.length, warningCount: warnings.length, errors: errors.slice(0, 3) // Log first 3 errors }, 'Pre-execution validation completed'); return validationResult; } /** * Get field value from data object using dot notation path */ getFieldValue(data, fieldPath) { const parts = fieldPath.split('.'); let current = data; for (const part of parts) { if (current === null || current === undefined) { return undefined; } current = current[part]; } return current; } /** * Validate ModelFriendlyTemplate compliance */ validateModelFriendlyCompliance(entityType, data, platform) { const errors = []; const warnings = []; // Platform compatibility validation if (platform) { const webOnlyEntities = ['experiment', 'campaign', 'page', 'extension']; const featureOnlyEntities = ['flag', 'rule', 'ruleset', 'variable_definition']; if (webOnlyEntities.includes(entityType) && platform === 'feature') { errors.push(`Entity type '${entityType}' is only available in Web Experimentation`); } if (featureOnlyEntities.includes(entityType) && platform === 'web') { errors.push(`Entity type '${entityType}' is only available in Feature Experimentation`); } } // Complex entity requirements const complexEntities = ['audience', 'experiment', 'campaign']; if (complexEntities.includes(entityType) && !data._acknowledged_complexity) { errors.push(`Missing _acknowledged_complexity: true for complex entity type: ${entityType}`); } // Project platform validation if (entityType === 'project' && data.platform === 'feature_experimentation') { errors.push('Invalid platform value "feature_experimentation". Use "custom" for Feature Experimentation projects'); } // Key format validation - FIXED: Allow uppercase letters if (data.key && typeof data.key === 'string') { if (!/^[a-zA-Z0-9_]+$/.test(data.key)) { errors.push('Key must contain only letters, numbers, and underscores'); } } // Flag-specific validations if (entityType === 'flag' && data.variations && Array.isArray(data.variations)) { if (data.variations.length < 2) { errors.push('Flag must have at least 2 variations'); } // Validate variation structure data.variations.forEach((variation, index) => { if (!variation.key) { errors.push(`Variation at index ${index} missing required field 'key'`); } if (!variation.name) { errors.push(`Variation at index ${index} missing required field 'name'`); } if (variation.value === undefined) { errors.push(`Variation at index ${index} missing required field 'value'`); } }); } return { errors, warnings }; } /** * Get all registered validation rules (for debugging/testing) */ getRegisteredRules() { return this.rules; } /** * Clear all validation rules */ clearRules() { this.rules.clear(); } } // Export singleton instance for convenience export const preExecutionValidator = new PreExecutionValidator(); //# sourceMappingURL=PreExecutionValidator.js.map