UNPKG

nigerian-mobile-validator

Version:

The most rigorous, up-to-date library for validating Nigerian mobile numbers. Fully NCC-compliant, and security-focused, with enterprise-grade features to prevent the business risks of validation failures in regulated industries.

410 lines (409 loc) 21.1 kB
"use strict"; // src/number-validation/nigerian-mobile-number-validator.ts Object.defineProperty(exports, "__esModule", { value: true }); exports.NigerianMobileNumberValidator = void 0; const events_1 = require("events"); const mobile_numbering_plan_1 = require("../numbering-plan/mobile-numbering-plan"); const mobile_number_validation_result_1 = require("./mobile-number-validation-result"); const mobile_validation_status_1 = require("./mobile-validation-status"); const network_access_code_1 = require("../numbering-plan/network-access-code"); const telco_1 = require("../numbering-plan/telco"); const validation_triggering_flags_1 = require("./validation-triggering-flags"); const general_utils_1 = require("../utils/general-utils"); const logger_1 = require("../logging/logger"); const validator_security_1 = require("../security/validator-security"); const typing_direction_1 = require("./typing-direction"); /** * This class validates Nigerian mobile numbers in strict compliance with the * official Nigerian National Numbering Plan. * * - Numbering plan last updated: March 2025 */ class NigerianMobileNumberValidator { /** * Create a new Nigerian Mobile Number Validator * * @param options Validator options */ constructor(options = {}) { var _a; this.emitter = new events_1.EventEmitter(); this.mobileNumberingPlan = new mobile_numbering_plan_1.MobileNumberingPlan(); this.validationTriggeringFlags = new validation_triggering_flags_1.ValidationTriggeringFlags(); this.logger = (_a = options.logger) !== null && _a !== void 0 ? _a : (0, logger_1.getDefaultLogger)(); // Set maximum listeners to prevent memory leaks this.emitter.setMaxListeners(validator_security_1.ValidatorSecurity.DEFAULT_MAX_LISTENERS); // Set up improved rate limiting if requested if (options.rateLimit && options.rateLimit > 0) { this.rateLimiter = validator_security_1.ValidatorSecurity.createRollingWindowRateLimiter(options.rateLimit, 60000 // 1 minute window ); this.logger.info(`Rate limiting enabled: ${options.rateLimit} validations per minute`); } this.currentTypingDirection = typing_direction_1.TypingDirection.Unknown; this.logger.debug('NigerianMobileNumberValidator initialized'); } /** * Register a listener for validation results * * @param callback Function to call with validation results * @returns Function to remove the listener */ onValidationResult(callback) { this.emitter.on('validationResult', callback); this.logger.debug('Validation result listener registered'); return () => { this.emitter.off('validationResult', callback); this.logger.debug('Validation result listener removed'); }; } /** * Publish a validation result and emit it to listeners */ publishValidationResult(userProvidedDigits, mobileValidationStatus, validationSucceeded, mobileNumber, telcoNumberAllocation) { // Update flags to help with future validation triggers this.updateValidationTriggeringFlags(validationSucceeded); // Create the result const validationResult = new mobile_number_validation_result_1.MobileNumberValidationResult(userProvidedDigits, mobileValidationStatus, validationSucceeded, mobileNumber, telcoNumberAllocation); // Log the validation result if (validationSucceeded) { this.logger.info(`Validation succeeded for number: ${userProvidedDigits}`, { telco: mobileNumber === null || mobileNumber === void 0 ? void 0 : mobileNumber.telco, networkCode: mobileNumber === null || mobileNumber === void 0 ? void 0 : mobileNumber.networkCode }); } else if (mobileValidationStatus !== mobile_validation_status_1.MobileValidationStatus.IncorrectNumberOfDigits) { this.logger.warn(`Validation failed for number: ${userProvidedDigits}`, { status: mobileValidationStatus, reason: mobile_validation_status_1.MobileValidationStatus[mobileValidationStatus] }); } // Emit to listeners this.emitter.emit('validationResult', validationResult); return validationResult; } /** * Updates internal flags after validation to help determine * when to trigger validation on future input. */ updateValidationTriggeringFlags(validationSucceeded) { if (!validationSucceeded) { this.validationTriggeringFlags.hasPreviouslyErrored = true; } this.validationTriggeringFlags.validated = validationSucceeded; } /** * Check if validation is allowed based on rate limiting */ checkHasExceededRateLimit() { if (!this.rateLimiter) { return true; } if (!this.rateLimiter.hasExceededLimit()) { this.logger.warn('Rate limit exceeded, validation blocked', { currentCount: this.rateLimiter.currentCount, timeUntilNext: this.rateLimiter.timeUntilNextAllowed }); return false; } return true; } /** * Determines if there are enough digits to perform validation */ // eslint-disable-next-line sonarjs/cognitive-complexity areThereEnoughDigitsToValidate(currentUserInput) { // Determine the direction of typing (to handle edge cases) if (this.validationTriggeringFlags.previousUserInput.length == currentUserInput.length) { this.usersTypingDirection = typing_direction_1.TypingDirection.Unknown; } else if (this.validationTriggeringFlags.previousUserInput.length <= currentUserInput.length) { this.usersTypingDirection = typing_direction_1.TypingDirection.Forward; } else { this.usersTypingDirection = typing_direction_1.TypingDirection.Backward; } // Store current input as previous for next time this.validationTriggeringFlags.previousUserInput = currentUserInput; if (currentUserInput.length === 0) { // unreachable code, ValidatorSecurity.fastReject stopped validation long ago this.validationTriggeringFlags.hasPreviouslyErrored = false; this.validationTriggeringFlags.validated = false; return false; } if (this.validationTriggeringFlags.hasPreviouslyErrored || this.validationTriggeringFlags.validated) { if (currentUserInput.startsWith('0')) { if (this.usersTypingDirection === typing_direction_1.TypingDirection.Forward) { if (currentUserInput.length >= 11) { return true; } } else { // Moving backwards or unknown if (currentUserInput.length === 10 || currentUserInput.length === 11) { return true; } } } else if (currentUserInput.startsWith('234')) { if (this.usersTypingDirection === typing_direction_1.TypingDirection.Forward) { if (currentUserInput.length >= 13) { return true; } } else { // Moving backwards or unknown if (currentUserInput.length === 12 || currentUserInput.length === 13) { return true; } } } else { // Sounds like an invalid network code or a foreign number return false; } } else if (currentUserInput.startsWith('0') && currentUserInput.length >= 11) { return true; } else if (currentUserInput.startsWith('234') && currentUserInput.length >= 13) { return true; } return false; } /** * Sanitize user input by removing spaces, plus signs, and fixing common errors */ static sanitizeUserProvidedMobileNumber(userProvidedDigits) { return validator_security_1.ValidatorSecurity.stripUnsafeInputs(userProvidedDigits); } set usersTypingDirection(typingDirection) { this.currentTypingDirection = typingDirection; } /** * Gets an enum representing the direction in which the user is currently typing. */ get usersTypingDirection() { return this.currentTypingDirection; } /** * Truthy method to quickly check if a phone number is not a Nigerian mobile number. * This is a fast pre-validation step to reject obviously foreign numbers. * * @param sanitizedUserInput The phone number to check * @returns true if the number looks foreign, false if it could be Nigerian */ isForeignNumber(sanitizedUserInput) { // Empty input can't be evaluated if (!sanitizedUserInput) return false; // Check if the number starts with a Nigerian prefix // Nigerian numbers start with: // 1. 0 followed by valid 1st digit of a mobile network code (local format) // 2. 234 followed by valid 1st digit of a mobile network code (international format) // Valid network codes start with 7, 8, or 9 if (sanitizedUserInput.startsWith('0')) { // Local format: Second digit must be 7, 8, or 9 if (sanitizedUserInput.length < 2) return true; const secondDigit = sanitizedUserInput.charAt(1); return !['7', '8', '9'].includes(secondDigit); } else if (sanitizedUserInput.startsWith('234')) { // International format: 4th digit must be 7, 8, or 9 if (sanitizedUserInput.length < 4) return true; const fourthDigit = sanitizedUserInput.charAt(3); return !['7', '8', '9'].includes(fourthDigit); } else if (sanitizedUserInput.startsWith('+234')) { // unreachable code, sanitizeUserProvidedMobileNumber() has already removed leading + // International format with plus: 5th digit must be 7, 8, or 9 if (sanitizedUserInput.length < 5) return true; const fifthDigit = sanitizedUserInput.charAt(4); return !['7', '8', '9'].includes(fifthDigit); } // If it doesn't match any of these patterns, it's definitely not a Nigerian mobile number return true; } /** * Validates a Nigerian mobile number in strict compliance with the official * Nigerian National Numbering Plan. * * @param userProvidedDigits The characters input by the user representing their mobile number * @returns A MobileNumberValidationResult representing the validation result */ // eslint-disable-next-line sonarjs/cognitive-complexity validate(userProvidedDigits) { // Fast rejection for obviously invalid inputs if (!userProvidedDigits || validator_security_1.ValidatorSecurity.fastReject(userProvidedDigits)) { return this.publishValidationResult(userProvidedDigits !== null && userProvidedDigits !== void 0 ? userProvidedDigits : '', mobile_validation_status_1.MobileValidationStatus.ContainsNonNumericChars, false); } // Check if rate limit exceeded (if enabled) if (!this.checkHasExceededRateLimit()) { return new mobile_number_validation_result_1.MobileNumberValidationResult(userProvidedDigits !== null && userProvidedDigits !== void 0 ? userProvidedDigits : '', mobile_validation_status_1.MobileValidationStatus.RateLimitExceeded, false); } // Handle empty input if (!userProvidedDigits || userProvidedDigits.length === 0) { // unreachable code, ValidatorSecurity.fastReject() has already stopped validation this.logger.debug('Empty input provided'); return this.publishValidationResult('', mobile_validation_status_1.MobileValidationStatus.IncorrectNumberOfDigits, false); } // Sanitize the input const sanitizedUserInput = NigerianMobileNumberValidator.sanitizeUserProvidedMobileNumber(userProvidedDigits); // Check if the input is numeric if (!general_utils_1.GeneralUtils.isNumeric(sanitizedUserInput)) { // unreachable code, ValidatorSecurity.fastReject() has already stopped validation this.logger.warn(`Input contains non-numeric characters: ${sanitizedUserInput}`); return this.publishValidationResult(userProvidedDigits, mobile_validation_status_1.MobileValidationStatus.ContainsNonNumericChars, false); } if (this.isForeignNumber(sanitizedUserInput)) { // Not a Nigerian number format this.logger.warn(`Invalid number format: ${sanitizedUserInput}`); return this.publishValidationResult(userProvidedDigits, mobile_validation_status_1.MobileValidationStatus.NotNigerianNumber, false); } this.logger.debug(`Sanitized input: ${sanitizedUserInput}`); const areThereEnoughDigitsToValidate = this.areThereEnoughDigitsToValidate(sanitizedUserInput); // Check if we have enough digits to validate if (!areThereEnoughDigitsToValidate) { this.logger.debug(`Not enough digits to validate: ${sanitizedUserInput}`); return this.publishValidationResult(userProvidedDigits, mobile_validation_status_1.MobileValidationStatus.IncorrectNumberOfDigits, false); } let expectedCharLength; // Determine expected length based on the format if (sanitizedUserInput.startsWith('0')) { expectedCharLength = 11; } else if (sanitizedUserInput.startsWith('234')) { expectedCharLength = 13; } else if (sanitizedUserInput.startsWith('+234')) { // unreachable code, sanitizeUserProvidedMobileNumber() removes leading + expectedCharLength = 14; } else { // Not a Nigerian number format this.logger.warn(`Invalid number format: ${sanitizedUserInput}`); return this.publishValidationResult(userProvidedDigits, mobile_validation_status_1.MobileValidationStatus.NotNigerianNumber, false); } // Check if the length is correct if (sanitizedUserInput.length !== expectedCharLength) { this.logger.warn(`Incorrect number of digits: ${sanitizedUserInput.length}, expected: ${expectedCharLength}`); return this.publishValidationResult(userProvidedDigits, mobile_validation_status_1.MobileValidationStatus.IncorrectNumberOfDigits, false); } // Parse the mobile number const mobileNumber = new NigerianMobileNumberValidator.MobileNumber(sanitizedUserInput); this.logger.debug('Parsed mobile number:', { networkCode: mobileNumber.networkCode, subscriberNumber: mobileNumber.subscriberNumber, countryCode: mobileNumber.countryCode }); // Validate network code if (!network_access_code_1.NetworkAccessCodeUtil.isNetworkCodeValid(mobileNumber.networkCode)) { this.logger.warn(`Invalid network code: ${mobileNumber.networkCode}`); return this.publishValidationResult(userProvidedDigits, mobile_validation_status_1.MobileValidationStatus.IncorrectNetworkCode, false, mobileNumber); } // Search the numbering plan for this number const localNumber = parseInt(mobileNumber.localNumber); const telcoNumberAllocation = this.mobileNumberingPlan.search(localNumber); // Handle allocation not found if (!telcoNumberAllocation) { this.logger.warn(`No telco allocation found for number: ${localNumber}`); return this.publishValidationResult(userProvidedDigits, mobile_validation_status_1.MobileValidationStatus.InvalidSubscriberNumber, false, mobileNumber); } // Check the telco status if (telcoNumberAllocation.telco === telco_1.Telco.Unassigned) { // potentially unreachable, there are currently no ranges designated as unassigned // but may be reachable in the future (NOTE: if so a test will need to be created) this.logger.warn(`Unassigned network code: ${mobileNumber.networkCode}`); return this.publishValidationResult(userProvidedDigits, mobile_validation_status_1.MobileValidationStatus.UnassignedNetworkCode, false, mobileNumber, telcoNumberAllocation); } else if (telcoNumberAllocation.telco === telco_1.Telco.SharedVAS) { this.logger.warn(`Shared VAS network code: ${mobileNumber.networkCode}`); return this.publishValidationResult(userProvidedDigits, mobile_validation_status_1.MobileValidationStatus.SharedVASNetworkCode, false, mobileNumber, telcoNumberAllocation); } else if (telcoNumberAllocation.telco === telco_1.Telco.Withdrawn) { this.logger.warn(`Withdrawn network code: ${mobileNumber.networkCode}`); return this.publishValidationResult(userProvidedDigits, mobile_validation_status_1.MobileValidationStatus.WithdrawnNetworkCode, false, mobileNumber, telcoNumberAllocation); } else if (telcoNumberAllocation.telco === telco_1.Telco.Reserved) { this.logger.warn(`Reserved network code: ${mobileNumber.networkCode}`); return this.publishValidationResult(userProvidedDigits, mobile_validation_status_1.MobileValidationStatus.ReservedNetworkCode, false, mobileNumber, telcoNumberAllocation); } else if (telcoNumberAllocation.telco === telco_1.Telco.Returned) { this.logger.warn(`Reserved network code: ${mobileNumber.networkCode}`); return this.publishValidationResult(userProvidedDigits, mobile_validation_status_1.MobileValidationStatus.ReturnedNetworkCode, false, mobileNumber, telcoNumberAllocation); } // Set the telco name for the mobile number mobileNumber.telcoName = telcoNumberAllocation.telco; // Validation successful! this.logger.info(`Validation successful: ${userProvidedDigits}, Telco: ${mobileNumber.telco}`); return this.publishValidationResult(userProvidedDigits, mobile_validation_status_1.MobileValidationStatus.Success, true, mobileNumber, telcoNumberAllocation); } /** * Clean up resources */ dispose() { this.logger.debug('Disposing validator'); this.emitter.removeAllListeners(); } } exports.NigerianMobileNumberValidator = NigerianMobileNumberValidator; /** * Represents a mobile phone number. */ NigerianMobileNumberValidator.MobileNumber = class { constructor(msisdn) { if (msisdn.startsWith('0')) { // Local format: 0803xxxxxxx this._countryCode = 234; // Nigeria this._networkCode = parseInt(msisdn.substring(1, 4)); // e.g., 803 this._subscriberNumber = msisdn.substring(4); // 7-digit subscriber number } else if (msisdn.startsWith('234')) { // International format without +: 234803xxxxxxx this._countryCode = parseInt(msisdn.substring(0, 3)); // 234 this._networkCode = parseInt(msisdn.substring(3, 6)); // e.g., 803 this._subscriberNumber = msisdn.substring(6); } else { // Assume format with +: +234803xxxxxxx (but + should be stripped before) // potentially unreachable code, since we only validate sanitized inputs this._countryCode = parseInt(msisdn.substring(1, 4)); // +234 this._networkCode = parseInt(msisdn.substring(4, 7)); // e.g., 803 this._subscriberNumber = msisdn.substring(7); } // Always store in international format with + this._msisdn = `+${this._countryCode}${this._networkCode}${this._subscriberNumber}`; } /** The full subscriber number including the country code if available. */ get msisdn() { return this._msisdn; } /** The 7-digit number assigned to the subscriber (excluding the network code). */ get subscriberNumber() { return this._subscriberNumber; } /** The country code of the mobile number. */ get countryCode() { return this._countryCode; } /** The code assigned to the mobile network e.g. 802, 803, 805. */ get networkCode() { return this._networkCode; } /** The name of the mobile operator. */ get telco() { var _a; return (_a = this._telcoName) !== null && _a !== void 0 ? _a : ''; } /** Set the telco name for this mobile number */ set telcoName(name) { this._telcoName = name; } /** The local format of the mobile number without country code. */ get localNumber() { return `0${this._networkCode}${this._subscriberNumber}`; } };