@simonecoelhosfo/optimizely-mcp-server
Version:
Optimizely MCP Server for AI assistants with integrated CLI tools
623 lines • 27.4 kB
JavaScript
/**
* 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