@better-auth/infra 0.2.10 → 0.2.12

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/client.mjs CHANGED
@@ -422,6 +422,8 @@ async function waitForIdentify(timeoutMs) {
422
422
  }
423
423
  //#endregion
424
424
  //#region src/sentinel/client.ts
425
+ /** One recovery round after a consumed or superseded challenge (e.g. double-submit). */
426
+ const MAX_POW_CHALLENGE_ROUNDS = 2;
425
427
  const sentinelClient = (options) => {
426
428
  const autoSolve = options?.autoSolveChallenge !== false;
427
429
  const $kv = createKV({
@@ -470,50 +472,55 @@ const sentinelClient = (options) => {
470
472
  async onResponse(context) {
471
473
  if (typeof window === "undefined") return context;
472
474
  if (context.response.status !== 423 || !autoSolve) return context;
473
- const challengeHeader = context.response.headers.get("X-PoW-Challenge");
474
- const reason = context.response.headers.get("X-PoW-Reason") || "";
475
- if (!challengeHeader) return context;
476
- options?.onChallengeReceived?.(reason);
477
- const challenge = decodePoWChallenge(challengeHeader);
478
- if (!challenge) return context;
479
- try {
480
- const startTime = Date.now();
481
- const solution = await solvePoWChallenge(challenge);
482
- const solveTime = Date.now() - startTime;
483
- options?.onChallengeSolved?.(solveTime);
484
- const originalUrl = context.response.url;
485
- const fingerprint = await getFingerprint();
486
- const retryHeaders = new Headers();
487
- retryHeaders.set("X-PoW-Solution", encodePoWSolution(solution));
488
- if (fingerprint) {
489
- retryHeaders.set("X-Visitor-Id", fingerprint.visitorId);
490
- retryHeaders.set("X-Request-Id", fingerprint.requestId);
491
- }
492
- retryHeaders.set("Content-Type", "application/json");
493
- let body;
494
- if (context.request && context.request.body) body = context.request._originalBody;
495
- const req = context.request;
496
- const powRetry = createPowRetryTimeout(req.timeout);
475
+ if (!context.response.headers.get("X-PoW-Challenge")) return context;
476
+ const req = context.request;
477
+ let response = context.response;
478
+ const url = response.url;
479
+ const body = req._originalBody;
480
+ for (let round = 0; round < MAX_POW_CHALLENGE_ROUNDS; round++) {
481
+ if (response.status !== 423) break;
482
+ const challengeHeader = response.headers.get("X-PoW-Challenge");
483
+ if (!challengeHeader) break;
484
+ const reason = response.headers.get("X-PoW-Reason") || "";
485
+ options?.onChallengeReceived?.(reason);
486
+ const challenge = decodePoWChallenge(challengeHeader);
487
+ if (!challenge) break;
497
488
  try {
498
- const retryResponse = await fetch(originalUrl, {
499
- method: req.method || "POST",
500
- headers: retryHeaders,
501
- body,
502
- credentials: "include",
503
- ...powRetry.signal ? { signal: powRetry.signal } : {}
504
- });
505
- return {
506
- ...context,
507
- response: retryResponse
508
- };
509
- } finally {
510
- powRetry.cleanup?.();
489
+ const startTime = Date.now();
490
+ const solution = await solvePoWChallenge(challenge);
491
+ options?.onChallengeSolved?.(Date.now() - startTime);
492
+ const fingerprint = await getFingerprint();
493
+ const retryHeaders = new Headers();
494
+ retryHeaders.set("X-PoW-Solution", encodePoWSolution(solution));
495
+ if (fingerprint) {
496
+ retryHeaders.set("X-Visitor-Id", fingerprint.visitorId);
497
+ retryHeaders.set("X-Request-Id", fingerprint.requestId);
498
+ }
499
+ retryHeaders.set("Content-Type", "application/json");
500
+ const powRetry = createPowRetryTimeout(req.timeout);
501
+ try {
502
+ response = await fetch(url, {
503
+ method: req.method || "POST",
504
+ headers: retryHeaders,
505
+ body,
506
+ credentials: "include",
507
+ ...powRetry.signal ? { signal: powRetry.signal } : {}
508
+ });
509
+ } finally {
510
+ powRetry.cleanup?.();
511
+ }
512
+ if (response.status !== 423) break;
513
+ if (!response.headers.get("X-PoW-Challenge")) break;
514
+ } catch (error) {
515
+ console.error("[Sentinel] Failed to solve PoW challenge:", error);
516
+ options?.onChallengeFailed?.(error instanceof Error ? error : new Error(String(error)));
517
+ return context;
511
518
  }
512
- } catch (error) {
513
- console.error("[Sentinel] Failed to solve PoW challenge:", error);
514
- options?.onChallengeFailed?.(error instanceof Error ? error : new Error(String(error)));
515
- return context;
516
519
  }
520
+ return {
521
+ ...context,
522
+ response
523
+ };
517
524
  },
518
525
  async onRequest(context) {
519
526
  if (context.body) context._originalBody = context.body;
package/dist/index.d.mts CHANGED
@@ -117,6 +117,8 @@ interface SecurityVerdict {
117
117
  challenge?: string;
118
118
  reason?: string;
119
119
  details?: Record<string, unknown>;
120
+ /** Set when the request included a valid, consumed PoW solution. */
121
+ powVerified?: boolean;
120
122
  }
121
123
  interface CredentialStuffingResult {
122
124
  blocked: boolean;
@@ -265,7 +267,7 @@ declare const sentinel: (options?: SentinelOptions) => {
265
267
  };
266
268
  hooks: {
267
269
  before: ({
268
- matcher: (context: import("better-auth").HookEndpointContext) => boolean;
270
+ matcher: (context: Pick<import("better-auth").HookEndpointContext, "path">) => boolean;
269
271
  handler: (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<{
270
272
  context: {
271
273
  method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
@@ -437,7 +439,7 @@ declare const sentinel: (options?: SentinelOptions) => {
437
439
  };
438
440
  } | undefined>;
439
441
  } | {
440
- matcher: (context: import("better-auth").HookEndpointContext) => boolean;
442
+ matcher: (ctx: import("better-auth").HookEndpointContext) => boolean;
441
443
  handler: (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<void>;
442
444
  })[];
443
445
  after: {
@@ -555,6 +557,8 @@ type InfraEndpointContext = (GenericEndpointContext & {
555
557
  identification?: Identification | null | undefined;
556
558
  visitorId: string | null;
557
559
  requestId: string | null;
560
+ ip: string | null;
561
+ untrustedVisitorId: string | null;
558
562
  location: LocationData | undefined;
559
563
  };
560
564
  }) | undefined;
@@ -770,7 +774,7 @@ interface DashIdRow {
770
774
  id: string;
771
775
  }
772
776
  //#endregion
773
- //#region ../../node_modules/.bun/@better-auth+scim@1.6.11+89e630460ed02574/node_modules/@better-auth/scim/dist/index.d.mts
777
+ //#region ../../node_modules/.bun/@better-auth+scim@1.6.11+25e6a1339d5195dd/node_modules/@better-auth/scim/dist/index.d.mts
774
778
  //#region src/types.d.ts
775
779
  interface SCIMProvider {
776
780
  id: string;
@@ -4523,7 +4527,6 @@ interface DashTwoFactorEnableResponse {
4523
4527
  }
4524
4528
  interface DashTwoFactorTotpViewResponse {
4525
4529
  totpURI: string;
4526
- secret: string;
4527
4530
  }
4528
4531
  interface DashTwoFactorBackupCodesResponse {
4529
4532
  backupCodes: string[];
@@ -5771,12 +5774,50 @@ declare const dash: <O extends DashOptions>(options?: O) => {
5771
5774
  body: import("zod").ZodObject<{
5772
5775
  token: import("zod").ZodString;
5773
5776
  password: import("zod").ZodOptional<import("zod").ZodString>;
5774
- providerId: import("zod").ZodOptional<import("zod").ZodString>;
5775
- providerAccountId: import("zod").ZodOptional<import("zod").ZodString>;
5776
- accessToken: import("zod").ZodOptional<import("zod").ZodString>;
5777
- refreshToken: import("zod").ZodOptional<import("zod").ZodString>;
5778
5777
  }, import("zod/v4/core").$strip>;
5779
5778
  }, DashCompleteInvitationResponse>;
5779
+ dashCompleteInvitationSocial: import("better-call").StrictEndpoint<"/dash/complete-invitation-social", {
5780
+ method: "GET";
5781
+ query: import("zod").ZodObject<{
5782
+ token: import("zod").ZodString;
5783
+ }, import("zod/v4/core").$strip>;
5784
+ use: ((inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<{
5785
+ session: {
5786
+ session: Record<string, any> & {
5787
+ id: string;
5788
+ createdAt: Date;
5789
+ updatedAt: Date;
5790
+ userId: string;
5791
+ expiresAt: Date;
5792
+ token: string;
5793
+ ipAddress?: string | null | undefined;
5794
+ userAgent?: string | null | undefined;
5795
+ };
5796
+ user: Record<string, any> & {
5797
+ id: string;
5798
+ createdAt: Date;
5799
+ updatedAt: Date;
5800
+ email: string;
5801
+ emailVerified: boolean;
5802
+ name: string;
5803
+ image?: string | null | undefined;
5804
+ };
5805
+ };
5806
+ }>)[];
5807
+ }, {
5808
+ status: ("OK" | "CREATED" | "ACCEPTED" | "NO_CONTENT" | "MULTIPLE_CHOICES" | "MOVED_PERMANENTLY" | "FOUND" | "SEE_OTHER" | "NOT_MODIFIED" | "TEMPORARY_REDIRECT" | "BAD_REQUEST" | "UNAUTHORIZED" | "PAYMENT_REQUIRED" | "FORBIDDEN" | "NOT_FOUND" | "METHOD_NOT_ALLOWED" | "NOT_ACCEPTABLE" | "PROXY_AUTHENTICATION_REQUIRED" | "REQUEST_TIMEOUT" | "CONFLICT" | "GONE" | "LENGTH_REQUIRED" | "PRECONDITION_FAILED" | "PAYLOAD_TOO_LARGE" | "URI_TOO_LONG" | "UNSUPPORTED_MEDIA_TYPE" | "RANGE_NOT_SATISFIABLE" | "EXPECTATION_FAILED" | "I'M_A_TEAPOT" | "MISDIRECTED_REQUEST" | "UNPROCESSABLE_ENTITY" | "LOCKED" | "FAILED_DEPENDENCY" | "TOO_EARLY" | "UPGRADE_REQUIRED" | "PRECONDITION_REQUIRED" | "TOO_MANY_REQUESTS" | "REQUEST_HEADER_FIELDS_TOO_LARGE" | "UNAVAILABLE_FOR_LEGAL_REASONS" | "INTERNAL_SERVER_ERROR" | "NOT_IMPLEMENTED" | "BAD_GATEWAY" | "SERVICE_UNAVAILABLE" | "GATEWAY_TIMEOUT" | "HTTP_VERSION_NOT_SUPPORTED" | "VARIANT_ALSO_NEGOTIATES" | "INSUFFICIENT_STORAGE" | "LOOP_DETECTED" | "NOT_EXTENDED" | "NETWORK_AUTHENTICATION_REQUIRED") | import("better-call").Status;
5809
+ body: ({
5810
+ message?: string;
5811
+ code?: string;
5812
+ cause?: unknown;
5813
+ } & Record<string, any>) | undefined;
5814
+ headers: HeadersInit;
5815
+ statusCode: number;
5816
+ name: string;
5817
+ message: string;
5818
+ stack?: string;
5819
+ cause?: unknown;
5820
+ }>;
5780
5821
  dashCheckUserExists: import("better-call").StrictEndpoint<"/dash/check-user-exists", {
5781
5822
  method: "POST";
5782
5823
  use: ((inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<{
package/dist/index.mjs CHANGED
@@ -942,45 +942,67 @@ function extractIdentificationHeaders(request) {
942
942
  };
943
943
  }
944
944
  /**
945
+ * Visitor ID for security enforcement from a loaded identification record —
946
+ * not a client-supplied X-Visitor-Id header alone.
947
+ */
948
+ function resolveSecurityVisitorId(headerVisitorId, identification) {
949
+ const identified = identification?.visitorId ?? null;
950
+ if (!identified) return null;
951
+ if (headerVisitorId && headerVisitorId !== identified) logger.warn("[Sentinel] X-Visitor-Id does not match identification; using identification visitorId for security checks.");
952
+ return identified;
953
+ }
954
+ /** IP for security checks: prefer identify record, then request-derived fallback. */
955
+ function resolveSecurityIp(identification, location) {
956
+ return identification?.ip ?? location?.ipAddress ?? null;
957
+ }
958
+ /**
959
+ * Visitor id for observability and failed-login tracking when identification
960
+ * is unavailable. Prefer bound visitor id, then a stable IP bucket; header is
961
+ * legacy fallback only — not used for enforcement.
962
+ */
963
+ function resolveUntrustedVisitorId(visitorId, ip, headerVisitorId) {
964
+ if (visitorId) return visitorId;
965
+ if (ip) return `ip:${ip}`;
966
+ return headerVisitorId;
967
+ }
968
+ /**
945
969
  * Early middleware that loads identification data.
946
970
  *
947
971
  * @param $kv — KV client from {@link createKV} for the same plugin options (one instance per plugin).
948
972
  */
949
973
  function createIdentificationMiddleware($kv) {
950
974
  return createAuthMiddleware(async (ctx) => {
951
- const { visitorId, requestId: headerRequestId } = extractIdentificationHeaders(ctx.request);
975
+ const { visitorId: headerVisitorId, requestId: headerRequestId } = extractIdentificationHeaders(ctx.request);
952
976
  const requestId = headerRequestId ?? ctx.getCookie("__infra-rid") ?? null;
953
- ctx.context.visitorId = visitorId;
954
977
  ctx.context.requestId = requestId;
955
978
  if (requestId) {
956
979
  if (ctx.context.identification === void 0) ctx.context.identification = await getIdentification(requestId, $kv) ?? null;
957
980
  } else ctx.context.identification = null;
958
- const ipConfig = ctx.context.options?.advanced?.ipAddress;
959
- if (ipConfig?.disableIpTracking === true) {
960
- ctx.context.location = void 0;
961
- return;
962
- }
963
981
  const identification = ctx.context.identification;
964
- if (requestId && identification) {
982
+ const visitorId = resolveSecurityVisitorId(headerVisitorId, identification);
983
+ ctx.context.visitorId = visitorId;
984
+ const ipConfig = ctx.context.options?.advanced?.ipAddress;
985
+ let location;
986
+ if (ipConfig?.disableIpTracking === true) location = void 0;
987
+ else if (requestId && identification) {
965
988
  const loc = getLocation(identification);
966
- ctx.context.location = {
989
+ location = {
967
990
  ipAddress: identification.ip || void 0,
968
991
  city: loc?.city || void 0,
969
992
  country: loc?.country?.name || void 0,
970
993
  countryCode: loc?.country?.code || void 0
971
994
  };
972
- return;
973
- }
974
- const ipAddress = getClientIpFromRequest(ctx.request, ipConfig?.ipAddressHeaders || null);
975
- if (ipAddress) {
976
- const countryCode = getCountryCodeFromRequest(ctx.request);
977
- ctx.context.location = {
995
+ } else {
996
+ const ipAddress = getClientIpFromRequest(ctx.request, ipConfig?.ipAddressHeaders || null);
997
+ if (ipAddress) location = {
978
998
  ipAddress,
979
- countryCode
999
+ countryCode: getCountryCodeFromRequest(ctx.request)
980
1000
  };
981
- return;
1001
+ else location = void 0;
982
1002
  }
983
- ctx.context.location = void 0;
1003
+ ctx.context.location = location;
1004
+ ctx.context.ip = resolveSecurityIp(identification, location);
1005
+ ctx.context.untrustedVisitorId = resolveUntrustedVisitorId(visitorId, ctx.context.ip, headerVisitorId);
984
1006
  });
985
1007
  }
986
1008
  /**
@@ -1024,7 +1046,13 @@ function getEvaluation(ctx) {
1024
1046
  function recordCheck(ctx, name) {
1025
1047
  getEvaluation(ctx).checks.add(name);
1026
1048
  }
1027
- /** Call immediately before throwing so the buffer reflects the final outcome. */
1049
+ /**
1050
+ * Call immediately before throwing so the buffer reflects the final outcome.
1051
+ *
1052
+ * `details.reason`, when set, is surfaced as the top-level dashboard reason
1053
+ * (see `emitEvaluation`) — so it must be a renderable reason, not a free-form
1054
+ * value. Put internal sub-states under a different key (e.g. `phase`).
1055
+ */
1028
1056
  function setOutcome(ctx, outcome, triggeredBy, details) {
1029
1057
  const ev = getEvaluation(ctx);
1030
1058
  ev.outcome = outcome;
@@ -1052,6 +1080,7 @@ function emitEvaluation(ctx, trackEvent, options = {}) {
1052
1080
  outcome: ev.outcome,
1053
1081
  evaluatedChecks: [...ev.checks].sort(),
1054
1082
  triggeredBy: ev.triggeredBy,
1083
+ reason: ev.details?.reason ?? ev.triggeredBy,
1055
1084
  identifier: options.identifier,
1056
1085
  path,
1057
1086
  userAgent: options.userAgent,
@@ -1171,9 +1200,11 @@ function createSecurityClient(conn, $api, options, onSecurityEvent) {
1171
1200
  logger.error("[Dash] Clear failed attempts error:", error);
1172
1201
  }
1173
1202
  },
1174
- async isBlocked(visitorId) {
1203
+ async isBlocked(visitorId, ip) {
1175
1204
  try {
1176
- return (await $api(`/security/is-blocked?visitorId=${encodeURIComponent(visitorId)}`, { method: "GET" })).blocked ?? false;
1205
+ const params = new URLSearchParams({ visitorId });
1206
+ if (ip) params.set("ip", ip);
1207
+ return (await $api(`/security/is-blocked?${params.toString()}`, { method: "GET" })).blocked ?? false;
1177
1208
  } catch (error) {
1178
1209
  logger.warn("[Dash] Security is-blocked check failed:", error);
1179
1210
  return false;
@@ -1483,52 +1514,47 @@ function createSecurityClient(conn, $api, options, onSecurityEvent) {
1483
1514
  }
1484
1515
  //#endregion
1485
1516
  //#region src/validation/matchers.ts
1486
- const all = new Set([
1517
+ /**
1518
+ * Routes where a new or changed email address enters the system. Deliverability
1519
+ * and disposable validation runs here so invalid addresses are caught at the
1520
+ * source.
1521
+ */
1522
+ const registrationPaths = [
1487
1523
  "/sign-up/email",
1488
- "/email-otp/verify-email",
1489
- "/sign-in/email-otp",
1490
- "/sign-in/magic-link",
1491
- "/sign-in/email",
1492
- "/forget-password/email-otp",
1493
- "/email-otp/reset-password",
1494
- "/email-otp/create-verification-otp",
1495
- "/email-otp/get-verification-otp",
1496
- "/email-otp/send-verification-otp",
1497
- "/forget-password",
1498
- "/request-password-reset",
1499
- "/send-verification-email",
1500
1524
  "/change-email",
1501
1525
  "/organization/invite-member",
1502
1526
  "/dash/organization/invite-member",
1503
1527
  "/dash/create-user",
1504
1528
  "/dash/update-user"
1505
- ]);
1529
+ ];
1506
1530
  /**
1507
- * Path is one of `[
1508
- * '/sign-up/email',
1509
- * '/email-otp/verify-email',
1510
- * '/sign-in/email-otp',
1511
- * '/sign-in/magic-link',
1512
- * '/sign-in/email',
1513
- * '/forget-password/email-otp',
1514
- * '/email-otp/reset-password',
1515
- * '/email-otp/create-verification-otp',
1516
- * '/email-otp/get-verification-otp',
1517
- * '/email-otp/send-verification-otp',
1518
- * '/forget-password',
1519
- * '/request-password-reset',
1520
- * '/send-verification-email',
1521
- * '/change-email',
1522
- * '/organization/invite-member',
1523
- * '/dash/organization/invite-member',
1524
- * '/dash/create-user',
1525
- * '/dash/update-user'
1526
- * ]`.
1527
- * @param context Request context
1528
- * @param context.path Request path
1529
- * @returns boolean
1531
+ * Authentication and account-recovery routes. These act on an already-known
1532
+ * address, so only the syntax check runs — a deliverability false-positive must
1533
+ * never lock out an existing user (better-auth#9803).
1530
1534
  */
1535
+ const authPaths = [
1536
+ "/sign-in/email",
1537
+ "/sign-in/email-otp",
1538
+ "/sign-in/magic-link",
1539
+ "/forget-password",
1540
+ "/forget-password/email-otp",
1541
+ "/request-password-reset",
1542
+ "/send-verification-email",
1543
+ "/email-otp/verify-email",
1544
+ "/email-otp/reset-password",
1545
+ "/email-otp/create-verification-otp",
1546
+ "/email-otp/get-verification-otp",
1547
+ "/email-otp/send-verification-otp"
1548
+ ];
1549
+ const registration = new Set(registrationPaths);
1550
+ const all = new Set([...registrationPaths, ...authPaths]);
1551
+ /** Path carries an email we hook for normalization + syntax validation. */
1531
1552
  const allEmail = ({ path }) => !!path && all.has(path);
1553
+ /**
1554
+ * Path introduces a new or changed address — run full deliverability/disposable
1555
+ * validation. Authentication routes are intentionally excluded.
1556
+ */
1557
+ const registrationEmail = ({ path }) => !!path && registration.has(path);
1532
1558
  //#endregion
1533
1559
  //#region src/validation/email.ts
1534
1560
  /**
@@ -1624,7 +1650,7 @@ async validate(email, checkMx = true) {
1624
1650
  policy
1625
1651
  };
1626
1652
  try {
1627
- const { data } = await $kv("/email/validate", {
1653
+ const { data, error } = await $kv("/email/validate", {
1628
1654
  method: "POST",
1629
1655
  body: {
1630
1656
  email: trimmed,
@@ -1632,11 +1658,15 @@ async validate(email, checkMx = true) {
1632
1658
  strictness: policy.strictness
1633
1659
  }
1634
1660
  });
1661
+ if (error || !data) {
1662
+ logger.warn("[Dash] Email validation unavailable, allowing email:", error);
1663
+ return {
1664
+ valid: true,
1665
+ policy
1666
+ };
1667
+ }
1635
1668
  return {
1636
- ...data || {
1637
- valid: false,
1638
- reason: "invalid_format"
1639
- },
1669
+ ...data,
1640
1670
  policy
1641
1671
  };
1642
1672
  } catch (error) {
@@ -1649,6 +1679,26 @@ async validate(email, checkMx = true) {
1649
1679
  } };
1650
1680
  }
1651
1681
  /**
1682
+ * Map a raw validation result to a single dashboard reason and user-facing
1683
+ * message. One source so the recorded reason and the thrown message can't drift,
1684
+ * and a block always carries a populated reason (better-auth#9803).
1685
+ */
1686
+ function resolveEmailBlock(result) {
1687
+ const raw = result.reason ?? "";
1688
+ if (result.disposable || raw === "blocklist" || raw.startsWith("known_disposable")) return {
1689
+ reason: "disposable_email",
1690
+ message: "Disposable email addresses are not allowed"
1691
+ };
1692
+ if (raw === "no_mx_records") return {
1693
+ reason: "no_mx_records",
1694
+ message: "This email domain cannot receive emails"
1695
+ };
1696
+ return {
1697
+ reason: "invalid_email",
1698
+ message: "This email address appears to be invalid"
1699
+ };
1700
+ }
1701
+ /**
1652
1702
  * Basic local email format validation (fallback)
1653
1703
  */
1654
1704
  function isValidEmailFormatLocal(email) {
@@ -1714,14 +1764,15 @@ function createEmailValidationHook(validator, trackEvent) {
1714
1764
  if (!isValidEmailFormatLocal(trimmed)) {
1715
1765
  if (trackEvent) {
1716
1766
  setOutcome(ctx, "blocked", "email_validity", {
1717
- reason: "invalid_format",
1767
+ reason: "invalid_email",
1768
+ rawReason: "invalid_format",
1718
1769
  email: trimmed
1719
1770
  });
1720
1771
  emitEvaluation(ctx, trackEvent, { identifier: trimmed });
1721
1772
  }
1722
1773
  throw new APIError$1("BAD_REQUEST", { message: "Invalid email" });
1723
1774
  }
1724
- if (validator) {
1775
+ if (validator && registrationEmail(ctx)) {
1725
1776
  const result = await validator.validate(trimmed);
1726
1777
  const policy = result.policy;
1727
1778
  if (!policy?.enabled) return;
@@ -1732,10 +1783,11 @@ function createEmailValidationHook(validator, trackEvent) {
1732
1783
  const action = policy.action;
1733
1784
  if (!result.valid) {
1734
1785
  if (action === "allow") return;
1735
- 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";
1786
+ const { reason, message } = resolveEmailBlock(result);
1736
1787
  if (trackEvent) {
1737
1788
  setOutcome(ctx, "blocked", "email_validity", {
1738
- reason: result.reason,
1789
+ reason,
1790
+ rawReason: result.reason,
1739
1791
  confidence: result.confidence,
1740
1792
  email: trimmed
1741
1793
  });
@@ -2006,6 +2058,7 @@ function throwChallengeError(challenge, reason, message = "Please complete a sec
2006
2058
  }
2007
2059
  /** Emits the `security_check` event inline before throwing — the after-hook won't run on throw. */
2008
2060
  async function handleSecurityVerdict(verdict, hookCtx, checkCtx, trackEvent, securityService) {
2061
+ if (verdict.powVerified) recordCheck(hookCtx, "pow_challenge");
2009
2062
  if (verdict.action === "allow") return;
2010
2063
  const reason = verdict.reason || "unknown";
2011
2064
  if (verdict.action === "challenge" && checkCtx.visitorId) {
@@ -2040,15 +2093,15 @@ async function handleSecurityVerdict(verdict, hookCtx, checkCtx, trackEvent, sec
2040
2093
  * Single API call from the plugin; server runs all configured rules and returns
2041
2094
  * one verdict — counts as one billable check.
2042
2095
  */
2043
- async function runSecurityChecks(hookCtx, checkCtx, securityService, trackEvent, powVerified) {
2044
- if (powVerified) return;
2096
+ async function runSecurityChecks(hookCtx, checkCtx, securityService, trackEvent, powSolution) {
2045
2097
  recordCheck(hookCtx, "server_security_check");
2046
2098
  await handleSecurityVerdict(await securityService.checkSecurity({
2047
2099
  visitorId: checkCtx.visitorId,
2048
2100
  requestId: checkCtx.identification?.requestId || null,
2049
- ip: checkCtx.identification?.ip || null,
2101
+ ip: checkCtx.ip,
2050
2102
  path: checkCtx.path,
2051
- identifier: checkCtx.identifier
2103
+ identifier: checkCtx.identifier,
2104
+ powSolution: powSolution ?? void 0
2052
2105
  }), hookCtx, checkCtx, trackEvent, securityService);
2053
2106
  }
2054
2107
  //#endregion
@@ -2228,16 +2281,6 @@ const sentinel = (options) => {
2228
2281
  matcher: (ctx) => ctx.request?.method !== "GET",
2229
2282
  handler: createIdentificationMiddleware($kv)
2230
2283
  },
2231
- {
2232
- matcher: (ctx) => ctx.request?.method !== "GET",
2233
- handler: createAuthMiddleware(async (ctx) => {
2234
- const visitorId = ctx.context.visitorId;
2235
- const powSolution = ctx.headers?.get?.("X-PoW-Solution");
2236
- if (visitorId && powSolution) {
2237
- if ((await securityService.verifyPoWSolution(visitorId, powSolution)).valid) ctx.context.powVerified = true;
2238
- }
2239
- })
2240
- },
2241
2284
  ...emailHooks.before,
2242
2285
  ...phoneValidationHooks.before,
2243
2286
  {
@@ -2245,6 +2288,7 @@ const sentinel = (options) => {
2245
2288
  handler: createAuthMiddleware(async (ctx) => {
2246
2289
  const visitorId = ctx.context.visitorId;
2247
2290
  const identification = ctx.context.identification;
2291
+ const ip = ctx.context.ip;
2248
2292
  const isSignIn = matchesAnyRoute(ctx.path, [
2249
2293
  routes.SIGN_IN_EMAIL,
2250
2294
  routes.SIGN_IN_USERNAME,
@@ -2275,14 +2319,13 @@ const sentinel = (options) => {
2275
2319
  if (!(isSignIn || isSignUp || isPasswordReset || isTwoFactor || isOtpSend || isMagicLinkVerify || isOrgCreate || isSensitiveAction)) return;
2276
2320
  const requestBody = ctx.body;
2277
2321
  const identifier = requestBody?.email || requestBody?.phone || requestBody?.username || void 0;
2278
- const powVerified = ctx.context.powVerified === true;
2279
- if (visitorId && powVerified) recordCheck(ctx, "pow_challenge");
2280
- if (visitorId) {
2322
+ const powSolution = ctx.headers?.get?.("X-PoW-Solution");
2323
+ if (visitorId || ip) {
2281
2324
  recordCheck(ctx, "credential_stuffing");
2282
- if (await securityService.isBlocked(visitorId)) {
2325
+ if (await securityService.isBlocked(visitorId ?? "", ip)) {
2283
2326
  setOutcome(ctx, "blocked", "credential_stuffing", {
2284
- reason: "cooldown_active",
2285
- visitorId,
2327
+ phase: "cooldown_active",
2328
+ visitorId: ctx.context.untrustedVisitorId ?? void 0,
2286
2329
  confidence: 1
2287
2330
  });
2288
2331
  emitEvaluation(ctx, trackEvent, {
@@ -2297,8 +2340,9 @@ const sentinel = (options) => {
2297
2340
  identifier,
2298
2341
  visitorId,
2299
2342
  identification,
2343
+ ip: ctx.context.ip,
2300
2344
  userAgent: ctx.headers?.get?.("user-agent") || ""
2301
- }, securityService, trackEvent, powVerified);
2345
+ }, securityService, trackEvent, powSolution);
2302
2346
  if (matchesAnyRoute(ctx.path, [
2303
2347
  routes.SIGN_UP_EMAIL,
2304
2348
  routes.CHANGE_PASSWORD,
@@ -2331,22 +2375,21 @@ const sentinel = (options) => {
2331
2375
  after: [{
2332
2376
  matcher: (ctx) => ctx.request?.method !== "GET",
2333
2377
  handler: createAuthMiddleware(async (ctx) => {
2334
- const visitorId = ctx.context.visitorId;
2335
- const identification = ctx.context.identification;
2378
+ const untrustedVisitorId = ctx.context.untrustedVisitorId;
2379
+ const ip = ctx.context.ip;
2336
2380
  const body = ctx.body;
2381
+ const loginId = matchesAnyRoute(ctx.path, [routes.SIGN_IN_USERNAME]) ? body?.username : body?.email;
2382
+ const isPasswordSignInRoute = matchesAnyRoute(ctx.path, [
2383
+ routes.SIGN_IN_EMAIL,
2384
+ routes.SIGN_IN_USERNAME,
2385
+ routes.SIGN_IN_EMAIL_OTP
2386
+ ]);
2337
2387
  emitEvaluation(ctx, trackEvent, {
2338
- identifier: body?.email,
2388
+ identifier: loginId,
2339
2389
  userAgent: ctx.headers?.get?.("user-agent") || ""
2340
2390
  });
2341
- if (matchesAnyRoute(ctx.path, [routes.SIGN_IN_EMAIL, routes.SIGN_IN_EMAIL_OTP]) && ctx.context.returned instanceof Error && body?.email && body?.password && visitorId) {
2342
- const { email, password } = body;
2343
- const ip = identification?.ip || null;
2344
- await ctx.context.runInBackgroundOrAwait(securityService.trackFailedAttempt(email, visitorId, password, ip));
2345
- }
2346
- if (matchesAnyRoute(ctx.path, [routes.SIGN_IN_EMAIL, routes.SIGN_IN_EMAIL_OTP]) && !(ctx.context.returned instanceof Error) && body?.email) {
2347
- const email = body.email;
2348
- await ctx.context.runInBackgroundOrAwait(securityService.clearFailedAttempts(email));
2349
- }
2391
+ if (isPasswordSignInRoute && ctx.context.returned instanceof Error && loginId && body?.password && untrustedVisitorId) await ctx.context.runInBackgroundOrAwait(securityService.trackFailedAttempt(loginId, untrustedVisitorId, body.password, ip));
2392
+ if (isPasswordSignInRoute && !(ctx.context.returned instanceof Error) && loginId) await ctx.context.runInBackgroundOrAwait(securityService.clearFailedAttempts(loginId));
2350
2393
  })
2351
2394
  }]
2352
2395
  }
@@ -2794,7 +2837,7 @@ const jwtValidateMiddleware = (options) => {
2794
2837
  };
2795
2838
  //#endregion
2796
2839
  //#region src/version.ts
2797
- const PLUGIN_VERSION = "0.2.10";
2840
+ const PLUGIN_VERSION = "0.2.12";
2798
2841
  //#endregion
2799
2842
  //#region src/routes/auth/config.ts
2800
2843
  const PLUGIN_OPTIONS_EXCLUDE_KEYS = { stripe: new Set(["stripeClient"]) };
@@ -3636,6 +3679,31 @@ const executeAdapter = (options) => {
3636
3679
  };
3637
3680
  //#endregion
3638
3681
  //#region src/routes/invitations/index.ts
3682
+ async function verifyPendingInvitation(token, $api, ctx) {
3683
+ const { data: invitation, error } = await $api("/api/internal/invitations/verify", {
3684
+ method: "POST",
3685
+ body: { token }
3686
+ });
3687
+ if (error || !invitation) {
3688
+ ctx.context.logger.warn("[Dash] Failed to verify invitation", error);
3689
+ throw new APIError("BAD_REQUEST", { message: "Invalid or expired invitation." });
3690
+ }
3691
+ if (invitation.status !== "pending") throw new APIError("BAD_REQUEST", { message: `This invitation has already been ${invitation.status}.` });
3692
+ if (invitation.expiresAt) {
3693
+ if (new Date(invitation.expiresAt) < /* @__PURE__ */ new Date()) {
3694
+ const { error: markError } = await $api("/api/internal/invitations/mark-expired", {
3695
+ method: "POST",
3696
+ body: { token }
3697
+ });
3698
+ if (markError) ctx.context.logger.warn("[Dash] Failed to mark invitation as expired", markError);
3699
+ throw new APIError("BAD_REQUEST", { message: "This invitation has expired." });
3700
+ }
3701
+ }
3702
+ return invitation;
3703
+ }
3704
+ function getFallbackRedirect(ctx) {
3705
+ return typeof ctx.context.options.baseURL === "string" ? ctx.context.options.baseURL : "/";
3706
+ }
3639
3707
  /**
3640
3708
  * Accept invitation endpoint
3641
3709
  * This is called when a user clicks the invitation link.
@@ -3648,25 +3716,7 @@ const acceptInvitation = (options) => {
3648
3716
  query: z$1.object({ token: z$1.string() })
3649
3717
  }, async (ctx) => {
3650
3718
  const { token } = ctx.query;
3651
- const { data: invitation, error } = await $api("/api/internal/invitations/verify", {
3652
- method: "POST",
3653
- body: { token }
3654
- });
3655
- if (error || !invitation) {
3656
- ctx.context.logger.warn("[Dash] Failed to verify invitation", error);
3657
- throw new APIError("BAD_REQUEST", { message: "Invalid or expired invitation." });
3658
- }
3659
- if (invitation.status !== "pending") throw new APIError("BAD_REQUEST", { message: `This invitation has already been ${invitation.status}.` });
3660
- if (invitation.expiresAt) {
3661
- if (new Date(invitation.expiresAt) < /* @__PURE__ */ new Date()) {
3662
- const { error: markError } = await $api("/api/internal/invitations/mark-expired", {
3663
- method: "POST",
3664
- body: { token }
3665
- });
3666
- if (markError) ctx.context.logger.warn("[Dash] Failed to mark invitation as expired", markError);
3667
- throw new APIError("BAD_REQUEST", { message: "This invitation has expired." });
3668
- }
3669
- }
3719
+ const invitation = await verifyPendingInvitation(token, $api, ctx);
3670
3720
  const invitationEmail = normalizeEmail(invitation.email, ctx.context);
3671
3721
  const existingUser = await ctx.context.internalAdapter.findUserByEmail(invitationEmail).then((user) => user?.user);
3672
3722
  if (existingUser) {
@@ -3727,24 +3777,12 @@ const completeInvitation = (options) => {
3727
3777
  method: "POST",
3728
3778
  body: z$1.object({
3729
3779
  token: z$1.string(),
3730
- password: z$1.string().optional(),
3731
- providerId: z$1.string().optional(),
3732
- providerAccountId: z$1.string().optional(),
3733
- accessToken: z$1.string().optional(),
3734
- refreshToken: z$1.string().optional()
3780
+ password: z$1.string().optional()
3735
3781
  })
3736
3782
  }, async (ctx) => {
3737
- const { token, password, providerId, providerAccountId, accessToken, refreshToken } = ctx.body;
3738
- const fallbackRedirect = typeof ctx.context.options.baseURL === "string" ? ctx.context.options.baseURL : "/";
3739
- const { data: invitation, error } = await $api("/api/internal/invitations/verify", {
3740
- method: "POST",
3741
- body: { token }
3742
- });
3743
- if (error || !invitation) {
3744
- ctx.context.logger.warn("[Dash] Failed to verify invitation", error);
3745
- throw new APIError("BAD_REQUEST", { message: "Invalid or expired invitation." });
3746
- }
3747
- if (invitation.status !== "pending") throw new APIError("BAD_REQUEST", { message: `This invitation has already been ${invitation.status}.` });
3783
+ const { token, password } = ctx.body;
3784
+ const fallbackRedirect = getFallbackRedirect(ctx);
3785
+ const invitation = await verifyPendingInvitation(token, $api, ctx);
3748
3786
  if (!ctx.context) throw new APIError("BAD_REQUEST", { message: "Context is required" });
3749
3787
  const invitationEmail = normalizeEmail(invitation.email, ctx.context);
3750
3788
  const existingUser = await ctx.context.internalAdapter.findUserByEmail(invitationEmail).then((user) => user?.user);
@@ -3766,6 +3804,7 @@ const completeInvitation = (options) => {
3766
3804
  redirectUrl: invitation.redirectUrl ?? fallbackRedirect
3767
3805
  };
3768
3806
  }
3807
+ if (!password) throw new APIError("BAD_REQUEST", { message: "Password is required to complete this invitation." });
3769
3808
  const user = await ctx.context.internalAdapter.createUser({
3770
3809
  email: invitationEmail,
3771
3810
  name: invitation.name || invitation.email.split("@")[0] || "",
@@ -3773,19 +3812,12 @@ const completeInvitation = (options) => {
3773
3812
  createdAt: /* @__PURE__ */ new Date(),
3774
3813
  updatedAt: /* @__PURE__ */ new Date()
3775
3814
  });
3776
- if (password) await ctx.context.internalAdapter.createAccount({
3815
+ await ctx.context.internalAdapter.createAccount({
3777
3816
  userId: user.id,
3778
3817
  providerId: "credential",
3779
3818
  accountId: user.id,
3780
3819
  password: await ctx.context.password.hash(password)
3781
3820
  });
3782
- else if (providerId && providerAccountId) await ctx.context.internalAdapter.createAccount({
3783
- userId: user.id,
3784
- providerId,
3785
- accountId: providerAccountId,
3786
- accessToken,
3787
- refreshToken
3788
- });
3789
3821
  const { error: markError } = await $api("/api/internal/invitations/mark-accepted", {
3790
3822
  method: "POST",
3791
3823
  body: {
@@ -3804,6 +3836,30 @@ const completeInvitation = (options) => {
3804
3836
  };
3805
3837
  });
3806
3838
  };
3839
+ const completeInvitationSocial = (options) => {
3840
+ const { $api } = options;
3841
+ return createAuthEndpoint("/dash/complete-invitation-social", {
3842
+ method: "GET",
3843
+ query: z$1.object({ token: z$1.string() }),
3844
+ use: [sessionMiddleware]
3845
+ }, async (ctx) => {
3846
+ const sessionUser = ctx.context.session?.user;
3847
+ if (!sessionUser) throw new APIError("UNAUTHORIZED", { message: "Authentication required." });
3848
+ const invitation = await verifyPendingInvitation(ctx.query.token, $api, ctx);
3849
+ const invitationEmail = normalizeEmail(invitation.email, ctx.context);
3850
+ const sessionEmail = sessionUser.email ? normalizeEmail(sessionUser.email, ctx.context) : null;
3851
+ if (!sessionEmail || sessionEmail !== invitationEmail) throw new APIError("BAD_REQUEST", { message: "Signed-in account does not match this invitation." });
3852
+ const { error: markError } = await $api("/api/internal/invitations/mark-accepted", {
3853
+ method: "POST",
3854
+ body: {
3855
+ token: ctx.query.token,
3856
+ userId: sessionUser.id
3857
+ }
3858
+ });
3859
+ if (markError) ctx.context.logger.warn("[Dash] Failed to mark invitation as accepted", markError);
3860
+ return ctx.redirect((invitation.redirectUrl || getFallbackRedirect(ctx)).toString());
3861
+ });
3862
+ };
3807
3863
  /**
3808
3864
  * Check if a user exists by email
3809
3865
  * Used by the platform to verify before sending invitation
@@ -5694,8 +5750,14 @@ function isPrivateIPv4(a, b) {
5694
5750
  if (a === 169 && b === 254) return true;
5695
5751
  if (a === 0) return true;
5696
5752
  if (a === 100 && b >= 64 && b <= 127) return true;
5753
+ if (a >= 224) return true;
5697
5754
  return false;
5698
5755
  }
5756
+ function parseIPv4Quad(quad) {
5757
+ const v4parts = quad.split(".").map(Number);
5758
+ if (v4parts.length === 4 && v4parts.every((p) => !isNaN(p) && p >= 0 && p <= 255)) return [v4parts[0], v4parts[1]];
5759
+ return null;
5760
+ }
5699
5761
  /**
5700
5762
  * Loopback, RFC1918, link-local, CGNAT, ULA, documentation, NAT64 well-known,
5701
5763
  * IPv4-mapped private, unspecified, and multicast (RFC 4291 section 2.7).
@@ -5732,12 +5794,23 @@ function parseIPv6(addr) {
5732
5794
  if (left.length !== 8) return null;
5733
5795
  return left;
5734
5796
  }
5797
+ /** True when the hostname is an IPv4 or IPv6 literal (not a DNS name). */
5798
+ function isIpLiteralHostname(hostname) {
5799
+ const bare = hostname.replace(/^\[|\]$/g, "");
5800
+ if (parseIPv4Quad(bare)) return true;
5801
+ return parseIPv6(bare)?.length === 8;
5802
+ }
5735
5803
  /** Returns true if the hostname resolves to a private/reserved IP or is a local hostname. */
5736
5804
  function isPrivateHost(hostname) {
5737
- if (hostname === "localhost" || hostname.endsWith(".local") || hostname.endsWith(".internal")) return true;
5805
+ if (hostname === "localhost" || hostname.endsWith(".local") || hostname.endsWith(".internal") || hostname.endsWith(".localhost")) return true;
5738
5806
  const bare = hostname.replace(/^\[|\]$/g, "");
5739
- const v4parts = bare.split(".").map(Number);
5740
- if (v4parts.length === 4 && v4parts.every((p) => !isNaN(p) && p >= 0 && p <= 255)) return isPrivateIPv4(v4parts[0], v4parts[1]);
5807
+ const v4 = parseIPv4Quad(bare);
5808
+ if (v4) return isPrivateIPv4(v4[0], v4[1]);
5809
+ const mappedTail = bare.match(/(?:^|:)([\d.]+)$/);
5810
+ if (mappedTail && mappedTail[1].includes(".")) {
5811
+ const mappedV4 = parseIPv4Quad(mappedTail[1]);
5812
+ if (mappedV4) return isPrivateIPv4(mappedV4[0], mappedV4[1]);
5813
+ }
5741
5814
  const groups = parseIPv6(bare);
5742
5815
  if (groups && groups.length === 8) return ipv6GroupsAreNonPublic(groups);
5743
5816
  return false;
@@ -5822,13 +5895,20 @@ async function validateUrl(rawUrl, options = {}) {
5822
5895
  const sync = validateUrlSync(rawUrl, options);
5823
5896
  if (!sync.ok) return sync;
5824
5897
  if (options.dns === false) return sync;
5825
- const addresses = await resolveHostnameAddresses(sync.url.hostname);
5898
+ const hostname = sync.url.hostname;
5899
+ const addresses = await resolveHostnameAddresses(hostname);
5900
+ if (!isIpLiteralHostname(hostname) && !addresses?.length) return {
5901
+ ok: false,
5902
+ reason: "unresolved_host",
5903
+ url: sync.url.href,
5904
+ hostname
5905
+ };
5826
5906
  if (addresses) {
5827
5907
  for (const addr of addresses) if (isPrivateHost(addr)) return {
5828
5908
  ok: false,
5829
5909
  reason: "private_dns",
5830
5910
  url: sync.url.href,
5831
- hostname: sync.url.hostname,
5911
+ hostname,
5832
5912
  address: addr
5833
5913
  };
5834
5914
  }
@@ -6571,41 +6651,13 @@ const viewTwoFactorTotpUri = (options) => createAuthEndpoint("/dash/view-two-fac
6571
6651
  } catch {}
6572
6652
  const user = await ctx.context.internalAdapter.findUserById(userId);
6573
6653
  const issuer = twoFactorPlugin.options?.issuer || ctx.context.appName || "BetterAuth";
6574
- return {
6575
- totpURI: `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(user?.email || userId)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`,
6576
- secret
6577
- };
6654
+ return { totpURI: `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(user?.email || userId)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30` };
6578
6655
  });
6579
6656
  const viewBackupCodes = (options) => createAuthEndpoint("/dash/view-backup-codes", {
6580
6657
  method: "POST",
6581
6658
  use: [jwtMiddleware(options, z$1.object({ userId: z$1.string() }))]
6582
- }, async (ctx) => {
6583
- const { userId } = ctx.context.payload;
6584
- const twoFactorRecord = await ctx.context.adapter.findOne({
6585
- model: "twoFactor",
6586
- where: [{
6587
- field: "userId",
6588
- value: userId
6589
- }]
6590
- });
6591
- if (!twoFactorRecord) throw new APIError("NOT_FOUND", { message: "Two-factor authentication not set up for this user" });
6592
- let backupCodes;
6593
- try {
6594
- backupCodes = JSON.parse(twoFactorRecord.backupCodes);
6595
- } catch {
6596
- try {
6597
- const { symmetricDecrypt } = await import("better-auth/crypto");
6598
- const decrypted = await symmetricDecrypt({
6599
- key: ctx.context.secret,
6600
- data: twoFactorRecord.backupCodes
6601
- });
6602
- backupCodes = JSON.parse(decrypted);
6603
- } catch (decryptError) {
6604
- ctx.context.logger.debug("[Dash] Failed to decrypt backup codes:", decryptError);
6605
- throw new APIError("INTERNAL_SERVER_ERROR", { message: "Failed to decrypt backup codes" });
6606
- }
6607
- }
6608
- return { backupCodes };
6659
+ }, async () => {
6660
+ throw new APIError("FORBIDDEN", { message: "Backup codes cannot be viewed after initial setup. Generate new codes instead." });
6609
6661
  });
6610
6662
  const disableTwoFactor = (options) => createAuthEndpoint("/dash/disable-two-factor", {
6611
6663
  method: "POST",
@@ -8341,6 +8393,7 @@ const dash = (options) => {
8341
8393
  getEventTypes: getEventTypes(settings),
8342
8394
  dashAcceptInvitation: acceptInvitation(settings),
8343
8395
  dashCompleteInvitation: completeInvitation(settings),
8396
+ dashCompleteInvitationSocial: completeInvitationSocial(settings),
8344
8397
  dashCheckUserExists: checkUserExists(settings),
8345
8398
  listDashOrganizationDirectories: listOrganizationDirectories(settings),
8346
8399
  createDashOrganizationDirectory: createOrganizationDirectory(settings),
package/dist/native.mjs CHANGED
@@ -190,6 +190,8 @@ async function getOrCreateVisitorId(storage) {
190
190
  }
191
191
  //#endregion
192
192
  //#region src/sentinel/native/client.ts
193
+ /** One recovery round after a consumed or superseded challenge (e.g. double-submit). */
194
+ const MAX_POW_CHALLENGE_ROUNDS = 2;
193
195
  function scheduleIdentify(send) {
194
196
  const run = () => {
195
197
  try {
@@ -248,49 +250,54 @@ const sentinelNativeClient = (options) => {
248
250
  hooks: {
249
251
  async onResponse(context) {
250
252
  if (context.response.status !== 423 || !autoSolve) return context;
251
- const challengeHeader = context.response.headers.get("X-PoW-Challenge");
252
- const reason = context.response.headers.get("X-PoW-Reason") || "";
253
- if (!challengeHeader) return context;
254
- options?.onChallengeReceived?.(reason);
255
- const challenge = decodePoWChallenge(challengeHeader);
256
- if (!challenge) return context;
257
- try {
258
- const startTime = Date.now();
259
- const solution = await solvePoWChallenge(challenge);
260
- const solveTime = Date.now() - startTime;
261
- options?.onChallengeSolved?.(solveTime);
262
- const originalUrl = context.response.url;
263
- const fingerprint = await runtime.getFingerprint();
264
- const retryHeaders = new Headers();
265
- retryHeaders.set("X-PoW-Solution", encodePoWSolution(solution));
266
- if (fingerprint) {
267
- retryHeaders.set("X-Visitor-Id", fingerprint.visitorId);
268
- retryHeaders.set("X-Request-Id", fingerprint.requestId);
269
- }
270
- retryHeaders.set("Content-Type", "application/json");
271
- let body;
272
- if (context.request?.body) body = context.request._originalBody;
273
- const req = context.request;
274
- const powRetry = createPowRetryTimeout(req.timeout);
253
+ if (!context.response.headers.get("X-PoW-Challenge")) return context;
254
+ const req = context.request;
255
+ let response = context.response;
256
+ const url = response.url;
257
+ const body = req._originalBody;
258
+ for (let round = 0; round < MAX_POW_CHALLENGE_ROUNDS; round++) {
259
+ if (response.status !== 423) break;
260
+ const challengeHeader = response.headers.get("X-PoW-Challenge");
261
+ if (!challengeHeader) break;
262
+ const reason = response.headers.get("X-PoW-Reason") || "";
263
+ options?.onChallengeReceived?.(reason);
264
+ const challenge = decodePoWChallenge(challengeHeader);
265
+ if (!challenge) break;
275
266
  try {
276
- const retryResponse = await fetch(originalUrl, {
277
- method: req.method || "POST",
278
- headers: retryHeaders,
279
- body,
280
- ...powRetry.signal ? { signal: powRetry.signal } : {}
281
- });
282
- return {
283
- ...context,
284
- response: retryResponse
285
- };
286
- } finally {
287
- powRetry.cleanup?.();
267
+ const startTime = Date.now();
268
+ const solution = await solvePoWChallenge(challenge);
269
+ options?.onChallengeSolved?.(Date.now() - startTime);
270
+ const fingerprint = await runtime.getFingerprint();
271
+ const retryHeaders = new Headers();
272
+ retryHeaders.set("X-PoW-Solution", encodePoWSolution(solution));
273
+ if (fingerprint) {
274
+ retryHeaders.set("X-Visitor-Id", fingerprint.visitorId);
275
+ retryHeaders.set("X-Request-Id", fingerprint.requestId);
276
+ }
277
+ retryHeaders.set("Content-Type", "application/json");
278
+ const powRetry = createPowRetryTimeout(req.timeout);
279
+ try {
280
+ response = await fetch(url, {
281
+ method: req.method || "POST",
282
+ headers: retryHeaders,
283
+ body,
284
+ ...powRetry.signal ? { signal: powRetry.signal } : {}
285
+ });
286
+ } finally {
287
+ powRetry.cleanup?.();
288
+ }
289
+ if (response.status !== 423) break;
290
+ if (!response.headers.get("X-PoW-Challenge")) break;
291
+ } catch (err) {
292
+ console.error("[Sentinel native] Failed to solve PoW challenge:", err);
293
+ options?.onChallengeFailed?.(err instanceof Error ? err : new Error(String(err)));
294
+ return context;
288
295
  }
289
- } catch (err) {
290
- console.error("[Sentinel native] Failed to solve PoW challenge:", err);
291
- options?.onChallengeFailed?.(err instanceof Error ? err : new Error(String(err)));
292
- return context;
293
296
  }
297
+ return {
298
+ ...context,
299
+ response
300
+ };
294
301
  },
295
302
  async onRequest(context) {
296
303
  if (context.body) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@better-auth/infra",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
4
4
  "description": "Dashboard and analytics plugin for Better Auth",
5
5
  "type": "module",
6
6
  "main": "dist/index.mjs",
@@ -79,7 +79,7 @@
79
79
  "@better-fetch/fetch": "^1.1.21",
80
80
  "better-call": "^1.3.2",
81
81
  "jose": "^6.1.0",
82
- "libphonenumber-js": "^1.13.2"
82
+ "libphonenumber-js": "^1.13.3"
83
83
  },
84
84
  "peerDependencies": {
85
85
  "better-auth": ">=1.4.0",