import { MissingRequiredParamsError } from '../errors'; import { __logger } from '../logger/internal-logger'; import { CBFileSystem } from '../node/file-system'; import { IparamDefn, IparamInputs, IparamDefns, SectionObject, IparamValue } from '../types/common'; import { IPARAM_URL_ALLOWED_PROTOCOLS, IPARAM_VALUE_MAX_LENGTHS } from './iparam-constants'; import * as validatorUtils from './validator-utils'; /** * Validator for parameter inputs * Validates input values against the sectioned iparamDefns */ export class IparamInputsValidator { private fileSystem: CBFileSystem; constructor(fileSystem: CBFileSystem) { this.fileSystem = fileSystem; } /** * Validates whether the value counts as a provided input. * Not whether it is allowed by iparam definitions—that is handled by {@link IparamInputsValidator.validateInputs}. */ static isProvidedIparamInputValue( value: IparamValue | undefined | null ): value is IparamValue { if (value === undefined || value === null) return false; if (typeof value === 'string') return value.trim() !== ''; if (Array.isArray(value)) return value.length > 0; return true; } /** * Validates and loads the iparam inputs from a file (no validation against iparamDefns). * Only ensures the file is valid JSON and the root value is an object. * @param {string} path - Path to the iparams.local.json file * @returns {IparamInputs} The loaded inputs * @throws {Error} If the file does not exist, cannot be read or parsed, or root is not an object */ validateAndGetIparamInputsFile(path: string): IparamInputs { validatorUtils.ensureFileExists(this.fileSystem, path); try { const inputs = JSON.parse(this.fileSystem.readFileSync(path, 'utf8')); if (typeof inputs !== 'object' || Array.isArray(inputs)) { throw new Error('iparams.local.json must be an object'); } return inputs; } catch (error) { if (error instanceof SyntaxError) { throw new Error(`Failed to parse iparams.local.json: ${error.message}`); } throw new Error(`Failed to read iparams.local.json: ${(error as Error).message}`); } } /** * Ensures the inputs value is a plain object * @param {any} inputs - The value to check * @throws {Error} If inputs is not a plain object */ ensureInputsIsObject(inputs: any): void { if (inputs === null || typeof inputs !== 'object' || Array.isArray(inputs)) { throw new Error('Inputs must be an object'); } } /** * Validates inputs against the iparamDefns (without applying defaults) * Only validates provided values, does not include defaults in the result * @param {IparamInputs} inputs - The input values to validate * @param {IparamDefns} iparamDefns - The iparamDefns * @returns {IparamInputs} The validated inputs (only provided values, no defaults) * @throws {Error} If validation fails * @throws {MissingRequiredParamsError} If required parameters are missing */ validateInputs(inputs: IparamInputs, iparamDefns: IparamDefns): IparamInputs { this.ensureInputsIsObject(inputs); const validatedInputs: IparamInputs = {}; const missingRequired: string[] = []; const sections = iparamDefns.installation_parameters?.sections ?? []; const sectionByName = new Map(sections.map((section) => [section.name, section])); for (const section of sections) { const sectionInputs = inputs[section.name]; if (sectionInputs !== undefined && sectionInputs !== null) { if (typeof sectionInputs !== 'object' || Array.isArray(sectionInputs)) { throw new Error(`Section "${section.name}": inputs must be an object`); } } const sectionObject: SectionObject = {}; for (const param of section.parameters) { const sectionDotParam = `${section.name}.${param.name}`; const value = sectionInputs?.[param.name]; if (IparamInputsValidator.isProvidedIparamInputValue(value)) { this.validateParamValue(param, value, sectionDotParam); sectionObject[param.name] = value; } else if (param.required && (param.default === undefined || param.default === null)) { missingRequired.push(sectionDotParam); } } if (Object.keys(sectionObject).length > 0) { validatedInputs[section.name] = sectionObject; } } if (missingRequired.length > 0) { throw new MissingRequiredParamsError(missingRequired); } for (const [sectionName, sectionInputs] of Object.entries(inputs)) { const section = sectionByName.get(sectionName); if (!section) { __logger.warn(`Unknown section "${sectionName}" will be ignored`); continue; } if (sectionInputs === undefined || sectionInputs === null) continue; if (typeof sectionInputs !== 'object' || Array.isArray(sectionInputs)) continue; const knownParamNames = new Set(section.parameters.map((param) => param.name)); for (const key of Object.keys(sectionInputs)) { if (!knownParamNames.has(key)) { __logger.warn(`Unknown parameter "${sectionName}.${key}" will be ignored`); } } } return validatedInputs; } /** * Validates a single parameter value against its parameter definition. * @param {IparamDefn} param - The parameter definition * @param {any} value - The value to validate * @param {string} [sectionDotParam] - Optional section.param identifier for error messages (e.g. processing_fee_configuration.fee_percentage) * @throws {Error} If the value is invalid */ validateParamValue(param: IparamDefn, value: unknown, sectionDotParam = param.name): void { switch (param.type) { case 'NUMBER': this.validateNumber(sectionDotParam, value); break; case 'TEXT': this.requireNonEmptyString(sectionDotParam, value, IPARAM_VALUE_MAX_LENGTHS.TEXT); break; case 'SECRET': this.requireNonEmptyString(sectionDotParam, value, IPARAM_VALUE_MAX_LENGTHS.SECRET); break; case 'BOOLEAN': this.validateBoolean(sectionDotParam, value); break; case 'URL': this.validateUrl(sectionDotParam, value); break; case 'DATE': this.validateDate(sectionDotParam, value); break; case 'DROPDOWN': this.validateDropdown(param, value, sectionDotParam); break; case 'MULTISELECT_DROPDOWN': this.validateMultiselectDropdown(param, value, sectionDotParam); break; default: { const exhaustive: never = param.type; throw new Error(`Parameter "${sectionDotParam}": unknown type "${(exhaustive as string)}"`); } } } /** * Requires value to be a non-empty string, optionally with max length. * @throws {Error} If not a string, empty after trim, or exceeds maxLength */ private requireNonEmptyString(paramName: string, value: unknown, maxLength?: number): void { if (!validatorUtils.isNonEmptyString(value)) { throw new Error(`Parameter "${paramName}": value must be a non-empty string`); } if (maxLength !== undefined && value.length > maxLength) { throw new Error(`Parameter "${paramName}": value cannot exceed ${maxLength} characters`); } } /** * Requires value to be one of the allowed options. */ private requireInOptions(paramName: string, value: string, options: string[]): void { if (options.length === 0 || options.includes(value)) return; throw new Error(`Parameter "${paramName}": value must be one of: ${options.join(', ')}`); } private validateNumber(paramName: string, value: unknown): void { if (typeof value !== 'number' || !Number.isFinite(value)) { throw new Error(`Parameter "${paramName}": value must be a finite number`); } if (value > Number.MAX_SAFE_INTEGER || value < Number.MIN_SAFE_INTEGER) { throw new Error( `Parameter "${paramName}": value must be within the range of ${Number.MIN_SAFE_INTEGER} to ${Number.MAX_SAFE_INTEGER}` ); } } private validateBoolean(paramName: string, value: unknown): void { if (typeof value !== 'boolean') { throw new Error(`Parameter "${paramName}": value must be a boolean`); } } private validateUrl(paramName: string, value: unknown): void { this.requireNonEmptyString(paramName, value, IPARAM_VALUE_MAX_LENGTHS.URL); let parsed: URL; try { parsed = new URL(value as string); } catch { throw new Error(`Parameter "${paramName}": value must be a valid URL`); } if (!IPARAM_URL_ALLOWED_PROTOCOLS.some((p) => p === parsed.protocol)) { throw new Error( `Parameter "${paramName}": value must be a valid URL with protocol ${IPARAM_URL_ALLOWED_PROTOCOLS.join(' or ')}` ); } } private validateDate(paramName: string, value: unknown): void { this.requireNonEmptyString(paramName, value, IPARAM_VALUE_MAX_LENGTHS.DATE); const str = value as string; if (!/^\d{4}-\d{2}-\d{2}$/.test(str)) { throw new Error( `Parameter "${paramName}": value must be a valid date in YYYY-MM-DD format (e.g., 2025-12-31)` ); } const [y, m, d] = str.split('-').map(Number) as [number, number, number]; const dateObj = new Date(Date.UTC(y, m - 1, d)); if ( isNaN(dateObj.getTime()) || dateObj.getUTCFullYear() !== y || dateObj.getUTCMonth() !== m - 1 || dateObj.getUTCDate() !== d ) { throw new Error( `Parameter "${paramName}": value must be a valid date in YYYY-MM-DD format (e.g., 2025-12-31)` ); } } private validateDropdown(param: IparamDefn, value: unknown, sectionDotParam: string): void { if (typeof value !== 'string') { throw new Error(`Parameter "${sectionDotParam}": value must be a string for DROPDOWN type`); } this.requireNonEmptyString(sectionDotParam, value); if (param.options) this.requireInOptions(sectionDotParam, value, param.options); } private validateMultiselectDropdown(param: IparamDefn, value: unknown, sectionDotParam: string): void { if (!Array.isArray(value)) { throw new Error( `Parameter "${sectionDotParam}": value must be an array of strings for MULTISELECT_DROPDOWN type` ); } if (value.length === 0) { throw new Error( `Parameter "${sectionDotParam}": value must be a non-empty array of strings for MULTISELECT_DROPDOWN type` ); } const seenTrimmed = new Set(); for (let i = 0; i < value.length; i++) { try { this.requireNonEmptyString(sectionDotParam, value[i]); if (param.options) this.requireInOptions(sectionDotParam, value[i] as string, param.options); } catch (error) { const msg = (error as Error).message.replace(/\bvalue\b/g, `value array item at index ${i}`); throw new Error(msg); } const trimmed = (value[i] as string).trim(); if (seenTrimmed.has(trimmed)) { throw new Error(`Parameter "${sectionDotParam}": multiselect value must not contain duplicate entries`); } seenTrimmed.add(trimmed); } } }