/** * Schema validation service * Validates module configuration values against JSON Schema definitions */ import { existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; import Ajv, { type ValidateFunction } from 'ajv'; import { eq } from 'drizzle-orm'; import { type DbClient, getDb } from '../db/client'; import { modules } from '../db/schema'; const ajv = new Ajv({ allErrors: true, strict: false }); interface ValidationResult { valid: boolean; errors?: string[]; } /** * Get module source path from database */ function getModuleSourcePath(moduleId: string, db: DbClient = getDb()): string | null { try { const module = db.select().from(modules).where(eq(modules.id, moduleId)).get(); return module?.sourcePath || null; } catch (error) { // If database access fails, validation will be skipped (schema not found) console.error(`Failed to get module source path for ${moduleId}:`, error); return null; } } /** * Load JSON Schema for a module * Looks for schema/user_config.json in the module directory */ async function loadModuleSchema( moduleId: string, db: DbClient = getDb(), ): Promise | null> { const sourcePath = getModuleSourcePath(moduleId, db); if (!sourcePath) { return null; } const schemaPath = join(sourcePath, 'schema', 'user_config.json'); if (!existsSync(schemaPath)) { return null; } try { const schemaContent = await readFile(schemaPath, 'utf-8'); try { return JSON.parse(schemaContent) as Record; } catch (parseError) { console.error( `Failed to parse user_config.json schema for ${moduleId}:`, parseError instanceof Error ? parseError.message : parseError, ); return null; } } catch (error) { console.error(`Failed to load schema for ${moduleId}:`, error); return null; } } /** * Get JSON Schema for a specific property * Extracts the property schema from the module's user_config.json */ async function getPropertySchema( moduleId: string, propertyName: string, db: DbClient = getDb(), ): Promise | null> { const schema = await loadModuleSchema(moduleId, db); if (!schema) { return null; } const properties = schema.properties as Record | undefined; if (!properties) { return null; } return (properties[propertyName] as Record) || null; } /** * Validate a configuration value against its schema * * @param moduleId - Module ID * @param key - Configuration key * @param value - Value to validate (already parsed) * @param db - Database client (defaults to singleton) * @returns Validation result with errors if invalid */ export async function validateConfigValue( moduleId: string, key: string, value: unknown, db: DbClient = getDb(), ): Promise { // Load property schema const propertySchema = await getPropertySchema(moduleId, key, db); // If no schema exists, validation passes (schema is optional) if (!propertySchema) { return { valid: true }; } // Compile and validate let validate: ValidateFunction; try { validate = ajv.compile(propertySchema); } catch (error) { // Schema compilation error - invalid schema definition return { valid: false, errors: [ `Invalid schema for property ${key}: ${error instanceof Error ? error.message : String(error)}`, ], }; } const valid = validate(value); if (!valid && validate.errors) { const errors = validate.errors.map((err) => { const path = err.instancePath ? `${err.instancePath} ` : ''; return `${path}${err.message}`; }); return { valid: false, errors }; } return { valid: true }; } /** * Format validation errors for display */ export function formatValidationErrors(errors: string[]): string { if (errors.length === 0) { return 'Validation failed'; } if (errors.length === 1) { return `Validation error: ${errors[0]}`; } return `Validation errors:\n${errors.map((err) => ` - ${err}`).join('\n')}`; }