sms-verification-api 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/.env.example +20 -0
  2. package/DEPLOYMENT.md +151 -0
  3. package/README.md +475 -0
  4. package/docs/app/(home)/layout.tsx +7 -0
  5. package/docs/app/(home)/page.tsx +38 -0
  6. package/docs/app/docs/[[...slug]]/page.tsx +59 -0
  7. package/docs/app/docs/layout.tsx +12 -0
  8. package/docs/app/docs-og/[...slug]/route.ts +24 -0
  9. package/docs/app/globals.css +587 -0
  10. package/docs/app/layout.config.tsx +13 -0
  11. package/docs/app/layout.tsx +27 -0
  12. package/docs/app/logo.tsx +35 -0
  13. package/docs/content/docs/API_AUTHENTICATION.md +91 -0
  14. package/docs/content/docs/DEPLOYMENT.md +181 -0
  15. package/docs/content/docs/api/post.mdx +35 -0
  16. package/docs/content/docs/api/verify.mdx +34 -0
  17. package/docs/content/docs/meta.json +8 -0
  18. package/docs/content/docs/verify-legal-name.md +339 -0
  19. package/docs/lib/source.ts +14 -0
  20. package/docs/mdx-components.tsx +12 -0
  21. package/docs/next.config.mjs +51 -0
  22. package/docs/openapi.json +329 -0
  23. package/docs/package.json +37 -0
  24. package/docs/postcss.config.mjs +5 -0
  25. package/docs/scripts/generate-docs.mjs +23 -0
  26. package/docs/source.config.ts +5 -0
  27. package/docs/tsconfig.json +29 -0
  28. package/docs/worker.js +35 -0
  29. package/docs/wrangler.toml +26 -0
  30. package/examples/client.js +119 -0
  31. package/examples/demo.html +325 -0
  32. package/examples/libphonenumber-example.js +120 -0
  33. package/openapi.json +329 -0
  34. package/package.json +71 -0
  35. package/scripts/deploy.sh +63 -0
  36. package/src/identity-verification-server.ts +553 -0
  37. package/src/index.js +8 -0
  38. package/src/sns.js +236 -0
  39. package/src/verify-phone-server.js +448 -0
  40. package/src/verify-phone.ts +551 -0
  41. package/test/api.test.js +201 -0
  42. package/test/integration.test.js +152 -0
  43. package/test/metadata-test.js +73 -0
  44. package/test/server.test.js +143 -0
  45. package/test/setup.js +32 -0
  46. package/test/utils.test.js +186 -0
  47. package/test/verify.test.js +23 -0
  48. package/test/voip.test.js +112 -0
  49. package/vitest.config.js +10 -0
  50. package/wrangler.toml +27 -0
