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
JavaScript
"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}`;
}
};