@better-auth/infra 0.2.5 → 0.2.7

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
@@ -1,10 +1,11 @@
1
- import { n as INFRA_KV_URL, r as KV_TIMEOUT_MS, t as INFRA_API_URL } from "./constants-DdWGfvz1.mjs";
1
+ import { n as INFRA_API_URL, r as INFRA_KV_URL } from "./constants-CvriWQVc.mjs";
2
+ import { n as createKV, t as createAPI } from "./fetch-DiAhoiKA.mjs";
2
3
  import { EMAIL_TEMPLATES, createEmailSender, sendBulkEmails, sendEmail } from "./email.mjs";
3
4
  import { APIError, generateId, getAuthTables, logger, parseState } from "better-auth";
4
5
  import { env } from "@better-auth/core/env";
5
6
  import { APIError as APIError$1, createAuthEndpoint, createAuthMiddleware, requestPasswordReset, sendVerificationEmailFn, sessionMiddleware } from "better-auth/api";
6
7
  import { getCurrentAuthContext } from "@better-auth/core/context";
7
- import { betterFetch, createFetch } from "@better-fetch/fetch";
8
+ import { createFetch } from "@better-fetch/fetch";
8
9
  import { isValidPhoneNumber, parsePhoneNumberFromString } from "libphonenumber-js";
9
10
  import { createLocalJWKSet, jwtVerify } from "jose";
10
11
  import z$1, { z } from "zod";
@@ -14,7 +15,9 @@ function resolveConnectionOptions(options) {
14
15
  return {
15
16
  apiUrl: options?.apiUrl || INFRA_API_URL,
16
17
  kvUrl: options?.kvUrl || INFRA_KV_URL,
17
- apiKey: options?.apiKey || env.BETTER_AUTH_API_KEY || ""
18
+ apiKey: options?.apiKey || env.BETTER_AUTH_API_KEY || "",
19
+ apiTimeout: options?.apiTimeout ?? 3e3,
20
+ kvTimeout: options?.kvTimeout ?? 1e3
18
21
  };
19
22
  }
20
23
  function resolveDashOptions(options) {
@@ -96,6 +99,8 @@ const routes = {
96
99
  EMAIL_OTP_SEND: "/email-otp/send-verification-otp",
97
100
  PHONE_SEND_OTP: "/phone-number/send-otp",
98
101
  PHONE_VERIFY_OTP: "/phone-number/verify-phone-number",
102
+ DEVICE_TOKEN: "/device/token",
103
+ SIWE_VERIFY: "/siwe/verify",
99
104
  ORG_CREATE: "/organization/create",
100
105
  ORG_INVITE_MEMBER: "/organization/invite-member",
101
106
  API_KEY_CREATE: "/api-key/create",
@@ -109,7 +114,8 @@ const routes = {
109
114
  ADMIN_REVOKE_USER_SESSIONS: "/admin/revoke-user-sessions",
110
115
  ADMIN_SET_PASSWORD: "/admin/set-user-password",
111
116
  ADMIN_BAN_USER: "/admin/ban-user",
112
- ADMIN_UNBAN_USER: "/admin/unban-user"
117
+ ADMIN_UNBAN_USER: "/admin/unban-user",
118
+ ADMIN_IMPERSONATE_USER: "/admin/impersonate-user"
113
119
  };
114
120
  const ORGANIZATION_EVENT_TYPES = {
115
121
  ORGANIZATION_CREATED: "organization_created",
@@ -308,16 +314,33 @@ const matchesAnyRoute = (path, routes) => {
308
314
  };
309
315
  //#endregion
310
316
  //#region src/events/login-methods.ts
311
- const loginPaths = [
312
- routes.SIGN_IN_SOCIAL_CALLBACK,
313
- routes.SIGN_IN_OAUTH_CALLBACK,
314
- routes.SIGN_IN_EMAIL,
315
- routes.SIGN_IN_SOCIAL,
316
- routes.SIGN_IN_EMAIL_OTP,
317
- routes.SIGN_UP_EMAIL
317
+ const OAUTH_CALLBACK_PATHS = [routes.SIGN_IN_SOCIAL_CALLBACK, routes.SIGN_IN_OAUTH_CALLBACK];
318
+ const PATH_TO_METHOD = [
319
+ [routes.SIGN_IN_EMAIL, "email"],
320
+ [routes.SIGN_UP_EMAIL, "email"],
321
+ [routes.SIGN_IN_USERNAME, "username"],
322
+ [routes.SIGN_IN_EMAIL_OTP, "email-otp"],
323
+ [routes.SIGN_IN_SOCIAL, "social"],
324
+ [routes.SIGN_IN_ANONYMOUS, "anonymous"],
325
+ [routes.SIGN_IN_PASSKEY, "passkey"],
326
+ [routes.SIGN_IN_MAGIC_LINK, "magic-link"],
327
+ [routes.MAGIC_LINK_VERIFY, "magic-link"],
328
+ [routes.SIGN_IN_SSO, "sso"],
329
+ [routes.PHONE_VERIFY_OTP, "phone"],
330
+ [routes.TWO_FACTOR_VERIFY_TOTP, "totp"],
331
+ [routes.TWO_FACTOR_VERIFY_OTP, "two-factor-otp"],
332
+ [routes.TWO_FACTOR_VERIFY_BACKUP, "backup-code"],
333
+ [routes.ADMIN_IMPERSONATE_USER, "impersonation"],
334
+ [routes.DEVICE_TOKEN, "device-code"],
335
+ [routes.SIWE_VERIFY, "siwe"]
318
336
  ];
319
337
  const getLoginMethod = (ctx) => {
320
- if (matchesAnyRoute(ctx.path, loginPaths)) return ctx.params?.id ? ctx.params.id : ctx.path.split("/").pop();
338
+ if (matchesAnyRoute(ctx.path, OAUTH_CALLBACK_PATHS)) {
339
+ const id = ctx.params?.id;
340
+ if (typeof id === "string" && id && !id.startsWith(":")) return id;
341
+ return null;
342
+ }
343
+ for (const [path, method] of PATH_TO_METHOD) if (matchesAnyRoute(ctx.path, [path])) return method;
321
344
  return null;
322
345
  };
323
346
  //#endregion
@@ -812,18 +835,11 @@ const getOrganizationTriggerInfo = (user) => {
812
835
  };
813
836
  //#endregion
814
837
  //#region src/events/index.ts
815
- const initTrackEvents = (options) => {
816
- const $fetch = createFetch({
817
- baseURL: options.apiUrl,
818
- headers: {
819
- "user-agent": "better-auth",
820
- "x-api-key": options.apiKey
821
- }
822
- });
838
+ const initTrackEvents = ($api) => {
823
839
  const trackEvent = (data) => {
824
840
  const track = async () => {
825
841
  try {
826
- await $fetch("/events/track", {
842
+ await $api("/events/track", {
827
843
  method: "POST",
828
844
  body: {
829
845
  eventType: data.eventType,
@@ -874,11 +890,10 @@ function maybeCleanup() {
874
890
  /**
875
891
  * Fetch identification data from durable-kv by requestId
876
892
  */
877
- async function getIdentification(requestId, apiKey, kvUrl) {
893
+ async function getIdentification(requestId, $kv) {
878
894
  maybeCleanup();
879
895
  const cached = identificationCache.get(requestId);
880
896
  if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) return cached.data;
881
- const baseUrl = kvUrl || INFRA_KV_URL;
882
897
  const maxRetries = 3;
883
898
  const retryDelays = [
884
899
  50,
@@ -886,24 +901,20 @@ async function getIdentification(requestId, apiKey, kvUrl) {
886
901
  200
887
902
  ];
888
903
  for (let attempt = 0; attempt <= maxRetries; attempt++) try {
889
- const response = await fetch(`${baseUrl}/identify/${requestId}`, {
890
- method: "GET",
891
- headers: { "x-api-key": apiKey },
892
- signal: AbortSignal.timeout(KV_TIMEOUT_MS)
893
- });
894
- if (response.ok) {
895
- const data = await response.json();
904
+ const { data, error } = await $kv(`/identify/${requestId}`, { method: "GET" });
905
+ if (data && !error) {
896
906
  identificationCache.set(requestId, {
897
907
  data,
898
908
  timestamp: Date.now()
899
909
  });
900
910
  return data;
901
911
  }
902
- if (response.status === 404 && attempt < maxRetries) {
912
+ const status = error?.status;
913
+ if (status === 404 && attempt < maxRetries) {
903
914
  await new Promise((resolve) => setTimeout(resolve, retryDelays[attempt]));
904
915
  continue;
905
916
  }
906
- if (response.status !== 404) identificationCache.set(requestId, {
917
+ if (status !== 404) identificationCache.set(requestId, {
907
918
  data: null,
908
919
  timestamp: Date.now()
909
920
  });
@@ -931,16 +942,19 @@ function extractIdentificationHeaders(request) {
931
942
  };
932
943
  }
933
944
  /**
934
- * Early middleware that loads identification data
945
+ * Early middleware that loads identification data.
946
+ *
947
+ * @param $kv — KV client from {@link createKV} for the same plugin options (one instance per plugin).
935
948
  */
936
- function createIdentificationMiddleware(options) {
949
+ function createIdentificationMiddleware($kv) {
937
950
  return createAuthMiddleware(async (ctx) => {
938
951
  const { visitorId, requestId: headerRequestId } = extractIdentificationHeaders(ctx.request);
939
952
  const requestId = headerRequestId ?? ctx.getCookie("__infra-rid") ?? null;
940
953
  ctx.context.visitorId = visitorId;
941
954
  ctx.context.requestId = requestId;
942
- if (requestId) ctx.context.identification = ctx.context.identification ?? await getIdentification(requestId, options.apiKey, options.kvUrl) ?? null;
943
- else ctx.context.identification = null;
955
+ if (requestId) {
956
+ if (ctx.context.identification === void 0) ctx.context.identification = await getIdentification(requestId, $kv) ?? null;
957
+ } else ctx.context.identification = null;
944
958
  const ipConfig = ctx.context.options?.advanced?.ipAddress;
945
959
  if (ipConfig?.disableIpTracking === true) {
946
960
  ctx.context.location = void 0;
@@ -997,6 +1011,59 @@ function getCountryCodeFromRequest(request) {
997
1011
  return cc ? cc.toUpperCase() : void 0;
998
1012
  }
999
1013
  //#endregion
1014
+ //#region src/sentinel/evaluation.ts
1015
+ /** Lazily attach the buffer to the request context. */
1016
+ function getEvaluation(ctx) {
1017
+ if (!ctx.context.sentinelEvaluation) ctx.context.sentinelEvaluation = {
1018
+ checks: /* @__PURE__ */ new Set(),
1019
+ outcome: "passed",
1020
+ emitted: false
1021
+ };
1022
+ return ctx.context.sentinelEvaluation;
1023
+ }
1024
+ function recordCheck(ctx, name) {
1025
+ getEvaluation(ctx).checks.add(name);
1026
+ }
1027
+ /** Call immediately before throwing so the buffer reflects the final outcome. */
1028
+ function setOutcome(ctx, outcome, triggeredBy, details) {
1029
+ const ev = getEvaluation(ctx);
1030
+ ev.outcome = outcome;
1031
+ ev.triggeredBy = triggeredBy;
1032
+ if (details) ev.details = details;
1033
+ }
1034
+ /**
1035
+ * Emit one `security_check` event for the request. Idempotent per request and
1036
+ * skipped when no checks ran (unprotected route, or short-circuit before any
1037
+ * sentinel hook). `evaluatedChecks` carries the per-type breakdown — all under
1038
+ * the same Stripe meter, so the array is for support inspection only.
1039
+ */
1040
+ function emitEvaluation(ctx, trackEvent, options = {}) {
1041
+ const ev = ctx.context.sentinelEvaluation;
1042
+ if (!ev || ev.emitted || ev.checks.size === 0) return;
1043
+ ev.emitted = true;
1044
+ const identification = ctx.context.identification ?? null;
1045
+ const visitorId = ctx.context.visitorId ?? null;
1046
+ const path = ctx.path ?? "";
1047
+ trackEvent({
1048
+ eventKey: visitorId || identification?.ip || "unknown",
1049
+ eventType: "security_check",
1050
+ eventDisplayName: ev.outcome === "blocked" ? "Security: blocked" : ev.outcome === "challenged" ? "Security: challenged" : "Security: passed",
1051
+ eventData: {
1052
+ outcome: ev.outcome,
1053
+ evaluatedChecks: [...ev.checks].sort(),
1054
+ triggeredBy: ev.triggeredBy,
1055
+ identifier: options.identifier,
1056
+ path,
1057
+ userAgent: options.userAgent,
1058
+ details: ev.details
1059
+ },
1060
+ ipAddress: identification?.ip || void 0,
1061
+ city: identification?.location?.city || void 0,
1062
+ country: identification?.location?.country?.name || void 0,
1063
+ countryCode: identification?.location?.country?.code || void 0
1064
+ });
1065
+ }
1066
+ //#endregion
1000
1067
  //#region src/sentinel/security.ts
1001
1068
  async function hashForFingerprint(input) {
1002
1069
  const data = new TextEncoder().encode(input);
@@ -1011,23 +1078,19 @@ async function sha1Hash(input) {
1011
1078
  /**
1012
1079
  * Whether Sentinel should normalize emails for deduplication/sign-in consistency.
1013
1080
  * If `emailNormalization` is set, it wins; otherwise legacy behavior uses `emailValidation.enabled`.
1014
- * @internal Not part of the package public API; used by the sentinel plugin and email helpers.
1015
1081
  */
1016
1082
  function isEmailNormalizationEnabled(security) {
1017
1083
  const explicit = security?.emailNormalization;
1018
1084
  if (explicit !== void 0) return explicit.enabled !== false;
1019
1085
  return security?.emailValidation?.enabled !== false;
1020
1086
  }
1021
- function createSecurityClient(apiUrl, apiKey, options, onSecurityEvent) {
1022
- const resolvedApiUrl = apiUrl || INFRA_API_URL;
1023
- const $api = createFetch({
1024
- baseURL: resolvedApiUrl,
1025
- headers: { "x-api-key": apiKey },
1026
- throw: true
1027
- });
1087
+ /**
1088
+ * @param $api Dash client from `createAPI(opts, { throw: true })`.
1089
+ */
1090
+ function createSecurityClient(conn, $api, options, onSecurityEvent) {
1028
1091
  const emailSender = createEmailSender({
1029
- apiUrl: resolvedApiUrl,
1030
- apiKey
1092
+ apiUrl: conn.apiUrl || INFRA_API_URL,
1093
+ apiKey: conn.apiKey
1031
1094
  });
1032
1095
  function logEvent(event) {
1033
1096
  const fullEvent = {
@@ -1183,6 +1246,9 @@ function createSecurityClient(apiUrl, apiKey, options, onSecurityEvent) {
1183
1246
  return null;
1184
1247
  }
1185
1248
  },
1249
+ /**
1250
+ * Store user's last known location for impossible travel detection
1251
+ */
1186
1252
  async storeLastLocation(userId, location) {
1187
1253
  if (!location) return;
1188
1254
  try {
@@ -1197,6 +1263,9 @@ function createSecurityClient(apiUrl, apiKey, options, onSecurityEvent) {
1197
1263
  logger.error("[Dash] Store last location error:", error);
1198
1264
  }
1199
1265
  },
1266
+ /**
1267
+ * Check if a visitor has exceeded the free trial signup limit.
1268
+ */
1200
1269
  async checkFreeTrialAbuse(visitorId) {
1201
1270
  if (!options.freeTrialAbuse?.enabled) return {
1202
1271
  isAbuse: false,
@@ -1235,6 +1304,10 @@ function createSecurityClient(apiUrl, apiKey, options, onSecurityEvent) {
1235
1304
  };
1236
1305
  }
1237
1306
  },
1307
+ /**
1308
+ * Track a new signup for free trial abuse detection.
1309
+ * Stores the userId for auditing purposes.
1310
+ */
1238
1311
  async trackFreeTrialSignup(visitorId, userId) {
1239
1312
  if (!options.freeTrialAbuse?.enabled) return;
1240
1313
  try {
@@ -1285,6 +1358,10 @@ function createSecurityClient(apiUrl, apiKey, options, onSecurityEvent) {
1285
1358
  return { compromised: false };
1286
1359
  }
1287
1360
  },
1361
+ /**
1362
+ * Check if a user account is stale (inactive for a configured period)
1363
+ * This helps detect potential account takeover when dormant accounts become active
1364
+ */
1288
1365
  async checkStaleUser(userId, lastActiveAt) {
1289
1366
  if (!options.staleUsers?.enabled) return { isStale: false };
1290
1367
  try {
@@ -1317,6 +1394,9 @@ function createSecurityClient(apiUrl, apiKey, options, onSecurityEvent) {
1317
1394
  return { isStale: false };
1318
1395
  }
1319
1396
  },
1397
+ /**
1398
+ * Send stale account reactivation notification to the user
1399
+ */
1320
1400
  async notifyStaleAccountUser(userEmail, userName, daysSinceLastActive, identification, appName) {
1321
1401
  const loginTime = (/* @__PURE__ */ new Date()).toLocaleString("en-US", {
1322
1402
  dateStyle: "long",
@@ -1344,6 +1424,9 @@ function createSecurityClient(apiUrl, apiKey, options, onSecurityEvent) {
1344
1424
  if (result.success) logger.info(`[Dash] Stale account notification sent to user: ${userEmail}`);
1345
1425
  else logger.error(`[Dash] Failed to send stale account user notification: ${result.error}`);
1346
1426
  },
1427
+ /**
1428
+ * Send stale account reactivation notification to the admin
1429
+ */
1347
1430
  async notifyStaleAccountAdmin(adminEmail, userId, userEmail, userName, daysSinceLastActive, identification, appName) {
1348
1431
  const loginTime = (/* @__PURE__ */ new Date()).toLocaleString("en-US", {
1349
1432
  dateStyle: "long",
@@ -1412,7 +1495,9 @@ const all = new Set([
1412
1495
  "/forget-password",
1413
1496
  "/request-password-reset",
1414
1497
  "/send-verification-email",
1415
- "/change-email"
1498
+ "/change-email",
1499
+ "/organization/invite-member",
1500
+ "/dash/organization/invite-member"
1416
1501
  ]);
1417
1502
  /**
1418
1503
  * Path is one of `[
@@ -1429,7 +1514,9 @@ const all = new Set([
1429
1514
  * '/forget-password',
1430
1515
  * '/request-password-reset',
1431
1516
  * '/send-verification-email',
1432
- * '/change-email'
1517
+ * '/change-email',
1518
+ * '/organization/invite-member',
1519
+ * '/dash/organization/invite-member'
1433
1520
  * ]`.
1434
1521
  * @param context Request context
1435
1522
  * @param context.path Request path
@@ -1487,17 +1574,11 @@ function normalizeEmail(email, context) {
1487
1574
  if (GMAIL_LIKE_DOMAINS.has(domain)) localPart = localPart.replace(/\./g, "");
1488
1575
  return `${localPart}@${domain}`;
1489
1576
  }
1490
- function createEmailValidator(options = {}) {
1491
- const { apiKey = "", apiUrl = INFRA_API_URL, kvUrl = INFRA_KV_URL, emailValidationOptions = {} } = options;
1492
- const $api = createFetch({
1493
- baseURL: apiUrl,
1494
- headers: { "x-api-key": apiKey }
1495
- });
1496
- const $kv = createFetch({
1497
- baseURL: kvUrl,
1498
- headers: { "x-api-key": apiKey },
1499
- timeout: KV_TIMEOUT_MS
1500
- });
1577
+ function hasRemoteEmailClients(options) {
1578
+ return options.$api !== void 0 && options.$kv !== void 0;
1579
+ }
1580
+ function createEmailValidator(options) {
1581
+ const { $api, $kv, emailValidationOptions = {} } = options;
1501
1582
  /**
1502
1583
  * Fetch and resolve email validity policy from API with caching
1503
1584
  * Sends client config to API which merges with user's dashboard settings
@@ -1522,7 +1603,12 @@ function createEmailValidator(options = {}) {
1522
1603
  }
1523
1604
  return null;
1524
1605
  }
1525
- return { async validate(email, checkMx = true) {
1606
+ return {
1607
+ /**
1608
+ * Validate an email address
1609
+ * Returns validation result - caller should use getPolicy() to determine action
1610
+ */
1611
+ async validate(email, checkMx = true) {
1526
1612
  const trimmed = email.trim();
1527
1613
  const policy = await fetchPolicy();
1528
1614
  if (!policy?.enabled) return {
@@ -1608,16 +1694,27 @@ function createEmailNormalizationHook() {
1608
1694
  };
1609
1695
  }
1610
1696
  /**
1611
- * Create email validation hook with configurable validation strategy
1697
+ * Records `email_validity` on every email-bearing request. On block, emits the
1698
+ * `security_check` event inline before throwing — the after-hook won't run on throw.
1612
1699
  */
1613
- function createEmailValidationHook(validator, onDisposableEmail) {
1700
+ function createEmailValidationHook(validator, trackEvent) {
1614
1701
  return {
1615
1702
  matcher: allEmail,
1616
1703
  handler: createAuthMiddleware(async (ctx) => {
1617
1704
  const { email } = getEmail(ctx);
1618
1705
  if (typeof email !== "string") return;
1619
1706
  const trimmed = email.trim();
1620
- if (!isValidEmailFormatLocal(trimmed)) throw new APIError$1("BAD_REQUEST", { message: "Invalid email" });
1707
+ recordCheck(ctx, "email_validity");
1708
+ if (!isValidEmailFormatLocal(trimmed)) {
1709
+ if (trackEvent) {
1710
+ setOutcome(ctx, "blocked", "email_validity", {
1711
+ reason: "invalid_format",
1712
+ email: trimmed
1713
+ });
1714
+ emitEvaluation(ctx, trackEvent, { identifier: trimmed });
1715
+ }
1716
+ throw new APIError$1("BAD_REQUEST", { message: "Invalid email" });
1717
+ }
1621
1718
  if (validator) {
1622
1719
  const result = await validator.validate(trimmed);
1623
1720
  const policy = result.policy;
@@ -1628,19 +1725,17 @@ function createEmailValidationHook(validator, onDisposableEmail) {
1628
1725
  }
1629
1726
  const action = policy.action;
1630
1727
  if (!result.valid) {
1631
- 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) {
1632
- const ip = ctx.request?.headers?.get("x-forwarded-for")?.split(",")[0] || ctx.request?.headers?.get("cf-connecting-ip") || void 0;
1633
- onDisposableEmail({
1634
- email: trimmed,
1635
- reason: result.reason || "disposable",
1728
+ if (action === "allow") return;
1729
+ const 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";
1730
+ if (trackEvent) {
1731
+ setOutcome(ctx, "blocked", "email_validity", {
1732
+ reason: result.reason,
1636
1733
  confidence: result.confidence,
1637
- ip,
1638
- path: ctx.path,
1639
- action
1734
+ email: trimmed
1640
1735
  });
1736
+ emitEvaluation(ctx, trackEvent, { identifier: trimmed });
1641
1737
  }
1642
- if (action === "allow") return;
1643
- 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" });
1738
+ throw new APIError$1("BAD_REQUEST", { message });
1644
1739
  }
1645
1740
  }
1646
1741
  })
@@ -1657,18 +1752,10 @@ function createEmailValidationHook(validator, onDisposableEmail) {
1657
1752
  *
1658
1753
  * @example
1659
1754
  * // API-backed validation
1660
- * createEmailHooks({ useApi: true, apiKey: "your-api-key" })
1661
- *
1662
- * @example
1663
- * // High strictness + API
1664
- * createEmailHooks({
1665
- * emailValidationOptions: { strictness: "high" },
1666
- * useApi: true,
1667
- * apiKey: "your-api-key",
1668
- * })
1755
+ * createEmailHooks({ $api, $kv })
1669
1756
  */
1670
1757
  function createEmailHooks(options = {}) {
1671
- const { emailValidationOptions, emailNormalizationOptions, useApi = false, apiKey = "", apiUrl = INFRA_API_URL, kvUrl = INFRA_KV_URL, onDisposableEmail } = options;
1758
+ const { emailValidationOptions, emailNormalizationOptions, trackEvent } = options;
1672
1759
  const emailConfig = {
1673
1760
  enabled: true,
1674
1761
  strictness: "medium",
@@ -1678,12 +1765,11 @@ function createEmailHooks(options = {}) {
1678
1765
  return { before: [...isEmailNormalizationEnabled({
1679
1766
  emailValidation: emailValidationOptions,
1680
1767
  emailNormalization: emailNormalizationOptions
1681
- }) ? [createEmailNormalizationHook()] : [], ...emailConfig.enabled ? [createEmailValidationHook(useApi ? createEmailValidator({
1682
- apiUrl,
1683
- kvUrl,
1684
- apiKey,
1768
+ }) ? [createEmailNormalizationHook()] : [], ...emailConfig.enabled ? [createEmailValidationHook(hasRemoteEmailClients(options) ? createEmailValidator({
1769
+ $api: options.$api,
1770
+ $kv: options.$kv,
1685
1771
  emailValidationOptions: emailConfig
1686
- }) : void 0, onDisposableEmail)] : []] };
1772
+ }) : void 0, trackEvent)] : []] };
1687
1773
  }
1688
1774
  //#endregion
1689
1775
  //#region src/validation/phone.ts
@@ -1898,32 +1984,6 @@ const ERROR_MESSAGES = {
1898
1984
  compromised_password: "This password has been found in data breaches. Please choose a different password.",
1899
1985
  impossible_travel: "Login blocked due to suspicious location change."
1900
1986
  };
1901
- const DISPLAY_NAMES = {
1902
- geo_blocked: {
1903
- challenge: "Security: geo challenge",
1904
- block: "Security: geo blocked"
1905
- },
1906
- bot_detected: {
1907
- challenge: "Security: bot challenge",
1908
- block: "Security: bot blocked"
1909
- },
1910
- suspicious_ip_detected: {
1911
- challenge: "Security: anonymous IP challenge",
1912
- block: "Security: anonymous IP blocked"
1913
- },
1914
- rate_limited: {
1915
- challenge: "Security: velocity challenge",
1916
- block: "Security: velocity exceeded"
1917
- },
1918
- compromised_password: {
1919
- challenge: "Security: breached password warning",
1920
- block: "Security: breached password blocked"
1921
- },
1922
- impossible_travel: {
1923
- challenge: "Security: impossible travel challenge",
1924
- block: "Security: impossible travel blocked"
1925
- }
1926
- };
1927
1987
  /**
1928
1988
  * Throw a challenge error with appropriate headers
1929
1989
  */
@@ -1938,86 +1998,68 @@ function throwChallengeError(challenge, reason, message = "Please complete a sec
1938
1998
  };
1939
1999
  throw error;
1940
2000
  }
1941
- /**
1942
- * Build common event data for security tracking
1943
- */
1944
- function buildEventData(ctx, action, reason, confidence = 1, extraData) {
1945
- const { visitorId, identification, path, identifier, userAgent } = ctx;
1946
- const countryCode = identification?.location?.country?.code || void 0;
1947
- return {
1948
- eventKey: visitorId || identification?.ip || "unknown",
1949
- eventType: action === "challenged" ? "security_challenged" : "security_blocked",
1950
- eventDisplayName: DISPLAY_NAMES[reason]?.[action === "challenged" ? "challenge" : "block"] || `Security: ${reason}`,
1951
- eventData: {
1952
- action,
1953
- reason,
1954
- visitorId: visitorId || "",
1955
- path,
1956
- userAgent,
1957
- identifier,
1958
- confidence,
1959
- ...extraData
1960
- },
1961
- ipAddress: identification?.ip || void 0,
1962
- city: identification?.location?.city || void 0,
1963
- country: identification?.location?.country?.name || void 0,
1964
- countryCode
1965
- };
1966
- }
1967
- /**
1968
- * Handle a security check result by tracking events and throwing appropriate errors
1969
- *
1970
- * @param verdict - The security verdict from the security service
1971
- * @param ctx - Security check context with request information
1972
- * @param trackEvent - Function to track security events
1973
- * @param securityService - Security service for generating challenges
1974
- * @returns True if the request should be blocked
1975
- */
1976
- async function handleSecurityVerdict(verdict, ctx, trackEvent, securityService) {
2001
+ /** Emits the `security_check` event inline before throwing — the after-hook won't run on throw. */
2002
+ async function handleSecurityVerdict(verdict, hookCtx, checkCtx, trackEvent, securityService) {
1977
2003
  if (verdict.action === "allow") return;
1978
2004
  const reason = verdict.reason || "unknown";
1979
- const confidence = 1;
1980
- if (verdict.action === "challenge" && ctx.visitorId) {
1981
- const challenge = verdict.challenge || await securityService.generateChallenge(ctx.visitorId);
2005
+ if (verdict.action === "challenge" && checkCtx.visitorId) {
2006
+ const challenge = verdict.challenge || await securityService.generateChallenge(checkCtx.visitorId);
1982
2007
  if (!challenge?.trim()) {
1983
2008
  logger.warn("[Sentinel] Could not generate PoW challenge (service may be unavailable). Falling back to allow.");
1984
2009
  return;
1985
2010
  }
1986
- trackEvent(buildEventData(ctx, "challenged", reason, confidence, verdict.details));
1987
- throwChallengeError(challenge, reason, "Please complete a security check to continue.");
1988
- } else if (verdict.action === "block") {
1989
- trackEvent(buildEventData(ctx, "blocked", reason, confidence, verdict.details));
2011
+ setOutcome(hookCtx, "challenged", "server_security_check", {
2012
+ reason,
2013
+ ...verdict.details
2014
+ });
2015
+ emitEvaluation(hookCtx, trackEvent, {
2016
+ identifier: checkCtx.identifier,
2017
+ userAgent: checkCtx.userAgent
2018
+ });
2019
+ throwChallengeError(challenge, reason);
2020
+ }
2021
+ if (verdict.action === "block") {
2022
+ setOutcome(hookCtx, "blocked", "server_security_check", {
2023
+ reason,
2024
+ ...verdict.details
2025
+ });
2026
+ emitEvaluation(hookCtx, trackEvent, {
2027
+ identifier: checkCtx.identifier,
2028
+ userAgent: checkCtx.userAgent
2029
+ });
1990
2030
  throw new APIError("FORBIDDEN", { message: ERROR_MESSAGES[reason] || "Access denied." });
1991
2031
  }
1992
2032
  }
1993
2033
  /**
1994
- * Run all security checks using the consolidated checkSecurity API
1995
- *
1996
- * This replaces the multiple individual check calls with a single API call
1997
- * that handles all security checks server-side.
2034
+ * Single API call from the plugin; server runs all configured rules and returns
2035
+ * one verdict — counts as one billable check.
1998
2036
  */
1999
- async function runSecurityChecks(ctx, securityService, trackEvent, powVerified) {
2037
+ async function runSecurityChecks(hookCtx, checkCtx, securityService, trackEvent, powVerified) {
2000
2038
  if (powVerified) return;
2039
+ recordCheck(hookCtx, "server_security_check");
2001
2040
  await handleSecurityVerdict(await securityService.checkSecurity({
2002
- visitorId: ctx.visitorId,
2003
- requestId: ctx.identification?.requestId || null,
2004
- ip: ctx.identification?.ip || null,
2005
- path: ctx.path,
2006
- identifier: ctx.identifier
2007
- }), ctx, trackEvent, securityService);
2041
+ visitorId: checkCtx.visitorId,
2042
+ requestId: checkCtx.identification?.requestId || null,
2043
+ ip: checkCtx.identification?.ip || null,
2044
+ path: checkCtx.path,
2045
+ identifier: checkCtx.identifier
2046
+ }), hookCtx, checkCtx, trackEvent, securityService);
2008
2047
  }
2009
2048
  //#endregion
2010
2049
  //#region src/sentinel/sentinel.ts
2011
2050
  const sentinel = (options) => {
2012
2051
  const opts = resolveSentinelOptions(options);
2013
- const { tracker } = initTrackEvents(opts);
2052
+ const $api = createAPI(opts);
2053
+ const $apiThrowing = createAPI(opts, { throw: true });
2054
+ const $kv = createKV(opts);
2055
+ const { tracker } = initTrackEvents($api);
2014
2056
  const { trackEvent } = tracker;
2015
2057
  if (!opts.apiKey) logger.warn("[Sentinel] Missing BETTER_AUTH_API_KEY. Security checks may fall back to allow mode when the Infra API rejects requests.");
2016
- const securityService = createSecurityClient(opts.apiUrl, opts.apiKey, opts.security || {}, (event) => {
2058
+ const securityService = createSecurityClient(opts, $apiThrowing, opts.security || {}, (event) => {
2017
2059
  trackEvent({
2018
2060
  eventKey: event.visitorId || event.userId || "unknown",
2019
- eventType: `security_${event.type}`,
2020
- eventDisplayName: `Security: ${event.type.replace(/_/g, " ")}`,
2061
+ eventType: "security_signal",
2062
+ eventDisplayName: `Security signal: ${event.type.replace(/_/g, " ")}`,
2021
2063
  eventData: {
2022
2064
  type: event.type,
2023
2065
  userId: event.userId || void 0,
@@ -2032,35 +2074,11 @@ const sentinel = (options) => {
2032
2074
  const emailHooks = createEmailHooks({
2033
2075
  emailValidationOptions: opts.security?.emailValidation,
2034
2076
  emailNormalizationOptions: opts.security?.emailNormalization,
2035
- useApi: !!opts.apiKey,
2036
- apiKey: opts.apiKey,
2037
- apiUrl: opts.apiUrl,
2038
- kvUrl: opts.kvUrl,
2039
- onDisposableEmail: (data) => {
2040
- const isNoMxRecord = data.reason === "no_mx_records";
2041
- const reason = isNoMxRecord ? "no_mx_records" : "disposable_email";
2042
- const label = isNoMxRecord ? "No MX Records" : "Disposable Email";
2043
- const actionLabel = data.action === "allow" ? "allowed" : "blocked";
2044
- const actionVerb = data.action === "allow" ? "detected" : "blocked";
2045
- const eventType = data.action === "allow" ? "security_allowed" : "security_blocked";
2046
- const displayName = isNoMxRecord ? `Security: invalid email domain ${actionVerb}` : `Security: disposable email ${actionVerb}`;
2047
- const description = isNoMxRecord ? `${actionVerb.charAt(0).toUpperCase() + actionVerb.slice(1)} signup attempt with invalid email domain (no MX records): ${data.email}` : `${actionVerb.charAt(0).toUpperCase() + actionVerb.slice(1)} signup attempt with disposable email: ${data.email} (${data.reason}, ${data.confidence} confidence)`;
2048
- logger.info(`[Sentinel] Tracking ${reason} event for email: ${data.email} (action: ${actionLabel})`);
2049
- trackEvent({
2050
- eventKey: data.email,
2051
- eventType,
2052
- eventDisplayName: displayName,
2053
- eventData: {
2054
- action: actionLabel,
2055
- reason,
2056
- identifier: data.email,
2057
- detectionLabel: label,
2058
- description,
2059
- path: data.path
2060
- },
2061
- ipAddress: data.ip
2062
- });
2063
- }
2077
+ trackEvent,
2078
+ ...opts.apiKey ? {
2079
+ $api,
2080
+ $kv
2081
+ } : {}
2064
2082
  });
2065
2083
  return {
2066
2084
  id: "sentinel",
@@ -2075,8 +2093,16 @@ const sentinel = (options) => {
2075
2093
  if (!ctx) return;
2076
2094
  const visitorId = ctx.context.visitorId;
2077
2095
  if (visitorId && opts.security?.freeTrialAbuse?.enabled) {
2096
+ recordCheck(ctx, "free_trial_abuse");
2078
2097
  const abuseCheck = await securityService.checkFreeTrialAbuse(visitorId);
2079
- if (abuseCheck.isAbuse && abuseCheck.action === "block") throw new APIError("FORBIDDEN", { message: "Account creation is not allowed from this device." });
2098
+ if (abuseCheck.isAbuse && abuseCheck.action === "block") {
2099
+ setOutcome(ctx, "blocked", "free_trial_abuse", {
2100
+ accountCount: abuseCheck.accountCount,
2101
+ maxAccounts: abuseCheck.maxAccounts
2102
+ });
2103
+ emitEvaluation(ctx, trackEvent);
2104
+ throw new APIError("FORBIDDEN", { message: "Account creation is not allowed from this device." });
2105
+ }
2080
2106
  }
2081
2107
  if (user.email && typeof user.email === "string" && isEmailNormalizationEnabled(opts.security)) return { data: {
2082
2108
  ...user,
@@ -2095,10 +2121,29 @@ const sentinel = (options) => {
2095
2121
  const visitorId = ctx.context.visitorId;
2096
2122
  const identification = ctx.context.identification;
2097
2123
  if (session.userId && identification?.location && visitorId) {
2124
+ recordCheck(ctx, "impossible_travel");
2098
2125
  const travelCheck = await securityService.checkImpossibleTravel(session.userId, identification.location, visitorId);
2099
2126
  if (travelCheck?.isImpossible) {
2100
- if (travelCheck.action === "block") throw new APIError("FORBIDDEN", { message: "Login blocked due to suspicious location change." });
2101
- if (travelCheck.action === "challenge" && travelCheck.challenge) throwChallengeError(travelCheck.challenge, "impossible_travel", "Unusual login location detected. Please complete a security check.");
2127
+ if (travelCheck.action === "block") {
2128
+ setOutcome(ctx, "blocked", "impossible_travel", {
2129
+ from: travelCheck.from,
2130
+ to: travelCheck.to,
2131
+ distance: travelCheck.distance,
2132
+ speedRequired: travelCheck.speedRequired
2133
+ });
2134
+ emitEvaluation(ctx, trackEvent);
2135
+ throw new APIError("FORBIDDEN", { message: "Login blocked due to suspicious location change." });
2136
+ }
2137
+ if (travelCheck.action === "challenge" && travelCheck.challenge) {
2138
+ setOutcome(ctx, "challenged", "impossible_travel", {
2139
+ from: travelCheck.from,
2140
+ to: travelCheck.to,
2141
+ distance: travelCheck.distance,
2142
+ speedRequired: travelCheck.speedRequired
2143
+ });
2144
+ emitEvaluation(ctx, trackEvent);
2145
+ throwChallengeError(travelCheck.challenge, "impossible_travel", "Unusual login location detected. Please complete a security check.");
2146
+ }
2102
2147
  }
2103
2148
  }
2104
2149
  },
@@ -2128,6 +2173,7 @@ const sentinel = (options) => {
2128
2173
  if (await securityService.checkUnknownDevice(session.userId, visitorId) && user?.email) await ctx.context.runInBackgroundOrAwait(securityService.notifyUnknownDevice(session.userId, user.email, identification));
2129
2174
  }
2130
2175
  if (opts.security?.staleUsers?.enabled && user) {
2176
+ recordCheck(ctx, "stale_users");
2131
2177
  const lastActiveAtForStale = activityTrackingEnabled ? user.lastActiveAt ?? null : null;
2132
2178
  const staleCheck = await securityService.checkStaleUser(session.userId, lastActiveAtForStale);
2133
2179
  if (staleCheck.isStale) {
@@ -2138,31 +2184,21 @@ const sentinel = (options) => {
2138
2184
  if (notificationPromises.length > 0) Promise.all(notificationPromises).catch((error) => {
2139
2185
  logger.error("[Sentinel] Failed to send stale account notifications:", error);
2140
2186
  });
2141
- trackEvent({
2142
- eventKey: session.userId,
2143
- eventType: "security_stale_account",
2144
- eventDisplayName: "Security: stale account reactivated",
2145
- eventData: {
2146
- action: staleCheck.action === "block" ? "blocked" : staleCheck.action === "challenge" ? "challenged" : "logged",
2147
- reason: "stale_account_reactivation",
2187
+ if (staleCheck.action === "block") {
2188
+ setOutcome(ctx, "blocked", "stale_users", {
2148
2189
  userId: session.userId,
2149
2190
  daysSinceLastActive: staleCheck.daysSinceLastActive,
2150
2191
  staleDays: staleCheck.staleDays,
2151
2192
  lastActiveAt: staleCheck.lastActiveAt,
2152
2193
  notifyUser: staleCheck.notifyUser,
2153
- notifyAdmin: staleCheck.notifyAdmin,
2154
- detectionLabel: "Stale Account Reactivation",
2155
- description: `Dormant account (inactive for ${staleCheck.daysSinceLastActive} days) became active`
2156
- },
2157
- ipAddress: identification?.ip || void 0,
2158
- city: identification?.location?.city || void 0,
2159
- country: identification?.location?.country?.name || void 0,
2160
- countryCode: identification?.location?.country?.code || void 0
2161
- });
2162
- if (staleCheck.action === "block") throw new APIError("FORBIDDEN", {
2163
- message: "This account has been inactive for an extended period. Please contact support to reactivate.",
2164
- code: "STALE_ACCOUNT"
2165
- });
2194
+ notifyAdmin: staleCheck.notifyAdmin
2195
+ });
2196
+ emitEvaluation(ctx, trackEvent);
2197
+ throw new APIError("FORBIDDEN", {
2198
+ message: "This account has been inactive for an extended period. Please contact support to reactivate.",
2199
+ code: "STALE_ACCOUNT"
2200
+ });
2201
+ }
2166
2202
  }
2167
2203
  }
2168
2204
  if (identification?.location) await ctx.context.runInBackgroundOrAwait(securityService.storeLastLocation(session.userId, identification.location));
@@ -2175,7 +2211,7 @@ const sentinel = (options) => {
2175
2211
  before: [
2176
2212
  {
2177
2213
  matcher: (ctx) => ctx.request?.method !== "GET",
2178
- handler: createIdentificationMiddleware(opts)
2214
+ handler: createIdentificationMiddleware($kv)
2179
2215
  },
2180
2216
  {
2181
2217
  matcher: (ctx) => ctx.request?.method !== "GET",
@@ -2225,52 +2261,23 @@ const sentinel = (options) => {
2225
2261
  const requestBody = ctx.body;
2226
2262
  const identifier = requestBody?.email || requestBody?.phone || requestBody?.username || void 0;
2227
2263
  const powVerified = ctx.context.powVerified === true;
2228
- if (visitorId && powVerified) trackEvent({
2229
- eventKey: visitorId,
2230
- eventType: "security_allowed",
2231
- eventDisplayName: "Security: challenge completed",
2232
- eventData: {
2233
- action: "allowed",
2234
- reason: "pow_verified",
2235
- visitorId,
2236
- path: ctx.path,
2237
- userAgent: ctx.headers?.get?.("user-agent") || "",
2238
- identifier,
2239
- detectionLabel: "Challenge Completed",
2240
- description: identifier ? `Successfully completed security challenge for "${identifier}"` : "Successfully completed security challenge"
2241
- },
2242
- ipAddress: identification?.ip || void 0,
2243
- city: identification?.location?.city || void 0,
2244
- country: identification?.location?.country?.name || void 0,
2245
- countryCode: identification?.location?.country?.code || void 0
2246
- });
2264
+ if (visitorId && powVerified) recordCheck(ctx, "pow_challenge");
2247
2265
  if (visitorId) {
2266
+ recordCheck(ctx, "credential_stuffing");
2248
2267
  if (await securityService.isBlocked(visitorId)) {
2249
- trackEvent({
2250
- eventKey: visitorId,
2251
- eventType: "security_blocked",
2252
- eventDisplayName: "Security: credential stuffing blocked",
2253
- eventData: {
2254
- action: "blocked",
2255
- reason: "credential_stuffing",
2256
- visitorId,
2257
- path: ctx.path,
2258
- userAgent: ctx.headers?.get?.("user-agent") || "",
2259
- identifier,
2260
- detectionType: "cooldown_active",
2261
- detectionLabel: "Credential Stuffing",
2262
- description: identifier ? `Visitor attempting "${identifier}" still in cooldown from prior detection` : "Visitor still in cooldown from prior detection",
2263
- confidence: 1
2264
- },
2265
- ipAddress: identification?.ip || void 0,
2266
- city: identification?.location?.city || void 0,
2267
- country: identification?.location?.country?.name || void 0,
2268
- countryCode: identification?.location?.country?.code || void 0
2268
+ setOutcome(ctx, "blocked", "credential_stuffing", {
2269
+ reason: "cooldown_active",
2270
+ visitorId,
2271
+ confidence: 1
2272
+ });
2273
+ emitEvaluation(ctx, trackEvent, {
2274
+ identifier,
2275
+ userAgent: ctx.headers?.get?.("user-agent") || ""
2269
2276
  });
2270
2277
  throw new APIError(403, { message: "Too many failed attempts. Please try again later." });
2271
2278
  }
2272
2279
  }
2273
- await runSecurityChecks({
2280
+ await runSecurityChecks(ctx, {
2274
2281
  path: ctx.path,
2275
2282
  identifier,
2276
2283
  visitorId,
@@ -2286,12 +2293,20 @@ const sentinel = (options) => {
2286
2293
  const body = ctx.body;
2287
2294
  const passwordToCheck = body?.newPassword || body?.password;
2288
2295
  if (passwordToCheck) {
2296
+ recordCheck(ctx, "compromised_password");
2289
2297
  const compromisedResult = await securityService.checkCompromisedPassword(passwordToCheck);
2290
2298
  if (compromisedResult.compromised && compromisedResult.action) {
2291
- if (compromisedResult.action === "block") throw new APIError("BAD_REQUEST", {
2292
- message: "This password has been found in data breaches. Please choose a different password.",
2293
- code: "COMPROMISED_PASSWORD"
2294
- });
2299
+ if (compromisedResult.action === "block") {
2300
+ setOutcome(ctx, "blocked", "compromised_password", { breachCount: compromisedResult.breachCount });
2301
+ emitEvaluation(ctx, trackEvent, {
2302
+ identifier,
2303
+ userAgent: ctx.headers?.get?.("user-agent") || ""
2304
+ });
2305
+ throw new APIError("BAD_REQUEST", {
2306
+ message: "This password has been found in data breaches. Please choose a different password.",
2307
+ code: "COMPROMISED_PASSWORD"
2308
+ });
2309
+ }
2295
2310
  }
2296
2311
  }
2297
2312
  }
@@ -2304,6 +2319,10 @@ const sentinel = (options) => {
2304
2319
  const visitorId = ctx.context.visitorId;
2305
2320
  const identification = ctx.context.identification;
2306
2321
  const body = ctx.body;
2322
+ emitEvaluation(ctx, trackEvent, {
2323
+ identifier: body?.email,
2324
+ userAgent: ctx.headers?.get?.("user-agent") || ""
2325
+ });
2307
2326
  if (matchesAnyRoute(ctx.path, [routes.SIGN_IN_EMAIL, routes.SIGN_IN_EMAIL_OTP]) && ctx.context.returned instanceof Error && body?.email && body?.password && visitorId) {
2308
2327
  const { email, password } = body;
2309
2328
  const ip = identification?.ip || null;
@@ -2638,32 +2657,32 @@ const JTI_CHECK_GRACE_PERIOD_SECONDS = 30;
2638
2657
  const JWKS_CACHE_TTL_MS = 900 * 1e3;
2639
2658
  const jwksCache = /* @__PURE__ */ new Map();
2640
2659
  const inflightRequests = /* @__PURE__ */ new Map();
2641
- async function fetchJWKS(ctx, apiUrl) {
2642
- const { data, error } = await betterFetch(`${apiUrl}/api/auth/jwks`);
2660
+ async function fetchJWKS(ctx, cacheKey, $api) {
2661
+ const { data, error } = await $api("/api/auth/jwks");
2643
2662
  if (error || !data) {
2644
2663
  ctx.context.logger.warn("[Dash] Failed to fetch JWKS", error);
2645
- throw new APIError$1("UNAUTHORIZED", { message: "Invalid API key" });
2664
+ throw ctx.error("UNAUTHORIZED", { message: "Invalid API key" });
2646
2665
  }
2647
- jwksCache.set(apiUrl, {
2666
+ jwksCache.set(cacheKey, {
2648
2667
  data,
2649
2668
  expiresAt: Date.now() + JWKS_CACHE_TTL_MS
2650
2669
  });
2651
2670
  return createLocalJWKSet(data);
2652
2671
  }
2653
- async function prefetchJWKS(ctx, apiUrl) {
2654
- const fetchPromise = fetchJWKS(ctx, apiUrl);
2655
- inflightRequests.set(apiUrl, fetchPromise);
2656
- fetchPromise.finally(() => inflightRequests.delete(apiUrl));
2672
+ async function prefetchJWKS(ctx, cacheKey, $api) {
2673
+ const fetchPromise = fetchJWKS(ctx, cacheKey, $api);
2674
+ inflightRequests.set(cacheKey, fetchPromise);
2675
+ fetchPromise.finally(() => inflightRequests.delete(cacheKey));
2657
2676
  return fetchPromise;
2658
2677
  }
2659
- async function getJWKs(ctx, apiUrl) {
2660
- const cached = jwksCache.get(apiUrl);
2678
+ async function getJWKs(ctx, cacheKey, $api) {
2679
+ const cached = jwksCache.get(cacheKey);
2661
2680
  if (cached && Date.now() < cached.expiresAt) return createLocalJWKSet(cached.data);
2662
2681
  if (cached) {
2663
- if (!inflightRequests.has(apiUrl)) prefetchJWKS(ctx, apiUrl);
2682
+ if (!inflightRequests.has(cacheKey)) prefetchJWKS(ctx, cacheKey, $api);
2664
2683
  return createLocalJWKSet(cached.data);
2665
2684
  }
2666
- return inflightRequests.get(apiUrl) ?? prefetchJWKS(ctx, apiUrl);
2685
+ return inflightRequests.get(cacheKey) ?? prefetchJWKS(ctx, cacheKey, $api);
2667
2686
  }
2668
2687
  /**
2669
2688
  * Check if JWT is recently issued and can skip JTI verification.
@@ -2675,86 +2694,92 @@ function isRecentlyIssued(payload) {
2675
2694
  const issuedAt = payload.iat * 1e3;
2676
2695
  return Date.now() - issuedAt < JTI_CHECK_GRACE_PERIOD_SECONDS * 1e3;
2677
2696
  }
2678
- const jwtMiddleware = (options, schema, getJWT) => createAuthMiddleware(async (ctx) => {
2679
- const jwsFromHeader = getJWT ? await getJWT(ctx) : ctx.headers?.get("Authorization")?.split(" ")[1];
2680
- if (!jwsFromHeader) {
2681
- ctx.context.logger.warn("[Dash] JWT is missing from header");
2682
- throw ctx.error("UNAUTHORIZED", { message: "Invalid API key" });
2683
- }
2684
- const { payload } = await jwtVerify(jwsFromHeader, await getJWKs(ctx, options.apiUrl), { maxTokenAge: "5m" }).catch((e) => {
2685
- ctx.context.logger.warn("[Dash] JWT verification failed:", e);
2686
- throw ctx.error("UNAUTHORIZED", { message: "Invalid API key" });
2687
- });
2688
- if (!isRecentlyIssued(payload)) {
2689
- const { error, data } = await betterFetch("/api/auth/check-jti", {
2690
- baseURL: options.apiUrl,
2691
- method: "POST",
2692
- headers: { "x-api-key": options.apiKey },
2693
- body: {
2694
- jti: payload.jti,
2695
- expiresAt: payload.exp
2696
- }
2697
- });
2698
- if (error || !data?.valid) {
2699
- ctx.context.logger.warn("[Dash] JTI check failed with error", error, data?.valid);
2697
+ const jwtMiddleware = (options, schema, getJWT) => {
2698
+ const { $api } = options;
2699
+ const cacheKey = options.apiUrl;
2700
+ return createAuthMiddleware(async (ctx) => {
2701
+ const jwsFromHeader = getJWT ? await getJWT(ctx) : ctx.headers?.get("Authorization")?.split(" ")[1];
2702
+ if (!jwsFromHeader) {
2703
+ ctx.context.logger.warn("[Dash] JWT is missing from header");
2700
2704
  throw ctx.error("UNAUTHORIZED", { message: "Invalid API key" });
2701
2705
  }
2702
- }
2703
- const apiKeyHash = payload.apiKeyHash;
2704
- if (typeof apiKeyHash !== "string" || !options.apiKey) {
2705
- ctx.context.logger.warn("[Dash] API key hash is missing or invalid", {
2706
- apiKeyHash,
2707
- apiKey: options.apiKey ? "present" : "missing"
2706
+ const { payload } = await jwtVerify(jwsFromHeader, await getJWKs(ctx, cacheKey, $api), { maxTokenAge: "5m" }).catch((e) => {
2707
+ ctx.context.logger.warn("[Dash] JWT verification failed:", e);
2708
+ throw ctx.error("UNAUTHORIZED", { message: "Invalid API key" });
2708
2709
  });
2709
- throw ctx.error("UNAUTHORIZED", { message: "Invalid API key" });
2710
- }
2711
- const expectedHash = await hash(options.apiKey);
2712
- if (apiKeyHash !== expectedHash) {
2713
- ctx.context.logger.warn("[Dash] API key hash is invalid", apiKeyHash, expectedHash);
2714
- throw ctx.error("UNAUTHORIZED", { message: "Invalid API key" });
2715
- }
2716
- if (schema) {
2717
- const parsed = schema.safeParse(payload);
2718
- if (!parsed.success) {
2719
- ctx.context.logger.warn("[Dash] JWT payload is invalid", parsed.error);
2710
+ const apiKeyHash = payload.apiKeyHash;
2711
+ if (typeof apiKeyHash !== "string" || !options.apiKey) {
2712
+ ctx.context.logger.warn("[Dash] API key hash is missing or invalid", {
2713
+ apiKeyHash,
2714
+ apiKey: options.apiKey ? "present" : "missing"
2715
+ });
2720
2716
  throw ctx.error("UNAUTHORIZED", { message: "Invalid API key" });
2721
2717
  }
2722
- return { payload: parsed.data };
2723
- }
2724
- return { payload };
2725
- });
2718
+ const expectedHash = await hash(options.apiKey);
2719
+ if (apiKeyHash !== expectedHash) {
2720
+ ctx.context.logger.warn("[Dash] API key hash is invalid", apiKeyHash, expectedHash);
2721
+ throw ctx.error("UNAUTHORIZED", { message: "Invalid API key" });
2722
+ }
2723
+ if (!isRecentlyIssued(payload)) {
2724
+ const { error, data } = await $api("/api/auth/check-jti", {
2725
+ method: "POST",
2726
+ body: {
2727
+ jti: payload.jti,
2728
+ expiresAt: payload.exp
2729
+ }
2730
+ });
2731
+ if (error || !data?.valid) {
2732
+ ctx.context.logger.warn("[Dash] JTI check failed with error", error, data?.valid);
2733
+ throw ctx.error("UNAUTHORIZED", { message: "Invalid API key" });
2734
+ }
2735
+ }
2736
+ if (schema) {
2737
+ const parsed = schema.safeParse(payload);
2738
+ if (!parsed.success) {
2739
+ ctx.context.logger.warn("[Dash] JWT payload is invalid", parsed.error);
2740
+ throw ctx.error("UNAUTHORIZED", { message: "Invalid API key" });
2741
+ }
2742
+ return { payload: parsed.data };
2743
+ }
2744
+ return { payload };
2745
+ });
2746
+ };
2726
2747
  /**
2727
2748
  * Lightweight JWT middleware for /dash/validate. Verifies JWT signature and
2728
2749
  * apiKeyHash only—no JTI check. Used during onboarding when the org doesn't
2729
2750
  * exist yet.
2730
2751
  */
2731
- const jwtValidateMiddleware = (options) => createAuthMiddleware(async (ctx) => {
2732
- const jwsFromHeader = ctx.headers?.get("Authorization")?.split(" ")[1];
2733
- if (!jwsFromHeader) {
2734
- ctx.context.logger.warn("[Dash] JWT is missing from header");
2735
- throw ctx.error("UNAUTHORIZED", { message: "Invalid API key" });
2736
- }
2737
- const { payload } = await jwtVerify(jwsFromHeader, await getJWKs(ctx, options.apiUrl), { maxTokenAge: "5m" }).catch((e) => {
2738
- ctx.context.logger.error("[Dash] JWT verification failed:", e);
2739
- throw ctx.error("UNAUTHORIZED", { message: "Invalid API key" });
2740
- });
2741
- const apiKeyHash = payload.apiKeyHash;
2742
- if (typeof apiKeyHash !== "string" || !options.apiKey) {
2743
- ctx.context.logger.warn("[Dash] API key hash is missing or invalid", {
2744
- apiKeyHash: typeof apiKeyHash,
2745
- apiKey: options.apiKey ? "present" : "missing"
2752
+ const jwtValidateMiddleware = (options) => {
2753
+ const { $api } = options;
2754
+ const cacheKey = options.apiUrl;
2755
+ return createAuthMiddleware(async (ctx) => {
2756
+ const jwsFromHeader = ctx.headers?.get("Authorization")?.split(" ")[1];
2757
+ if (!jwsFromHeader) {
2758
+ ctx.context.logger.warn("[Dash] JWT is missing from header");
2759
+ throw ctx.error("UNAUTHORIZED", { message: "Invalid API key" });
2760
+ }
2761
+ const { payload } = await jwtVerify(jwsFromHeader, await getJWKs(ctx, cacheKey, $api), { maxTokenAge: "5m" }).catch((e) => {
2762
+ ctx.context.logger.error("[Dash] JWT verification failed:", e);
2763
+ throw ctx.error("UNAUTHORIZED", { message: "Invalid API key" });
2746
2764
  });
2747
- throw ctx.error("UNAUTHORIZED", { message: "Invalid API key" });
2748
- }
2749
- if (apiKeyHash !== await hash(options.apiKey)) {
2750
- ctx.context.logger.warn("[Dash] API key hash mismatch");
2751
- throw ctx.error("UNAUTHORIZED", { message: "Invalid API key" });
2752
- }
2753
- return { payload };
2754
- });
2765
+ const apiKeyHash = payload.apiKeyHash;
2766
+ if (typeof apiKeyHash !== "string" || !options.apiKey) {
2767
+ ctx.context.logger.warn("[Dash] API key hash is missing or invalid", {
2768
+ apiKeyHash: typeof apiKeyHash,
2769
+ apiKey: options.apiKey ? "present" : "missing"
2770
+ });
2771
+ throw ctx.error("UNAUTHORIZED", { message: "Invalid API key" });
2772
+ }
2773
+ if (apiKeyHash !== await hash(options.apiKey)) {
2774
+ ctx.context.logger.warn("[Dash] API key hash mismatch");
2775
+ throw ctx.error("UNAUTHORIZED", { message: "Invalid API key" });
2776
+ }
2777
+ return { payload };
2778
+ });
2779
+ };
2755
2780
  //#endregion
2756
2781
  //#region src/version.ts
2757
- const PLUGIN_VERSION = "0.2.5";
2782
+ const PLUGIN_VERSION = "0.2.7";
2758
2783
  //#endregion
2759
2784
  //#region src/routes/auth/config.ts
2760
2785
  const PLUGIN_OPTIONS_EXCLUDE_KEYS = { stripe: new Set(["stripeClient"]) };
@@ -3068,6 +3093,12 @@ function getScimEndpoint(baseUrl) {
3068
3093
  function getSCIMPlugin(ctx) {
3069
3094
  return ctx.context.getPlugin("scim");
3070
3095
  }
3096
+ const DIRECTORY_SYNC_DUPLICATE_MESSAGE = "A directory sync connection with this provider ID already exists. Remove the existing connection or choose a different provider ID.";
3097
+ function isDuplicateDirectorySyncError(e) {
3098
+ if (e instanceof APIError$1) return e.status === "CONFLICT" || /duplicate|already exists|unique constraint/i.test(e.message ?? "");
3099
+ if (e instanceof Error) return /unique constraint|duplicate key|already exists|P2002/i.test(e.message);
3100
+ return false;
3101
+ }
3071
3102
  const listOrganizationDirectories = (options) => {
3072
3103
  return createAuthEndpoint("/dash/organization/:id/directories", {
3073
3104
  method: "GET",
@@ -3118,14 +3149,22 @@ const createOrganizationDirectory = (options) => {
3118
3149
  const scimPlugin = getSCIMPlugin(ctx);
3119
3150
  if (!scimPlugin?.endpoints.generateSCIMToken || !scimPlugin?.options?.providerOwnership?.enabled) throw ctx.error("BAD_REQUEST", { message: "SCIM plugin is not enabled or does not support this feature" });
3120
3151
  const { providerId, ownerUserId } = ctx.body;
3121
- const { scimToken } = await scimPlugin.endpoints.generateSCIMToken({
3122
- body: {
3123
- providerId,
3124
- organizationId
3125
- },
3126
- context: await buildSyntheticSession(ctx, ownerUserId),
3127
- headers: ctx.request?.headers ?? new Headers()
3128
- });
3152
+ let scimToken;
3153
+ try {
3154
+ scimToken = (await scimPlugin.endpoints.generateSCIMToken({
3155
+ body: {
3156
+ providerId,
3157
+ organizationId
3158
+ },
3159
+ context: await buildSyntheticSession(ctx, ownerUserId),
3160
+ headers: ctx.request?.headers ?? new Headers()
3161
+ })).scimToken;
3162
+ } catch (e) {
3163
+ if (isDuplicateDirectorySyncError(e)) throw ctx.error("BAD_REQUEST", { message: DIRECTORY_SYNC_DUPLICATE_MESSAGE });
3164
+ ctx.context.logger.error("[Dash] Failed to create directory sync provider (organizationId=%s, providerId=%s)", organizationId, providerId, e);
3165
+ if (e instanceof APIError$1) throw e;
3166
+ throw ctx.error("INTERNAL_SERVER_ERROR", { message: "Failed to create directory sync provider" });
3167
+ }
3129
3168
  if (!scimToken) throw ctx.error("INTERNAL_SERVER_ERROR", { message: "Failed to create directory sync provider" });
3130
3169
  return {
3131
3170
  organizationId,
@@ -3226,16 +3265,16 @@ function transformEvent(raw) {
3226
3265
  * ```
3227
3266
  */
3228
3267
  const getUserEvents = (options) => {
3229
- const $fetch = createFetch({
3230
- baseURL: options.apiUrl,
3231
- headers: { "x-api-key": options.apiKey }
3232
- });
3268
+ const { $api } = options;
3233
3269
  return createAuthEndpoint("/events/list", {
3234
3270
  method: "GET",
3235
3271
  use: [sessionMiddleware],
3236
3272
  query: z$1.object({
3273
+ /** Maximum number of events to return (default: 50, max: 100) */
3237
3274
  limit: z$1.number().or(z$1.string().transform(Number)).optional(),
3275
+ /** Number of events to skip for pagination (default: 0) */
3238
3276
  offset: z$1.number().or(z$1.string().transform(Number)).optional(),
3277
+ /** Filter by event type (e.g., "user_signed_in") */
3239
3278
  eventType: z$1.string().optional()
3240
3279
  }).optional()
3241
3280
  }, async (ctx) => {
@@ -3245,7 +3284,7 @@ const getUserEvents = (options) => {
3245
3284
  const requestedLimit = ctx.query?.limit ?? 50;
3246
3285
  const limit = Math.min(Math.max(1, requestedLimit), 100);
3247
3286
  const offset = Math.max(0, ctx.query?.offset ?? 0);
3248
- const { data, error } = await $fetch("/events/user", {
3287
+ const { data, error } = await $api("/events/user", {
3249
3288
  method: "GET",
3250
3289
  query: {
3251
3290
  userId: session.user.id,
@@ -3295,10 +3334,7 @@ const getEventTypes = (options) => {
3295
3334
  * results to the resolved user/identifier.
3296
3335
  */
3297
3336
  const getAuditLogs = (options) => {
3298
- const $fetch = createFetch({
3299
- baseURL: options.apiUrl,
3300
- headers: { "x-api-key": options.apiKey }
3301
- });
3337
+ const { $api } = options;
3302
3338
  return createAuthEndpoint("/events/audit-logs", {
3303
3339
  method: "GET",
3304
3340
  use: [sessionMiddleware],
@@ -3330,7 +3366,7 @@ const getAuditLogs = (options) => {
3330
3366
  if (!(sessionData.session?.activeOrganizationId === organizationId) && (!Array.isArray(sessionOrganizations) || !sessionOrganizations.some((org) => {
3331
3367
  return org.id === organizationId;
3332
3368
  }))) throw ctx.error("FORBIDDEN", { message: "Not allowed to access this organization" });
3333
- const { data, error } = await $fetch("/events/organization", {
3369
+ const { data, error } = await $api("/events/organization", {
3334
3370
  method: "GET",
3335
3371
  query: {
3336
3372
  organizationId,
@@ -3347,7 +3383,7 @@ const getAuditLogs = (options) => {
3347
3383
  responseLimit = data.limit;
3348
3384
  responseOffset = data.offset;
3349
3385
  } else {
3350
- const { data, error } = await $fetch("/events/user", {
3386
+ const { data, error } = await $api("/events/user", {
3351
3387
  method: "GET",
3352
3388
  query: {
3353
3389
  userId: resolvedUserId,
@@ -3383,6 +3419,110 @@ const getAuditLogs = (options) => {
3383
3419
  };
3384
3420
  });
3385
3421
  };
3422
+ const OWNER_ADMIN_ROLES = new Set(["owner", "admin"]);
3423
+ function isOwnerOrAdminRole(role) {
3424
+ return role !== void 0 && OWNER_ADMIN_ROLES.has(role);
3425
+ }
3426
+ /**
3427
+ * Get all audit logs the current user has access to
3428
+ * Queries can be restricted to a single organization, a single user, or all organizations the user has access to
3429
+ * @param options
3430
+ * @returns
3431
+ */
3432
+ const getAllAuditLogs = (options) => {
3433
+ const { $api } = options;
3434
+ const getUserOrganizationRole = async (adapter, orgId, userId) => {
3435
+ return (await adapter.findOne({
3436
+ model: "member",
3437
+ where: [{
3438
+ field: "organizationId",
3439
+ value: orgId
3440
+ }, {
3441
+ field: "userId",
3442
+ value: userId
3443
+ }],
3444
+ select: ["role"]
3445
+ }))?.role;
3446
+ };
3447
+ const listElevatedOrganizationIds = async (adapter, userId) => {
3448
+ const memberships = await adapter.findMany({
3449
+ model: "member",
3450
+ where: [{
3451
+ field: "userId",
3452
+ value: userId
3453
+ }],
3454
+ select: ["organizationId", "role"]
3455
+ });
3456
+ const ids = /* @__PURE__ */ new Set();
3457
+ for (const m of memberships) if (isOwnerOrAdminRole(m.role)) ids.add(m.organizationId);
3458
+ return [...ids];
3459
+ };
3460
+ return createAuthEndpoint("/events/all-audit-logs", {
3461
+ method: "GET",
3462
+ use: [sessionMiddleware],
3463
+ query: z$1.object({
3464
+ limit: z$1.number().or(z$1.string().transform(Number)).optional(),
3465
+ offset: z$1.number().or(z$1.string().transform(Number)).optional(),
3466
+ userId: z$1.string().optional(),
3467
+ organizationId: z$1.string().optional(),
3468
+ /** Filter by event type (e.g. `organization_member_added`) */
3469
+ eventType: z$1.string().optional(),
3470
+ /** Match `eventData.identifier` (organization-scoped actor identity) */
3471
+ identifier: z$1.string().optional()
3472
+ }).refine((q) => {
3473
+ const u = q.userId?.trim();
3474
+ const o = q.organizationId?.trim();
3475
+ return !(u && o);
3476
+ }, { message: "Provide at most one of userId and organizationId." }).optional()
3477
+ }, async (ctx) => {
3478
+ const session = ctx.context.session;
3479
+ if (!session?.user?.id) throw ctx.error("UNAUTHORIZED", { message: "You must be signed in to view activity logs" });
3480
+ if (!options.apiKey) throw ctx.error("INTERNAL_SERVER_ERROR", { message: "Events API is not configured" });
3481
+ const query = ctx.query ?? {};
3482
+ const adapter = ctx.context.adapter;
3483
+ const sessionUserId = session.user.id;
3484
+ const limit = Math.min(Math.max(1, query.limit ?? 50), 100);
3485
+ const offset = Math.max(0, query.offset ?? 0);
3486
+ const organizationId = query.organizationId?.trim();
3487
+ const userId = query.userId?.trim();
3488
+ const forbidAccess = () => {
3489
+ return ctx.error("FORBIDDEN", { message: "Only organization owners and admins can view activity logs." });
3490
+ };
3491
+ const elevatedOrgIds = await listElevatedOrganizationIds(adapter, sessionUserId);
3492
+ if (organizationId) {
3493
+ if (!isOwnerOrAdminRole(await getUserOrganizationRole(adapter, organizationId, sessionUserId))) throw forbidAccess();
3494
+ } else if (userId) {
3495
+ if (elevatedOrgIds.length === 0) throw forbidAccess();
3496
+ } else if (elevatedOrgIds.length === 0) throw forbidAccess();
3497
+ const apiQuery = {
3498
+ limit: limit.toString(),
3499
+ offset: offset.toString()
3500
+ };
3501
+ if (organizationId) apiQuery.organizationIds = organizationId;
3502
+ else if (userId) {
3503
+ apiQuery.userId = userId;
3504
+ apiQuery.organizationIds = elevatedOrgIds.join(",");
3505
+ } else apiQuery.organizationIds = elevatedOrgIds.join(",");
3506
+ const eventTypeFilter = query.eventType?.trim();
3507
+ const identifierFilter = query.identifier?.trim();
3508
+ if (eventTypeFilter) apiQuery.eventType = eventTypeFilter;
3509
+ if (identifierFilter) apiQuery.identifier = identifierFilter;
3510
+ const { data, error } = await $api("/events/activity", {
3511
+ method: "GET",
3512
+ query: apiQuery
3513
+ });
3514
+ if (error || !data) {
3515
+ ctx.context.logger.error("[Dash] Failed to fetch activity logs:", error);
3516
+ throw ctx.error("INTERNAL_SERVER_ERROR", { message: "Failed to fetch activity logs" });
3517
+ }
3518
+ return {
3519
+ events: data.events.map(transformEvent),
3520
+ total: data.total,
3521
+ limit: data.limit,
3522
+ offset: data.offset
3523
+ };
3524
+ });
3525
+ };
3386
3526
  //#endregion
3387
3527
  //#region src/routes/execute-adapter/index.ts
3388
3528
  const whereClause = z.object({
@@ -3487,10 +3627,7 @@ const executeAdapter = (options) => {
3487
3627
  * It creates the user in the auth database and sets up a session.
3488
3628
  */
3489
3629
  const acceptInvitation = (options) => {
3490
- const $api = createFetch({
3491
- baseURL: options.apiUrl || INFRA_API_URL,
3492
- headers: { "x-api-key": options.apiKey }
3493
- });
3630
+ const { $api } = options;
3494
3631
  return createAuthEndpoint("/dash/accept-invitation", {
3495
3632
  method: "GET",
3496
3633
  query: z$1.object({ token: z$1.string() })
@@ -3570,10 +3707,7 @@ const acceptInvitation = (options) => {
3570
3707
  * This creates the user in the auth database and sets up a session
3571
3708
  */
3572
3709
  const completeInvitation = (options) => {
3573
- const $api = createFetch({
3574
- baseURL: options.apiUrl || INFRA_API_URL,
3575
- headers: { "x-api-key": options.apiKey }
3576
- });
3710
+ const { $api } = options;
3577
3711
  return createAuthEndpoint("/dash/complete-invitation", {
3578
3712
  method: "POST",
3579
3713
  body: z$1.object({
@@ -6674,7 +6808,7 @@ const updateUser = (options) => createAuthEndpoint("/dash/update-user", {
6674
6808
  const updateData = ctx.body;
6675
6809
  const userId = ctx.context.payload?.userId;
6676
6810
  if (!userId) throw new APIError("FORBIDDEN", { message: "Invalid payload" });
6677
- const filteredData = Object.fromEntries(Object.entries(updateData).filter(([_, value]) => value !== void 0));
6811
+ const filteredData = Object.fromEntries(Object.entries(updateData).filter(([, value]) => value !== void 0));
6678
6812
  if (Object.keys(filteredData).length === 0) throw new APIError("BAD_REQUEST", { message: "No valid fields to update" });
6679
6813
  const user = await ctx.context.internalAdapter.updateUser(userId, {
6680
6814
  ...filteredData,
@@ -7400,6 +7534,11 @@ function createSMSSender(config) {
7400
7534
  const apiUrl = baseUrl.endsWith("/api") ? baseUrl : `${baseUrl}/api`;
7401
7535
  const apiKey = config?.apiKey || env.BETTER_AUTH_API_KEY || "";
7402
7536
  if (!apiKey) logger.warn("[Dash] No API key provided for SMS sending. Set BETTER_AUTH_API_KEY environment variable or pass apiKey in config.");
7537
+ const $api = createFetch({
7538
+ baseURL: apiUrl,
7539
+ headers: { Authorization: `Bearer ${apiKey}` },
7540
+ timeout: config?.apiTimeout ?? 3e3
7541
+ });
7403
7542
  /**
7404
7543
  * Send an SMS with OTP code
7405
7544
  */
@@ -7409,25 +7548,25 @@ function createSMSSender(config) {
7409
7548
  error: "API key not configured"
7410
7549
  };
7411
7550
  try {
7412
- const response = await fetch(`${apiUrl}/v1/sms/send`, {
7551
+ const { data, error } = await $api("/v1/sms/send", {
7413
7552
  method: "POST",
7414
- headers: {
7415
- "Content-Type": "application/json",
7416
- Authorization: `Bearer ${apiKey}`
7417
- },
7418
- body: JSON.stringify({
7553
+ body: {
7419
7554
  to: options.to,
7420
7555
  code: options.code,
7421
7556
  template: options.template
7422
- })
7557
+ }
7423
7558
  });
7424
- if (!response.ok) return {
7559
+ if (error) return {
7425
7560
  success: false,
7426
- error: (await response.json().catch(() => ({ message: "Unknown error" }))).message || `HTTP ${response.status}`
7561
+ error: error.message || `HTTP ${error.status}`
7562
+ };
7563
+ if (typeof data !== "object" || data === null || Array.isArray(data)) return {
7564
+ success: false,
7565
+ error: "Failed to parse JSON"
7427
7566
  };
7428
7567
  return {
7429
7568
  success: true,
7430
- messageId: (await response.json()).messageId
7569
+ messageId: data.messageId
7431
7570
  };
7432
7571
  } catch (error) {
7433
7572
  logger.warn("[Dash] SMS send failed:", error);
@@ -7475,8 +7614,14 @@ async function sendSMS(options, config) {
7475
7614
  const dash = (options) => {
7476
7615
  const processedBulkOperationContexts = /* @__PURE__ */ new WeakSet();
7477
7616
  const opts = resolveDashOptions(options);
7617
+ const $api = createAPI(opts);
7618
+ const settings = {
7619
+ ...opts,
7620
+ $api
7621
+ };
7622
+ const $kv = createKV(opts);
7478
7623
  const activityUpdateInterval = opts.activityTracking?.updateInterval ?? 3e5;
7479
- const { tracker } = initTrackEvents(opts);
7624
+ const { tracker } = initTrackEvents($api);
7480
7625
  const { trackUserSignedUp, trackUserProfileUpdated, trackUserProfileImageUpdated, trackUserEmailVerified, trackUserBanned, trackUserUnBanned, trackUserDeleted } = initUserEvents(tracker);
7481
7626
  const { trackEmailVerificationSent, trackEmailSignInAttempt, trackUserSignedIn, trackUserSignedOut, trackSessionCreated, trackSocialSignInAttempt, trackSocialSignInRedirectionAttempt, trackUserImpersonated, trackUserImpersonationStop, trackSessionRevoked, trackSessionRevokedAll } = initSessionEvents(tracker);
7482
7627
  const { trackAccountLinking, trackAccountUnlink, trackAccountPasswordChange } = initAccountEvents(tracker);
@@ -7758,7 +7903,7 @@ const dash = (options) => {
7758
7903
  const path = new URL(ctx.request.url).pathname;
7759
7904
  return matchesAnyRoute(path, [routes.SIGN_IN_SOCIAL_CALLBACK, routes.SIGN_IN_OAUTH_CALLBACK]);
7760
7905
  },
7761
- handler: createIdentificationMiddleware(opts)
7906
+ handler: createIdentificationMiddleware($kv)
7762
7907
  }],
7763
7908
  after: [{
7764
7909
  matcher: (ctx) => {
@@ -7773,7 +7918,7 @@ const dash = (options) => {
7773
7918
  const body = ctx.body;
7774
7919
  if (matchesAnyRoute(ctx.path, [routes.SIGN_IN_EMAIL, routes.SIGN_IN_EMAIL_OTP]) && ctx.context.returned instanceof Error && body?.email) trackEmailSignInAttempt(ctx, trigger);
7775
7920
  if (matchesAnyRoute(ctx.path, [routes.SIGN_IN_SOCIAL]) && ctx.context.returned instanceof Error && ctx.body.provider && ctx.body.idToken) trackSocialSignInAttempt(ctx, trigger);
7776
- if (matchesAnyRoute(ctx.path, [routes.SIGN_IN_SOCIAL_CALLBACK]) && ctx.context.returned instanceof Error) trackSocialSignInRedirectionAttempt(ctx, trigger);
7921
+ if (matchesAnyRoute(ctx.path, [routes.SIGN_IN_SOCIAL_CALLBACK]) && ctx.request?.method === "GET" && ctx.context.returned instanceof Error && !ctx.context.newSession) trackSocialSignInRedirectionAttempt(ctx, trigger);
7777
7922
  const headerRequestId = ctx.request?.headers.get("X-Request-Id");
7778
7923
  if (headerRequestId) ctx.setCookie(IDENTIFICATION_COOKIE_NAME, headerRequestId, {
7779
7924
  maxAge: 600,
@@ -7811,78 +7956,79 @@ const dash = (options) => {
7811
7956
  }]
7812
7957
  },
7813
7958
  endpoints: {
7814
- getDashConfig: getConfig(opts),
7815
- getDashValidate: getValidate(opts),
7816
- getDashUsers: getUsers(opts),
7817
- exportDashUsers: exportUsers(opts),
7818
- createDashUser: createUser(opts),
7819
- deleteDashUser: deleteUser(opts),
7820
- deleteManyDashUsers: deleteManyUsers(opts),
7821
- listDashOrganizations: listOrganizations(opts),
7822
- exportDashOrganizations: exportOrganizations(opts),
7823
- getDashOrganization: getOrganization(opts),
7824
- listDashOrganizationMembers: listOrganizationMembers(opts),
7825
- listDashOrganizationInvitations: listOrganizationInvitations(opts),
7826
- listDashOrganizationTeams: listOrganizationTeams(opts),
7827
- listDashOrganizationSsoProviders: listOrganizationSsoProviders(opts),
7828
- createDashSsoProvider: createSsoProvider(opts),
7829
- updateDashSsoProvider: updateSsoProvider(opts),
7830
- requestDashSsoVerificationToken: requestSsoVerificationToken(opts),
7831
- verifyDashSsoProviderDomain: verifySsoProviderDomain(opts),
7832
- deleteDashSsoProvider: deleteSsoProvider(opts),
7833
- markDashSsoProviderDomainVerified: markSsoProviderDomainVerified(opts),
7834
- listDashTeamMembers: listTeamMembers(opts),
7835
- createDashOrganization: createOrganization(opts),
7836
- deleteDashOrganization: deleteOrganization(opts),
7837
- deleteManyDashOrganizations: deleteManyOrganizations(opts),
7838
- getDashOrganizationOptions: getOrganizationOptions(opts),
7839
- getDashUser: getUserDetails(opts),
7840
- getDashUserOrganizations: getUserOrganizations(opts),
7841
- updateDashUser: updateUser(opts),
7842
- setDashPassword: setPassword(opts),
7843
- unlinkDashAccount: unlinkAccount(opts),
7844
- dashRevokeSession: revokeSession(opts),
7845
- dashRevokeAllSessions: revokeAllSessions(opts),
7846
- dashRevokeManySessions: revokeManySessions(opts),
7847
- dashImpersonateUser: impersonateUser(opts),
7848
- updateDashOrganization: updateOrganization(opts),
7849
- createDashTeam: createTeam(opts),
7850
- updateDashTeam: updateTeam(opts),
7851
- deleteDashTeam: deleteTeam(opts),
7852
- addDashTeamMember: addTeamMember(opts),
7853
- removeDashTeamMember: removeTeamMember(opts),
7854
- addDashMember: addMember(opts),
7855
- removeDashMember: removeMember(opts),
7856
- updateDashMemberRole: updateMemberRole(opts),
7857
- inviteDashMember: inviteMember(opts),
7858
- cancelDashInvitation: cancelInvitation(opts),
7859
- resendDashInvitation: resendInvitation(opts),
7860
- dashCheckUserByEmail: checkUserByEmail(opts),
7861
- dashGetUserStats: getUserStats(opts),
7862
- dashGetUserGraphData: getUserGraphData(opts),
7863
- dashGetUserRetentionData: getUserRetentionData(opts),
7864
- dashBanUser: banUser(opts),
7865
- dashBanManyUsers: banManyUsers(opts),
7866
- dashUnbanUser: unbanUser(opts),
7867
- dashSendVerificationEmail: sendVerificationEmail(opts),
7868
- dashSendManyVerificationEmails: sendManyVerificationEmails(opts),
7869
- dashSendResetPasswordEmail: sendResetPasswordEmail(opts),
7870
- dashEnableTwoFactor: enableTwoFactor(opts),
7871
- dashViewTwoFactorTotpUri: viewTwoFactorTotpUri(opts),
7872
- dashViewBackupCodes: viewBackupCodes(opts),
7873
- dashDisableTwoFactor: disableTwoFactor(opts),
7874
- dashGenerateBackupCodes: generateBackupCodes(opts),
7875
- getUserEvents: getUserEvents(opts),
7876
- getAuditLogs: getAuditLogs(opts),
7877
- getEventTypes: getEventTypes(opts),
7878
- dashAcceptInvitation: acceptInvitation(opts),
7879
- dashCompleteInvitation: completeInvitation(opts),
7880
- dashCheckUserExists: checkUserExists(opts),
7881
- listDashOrganizationDirectories: listOrganizationDirectories(opts),
7882
- createDashOrganizationDirectory: createOrganizationDirectory(opts),
7883
- deleteDashOrganizationDirectory: deleteOrganizationDirectory(opts),
7884
- regenerateDashDirectoryToken: regenerateDirectoryToken(opts),
7885
- dashExecuteAdapter: executeAdapter(opts)
7959
+ getDashConfig: getConfig(settings),
7960
+ getDashValidate: getValidate(settings),
7961
+ getDashUsers: getUsers(settings),
7962
+ exportDashUsers: exportUsers(settings),
7963
+ createDashUser: createUser(settings),
7964
+ deleteDashUser: deleteUser(settings),
7965
+ deleteManyDashUsers: deleteManyUsers(settings),
7966
+ listDashOrganizations: listOrganizations(settings),
7967
+ exportDashOrganizations: exportOrganizations(settings),
7968
+ getDashOrganization: getOrganization(settings),
7969
+ listDashOrganizationMembers: listOrganizationMembers(settings),
7970
+ listDashOrganizationInvitations: listOrganizationInvitations(settings),
7971
+ listDashOrganizationTeams: listOrganizationTeams(settings),
7972
+ listDashOrganizationSsoProviders: listOrganizationSsoProviders(settings),
7973
+ createDashSsoProvider: createSsoProvider(settings),
7974
+ updateDashSsoProvider: updateSsoProvider(settings),
7975
+ requestDashSsoVerificationToken: requestSsoVerificationToken(settings),
7976
+ verifyDashSsoProviderDomain: verifySsoProviderDomain(settings),
7977
+ deleteDashSsoProvider: deleteSsoProvider(settings),
7978
+ markDashSsoProviderDomainVerified: markSsoProviderDomainVerified(settings),
7979
+ listDashTeamMembers: listTeamMembers(settings),
7980
+ createDashOrganization: createOrganization(settings),
7981
+ deleteDashOrganization: deleteOrganization(settings),
7982
+ deleteManyDashOrganizations: deleteManyOrganizations(settings),
7983
+ getDashOrganizationOptions: getOrganizationOptions(settings),
7984
+ getDashUser: getUserDetails(settings),
7985
+ getDashUserOrganizations: getUserOrganizations(settings),
7986
+ updateDashUser: updateUser(settings),
7987
+ setDashPassword: setPassword(settings),
7988
+ unlinkDashAccount: unlinkAccount(settings),
7989
+ dashRevokeSession: revokeSession(settings),
7990
+ dashRevokeAllSessions: revokeAllSessions(settings),
7991
+ dashRevokeManySessions: revokeManySessions(settings),
7992
+ dashImpersonateUser: impersonateUser(settings),
7993
+ updateDashOrganization: updateOrganization(settings),
7994
+ createDashTeam: createTeam(settings),
7995
+ updateDashTeam: updateTeam(settings),
7996
+ deleteDashTeam: deleteTeam(settings),
7997
+ addDashTeamMember: addTeamMember(settings),
7998
+ removeDashTeamMember: removeTeamMember(settings),
7999
+ addDashMember: addMember(settings),
8000
+ removeDashMember: removeMember(settings),
8001
+ updateDashMemberRole: updateMemberRole(settings),
8002
+ inviteDashMember: inviteMember(settings),
8003
+ cancelDashInvitation: cancelInvitation(settings),
8004
+ resendDashInvitation: resendInvitation(settings),
8005
+ dashCheckUserByEmail: checkUserByEmail(settings),
8006
+ dashGetUserStats: getUserStats(settings),
8007
+ dashGetUserGraphData: getUserGraphData(settings),
8008
+ dashGetUserRetentionData: getUserRetentionData(settings),
8009
+ dashBanUser: banUser(settings),
8010
+ dashBanManyUsers: banManyUsers(settings),
8011
+ dashUnbanUser: unbanUser(settings),
8012
+ dashSendVerificationEmail: sendVerificationEmail(settings),
8013
+ dashSendManyVerificationEmails: sendManyVerificationEmails(settings),
8014
+ dashSendResetPasswordEmail: sendResetPasswordEmail(settings),
8015
+ dashEnableTwoFactor: enableTwoFactor(settings),
8016
+ dashViewTwoFactorTotpUri: viewTwoFactorTotpUri(settings),
8017
+ dashViewBackupCodes: viewBackupCodes(settings),
8018
+ dashDisableTwoFactor: disableTwoFactor(settings),
8019
+ dashGenerateBackupCodes: generateBackupCodes(settings),
8020
+ getUserEvents: getUserEvents(settings),
8021
+ getAuditLogs: getAuditLogs(settings),
8022
+ getAllAuditLogs: getAllAuditLogs(settings),
8023
+ getEventTypes: getEventTypes(settings),
8024
+ dashAcceptInvitation: acceptInvitation(settings),
8025
+ dashCompleteInvitation: completeInvitation(settings),
8026
+ dashCheckUserExists: checkUserExists(settings),
8027
+ listDashOrganizationDirectories: listOrganizationDirectories(settings),
8028
+ createDashOrganizationDirectory: createOrganizationDirectory(settings),
8029
+ deleteDashOrganizationDirectory: deleteOrganizationDirectory(settings),
8030
+ regenerateDashDirectoryToken: regenerateDirectoryToken(settings),
8031
+ dashExecuteAdapter: executeAdapter(settings)
7886
8032
  },
7887
8033
  schema: opts.activityTracking?.enabled ? { user: { fields: { lastActiveAt: {
7888
8034
  type: "date",