@@ -0,0 +1,551 @@
1
+ import { parsePhoneNumber, isValidPhoneNumber as isValidPhoneNumberLib, getNumberType } from 'libphonenumber-js';
2
+
3
+ interface VerifyPhoneOptions {
4
+ /**
5
+ * The phone number to send the SMS to (e.g., "+1234567890")
6
+ */
7
+ phoneNumber: string;
8
+ /**
9
+ * The verification code to send (required)
10
+ */
11
+ code: string;
12
+ /**
13
+ * AWS access key ID
14
+ */
15
+ accessKeyId?: string;
16
+ /**
17
+ * AWS secret access key
18
+ */
19
+ secretAccessKey?: string;
20
+ /**
21
+ * AWS region (default: 'us-east-1')
22
+ */
23
+ awsRegion?: string;
24
+ /**
25
+ * Whether to block VoIP numbers (default: false)
26
+ */
27
+ blockVoip?: boolean;
28
+ /**
29
+ * Method to use for VoIP detection: 'api' (external API) or 'libphonenumber' (default: 'api')
30
+ */
31
+ voipDetectionMethod?: 'api' | 'libphonenumber';
32
+ /**
33
+ * Whether to use libphonenumber-js for phone number formatting and validation (default: false)
34
+ */
35
+ useLibPhoneNumber?: boolean;
36
+ /**
37
+ * Metadata type to use with libphonenumber-js: 'minimal' (75KB) or 'full' (140KB, default: 'minimal')
38
+ * Full metadata provides better phone number type detection (MOBILE, FIXED_LINE, VOIP, etc.)
39
+ */
40
+ metadataType?: 'minimal' | 'full';
41
+ /**
42
+ * SMS sender ID (max 11 characters, default: 'Verify')
43
+ */
44
+ senderId?: string;
45
+ /**
46
+ * SMS type ('Transactional' or 'Promotional', default: 'Transactional')
47
+ */
48
+ smsType?: 'Transactional' | 'Promotional';
49
+ /**
50
+ * Custom message template. Use {code} as placeholder for the code.
51
+ * (default: 'Your verification code is: {code}.')
52
+ */
53
+ messageTemplate?: string;
54
+ }
55
+
56
+ /**
57
+ * Verify phone number by sending an SMS text with a code via AWS SNS.
58
+ *
59
+ * ![phone_logo](https://i.imgur.com/2adfBGT.png)
60
+ * @param {string} options.phoneNumber - The phone number to send the SMS to (e.g., "+1234567890")
61
+ * @param {string} options.code - The verification code to send (required)
62
+ * @param {string} options.accessKeyId - AWS access key ID
63
+ * @param {string} options.secretAccessKey - AWS secret access key
64
+ * @param {string} [options.awsRegion='us-east-1'] - AWS region
65
+ * @param {boolean} [options.blockVoip=false] - Whether to block VoIP numbers
66
+ * @param {string} [options.voipDetectionMethod='api'] - Method for VoIP detection: 'api' (external API) or 'libphonenumber' (local analysis)
67
+ * @param {boolean} [options.useLibPhoneNumber=false] - Whether to use libphonenumber-js for phone number formatting and validation
68
+ * @param {string} [options.metadataType='minimal'] - Metadata type: 'minimal' (75KB) or 'full' (140KB) for better phone type detection
69
+ * @param {string} [options.senderId='Verify'] - SMS sender ID (max 11 characters)
70
+ * @param {string} [options.smsType='Transactional'] - SMS type ('Transactional' or 'Promotional')
71
+ * @param {string} [options.messageTemplate] - Custom message template. Use {code} as placeholder for the code.
72
+ * @returns {Promise<Object>} Response object with success status, message, messageId, and code
73
+ */
74
+ export default async function verifyPhone(options = {} as VerifyPhoneOptions) {
75
+ var {
76
+ phoneNumber,
77
+ code,
78
+ accessKeyId = process?.env?.AWS_ACCESS_KEY_ID,
79
+ secretAccessKey = process?.env?.AWS_SECRET_ACCESS_KEY,
80
+ awsRegion = process?.env?.AWS_REGION,
81
+ blockVoip = false,
82
+ voipDetectionMethod = 'api',
83
+ useLibPhoneNumber = false,
84
+ metadataType = 'minimal',
85
+ senderId = 'Verify',
86
+ smsType = 'Transactional',
87
+ messageTemplate = 'Your verification code is: {code}.'
88
+ } = options;
89
+
90
+ try {
91
+ // Validate that code is provided
92
+ if (!code) {
93
+ throw new Error('Verification code is required');
94
+ }
95
+
96
+ // Validate code format (alphanumeric, min 4 characters)
97
+ if (!/^[a-zA-Z0-9]{4,}$/.test(code)) {
98
+ throw new Error('Code must be alphanumeric and at least 4 characters');
99
+ }
100
+
101
+ // Format and validate phone number
102
+ const formattedPhone = useLibPhoneNumber
103
+ ? formatPhoneNumberLibPhoneNumber(phoneNumber)
104
+ : formatPhoneNumber(phoneNumber);
105
+
106
+ if (!isValidPhoneNumber(formattedPhone, useLibPhoneNumber)) {
107
+ throw new Error('Invalid phone number format. Please use E.164 format (e.g., +1234567890)');
108
+ }
109
+
110
+ // Check if VoIP blocking is enabled
111
+ if (blockVoip) {
112
+ const isVoip = voipDetectionMethod === 'libphonenumber'
113
+ ? await isPhoneNumberVoipLibPhoneNumber(formattedPhone, metadataType)
114
+ : await isPhoneNumberVoip(formattedPhone);
115
+
116
+ if (isVoip) {
117
+ return {
118
+ success: false,
119
+ error: 'VoIP numbers are not allowed',
120
+ details: 'This phone number appears to be a VoIP number, which is not supported for verification',
121
+ isVoip: true
122
+ };
123
+ }
124
+ }
125
+
126
+ // Create SNS client
127
+ const snsClient = new SNSClient({ accessKeyId, secretAccessKey, awsRegion });
128
+
129
+ // Prepare message
130
+ const message = messageTemplate.replace('{code}', code);
131
+
132
+ // Prepare SNS parameters
133
+ const params = {
134
+ Message: message,
135
+ PhoneNumber: formattedPhone,
136
+ 'MessageAttributes.entry.1.Name': 'AWS.SNS.SMS.SenderID',
137
+ 'MessageAttributes.entry.1.Value.DataType': 'String',
138
+ 'MessageAttributes.entry.1.Value.StringValue': senderId,
139
+ 'MessageAttributes.entry.2.Name': 'AWS.SNS.SMS.SMSType',
140
+ 'MessageAttributes.entry.2.Value.DataType': 'String',
141
+ 'MessageAttributes.entry.2.Value.StringValue': smsType
142
+ };
143
+
144
+ // Send SMS
145
+ const response = await snsClient.makeRequest('Publish', params);
146
+
147
+ return {
148
+ success: true,
149
+ message: 'Verification code sent successfully',
150
+ messageId: response.MessageId,
151
+ code: code,
152
+ phoneNumber: formattedPhone,
153
+ expiresIn: 600 // 10 minutes in seconds
154
+ };
155
+
156
+ } catch (error) {
157
+ return {
158
+ success: false,
159
+ error: error.message,
160
+ details: error.stack || undefined
161
+ };
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Format phone number to E.164 format using libphonenumber-js
167
+ * @param {string} phone - The input phone number
168
+ * @returns {string} - The formatted E.164 phone number
169
+ */
170
+ function formatPhoneNumberLibPhoneNumber(phone) {
171
+ try {
172
+ const phoneNumber = parsePhoneNumber(phone);
173
+ if (phoneNumber && phoneNumber.isValid()) {
174
+ return phoneNumber.format('E.164');
175
+ }
176
+ // Fallback to basic formatting if libphonenumber-js fails
177
+ return formatPhoneNumber(phone);
178
+ } catch (error) {
179
+ console.warn('libphonenumber-js formatting failed, falling back to basic formatting:', error);
180
+ return formatPhoneNumber(phone);
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Format phone number to E.164 format
186
+ * @param {string} phone - The input phone number
187
+ * @returns {string} - The formatted E.164 phone number
188
+ */
189
+ function formatPhoneNumber(phone) {
190
+ const cleaned = phone.replace(/\D/g, '');
191
+
192
+ if (cleaned.length === 10) {
193
+ return `+1${cleaned}`;
194
+ } else if (cleaned.length === 11 && cleaned.startsWith('1')) {
195
+ return `+${cleaned}`;
196
+ }
197
+
198
+ return phone.startsWith('+') ? phone : `+${cleaned}`;
199
+ }
200
+
201
+ /**
202
+ * Validate phone number against E.164 format
203
+ * @param {string} phone - The phone number to validate
204
+ * @param {boolean} useLibPhoneNumber - Whether to use libphonenumber-js for validation
205
+ * @returns {boolean} - True if valid, false otherwise
206
+ */
207
+ function isValidPhoneNumber(phone, useLibPhoneNumber = false) {
208
+ if (useLibPhoneNumber) {
209
+ try {
210
+ const phoneNumber = parsePhoneNumber(phone);
211
+ return phoneNumber ? phoneNumber.isValid() : false;
212
+ } catch (error) {
213
+ console.warn('libphonenumber-js validation failed, falling back to regex validation:', error);
214
+ // Fallback to regex validation
215
+ }
216
+ }
217
+
218
+ const phoneRegex = /^\+[1-9]\d{1,14}$/;
219
+ return phoneRegex.test(phone) && phone.length >= 7 && phone.length <= 16;
220
+ }
221
+
222
+ /**
223
+ * AWS SNS HTTP API Client for sending SMS verification codes
224
+ * Works directly in the browser using Web Crypto API
225
+ */
226
+
227
+ class SNSClient {
228
+ constructor(options = {}) {
229
+ this.accessKeyId = options.accessKeyId;
230
+ this.secretAccessKey = options.secretAccessKey;
231
+ this.region = options.awsRegion || 'us-east-1';
232
+ this.endpoint = `https://sns.${this.region}.amazonaws.com`;
233
+ }
234
+
235
+ stringToUint8Array(str) {
236
+ return new TextEncoder().encode(str);
237
+ }
238
+
239
+ arrayBufferToHex(buffer) {
240
+ return Array.from(new Uint8Array(buffer))
241
+ .map(b => b.toString(16).padStart(2, '0'))
242
+ .join('');
243
+ }
244
+
245
+ async sign(method, url, headers, payload) {
246
+ const now = new Date();
247
+ const amzDate = now.toISOString().replace(/[:\-]|\.\d{3}/g, '');
248
+ const dateStamp = amzDate.slice(0, 8);
249
+
250
+ const canonicalUri = '/';
251
+ const canonicalQuerystring = url.split('?')[1] || '';
252
+
253
+ headers['host'] = `sns.${this.region}.amazonaws.com`;
254
+ headers['x-amz-date'] = amzDate;
255
+
256
+ const payloadHash = await crypto.subtle.digest('SHA-256', this.stringToUint8Array(payload))
257
+ .then(buffer => this.arrayBufferToHex(buffer));
258
+ headers['x-amz-content-sha256'] = payloadHash;
259
+
260
+ const sortedHeaders = Object.keys(headers).sort().map(key =>
261
+ `${key.toLowerCase()}:${headers[key]}`
262
+ ).join('\n');
263
+
264
+ const signedHeaders = Object.keys(headers).sort().map(key =>
265
+ key.toLowerCase()
266
+ ).join(';');
267
+
268
+ const canonicalRequest = [
269
+ method,
270
+ canonicalUri,
271
+ canonicalQuerystring,
272
+ sortedHeaders,
273
+ '',
274
+ signedHeaders,
275
+ payloadHash
276
+ ].join('\n');
277
+
278
+ const algorithm = 'AWS4-HMAC-SHA256';
279
+ const credentialScope = `${dateStamp}/${this.region}/sns/aws4_request`;
280
+
281
+ const canonicalRequestHash = await crypto.subtle.digest('SHA-256', this.stringToUint8Array(canonicalRequest))
282
+ .then(buffer => this.arrayBufferToHex(buffer));
283
+
284
+ const stringToSign = [
285
+ algorithm,
286
+ amzDate,
287
+ credentialScope,
288
+ canonicalRequestHash
289
+ ].join('\n');
290
+
291
+ const kDate = await crypto.subtle.importKey(
292
+ 'raw',
293
+ this.stringToUint8Array(`AWS4${this.secretAccessKey}`),
294
+ { name: 'HMAC', hash: 'SHA-256' },
295
+ false,
296
+ ['sign']
297
+ ).then(key => crypto.subtle.sign('HMAC', key, this.stringToUint8Array(dateStamp)));
298
+
299
+ const kRegion = await crypto.subtle.importKey(
300
+ 'raw',
301
+ kDate,
302
+ { name: 'HMAC', hash: 'SHA-256' },
303
+ false,
304
+ ['sign']
305
+ ).then(key => crypto.subtle.sign('HMAC', key, this.stringToUint8Array(this.region)));
306
+
307
+ const kService = await crypto.subtle.importKey(
308
+ 'raw',
309
+ kRegion,
310
+ { name: 'HMAC', hash: 'SHA-256' },
311
+ false,
312
+ ['sign']
313
+ ).then(key => crypto.subtle.sign('HMAC', key, this.stringToUint8Array('sns')));
314
+
315
+ const kSigning = await crypto.subtle.importKey(
316
+ 'raw',
317
+ kService,
318
+ { name: 'HMAC', hash: 'SHA-256' },
319
+ false,
320
+ ['sign']
321
+ ).then(key => crypto.subtle.sign('HMAC', key, this.stringToUint8Array('aws4_request')));
322
+
323
+ const signature = await crypto.subtle.importKey(
324
+ 'raw',
325
+ kSigning,
326
+ { name: 'HMAC', hash: 'SHA-256' },
327
+ false,
328
+ ['sign']
329
+ ).then(key => crypto.subtle.sign('HMAC', key, this.stringToUint8Array(stringToSign)))
330
+ .then(buffer => this.arrayBufferToHex(buffer));
331
+
332
+ headers['authorization'] = `${algorithm} Credential=${this.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
333
+
334
+ return headers;
335
+ }
336
+
337
+ async makeRequest(action, params = {}) {
338
+ const queryParams = new URLSearchParams({
339
+ Action: action,
340
+ Version: '2010-03-31',
341
+ ...params
342
+ });
343
+
344
+ const url = `${this.endpoint}/?${queryParams.toString()}`;
345
+ const payload = '';
346
+ const headers = {
347
+ 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8'
348
+ };
349
+
350
+ const signedHeaders = await this.sign('GET', url, headers, payload);
351
+
352
+ try {
353
+ const response = await fetch(url, {
354
+ method: 'GET',
355
+ headers: signedHeaders
356
+ });
357
+
358
+ const text = await response.text();
359
+
360
+ if (!response.ok) {
361
+ throw new Error(`SNS API Error: ${response.status} - ${text}`);
362
+ }
363
+
364
+ return this.parseXMLResponse(text);
365
+ } catch (error) {
366
+ throw new Error(`SNS Request failed: ${error.message}`);
367
+ }
368
+ }
369
+
370
+ parseXMLResponse(xmlText) {
371
+ const messageIdMatch = xmlText.match(/<MessageId>([^<]+)<\/MessageId>/);
372
+ const errorCodeMatch = xmlText.match(/<Code>([^<]+)<\/Code>/);
373
+ const errorMessageMatch = xmlText.match(/<Message>([^<]+)<\/Message>/);
374
+
375
+ if (errorCodeMatch && errorMessageMatch) {
376
+ throw new Error(`${errorCodeMatch[1]}: ${errorMessageMatch[1]}`);
377
+ }
378
+
379
+ if (messageIdMatch) {
380
+ return { MessageId: messageIdMatch[1] };
381
+ }
382
+
383
+ return { raw: xmlText };
384
+ }
385
+ }
386
+
387
+ /**
388
+ * Checks if a phone number is a Bandwidth-only VoIP number (e.g. Google Voice).
389
+ *
390
+ * @see https://www.sent.dm/resources/phone-lookup
391
+ * @param {string} phone - The phone number in E.164 format (e.g., +1234567890).
392
+ * @returns {Promise<boolean>} True if phone is Bandwidth-only VoIP
393
+ */
394
+ async function isPhoneNumberVoip(phone) {
395
+ try {
396
+ const response = await fetch(`https://www.sent.dm/api/phone-lookup?phone=${encodeURIComponent(phone)}`, {
397
+ headers: {
398
+ 'Accept': 'application/json'
399
+ }
400
+ });
401
+
402
+ if (!response.ok) {
403
+ console.warn('Phone lookup failed:', response.status);
404
+ return false; // Default to allowing the number if lookup fails
405
+ }
406
+
407
+ const phoneData = await response.json();
408
+
409
+ if (!phoneData || !phoneData.carrier) return false;
410
+
411
+ return (
412
+ phoneData.carrier.name.toLowerCase().includes("bandwidth") ||
413
+ phoneData.carrier.type.toLowerCase() === "voip" ||
414
+ phoneData.portability.line_type.toLowerCase() === "mobile"
415
+ );
416
+ } catch (error) {
417
+ console.warn('Phone lookup error:', error);
418
+ return false; // Default to allowing the number if lookup fails
419
+ }
420
+ }
421
+
422
+ /**
423
+ * Checks if a phone number is likely a VoIP number using libphonenumber-js.
424
+ * This method analyzes the phone number structure and patterns to identify
425
+ * common VoIP number characteristics.
426
+ *
427
+ * @param {string} phone - The phone number in E.164 format (e.g., +1234567890).
428
+ * @param {string} metadataType - Metadata type: 'minimal' (75KB) or 'full' (140KB)
429
+ * @returns {Promise<boolean>} True if phone is likely VoIP
430
+ */
431
+ async function isPhoneNumberVoipLibPhoneNumber(phone, metadataType = 'minimal') {
432
+ try {
433
+ // Parse the phone number using libphonenumber-js
434
+ const phoneNumber = parsePhoneNumber(phone);
435
+
436
+ if (!phoneNumber) {
437
+ console.warn('Could not parse phone number with libphonenumber-js');
438
+ return false;
439
+ }
440
+
441
+ // Check if the number is valid
442
+ if (!phoneNumber.isValid()) {
443
+ console.warn('Phone number is not valid according to libphonenumber-js');
444
+ return false;
445
+ }
446
+
447
+ // Get the country code
448
+ const country = phoneNumber.country;
449
+
450
+ // VoIP detection heuristics based on common patterns
451
+
452
+ // 1. Check for common VoIP area codes in the US
453
+ if (country === 'US') {
454
+ const nationalNumber = phoneNumber.nationalNumber;
455
+
456
+ // Common VoIP area codes (these are often used by VoIP providers)
457
+ const voipAreaCodes = [
458
+ '800', '888', '877', '866', '855', '844', '833', // Toll-free numbers
459
+ '900', '976', // Premium rate numbers
460
+ '700', // Personal communication services
461
+ '500', '521', '522', '523', '524', '525', '526', '527', '528', '529', // Personal communication services
462
+ '600', '601', '602', '603', '604', '605', '606', '607', '608', '609' // Personal communication services
463
+ ];
464
+
465
+ if (voipAreaCodes.includes(nationalNumber.substring(0, 3))) {
466
+ return true;
467
+ }
468
+ }
469
+
470
+ // 2. Check for non-geographic numbers (often VoIP)
471
+ if (phoneNumber.isNonGeographic()) {
472
+ return true;
473
+ }
474
+
475
+ // 3. Use phone number type detection when available (requires 'full' metadata)
476
+ if (metadataType === 'full') {
477
+ try {
478
+ const numberType = getNumberType(phone);
479
+
480
+ // Direct VoIP detection
481
+ if (numberType === 'VOIP') {
482
+ return true;
483
+ }
484
+
485
+ // Premium rate and toll-free numbers are often used by VoIP services
486
+ if (numberType === 'PREMIUM_RATE' || numberType === 'TOLL_FREE') {
487
+ return true;
488
+ }
489
+
490
+ // Shared cost numbers might be VoIP
491
+ if (numberType === 'SHARED_COST') {
492
+ return true;
493
+ }
494
+
495
+ // Mobile numbers are generally safe (not VoIP)
496
+ if (numberType === 'MOBILE') {
497
+ return false;
498
+ }
499
+
500
+ // Fixed line numbers are generally safe (not VoIP)
501
+ if (numberType === 'FIXED_LINE') {
502
+ return false;
503
+ }
504
+
505
+ // Fixed line or mobile could be either, so we'll use other heuristics
506
+ if (numberType === 'FIXED_LINE_OR_MOBILE') {
507
+ // Continue with pattern analysis below
508
+ }
509
+
510
+ } catch (error) {
511
+ console.warn('Phone number type detection failed:', error);
512
+ // Continue with pattern analysis below
513
+ }
514
+ }
515
+
516
+ // 4. Pattern-based heuristics for when type detection is not available
517
+ const nationalNumber = phoneNumber.nationalNumber;
518
+
519
+ // Check for repeated digits (e.g., 555-5555)
520
+ if (/(\d)\1{2,}/.test(nationalNumber)) {
521
+ return true;
522
+ }
523
+
524
+ // Check for sequential digits (e.g., 123-4567)
525
+ if (/(?:0(?=1)|1(?=2)|2(?=3)|3(?=4)|4(?=5)|5(?=6)|6(?=7)|7(?=8)|8(?=9)){3,}/.test(nationalNumber)) {
526
+ return true;
527
+ }
528
+
529
+ // Check for numbers ending in common VoIP patterns
530
+ const voipEndings = ['0000', '1111', '2222', '3333', '4444', '5555', '6666', '7777', '8888', '9999'];
531
+ if (voipEndings.some(ending => nationalNumber.endsWith(ending))) {
532
+ return true;
533
+ }
534
+
535
+ // Check for numbers that are too "perfect" (often used by VoIP providers)
536
+ const digits = nationalNumber.split('');
537
+ const uniqueDigits = new Set(digits);
538
+
539
+ // If the number has very few unique digits, it might be VoIP
540
+ if (uniqueDigits.size <= 3 && nationalNumber.length >= 7) {
541
+ return true;
542
+ }
543
+
544
+ // Default to not VoIP if no patterns match
545
+ return false;
546
+
547
+ } catch (error) {
548
+ console.warn('libphonenumber-js VoIP detection error:', error);
549
+ return false; // Default to allowing the number if detection fails
550
+ }
551
+ }