@better-auth/infra 0.2.2 → 0.2.3

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.
package/dist/index.d.mts CHANGED
@@ -101,6 +101,9 @@ interface SecurityOptions {
101
101
  action?: SecurityAction;
102
102
  domainAllowlist?: string[];
103
103
  };
104
+ emailNormalization?: {
105
+ enabled?: boolean;
106
+ };
104
107
  staleUsers?: {
105
108
  enabled: boolean;
106
109
  staleDays?: number;
@@ -181,6 +184,9 @@ declare const sentinel: (options?: SentinelOptions) => {
181
184
  action?: SecurityAction;
182
185
  domainAllowlist?: string[];
183
186
  } | undefined;
187
+ emailNormalization: {
188
+ enabled?: boolean;
189
+ } | undefined;
184
190
  databaseHooks: {
185
191
  user: {
186
192
  create: {
@@ -723,7 +729,7 @@ interface DashIdRow {
723
729
  id: string;
724
730
  }
725
731
  //#endregion
726
- //#region ../../node_modules/.bun/@better-auth+scim@1.6.1+27eabf8bb9704597/node_modules/@better-auth/scim/dist/index.d.mts
732
+ //#region ../../node_modules/.bun/@better-auth+scim@1.6.1+f10cf570321458be/node_modules/@better-auth/scim/dist/index.d.mts
727
733
  //#region src/types.d.ts
728
734
  interface SCIMProvider {
729
735
  id: string;
@@ -4586,7 +4592,7 @@ interface DashCheckUserExistsResponse {
4586
4592
  * - Normalize googlemail.com to gmail.com
4587
4593
  *
4588
4594
  * @param email - Raw email to normalize
4589
- * @param context - Auth context with getPlugin (for sentinel policy)
4595
+ * @param context - Auth context
4590
4596
  */
4591
4597
  declare function normalizeEmail(email: string, context: AuthContext): string;
4592
4598
  //#endregion
package/dist/index.mjs CHANGED
@@ -997,531 +997,45 @@ function getCountryCodeFromRequest(request) {
997
997
  return cc ? cc.toUpperCase() : void 0;
998
998
  }
999
999
  //#endregion
1000
- //#region src/validation/matchers.ts
1001
- const paths = [
1002
- "/sign-up/email",
1003
- "/email-otp/verify-email",
1004
- "/sign-in/email-otp",
1005
- "/sign-in/magic-link",
1006
- "/sign-in/email",
1007
- "/forget-password/email-otp",
1008
- "/email-otp/reset-password",
1009
- "/email-otp/create-verification-otp",
1010
- "/email-otp/get-verification-otp",
1011
- "/email-otp/send-verification-otp",
1012
- "/forget-password",
1013
- "/request-password-reset",
1014
- "/send-verification-email",
1015
- "/change-email"
1016
- ];
1017
- const all = new Set(paths);
1018
- new Set(paths.slice(1, 12));
1019
- /**
1020
- * Path is one of `[
1021
- * '/sign-up/email',
1022
- * '/email-otp/verify-email',
1023
- * '/sign-in/email-otp',
1024
- * '/sign-in/magic-link',
1025
- * '/sign-in/email',
1026
- * '/forget-password/email-otp',
1027
- * '/email-otp/reset-password',
1028
- * '/email-otp/create-verification-otp',
1029
- * '/email-otp/get-verification-otp',
1030
- * '/email-otp/send-verification-otp',
1031
- * '/forget-password',
1032
- * '/request-password-reset',
1033
- * '/send-verification-email',
1034
- * '/change-email'
1035
- * ]`.
1036
- * @param context Request context
1037
- * @param context.path Request path
1038
- * @returns boolean
1039
- */
1040
- const allEmail = ({ path }) => !!path && all.has(path);
1041
- //#endregion
1042
- //#region src/validation/email.ts
1043
- /**
1044
- * Gmail-like providers that ignore dots in the local part
1045
- */
1046
- const GMAIL_LIKE_DOMAINS = new Set(["gmail.com", "googlemail.com"]);
1047
- /**
1048
- * Providers known to support plus addressing
1049
- */
1050
- const PLUS_ADDRESSING_DOMAINS = new Set([
1051
- "gmail.com",
1052
- "googlemail.com",
1053
- "outlook.com",
1054
- "hotmail.com",
1055
- "live.com",
1056
- "yahoo.com",
1057
- "icloud.com",
1058
- "me.com",
1059
- "mac.com",
1060
- "protonmail.com",
1061
- "proton.me",
1062
- "fastmail.com",
1063
- "zoho.com"
1064
- ]);
1000
+ //#region src/sentinel/security.ts
1001
+ async function hashForFingerprint(input) {
1002
+ const data = new TextEncoder().encode(input);
1003
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
1004
+ return Array.from(new Uint8Array(hashBuffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
1005
+ }
1006
+ async function sha1Hash(input) {
1007
+ const data = new TextEncoder().encode(input);
1008
+ const hashBuffer = await crypto.subtle.digest("SHA-1", data);
1009
+ return Array.from(new Uint8Array(hashBuffer)).map((b) => b.toString(16).padStart(2, "0")).join("").toUpperCase();
1010
+ }
1065
1011
  /**
1066
- * Normalize an email address for comparison/deduplication
1067
- * - Lowercase the entire email
1068
- * - Remove dots from Gmail-like providers (they ignore dots)
1069
- * - Remove plus addressing (user+tag@domain → user@domain)
1070
- * - Normalize googlemail.com to gmail.com
1071
- *
1072
- * @param email - Raw email to normalize
1073
- * @param context - Auth context with getPlugin (for sentinel policy)
1012
+ * Whether Sentinel should normalize emails for deduplication/sign-in consistency.
1013
+ * If `emailNormalization` is set, it wins; otherwise legacy behavior uses `emailValidation.enabled`.
1014
+ * @internal Not part of the package public API; used by the sentinel plugin and email helpers.
1074
1015
  */
1075
- function normalizeEmail(email, context) {
1076
- if (!email || typeof email !== "string") return email;
1077
- if ((context.getPlugin?.("sentinel"))?.options?.emailValidation?.enabled === false) return email;
1078
- const trimmed = email.trim().toLowerCase();
1079
- const atIndex = trimmed.lastIndexOf("@");
1080
- if (atIndex === -1) return trimmed;
1081
- let localPart = trimmed.slice(0, atIndex);
1082
- let domain = trimmed.slice(atIndex + 1);
1083
- if (domain === "googlemail.com") domain = "gmail.com";
1084
- if (PLUS_ADDRESSING_DOMAINS.has(domain)) {
1085
- const plusIndex = localPart.indexOf("+");
1086
- if (plusIndex !== -1) localPart = localPart.slice(0, plusIndex);
1087
- }
1088
- if (GMAIL_LIKE_DOMAINS.has(domain)) localPart = localPart.replace(/\./g, "");
1089
- return `${localPart}@${domain}`;
1016
+ function isEmailNormalizationEnabled(security) {
1017
+ const explicit = security?.emailNormalization;
1018
+ if (explicit !== void 0) return explicit.enabled !== false;
1019
+ return security?.emailValidation?.enabled !== false;
1090
1020
  }
1091
- function createEmailValidator(options = {}) {
1092
- const { apiKey = "", apiUrl = INFRA_API_URL, kvUrl = INFRA_KV_URL, defaultConfig = {} } = options;
1021
+ function createSecurityClient(apiUrl, apiKey, options, onSecurityEvent) {
1022
+ const resolvedApiUrl = apiUrl || INFRA_API_URL;
1093
1023
  const $api = createFetch({
1094
- baseURL: apiUrl,
1095
- headers: { "x-api-key": apiKey }
1096
- });
1097
- const $kv = createFetch({
1098
- baseURL: kvUrl,
1024
+ baseURL: resolvedApiUrl,
1099
1025
  headers: { "x-api-key": apiKey },
1100
- timeout: KV_TIMEOUT_MS
1026
+ throw: true
1101
1027
  });
1102
- /**
1103
- * Fetch and resolve email validity policy from API with caching
1104
- * Sends client config to API which merges with user's dashboard settings
1105
- */
1106
- async function fetchPolicy() {
1107
- try {
1108
- const { data } = await $api("/security/resolve-policy", {
1109
- method: "POST",
1110
- body: {
1111
- policyId: "email_validity",
1112
- config: { emailValidation: {
1113
- enabled: defaultConfig.enabled,
1114
- strictness: defaultConfig.strictness,
1115
- action: defaultConfig.action,
1116
- domainAllowlist: defaultConfig.domainAllowlist
1117
- } }
1118
- }
1119
- });
1120
- if (data?.policy) return data.policy;
1121
- } catch (error) {
1122
- logger.warn("[Dash] Failed to fetch email policy, using defaults:", error);
1123
- }
1124
- return null;
1125
- }
1126
- return { async validate(email, checkMx = true) {
1127
- const trimmed = email.trim();
1128
- const policy = await fetchPolicy();
1129
- if (!policy?.enabled) return {
1130
- valid: true,
1131
- disposable: false,
1132
- confidence: "high",
1133
- policy
1028
+ const emailSender = createEmailSender({
1029
+ apiUrl: resolvedApiUrl,
1030
+ apiKey
1031
+ });
1032
+ function logEvent(event) {
1033
+ const fullEvent = {
1034
+ ...event,
1035
+ timestamp: Date.now()
1134
1036
  };
1135
- try {
1136
- const { data } = await $kv("/email/validate", {
1137
- method: "POST",
1138
- body: {
1139
- email: trimmed,
1140
- checkMx,
1141
- strictness: policy.strictness
1142
- }
1143
- });
1144
- return {
1145
- ...data || {
1146
- valid: false,
1147
- reason: "invalid_format"
1148
- },
1149
- policy
1150
- };
1151
- } catch (error) {
1152
- logger.warn("[Dash] Email validation API error, falling back to allow:", error);
1153
- return {
1154
- valid: true,
1155
- policy
1156
- };
1157
- }
1158
- } };
1159
- }
1160
- /**
1161
- * Basic local email format validation (fallback)
1162
- */
1163
- function isValidEmailFormatLocal(email) {
1164
- if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return false;
1165
- if (email.length > 254) return false;
1166
- const [localPart, domain] = email.split("@");
1167
- if (!localPart || !domain) return false;
1168
- if (localPart.length > 64) return false;
1169
- if (domain.length > 253) return false;
1170
- return true;
1171
- }
1172
- const getEmail = (ctx) => {
1173
- if (ctx.path === "/change-email") return {
1174
- email: ctx.body?.newEmail,
1175
- container: "body",
1176
- field: "newEmail"
1177
- };
1178
- const body = ctx.body;
1179
- const query = ctx.query;
1180
- return {
1181
- email: body?.email ?? query?.email,
1182
- container: body ? "body" : "query",
1183
- field: "email"
1184
- };
1185
- };
1186
- /**
1187
- * Create email normalization hook (shared between all configurations)
1188
- */
1189
- function createEmailNormalizationHook() {
1190
- return {
1191
- matcher: allEmail,
1192
- handler: createAuthMiddleware(async (ctx) => {
1193
- const { email, container, field } = getEmail(ctx);
1194
- if (typeof email !== "string") return;
1195
- const normalized = normalizeEmail(email, ctx.context);
1196
- if (normalized === email) return;
1197
- const data = container === "query" ? {
1198
- ...ctx.query,
1199
- [field]: normalized
1200
- } : {
1201
- ...ctx.body,
1202
- [field]: normalized
1203
- };
1204
- return { context: {
1205
- ...ctx,
1206
- [container]: data
1207
- } };
1208
- })
1209
- };
1210
- }
1211
- /**
1212
- * Create email validation hook with configurable validation strategy
1213
- */
1214
- function createEmailValidationHook(validator, onDisposableEmail) {
1215
- return {
1216
- matcher: allEmail,
1217
- handler: createAuthMiddleware(async (ctx) => {
1218
- const { email } = getEmail(ctx);
1219
- if (typeof email !== "string") return;
1220
- const trimmed = email.trim();
1221
- if (!isValidEmailFormatLocal(trimmed)) throw new APIError$1("BAD_REQUEST", { message: "Invalid email" });
1222
- if (validator) {
1223
- const result = await validator.validate(trimmed);
1224
- const policy = result.policy;
1225
- if (!policy?.enabled) return;
1226
- if (policy.domainAllowlist?.length) {
1227
- const domain = trimmed.toLowerCase().split("@")[1];
1228
- if (domain && policy.domainAllowlist.includes(domain)) return;
1229
- }
1230
- const action = policy.action;
1231
- if (!result.valid) {
1232
- if ((result.disposable || result.reason === "no_mx_records" || result.reason === "blocklist" || result.reason === "known_invalid_email" || result.reason === "known_invalid_host" || result.reason === "reserved_tld" || result.reason === "provider_local_too_short") && onDisposableEmail) {
1233
- const ip = ctx.request?.headers?.get("x-forwarded-for")?.split(",")[0] || ctx.request?.headers?.get("cf-connecting-ip") || void 0;
1234
- onDisposableEmail({
1235
- email: trimmed,
1236
- reason: result.reason || "disposable",
1237
- confidence: result.confidence,
1238
- ip,
1239
- path: ctx.path,
1240
- action
1241
- });
1242
- }
1243
- if (action === "allow") return;
1244
- throw new APIError$1("BAD_REQUEST", { message: result.reason === "no_mx_records" ? "This email domain cannot receive emails" : result.disposable || result.reason === "blocklist" ? "Disposable email addresses are not allowed" : result.reason === "known_invalid_email" || result.reason === "known_invalid_host" || result.reason === "reserved_tld" || result.reason === "provider_local_too_short" ? "This email address appears to be invalid" : "Invalid email" });
1245
- }
1246
- }
1247
- })
1248
- };
1249
- }
1250
- /**
1251
- * Create email validation hooks with optional API-backed validation
1252
- *
1253
- * @param options - Configuration options
1254
- * @param options.enabled - Enable email validation (default: true)
1255
- * @param options.useApi - Use API-backed validation (requires apiKey)
1256
- * @param options.apiKey - API key for remote validation
1257
- * @param options.apiUrl - API URL for policy fetching (defaults to INFRA_API_URL)
1258
- * @param options.kvUrl - KV URL for email validation (defaults to INFRA_KV_URL)
1259
- * @param options.strictness - Default strictness level: 'low', 'medium' (default), or 'high'
1260
- * @param options.action - Default action when invalid: 'allow', 'block' (default), or 'challenge'
1261
- *
1262
- * @example
1263
- * // Local validation only
1264
- * createEmailHooks()
1265
- *
1266
- * @example
1267
- * // API-backed validation
1268
- * createEmailHooks({ useApi: true, apiKey: "your-api-key" })
1269
- *
1270
- * @example
1271
- * // API-backed validation with high strictness default
1272
- * createEmailHooks({ useApi: true, apiKey: "your-api-key", strictness: "high" })
1273
- */
1274
- function createEmailHooks(options = {}) {
1275
- const { useApi = false, apiKey = "", apiUrl = INFRA_API_URL, kvUrl = INFRA_KV_URL, defaultConfig, onDisposableEmail } = options;
1276
- const emailConfig = {
1277
- enabled: true,
1278
- strictness: "medium",
1279
- action: "block",
1280
- ...defaultConfig
1281
- };
1282
- if (!emailConfig.enabled) return { before: [] };
1283
- const validator = useApi ? createEmailValidator({
1284
- apiUrl,
1285
- kvUrl,
1286
- apiKey,
1287
- defaultConfig: emailConfig
1288
- }) : void 0;
1289
- return { before: [createEmailNormalizationHook(), createEmailValidationHook(validator, onDisposableEmail)] };
1290
- }
1291
- createEmailHooks();
1292
- //#endregion
1293
- //#region src/validation/phone.ts
1294
- /**
1295
- * Common fake/test phone numbers that should be blocked
1296
- * These are numbers commonly used in testing, movies, documentation, etc.
1297
- */
1298
- const INVALID_PHONE_NUMBERS = new Set([
1299
- "+15550000000",
1300
- "+15550001111",
1301
- "+15550001234",
1302
- "+15551234567",
1303
- "+15555555555",
1304
- "+15551111111",
1305
- "+15550000001",
1306
- "+15550123456",
1307
- "+12125551234",
1308
- "+13105551234",
1309
- "+14155551234",
1310
- "+12025551234",
1311
- "+10000000000",
1312
- "+11111111111",
1313
- "+12222222222",
1314
- "+13333333333",
1315
- "+14444444444",
1316
- "+15555555555",
1317
- "+16666666666",
1318
- "+17777777777",
1319
- "+18888888888",
1320
- "+19999999999",
1321
- "+11234567890",
1322
- "+10123456789",
1323
- "+19876543210",
1324
- "+441632960000",
1325
- "+447700900000",
1326
- "+447700900001",
1327
- "+447700900123",
1328
- "+447700900999",
1329
- "+442079460000",
1330
- "+442079460123",
1331
- "+441134960000",
1332
- "+0000000000",
1333
- "+1000000000",
1334
- "+123456789",
1335
- "+1234567890",
1336
- "+12345678901",
1337
- "+0123456789",
1338
- "+9876543210",
1339
- "+11111111111",
1340
- "+99999999999",
1341
- "+491234567890",
1342
- "+491111111111",
1343
- "+33123456789",
1344
- "+33111111111",
1345
- "+61123456789",
1346
- "+61111111111",
1347
- "+81123456789",
1348
- "+81111111111",
1349
- "+19001234567",
1350
- "+19761234567",
1351
- "+1911",
1352
- "+1411",
1353
- "+1611",
1354
- "+44999",
1355
- "+44112"
1356
- ]);
1357
- /**
1358
- * Patterns that indicate fake/test phone numbers
1359
- */
1360
- const INVALID_PHONE_PATTERNS = [
1361
- /^\+\d(\d)\1{6,}$/,
1362
- /^\+\d*1234567890/,
1363
- /^\+\d*0123456789/,
1364
- /^\+\d*9876543210/,
1365
- /^\+\d*0987654321/,
1366
- /^\+\d*(12){4,}/,
1367
- /^\+\d*(21){4,}/,
1368
- /^\+\d*(00){4,}/,
1369
- /^\+1\d{3}555\d{4}$/,
1370
- /^\+\d{1,3}\d{1,5}$/,
1371
- /^\+\d+0{7,}$/,
1372
- /^\+\d*147258369/,
1373
- /^\+\d*258369147/,
1374
- /^\+\d*369258147/,
1375
- /^\+\d*789456123/,
1376
- /^\+\d*123456789/,
1377
- /^\+\d*1234512345/,
1378
- /^\+\d*1111122222/,
1379
- /^\+\d*1212121212/,
1380
- /^\+\d*1010101010/
1381
- ];
1382
- /**
1383
- * Invalid area codes / prefixes that indicate test numbers
1384
- * Key: country code, Value: set of invalid prefixes
1385
- */
1386
- const INVALID_PREFIXES_BY_COUNTRY = {
1387
- US: new Set([
1388
- "555",
1389
- "000",
1390
- "111",
1391
- "911",
1392
- "411",
1393
- "611"
1394
- ]),
1395
- CA: new Set([
1396
- "555",
1397
- "000",
1398
- "911"
1399
- ]),
1400
- GB: new Set([
1401
- "7700900",
1402
- "1632960",
1403
- "1134960"
1404
- ]),
1405
- AU: new Set([
1406
- "0491570",
1407
- "0491571",
1408
- "0491572"
1409
- ])
1410
- };
1411
- /**
1412
- * Check if a phone number is a commonly used fake/test number
1413
- * @param phone - The phone number to check (E.164 format preferred)
1414
- * @param defaultCountry - Default country code if not included in phone string
1415
- * @returns true if the phone appears to be fake/test, false if it seems legitimate
1416
- */
1417
- const isFakePhoneNumber = (phone, defaultCountry) => {
1418
- const parsed = parsePhoneNumberFromString(phone, defaultCountry);
1419
- if (!parsed) return true;
1420
- const e164 = parsed.number;
1421
- const nationalNumber = parsed.nationalNumber;
1422
- const country = parsed.country;
1423
- if (INVALID_PHONE_NUMBERS.has(e164)) return true;
1424
- for (const pattern of INVALID_PHONE_PATTERNS) if (pattern.test(e164)) return true;
1425
- if (country && INVALID_PREFIXES_BY_COUNTRY[country]) {
1426
- const prefixes = INVALID_PREFIXES_BY_COUNTRY[country];
1427
- for (const prefix of prefixes) if (nationalNumber.startsWith(prefix)) return true;
1428
- }
1429
- if (/^(\d)\1+$/.test(nationalNumber)) return true;
1430
- const digits = nationalNumber.split("").map(Number);
1431
- let isSequential = digits.length >= 6;
1432
- for (let i = 1; i < digits.length && isSequential; i++) {
1433
- const current = digits[i];
1434
- const previous = digits[i - 1];
1435
- if (current === void 0 || previous === void 0 || current !== previous + 1 && current !== previous - 1) isSequential = false;
1436
- }
1437
- if (isSequential) return true;
1438
- return false;
1439
- };
1440
- /**
1441
- * Validate a phone number format
1442
- * @param phone - The phone number to validate
1443
- * @param defaultCountry - Default country code if not included in phone string
1444
- * @returns true if the phone number is valid
1445
- */
1446
- const isValidPhone = (phone, defaultCountry) => {
1447
- return isValidPhoneNumber(phone, defaultCountry);
1448
- };
1449
- /**
1450
- * Comprehensive phone number validation
1451
- * @param phone - The phone number to validate
1452
- * @param options - Validation options
1453
- * @returns true if valid, false otherwise
1454
- */
1455
- const validatePhone = (phone, options = {}) => {
1456
- const { mobileOnly = false, allowedCountries, blockedCountries, blockFakeNumbers = true, blockPremiumRate = true, blockTollFree = false, blockVoip = false, defaultCountry } = options;
1457
- if (!isValidPhone(phone, defaultCountry)) return false;
1458
- const parsed = parsePhoneNumberFromString(phone, defaultCountry);
1459
- if (!parsed) return false;
1460
- if (blockFakeNumbers && isFakePhoneNumber(phone, defaultCountry)) return false;
1461
- const country = parsed.country;
1462
- if (country) {
1463
- if (allowedCountries && !allowedCountries.includes(country)) return false;
1464
- if (blockedCountries?.includes(country)) return false;
1465
- }
1466
- const phoneType = parsed.getType();
1467
- if (mobileOnly) {
1468
- if (phoneType !== "MOBILE" && phoneType !== "FIXED_LINE_OR_MOBILE") return false;
1469
- }
1470
- if (blockPremiumRate && phoneType === "PREMIUM_RATE") return false;
1471
- if (blockTollFree && phoneType === "TOLL_FREE") return false;
1472
- if (blockVoip && phoneType === "VOIP") return false;
1473
- return true;
1474
- };
1475
- const allPhonePaths = new Set([
1476
- "/phone-number/send-otp",
1477
- "/phone-number/verify",
1478
- "/sign-in/phone-number",
1479
- "/phone-number/request-password-reset",
1480
- "/phone-number/reset-password"
1481
- ]);
1482
- const getPhoneNumber = (ctx) => ctx.body?.phoneNumber ?? ctx.query?.phoneNumber;
1483
- /**
1484
- * Better Auth plugin for phone number validation
1485
- * Validates phone numbers on all phone-related endpoints
1486
- */
1487
- const phoneValidationHooks = { before: [{
1488
- matcher: (context) => !!context.path && allPhonePaths.has(context.path),
1489
- handler: createAuthMiddleware(async (ctx) => {
1490
- const phoneNumber = getPhoneNumber(ctx);
1491
- if (typeof phoneNumber !== "string") return;
1492
- if (!validatePhone(phoneNumber)) throw new APIError$1("BAD_REQUEST", { message: "Invalid phone number" });
1493
- })
1494
- }] };
1495
- //#endregion
1496
- //#region src/sentinel/security.ts
1497
- async function hashForFingerprint(input) {
1498
- const data = new TextEncoder().encode(input);
1499
- const hashBuffer = await crypto.subtle.digest("SHA-256", data);
1500
- return Array.from(new Uint8Array(hashBuffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
1501
- }
1502
- async function sha1Hash(input) {
1503
- const data = new TextEncoder().encode(input);
1504
- const hashBuffer = await crypto.subtle.digest("SHA-1", data);
1505
- return Array.from(new Uint8Array(hashBuffer)).map((b) => b.toString(16).padStart(2, "0")).join("").toUpperCase();
1506
- }
1507
- function createSecurityClient(apiUrl, apiKey, options, onSecurityEvent) {
1508
- const resolvedApiUrl = apiUrl || INFRA_API_URL;
1509
- const $api = createFetch({
1510
- baseURL: resolvedApiUrl,
1511
- headers: { "x-api-key": apiKey },
1512
- throw: true
1513
- });
1514
- const emailSender = createEmailSender({
1515
- apiUrl: resolvedApiUrl,
1516
- apiKey
1517
- });
1518
- function logEvent(event) {
1519
- const fullEvent = {
1520
- ...event,
1521
- timestamp: Date.now()
1522
- };
1523
- if (onSecurityEvent) onSecurityEvent(fullEvent);
1524
- }
1037
+ if (onSecurityEvent) onSecurityEvent(fullEvent);
1038
+ }
1525
1039
  return {
1526
1040
  async checkSecurity(request) {
1527
1041
  try {
@@ -1827,61 +1341,556 @@ function createSecurityClient(apiUrl, apiKey, options, onSecurityEvent) {
1827
1341
  loginIp: identification?.ip || "Unknown"
1828
1342
  }
1829
1343
  });
1830
- if (result.success) logger.info(`[Dash] Stale account notification sent to user: ${userEmail}`);
1831
- else logger.error(`[Dash] Failed to send stale account user notification: ${result.error}`);
1832
- },
1833
- async notifyStaleAccountAdmin(adminEmail, userId, userEmail, userName, daysSinceLastActive, identification, appName) {
1834
- const loginTime = (/* @__PURE__ */ new Date()).toLocaleString("en-US", {
1835
- dateStyle: "long",
1836
- timeStyle: "short",
1837
- timeZone: "UTC"
1838
- }) + " UTC";
1839
- const location = identification?.location;
1840
- const loginLocation = location?.city && location?.country?.name ? `${location.city}, ${location.country.code}` : location?.country?.name || "Unknown";
1841
- const browser = identification?.browser;
1842
- const loginDevice = browser?.name && browser?.os ? `${browser.name} on ${browser.os}` : "Unknown device";
1843
- const result = await emailSender.send({
1844
- template: "stale-account-admin",
1845
- to: adminEmail,
1846
- variables: {
1847
- userEmail,
1848
- userName: userName || "User",
1849
- userId,
1850
- appName: appName || "Your App",
1851
- daysSinceLastActive: String(daysSinceLastActive),
1852
- loginTime,
1853
- loginLocation,
1854
- loginDevice,
1855
- loginIp: identification?.ip || "Unknown",
1856
- adminEmail
1344
+ if (result.success) logger.info(`[Dash] Stale account notification sent to user: ${userEmail}`);
1345
+ else logger.error(`[Dash] Failed to send stale account user notification: ${result.error}`);
1346
+ },
1347
+ async notifyStaleAccountAdmin(adminEmail, userId, userEmail, userName, daysSinceLastActive, identification, appName) {
1348
+ const loginTime = (/* @__PURE__ */ new Date()).toLocaleString("en-US", {
1349
+ dateStyle: "long",
1350
+ timeStyle: "short",
1351
+ timeZone: "UTC"
1352
+ }) + " UTC";
1353
+ const location = identification?.location;
1354
+ const loginLocation = location?.city && location?.country?.name ? `${location.city}, ${location.country.code}` : location?.country?.name || "Unknown";
1355
+ const browser = identification?.browser;
1356
+ const loginDevice = browser?.name && browser?.os ? `${browser.name} on ${browser.os}` : "Unknown device";
1357
+ const result = await emailSender.send({
1358
+ template: "stale-account-admin",
1359
+ to: adminEmail,
1360
+ variables: {
1361
+ userEmail,
1362
+ userName: userName || "User",
1363
+ userId,
1364
+ appName: appName || "Your App",
1365
+ daysSinceLastActive: String(daysSinceLastActive),
1366
+ loginTime,
1367
+ loginLocation,
1368
+ loginDevice,
1369
+ loginIp: identification?.ip || "Unknown",
1370
+ adminEmail
1371
+ }
1372
+ });
1373
+ if (result.success) logger.info(`[Dash] Stale account admin notification sent to: ${adminEmail}`);
1374
+ else logger.error(`[Dash] Failed to send stale account admin notification: ${result.error}`);
1375
+ },
1376
+ async checkUnknownDevice(_userId, _visitorId) {
1377
+ return false;
1378
+ },
1379
+ async notifyUnknownDevice(userId, email, identification) {
1380
+ logEvent({
1381
+ type: "unknown_device",
1382
+ userId,
1383
+ visitorId: identification?.visitorId || null,
1384
+ ip: identification?.ip || null,
1385
+ country: identification?.location?.country?.code || null,
1386
+ details: {
1387
+ email,
1388
+ device: identification?.browser.device,
1389
+ os: identification?.browser.os,
1390
+ browser: identification?.browser.name,
1391
+ city: identification?.location?.city,
1392
+ country: identification?.location?.country?.name
1393
+ },
1394
+ action: "logged"
1395
+ });
1396
+ }
1397
+ };
1398
+ }
1399
+ //#endregion
1400
+ //#region src/validation/matchers.ts
1401
+ const paths = [
1402
+ "/sign-up/email",
1403
+ "/email-otp/verify-email",
1404
+ "/sign-in/email-otp",
1405
+ "/sign-in/magic-link",
1406
+ "/sign-in/email",
1407
+ "/forget-password/email-otp",
1408
+ "/email-otp/reset-password",
1409
+ "/email-otp/create-verification-otp",
1410
+ "/email-otp/get-verification-otp",
1411
+ "/email-otp/send-verification-otp",
1412
+ "/forget-password",
1413
+ "/request-password-reset",
1414
+ "/send-verification-email",
1415
+ "/change-email"
1416
+ ];
1417
+ const all = new Set(paths);
1418
+ new Set(paths.slice(1, 12));
1419
+ /**
1420
+ * Path is one of `[
1421
+ * '/sign-up/email',
1422
+ * '/email-otp/verify-email',
1423
+ * '/sign-in/email-otp',
1424
+ * '/sign-in/magic-link',
1425
+ * '/sign-in/email',
1426
+ * '/forget-password/email-otp',
1427
+ * '/email-otp/reset-password',
1428
+ * '/email-otp/create-verification-otp',
1429
+ * '/email-otp/get-verification-otp',
1430
+ * '/email-otp/send-verification-otp',
1431
+ * '/forget-password',
1432
+ * '/request-password-reset',
1433
+ * '/send-verification-email',
1434
+ * '/change-email'
1435
+ * ]`.
1436
+ * @param context Request context
1437
+ * @param context.path Request path
1438
+ * @returns boolean
1439
+ */
1440
+ const allEmail = ({ path }) => !!path && all.has(path);
1441
+ //#endregion
1442
+ //#region src/validation/email.ts
1443
+ /**
1444
+ * Gmail-like providers that ignore dots in the local part
1445
+ */
1446
+ const GMAIL_LIKE_DOMAINS = new Set(["gmail.com", "googlemail.com"]);
1447
+ /**
1448
+ * Providers known to support plus addressing
1449
+ */
1450
+ const PLUS_ADDRESSING_DOMAINS = new Set([
1451
+ "gmail.com",
1452
+ "googlemail.com",
1453
+ "outlook.com",
1454
+ "hotmail.com",
1455
+ "live.com",
1456
+ "yahoo.com",
1457
+ "icloud.com",
1458
+ "me.com",
1459
+ "mac.com",
1460
+ "protonmail.com",
1461
+ "proton.me",
1462
+ "fastmail.com",
1463
+ "zoho.com"
1464
+ ]);
1465
+ /**
1466
+ * Normalize an email address for comparison/deduplication
1467
+ * - Lowercase the entire email
1468
+ * - Remove dots from Gmail-like providers (they ignore dots)
1469
+ * - Remove plus addressing (user+tag@domain → user@domain)
1470
+ * - Normalize googlemail.com to gmail.com
1471
+ *
1472
+ * @param email - Raw email to normalize
1473
+ * @param context - Auth context
1474
+ */
1475
+ function normalizeEmail(email, context) {
1476
+ if (!email || typeof email !== "string") return email;
1477
+ const mergedOpts = context.options;
1478
+ if (!isEmailNormalizationEnabled(mergedOpts)) return email;
1479
+ const trimmed = email.trim().toLowerCase();
1480
+ const atIndex = trimmed.lastIndexOf("@");
1481
+ if (atIndex === -1) return trimmed;
1482
+ let localPart = trimmed.slice(0, atIndex);
1483
+ let domain = trimmed.slice(atIndex + 1);
1484
+ if (domain === "googlemail.com") domain = "gmail.com";
1485
+ if (PLUS_ADDRESSING_DOMAINS.has(domain)) {
1486
+ const plusIndex = localPart.indexOf("+");
1487
+ if (plusIndex !== -1) localPart = localPart.slice(0, plusIndex);
1488
+ }
1489
+ if (GMAIL_LIKE_DOMAINS.has(domain)) localPart = localPart.replace(/\./g, "");
1490
+ return `${localPart}@${domain}`;
1491
+ }
1492
+ function createEmailValidator(options = {}) {
1493
+ const { apiKey = "", apiUrl = INFRA_API_URL, kvUrl = INFRA_KV_URL, emailValidationOptions = {} } = options;
1494
+ const $api = createFetch({
1495
+ baseURL: apiUrl,
1496
+ headers: { "x-api-key": apiKey }
1497
+ });
1498
+ const $kv = createFetch({
1499
+ baseURL: kvUrl,
1500
+ headers: { "x-api-key": apiKey },
1501
+ timeout: KV_TIMEOUT_MS
1502
+ });
1503
+ /**
1504
+ * Fetch and resolve email validity policy from API with caching
1505
+ * Sends client config to API which merges with user's dashboard settings
1506
+ */
1507
+ async function fetchPolicy() {
1508
+ try {
1509
+ const { data } = await $api("/security/resolve-policy", {
1510
+ method: "POST",
1511
+ body: {
1512
+ policyId: "email_validity",
1513
+ config: { emailValidation: {
1514
+ enabled: emailValidationOptions.enabled,
1515
+ strictness: emailValidationOptions.strictness,
1516
+ action: emailValidationOptions.action,
1517
+ domainAllowlist: emailValidationOptions.domainAllowlist
1518
+ } }
1519
+ }
1520
+ });
1521
+ if (data?.policy) return data.policy;
1522
+ } catch (error) {
1523
+ logger.warn("[Dash] Failed to fetch email policy, using defaults:", error);
1524
+ }
1525
+ return null;
1526
+ }
1527
+ return { async validate(email, checkMx = true) {
1528
+ const trimmed = email.trim();
1529
+ const policy = await fetchPolicy();
1530
+ if (!policy?.enabled) return {
1531
+ valid: true,
1532
+ disposable: false,
1533
+ confidence: "high",
1534
+ policy
1535
+ };
1536
+ try {
1537
+ const { data } = await $kv("/email/validate", {
1538
+ method: "POST",
1539
+ body: {
1540
+ email: trimmed,
1541
+ checkMx,
1542
+ strictness: policy.strictness
1543
+ }
1544
+ });
1545
+ return {
1546
+ ...data || {
1547
+ valid: false,
1548
+ reason: "invalid_format"
1549
+ },
1550
+ policy
1551
+ };
1552
+ } catch (error) {
1553
+ logger.warn("[Dash] Email validation API error, falling back to allow:", error);
1554
+ return {
1555
+ valid: true,
1556
+ policy
1557
+ };
1558
+ }
1559
+ } };
1560
+ }
1561
+ /**
1562
+ * Basic local email format validation (fallback)
1563
+ */
1564
+ function isValidEmailFormatLocal(email) {
1565
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return false;
1566
+ if (email.length > 254) return false;
1567
+ const [localPart, domain] = email.split("@");
1568
+ if (!localPart || !domain) return false;
1569
+ if (localPart.length > 64) return false;
1570
+ if (domain.length > 253) return false;
1571
+ return true;
1572
+ }
1573
+ const getEmail = (ctx) => {
1574
+ if (ctx.path === "/change-email") return {
1575
+ email: ctx.body?.newEmail,
1576
+ container: "body",
1577
+ field: "newEmail"
1578
+ };
1579
+ const body = ctx.body;
1580
+ const query = ctx.query;
1581
+ return {
1582
+ email: body?.email ?? query?.email,
1583
+ container: body ? "body" : "query",
1584
+ field: "email"
1585
+ };
1586
+ };
1587
+ /**
1588
+ * Create email normalization hook (shared between all configurations)
1589
+ */
1590
+ function createEmailNormalizationHook() {
1591
+ return {
1592
+ matcher: allEmail,
1593
+ handler: createAuthMiddleware(async (ctx) => {
1594
+ const { email, container, field } = getEmail(ctx);
1595
+ if (typeof email !== "string") return;
1596
+ const normalized = normalizeEmail(email, ctx.context);
1597
+ if (normalized === email) return;
1598
+ const data = container === "query" ? {
1599
+ ...ctx.query,
1600
+ [field]: normalized
1601
+ } : {
1602
+ ...ctx.body,
1603
+ [field]: normalized
1604
+ };
1605
+ return { context: {
1606
+ ...ctx,
1607
+ [container]: data
1608
+ } };
1609
+ })
1610
+ };
1611
+ }
1612
+ /**
1613
+ * Create email validation hook with configurable validation strategy
1614
+ */
1615
+ function createEmailValidationHook(validator, onDisposableEmail) {
1616
+ return {
1617
+ matcher: allEmail,
1618
+ handler: createAuthMiddleware(async (ctx) => {
1619
+ const { email } = getEmail(ctx);
1620
+ if (typeof email !== "string") return;
1621
+ const trimmed = email.trim();
1622
+ if (!isValidEmailFormatLocal(trimmed)) throw new APIError$1("BAD_REQUEST", { message: "Invalid email" });
1623
+ if (validator) {
1624
+ const result = await validator.validate(trimmed);
1625
+ const policy = result.policy;
1626
+ if (!policy?.enabled) return;
1627
+ if (policy.domainAllowlist?.length) {
1628
+ const domain = trimmed.toLowerCase().split("@")[1];
1629
+ if (domain && policy.domainAllowlist.includes(domain)) return;
1630
+ }
1631
+ const action = policy.action;
1632
+ if (!result.valid) {
1633
+ if ((result.disposable || result.reason === "no_mx_records" || result.reason === "blocklist" || result.reason === "known_invalid_email" || result.reason === "known_invalid_host" || result.reason === "reserved_tld" || result.reason === "provider_local_too_short") && onDisposableEmail) {
1634
+ const ip = ctx.request?.headers?.get("x-forwarded-for")?.split(",")[0] || ctx.request?.headers?.get("cf-connecting-ip") || void 0;
1635
+ onDisposableEmail({
1636
+ email: trimmed,
1637
+ reason: result.reason || "disposable",
1638
+ confidence: result.confidence,
1639
+ ip,
1640
+ path: ctx.path,
1641
+ action
1642
+ });
1643
+ }
1644
+ if (action === "allow") return;
1645
+ throw new APIError$1("BAD_REQUEST", { message: result.reason === "no_mx_records" ? "This email domain cannot receive emails" : result.disposable || result.reason === "blocklist" ? "Disposable email addresses are not allowed" : result.reason === "known_invalid_email" || result.reason === "known_invalid_host" || result.reason === "reserved_tld" || result.reason === "provider_local_too_short" ? "This email address appears to be invalid" : "Invalid email" });
1857
1646
  }
1858
- });
1859
- if (result.success) logger.info(`[Dash] Stale account admin notification sent to: ${adminEmail}`);
1860
- else logger.error(`[Dash] Failed to send stale account admin notification: ${result.error}`);
1861
- },
1862
- async checkUnknownDevice(_userId, _visitorId) {
1863
- return false;
1864
- },
1865
- async notifyUnknownDevice(userId, email, identification) {
1866
- logEvent({
1867
- type: "unknown_device",
1868
- userId,
1869
- visitorId: identification?.visitorId || null,
1870
- ip: identification?.ip || null,
1871
- country: identification?.location?.country?.code || null,
1872
- details: {
1873
- email,
1874
- device: identification?.browser.device,
1875
- os: identification?.browser.os,
1876
- browser: identification?.browser.name,
1877
- city: identification?.location?.city,
1878
- country: identification?.location?.country?.name
1879
- },
1880
- action: "logged"
1881
- });
1882
- }
1647
+ }
1648
+ })
1649
+ };
1650
+ }
1651
+ /**
1652
+ * Create email validation hooks with optional API-backed validation.
1653
+ *
1654
+ * @param options - {@link EmailHooksOptions}
1655
+ *
1656
+ * @example
1657
+ * // Local validation only
1658
+ * createEmailHooks()
1659
+ *
1660
+ * @example
1661
+ * // API-backed validation
1662
+ * createEmailHooks({ useApi: true, apiKey: "your-api-key" })
1663
+ *
1664
+ * @example
1665
+ * // High strictness + API
1666
+ * createEmailHooks({
1667
+ * emailValidationOptions: { strictness: "high" },
1668
+ * useApi: true,
1669
+ * apiKey: "your-api-key",
1670
+ * })
1671
+ */
1672
+ function createEmailHooks(options = {}) {
1673
+ const { emailValidationOptions, emailNormalizationOptions, useApi = false, apiKey = "", apiUrl = INFRA_API_URL, kvUrl = INFRA_KV_URL, onDisposableEmail } = options;
1674
+ const emailConfig = {
1675
+ enabled: true,
1676
+ strictness: "medium",
1677
+ action: "block",
1678
+ ...emailValidationOptions
1883
1679
  };
1680
+ return { before: [...isEmailNormalizationEnabled({
1681
+ emailValidation: emailValidationOptions,
1682
+ emailNormalization: emailNormalizationOptions
1683
+ }) ? [createEmailNormalizationHook()] : [], ...emailConfig.enabled ? [createEmailValidationHook(useApi ? createEmailValidator({
1684
+ apiUrl,
1685
+ kvUrl,
1686
+ apiKey,
1687
+ emailValidationOptions: emailConfig
1688
+ }) : void 0, onDisposableEmail)] : []] };
1884
1689
  }
1690
+ createEmailHooks();
1691
+ //#endregion
1692
+ //#region src/validation/phone.ts
1693
+ /**
1694
+ * Common fake/test phone numbers that should be blocked
1695
+ * These are numbers commonly used in testing, movies, documentation, etc.
1696
+ */
1697
+ const INVALID_PHONE_NUMBERS = new Set([
1698
+ "+15550000000",
1699
+ "+15550001111",
1700
+ "+15550001234",
1701
+ "+15551234567",
1702
+ "+15555555555",
1703
+ "+15551111111",
1704
+ "+15550000001",
1705
+ "+15550123456",
1706
+ "+12125551234",
1707
+ "+13105551234",
1708
+ "+14155551234",
1709
+ "+12025551234",
1710
+ "+10000000000",
1711
+ "+11111111111",
1712
+ "+12222222222",
1713
+ "+13333333333",
1714
+ "+14444444444",
1715
+ "+15555555555",
1716
+ "+16666666666",
1717
+ "+17777777777",
1718
+ "+18888888888",
1719
+ "+19999999999",
1720
+ "+11234567890",
1721
+ "+10123456789",
1722
+ "+19876543210",
1723
+ "+441632960000",
1724
+ "+447700900000",
1725
+ "+447700900001",
1726
+ "+447700900123",
1727
+ "+447700900999",
1728
+ "+442079460000",
1729
+ "+442079460123",
1730
+ "+441134960000",
1731
+ "+0000000000",
1732
+ "+1000000000",
1733
+ "+123456789",
1734
+ "+1234567890",
1735
+ "+12345678901",
1736
+ "+0123456789",
1737
+ "+9876543210",
1738
+ "+11111111111",
1739
+ "+99999999999",
1740
+ "+491234567890",
1741
+ "+491111111111",
1742
+ "+33123456789",
1743
+ "+33111111111",
1744
+ "+61123456789",
1745
+ "+61111111111",
1746
+ "+81123456789",
1747
+ "+81111111111",
1748
+ "+19001234567",
1749
+ "+19761234567",
1750
+ "+1911",
1751
+ "+1411",
1752
+ "+1611",
1753
+ "+44999",
1754
+ "+44112"
1755
+ ]);
1756
+ /**
1757
+ * Patterns that indicate fake/test phone numbers
1758
+ */
1759
+ const INVALID_PHONE_PATTERNS = [
1760
+ /^\+\d(\d)\1{6,}$/,
1761
+ /^\+\d*1234567890/,
1762
+ /^\+\d*0123456789/,
1763
+ /^\+\d*9876543210/,
1764
+ /^\+\d*0987654321/,
1765
+ /^\+\d*(12){4,}/,
1766
+ /^\+\d*(21){4,}/,
1767
+ /^\+\d*(00){4,}/,
1768
+ /^\+1\d{3}555\d{4}$/,
1769
+ /^\+\d{1,3}\d{1,5}$/,
1770
+ /^\+\d+0{7,}$/,
1771
+ /^\+\d*147258369/,
1772
+ /^\+\d*258369147/,
1773
+ /^\+\d*369258147/,
1774
+ /^\+\d*789456123/,
1775
+ /^\+\d*123456789/,
1776
+ /^\+\d*1234512345/,
1777
+ /^\+\d*1111122222/,
1778
+ /^\+\d*1212121212/,
1779
+ /^\+\d*1010101010/
1780
+ ];
1781
+ /**
1782
+ * Invalid area codes / prefixes that indicate test numbers
1783
+ * Key: country code, Value: set of invalid prefixes
1784
+ */
1785
+ const INVALID_PREFIXES_BY_COUNTRY = {
1786
+ US: new Set([
1787
+ "555",
1788
+ "000",
1789
+ "111",
1790
+ "911",
1791
+ "411",
1792
+ "611"
1793
+ ]),
1794
+ CA: new Set([
1795
+ "555",
1796
+ "000",
1797
+ "911"
1798
+ ]),
1799
+ GB: new Set([
1800
+ "7700900",
1801
+ "1632960",
1802
+ "1134960"
1803
+ ]),
1804
+ AU: new Set([
1805
+ "0491570",
1806
+ "0491571",
1807
+ "0491572"
1808
+ ])
1809
+ };
1810
+ /**
1811
+ * Check if a phone number is a commonly used fake/test number
1812
+ * @param phone - The phone number to check (E.164 format preferred)
1813
+ * @param defaultCountry - Default country code if not included in phone string
1814
+ * @returns true if the phone appears to be fake/test, false if it seems legitimate
1815
+ */
1816
+ const isFakePhoneNumber = (phone, defaultCountry) => {
1817
+ const parsed = parsePhoneNumberFromString(phone, defaultCountry);
1818
+ if (!parsed) return true;
1819
+ const e164 = parsed.number;
1820
+ const nationalNumber = parsed.nationalNumber;
1821
+ const country = parsed.country;
1822
+ if (INVALID_PHONE_NUMBERS.has(e164)) return true;
1823
+ for (const pattern of INVALID_PHONE_PATTERNS) if (pattern.test(e164)) return true;
1824
+ if (country && INVALID_PREFIXES_BY_COUNTRY[country]) {
1825
+ const prefixes = INVALID_PREFIXES_BY_COUNTRY[country];
1826
+ for (const prefix of prefixes) if (nationalNumber.startsWith(prefix)) return true;
1827
+ }
1828
+ if (/^(\d)\1+$/.test(nationalNumber)) return true;
1829
+ const digits = nationalNumber.split("").map(Number);
1830
+ let isSequential = digits.length >= 6;
1831
+ for (let i = 1; i < digits.length && isSequential; i++) {
1832
+ const current = digits[i];
1833
+ const previous = digits[i - 1];
1834
+ if (current === void 0 || previous === void 0 || current !== previous + 1 && current !== previous - 1) isSequential = false;
1835
+ }
1836
+ if (isSequential) return true;
1837
+ return false;
1838
+ };
1839
+ /**
1840
+ * Validate a phone number format
1841
+ * @param phone - The phone number to validate
1842
+ * @param defaultCountry - Default country code if not included in phone string
1843
+ * @returns true if the phone number is valid
1844
+ */
1845
+ const isValidPhone = (phone, defaultCountry) => {
1846
+ return isValidPhoneNumber(phone, defaultCountry);
1847
+ };
1848
+ /**
1849
+ * Comprehensive phone number validation
1850
+ * @param phone - The phone number to validate
1851
+ * @param options - Validation options
1852
+ * @returns true if valid, false otherwise
1853
+ */
1854
+ const validatePhone = (phone, options = {}) => {
1855
+ const { mobileOnly = false, allowedCountries, blockedCountries, blockFakeNumbers = true, blockPremiumRate = true, blockTollFree = false, blockVoip = false, defaultCountry } = options;
1856
+ if (!isValidPhone(phone, defaultCountry)) return false;
1857
+ const parsed = parsePhoneNumberFromString(phone, defaultCountry);
1858
+ if (!parsed) return false;
1859
+ if (blockFakeNumbers && isFakePhoneNumber(phone, defaultCountry)) return false;
1860
+ const country = parsed.country;
1861
+ if (country) {
1862
+ if (allowedCountries && !allowedCountries.includes(country)) return false;
1863
+ if (blockedCountries?.includes(country)) return false;
1864
+ }
1865
+ const phoneType = parsed.getType();
1866
+ if (mobileOnly) {
1867
+ if (phoneType !== "MOBILE" && phoneType !== "FIXED_LINE_OR_MOBILE") return false;
1868
+ }
1869
+ if (blockPremiumRate && phoneType === "PREMIUM_RATE") return false;
1870
+ if (blockTollFree && phoneType === "TOLL_FREE") return false;
1871
+ if (blockVoip && phoneType === "VOIP") return false;
1872
+ return true;
1873
+ };
1874
+ const allPhonePaths = new Set([
1875
+ "/phone-number/send-otp",
1876
+ "/phone-number/verify",
1877
+ "/sign-in/phone-number",
1878
+ "/phone-number/request-password-reset",
1879
+ "/phone-number/reset-password"
1880
+ ]);
1881
+ const getPhoneNumber = (ctx) => ctx.body?.phoneNumber ?? ctx.query?.phoneNumber;
1882
+ /**
1883
+ * Better Auth plugin for phone number validation
1884
+ * Validates phone numbers on all phone-related endpoints
1885
+ */
1886
+ const phoneValidationHooks = { before: [{
1887
+ matcher: (context) => !!context.path && allPhonePaths.has(context.path),
1888
+ handler: createAuthMiddleware(async (ctx) => {
1889
+ const phoneNumber = getPhoneNumber(ctx);
1890
+ if (typeof phoneNumber !== "string") return;
1891
+ if (!validatePhone(phoneNumber)) throw new APIError$1("BAD_REQUEST", { message: "Invalid phone number" });
1892
+ })
1893
+ }] };
1885
1894
  //#endregion
1886
1895
  //#region src/sentinel/security-hooks.ts
1887
1896
  const ERROR_MESSAGES = {
@@ -2024,11 +2033,12 @@ const sentinel = (options) => {
2024
2033
  });
2025
2034
  });
2026
2035
  const emailHooks = createEmailHooks({
2036
+ emailValidationOptions: opts.security?.emailValidation,
2037
+ emailNormalizationOptions: opts.security?.emailNormalization,
2027
2038
  useApi: !!opts.apiKey,
2028
2039
  apiKey: opts.apiKey,
2029
2040
  apiUrl: opts.apiUrl,
2030
2041
  kvUrl: opts.kvUrl,
2031
- defaultConfig: opts.security?.emailValidation,
2032
2042
  onDisposableEmail: (data) => {
2033
2043
  const isNoMxRecord = data.reason === "no_mx_records";
2034
2044
  const reason = isNoMxRecord ? "no_mx_records" : "disposable_email";
@@ -2061,6 +2071,7 @@ const sentinel = (options) => {
2061
2071
  const activityTrackingEnabled = (ctx.getPlugin("dash")?.options)?.activityTracking?.enabled === true;
2062
2072
  return { options: {
2063
2073
  emailValidation: opts.security?.emailValidation,
2074
+ emailNormalization: opts.security?.emailNormalization,
2064
2075
  databaseHooks: {
2065
2076
  user: { create: {
2066
2077
  async before(user, ctx) {
@@ -2070,7 +2081,7 @@ const sentinel = (options) => {
2070
2081
  const abuseCheck = await securityService.checkFreeTrialAbuse(visitorId);
2071
2082
  if (abuseCheck.isAbuse && abuseCheck.action === "block") throw new APIError("FORBIDDEN", { message: "Account creation is not allowed from this device." });
2072
2083
  }
2073
- if (user.email && typeof user.email === "string" && opts.security?.emailValidation?.enabled !== false) return { data: {
2084
+ if (user.email && typeof user.email === "string" && isEmailNormalizationEnabled(opts.security)) return { data: {
2074
2085
  ...user,
2075
2086
  email: normalizeEmail(user.email, ctx.context)
2076
2087
  } };
@@ -2746,7 +2757,7 @@ const jwtValidateMiddleware = (options) => createAuthMiddleware(async (ctx) => {
2746
2757
  });
2747
2758
  //#endregion
2748
2759
  //#region src/version.ts
2749
- const PLUGIN_VERSION = "0.2.2";
2760
+ const PLUGIN_VERSION = "0.2.3";
2750
2761
  //#endregion
2751
2762
  //#region src/routes/auth/config.ts
2752
2763
  const PLUGIN_OPTIONS_EXCLUDE_KEYS = { stripe: new Set(["stripeClient"]) };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@better-auth/infra",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Dashboard and analytics plugin for Better Auth",
5
5
  "type": "module",
6
6
  "main": "dist/index.mjs",
@@ -70,7 +70,7 @@
70
70
  "expo-crypto": "^14.0.2",
71
71
  "happy-dom": "^20.8.9",
72
72
  "msw": "^2.13.0",
73
- "tsdown": "^0.21.1",
73
+ "tsdown": "^0.21.8",
74
74
  "typescript": "catalog:",
75
75
  "zod": "catalog:"
76
76
  },