From 45ee04ab43e0815f9101b3523cf5c8e74faaed23 Mon Sep 17 00:00:00 2001 From: GeKorm Date: Thu, 28 Nov 2024 16:01:42 +0000 Subject: [PATCH] Add phone number normalization plugin --- .changeset/brave-feet-boil.md | 5 + packages/plugins/README.md | 80 ++++- packages/plugins/index.ts | 1 + packages/plugins/package.json | 23 +- .../plugins/src/{ => email}/email.test.ts | 12 +- .../plugins/src/{email.ts => email/index.ts} | 6 +- packages/plugins/src/phone/index.ts | 128 +++++++ packages/plugins/src/phone/phone.test.ts | 325 ++++++++++++++++++ packages/plugins/tsup.config.ts | 3 +- yarn.lock | 24 +- 10 files changed, 578 insertions(+), 29 deletions(-) create mode 100644 .changeset/brave-feet-boil.md rename packages/plugins/src/{ => email}/email.test.ts (91%) rename packages/plugins/src/{email.ts => email/index.ts} (94%) create mode 100644 packages/plugins/src/phone/index.ts create mode 100644 packages/plugins/src/phone/phone.test.ts diff --git a/.changeset/brave-feet-boil.md b/.changeset/brave-feet-boil.md new file mode 100644 index 0000000..ddfeee7 --- /dev/null +++ b/.changeset/brave-feet-boil.md @@ -0,0 +1,5 @@ +--- +'better-auth-harmony': minor +--- + +Add phone number normalization plugin diff --git a/packages/plugins/README.md b/packages/plugins/README.md index bdd1b7d..579fdb4 100644 --- a/packages/plugins/README.md +++ b/packages/plugins/README.md @@ -1,5 +1,3 @@ -[//]: # 'FIXME: Use a single picture of the pair (one dark, one light)' -
-A [better-auth](https://github.com/better-auth/better-auth) plugin for email normalization and -additional validation, blocking over 55,000 temporary email domains. +A [better-auth](https://github.com/better-auth/better-auth) plugin for email & phone normalization +and additional validation, blocking over 55,000 temporary email domains. -**Normalization:** `foo+temp@gmail.com` -> `foo@gmail.com` +**Email normalization:** `foo+temp@gmail.com` -> `foo@gmail.com` +**Phone normalization:** `+1 (555) 123-1234` -> `+15551231234` **Validation:** `throwaway@mailinator.com` -> Blocked + + +- [Email](#email) + - [Getting Started](#getting-started) + - [Options](#options) + - [Schema](#schema) +- [Phone number](#phone-number) + - [Getting Started](#getting-started-1) + - [Options](#options-1) + + +# Email + ## Getting Started ### 1. Install the plugin @@ -44,7 +56,6 @@ npm i better-auth-harmony ```typescript // auth.ts - import { betterAuth } from 'better-auth'; import { emailHarmony } from 'better-auth-harmony'; @@ -90,3 +101,60 @@ The `emailHarmony` plugin requires an additional field in the user table: The `normalizedEmail` field being unique prevents users from signing up with throwaway variations of the same email address. + +--- + +# Phone number + + + +> [!NOTE] +> Unlike `emailHarmony`, phone number normalization intercepts and modifies the user's +`phoneNumber`, permitting only normalized numbers in the backend. + + + +## Getting Started + +### 1. Install the plugin + +```shell +npm i better-auth-harmony +``` + +#### 2. Add the plugin to your auth config + +```typescript +// auth.ts +import { betterAuth } from 'better-auth'; +import { phoneNumber } from 'better-auth/plugins'; +import { phoneHarmony } from 'better-auth-harmony'; + +export const auth = betterAuth({ + // ... other config options + plugins: [phoneNumber(), phoneHarmony()] +}); +``` + +See the better-auth +[`phoneNumber` plugin documentation](https://www.better-auth.com/docs/plugins/phone-number) for +information on configuring the `phoneNumber()`, including **validation**. + +## Options + +- `defaultCountry` - Default [country](https://www.npmjs.com/package/libphonenumber-js#country-code) + for numbers written in non-international form (without a `+` sign). +- `defaultCallingCode` - Default calling code for numbers written in non-international form (without + a `+` sign). Useful for parsing non-geographic codes such as + [`+800` numbers](https://en.wikipedia.org/wiki/Toll-free_telephone_number). +- `extract` (default=**true**) - Defines the + ["strictness"](https://www.npmjs.com/package/libphonenumber-js#strictness) of parsing a phone + number. By default, it will attempt to extract the phone number from any input string, such as + `"My phone number is (213) 373-4253"`. +- `acceptRawInputOnError` (default=**false**) - If the normalizer throws, for example because it is + unable to parse the phone number, use the original input. For example, the phone number `"+12"` + will be saved as-is to the database. +- `normalizer` - Custom function to normalize phone number. Default uses + [`parsePhoneNumberWithError`](https://www.npmjs.com/package/libphonenumber-js#user-content-parse-phone-number) + from `libphonenumber-js/max`. Can be used to infer the country through the Request object, for + example using IP address geolocation. diff --git a/packages/plugins/index.ts b/packages/plugins/index.ts index 9844b76..1159131 100644 --- a/packages/plugins/index.ts +++ b/packages/plugins/index.ts @@ -1 +1,2 @@ export { default as emailHarmony } from './src/email'; +export { default as phoneHarmony } from './src/phone'; diff --git a/packages/plugins/package.json b/packages/plugins/package.json index f36c3c1..7c370cb 100644 --- a/packages/plugins/package.json +++ b/packages/plugins/package.json @@ -15,7 +15,9 @@ "authentication", "email", "domains", - "disposable" + "disposable", + "phone number", + "mobile" ], "license": "MIT", "homepage": "https://github.com/gekorm/better-auth-harmony", @@ -39,6 +41,16 @@ "default": "./dist/index.cjs" } }, + "./phone": { + "import": { + "types": "./dist/phone.d.ts", + "default": "./dist/phone.js" + }, + "require": { + "types": "./dist/phone.d.cts", + "default": "./dist/phone.cjs" + } + }, "./email": { "import": { "types": "./dist/email.d.ts", @@ -59,23 +71,24 @@ "@repo/eslint-config": "*", "@repo/tsconfig": "*", "@types/eslint": "^9.6.1", - "@types/node": "^22.10.0", + "@types/node": "^22.10.1", "@types/react-dom": "18.3.1", "@types/validator": "^13.12.2", - "@vitest/coverage-v8": "^2.1.5", + "@vitest/coverage-v8": "^2.1.6", "better-auth": "1.0.4", "eslint": "^9.15.0", "rimraf": "^6.0.1", "tsup": "^8.3.5", "typescript": "5.7.2", - "vitest": "^2.1.5" + "vitest": "^2.1.6" }, "peerDependencies": { "better-auth": "^1.0.3" }, "dependencies": { "better-call": "^0.3.2", - "mailchecker": "^6.0.12", + "libphonenumber-js": "^1.11.15", + "mailchecker": "^6.0.13", "validator": "^13.12.0" } } diff --git a/packages/plugins/src/email.test.ts b/packages/plugins/src/email/email.test.ts similarity index 91% rename from packages/plugins/src/email.test.ts rename to packages/plugins/src/email/email.test.ts index a8b85f4..0015c56 100644 --- a/packages/plugins/src/email.test.ts +++ b/packages/plugins/src/email/email.test.ts @@ -1,7 +1,7 @@ -import { afterAll, describe, expect } from 'vitest'; +import { afterAll, describe, expect, it } from 'vitest'; // eslint-disable-next-line import/no-relative-packages -- couldn't find a better way to include it -import { getTestInstance } from '../../../better-auth/packages/better-auth/src/test-utils/test-instance'; -import emailHarmony, { type UserWithNormalizedEmail } from './email'; +import { getTestInstance } from '../../../../better-auth/packages/better-auth/src/test-utils/test-instance'; +import emailHarmony, { type UserWithNormalizedEmail } from '.'; interface SQLiteDB { close: () => Promise; @@ -22,7 +22,7 @@ describe('email harmony', async () => { await (auth.options.database as unknown as SQLiteDB).close(); }); - describe('signup', (it) => { + describe('signup', () => { it('should normalize email', async () => { const rawEmail = 'new.email+test@googlemail.com'; await client.signUp.email({ @@ -81,7 +81,7 @@ describe('email harmony', async () => { }); }); - describe('login', (it) => { + describe('login', () => { it('should work with normalized email form', async () => { const email = 'email@gmail.com'; await client.signUp.email({ @@ -94,7 +94,7 @@ describe('email harmony', async () => { password: 'new-password' }); expect(data?.user.email).toBe(email); - }); + }, 15_000); it('should return error on incorrect email type', async () => { const { data, error } = await client.signIn.email({ diff --git a/packages/plugins/src/email.ts b/packages/plugins/src/email/index.ts similarity index 94% rename from packages/plugins/src/email.ts rename to packages/plugins/src/email/index.ts index 1d39a37..1664b70 100644 --- a/packages/plugins/src/email.ts +++ b/packages/plugins/src/email/index.ts @@ -10,9 +10,9 @@ export interface UserWithNormalizedEmail extends User { export interface EmailHarmonyOptions { /** - * Allow logging in with any version of the unnormalized email address. For example a user who signed up with the - * email `johndoe@googlemail.com` may also log in with `john.doe@gmail.com`. Makes 1 extra database query for - * every login attempt. + * Allow logging in with any version of the unnormalized email address. For example a user who + * signed up with the email `johndoe@googlemail.com` may also log in with `john.doe@gmail.com`. + * Makes 1 extra database query for every login attempt. * @default false */ allowNormalizedSignin?: boolean; diff --git a/packages/plugins/src/phone/index.ts b/packages/plugins/src/phone/index.ts new file mode 100644 index 0000000..80c264d --- /dev/null +++ b/packages/plugins/src/phone/index.ts @@ -0,0 +1,128 @@ +import { type BetterAuthPlugin, createAuthMiddleware } from 'better-auth/plugins'; +import { APIError } from 'better-call'; +import { + type CountryCode, + type E164Number, + ParseError, + parsePhoneNumberWithError +} from 'libphonenumber-js/max'; +import type { HookEndpointContext } from 'better-auth'; + +interface NormalizationOptions { + /** + * Default [country](https://www.npmjs.com/package/libphonenumber-js#country-code) + * for parsing numbers written in non-international form (without a `+` sign). Will be ignored + * when parsing numbers written in international form + * (with a `+` sign). + */ + defaultCountry?: CountryCode; + /** + * Default calling code for parsing numbers written in + * non-international form (without a `+` sign). Will be ignored when parsing numbers written in + * international form (with a `+` sign). It could be specified when parsing phone numbers + * belonging to ["non-geographic numbering + * plans"](https://www.npmjs.com/package/libphonenumber-js#non-geographic) which by nature don't + * have a country code, making the `defaultCountry` option unusable. + */ + defaultCallingCode?: string; + /** + * Defines the + * ["strictness"](https://www.npmjs.com/package/libphonenumber-js#strictness) of parsing a phone + * number. By default, the extract flag is `true` meaning that it will attempt to extract the + * phone number from an input string like `"My phone number is (213) 373-4253 and my hair is + * blue"`. This could be thought of as + * "less strict" parsing. To make it "more strict", one could pass `extract: false` flag, in which + * case the function will attempt to parse the input string as if the whole string was a phone + * number. Applied to the example above, it would return `undefined` because the entire string is + * not a phone number, but for input string `"(213) 373-4253"` it would return a parsed + * `PhoneNumber`. + * @default true + */ + extract?: boolean; +} + +/** + * @see https://www.npmjs.com/package/libphonenumber-js#api + * @returns The phone number in E.164 format. Example: `"+12133734253"`. Returns `undefined` if no + * phone number could be parsed: for example, when the string contains no phone number, or the + * phone number starts with a non-existent country calling code, etc. + */ +export type NormalizePhoneNumber = ( + phone: string, + request?: HookEndpointContext['request'] +) => Promise | E164Number; + +export interface PhoneHarmonyOptions extends NormalizationOptions { + /** + * If the normalizer throws, for example because it is unable to parse the phone number, use the + * original input. For example, the phone number `"+12"` will be saved as-is to the database. + * @default false + */ + acceptRawInputOnError?: boolean; + /** + * Function to normalize phone number. Default uses `parsePhoneNumberWithError` from + * `libphonenumber-js/max`. + * Can be used to infer the country through the Request object, for example using IP address + * geolocation. + * @see https://www.npmjs.com/package/libphonenumber-js#user-content-parse-phone-number + */ + normalizer?: NormalizePhoneNumber; +} + +const phonePaths = ['/sign-in/phone-number', '/phone-number/send-otp', '/phone-number/verify']; + +const phoneHarmony = ({ + defaultCountry, + defaultCallingCode, + extract = true, + acceptRawInputOnError = false, + normalizer +}: PhoneHarmonyOptions = {}) => + ({ + id: 'harmony-phone-number', + hooks: { + before: [ + { + matcher: (context) => phonePaths.some((pathname) => context.path.startsWith(pathname)), + handler: createAuthMiddleware(async (ctx) => { + // Replace context number with the value of `normalizedPhone` + const { phoneNumber } = ctx.body; + + if (typeof phoneNumber !== 'string') return; + + let normalize = normalizer; + if (!normalize) { + normalize = (text: string) => + parsePhoneNumberWithError(text, { defaultCountry, defaultCallingCode, extract }) + .number; + } + + let normalizedPhone = phoneNumber; + + try { + normalizedPhone = await normalize(phoneNumber, ctx.request); + } catch (error) { + if (!acceptRawInputOnError && error instanceof ParseError) { + throw new APIError('BAD_REQUEST', { message: error.message }); + } else if (!acceptRawInputOnError) { + throw error; + } else { + normalizedPhone = phoneNumber; // fall back to the raw input + } + } + + return { + context: { + body: { + ...ctx.body, + phoneNumber: normalizedPhone + } + } + }; + }) + } + ] + } + }) satisfies BetterAuthPlugin; + +export default phoneHarmony; diff --git a/packages/plugins/src/phone/phone.test.ts b/packages/plugins/src/phone/phone.test.ts new file mode 100644 index 0000000..518766a --- /dev/null +++ b/packages/plugins/src/phone/phone.test.ts @@ -0,0 +1,325 @@ +/* eslint-disable n/no-unsupported-features/node-builtins -- tests run on Node 22+ */ +import { createAuthClient } from 'better-auth/client'; +import { phoneNumberClient } from 'better-auth/client/plugins'; +import { phoneNumber } from 'better-auth/plugins'; +import { parsePhoneNumberWithError } from 'libphonenumber-js/max'; +import { afterAll, describe, expect, it, vi } from 'vitest'; +// eslint-disable-next-line import/no-relative-packages -- couldn't find a better way to include it +import { getTestInstance } from '../../../../better-auth/packages/better-auth/src/test-utils/test-instance'; +import phoneHarmony from '.'; + +// Tests largely copied from +// https://github.com/better-auth/better-auth/blob/9b00f1e169349b845b8bdafcc4c8359eb7e397fa/packages/better-auth/src/plugins/phone-number/phone-number.test.ts + +interface SQLiteDB { + close: () => Promise; +} + +describe('phone-number', async () => { + let otp = ''; + + const { customFetchImpl, sessionSetter, auth } = await getTestInstance({ + plugins: [ + phoneNumber({ + sendOTP({ code }) { + otp = code; + }, + signUpOnVerification: { + getTempEmail(digits) { + return `temp-${digits}`; + } + } + }), + phoneHarmony() + ] + }); + + afterAll(async () => { + await (auth.options.database as unknown as SQLiteDB).close(); + }); + + const client = createAuthClient({ + baseURL: 'http://localhost:3000', + plugins: [phoneNumberClient()], + fetchOptions: { + customFetchImpl + } + }); + + const headers = new Headers(); + + const testPhoneNumber = '+1 (555) 123-1234'; + it('should return error on incorrect phone number type', async () => { + const { error } = await client.phoneNumber.sendOtp({ + // @ts-expect-error -- extra safety test + phoneNumber: 22 + }); + expect(error?.status).toBe(500); + expect(otp).toBe(''); + }); + + it('should return error on missing country', async () => { + const { error } = await client.phoneNumber.sendOtp({ + phoneNumber: '55555555555' + }); + expect(error?.status).toBe(500); + expect(otp).toBe(''); + }); + + it('should send verification code', async () => { + const res = await client.phoneNumber.sendOtp({ + phoneNumber: testPhoneNumber + }); + expect(res.error).toBeNull(); + expect(otp).toHaveLength(6); + }); + + it('should verify phone number', async () => { + const res = await client.phoneNumber.verify( + { + phoneNumber: testPhoneNumber, + code: otp + }, + { + onSuccess: sessionSetter(headers) + } + ); + expect(res.error).toBeNull(); + expect(res.data?.user.phoneNumberVerified).toBe(true); + }); + + it("shouldn't verify again with the same code", async () => { + const res = await client.phoneNumber.verify({ + phoneNumber: testPhoneNumber, + code: otp + }); + expect(res.error?.status).toBe(500); + }); + + it('should update phone number', async () => { + const newPhoneNumber = '+1 (555) 123-1111'; + await client.phoneNumber.sendOtp({ + phoneNumber: newPhoneNumber, + fetchOptions: { + headers + } + }); + await client.phoneNumber.verify({ + phoneNumber: newPhoneNumber, + updatePhoneNumber: true, + code: otp, + fetchOptions: { + headers + } + }); + const user = await client.getSession({ + fetchOptions: { + headers + } + }); + expect(user.data?.user.phoneNumber).toBe('+15551231111'); + expect(user.data?.user.phoneNumberVerified).toBe(true); + }); + + it('should not verify if code expired', async () => { + vi.useFakeTimers(); + await client.phoneNumber.sendOtp({ + phoneNumber: '+25120201212' + }); + vi.advanceTimersByTime(1000 * 60 * 5 + 1); // 5 minutes + 1ms + const res = await client.phoneNumber.verify({ + phoneNumber: '+25120201212', + code: otp + }); + expect(res.error?.status).toBe(500); + }); +}, 15_000); + +describe('phone auth flow', async () => { + let otp = ''; + + const { customFetchImpl, sessionSetter, auth } = await getTestInstance({ + plugins: [ + phoneNumber({ + sendOTP({ code }) { + otp = code; + }, + signUpOnVerification: { + getTempEmail(digits) { + return `temp-${digits}`; + } + } + }), + phoneHarmony({ acceptRawInputOnError: true }) + ], + user: { + changeEmail: { + enabled: true + } + } + }); + + afterAll(async () => { + await (auth.options.database as unknown as SQLiteDB).close(); + }); + + const client = createAuthClient({ + baseURL: 'http://localhost:3000', + plugins: [phoneNumberClient()], + fetchOptions: { + customFetchImpl + } + }); + + it('should fall back to raw input on unparseable number', async () => { + const { error } = await client.phoneNumber.sendOtp({ + phoneNumber: '123' + }); + expect(error).toBeNull(); + expect(otp).toHaveLength(6); + }); + + it('should send otp', async () => { + const res = await client.phoneNumber.sendOtp({ + phoneNumber: '+1 (555) 123-1234' + }); + expect(res.error).toBeNull(); + expect(otp).toHaveLength(6); + }); + + it('should verify phone number and create user & session', async () => { + const res = await client.phoneNumber.verify({ + phoneNumber: '+1 (555) 123-1234', + code: otp + }); + expect(res.data?.user.phoneNumberVerified).toBe(true); + expect(res.data?.user.email).toBe('temp-+15551231234'); + expect(res.data?.session).toBeDefined(); + }); + + const headers = new Headers(); + it('should go through send-verify and sign-in the user', async () => { + await client.phoneNumber.sendOtp({ + phoneNumber: '+1 (555) 123-1234' + }); + const res = await client.phoneNumber.verify( + { + phoneNumber: '+1 (555) 123-1234', + code: otp + }, + { + onSuccess: sessionSetter(headers) + } + ); + expect(res.data?.session).toBeDefined(); + }); + + const newEmail = 'new-email@email.com'; + it('should set password and update user', async () => { + await auth.api.setPassword({ + body: { + newPassword: 'password' + }, + headers + }); + const changedEmailRes = await client.changeEmail({ + newEmail, + fetchOptions: { + headers + } + }); + expect(changedEmailRes.error).toBeNull(); + expect(changedEmailRes.data?.user.email).toBe(newEmail); + }); + + it('should sign in with phone number and password', async () => { + const res = await client.signIn.phoneNumber({ + phoneNumber: '+1 (555) 123-1234', + password: 'password' + }); + expect(res.data?.session).toBeDefined(); + }); + + it('should sign in with new email', async () => { + const res = await client.signIn.email({ + email: newEmail, + password: 'password' + }); + expect(res.error).toBeNull(); + }); +}, 15_000); + +describe('verify phone-number', async () => { + let otp = ''; + + const { customFetchImpl, sessionSetter, auth } = await getTestInstance({ + plugins: [ + phoneNumber({ + sendOTP({ code }) { + otp = code; + }, + signUpOnVerification: { + getTempEmail(digits) { + return `temp-${digits}`; + } + } + }), + phoneHarmony({ + normalizer: (phone) => { + if (phone === '5') throw new Error('Test'); // to test non-ParseError errors + return parsePhoneNumberWithError(phone).number; + } + }) + ] + }); + + afterAll(async () => { + await (auth.options.database as unknown as SQLiteDB).close(); + }); + + const client = createAuthClient({ + baseURL: 'http://localhost:3000', + plugins: [phoneNumberClient()], + fetchOptions: { + customFetchImpl + } + }); + + const headers = new Headers(); + + const testPhoneNumber = '+1 (555) 123-1234'; + + it('should return error on missing country', async () => { + const { error } = await client.phoneNumber.sendOtp({ + phoneNumber: '5' + }); + expect(error?.status).toBe(500); + expect(otp).toBe(''); + }); + + it('should verify the last code', async () => { + await client.phoneNumber.sendOtp({ + phoneNumber: testPhoneNumber + }); + vi.useFakeTimers(); + vi.advanceTimersByTime(1000); + await client.phoneNumber.sendOtp({ + phoneNumber: testPhoneNumber + }); + vi.advanceTimersByTime(1000); + await client.phoneNumber.sendOtp({ + phoneNumber: testPhoneNumber + }); + const res = await client.phoneNumber.verify( + { + phoneNumber: testPhoneNumber, + code: otp + }, + { + onSuccess: sessionSetter(headers) + } + ); + expect(res.error).toBeNull(); + expect(res.data?.user.phoneNumberVerified).toBe(true); + }); +}, 15_000); diff --git a/packages/plugins/tsup.config.ts b/packages/plugins/tsup.config.ts index 59d5c1d..cddee71 100644 --- a/packages/plugins/tsup.config.ts +++ b/packages/plugins/tsup.config.ts @@ -3,7 +3,8 @@ import { defineConfig } from 'tsup'; export default defineConfig(() => ({ entry: { index: './index.ts', - email: './src/email.ts' + email: './src/email/index.ts', + phone: './src/phone/index.ts' }, format: ['esm', 'cjs'], bundle: true, diff --git a/yarn.lock b/yarn.lock index f732bc0..136a1b4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1545,7 +1545,7 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:^22.10.0": +"@types/node@npm:^22.10.1": version: 22.10.1 resolution: "@types/node@npm:22.10.1" dependencies: @@ -1722,7 +1722,7 @@ __metadata: languageName: node linkType: hard -"@vitest/coverage-v8@npm:^2.1.5": +"@vitest/coverage-v8@npm:^2.1.6": version: 2.1.6 resolution: "@vitest/coverage-v8@npm:2.1.6" dependencies: @@ -2102,19 +2102,20 @@ __metadata: "@repo/eslint-config": "npm:*" "@repo/tsconfig": "npm:*" "@types/eslint": "npm:^9.6.1" - "@types/node": "npm:^22.10.0" + "@types/node": "npm:^22.10.1" "@types/react-dom": "npm:18.3.1" "@types/validator": "npm:^13.12.2" - "@vitest/coverage-v8": "npm:^2.1.5" + "@vitest/coverage-v8": "npm:^2.1.6" better-auth: "npm:1.0.4" better-call: "npm:^0.3.2" eslint: "npm:^9.15.0" - mailchecker: "npm:^6.0.12" + libphonenumber-js: "npm:^1.11.15" + mailchecker: "npm:^6.0.13" rimraf: "npm:^6.0.1" tsup: "npm:^8.3.5" typescript: "npm:5.7.2" validator: "npm:^13.12.0" - vitest: "npm:^2.1.5" + vitest: "npm:^2.1.6" peerDependencies: better-auth: ^1.0.3 languageName: unknown @@ -4418,6 +4419,13 @@ __metadata: languageName: node linkType: hard +"libphonenumber-js@npm:^1.11.15": + version: 1.11.15 + resolution: "libphonenumber-js@npm:1.11.15" + checksum: 10c0/eb1794ee38d2bc3087f7e5596740949493c745fe4734c5bcee3cf51344da16e1ea49c96fcc19c57eac36ebfffda6d328cbdbaff7bc26714a594a4a98f7789153 + languageName: node + linkType: hard + "lilconfig@npm:^3.1.1": version: 3.1.2 resolution: "lilconfig@npm:3.1.2" @@ -4533,7 +4541,7 @@ __metadata: languageName: node linkType: hard -"mailchecker@npm:^6.0.12": +"mailchecker@npm:^6.0.13": version: 6.0.13 resolution: "mailchecker@npm:6.0.13" dependencies: @@ -7179,7 +7187,7 @@ __metadata: languageName: node linkType: hard -"vitest@npm:^2.1.5": +"vitest@npm:^2.1.6": version: 2.1.6 resolution: "vitest@npm:2.1.6" dependencies: