@better-auth/infra 0.1.13 → 0.2.0

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.mjs CHANGED
@@ -9,7 +9,6 @@ import { isValidPhoneNumber, parsePhoneNumberFromString } from "libphonenumber-j
9
9
  import { createLocalJWKSet, jwtVerify } from "jose";
10
10
  import z$1, { z } from "zod";
11
11
  import { setSessionCookie } from "better-auth/cookies";
12
- import { DEFAULT_MAX_SAML_METADATA_SIZE, DigestAlgorithm, DiscoveryError, SignatureAlgorithm, discoverOIDCConfig } from "@better-auth/sso";
13
12
  //#region src/options.ts
14
13
  function resolveConnectionOptions(options) {
15
14
  return {
@@ -128,6 +127,11 @@ const ORGANIZATION_EVENT_TYPES = {
128
127
  ORGANIZATION_TEAM_MEMBER_ADDED: "organization_team_member_added",
129
128
  ORGANIZATION_TEAM_MEMBER_REMOVED: "organization_team_member_removed"
130
129
  };
130
+ /** All audit event type string constants (user + organization). */
131
+ const USER_EVENT_TYPES = {
132
+ ...EVENT_TYPES,
133
+ ...ORGANIZATION_EVENT_TYPES
134
+ };
131
135
  //#endregion
132
136
  //#region src/events/core/adapter.ts
133
137
  const getUserByEmail = async (email, ctx) => {
@@ -993,1020 +997,1011 @@ function getCountryCodeFromRequest(request) {
993
997
  return cc ? cc.toUpperCase() : void 0;
994
998
  }
995
999
  //#endregion
996
- //#region src/security.ts
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
+ ]);
997
1065
  /**
998
- * Security Client
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
999
1071
  *
1000
- * Thin client that forwards security checks to the Infra API
1072
+ * @param email - Raw email to normalize
1073
+ * @param context - Auth context with getPlugin (for sentinel policy)
1001
1074
  */
1002
- async function hashForFingerprint(input) {
1003
- const data = new TextEncoder().encode(input);
1004
- const hashBuffer = await crypto.subtle.digest("SHA-256", data);
1005
- return Array.from(new Uint8Array(hashBuffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
1006
- }
1007
- async function sha1Hash(input) {
1008
- const data = new TextEncoder().encode(input);
1009
- const hashBuffer = await crypto.subtle.digest("SHA-1", data);
1010
- return Array.from(new Uint8Array(hashBuffer)).map((b) => b.toString(16).padStart(2, "0")).join("").toUpperCase();
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}`;
1011
1090
  }
1012
- function createSecurityClient(apiUrl, apiKey, options, onSecurityEvent) {
1013
- const resolvedApiUrl = apiUrl || INFRA_API_URL;
1091
+ function createEmailValidator(options = {}) {
1092
+ const { apiKey = "", apiUrl = INFRA_API_URL, kvUrl = INFRA_KV_URL, defaultConfig = {} } = options;
1014
1093
  const $api = createFetch({
1015
- baseURL: resolvedApiUrl,
1016
- headers: { "x-api-key": apiKey },
1017
- throw: true
1094
+ baseURL: apiUrl,
1095
+ headers: { "x-api-key": apiKey }
1018
1096
  });
1019
- const emailSender = createEmailSender({
1020
- apiUrl: resolvedApiUrl,
1021
- apiKey
1097
+ const $kv = createFetch({
1098
+ baseURL: kvUrl,
1099
+ headers: { "x-api-key": apiKey },
1100
+ timeout: KV_TIMEOUT_MS
1022
1101
  });
1023
- function logEvent(event) {
1024
- const fullEvent = {
1025
- ...event,
1026
- timestamp: Date.now()
1027
- };
1028
- if (onSecurityEvent) onSecurityEvent(fullEvent);
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;
1029
1125
  }
1030
- return {
1031
- async checkSecurity(request) {
1032
- try {
1033
- const data = await $api("/security/check", {
1034
- method: "POST",
1035
- body: {
1036
- ...request,
1037
- config: options
1038
- }
1039
- });
1040
- if (data.action !== "allow") logEvent({
1041
- type: this.mapReasonToEventType(data.reason),
1042
- userId: null,
1043
- visitorId: request.visitorId,
1044
- ip: request.ip,
1045
- country: null,
1046
- details: data.details || { reason: data.reason },
1047
- action: data.action === "block" ? "blocked" : "challenged"
1048
- });
1049
- return data;
1050
- } catch (error) {
1051
- logger.error("[Dash] Security check failed:", error);
1052
- return { action: "allow" };
1053
- }
1054
- },
1055
- mapReasonToEventType(reason) {
1056
- switch (reason) {
1057
- case "geo_blocked": return "geo_blocked";
1058
- case "bot_detected": return "bot_blocked";
1059
- case "suspicious_ip_detected": return "suspicious_ip_detected";
1060
- case "rate_limited": return "velocity_exceeded";
1061
- case "credential_stuffing_cooldown": return "credential_stuffing";
1062
- default: return "credential_stuffing";
1063
- }
1064
- },
1065
- async trackFailedAttempt(identifier, visitorId, password, ip) {
1066
- try {
1067
- const data = await $api("/security/track-failed-login", {
1068
- method: "POST",
1069
- body: {
1070
- identifier,
1071
- visitorId,
1072
- passwordHash: await hashForFingerprint(password),
1073
- ip,
1074
- config: options
1075
- }
1076
- });
1077
- if (data.blocked || data.challenged) logEvent({
1078
- type: "credential_stuffing",
1079
- userId: null,
1080
- visitorId,
1081
- ip,
1082
- country: null,
1083
- details: data.details || { reason: data.reason },
1084
- action: data.blocked ? "blocked" : "challenged"
1085
- });
1086
- return data;
1087
- } catch (error) {
1088
- logger.error("[Dash] Track failed attempt error:", error);
1089
- return { blocked: false };
1090
- }
1091
- },
1092
- async clearFailedAttempts(identifier) {
1093
- try {
1094
- await $api("/security/clear-failed-attempts", {
1095
- method: "POST",
1096
- body: { identifier }
1097
- });
1098
- } catch (error) {
1099
- logger.error("[Dash] Clear failed attempts error:", error);
1100
- }
1101
- },
1102
- async isBlocked(visitorId) {
1103
- try {
1104
- return (await $api(`/security/is-blocked?visitorId=${encodeURIComponent(visitorId)}`, { method: "GET" })).blocked ?? false;
1105
- } catch (error) {
1106
- logger.warn("[Dash] Security is-blocked check failed:", error);
1107
- return false;
1108
- }
1109
- },
1110
- async verifyPoWSolution(visitorId, solution) {
1111
- try {
1112
- return await $api("/security/pow/verify", {
1113
- method: "POST",
1114
- body: {
1115
- visitorId,
1116
- solution
1117
- }
1118
- });
1119
- } catch (error) {
1120
- logger.warn("[Dash] PoW verify failed:", error);
1121
- return {
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
1134
+ };
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 || {
1122
1146
  valid: false,
1123
- reason: "error"
1124
- };
1125
- }
1126
- },
1127
- async generateChallenge(visitorId) {
1128
- try {
1129
- return (await $api("/security/pow/generate", {
1130
- method: "POST",
1131
- body: {
1132
- visitorId,
1133
- difficulty: options.challengeDifficulty
1134
- }
1135
- })).challenge || "";
1136
- } catch (error) {
1137
- logger.warn("[Dash] PoW generate challenge failed:", error);
1138
- return "";
1139
- }
1140
- },
1141
- async checkImpossibleTravel(userId, currentLocation, visitorId) {
1142
- if (!options.impossibleTravel?.enabled || !currentLocation) return null;
1143
- try {
1144
- const data = await $api("/security/impossible-travel", {
1145
- method: "POST",
1146
- body: {
1147
- userId,
1148
- visitorId,
1149
- location: currentLocation,
1150
- config: options
1151
- }
1152
- });
1153
- if (data.isImpossible) {
1154
- const actionTaken = data.action === "block" ? "blocked" : data.action === "challenge" ? "challenged" : "logged";
1155
- logEvent({
1156
- type: "impossible_travel",
1157
- userId,
1158
- visitorId: visitorId || null,
1159
- ip: null,
1160
- country: currentLocation.country?.code || null,
1161
- details: {
1162
- from: data.from,
1163
- to: data.to,
1164
- distance: data.distance,
1165
- speedRequired: data.speedRequired,
1166
- action: data.action
1167
- },
1168
- action: actionTaken
1169
- });
1170
- }
1171
- return data;
1172
- } catch (error) {
1173
- logger.warn("[Dash] Impossible travel check failed:", error);
1174
- return null;
1175
- }
1176
- },
1177
- async storeLastLocation(userId, location) {
1178
- if (!location) return;
1179
- try {
1180
- await $api("/security/store-last-login", {
1181
- method: "POST",
1182
- body: {
1183
- userId,
1184
- location
1185
- }
1186
- });
1187
- } catch (error) {
1188
- logger.error("[Dash] Store last location error:", error);
1189
- }
1190
- },
1191
- async checkFreeTrialAbuse(visitorId) {
1192
- if (!options.freeTrialAbuse?.enabled) return {
1193
- isAbuse: false,
1194
- accountCount: 0,
1195
- maxAccounts: 0,
1196
- action: "log"
1197
- };
1198
- try {
1199
- const data = await $api("/security/free-trial-abuse/check", {
1200
- method: "POST",
1201
- body: {
1202
- visitorId,
1203
- config: options
1204
- }
1205
- });
1206
- if (data.isAbuse) logEvent({
1207
- type: "free_trial_abuse",
1208
- userId: null,
1209
- visitorId,
1210
- ip: null,
1211
- country: null,
1212
- details: {
1213
- accountCount: data.accountCount,
1214
- maxAccounts: data.maxAccounts
1215
- },
1216
- action: data.action === "block" ? "blocked" : "logged"
1217
- });
1218
- return data;
1219
- } catch (error) {
1220
- logger.warn("[Dash] Free trial abuse check failed:", error);
1221
- return {
1222
- isAbuse: false,
1223
- accountCount: 0,
1224
- maxAccounts: 0,
1225
- action: "log"
1226
- };
1227
- }
1228
- },
1229
- async trackFreeTrialSignup(visitorId, userId) {
1230
- if (!options.freeTrialAbuse?.enabled) return;
1231
- try {
1232
- await $api("/security/free-trial-abuse/track", {
1233
- method: "POST",
1234
- body: {
1235
- visitorId,
1236
- userId
1237
- }
1238
- });
1239
- } catch (error) {
1240
- logger.error("[Dash] Track free trial signup error:", error);
1241
- }
1242
- },
1243
- async checkCompromisedPassword(password) {
1244
- try {
1245
- const hash = await sha1Hash(password);
1246
- const prefix = hash.substring(0, 5);
1247
- const suffix = hash.substring(5);
1248
- const data = await $api("/security/breached-passwords", {
1249
- method: "POST",
1250
- body: {
1251
- passwordPrefix: prefix,
1252
- config: options
1253
- }
1254
- });
1255
- if (!data.enabled) return { compromised: false };
1256
- const breachCount = (data.suffixes || {})[suffix] || 0;
1257
- const minBreachCount = data.minBreachCount ?? 1;
1258
- const action = data.action || "block";
1259
- const compromised = breachCount >= minBreachCount;
1260
- if (compromised) logEvent({
1261
- type: "compromised_password",
1262
- userId: null,
1263
- visitorId: null,
1264
- ip: null,
1265
- country: null,
1266
- details: { breachCount },
1267
- action: action === "block" ? "blocked" : action === "challenge" ? "challenged" : "logged"
1268
- });
1269
- return {
1270
- compromised,
1271
- breachCount: breachCount > 0 ? breachCount : void 0,
1272
- action: compromised ? action : void 0
1273
- };
1274
- } catch (error) {
1275
- logger.error("[Dash] Compromised password check error:", error);
1276
- return { compromised: false };
1277
- }
1278
- },
1279
- async checkStaleUser(userId, lastActiveAt) {
1280
- if (!options.staleUsers?.enabled) return { isStale: false };
1281
- try {
1282
- const data = await $api("/security/stale-user", {
1283
- method: "POST",
1284
- body: {
1285
- userId,
1286
- lastActiveAt: lastActiveAt instanceof Date ? lastActiveAt.toISOString() : lastActiveAt,
1287
- config: options
1288
- }
1289
- });
1290
- if (data.isStale) logEvent({
1291
- type: "stale_account_reactivation",
1292
- userId,
1293
- visitorId: null,
1294
- ip: null,
1295
- country: null,
1296
- details: {
1297
- daysSinceLastActive: data.daysSinceLastActive,
1298
- staleDays: data.staleDays,
1299
- lastActiveAt: data.lastActiveAt,
1300
- notifyUser: data.notifyUser,
1301
- notifyAdmin: data.notifyAdmin
1302
- },
1303
- action: data.action === "block" ? "blocked" : data.action === "challenge" ? "challenged" : "logged"
1304
- });
1305
- return data;
1306
- } catch (error) {
1307
- logger.error("[Dash] Stale user check error:", error);
1308
- return { isStale: false };
1309
- }
1310
- },
1311
- async notifyStaleAccountUser(userEmail, userName, daysSinceLastActive, identification, appName) {
1312
- const loginTime = (/* @__PURE__ */ new Date()).toLocaleString("en-US", {
1313
- dateStyle: "long",
1314
- timeStyle: "short",
1315
- timeZone: "UTC"
1316
- }) + " UTC";
1317
- const location = identification?.location;
1318
- const loginLocation = location?.city && location?.country?.name ? `${location.city}, ${location.country.code}` : location?.country?.name || "Unknown";
1319
- const browser = identification?.browser;
1320
- const loginDevice = browser?.name && browser?.os ? `${browser.name} on ${browser.os}` : "Unknown device";
1321
- const result = await emailSender.send({
1322
- template: "stale-account-user",
1323
- to: userEmail,
1324
- variables: {
1325
- userEmail,
1326
- userName: userName || "User",
1327
- appName: appName || "Your App",
1328
- daysSinceLastActive: String(daysSinceLastActive),
1329
- loginTime,
1330
- loginLocation,
1331
- loginDevice,
1332
- loginIp: identification?.ip || "Unknown"
1333
- }
1334
- });
1335
- if (result.success) logger.info(`[Dash] Stale account notification sent to user: ${userEmail}`);
1336
- else logger.error(`[Dash] Failed to send stale account user notification: ${result.error}`);
1337
- },
1338
- async notifyStaleAccountAdmin(adminEmail, userId, userEmail, userName, daysSinceLastActive, identification, appName) {
1339
- const loginTime = (/* @__PURE__ */ new Date()).toLocaleString("en-US", {
1340
- dateStyle: "long",
1341
- timeStyle: "short",
1342
- timeZone: "UTC"
1343
- }) + " UTC";
1344
- const location = identification?.location;
1345
- const loginLocation = location?.city && location?.country?.name ? `${location.city}, ${location.country.code}` : location?.country?.name || "Unknown";
1346
- const browser = identification?.browser;
1347
- const loginDevice = browser?.name && browser?.os ? `${browser.name} on ${browser.os}` : "Unknown device";
1348
- const result = await emailSender.send({
1349
- template: "stale-account-admin",
1350
- to: adminEmail,
1351
- variables: {
1352
- userEmail,
1353
- userName: userName || "User",
1354
- userId,
1355
- appName: appName || "Your App",
1356
- daysSinceLastActive: String(daysSinceLastActive),
1357
- loginTime,
1358
- loginLocation,
1359
- loginDevice,
1360
- loginIp: identification?.ip || "Unknown",
1361
- adminEmail
1362
- }
1363
- });
1364
- if (result.success) logger.info(`[Dash] Stale account admin notification sent to: ${adminEmail}`);
1365
- else logger.error(`[Dash] Failed to send stale account admin notification: ${result.error}`);
1366
- },
1367
- async checkUnknownDevice(_userId, _visitorId) {
1368
- return false;
1369
- },
1370
- async notifyUnknownDevice(userId, email, identification) {
1371
- logEvent({
1372
- type: "unknown_device",
1373
- userId,
1374
- visitorId: identification?.visitorId || null,
1375
- ip: identification?.ip || null,
1376
- country: identification?.location?.country?.code || null,
1377
- details: {
1378
- email,
1379
- device: identification?.browser.device,
1380
- os: identification?.browser.os,
1381
- browser: identification?.browser.name,
1382
- city: identification?.location?.city,
1383
- country: identification?.location?.country?.name
1147
+ reason: "invalid_format"
1384
1148
  },
1385
- action: "logged"
1386
- });
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
+ };
1387
1157
  }
1388
- };
1158
+ } };
1389
1159
  }
1390
- //#endregion
1391
- //#region src/security-hooks.ts
1392
1160
  /**
1393
- * Security Hooks
1394
- *
1395
- * Consolidated security check logic for the sentinel plugin.
1396
- * This module extracts the repetitive security check patterns into reusable functions.
1161
+ * Basic local email format validation (fallback)
1397
1162
  */
1398
- const ERROR_MESSAGES = {
1399
- geo_blocked: "Access from your location is not allowed.",
1400
- bot_detected: "Automated access is not allowed.",
1401
- suspicious_ip_detected: "Anonymous connections (VPN, proxy, Tor) are not allowed.",
1402
- rate_limited: "Too many attempts. Please try again later.",
1403
- compromised_password: "This password has been found in data breaches. Please choose a different password.",
1404
- impossible_travel: "Login blocked due to suspicious location change."
1405
- };
1406
- const DISPLAY_NAMES = {
1407
- geo_blocked: {
1408
- challenge: "Security: geo challenge",
1409
- block: "Security: geo blocked"
1410
- },
1411
- bot_detected: {
1412
- challenge: "Security: bot challenge",
1413
- block: "Security: bot blocked"
1414
- },
1415
- suspicious_ip_detected: {
1416
- challenge: "Security: anonymous IP challenge",
1417
- block: "Security: anonymous IP blocked"
1418
- },
1419
- rate_limited: {
1420
- challenge: "Security: velocity challenge",
1421
- block: "Security: velocity exceeded"
1422
- },
1423
- compromised_password: {
1424
- challenge: "Security: breached password warning",
1425
- block: "Security: breached password blocked"
1426
- },
1427
- impossible_travel: {
1428
- challenge: "Security: impossible travel challenge",
1429
- block: "Security: impossible travel blocked"
1430
- }
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
+ };
1431
1185
  };
1432
1186
  /**
1433
- * Throw a challenge error with appropriate headers
1187
+ * Create email normalization hook (shared between all configurations)
1434
1188
  */
1435
- function throwChallengeError(challenge, reason, message = "Please complete a security check to continue.") {
1436
- const error = new APIError("LOCKED", {
1437
- message,
1438
- code: "POW_CHALLENGE_REQUIRED"
1439
- });
1440
- error.headers = {
1441
- "X-PoW-Challenge": challenge,
1442
- "X-PoW-Reason": reason
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
+ })
1443
1209
  };
1444
- throw error;
1445
1210
  }
1446
1211
  /**
1447
- * Build common event data for security tracking
1212
+ * Create email validation hook with configurable validation strategy
1448
1213
  */
1449
- function buildEventData(ctx, action, reason, confidence = 1, extraData) {
1450
- const { visitorId, identification, path, identifier, userAgent } = ctx;
1451
- const countryCode = identification?.location?.country?.code || void 0;
1214
+ function createEmailValidationHook(validator, onDisposableEmail) {
1452
1215
  return {
1453
- eventKey: visitorId || identification?.ip || "unknown",
1454
- eventType: action === "challenged" ? "security_challenged" : "security_blocked",
1455
- eventDisplayName: DISPLAY_NAMES[reason]?.[action === "challenged" ? "challenge" : "block"] || `Security: ${reason}`,
1456
- eventData: {
1457
- action,
1458
- reason,
1459
- visitorId: visitorId || "",
1460
- path,
1461
- userAgent,
1462
- identifier,
1463
- confidence,
1464
- ...extraData
1465
- },
1466
- ipAddress: identification?.ip || void 0,
1467
- city: identification?.location?.city || void 0,
1468
- country: identification?.location?.country?.name || void 0,
1469
- countryCode
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
1470
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)] };
1471
1290
  }
1291
+ createEmailHooks();
1292
+ //#endregion
1293
+ //#region src/validation/phone.ts
1472
1294
  /**
1473
- * Handle a security check result by tracking events and throwing appropriate errors
1474
- *
1475
- * @param verdict - The security verdict from the security service
1476
- * @param ctx - Security check context with request information
1477
- * @param trackEvent - Function to track security events
1478
- * @param securityService - Security service for generating challenges
1479
- * @returns True if the request should be blocked
1295
+ * Common fake/test phone numbers that should be blocked
1296
+ * These are numbers commonly used in testing, movies, documentation, etc.
1480
1297
  */
1481
- async function handleSecurityVerdict(verdict, ctx, trackEvent, securityService) {
1482
- if (verdict.action === "allow") return;
1483
- const reason = verdict.reason || "unknown";
1484
- const confidence = 1;
1485
- if (verdict.action === "challenge" && ctx.visitorId) {
1486
- const challenge = verdict.challenge || await securityService.generateChallenge(ctx.visitorId);
1487
- if (!challenge?.trim()) {
1488
- logger.warn("[Sentinel] Could not generate PoW challenge (service may be unavailable). Falling back to allow.");
1489
- return;
1490
- }
1491
- trackEvent(buildEventData(ctx, "challenged", reason, confidence, verdict.details));
1492
- throwChallengeError(challenge, reason, "Please complete a security check to continue.");
1493
- } else if (verdict.action === "block") {
1494
- trackEvent(buildEventData(ctx, "blocked", reason, confidence, verdict.details));
1495
- throw new APIError("FORBIDDEN", { message: ERROR_MESSAGES[reason] || "Access denied." });
1496
- }
1497
- }
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
+ ]);
1498
1357
  /**
1499
- * Run all security checks using the consolidated checkSecurity API
1500
- *
1501
- * This replaces the multiple individual check calls with a single API call
1502
- * that handles all security checks server-side.
1358
+ * Patterns that indicate fake/test phone numbers
1503
1359
  */
1504
- async function runSecurityChecks(ctx, securityService, trackEvent, powVerified) {
1505
- if (powVerified) return;
1506
- await handleSecurityVerdict(await securityService.checkSecurity({
1507
- visitorId: ctx.visitorId,
1508
- requestId: ctx.identification?.requestId || null,
1509
- ip: ctx.identification?.ip || null,
1510
- path: ctx.path,
1511
- identifier: ctx.identifier
1512
- }), ctx, trackEvent, securityService);
1513
- }
1514
- //#endregion
1515
- //#region src/validation/matchers.ts
1516
- const paths = [
1517
- "/sign-up/email",
1518
- "/email-otp/verify-email",
1519
- "/sign-in/email-otp",
1520
- "/sign-in/magic-link",
1521
- "/sign-in/email",
1522
- "/forget-password/email-otp",
1523
- "/email-otp/reset-password",
1524
- "/email-otp/create-verification-otp",
1525
- "/email-otp/get-verification-otp",
1526
- "/email-otp/send-verification-otp",
1527
- "/forget-password",
1528
- "/request-password-reset",
1529
- "/send-verification-email",
1530
- "/change-email"
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/
1531
1381
  ];
1532
- const all = new Set(paths);
1533
- new Set(paths.slice(1, 12));
1534
1382
  /**
1535
- * Path is one of `[
1536
- * '/sign-up/email',
1537
- * '/email-otp/verify-email',
1538
- * '/sign-in/email-otp',
1539
- * '/sign-in/magic-link',
1540
- * '/sign-in/email',
1541
- * '/forget-password/email-otp',
1542
- * '/email-otp/reset-password',
1543
- * '/email-otp/create-verification-otp',
1544
- * '/email-otp/get-verification-otp',
1545
- * '/email-otp/send-verification-otp',
1546
- * '/forget-password',
1547
- * '/request-password-reset',
1548
- * '/send-verification-email',
1549
- * '/change-email'
1550
- * ]`.
1551
- * @param context Request context
1552
- * @param context.path Request path
1553
- * @returns boolean
1383
+ * Invalid area codes / prefixes that indicate test numbers
1384
+ * Key: country code, Value: set of invalid prefixes
1554
1385
  */
1555
- const allEmail = ({ path }) => !!path && all.has(path);
1556
- //#endregion
1557
- //#region src/validation/email.ts
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
+ };
1558
1411
  /**
1559
- * Gmail-like providers that ignore dots in the local part
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
1560
1416
  */
1561
- const GMAIL_LIKE_DOMAINS = new Set(["gmail.com", "googlemail.com"]);
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
+ };
1562
1440
  /**
1563
- * Providers known to support plus addressing
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
1564
1445
  */
1565
- const PLUS_ADDRESSING_DOMAINS = new Set([
1566
- "gmail.com",
1567
- "googlemail.com",
1568
- "outlook.com",
1569
- "hotmail.com",
1570
- "live.com",
1571
- "yahoo.com",
1572
- "icloud.com",
1573
- "me.com",
1574
- "mac.com",
1575
- "protonmail.com",
1576
- "proton.me",
1577
- "fastmail.com",
1578
- "zoho.com"
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"
1579
1481
  ]);
1482
+ const getPhoneNumber = (ctx) => ctx.body?.phoneNumber ?? ctx.query?.phoneNumber;
1580
1483
  /**
1581
- * Normalize an email address for comparison/deduplication
1582
- * - Lowercase the entire email
1583
- * - Remove dots from Gmail-like providers (they ignore dots)
1584
- * - Remove plus addressing (user+tag@domain → user@domain)
1585
- * - Normalize googlemail.com to gmail.com
1586
- *
1587
- * @param email - Raw email to normalize
1588
- * @param context - Auth context with getPlugin (for sentinel policy). Pass undefined when context unavailable (e.g. server, hooks).
1484
+ * Better Auth plugin for phone number validation
1485
+ * Validates phone numbers on all phone-related endpoints
1589
1486
  */
1590
- function normalizeEmail(email, context) {
1591
- if (!email || typeof email !== "string") return email;
1592
- if ((context.getPlugin?.("sentinel"))?.options?.emailValidation?.enabled === false) return email;
1593
- const trimmed = email.trim().toLowerCase();
1594
- const atIndex = trimmed.lastIndexOf("@");
1595
- if (atIndex === -1) return trimmed;
1596
- let localPart = trimmed.slice(0, atIndex);
1597
- let domain = trimmed.slice(atIndex + 1);
1598
- if (domain === "googlemail.com") domain = "gmail.com";
1599
- if (PLUS_ADDRESSING_DOMAINS.has(domain)) {
1600
- const plusIndex = localPart.indexOf("+");
1601
- if (plusIndex !== -1) localPart = localPart.slice(0, plusIndex);
1602
- }
1603
- if (GMAIL_LIKE_DOMAINS.has(domain)) localPart = localPart.replace(/\./g, "");
1604
- return `${localPart}@${domain}`;
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("");
1605
1501
  }
1606
- function createEmailValidator(options = {}) {
1607
- const { apiKey = "", apiUrl = INFRA_API_URL, kvUrl = INFRA_KV_URL, defaultConfig = {} } = options;
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;
1608
1509
  const $api = createFetch({
1609
- baseURL: apiUrl,
1610
- headers: { "x-api-key": apiKey }
1611
- });
1612
- const $kv = createFetch({
1613
- baseURL: kvUrl,
1510
+ baseURL: resolvedApiUrl,
1614
1511
  headers: { "x-api-key": apiKey },
1615
- timeout: KV_TIMEOUT_MS
1512
+ throw: true
1616
1513
  });
1617
- /**
1618
- * Fetch and resolve email validity policy from API with caching
1619
- * Sends client config to API which merges with user's dashboard settings
1620
- */
1621
- async function fetchPolicy() {
1622
- try {
1623
- const { data } = await $api("/security/resolve-policy", {
1624
- method: "POST",
1625
- body: {
1626
- policyId: "email_validity",
1627
- config: { emailValidation: {
1628
- enabled: defaultConfig.enabled,
1629
- strictness: defaultConfig.strictness,
1630
- action: defaultConfig.action,
1631
- domainAllowlist: defaultConfig.domainAllowlist
1632
- } }
1633
- }
1634
- });
1635
- if (data?.policy) return data.policy;
1636
- } catch (error) {
1637
- logger.warn("[Dash] Failed to fetch email policy, using defaults:", error);
1638
- }
1639
- return null;
1640
- }
1641
- return { async validate(email, checkMx = true) {
1642
- const policy = await fetchPolicy();
1643
- if (!policy?.enabled) return {
1644
- valid: true,
1645
- disposable: false,
1646
- confidence: "high",
1647
- policy
1514
+ const emailSender = createEmailSender({
1515
+ apiUrl: resolvedApiUrl,
1516
+ apiKey
1517
+ });
1518
+ function logEvent(event) {
1519
+ const fullEvent = {
1520
+ ...event,
1521
+ timestamp: Date.now()
1648
1522
  };
1649
- try {
1650
- const { data } = await $kv("/email/validate", {
1651
- method: "POST",
1652
- body: {
1653
- email,
1654
- checkMx,
1655
- strictness: policy.strictness
1523
+ if (onSecurityEvent) onSecurityEvent(fullEvent);
1524
+ }
1525
+ return {
1526
+ async checkSecurity(request) {
1527
+ try {
1528
+ const data = await $api("/security/check", {
1529
+ method: "POST",
1530
+ body: {
1531
+ ...request,
1532
+ config: options
1533
+ }
1534
+ });
1535
+ if (data.action !== "allow") logEvent({
1536
+ type: this.mapReasonToEventType(data.reason),
1537
+ userId: null,
1538
+ visitorId: request.visitorId,
1539
+ ip: request.ip,
1540
+ country: null,
1541
+ details: data.details || { reason: data.reason },
1542
+ action: data.action === "block" ? "blocked" : "challenged"
1543
+ });
1544
+ return data;
1545
+ } catch (error) {
1546
+ logger.error("[Dash] Security check failed:", error);
1547
+ return { action: "allow" };
1548
+ }
1549
+ },
1550
+ mapReasonToEventType(reason) {
1551
+ switch (reason) {
1552
+ case "geo_blocked": return "geo_blocked";
1553
+ case "bot_detected": return "bot_blocked";
1554
+ case "suspicious_ip_detected": return "suspicious_ip_detected";
1555
+ case "rate_limited": return "velocity_exceeded";
1556
+ case "credential_stuffing_cooldown": return "credential_stuffing";
1557
+ default: return "credential_stuffing";
1558
+ }
1559
+ },
1560
+ async trackFailedAttempt(identifier, visitorId, password, ip) {
1561
+ try {
1562
+ const data = await $api("/security/track-failed-login", {
1563
+ method: "POST",
1564
+ body: {
1565
+ identifier,
1566
+ visitorId,
1567
+ passwordHash: await hashForFingerprint(password),
1568
+ ip,
1569
+ config: options
1570
+ }
1571
+ });
1572
+ if (data.blocked || data.challenged) logEvent({
1573
+ type: "credential_stuffing",
1574
+ userId: null,
1575
+ visitorId,
1576
+ ip,
1577
+ country: null,
1578
+ details: data.details || { reason: data.reason },
1579
+ action: data.blocked ? "blocked" : "challenged"
1580
+ });
1581
+ return data;
1582
+ } catch (error) {
1583
+ logger.error("[Dash] Track failed attempt error:", error);
1584
+ return { blocked: false };
1585
+ }
1586
+ },
1587
+ async clearFailedAttempts(identifier) {
1588
+ try {
1589
+ await $api("/security/clear-failed-attempts", {
1590
+ method: "POST",
1591
+ body: { identifier }
1592
+ });
1593
+ } catch (error) {
1594
+ logger.error("[Dash] Clear failed attempts error:", error);
1595
+ }
1596
+ },
1597
+ async isBlocked(visitorId) {
1598
+ try {
1599
+ return (await $api(`/security/is-blocked?visitorId=${encodeURIComponent(visitorId)}`, { method: "GET" })).blocked ?? false;
1600
+ } catch (error) {
1601
+ logger.warn("[Dash] Security is-blocked check failed:", error);
1602
+ return false;
1603
+ }
1604
+ },
1605
+ async verifyPoWSolution(visitorId, solution) {
1606
+ try {
1607
+ return await $api("/security/pow/verify", {
1608
+ method: "POST",
1609
+ body: {
1610
+ visitorId,
1611
+ solution
1612
+ }
1613
+ });
1614
+ } catch (error) {
1615
+ logger.warn("[Dash] PoW verify failed:", error);
1616
+ return {
1617
+ valid: false,
1618
+ reason: "error"
1619
+ };
1620
+ }
1621
+ },
1622
+ async generateChallenge(visitorId) {
1623
+ try {
1624
+ return (await $api("/security/pow/generate", {
1625
+ method: "POST",
1626
+ body: {
1627
+ visitorId,
1628
+ difficulty: options.challengeDifficulty
1629
+ }
1630
+ })).challenge || "";
1631
+ } catch (error) {
1632
+ logger.warn("[Dash] PoW generate challenge failed:", error);
1633
+ return "";
1634
+ }
1635
+ },
1636
+ async checkImpossibleTravel(userId, currentLocation, visitorId) {
1637
+ if (!options.impossibleTravel?.enabled || !currentLocation) return null;
1638
+ try {
1639
+ const data = await $api("/security/impossible-travel", {
1640
+ method: "POST",
1641
+ body: {
1642
+ userId,
1643
+ visitorId,
1644
+ location: currentLocation,
1645
+ config: options
1646
+ }
1647
+ });
1648
+ if (data.isImpossible) {
1649
+ const actionTaken = data.action === "block" ? "blocked" : data.action === "challenge" ? "challenged" : "logged";
1650
+ logEvent({
1651
+ type: "impossible_travel",
1652
+ userId,
1653
+ visitorId: visitorId || null,
1654
+ ip: null,
1655
+ country: currentLocation.country?.code || null,
1656
+ details: {
1657
+ from: data.from,
1658
+ to: data.to,
1659
+ distance: data.distance,
1660
+ speedRequired: data.speedRequired,
1661
+ action: data.action
1662
+ },
1663
+ action: actionTaken
1664
+ });
1656
1665
  }
1657
- });
1658
- return {
1659
- ...data || {
1660
- valid: false,
1661
- reason: "invalid_format"
1662
- },
1663
- policy
1664
- };
1665
- } catch (error) {
1666
- logger.warn("[Dash] Email validation API error, falling back to allow:", error);
1667
- return {
1668
- valid: true,
1669
- policy
1670
- };
1671
- }
1672
- } };
1673
- }
1674
- /**
1675
- * Basic local email format validation (fallback)
1676
- */
1677
- function isValidEmailFormatLocal(email) {
1678
- if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return false;
1679
- if (email.length > 254) return false;
1680
- const [localPart, domain] = email.split("@");
1681
- if (!localPart || !domain) return false;
1682
- if (localPart.length > 64) return false;
1683
- if (domain.length > 253) return false;
1684
- return true;
1685
- }
1686
- const getEmail = (ctx) => {
1687
- if (ctx.path === "/change-email") return {
1688
- email: ctx.body?.newEmail,
1689
- container: "body",
1690
- field: "newEmail"
1691
- };
1692
- const body = ctx.body;
1693
- const query = ctx.query;
1694
- return {
1695
- email: body?.email ?? query?.email,
1696
- container: body ? "body" : "query",
1697
- field: "email"
1698
- };
1699
- };
1700
- /**
1701
- * Create email normalization hook (shared between all configurations)
1702
- */
1703
- function createEmailNormalizationHook() {
1704
- return {
1705
- matcher: allEmail,
1706
- handler: createAuthMiddleware(async (ctx) => {
1707
- const { email, container, field } = getEmail(ctx);
1708
- if (typeof email !== "string") return;
1709
- const normalized = normalizeEmail(email, ctx.context);
1710
- if (normalized === email) return;
1711
- const data = container === "query" ? {
1712
- ...ctx.query,
1713
- [field]: normalized
1714
- } : {
1715
- ...ctx.body,
1716
- [field]: normalized
1666
+ return data;
1667
+ } catch (error) {
1668
+ logger.warn("[Dash] Impossible travel check failed:", error);
1669
+ return null;
1670
+ }
1671
+ },
1672
+ async storeLastLocation(userId, location) {
1673
+ if (!location) return;
1674
+ try {
1675
+ await $api("/security/store-last-login", {
1676
+ method: "POST",
1677
+ body: {
1678
+ userId,
1679
+ location
1680
+ }
1681
+ });
1682
+ } catch (error) {
1683
+ logger.error("[Dash] Store last location error:", error);
1684
+ }
1685
+ },
1686
+ async checkFreeTrialAbuse(visitorId) {
1687
+ if (!options.freeTrialAbuse?.enabled) return {
1688
+ isAbuse: false,
1689
+ accountCount: 0,
1690
+ maxAccounts: 0,
1691
+ action: "log"
1717
1692
  };
1718
- return { context: {
1719
- ...ctx,
1720
- [container]: data
1721
- } };
1722
- })
1723
- };
1724
- }
1725
- /**
1726
- * Create email validation hook with configurable validation strategy
1727
- */
1728
- function createEmailValidationHook(validator, onDisposableEmail) {
1729
- return {
1730
- matcher: allEmail,
1731
- handler: createAuthMiddleware(async (ctx) => {
1732
- const { email } = getEmail(ctx);
1733
- if (typeof email !== "string") return;
1734
- if (!isValidEmailFormatLocal(email)) throw new APIError$1("BAD_REQUEST", { message: "Invalid email" });
1735
- if (validator) {
1736
- const result = await validator.validate(email);
1737
- const policy = result.policy;
1738
- if (!policy?.enabled) return;
1739
- if (policy.domainAllowlist?.length) {
1740
- const domain = email.toLowerCase().trim().split("@")[1];
1741
- if (domain && policy.domainAllowlist.includes(domain)) return;
1742
- }
1743
- const action = policy.action;
1744
- if (!result.valid) {
1745
- 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) {
1746
- const ip = ctx.request?.headers?.get("x-forwarded-for")?.split(",")[0] || ctx.request?.headers?.get("cf-connecting-ip") || void 0;
1747
- onDisposableEmail({
1748
- email,
1749
- reason: result.reason || "disposable",
1750
- confidence: result.confidence,
1751
- ip,
1752
- path: ctx.path,
1753
- action
1754
- });
1693
+ try {
1694
+ const data = await $api("/security/free-trial-abuse/check", {
1695
+ method: "POST",
1696
+ body: {
1697
+ visitorId,
1698
+ config: options
1755
1699
  }
1756
- if (action === "allow") return;
1757
- 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" });
1700
+ });
1701
+ if (data.isAbuse) logEvent({
1702
+ type: "free_trial_abuse",
1703
+ userId: null,
1704
+ visitorId,
1705
+ ip: null,
1706
+ country: null,
1707
+ details: {
1708
+ accountCount: data.accountCount,
1709
+ maxAccounts: data.maxAccounts
1710
+ },
1711
+ action: data.action === "block" ? "blocked" : "logged"
1712
+ });
1713
+ return data;
1714
+ } catch (error) {
1715
+ logger.warn("[Dash] Free trial abuse check failed:", error);
1716
+ return {
1717
+ isAbuse: false,
1718
+ accountCount: 0,
1719
+ maxAccounts: 0,
1720
+ action: "log"
1721
+ };
1722
+ }
1723
+ },
1724
+ async trackFreeTrialSignup(visitorId, userId) {
1725
+ if (!options.freeTrialAbuse?.enabled) return;
1726
+ try {
1727
+ await $api("/security/free-trial-abuse/track", {
1728
+ method: "POST",
1729
+ body: {
1730
+ visitorId,
1731
+ userId
1732
+ }
1733
+ });
1734
+ } catch (error) {
1735
+ logger.error("[Dash] Track free trial signup error:", error);
1736
+ }
1737
+ },
1738
+ async checkCompromisedPassword(password) {
1739
+ try {
1740
+ const hash = await sha1Hash(password);
1741
+ const prefix = hash.substring(0, 5);
1742
+ const suffix = hash.substring(5);
1743
+ const data = await $api("/security/breached-passwords", {
1744
+ method: "POST",
1745
+ body: {
1746
+ passwordPrefix: prefix,
1747
+ config: options
1748
+ }
1749
+ });
1750
+ if (!data.enabled) return { compromised: false };
1751
+ const breachCount = (data.suffixes || {})[suffix] || 0;
1752
+ const minBreachCount = data.minBreachCount ?? 1;
1753
+ const action = data.action || "block";
1754
+ const compromised = breachCount >= minBreachCount;
1755
+ if (compromised) logEvent({
1756
+ type: "compromised_password",
1757
+ userId: null,
1758
+ visitorId: null,
1759
+ ip: null,
1760
+ country: null,
1761
+ details: { breachCount },
1762
+ action: action === "block" ? "blocked" : action === "challenge" ? "challenged" : "logged"
1763
+ });
1764
+ return {
1765
+ compromised,
1766
+ breachCount: breachCount > 0 ? breachCount : void 0,
1767
+ action: compromised ? action : void 0
1768
+ };
1769
+ } catch (error) {
1770
+ logger.error("[Dash] Compromised password check error:", error);
1771
+ return { compromised: false };
1772
+ }
1773
+ },
1774
+ async checkStaleUser(userId, lastActiveAt) {
1775
+ if (!options.staleUsers?.enabled) return { isStale: false };
1776
+ try {
1777
+ const data = await $api("/security/stale-user", {
1778
+ method: "POST",
1779
+ body: {
1780
+ userId,
1781
+ lastActiveAt: lastActiveAt instanceof Date ? lastActiveAt.toISOString() : lastActiveAt,
1782
+ config: options
1783
+ }
1784
+ });
1785
+ if (data.isStale) logEvent({
1786
+ type: "stale_account_reactivation",
1787
+ userId,
1788
+ visitorId: null,
1789
+ ip: null,
1790
+ country: null,
1791
+ details: {
1792
+ daysSinceLastActive: data.daysSinceLastActive,
1793
+ staleDays: data.staleDays,
1794
+ lastActiveAt: data.lastActiveAt,
1795
+ notifyUser: data.notifyUser,
1796
+ notifyAdmin: data.notifyAdmin
1797
+ },
1798
+ action: data.action === "block" ? "blocked" : data.action === "challenge" ? "challenged" : "logged"
1799
+ });
1800
+ return data;
1801
+ } catch (error) {
1802
+ logger.error("[Dash] Stale user check error:", error);
1803
+ return { isStale: false };
1804
+ }
1805
+ },
1806
+ async notifyStaleAccountUser(userEmail, userName, daysSinceLastActive, identification, appName) {
1807
+ const loginTime = (/* @__PURE__ */ new Date()).toLocaleString("en-US", {
1808
+ dateStyle: "long",
1809
+ timeStyle: "short",
1810
+ timeZone: "UTC"
1811
+ }) + " UTC";
1812
+ const location = identification?.location;
1813
+ const loginLocation = location?.city && location?.country?.name ? `${location.city}, ${location.country.code}` : location?.country?.name || "Unknown";
1814
+ const browser = identification?.browser;
1815
+ const loginDevice = browser?.name && browser?.os ? `${browser.name} on ${browser.os}` : "Unknown device";
1816
+ const result = await emailSender.send({
1817
+ template: "stale-account-user",
1818
+ to: userEmail,
1819
+ variables: {
1820
+ userEmail,
1821
+ userName: userName || "User",
1822
+ appName: appName || "Your App",
1823
+ daysSinceLastActive: String(daysSinceLastActive),
1824
+ loginTime,
1825
+ loginLocation,
1826
+ loginDevice,
1827
+ loginIp: identification?.ip || "Unknown"
1758
1828
  }
1759
- }
1760
- })
1761
- };
1762
- }
1763
- /**
1764
- * Create email validation hooks with optional API-backed validation
1765
- *
1766
- * @param options - Configuration options
1767
- * @param options.enabled - Enable email validation (default: true)
1768
- * @param options.useApi - Use API-backed validation (requires apiKey)
1769
- * @param options.apiKey - API key for remote validation
1770
- * @param options.apiUrl - API URL for policy fetching (defaults to INFRA_API_URL)
1771
- * @param options.kvUrl - KV URL for email validation (defaults to INFRA_KV_URL)
1772
- * @param options.strictness - Default strictness level: 'low', 'medium' (default), or 'high'
1773
- * @param options.action - Default action when invalid: 'allow', 'block' (default), or 'challenge'
1774
- *
1775
- * @example
1776
- * // Local validation only
1777
- * createEmailHooks()
1778
- *
1779
- * @example
1780
- * // API-backed validation
1781
- * createEmailHooks({ useApi: true, apiKey: "your-api-key" })
1782
- *
1783
- * @example
1784
- * // API-backed validation with high strictness default
1785
- * createEmailHooks({ useApi: true, apiKey: "your-api-key", strictness: "high" })
1786
- */
1787
- function createEmailHooks(options = {}) {
1788
- const { useApi = false, apiKey = "", apiUrl = INFRA_API_URL, kvUrl = INFRA_KV_URL, defaultConfig, onDisposableEmail } = options;
1789
- const emailConfig = {
1790
- enabled: true,
1791
- strictness: "medium",
1792
- action: "block",
1793
- ...defaultConfig
1829
+ });
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
1857
+ }
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
+ }
1794
1883
  };
1795
- if (!emailConfig.enabled) return { before: [] };
1796
- const validator = useApi ? createEmailValidator({
1797
- apiUrl,
1798
- kvUrl,
1799
- apiKey,
1800
- defaultConfig: emailConfig
1801
- }) : void 0;
1802
- return { before: [createEmailNormalizationHook(), createEmailValidationHook(validator, onDisposableEmail)] };
1803
1884
  }
1804
- createEmailHooks();
1805
1885
  //#endregion
1806
- //#region src/validation/phone.ts
1807
- /**
1808
- * Common fake/test phone numbers that should be blocked
1809
- * These are numbers commonly used in testing, movies, documentation, etc.
1810
- */
1811
- const INVALID_PHONE_NUMBERS = new Set([
1812
- "+15550000000",
1813
- "+15550001111",
1814
- "+15550001234",
1815
- "+15551234567",
1816
- "+15555555555",
1817
- "+15551111111",
1818
- "+15550000001",
1819
- "+15550123456",
1820
- "+12125551234",
1821
- "+13105551234",
1822
- "+14155551234",
1823
- "+12025551234",
1824
- "+10000000000",
1825
- "+11111111111",
1826
- "+12222222222",
1827
- "+13333333333",
1828
- "+14444444444",
1829
- "+15555555555",
1830
- "+16666666666",
1831
- "+17777777777",
1832
- "+18888888888",
1833
- "+19999999999",
1834
- "+11234567890",
1835
- "+10123456789",
1836
- "+19876543210",
1837
- "+441632960000",
1838
- "+447700900000",
1839
- "+447700900001",
1840
- "+447700900123",
1841
- "+447700900999",
1842
- "+442079460000",
1843
- "+442079460123",
1844
- "+441134960000",
1845
- "+0000000000",
1846
- "+1000000000",
1847
- "+123456789",
1848
- "+1234567890",
1849
- "+12345678901",
1850
- "+0123456789",
1851
- "+9876543210",
1852
- "+11111111111",
1853
- "+99999999999",
1854
- "+491234567890",
1855
- "+491111111111",
1856
- "+33123456789",
1857
- "+33111111111",
1858
- "+61123456789",
1859
- "+61111111111",
1860
- "+81123456789",
1861
- "+81111111111",
1862
- "+19001234567",
1863
- "+19761234567",
1864
- "+1911",
1865
- "+1411",
1866
- "+1611",
1867
- "+44999",
1868
- "+44112"
1869
- ]);
1870
- /**
1871
- * Patterns that indicate fake/test phone numbers
1872
- */
1873
- const INVALID_PHONE_PATTERNS = [
1874
- /^\+\d(\d)\1{6,}$/,
1875
- /^\+\d*1234567890/,
1876
- /^\+\d*0123456789/,
1877
- /^\+\d*9876543210/,
1878
- /^\+\d*0987654321/,
1879
- /^\+\d*(12){4,}/,
1880
- /^\+\d*(21){4,}/,
1881
- /^\+\d*(00){4,}/,
1882
- /^\+1\d{3}555\d{4}$/,
1883
- /^\+\d{1,3}\d{1,5}$/,
1884
- /^\+\d+0{7,}$/,
1885
- /^\+\d*147258369/,
1886
- /^\+\d*258369147/,
1887
- /^\+\d*369258147/,
1888
- /^\+\d*789456123/,
1889
- /^\+\d*123456789/,
1890
- /^\+\d*1234512345/,
1891
- /^\+\d*1111122222/,
1892
- /^\+\d*1212121212/,
1893
- /^\+\d*1010101010/
1894
- ];
1895
- /**
1896
- * Invalid area codes / prefixes that indicate test numbers
1897
- * Key: country code, Value: set of invalid prefixes
1898
- */
1899
- const INVALID_PREFIXES_BY_COUNTRY = {
1900
- US: new Set([
1901
- "555",
1902
- "000",
1903
- "111",
1904
- "911",
1905
- "411",
1906
- "611"
1907
- ]),
1908
- CA: new Set([
1909
- "555",
1910
- "000",
1911
- "911"
1912
- ]),
1913
- GB: new Set([
1914
- "7700900",
1915
- "1632960",
1916
- "1134960"
1917
- ]),
1918
- AU: new Set([
1919
- "0491570",
1920
- "0491571",
1921
- "0491572"
1922
- ])
1886
+ //#region src/sentinel/security-hooks.ts
1887
+ const ERROR_MESSAGES = {
1888
+ geo_blocked: "Access from your location is not allowed.",
1889
+ bot_detected: "Automated access is not allowed.",
1890
+ suspicious_ip_detected: "Anonymous connections (VPN, proxy, Tor) are not allowed.",
1891
+ rate_limited: "Too many attempts. Please try again later.",
1892
+ compromised_password: "This password has been found in data breaches. Please choose a different password.",
1893
+ impossible_travel: "Login blocked due to suspicious location change."
1894
+ };
1895
+ const DISPLAY_NAMES = {
1896
+ geo_blocked: {
1897
+ challenge: "Security: geo challenge",
1898
+ block: "Security: geo blocked"
1899
+ },
1900
+ bot_detected: {
1901
+ challenge: "Security: bot challenge",
1902
+ block: "Security: bot blocked"
1903
+ },
1904
+ suspicious_ip_detected: {
1905
+ challenge: "Security: anonymous IP challenge",
1906
+ block: "Security: anonymous IP blocked"
1907
+ },
1908
+ rate_limited: {
1909
+ challenge: "Security: velocity challenge",
1910
+ block: "Security: velocity exceeded"
1911
+ },
1912
+ compromised_password: {
1913
+ challenge: "Security: breached password warning",
1914
+ block: "Security: breached password blocked"
1915
+ },
1916
+ impossible_travel: {
1917
+ challenge: "Security: impossible travel challenge",
1918
+ block: "Security: impossible travel blocked"
1919
+ }
1923
1920
  };
1924
1921
  /**
1925
- * Check if a phone number is a commonly used fake/test number
1926
- * @param phone - The phone number to check (E.164 format preferred)
1927
- * @param defaultCountry - Default country code if not included in phone string
1928
- * @returns true if the phone appears to be fake/test, false if it seems legitimate
1922
+ * Throw a challenge error with appropriate headers
1929
1923
  */
1930
- const isFakePhoneNumber = (phone, defaultCountry) => {
1931
- const parsed = parsePhoneNumberFromString(phone, defaultCountry);
1932
- if (!parsed) return true;
1933
- const e164 = parsed.number;
1934
- const nationalNumber = parsed.nationalNumber;
1935
- const country = parsed.country;
1936
- if (INVALID_PHONE_NUMBERS.has(e164)) return true;
1937
- for (const pattern of INVALID_PHONE_PATTERNS) if (pattern.test(e164)) return true;
1938
- if (country && INVALID_PREFIXES_BY_COUNTRY[country]) {
1939
- const prefixes = INVALID_PREFIXES_BY_COUNTRY[country];
1940
- for (const prefix of prefixes) if (nationalNumber.startsWith(prefix)) return true;
1941
- }
1942
- if (/^(\d)\1+$/.test(nationalNumber)) return true;
1943
- const digits = nationalNumber.split("").map(Number);
1944
- let isSequential = digits.length >= 6;
1945
- for (let i = 1; i < digits.length && isSequential; i++) {
1946
- const current = digits[i];
1947
- const previous = digits[i - 1];
1948
- if (current === void 0 || previous === void 0 || current !== previous + 1 && current !== previous - 1) isSequential = false;
1949
- }
1950
- if (isSequential) return true;
1951
- return false;
1952
- };
1924
+ function throwChallengeError(challenge, reason, message = "Please complete a security check to continue.") {
1925
+ const error = new APIError("LOCKED", {
1926
+ message,
1927
+ code: "POW_CHALLENGE_REQUIRED"
1928
+ });
1929
+ error.headers = {
1930
+ "X-PoW-Challenge": challenge,
1931
+ "X-PoW-Reason": reason
1932
+ };
1933
+ throw error;
1934
+ }
1953
1935
  /**
1954
- * Validate a phone number format
1955
- * @param phone - The phone number to validate
1956
- * @param defaultCountry - Default country code if not included in phone string
1957
- * @returns true if the phone number is valid
1936
+ * Build common event data for security tracking
1958
1937
  */
1959
- const isValidPhone = (phone, defaultCountry) => {
1960
- return isValidPhoneNumber(phone, defaultCountry);
1961
- };
1938
+ function buildEventData(ctx, action, reason, confidence = 1, extraData) {
1939
+ const { visitorId, identification, path, identifier, userAgent } = ctx;
1940
+ const countryCode = identification?.location?.country?.code || void 0;
1941
+ return {
1942
+ eventKey: visitorId || identification?.ip || "unknown",
1943
+ eventType: action === "challenged" ? "security_challenged" : "security_blocked",
1944
+ eventDisplayName: DISPLAY_NAMES[reason]?.[action === "challenged" ? "challenge" : "block"] || `Security: ${reason}`,
1945
+ eventData: {
1946
+ action,
1947
+ reason,
1948
+ visitorId: visitorId || "",
1949
+ path,
1950
+ userAgent,
1951
+ identifier,
1952
+ confidence,
1953
+ ...extraData
1954
+ },
1955
+ ipAddress: identification?.ip || void 0,
1956
+ city: identification?.location?.city || void 0,
1957
+ country: identification?.location?.country?.name || void 0,
1958
+ countryCode
1959
+ };
1960
+ }
1962
1961
  /**
1963
- * Comprehensive phone number validation
1964
- * @param phone - The phone number to validate
1965
- * @param options - Validation options
1966
- * @returns true if valid, false otherwise
1962
+ * Handle a security check result by tracking events and throwing appropriate errors
1963
+ *
1964
+ * @param verdict - The security verdict from the security service
1965
+ * @param ctx - Security check context with request information
1966
+ * @param trackEvent - Function to track security events
1967
+ * @param securityService - Security service for generating challenges
1968
+ * @returns True if the request should be blocked
1967
1969
  */
1968
- const validatePhone = (phone, options = {}) => {
1969
- const { mobileOnly = false, allowedCountries, blockedCountries, blockFakeNumbers = true, blockPremiumRate = true, blockTollFree = false, blockVoip = false, defaultCountry } = options;
1970
- if (!isValidPhone(phone, defaultCountry)) return false;
1971
- const parsed = parsePhoneNumberFromString(phone, defaultCountry);
1972
- if (!parsed) return false;
1973
- if (blockFakeNumbers && isFakePhoneNumber(phone, defaultCountry)) return false;
1974
- const country = parsed.country;
1975
- if (country) {
1976
- if (allowedCountries && !allowedCountries.includes(country)) return false;
1977
- if (blockedCountries?.includes(country)) return false;
1978
- }
1979
- const phoneType = parsed.getType();
1980
- if (mobileOnly) {
1981
- if (phoneType !== "MOBILE" && phoneType !== "FIXED_LINE_OR_MOBILE") return false;
1970
+ async function handleSecurityVerdict(verdict, ctx, trackEvent, securityService) {
1971
+ if (verdict.action === "allow") return;
1972
+ const reason = verdict.reason || "unknown";
1973
+ const confidence = 1;
1974
+ if (verdict.action === "challenge" && ctx.visitorId) {
1975
+ const challenge = verdict.challenge || await securityService.generateChallenge(ctx.visitorId);
1976
+ if (!challenge?.trim()) {
1977
+ logger.warn("[Sentinel] Could not generate PoW challenge (service may be unavailable). Falling back to allow.");
1978
+ return;
1979
+ }
1980
+ trackEvent(buildEventData(ctx, "challenged", reason, confidence, verdict.details));
1981
+ throwChallengeError(challenge, reason, "Please complete a security check to continue.");
1982
+ } else if (verdict.action === "block") {
1983
+ trackEvent(buildEventData(ctx, "blocked", reason, confidence, verdict.details));
1984
+ throw new APIError("FORBIDDEN", { message: ERROR_MESSAGES[reason] || "Access denied." });
1982
1985
  }
1983
- if (blockPremiumRate && phoneType === "PREMIUM_RATE") return false;
1984
- if (blockTollFree && phoneType === "TOLL_FREE") return false;
1985
- if (blockVoip && phoneType === "VOIP") return false;
1986
- return true;
1987
- };
1988
- const allPhonePaths = new Set([
1989
- "/phone-number/send-otp",
1990
- "/phone-number/verify",
1991
- "/sign-in/phone-number",
1992
- "/phone-number/request-password-reset",
1993
- "/phone-number/reset-password"
1994
- ]);
1995
- const getPhoneNumber = (ctx) => ctx.body?.phoneNumber ?? ctx.query?.phoneNumber;
1986
+ }
1996
1987
  /**
1997
- * Better Auth plugin for phone number validation
1998
- * Validates phone numbers on all phone-related endpoints
1988
+ * Run all security checks using the consolidated checkSecurity API
1989
+ *
1990
+ * This replaces the multiple individual check calls with a single API call
1991
+ * that handles all security checks server-side.
1999
1992
  */
2000
- const phoneValidationHooks = { before: [{
2001
- matcher: (context) => !!context.path && allPhonePaths.has(context.path),
2002
- handler: createAuthMiddleware(async (ctx) => {
2003
- const phoneNumber = getPhoneNumber(ctx);
2004
- if (typeof phoneNumber !== "string") return;
2005
- if (!validatePhone(phoneNumber)) throw new APIError$1("BAD_REQUEST", { message: "Invalid phone number" });
2006
- })
2007
- }] };
1993
+ async function runSecurityChecks(ctx, securityService, trackEvent, powVerified) {
1994
+ if (powVerified) return;
1995
+ await handleSecurityVerdict(await securityService.checkSecurity({
1996
+ visitorId: ctx.visitorId,
1997
+ requestId: ctx.identification?.requestId || null,
1998
+ ip: ctx.identification?.ip || null,
1999
+ path: ctx.path,
2000
+ identifier: ctx.identifier
2001
+ }), ctx, trackEvent, securityService);
2002
+ }
2008
2003
  //#endregion
2009
- //#region src/sentinel.ts
2004
+ //#region src/sentinel/sentinel.ts
2010
2005
  const sentinel = (options) => {
2011
2006
  const opts = resolveSentinelOptions(options);
2012
2007
  const { tracker } = initTrackEvents(opts);
@@ -2062,7 +2057,8 @@ const sentinel = (options) => {
2062
2057
  });
2063
2058
  return {
2064
2059
  id: "sentinel",
2065
- init() {
2060
+ init(ctx) {
2061
+ const activityTrackingEnabled = (ctx.getPlugin("dash")?.options)?.activityTracking?.enabled === true;
2066
2062
  return { options: {
2067
2063
  emailValidation: opts.security?.emailValidation,
2068
2064
  databaseHooks: {
@@ -2074,7 +2070,7 @@ const sentinel = (options) => {
2074
2070
  const abuseCheck = await securityService.checkFreeTrialAbuse(visitorId);
2075
2071
  if (abuseCheck.isAbuse && abuseCheck.action === "block") throw new APIError("FORBIDDEN", { message: "Account creation is not allowed from this device." });
2076
2072
  }
2077
- if (user.email && typeof user.email === "string") return { data: {
2073
+ if (user.email && typeof user.email === "string" && opts.security?.emailValidation?.enabled !== false) return { data: {
2078
2074
  ...user,
2079
2075
  email: normalizeEmail(user.email, ctx.context)
2080
2076
  } };
@@ -2103,14 +2099,15 @@ const sentinel = (options) => {
2103
2099
  const visitorId = ctx.context.visitorId;
2104
2100
  const identification = ctx.context.identification;
2105
2101
  let user = null;
2102
+ const userSelect = [
2103
+ "email",
2104
+ "name",
2105
+ ...activityTrackingEnabled ? ["lastActiveAt"] : []
2106
+ ];
2106
2107
  try {
2107
2108
  user = await ctx.context.adapter.findOne({
2108
2109
  model: "user",
2109
- select: [
2110
- "email",
2111
- "name",
2112
- "lastActiveAt"
2113
- ],
2110
+ select: [...userSelect],
2114
2111
  where: [{
2115
2112
  field: "id",
2116
2113
  value: session.userId
@@ -2123,7 +2120,8 @@ const sentinel = (options) => {
2123
2120
  if (await securityService.checkUnknownDevice(session.userId, visitorId) && user?.email) await ctx.context.runInBackgroundOrAwait(securityService.notifyUnknownDevice(session.userId, user.email, identification));
2124
2121
  }
2125
2122
  if (opts.security?.staleUsers?.enabled && user) {
2126
- const staleCheck = await securityService.checkStaleUser(session.userId, user.lastActiveAt || null);
2123
+ const lastActiveAtForStale = activityTrackingEnabled ? user.lastActiveAt ?? null : null;
2124
+ const staleCheck = await securityService.checkStaleUser(session.userId, lastActiveAtForStale);
2127
2125
  if (staleCheck.isStale) {
2128
2126
  const staleOpts = opts.security.staleUsers;
2129
2127
  const notificationPromises = [];
@@ -2747,6 +2745,9 @@ const jwtValidateMiddleware = (options) => createAuthMiddleware(async (ctx) => {
2747
2745
  return { payload };
2748
2746
  });
2749
2747
  //#endregion
2748
+ //#region src/version.ts
2749
+ const PLUGIN_VERSION = "0.2.0";
2750
+ //#endregion
2750
2751
  //#region src/routes/auth/config.ts
2751
2752
  const PLUGIN_OPTIONS_EXCLUDE_KEYS = { stripe: new Set(["stripeClient"]) };
2752
2753
  function isPlainSerializable(value) {
@@ -2792,11 +2793,17 @@ const getConfig = (options) => {
2792
2793
  socialProviders: Object.keys(ctx.context.options.socialProviders || {}),
2793
2794
  emailAndPassword: ctx.context.options.emailAndPassword,
2794
2795
  plugins: ctx.context.options.plugins?.map((plugin) => {
2795
- return {
2796
+ const base = {
2796
2797
  id: plugin.id,
2797
2798
  schema: plugin.schema,
2799
+ version: plugin.version,
2798
2800
  options: sanitizePluginOptions(plugin.id, plugin.options)
2799
2801
  };
2802
+ if (plugin.id === "dash" && !plugin.version) return {
2803
+ ...base,
2804
+ version: PLUGIN_VERSION
2805
+ };
2806
+ return base;
2800
2807
  }) ?? [],
2801
2808
  organization: {
2802
2809
  sendInvitationEmailEnabled: !!organizationPlugin?.options?.sendInvitationEmail,
@@ -3092,13 +3099,6 @@ const regenerateDirectoryToken = (options) => {
3092
3099
  };
3093
3100
  //#endregion
3094
3101
  //#region src/routes/events/index.ts
3095
- /**
3096
- * All available event types that can be returned in audit logs
3097
- */
3098
- const USER_EVENT_TYPES = {
3099
- ...EVENT_TYPES,
3100
- ...ORGANIZATION_EVENT_TYPES
3101
- };
3102
3102
  function transformEvent(raw) {
3103
3103
  const location = raw.ipAddress || raw.city || raw.country || raw.countryCode ? {
3104
3104
  ipAddress: raw.ipAddress,
@@ -3498,6 +3498,7 @@ const completeInvitation = (options) => {
3498
3498
  })
3499
3499
  }, async (ctx) => {
3500
3500
  const { token, password, providerId, providerAccountId, accessToken, refreshToken } = ctx.body;
3501
+ const fallbackRedirect = typeof ctx.context.options.baseURL === "string" ? ctx.context.options.baseURL : "/";
3501
3502
  const { data: invitation, error } = await $api("/api/internal/invitations/verify", {
3502
3503
  method: "POST",
3503
3504
  body: { token }
@@ -3525,7 +3526,7 @@ const completeInvitation = (options) => {
3525
3526
  });
3526
3527
  return {
3527
3528
  success: true,
3528
- redirectUrl: invitation.redirectUrl || ctx.context.options.baseURL || "/"
3529
+ redirectUrl: invitation.redirectUrl ?? fallbackRedirect
3529
3530
  };
3530
3531
  }
3531
3532
  const user = await ctx.context.internalAdapter.createUser({
@@ -3562,7 +3563,7 @@ const completeInvitation = (options) => {
3562
3563
  });
3563
3564
  return {
3564
3565
  success: true,
3565
- redirectUrl: invitation.redirectUrl || ctx.context.options.baseURL || "/"
3566
+ redirectUrl: invitation.redirectUrl ?? fallbackRedirect
3566
3567
  };
3567
3568
  });
3568
3569
  };
@@ -3710,7 +3711,7 @@ const checkUserByEmail = (options) => {
3710
3711
  id: user.id,
3711
3712
  name: user.name,
3712
3713
  email: user.email,
3713
- image: user.image
3714
+ image: user.image ?? null
3714
3715
  },
3715
3716
  isAlreadyMember: !!existingMember
3716
3717
  };
@@ -3861,26 +3862,31 @@ const listOrganizationMembers = (options) => {
3861
3862
  if (inviter && inv.inviterId) inviterById.set(inv.inviterId, inviter);
3862
3863
  }
3863
3864
  const invitationByEmail = new Map(invitations.map((i) => [i.email.toLowerCase(), i]));
3864
- return members.map((m) => {
3865
- const user = (Array.isArray(m.user) ? m.user[0] : m.user) ?? null;
3866
- const invitation = user ? invitationByEmail.get(user.email.toLowerCase()) : null;
3867
- const inviter = invitation ? inviterById.get(invitation.inviterId) : null;
3868
- return {
3869
- ...m,
3870
- user: user ? {
3871
- id: user.id,
3872
- email: user.email,
3873
- name: user.name,
3874
- image: user.image || null
3875
- } : null,
3876
- invitedBy: inviter ? {
3877
- id: inviter.id,
3878
- name: inviter.name,
3879
- email: inviter.email,
3880
- image: inviter.image || null
3881
- } : null
3865
+ const result = [];
3866
+ for (const m of members) {
3867
+ const joinedUser = Array.isArray(m.user) ? m.user[0] : m.user;
3868
+ if (!joinedUser) continue;
3869
+ const invitation = invitationByEmail.get(joinedUser.email.toLowerCase());
3870
+ const inviter = invitation ? inviterById.get(invitation.inviterId) : void 0;
3871
+ const user = {
3872
+ id: joinedUser.id,
3873
+ email: joinedUser.email,
3874
+ name: joinedUser.name,
3875
+ image: joinedUser.image ?? null
3882
3876
  };
3883
- });
3877
+ const invitedBy = inviter ? {
3878
+ id: inviter.id,
3879
+ name: inviter.name,
3880
+ email: inviter.email,
3881
+ image: inviter.image ?? null
3882
+ } : null;
3883
+ result.push({
3884
+ ...m,
3885
+ user,
3886
+ invitedBy
3887
+ });
3888
+ }
3889
+ return result;
3884
3890
  });
3885
3891
  };
3886
3892
  const addMember = (options) => {
@@ -4144,6 +4150,16 @@ const exportFactory = (input, options) => async (ctx) => {
4144
4150
  //#endregion
4145
4151
  //#region src/helper.ts
4146
4152
  /**
4153
+ * Returns true when sessions live exclusively in secondary storage
4154
+ * (i.e. the user has configured secondaryStorage but has NOT enabled
4155
+ * storeSessionInDatabase). In this case DB queries against the session
4156
+ * model will return empty results and we must go through the
4157
+ * internalAdapter instead.
4158
+ */
4159
+ function isSessionInSecondaryStorageOnly(context) {
4160
+ return !!context.options.secondaryStorage && !context.options.session?.storeSessionInDatabase;
4161
+ }
4162
+ /**
4147
4163
  * Checks whether a plugin is registered by its ID.
4148
4164
  * Prefers the native `hasPlugin` when available, otherwise
4149
4165
  * falls back to scanning `options.plugins`.
@@ -4200,14 +4216,16 @@ const listOrganizations = (options) => {
4200
4216
  endDate: z$1.date().or(z$1.string().transform((val) => new Date(val))).optional()
4201
4217
  }).optional()
4202
4218
  }, async (ctx) => {
4219
+ const { limit = 10, offset = 0, sortBy = "createdAt", sortOrder = "desc", search, filterMembers } = ctx.query || {};
4203
4220
  if (!isOrganizationEnabled(ctx)) {
4204
4221
  ctx.context.logger.warn("[Dash] Organization plugin not enabled, returning empty organizations list");
4205
4222
  return {
4206
4223
  organizations: [],
4207
- total: 0
4224
+ total: 0,
4225
+ offset,
4226
+ limit
4208
4227
  };
4209
4228
  }
4210
- const { limit = 10, offset = 0, sortBy = "createdAt", sortOrder = "desc", search, filterMembers } = ctx.query || {};
4211
4229
  const where = [];
4212
4230
  if (search && search.trim().length > 0) {
4213
4231
  const searchTerm = search.trim();
@@ -4215,11 +4233,13 @@ const listOrganizations = (options) => {
4215
4233
  field: "name",
4216
4234
  value: searchTerm,
4217
4235
  operator: "starts_with",
4236
+ mode: "insensitive",
4218
4237
  connector: "OR"
4219
4238
  }, {
4220
4239
  field: "slug",
4221
4240
  value: searchTerm,
4222
4241
  operator: "starts_with",
4242
+ mode: "insensitive",
4223
4243
  connector: "OR"
4224
4244
  });
4225
4245
  }
@@ -4235,7 +4255,7 @@ const listOrganizations = (options) => {
4235
4255
  });
4236
4256
  const needsInMemoryProcessing = sortBy === "members" || !!filterMembers;
4237
4257
  const dbSortBy = sortBy === "members" ? "createdAt" : sortBy;
4238
- const fetchLimit = needsInMemoryProcessing ? 1500 : limit;
4258
+ const fetchLimit = needsInMemoryProcessing ? 2500 : limit;
4239
4259
  const fetchOffset = needsInMemoryProcessing ? 0 : offset;
4240
4260
  const [organizations, initialTotal] = await Promise.all([ctx.context.adapter.findMany({
4241
4261
  model: "organization",
@@ -4356,7 +4376,7 @@ const getOrganizationOptions = (options) => {
4356
4376
  }, async (ctx) => {
4357
4377
  const organizationPlugin = ctx.context.getPlugin("organization");
4358
4378
  if (!organizationPlugin) return { teamsEnabled: false };
4359
- return { teamsEnabled: organizationPlugin.options?.teams?.enabled && organizationPlugin.options.teams.defaultTeam?.enabled !== false };
4379
+ return { teamsEnabled: Boolean(organizationPlugin.options?.teams?.enabled && organizationPlugin.options.teams.defaultTeam?.enabled !== false) };
4360
4380
  });
4361
4381
  };
4362
4382
  const getOrganization = (options) => {
@@ -4844,6 +4864,7 @@ const updateTeam = (options) => {
4844
4864
  }],
4845
4865
  update: updateData
4846
4866
  });
4867
+ if (!team) throw ctx.error("INTERNAL_SERVER_ERROR", { message: "Failed to update team" });
4847
4868
  if (orgOptions?.organizationHooks?.afterUpdateTeam) await orgOptions.organizationHooks.afterUpdateTeam({
4848
4869
  team,
4849
4870
  user,
@@ -5043,7 +5064,7 @@ const listTeamMembers = (options) => {
5043
5064
  id: user.id,
5044
5065
  name: user.name,
5045
5066
  email: user.email,
5046
- image: user.image
5067
+ image: user.image ?? null
5047
5068
  } : null
5048
5069
  };
5049
5070
  });
@@ -5232,43 +5253,6 @@ const removeTeamMember = (options) => {
5232
5253
  };
5233
5254
  //#endregion
5234
5255
  //#region src/routes/sessions/index.ts
5235
- const listAllSessions = (options) => {
5236
- return createAuthEndpoint("/dash/list-all-sessions", {
5237
- method: "GET",
5238
- use: [jwtMiddleware(options)],
5239
- query: z$1.object({
5240
- limit: z$1.number().optional(),
5241
- offset: z$1.number().optional()
5242
- }).optional()
5243
- }, async (ctx) => {
5244
- const sessionsCount = await ctx.context.adapter.count({ model: "session" });
5245
- const limit = ctx.query?.limit || sessionsCount;
5246
- const offset = ctx.query?.offset || 0;
5247
- const sessions = await ctx.context.adapter.findMany({
5248
- model: "session",
5249
- limit,
5250
- offset,
5251
- sortBy: {
5252
- field: "createdAt",
5253
- direction: "desc"
5254
- },
5255
- join: { user: true }
5256
- });
5257
- const userMap = /* @__PURE__ */ new Map();
5258
- for (const s of sessions) {
5259
- const user = Array.isArray(s.user) ? s.user[0] : s.user;
5260
- if (!user) continue;
5261
- const { user: _u, ...sessionData } = s;
5262
- const session = sessionData;
5263
- if (!userMap.has(user.id)) userMap.set(user.id, {
5264
- ...user,
5265
- sessions: []
5266
- });
5267
- userMap.get(user.id).sessions.push(session);
5268
- }
5269
- return Array.from(userMap.values());
5270
- });
5271
- };
5272
5256
  const revokeSession = (options) => createAuthEndpoint("/dash/sessions/revoke", {
5273
5257
  method: "POST",
5274
5258
  use: [jwtMiddleware(options)],
@@ -5276,18 +5260,26 @@ const revokeSession = (options) => createAuthEndpoint("/dash/sessions/revoke", {
5276
5260
  }, async (ctx) => {
5277
5261
  const { sessionId, userId } = ctx.context.payload;
5278
5262
  if (!sessionId || !userId) throw ctx.error("FORBIDDEN", { message: "Invalid payload" });
5279
- const session = await ctx.context.adapter.findOne({
5280
- model: "session",
5281
- where: [{
5282
- field: "id",
5283
- value: sessionId
5284
- }, {
5285
- field: "userId",
5286
- value: userId
5287
- }]
5288
- });
5289
- if (!session) throw ctx.error("NOT_FOUND", { message: "Session not found" });
5290
- await ctx.context.internalAdapter.deleteSession(session.token);
5263
+ let sessionToken;
5264
+ if (isSessionInSecondaryStorageOnly(ctx.context)) {
5265
+ const match = (await ctx.context.internalAdapter.listSessions(userId)).find((s) => s.id === sessionId || s.token === sessionId);
5266
+ if (!match) throw ctx.error("NOT_FOUND", { message: "Session not found" });
5267
+ sessionToken = match.token;
5268
+ } else {
5269
+ const session = await ctx.context.adapter.findOne({
5270
+ model: "session",
5271
+ where: [{
5272
+ field: "id",
5273
+ value: sessionId
5274
+ }, {
5275
+ field: "userId",
5276
+ value: userId
5277
+ }]
5278
+ });
5279
+ if (!session) throw ctx.error("NOT_FOUND", { message: "Session not found" });
5280
+ sessionToken = session.token;
5281
+ }
5282
+ await ctx.context.internalAdapter.deleteSession(sessionToken);
5291
5283
  return ctx.json({ success: true });
5292
5284
  });
5293
5285
  const revokeAllSessions = (options) => createAuthEndpoint("/dash/sessions/revoke-all", {
@@ -5408,8 +5400,16 @@ function getSSOPlugin(ctx) {
5408
5400
  }
5409
5401
  //#endregion
5410
5402
  //#region src/routes/sso-validation.ts
5411
- const DEPRECATED_SIGNATURE_ALGORITHMS = [SignatureAlgorithm.RSA_SHA1];
5412
- const DEPRECATED_DIGEST_ALGORITHMS = [DigestAlgorithm.SHA1];
5403
+ /**
5404
+ * SAML metadata limits and algorithm URIs aligned with @better-auth/sso
5405
+ * (see packages/sso dist constants). Inlined so consumers (e.g. Metro) never
5406
+ * statically pull @better-auth/sso for validation-only code paths.
5407
+ */
5408
+ const DEFAULT_MAX_SAML_METADATA_SIZE = 100 * 1024;
5409
+ const RSA_SHA1 = "http://www.w3.org/2000/09/xmldsig#rsa-sha1";
5410
+ const SHA1 = "http://www.w3.org/2000/09/xmldsig#sha1";
5411
+ const DEPRECATED_SIGNATURE_ALGORITHMS = [RSA_SHA1];
5412
+ const DEPRECATED_DIGEST_ALGORITHMS = [SHA1];
5413
5413
  function validateSAMLMetadataSize(metadataXml, maxSize = DEFAULT_MAX_SAML_METADATA_SIZE) {
5414
5414
  if (new TextEncoder().encode(metadataXml).byteLength > maxSize) throw new Error(`IdP metadata exceeds maximum allowed size (${Math.round(maxSize / 1024)}KB)`);
5415
5415
  }
@@ -5437,6 +5437,10 @@ function validateSAMLMetadataAlgorithms(metadataXml) {
5437
5437
  }
5438
5438
  //#endregion
5439
5439
  //#region src/routes/sso/index.ts
5440
+ let ssoRuntimeModule;
5441
+ function loadSsoRuntime() {
5442
+ return ssoRuntimeModule ??= import("@better-auth/sso");
5443
+ }
5440
5444
  function requireOrganizationAccess(ctx) {
5441
5445
  const orgIdFromUrl = tryDecode(ctx.params.id);
5442
5446
  const orgIdFromToken = ctx.context.payload?.organizationId;
@@ -5509,6 +5513,7 @@ async function resolveSAMLConfig(samlConfig, providerId, baseURL, ctx) {
5509
5513
  }] } : {},
5510
5514
  ...samlConfig.cert ? { cert: samlConfig.cert } : {}
5511
5515
  };
5516
+ const m = samlConfig.mapping;
5512
5517
  return {
5513
5518
  config: {
5514
5519
  issuer: samlConfig.entityId ?? `${baseURL}/sso/saml2/sp/metadata?providerId=${providerId}`,
@@ -5517,7 +5522,15 @@ async function resolveSAMLConfig(samlConfig, providerId, baseURL, ctx) {
5517
5522
  spMetadata: {},
5518
5523
  entryPoint: samlConfig.entryPoint ?? "",
5519
5524
  cert: samlConfig.cert ?? "",
5520
- ...samlConfig.mapping ? { mapping: samlConfig.mapping } : {}
5525
+ ...m ? { mapping: {
5526
+ id: m.id ?? "nameID",
5527
+ email: m.email ?? "email",
5528
+ name: m.name ?? "name",
5529
+ emailVerified: m.emailVerified,
5530
+ firstName: m.firstName,
5531
+ lastName: m.lastName,
5532
+ extraFields: m.extraFields
5533
+ } } : {}
5521
5534
  },
5522
5535
  ...metadataAlgorithmWarnings.length > 0 ? { warnings: metadataAlgorithmWarnings } : {}
5523
5536
  };
@@ -5531,8 +5544,9 @@ async function resolveOIDCConfig(oidcConfig, domain, ctx) {
5531
5544
  }
5532
5545
  const issuerHint = oidcConfig.issuer || `https://${normalizedDomain}`;
5533
5546
  const issuer = issuerHint.startsWith("http") ? issuerHint : `https://${issuerHint}`;
5547
+ const sso = await loadSsoRuntime();
5534
5548
  try {
5535
- const hydratedConfig = await discoverOIDCConfig({
5549
+ const hydratedConfig = await sso.discoverOIDCConfig({
5536
5550
  issuer,
5537
5551
  discoveryEndpoint: oidcConfig.discoveryUrl,
5538
5552
  isTrustedOrigin: (url) => {
@@ -5544,6 +5558,7 @@ async function resolveOIDCConfig(oidcConfig, domain, ctx) {
5544
5558
  }
5545
5559
  }
5546
5560
  });
5561
+ const om = oidcConfig.mapping;
5547
5562
  return {
5548
5563
  config: {
5549
5564
  clientId: oidcConfig.clientId,
@@ -5556,12 +5571,19 @@ async function resolveOIDCConfig(oidcConfig, domain, ctx) {
5556
5571
  userInfoEndpoint: hydratedConfig.userInfoEndpoint,
5557
5572
  tokenEndpointAuthentication: hydratedConfig.tokenEndpointAuthentication,
5558
5573
  pkce: true,
5559
- ...oidcConfig.mapping ? { mapping: oidcConfig.mapping } : {}
5574
+ ...om ? { mapping: {
5575
+ id: om.id ?? "sub",
5576
+ email: om.email ?? "email",
5577
+ name: om.name ?? "name",
5578
+ emailVerified: om.emailVerified,
5579
+ image: om.image,
5580
+ extraFields: om.extraFields
5581
+ } } : {}
5560
5582
  },
5561
5583
  issuer: hydratedConfig.issuer
5562
5584
  };
5563
5585
  } catch (e) {
5564
- if (e instanceof DiscoveryError) {
5586
+ if (e instanceof sso.DiscoveryError) {
5565
5587
  ctx.context.logger.error("[Dash] OIDC discovery failed:", e);
5566
5588
  throw ctx.error("BAD_REQUEST", {
5567
5589
  message: `OIDC discovery failed: ${e.message}`,
@@ -5648,6 +5670,8 @@ const createSsoProvider = (options) => {
5648
5670
  ...buildSessionContext(userId)
5649
5671
  }
5650
5672
  });
5673
+ let verificationToken = null;
5674
+ if ("domainVerificationToken" in result && typeof result.domainVerificationToken === "string") verificationToken = result.domainVerificationToken;
5651
5675
  return {
5652
5676
  success: true,
5653
5677
  provider: {
@@ -5657,7 +5681,7 @@ const createSsoProvider = (options) => {
5657
5681
  },
5658
5682
  domainVerification: {
5659
5683
  txtRecordName: `better-auth-token-${providerId}`,
5660
- verificationToken: result.domainVerificationToken ?? null
5684
+ verificationToken
5661
5685
  }
5662
5686
  };
5663
5687
  } catch (e) {
@@ -5758,7 +5782,9 @@ const requestSsoVerificationToken = (options) => {
5758
5782
  requireOrganizationPlugin(ctx);
5759
5783
  requireOrganizationAccess(ctx);
5760
5784
  const ssoPlugin = getSSOPlugin(ctx);
5761
- if (!ssoPlugin?.endpoints?.requestDomainVerification || !ssoPlugin.options?.domainVerification?.enabled) throw ctx.error("BAD_REQUEST", { message: "SSO plugin with domain verification is not enabled or feature is not supported in your plugin version" });
5785
+ if (!ssoPlugin || !ssoPlugin.options?.domainVerification?.enabled) throw ctx.error("BAD_REQUEST", { message: "SSO plugin with domain verification is not enabled or feature is not supported in your plugin version" });
5786
+ const endpoints = ssoPlugin.endpoints;
5787
+ if (typeof endpoints.requestDomainVerification !== "function") throw ctx.error("BAD_REQUEST", { message: "SSO plugin with domain verification is not enabled or feature is not supported in your plugin version" });
5762
5788
  const organizationId = tryDecode(ctx.params.id);
5763
5789
  const { providerId } = ctx.body;
5764
5790
  const provider = await ctx.context.adapter.findOne({
@@ -5774,7 +5800,7 @@ const requestSsoVerificationToken = (options) => {
5774
5800
  if (!provider) throw ctx.error("NOT_FOUND", { message: "SSO provider not found" });
5775
5801
  const txtRecordName = `${ssoPlugin.options?.domainVerification?.tokenPrefix || "better-auth-token"}-${provider.providerId}`;
5776
5802
  try {
5777
- const result = await ssoPlugin.endpoints.requestDomainVerification({
5803
+ const result = await endpoints.requestDomainVerification({
5778
5804
  body: { providerId },
5779
5805
  context: {
5780
5806
  ...ctx.context,
@@ -5785,7 +5811,7 @@ const requestSsoVerificationToken = (options) => {
5785
5811
  success: true,
5786
5812
  providerId: provider.providerId,
5787
5813
  domain: provider.domain,
5788
- verificationToken: result.domainVerificationToken,
5814
+ verificationToken: result.domainVerificationToken ?? "",
5789
5815
  txtRecordName
5790
5816
  };
5791
5817
  } catch (e) {
@@ -5804,7 +5830,9 @@ const verifySsoProviderDomain = (options) => {
5804
5830
  requireOrganizationPlugin(ctx);
5805
5831
  requireOrganizationAccess(ctx);
5806
5832
  const ssoPlugin = getSSOPlugin(ctx);
5807
- if (!ssoPlugin?.endpoints?.verifyDomain || !ssoPlugin.options?.domainVerification?.enabled) throw ctx.error("BAD_REQUEST", { message: "SSO plugin with domain verification is not enabled or feature is not supported in your plugin version" });
5833
+ if (!ssoPlugin || !ssoPlugin.options?.domainVerification?.enabled) throw ctx.error("BAD_REQUEST", { message: "SSO plugin with domain verification is not enabled or feature is not supported in your plugin version" });
5834
+ const dvEndpoints = ssoPlugin.endpoints;
5835
+ if (typeof dvEndpoints.verifyDomain !== "function") throw ctx.error("BAD_REQUEST", { message: "SSO plugin with domain verification is not enabled or feature is not supported in your plugin version" });
5808
5836
  const organizationId = tryDecode(ctx.params.id);
5809
5837
  const { providerId } = ctx.body;
5810
5838
  const provider = await ctx.context.adapter.findOne({
@@ -5819,7 +5847,7 @@ const verifySsoProviderDomain = (options) => {
5819
5847
  });
5820
5848
  if (!provider) throw ctx.error("NOT_FOUND", { message: "SSO provider not found" });
5821
5849
  try {
5822
- await ssoPlugin.endpoints.verifyDomain({
5850
+ await dvEndpoints.verifyDomain({
5823
5851
  body: { providerId },
5824
5852
  context: {
5825
5853
  ...ctx.context,
@@ -6434,6 +6462,7 @@ const getUserDetails = (options) => {
6434
6462
  const { userId } = ctx.context.payload;
6435
6463
  const minimal = !!ctx.query?.minimal;
6436
6464
  const hasAdminPlugin = hasPlugin(ctx.context, "admin");
6465
+ const secondaryStorageOnly = isSessionInSecondaryStorageOnly(ctx.context);
6437
6466
  const user = await ctx.context.adapter.findOne({
6438
6467
  model: "user",
6439
6468
  where: [{
@@ -6442,7 +6471,7 @@ const getUserDetails = (options) => {
6442
6471
  }],
6443
6472
  ...minimal ? {} : { join: {
6444
6473
  account: true,
6445
- session: true
6474
+ ...secondaryStorageOnly ? {} : { session: true }
6446
6475
  } }
6447
6476
  });
6448
6477
  if (!user) throw ctx.error("NOT_FOUND", { message: "User not found" });
@@ -6456,10 +6485,10 @@ const getUserDetails = (options) => {
6456
6485
  session: []
6457
6486
  };
6458
6487
  const activityTrackingEnabled = !!options.activityTracking?.enabled;
6459
- const sessions = user.session || [];
6488
+ const sessions = secondaryStorageOnly ? await ctx.context.internalAdapter.listSessions(userId) : user.session || [];
6460
6489
  let lastActiveAt = null;
6461
6490
  if (activityTrackingEnabled) {
6462
- lastActiveAt = user.lastActiveAt;
6491
+ lastActiveAt = user.lastActiveAt ?? null;
6463
6492
  let shouldUpdateLastActiveAt = false;
6464
6493
  if (sessions.length > 0) {
6465
6494
  const mostRecentSession = [...sessions].sort((a, b) => {
@@ -6487,6 +6516,7 @@ const getUserDetails = (options) => {
6487
6516
  }
6488
6517
  return {
6489
6518
  ...user,
6519
+ ...secondaryStorageOnly ? { session: sessions } : {},
6490
6520
  lastActiveAt,
6491
6521
  banned: hasAdminPlugin ? user.banned ?? false : false,
6492
6522
  banReason: hasAdminPlugin ? user.banReason ?? null : null,
@@ -6537,7 +6567,7 @@ const getUserOrganizations = (options) => {
6537
6567
  role: m.role,
6538
6568
  teams: teams.filter((team) => team.organizationId === organization.id)
6539
6569
  };
6540
- }).filter(Boolean) };
6570
+ }).filter((row) => row != null) };
6541
6571
  });
6542
6572
  };
6543
6573
  const updateUser = (options) => createAuthEndpoint("/dash/update-user", {
@@ -6562,8 +6592,8 @@ const updateUser = (options) => createAuthEndpoint("/dash/update-user", {
6562
6592
  if (!user) throw new APIError("NOT_FOUND", { message: "User not found" });
6563
6593
  return user;
6564
6594
  });
6565
- async function countUniqueActiveUsers(adapter, range, activityTrackingEnabled) {
6566
- const field = activityTrackingEnabled ? "lastActiveAt" : "updatedAt";
6595
+ async function countUniqueActiveUsers(ctx, range, options) {
6596
+ const field = options.activityTrackingEnabled ? "lastActiveAt" : "updatedAt";
6567
6597
  const where = [{
6568
6598
  field,
6569
6599
  operator: "gte",
@@ -6574,11 +6604,31 @@ async function countUniqueActiveUsers(adapter, range, activityTrackingEnabled) {
6574
6604
  operator: "lt",
6575
6605
  value: range.to
6576
6606
  });
6577
- if (activityTrackingEnabled) return adapter.count({
6607
+ if (options.activityTrackingEnabled) return ctx.context.adapter.count({
6578
6608
  model: "user",
6579
6609
  where
6580
6610
  });
6581
- const sessions = await adapter.findMany({
6611
+ if (options.storeInSecondaryStorageOnly) {
6612
+ const users = await ctx.context.adapter.findMany({
6613
+ model: "user",
6614
+ select: ["id"]
6615
+ });
6616
+ const fromMs = range.from.getTime();
6617
+ const toMs = range.to?.getTime();
6618
+ const activeUserIds = /* @__PURE__ */ new Set();
6619
+ await withConcurrency(users, async (user) => {
6620
+ const sessions = await ctx.context.internalAdapter.listSessions(user.id);
6621
+ for (const session of sessions) {
6622
+ const updatedAt = new Date(session.updatedAt).getTime();
6623
+ if (updatedAt >= fromMs && (toMs === void 0 || updatedAt < toMs)) {
6624
+ activeUserIds.add(user.id);
6625
+ break;
6626
+ }
6627
+ }
6628
+ }, { concurrency: 50 });
6629
+ return activeUserIds.size;
6630
+ }
6631
+ const sessions = await ctx.context.adapter.findMany({
6582
6632
  model: "session",
6583
6633
  select: ["userId"],
6584
6634
  where
@@ -6597,6 +6647,7 @@ const getUserStats = (options) => createAuthEndpoint("/dash/user-stats", {
6597
6647
  const oneMonthAgo = /* @__PURE__ */ new Date(now.getTime() - 720 * 60 * 60 * 1e3);
6598
6648
  const twoMonthsAgo = /* @__PURE__ */ new Date(now.getTime() - 1440 * 60 * 60 * 1e3);
6599
6649
  const activityTrackingEnabled = !!options.activityTracking?.enabled;
6650
+ const storeInSecondaryStorageOnly = isSessionInSecondaryStorageOnly(ctx.context);
6600
6651
  const [dailyCount, previousDailyCount, weeklyCount, previousWeeklyCount, monthlyCount, previousMonthlyCount, totalCount, dailyActiveCount, previousDailyActiveCount, weeklyActiveCount, previousWeeklyActiveCount, monthlyActiveCount, previousMonthlyActiveCount] = await Promise.all([
6601
6652
  ctx.context.adapter.count({
6602
6653
  model: "user",
@@ -6659,21 +6710,39 @@ const getUserStats = (options) => createAuthEndpoint("/dash/user-stats", {
6659
6710
  }]
6660
6711
  }),
6661
6712
  ctx.context.adapter.count({ model: "user" }),
6662
- countUniqueActiveUsers(ctx.context.adapter, { from: oneDayAgo }, activityTrackingEnabled),
6663
- countUniqueActiveUsers(ctx.context.adapter, {
6713
+ countUniqueActiveUsers(ctx, { from: oneDayAgo }, {
6714
+ storeInSecondaryStorageOnly,
6715
+ activityTrackingEnabled
6716
+ }),
6717
+ countUniqueActiveUsers(ctx, {
6664
6718
  from: twoDaysAgo,
6665
6719
  to: oneDayAgo
6666
- }, activityTrackingEnabled),
6667
- countUniqueActiveUsers(ctx.context.adapter, { from: oneWeekAgo }, activityTrackingEnabled),
6668
- countUniqueActiveUsers(ctx.context.adapter, {
6720
+ }, {
6721
+ storeInSecondaryStorageOnly,
6722
+ activityTrackingEnabled
6723
+ }),
6724
+ countUniqueActiveUsers(ctx, { from: oneWeekAgo }, {
6725
+ storeInSecondaryStorageOnly,
6726
+ activityTrackingEnabled
6727
+ }),
6728
+ countUniqueActiveUsers(ctx, {
6669
6729
  from: twoWeeksAgo,
6670
6730
  to: oneWeekAgo
6671
- }, activityTrackingEnabled),
6672
- countUniqueActiveUsers(ctx.context.adapter, { from: oneMonthAgo }, activityTrackingEnabled),
6673
- countUniqueActiveUsers(ctx.context.adapter, {
6731
+ }, {
6732
+ storeInSecondaryStorageOnly,
6733
+ activityTrackingEnabled
6734
+ }),
6735
+ countUniqueActiveUsers(ctx, { from: oneMonthAgo }, {
6736
+ storeInSecondaryStorageOnly,
6737
+ activityTrackingEnabled
6738
+ }),
6739
+ countUniqueActiveUsers(ctx, {
6674
6740
  from: twoMonthsAgo,
6675
6741
  to: oneMonthAgo
6676
- }, activityTrackingEnabled)
6742
+ }, {
6743
+ storeInSecondaryStorageOnly,
6744
+ activityTrackingEnabled
6745
+ })
6677
6746
  ]);
6678
6747
  const calculatePercentage = (current, previous) => {
6679
6748
  if (previous === 0) return current > 0 ? 100 : 0;
@@ -6721,6 +6790,7 @@ const getUserGraphData = (options) => createAuthEndpoint("/dash/user-graph-data"
6721
6790
  const { period } = ctx.query;
6722
6791
  const now = /* @__PURE__ */ new Date();
6723
6792
  const activityTrackingEnabled = !!options.activityTracking?.enabled;
6793
+ const storeInSecondaryStorageOnly = isSessionInSecondaryStorageOnly(ctx.context);
6724
6794
  const intervals = period === "daily" ? 7 : period === "weekly" ? 8 : 6;
6725
6795
  const msPerInterval = period === "daily" ? 1440 * 60 * 1e3 : period === "weekly" ? 10080 * 60 * 1e3 : 720 * 60 * 60 * 1e3;
6726
6796
  const intervalData = [];
@@ -6761,10 +6831,13 @@ const getUserGraphData = (options) => createAuthEndpoint("/dash/user-graph-data"
6761
6831
  value: interval.endDate
6762
6832
  }]
6763
6833
  }),
6764
- countUniqueActiveUsers(ctx.context.adapter, {
6834
+ countUniqueActiveUsers(ctx, {
6765
6835
  from: interval.startDate,
6766
6836
  to: interval.endDate
6767
- }, activityTrackingEnabled)
6837
+ }, {
6838
+ storeInSecondaryStorageOnly,
6839
+ activityTrackingEnabled
6840
+ })
6768
6841
  ]);
6769
6842
  const results = await Promise.all(allQueries);
6770
6843
  return {
@@ -6803,6 +6876,7 @@ const getUserRetentionData = (options) => createAuthEndpoint("/dash/user-retenti
6803
6876
  */
6804
6877
  const { period } = ctx.query;
6805
6878
  const activityTrackingEnabled = !!options.activityTracking?.enabled;
6879
+ const secondaryOnly = isSessionInSecondaryStorageOnly(ctx.context);
6806
6880
  const now = /* @__PURE__ */ new Date();
6807
6881
  const startOfUtcDay = (d) => new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
6808
6882
  const startOfUtcMonth = (d) => new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1));
@@ -6884,7 +6958,22 @@ const getUserRetentionData = (options) => createAuthEndpoint("/dash/user-retenti
6884
6958
  ctx.context.logger.warn("[Dash] Failed to count retained users by lastActiveAt:", error);
6885
6959
  return 0;
6886
6960
  });
6887
- else {
6961
+ else if (secondaryOnly) {
6962
+ const activeStartMs = activeStart.getTime();
6963
+ const activeEndMs = activeEnd.getTime();
6964
+ const retainedUsers = /* @__PURE__ */ new Set();
6965
+ await withConcurrency(cohortUserIds, async (userId) => {
6966
+ const sessions = await ctx.context.internalAdapter.listSessions(userId);
6967
+ for (const session of sessions) {
6968
+ const updatedAt = new Date(session.updatedAt).getTime();
6969
+ if (updatedAt >= activeStartMs && updatedAt < activeEndMs) {
6970
+ retainedUsers.add(userId);
6971
+ break;
6972
+ }
6973
+ }
6974
+ }, { concurrency: 50 });
6975
+ retained = retainedUsers.size;
6976
+ } else {
6888
6977
  const sessionsInActiveWindow = await ctx.context.adapter.findMany({
6889
6978
  model: "session",
6890
6979
  select: ["userId"],
@@ -6946,20 +7035,7 @@ const banUser = (options) => createAuthEndpoint("/dash/ban-user", {
6946
7035
  banExpires: banExpires ? new Date(banExpires) : null,
6947
7036
  updatedAt: /* @__PURE__ */ new Date()
6948
7037
  });
6949
- const sessions = await ctx.context.adapter.findMany({
6950
- model: "session",
6951
- where: [{
6952
- field: "userId",
6953
- value: userId
6954
- }]
6955
- });
6956
- for (const session of sessions) await ctx.context.adapter.delete({
6957
- model: "session",
6958
- where: [{
6959
- field: "id",
6960
- value: session.id
6961
- }]
6962
- });
7038
+ await ctx.context.internalAdapter.deleteSessions(userId);
6963
7039
  return { success: true };
6964
7040
  });
6965
7041
  const banManyUsers = (options) => {
@@ -7293,6 +7369,8 @@ const dash = (options) => {
7293
7369
  const { trackOrganizationMemberInvited, trackOrganizationMemberInviteAccepted, trackOrganizationMemberInviteCanceled, trackOrganizationMemberInviteRejected } = initInvitationEvents(tracker);
7294
7370
  return {
7295
7371
  id: "dash",
7372
+ options: opts,
7373
+ version: PLUGIN_VERSION,
7296
7374
  init(ctx) {
7297
7375
  const organizationPlugin = ctx.getPlugin("organization");
7298
7376
  if (organizationPlugin) {
@@ -7645,7 +7723,6 @@ const dash = (options) => {
7645
7723
  updateDashUser: updateUser(opts),
7646
7724
  setDashPassword: setPassword(opts),
7647
7725
  unlinkDashAccount: unlinkAccount(opts),
7648
- listAllDashSessions: listAllSessions(opts),
7649
7726
  dashRevokeSession: revokeSession(opts),
7650
7727
  dashRevokeAllSessions: revokeAllSessions(opts),
7651
7728
  dashRevokeManySessions: revokeManySessions(opts),
@@ -7689,7 +7766,10 @@ const dash = (options) => {
7689
7766
  regenerateDashDirectoryToken: regenerateDirectoryToken(opts),
7690
7767
  dashExecuteAdapter: executeAdapter(opts)
7691
7768
  },
7692
- schema: opts.activityTracking?.enabled ? { user: { fields: { lastActiveAt: { type: "date" } } } } : {}
7769
+ schema: opts.activityTracking?.enabled ? { user: { fields: { lastActiveAt: {
7770
+ type: "date",
7771
+ required: false
7772
+ } } } } : {}
7693
7773
  };
7694
7774
  };
7695
7775
  //#endregion