@better-auth/infra 0.4.3 → 0.4.5

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/CHANGELOG.md CHANGED
@@ -5,6 +5,22 @@ All notable changes to `@better-auth/infra` are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.4.5] - 2026-08-28
9
+
10
+ ### Fixed
11
+
12
+ - **Duplicate `__infra-rid` Set-Cookie** — When both `dash()` and `sentinel()` are installed, the identification cookie after-hook no longer appends the same `Set-Cookie` twice. The writer runs once per request via a shared context guard.
13
+
14
+ ## [0.4.4] - 2026-08-27
15
+
16
+ ### Added
17
+
18
+ - **`GET /dash/config` adapter insight** — Config insights report database adapter identity and capability flags.
19
+
20
+ ### Changed
21
+
22
+ - **Sentinel PoW vs rate limits** — Proof-of-work challenges no longer consume Better Auth rate-limit budget.
23
+
8
24
  ## [0.4.3] - 2026-08-25
9
25
 
10
26
  ### Added
package/dist/client.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as DashGetAllAuditLogsInput, i as DashClientOptions, n as DashAuditLog, o as DashGetAuditLogsInput, r as DashAuditLogsResponse, s as dashClient, t as IdentifyClientOptions } from "./identify-client-options-CgijjwVT.mjs";
1
+ import { a as DashGetAllAuditLogsInput, i as DashClientOptions, n as DashAuditLog, o as DashGetAuditLogsInput, r as DashAuditLogsResponse, s as dashClient, t as IdentifyClientOptions } from "./identify-client-options-Cim5Fztg.mjs";
2
2
  //#region src/sentinel/client.d.ts
3
3
  interface SentinelClientOptions {
4
4
  /**
package/dist/client.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { n as hash, o as createKV } from "./crypto-SmxL66Tk.mjs";
2
- import { a as resolveSentinelClientIdentifyUrl, c as identify, i as solvePoWChallenge, l as dashClient, n as decodePoWChallenge, o as resolveIdentifyClientRetry, r as encodePoWSolution, s as generateRequestId, t as createPowRetryTimeout } from "./pow-retry-BWDJUT8X.mjs";
1
+ import { n as hash, o as createKV } from "./crypto-BhIjjZNe.mjs";
2
+ import { a as resolveSentinelClientIdentifyUrl, c as identify, i as solvePoWChallenge, l as dashClient, n as decodePoWChallenge, o as resolveIdentifyClientRetry, r as encodePoWSolution, s as generateRequestId, t as createPowRetryTimeout } from "./pow-retry-CsInOtOL.mjs";
3
3
  import { env } from "@better-auth/core/env";
4
4
  //#region src/sentinel/fingerprint.ts
5
5
  function murmurhash3(str, seed = 0) {
@@ -1,7 +1,7 @@
1
1
  import { env } from "@better-auth/core/env";
2
2
  //#endregion
3
3
  //#region src/version.ts
4
- const PLUGIN_VERSION = "0.4.3";
4
+ const PLUGIN_VERSION = "0.4.5";
5
5
  //#endregion
6
6
  //#region src/constants.ts
7
7
  /**
@@ -1,4 +1,4 @@
1
- import { i as INFRA_USER_AGENT } from "./constants-CA5EAv5K.mjs";
1
+ import { i as INFRA_USER_AGENT } from "./constants-DjM7toCN.mjs";
2
2
  import { createFetch } from "@better-fetch/fetch";
3
3
  import { sha256 } from "@noble/hashes/sha2.js";
4
4
  //#region src/fetch.ts
package/dist/email.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { i as INFRA_USER_AGENT, n as INFRA_API_URL } from "./constants-CA5EAv5K.mjs";
1
+ import { i as INFRA_USER_AGENT, n as INFRA_API_URL } from "./constants-DjM7toCN.mjs";
2
2
  import { logger } from "better-auth";
3
3
  import { env } from "@better-auth/core/env";
4
4
  import { createFetch } from "@better-fetch/fetch";
@@ -1,4 +1,4 @@
1
- import { m as KvRetryOptions } from "./types-Ddk4r5x9.mjs";
1
+ import { m as KvRetryOptions } from "./types-sP9ucYj1.mjs";
2
2
  import { BetterAuthClientOptions, ClientStore } from "better-auth";
3
3
  import { BetterFetch } from "@better-fetch/fetch";
4
4
  //#region src/dash-client.d.ts
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { C as ImpossibleTravelResult, D as SecurityVerdict, E as SecurityOptions, O as StaleUserResult, S as CredentialStuffingResult, T as SecurityEventType, _ as LocationDataContext, a as DashOptionsInternal, b as sentinel, c as EndpointOptions, d as InfraPluginConnectionOptionsInternal, f as KvOptions, g as LocationData, h as KvRetryOptionsResolved, i as DashOptions, k as ThresholdConfig, l as InfraEndpointContext, m as KvRetryOptions, n as ApiOptions, o as DashOptionsResolved, p as KvOptionsResolved, r as ApiOptionsResolved, s as Endpoint, t as APIError, u as InfraPluginConnectionOptions, v as SentinelOptions, w as SecurityEvent, x as CompromisedPasswordResult, y as SentinelOptionsInternal } from "./types-Ddk4r5x9.mjs";
1
+ import { C as ImpossibleTravelResult, D as SecurityVerdict, E as SecurityOptions, O as StaleUserResult, S as CredentialStuffingResult, T as SecurityEventType, _ as LocationDataContext, a as DashOptionsInternal, b as sentinel, c as EndpointOptions, d as InfraPluginConnectionOptionsInternal, f as KvOptions, g as LocationData, h as KvRetryOptionsResolved, i as DashOptions, k as ThresholdConfig, l as InfraEndpointContext, m as KvRetryOptions, n as ApiOptions, o as DashOptionsResolved, p as KvOptionsResolved, r as ApiOptionsResolved, s as Endpoint, t as APIError, u as InfraPluginConnectionOptions, v as SentinelOptions, w as SecurityEvent, x as CompromisedPasswordResult, y as SentinelOptionsInternal } from "./types-sP9ucYj1.mjs";
2
2
  import { EMAIL_TEMPLATES, EmailConfig, EmailTemplateId, EmailTemplateVariables, SendBulkEmailsOptions, SendBulkEmailsResult, SendEmailOptions, SendEmailResult, createEmailSender, sendBulkEmails, sendEmail } from "./email.mjs";
3
3
  import { Account, AuthContext, GenericEndpointContext, Session, User } from "better-auth";
4
4
  import z from "zod";
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { i as INFRA_USER_AGENT, n as INFRA_API_URL, o as PLUGIN_VERSION, r as INFRA_KV_URL } from "./constants-CA5EAv5K.mjs";
2
- import { a as createAPI, n as hash$1, o as createKV, r as hmacSha256Hex } from "./crypto-SmxL66Tk.mjs";
1
+ import { i as INFRA_USER_AGENT, n as INFRA_API_URL, o as PLUGIN_VERSION, r as INFRA_KV_URL } from "./constants-DjM7toCN.mjs";
2
+ import { a as createAPI, n as hash$1, o as createKV, r as hmacSha256Hex } from "./crypto-BhIjjZNe.mjs";
3
3
  import { EMAIL_TEMPLATES, createEmailSender, sendBulkEmails, sendEmail } from "./email.mjs";
4
4
  import { getCurrentAdapter, getCurrentAuthContext, getCurrentDBAdapterAsyncLocalStorage, runWithTransaction } from "@better-auth/core/context";
5
5
  import { APIError, generateId, getAuthTables, logger } from "better-auth";
@@ -56,6 +56,27 @@ function resolveSentinelOptions(options) {
56
56
  };
57
57
  }
58
58
  //#endregion
59
+ //#region ../utils/dist/ip.mjs
60
+ /** Platform-set client IP headers (not accepted from clients on the open internet). */
61
+ const DEFAULT_TRUSTED_IP_HEADERS = [
62
+ "cf-connecting-ip",
63
+ "true-client-ip",
64
+ "x-vercel-forwarded-for"
65
+ ];
66
+ /**
67
+ * Resolves client IP from request headers.
68
+ * Defaults to platform-trusted headers only; set `ipAddressHeaders` for self-hosted proxies.
69
+ */
70
+ function resolveClientIpFromHeaders(headers, ipAddressHeaders) {
71
+ const headerNames = ipAddressHeaders?.length ? ipAddressHeaders : [...DEFAULT_TRUSTED_IP_HEADERS];
72
+ for (const headerName of headerNames) {
73
+ const value = headers.get(headerName);
74
+ if (!value) continue;
75
+ const ip = value.split(",")[0]?.trim();
76
+ if (ip) return ip;
77
+ }
78
+ }
79
+ //#endregion
59
80
  //#region src/events/constants.ts
60
81
  const EVENT_TYPES = {
61
82
  USER_CREATED: "user_created",
@@ -1060,27 +1081,6 @@ const initTrackEvents = ($api) => {
1060
1081
  return { tracker: { trackEvent } };
1061
1082
  };
1062
1083
  //#endregion
1063
- //#region ../utils/dist/ip.mjs
1064
- /** Platform-set client IP headers (not accepted from clients on the open internet). */
1065
- const DEFAULT_TRUSTED_IP_HEADERS = [
1066
- "cf-connecting-ip",
1067
- "true-client-ip",
1068
- "x-vercel-forwarded-for"
1069
- ];
1070
- /**
1071
- * Resolves client IP from request headers.
1072
- * Defaults to platform-trusted headers only; set `ipAddressHeaders` for self-hosted proxies.
1073
- */
1074
- function resolveClientIpFromHeaders(headers, ipAddressHeaders) {
1075
- const headerNames = ipAddressHeaders?.length ? ipAddressHeaders : [...DEFAULT_TRUSTED_IP_HEADERS];
1076
- for (const headerName of headerNames) {
1077
- const value = headers.get(headerName);
1078
- if (!value) continue;
1079
- const ip = value.split(",")[0]?.trim();
1080
- if (ip) return ip;
1081
- }
1082
- }
1083
- //#endregion
1084
1084
  //#region src/identification.ts
1085
1085
  /**
1086
1086
  * Identification Service
@@ -1113,9 +1113,14 @@ function shouldRunIdentification(ctx, getRoutes = IDENTIFICATION_GET_ROUTES) {
1113
1113
  * Persist `X-Request-Id` as `__infra-rid` so redirect-flow OAuth callbacks
1114
1114
  * (GET, no identify headers) can resolve visitor identification from KV.
1115
1115
  * Must run in an after hook so Set-Cookie reaches the response.
1116
+ *
1117
+ * Dedupes when both `dash()` and `sentinel()` register this hook (each alone
1118
+ * needs it; together they would append duplicate Set-Cookie headers).
1116
1119
  */
1117
1120
  function createIdentificationCookieAfterMiddleware() {
1118
1121
  return createAuthMiddleware(async (ctx) => {
1122
+ if (ctx.context.infraRidCookieSet) return;
1123
+ ctx.context.infraRidCookieSet = true;
1119
1124
  const headerRequestId = ctx.request?.headers.get("X-Request-Id");
1120
1125
  if (headerRequestId) {
1121
1126
  ctx.setCookie(IDENTIFICATION_COOKIE_NAME, headerRequestId, {
@@ -2316,6 +2321,9 @@ const ERROR_MESSAGES = {
2316
2321
  compromised_password: "This password has been found in data breaches. Please choose a different password.",
2317
2322
  impossible_travel: "Login blocked due to suspicious location change."
2318
2323
  };
2324
+ function securityBlockMessage(reason) {
2325
+ return ERROR_MESSAGES[reason] || "Access denied.";
2326
+ }
2319
2327
  /**
2320
2328
  * Throw a challenge error with appropriate headers
2321
2329
  */
@@ -2330,56 +2338,152 @@ function throwChallengeError(challenge, reason, message = "Please complete a sec
2330
2338
  };
2331
2339
  throw error;
2332
2340
  }
2333
- /** Emits the `security_check` event inline before throwing — the after-hook won't run on throw. */
2334
- async function handleSecurityVerdict(verdict, hookCtx, checkCtx, trackEvent, securityService) {
2335
- if (verdict.powVerified) recordCheck(hookCtx, "pow_challenge");
2336
- if (verdict.action === "allow") return;
2341
+ /**
2342
+ * Cooldown → consolidated security check → challenge/block/allow.
2343
+ * Does not emit events or throw; callers apply the result.
2344
+ */
2345
+ async function resolveAdmission(input, securityService) {
2346
+ const requestId = input.identification?.requestId ?? input.requestId ?? null;
2347
+ if (input.visitorId || input.ip) {
2348
+ if (await securityService.isBlocked(input.visitorId ?? "", input.ip, requestId)) return { type: "cooldown" };
2349
+ }
2350
+ const verdict = await securityService.checkSecurity({
2351
+ visitorId: input.visitorId,
2352
+ requestId,
2353
+ ip: input.ip,
2354
+ path: input.path,
2355
+ identifier: input.identifier,
2356
+ powSolution: input.powSolution ?? void 0
2357
+ });
2358
+ if (verdict.action === "allow") return {
2359
+ type: "allow",
2360
+ powVerified: verdict.powVerified
2361
+ };
2337
2362
  const reason = verdict.reason || "unknown";
2338
- if (verdict.action === "challenge" && checkCtx.visitorId) {
2339
- const challenge = verdict.challenge || await securityService.generateChallenge(checkCtx.visitorId, checkCtx.identification?.requestId ?? null);
2363
+ if (verdict.action === "challenge") {
2364
+ if (!input.visitorId) return {
2365
+ type: "allow",
2366
+ powVerified: verdict.powVerified
2367
+ };
2368
+ const challenge = verdict.challenge || await securityService.generateChallenge(input.visitorId, requestId);
2340
2369
  if (!challenge?.trim()) {
2341
2370
  logger.warn("[Sentinel] Could not generate PoW challenge (service may be unavailable). Falling back to allow.");
2342
- return;
2371
+ return {
2372
+ type: "allow",
2373
+ powVerified: verdict.powVerified
2374
+ };
2343
2375
  }
2344
- setOutcome(hookCtx, "challenged", "server_security_check", {
2376
+ return {
2377
+ type: "challenge",
2378
+ challenge,
2345
2379
  reason,
2346
- ...verdict.details
2347
- });
2348
- emitEvaluation(hookCtx, trackEvent, {
2349
- identifier: checkCtx.identifier,
2350
- userAgent: checkCtx.userAgent
2351
- });
2352
- throwChallengeError(challenge, reason);
2380
+ details: verdict.details
2381
+ };
2353
2382
  }
2354
- if (verdict.action === "block") {
2355
- setOutcome(hookCtx, "blocked", "server_security_check", {
2356
- reason,
2357
- ...verdict.details
2358
- });
2359
- emitEvaluation(hookCtx, trackEvent, {
2360
- identifier: checkCtx.identifier,
2361
- userAgent: checkCtx.userAgent
2383
+ return {
2384
+ type: "block",
2385
+ reason,
2386
+ details: verdict.details
2387
+ };
2388
+ }
2389
+ /**
2390
+ * Record evaluation side effects and throw on deny. Returns on allow.
2391
+ */
2392
+ function applyAdmission(admission, hookCtx, meta, trackEvent) {
2393
+ if (hookCtx.context.visitorId || hookCtx.context.ip) recordCheck(hookCtx, "credential_stuffing");
2394
+ if (admission.type === "allow") {
2395
+ recordCheck(hookCtx, "server_security_check");
2396
+ if (admission.powVerified) recordCheck(hookCtx, "pow_challenge");
2397
+ return;
2398
+ }
2399
+ if (admission.type === "cooldown") {
2400
+ setOutcome(hookCtx, "blocked", "credential_stuffing", {
2401
+ phase: "cooldown_active",
2402
+ visitorId: hookCtx.context.untrustedVisitorId ?? void 0,
2403
+ confidence: 1
2404
+ });
2405
+ emitEvaluation(hookCtx, trackEvent, meta);
2406
+ throw new APIError(403, { message: "Too many failed attempts. Please try again later." });
2407
+ }
2408
+ if (admission.type === "challenge") {
2409
+ recordCheck(hookCtx, "server_security_check");
2410
+ setOutcome(hookCtx, "challenged", "server_security_check", {
2411
+ reason: admission.reason,
2412
+ ...admission.details
2362
2413
  });
2363
- throw new APIError("FORBIDDEN", { message: ERROR_MESSAGES[reason] || "Access denied." });
2414
+ emitEvaluation(hookCtx, trackEvent, meta);
2415
+ throwChallengeError(admission.challenge, admission.reason);
2364
2416
  }
2417
+ recordCheck(hookCtx, "server_security_check");
2418
+ setOutcome(hookCtx, "blocked", "server_security_check", {
2419
+ reason: admission.reason,
2420
+ ...admission.details
2421
+ });
2422
+ emitEvaluation(hookCtx, trackEvent, meta);
2423
+ throw new APIError("FORBIDDEN", { message: securityBlockMessage(admission.reason) });
2365
2424
  }
2366
2425
  /**
2367
- * Single API call from the plugin; server runs all configured rules and returns
2368
- * one verdict — counts as one billable check.
2426
+ * Resolve + apply in one step (before-hook when rate-limit did not pre-decide).
2369
2427
  */
2370
2428
  async function runSecurityChecks(hookCtx, checkCtx, securityService, trackEvent, powSolution) {
2371
- recordCheck(hookCtx, "server_security_check");
2372
- await handleSecurityVerdict(await securityService.checkSecurity({
2429
+ applyAdmission(await resolveAdmission({
2373
2430
  visitorId: checkCtx.visitorId,
2374
- requestId: checkCtx.identification?.requestId ?? checkCtx.requestId ?? null,
2431
+ requestId: checkCtx.requestId,
2432
+ identification: checkCtx.identification,
2375
2433
  ip: checkCtx.ip,
2376
2434
  path: checkCtx.path,
2377
2435
  identifier: checkCtx.identifier,
2378
- powSolution: powSolution ?? void 0
2379
- }), hookCtx, checkCtx, trackEvent, securityService);
2436
+ powSolution
2437
+ }, securityService), hookCtx, {
2438
+ identifier: checkCtx.identifier,
2439
+ userAgent: checkCtx.userAgent
2440
+ }, trackEvent);
2380
2441
  }
2381
2442
  //#endregion
2382
2443
  //#region src/sentinel/sentinel.ts
2444
+ /**
2445
+ * Strip basePath from a request URL for path matching.
2446
+ * Local copy of BA's normalizePathname — `@better-auth/core/utils/url` is not
2447
+ * exported on 1.4; `@better-auth/core/utils` barrel is gone on 1.7.
2448
+ */
2449
+ function normalizeAuthPath(requestUrl, basePath) {
2450
+ try {
2451
+ const pathname = new URL(requestUrl).pathname.replace(/\/+$/, "") || "/";
2452
+ const base = basePath.replace(/\/+$/, "") || "";
2453
+ if (!base || base === "/") return pathname;
2454
+ if (pathname === base) return "/";
2455
+ if (pathname.startsWith(`${base}/`)) return pathname.slice(base.length).replace(/\/+$/, "") || "/";
2456
+ return pathname;
2457
+ } catch {
2458
+ return "/";
2459
+ }
2460
+ }
2461
+ const SECURITY_PROTECTED_ROUTES = [
2462
+ routes.SIGN_IN_EMAIL,
2463
+ routes.SIGN_IN_USERNAME,
2464
+ routes.SIGN_IN_EMAIL_OTP,
2465
+ routes.SIGN_IN_SOCIAL,
2466
+ routes.SIGN_IN_PASSKEY,
2467
+ routes.SIGN_IN_MAGIC_LINK,
2468
+ routes.SIGN_IN_SSO,
2469
+ routes.SIGN_IN_ANONYMOUS,
2470
+ routes.SIGN_UP_EMAIL,
2471
+ routes.FORGET_PASSWORD,
2472
+ routes.REQUEST_PASSWORD_RESET,
2473
+ routes.RESET_PASSWORD,
2474
+ routes.TWO_FACTOR_VERIFY_TOTP,
2475
+ routes.TWO_FACTOR_VERIFY_BACKUP,
2476
+ routes.TWO_FACTOR_VERIFY_OTP,
2477
+ routes.EMAIL_OTP_SEND,
2478
+ routes.PHONE_SEND_OTP,
2479
+ routes.MAGIC_LINK_VERIFY,
2480
+ routes.ORG_CREATE,
2481
+ routes.CHANGE_EMAIL,
2482
+ routes.CHANGE_PASSWORD,
2483
+ routes.SET_PASSWORD,
2484
+ routes.LINK_SOCIAL,
2485
+ routes.PASSKEY_ADD
2486
+ ];
2383
2487
  const sentinel = (options) => {
2384
2488
  const opts = resolveSentinelOptions(options);
2385
2489
  const $api = createAPI(opts);
@@ -2402,6 +2506,12 @@ const sentinel = (options) => {
2402
2506
  return typeof err.body === "object" && err.body !== null && "code" in err.body && err.body.code === STALE_ACCOUNT_BLOCK_ERROR.code;
2403
2507
  }
2404
2508
  let activityTrackingEnabled = false;
2509
+ /**
2510
+ * Admission from rateLimit.customRules for this Request. Reused by the
2511
+ * before-hook and by BA 1.5's second customRules pass on onResponse.
2512
+ * Do not delete — response-phase must still hit. GC cleans up with Request.
2513
+ */
2514
+ const rateLimitAdmission = /* @__PURE__ */ new WeakMap();
2405
2515
  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.");
2406
2516
  const securityService = createSecurityClient(opts, $apiThrowing, opts.security || {}, (event) => {
2407
2517
  trackEvent({
@@ -2432,114 +2542,165 @@ const sentinel = (options) => {
2432
2542
  id: "sentinel",
2433
2543
  init(ctx) {
2434
2544
  activityTrackingEnabled = (ctx.getPlugin("dash")?.options)?.activityTracking?.enabled === true;
2435
- return { options: {
2436
- emailValidation: opts.security?.emailValidation,
2437
- emailNormalization: opts.security?.emailNormalization,
2438
- databaseHooks: {
2439
- user: {
2440
- create: {
2441
- async before(user, ctx) {
2442
- if (!ctx) return;
2443
- if (isDashRoute(ctx.path)) {
2545
+ const basePath = new URL(ctx.baseURL).pathname;
2546
+ const ipHeaders = ctx.options.advanced?.ipAddress?.ipAddressHeaders || null;
2547
+ const disableIpTracking = ctx.options.advanced?.ipAddress?.disableIpTracking === true;
2548
+ /**
2549
+ * After any user customRule resolves, run Sentinel admission so PoW
2550
+ * challenges skip BA consume (protocol round-trip) even when the app
2551
+ * owns path rules or `**`. Block/cooldown still consume.
2552
+ */
2553
+ const applyCoordination = async (request, afterUser) => {
2554
+ const path = normalizeAuthPath(request.url, basePath);
2555
+ if (request.method === "GET" || isDashRoute(path) || !matchesAnyRoute(path, SECURITY_PROTECTED_ROUTES)) return afterUser;
2556
+ try {
2557
+ const cached = rateLimitAdmission.get(request);
2558
+ if (cached) return cached.type === "challenge" ? false : afterUser;
2559
+ const requestId = request.headers.get("X-Request-Id");
2560
+ const identification = requestId ? await getIdentification(requestId, $kv, opts.kvOptions.retry) : null;
2561
+ const ip = disableIpTracking ? identification?.ip ?? null : identification?.ip ?? resolveClientIpFromHeaders(request.headers, ipHeaders) ?? null;
2562
+ const admission = await resolveAdmission({
2563
+ visitorId: identification?.visitorId ?? null,
2564
+ requestId,
2565
+ identification,
2566
+ ip,
2567
+ path,
2568
+ powSolution: request.headers.get("X-PoW-Solution")
2569
+ }, securityService);
2570
+ rateLimitAdmission.set(request, admission);
2571
+ if (admission.type === "challenge") return false;
2572
+ return afterUser;
2573
+ } catch {
2574
+ return afterUser;
2575
+ }
2576
+ };
2577
+ const wrapCustomRule = (userRule) => {
2578
+ return async (request, current) => {
2579
+ const userResult = typeof userRule === "function" ? await userRule(request, current) : userRule;
2580
+ return applyCoordination(request, userResult === false ? false : userResult || current);
2581
+ };
2582
+ };
2583
+ const customRules = {};
2584
+ for (const [key, rule] of Object.entries(ctx.rateLimit?.customRules ?? {})) {
2585
+ if (rule === void 0) continue;
2586
+ customRules[key] = wrapCustomRule(rule);
2587
+ }
2588
+ if (!Object.hasOwn(customRules, "**")) customRules["**"] = async (request, current) => applyCoordination(request, current);
2589
+ return {
2590
+ context: { rateLimit: {
2591
+ ...ctx.rateLimit,
2592
+ customRules
2593
+ } },
2594
+ options: {
2595
+ emailValidation: opts.security?.emailValidation,
2596
+ emailNormalization: opts.security?.emailNormalization,
2597
+ rateLimit: { customRules },
2598
+ databaseHooks: {
2599
+ user: {
2600
+ create: {
2601
+ async before(user, ctx) {
2602
+ if (!ctx) return;
2603
+ if (isDashRoute(ctx.path)) {
2604
+ if (user.email && typeof user.email === "string" && isEmailNormalizationEnabled(opts.security)) return { data: {
2605
+ ...user,
2606
+ email: normalizeEmail(user.email, ctx.context)
2607
+ } };
2608
+ return;
2609
+ }
2610
+ const visitorId = ctx.context.visitorId;
2611
+ if (opts.security?.freeTrialAbuse?.enabled) {
2612
+ if (!visitorId) {
2613
+ recordCheck(ctx, "free_trial_abuse");
2614
+ setOutcome(ctx, "blocked", "free_trial_abuse", { reason: "identification_required" });
2615
+ emitEvaluation(ctx, trackEvent);
2616
+ throw new APIError("FORBIDDEN", { message: "Account creation is not allowed without device identification." });
2617
+ }
2618
+ recordCheck(ctx, "free_trial_abuse");
2619
+ const reservationId = crypto.randomUUID();
2620
+ ctx.context.freeTrialReservationId = reservationId;
2621
+ const abuseCheck = await securityService.reserveFreeTrialSignup(visitorId, reservationId, ctx.context.requestId ?? null);
2622
+ if (abuseCheck.isAbuse && abuseCheck.action === "block") {
2623
+ setOutcome(ctx, "blocked", "free_trial_abuse", {
2624
+ accountCount: abuseCheck.accountCount,
2625
+ maxAccounts: abuseCheck.maxAccounts
2626
+ });
2627
+ emitEvaluation(ctx, trackEvent);
2628
+ throw new APIError("FORBIDDEN", { message: "Account creation is not allowed from this device." });
2629
+ }
2630
+ }
2444
2631
  if (user.email && typeof user.email === "string" && isEmailNormalizationEnabled(opts.security)) return { data: {
2445
2632
  ...user,
2446
2633
  email: normalizeEmail(user.email, ctx.context)
2447
2634
  } };
2448
- return;
2449
- }
2450
- const visitorId = ctx.context.visitorId;
2451
- if (opts.security?.freeTrialAbuse?.enabled) {
2452
- if (!visitorId) {
2453
- recordCheck(ctx, "free_trial_abuse");
2454
- setOutcome(ctx, "blocked", "free_trial_abuse", { reason: "identification_required" });
2455
- emitEvaluation(ctx, trackEvent);
2456
- throw new APIError("FORBIDDEN", { message: "Account creation is not allowed without device identification." });
2457
- }
2458
- recordCheck(ctx, "free_trial_abuse");
2459
- const reservationId = crypto.randomUUID();
2460
- ctx.context.freeTrialReservationId = reservationId;
2461
- const abuseCheck = await securityService.reserveFreeTrialSignup(visitorId, reservationId, ctx.context.requestId ?? null);
2462
- if (abuseCheck.isAbuse && abuseCheck.action === "block") {
2463
- setOutcome(ctx, "blocked", "free_trial_abuse", {
2464
- accountCount: abuseCheck.accountCount,
2465
- maxAccounts: abuseCheck.maxAccounts
2466
- });
2467
- emitEvaluation(ctx, trackEvent);
2468
- throw new APIError("FORBIDDEN", { message: "Account creation is not allowed from this device." });
2469
- }
2635
+ },
2636
+ async after(user, ctx) {
2637
+ if (!ctx || isDashRoute(ctx.path)) return;
2638
+ const visitorId = ctx.context.visitorId;
2639
+ const reservationId = ctx.context.freeTrialReservationId;
2640
+ if (visitorId && reservationId && opts.security?.freeTrialAbuse?.enabled) await ctx.context.runInBackgroundOrAwait(securityService.confirmFreeTrialSignup(visitorId, reservationId, user.id, ctx.context.requestId ?? null));
2470
2641
  }
2642
+ },
2643
+ update: { async before(user, ctx) {
2644
+ if (!ctx) return;
2471
2645
  if (user.email && typeof user.email === "string" && isEmailNormalizationEnabled(opts.security)) return { data: {
2472
2646
  ...user,
2473
2647
  email: normalizeEmail(user.email, ctx.context)
2474
2648
  } };
2475
- },
2476
- async after(user, ctx) {
2649
+ } }
2650
+ },
2651
+ session: { create: {
2652
+ async before(session, ctx) {
2477
2653
  if (!ctx || isDashRoute(ctx.path)) return;
2478
2654
  const visitorId = ctx.context.visitorId;
2479
- const reservationId = ctx.context.freeTrialReservationId;
2480
- if (visitorId && reservationId && opts.security?.freeTrialAbuse?.enabled) await ctx.context.runInBackgroundOrAwait(securityService.confirmFreeTrialSignup(visitorId, reservationId, user.id, ctx.context.requestId ?? null));
2481
- }
2482
- },
2483
- update: { async before(user, ctx) {
2484
- if (!ctx) return;
2485
- if (user.email && typeof user.email === "string" && isEmailNormalizationEnabled(opts.security)) return { data: {
2486
- ...user,
2487
- email: normalizeEmail(user.email, ctx.context)
2488
- } };
2489
- } }
2490
- },
2491
- session: { create: {
2492
- async before(session, ctx) {
2493
- if (!ctx || isDashRoute(ctx.path)) return;
2494
- const visitorId = ctx.context.visitorId;
2495
- const identification = ctx.context.identification;
2496
- if (session.userId && identification?.location && visitorId) {
2497
- recordCheck(ctx, "impossible_travel");
2498
- const powSolution = ctx.headers?.get?.("X-PoW-Solution") ?? null;
2499
- const travelCheck = await securityService.checkImpossibleTravel(session.userId, identification.location, visitorId, identification.ip, powSolution, identification.requestId);
2500
- if (travelCheck?.powVerified) recordCheck(ctx, "pow_challenge");
2501
- if (travelCheck?.isImpossible) {
2502
- if (travelCheck.action === "block") {
2503
- setOutcome(ctx, "blocked", "impossible_travel", {
2504
- from: travelCheck.from,
2505
- to: travelCheck.to,
2506
- distance: travelCheck.distance,
2507
- speedRequired: travelCheck.speedRequired
2508
- });
2509
- emitEvaluation(ctx, trackEvent);
2510
- throw new APIError("FORBIDDEN", { message: "Login blocked due to suspicious location change." });
2511
- }
2512
- if (travelCheck.action === "challenge" && travelCheck.challenge) {
2513
- setOutcome(ctx, "challenged", "impossible_travel", {
2514
- from: travelCheck.from,
2515
- to: travelCheck.to,
2516
- distance: travelCheck.distance,
2517
- speedRequired: travelCheck.speedRequired
2518
- });
2519
- emitEvaluation(ctx, trackEvent);
2520
- throwChallengeError(travelCheck.challenge, "impossible_travel", "Unusual login location detected. Please complete a security check.");
2655
+ const identification = ctx.context.identification;
2656
+ if (session.userId && identification?.location && visitorId) {
2657
+ recordCheck(ctx, "impossible_travel");
2658
+ const powSolution = ctx.headers?.get?.("X-PoW-Solution") ?? null;
2659
+ const travelCheck = await securityService.checkImpossibleTravel(session.userId, identification.location, visitorId, identification.ip, powSolution, identification.requestId);
2660
+ if (travelCheck?.powVerified) recordCheck(ctx, "pow_challenge");
2661
+ if (travelCheck?.isImpossible) {
2662
+ if (travelCheck.action === "block") {
2663
+ setOutcome(ctx, "blocked", "impossible_travel", {
2664
+ from: travelCheck.from,
2665
+ to: travelCheck.to,
2666
+ distance: travelCheck.distance,
2667
+ speedRequired: travelCheck.speedRequired
2668
+ });
2669
+ emitEvaluation(ctx, trackEvent);
2670
+ throw new APIError("FORBIDDEN", { message: "Login blocked due to suspicious location change." });
2671
+ }
2672
+ if (travelCheck.action === "challenge" && travelCheck.challenge) {
2673
+ setOutcome(ctx, "challenged", "impossible_travel", {
2674
+ from: travelCheck.from,
2675
+ to: travelCheck.to,
2676
+ distance: travelCheck.distance,
2677
+ speedRequired: travelCheck.speedRequired
2678
+ });
2679
+ emitEvaluation(ctx, trackEvent);
2680
+ throwChallengeError(travelCheck.challenge, "impossible_travel", "Unusual login location detected. Please complete a security check.");
2681
+ }
2521
2682
  }
2522
2683
  }
2684
+ },
2685
+ async after(session, ctx) {
2686
+ if (!ctx || !session.userId || isDashRoute(ctx.path)) return;
2687
+ const visitorId = ctx.context.visitorId;
2688
+ const identification = ctx.context.identification;
2689
+ let user = null;
2690
+ try {
2691
+ user = await getUserById(session.userId, ctx, { includeLastActiveAt: activityTrackingEnabled });
2692
+ } catch (error) {
2693
+ logger.warn("[Sentinel] Failed to fetch user for security checks:", error);
2694
+ }
2695
+ if (visitorId) {
2696
+ if (await securityService.checkUnknownDevice(session.userId, visitorId) && user?.email) await ctx.context.runInBackgroundOrAwait(securityService.notifyUnknownDevice(session.userId, user.email, identification));
2697
+ }
2698
+ if (opts.security?.impossibleTravel?.enabled && identification?.location) await ctx.context.runInBackgroundOrAwait(securityService.storeLastLocation(session.userId, identification.location, identification.ip));
2523
2699
  }
2524
- },
2525
- async after(session, ctx) {
2526
- if (!ctx || !session.userId || isDashRoute(ctx.path)) return;
2527
- const visitorId = ctx.context.visitorId;
2528
- const identification = ctx.context.identification;
2529
- let user = null;
2530
- try {
2531
- user = await getUserById(session.userId, ctx, { includeLastActiveAt: activityTrackingEnabled });
2532
- } catch (error) {
2533
- logger.warn("[Sentinel] Failed to fetch user for security checks:", error);
2534
- }
2535
- if (visitorId) {
2536
- if (await securityService.checkUnknownDevice(session.userId, visitorId) && user?.email) await ctx.context.runInBackgroundOrAwait(securityService.notifyUnknownDevice(session.userId, user.email, identification));
2537
- }
2538
- if (opts.security?.impossibleTravel?.enabled && identification?.location) await ctx.context.runInBackgroundOrAwait(securityService.storeLastLocation(session.userId, identification.location, identification.ip));
2539
- }
2540
- } }
2700
+ } }
2701
+ }
2541
2702
  }
2542
- } };
2703
+ };
2543
2704
  },
2544
2705
  hooks: {
2545
2706
  before: [
@@ -2558,64 +2719,25 @@ const sentinel = (options) => {
2558
2719
  const visitorId = ctx.context.visitorId;
2559
2720
  const identification = ctx.context.identification;
2560
2721
  const ip = ctx.context.ip;
2561
- const isSignIn = matchesAnyRoute(ctx.path, [
2562
- routes.SIGN_IN_EMAIL,
2563
- routes.SIGN_IN_USERNAME,
2564
- routes.SIGN_IN_EMAIL_OTP,
2565
- routes.SIGN_IN_SOCIAL,
2566
- routes.SIGN_IN_PASSKEY,
2567
- routes.SIGN_IN_MAGIC_LINK,
2568
- routes.SIGN_IN_SSO,
2569
- routes.SIGN_IN_ANONYMOUS
2570
- ]);
2571
- const isSignUp = matchesAnyRoute(ctx.path, [routes.SIGN_UP_EMAIL]);
2572
- const isPasswordReset = matchesAnyRoute(ctx.path, [
2573
- routes.FORGET_PASSWORD,
2574
- routes.REQUEST_PASSWORD_RESET,
2575
- routes.RESET_PASSWORD
2576
- ]);
2577
- const isTwoFactor = matchesAnyRoute(ctx.path, [
2578
- routes.TWO_FACTOR_VERIFY_TOTP,
2579
- routes.TWO_FACTOR_VERIFY_BACKUP,
2580
- routes.TWO_FACTOR_VERIFY_OTP
2581
- ]);
2582
- const isOtpSend = matchesAnyRoute(ctx.path, [routes.EMAIL_OTP_SEND, routes.PHONE_SEND_OTP]);
2583
- const isMagicLinkVerify = matchesAnyRoute(ctx.path, [routes.MAGIC_LINK_VERIFY]);
2584
- const isOrgCreate = matchesAnyRoute(ctx.path, [routes.ORG_CREATE]);
2585
- const isSensitiveAction = matchesAnyRoute(ctx.path, [
2586
- routes.CHANGE_EMAIL,
2587
- routes.CHANGE_PASSWORD,
2588
- routes.SET_PASSWORD,
2589
- routes.LINK_SOCIAL,
2590
- routes.PASSKEY_ADD
2591
- ]);
2592
- if (!(isSignIn || isSignUp || isPasswordReset || isTwoFactor || isOtpSend || isMagicLinkVerify || isOrgCreate || isSensitiveAction)) return;
2722
+ if (!matchesAnyRoute(ctx.path, SECURITY_PROTECTED_ROUTES)) return;
2593
2723
  const requestBody = ctx.body;
2594
2724
  const identifier = requestBody?.email || requestBody?.phone || requestBody?.username || void 0;
2595
2725
  const powSolution = ctx.headers?.get?.("X-PoW-Solution");
2596
- if (visitorId || ip) {
2597
- recordCheck(ctx, "credential_stuffing");
2598
- if (await securityService.isBlocked(visitorId ?? "", ip, ctx.context.requestId ?? null)) {
2599
- setOutcome(ctx, "blocked", "credential_stuffing", {
2600
- phase: "cooldown_active",
2601
- visitorId: ctx.context.untrustedVisitorId ?? void 0,
2602
- confidence: 1
2603
- });
2604
- emitEvaluation(ctx, trackEvent, {
2605
- identifier,
2606
- userAgent: ctx.headers?.get?.("user-agent") || ""
2607
- });
2608
- throw new APIError(403, { message: "Too many failed attempts. Please try again later." });
2609
- }
2610
- }
2611
- await runSecurityChecks(ctx, {
2612
- path: ctx.path,
2726
+ const userAgent = ctx.headers?.get?.("user-agent") || "";
2727
+ const meta = {
2728
+ identifier,
2729
+ userAgent
2730
+ };
2731
+ const cached = ctx.request ? rateLimitAdmission.get(ctx.request) : void 0;
2732
+ if (cached) applyAdmission(cached, ctx, meta, trackEvent);
2733
+ else await runSecurityChecks(ctx, {
2734
+ path: ctx.path ?? "",
2613
2735
  identifier,
2614
2736
  visitorId,
2615
2737
  requestId: ctx.context.requestId ?? null,
2616
2738
  identification,
2617
- ip: ctx.context.ip,
2618
- userAgent: ctx.headers?.get?.("user-agent") || ""
2739
+ ip,
2740
+ userAgent
2619
2741
  }, securityService, trackEvent, powSolution);
2620
2742
  if (matchesAnyRoute(ctx.path, [
2621
2743
  routes.SIGN_UP_EMAIL,
@@ -2633,7 +2755,7 @@ const sentinel = (options) => {
2633
2755
  setOutcome(ctx, "blocked", "compromised_password", { breachCount: compromisedResult.breachCount });
2634
2756
  emitEvaluation(ctx, trackEvent, {
2635
2757
  identifier,
2636
- userAgent: ctx.headers?.get?.("user-agent") || ""
2758
+ userAgent
2637
2759
  });
2638
2760
  throw new APIError("BAD_REQUEST", {
2639
2761
  message: "This password has been found in data breaches. Please choose a different password.",
@@ -4280,6 +4402,23 @@ const getConfig = (options) => {
4280
4402
  emailVerification: { sendVerificationEmailEnabled: !!ctx.context.options.emailVerification?.sendVerificationEmail },
4281
4403
  insights: {
4282
4404
  hasDatabase: !!ctx.context.options.database,
4405
+ adapter: (() => {
4406
+ const adapter = ctx.context.adapter;
4407
+ if (!adapter) return null;
4408
+ const adapterConfig = adapter.options?.adapterConfig;
4409
+ return {
4410
+ id: adapter.id ?? adapterConfig?.adapterId ?? null,
4411
+ name: adapterConfig?.adapterName ?? adapter.id ?? adapterConfig?.adapterId ?? null,
4412
+ usePlural: adapterConfig?.usePlural ?? false,
4413
+ supportsTransactions: typeof adapterConfig?.transaction === "function",
4414
+ supportsJSON: typeof adapterConfig?.supportsJSON === "boolean" ? adapterConfig.supportsJSON : null,
4415
+ supportsDates: typeof adapterConfig?.supportsDates === "boolean" ? adapterConfig.supportsDates : null,
4416
+ supportsBooleans: typeof adapterConfig?.supportsBooleans === "boolean" ? adapterConfig.supportsBooleans : null,
4417
+ supportsArrays: typeof adapterConfig?.supportsArrays === "boolean" ? adapterConfig.supportsArrays : null,
4418
+ supportsNumericIds: typeof adapterConfig?.supportsNumericIds === "boolean" ? adapterConfig.supportsNumericIds : null,
4419
+ supportsUUIDs: typeof adapterConfig?.supportsUUIDs === "boolean" ? adapterConfig.supportsUUIDs : null
4420
+ };
4421
+ })(),
4283
4422
  cookies: advancedOptions?.cookies ? Object.entries(advancedOptions.cookies).map(([key, value]) => ({
4284
4423
  key,
4285
4424
  name: value?.name,
package/dist/native.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as DashGetAllAuditLogsInput, i as DashClientOptions, n as DashAuditLog, o as DashGetAuditLogsInput, r as DashAuditLogsResponse, s as dashClient, t as IdentifyClientOptions } from "./identify-client-options-CgijjwVT.mjs";
1
+ import { a as DashGetAllAuditLogsInput, i as DashClientOptions, n as DashAuditLog, o as DashGetAuditLogsInput, r as DashAuditLogsResponse, s as dashClient, t as IdentifyClientOptions } from "./identify-client-options-Cim5Fztg.mjs";
2
2
  import { BetterAuthClientPlugin } from "better-auth";
3
3
  //#region src/sentinel/native/client.d.ts
4
4
  interface SentinelNativeClientOptions {
package/dist/native.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { o as createKV, t as bytesToHex } from "./crypto-SmxL66Tk.mjs";
2
- import { a as resolveSentinelClientIdentifyUrl, c as identify, i as solvePoWChallenge, l as dashClient, n as decodePoWChallenge, o as resolveIdentifyClientRetry, r as encodePoWSolution, t as createPowRetryTimeout } from "./pow-retry-BWDJUT8X.mjs";
1
+ import { o as createKV, t as bytesToHex } from "./crypto-BhIjjZNe.mjs";
2
+ import { a as resolveSentinelClientIdentifyUrl, c as identify, i as solvePoWChallenge, l as dashClient, n as decodePoWChallenge, o as resolveIdentifyClientRetry, r as encodePoWSolution, t as createPowRetryTimeout } from "./pow-retry-CsInOtOL.mjs";
3
3
  import { env } from "@better-auth/core/env";
4
4
  import { Dimensions, InteractionManager, PixelRatio, Platform } from "react-native";
5
5
  //#region src/sentinel/native/components.ts
@@ -1,4 +1,4 @@
1
- import { i as randomBytes, n as hash, t as bytesToHex } from "./crypto-SmxL66Tk.mjs";
1
+ import { i as randomBytes, n as hash, t as bytesToHex } from "./crypto-BhIjjZNe.mjs";
2
2
  //#region src/dash-client.ts
3
3
  function resolveDashUserId(input, options) {
4
4
  return input.userId || options?.resolveUserId?.({
@@ -1,5 +1,6 @@
1
1
  import { BetterAuthPlugin, GenericEndpointContext } from "better-auth";
2
2
  import { createFetch } from "@better-fetch/fetch";
3
+ import { BetterAuthRateLimitRule } from "@better-auth/core";
3
4
  import { APIError as APIError$1, Endpoint, EndpointOptions } from "better-call";
4
5
  //#region src/identification.d.ts
5
6
  interface IPLocation {
@@ -167,6 +168,20 @@ type SecurityEventType = "unknown_device" | "credential_stuffing" | "impossible_
167
168
  declare const sentinel: (options?: SentinelOptions) => {
168
169
  id: "sentinel";
169
170
  init(ctx: import("better-auth").AuthContext): {
171
+ context: {
172
+ rateLimit: {
173
+ customRules: {
174
+ [key: string]: false | BetterAuthRateLimitRule | ((request: Request, currentRule: BetterAuthRateLimitRule) => import("@better-auth/core").Awaitable<false | BetterAuthRateLimitRule>);
175
+ };
176
+ enabled: boolean;
177
+ window: number;
178
+ max: number;
179
+ storage: "memory" | "database" | "secondary-storage";
180
+ modelName?: import("better-auth").LiteralString | "rateLimit" | undefined;
181
+ fields?: Partial<Record<"key" | "count" | "lastRequest", string>> | undefined;
182
+ customStorage?: import("better-auth").BetterAuthRateLimitStorage | undefined;
183
+ };
184
+ };
170
185
  options: {
171
186
  emailValidation: {
172
187
  enabled?: boolean;
@@ -177,6 +192,11 @@ declare const sentinel: (options?: SentinelOptions) => {
177
192
  emailNormalization: {
178
193
  enabled?: boolean;
179
194
  } | undefined;
195
+ rateLimit: {
196
+ customRules: {
197
+ [key: string]: false | BetterAuthRateLimitRule | ((request: Request, currentRule: BetterAuthRateLimitRule) => import("@better-auth/core").Awaitable<false | BetterAuthRateLimitRule>);
198
+ };
199
+ };
180
200
  databaseHooks: {
181
201
  user: {
182
202
  create: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@better-auth/infra",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "Dashboard and analytics plugin for Better Auth",
5
5
  "type": "module",
6
6
  "main": "dist/index.mjs",
@@ -74,7 +74,7 @@
74
74
  "happy-dom": "^20.9.0",
75
75
  "msw": "^2.14.6",
76
76
  "tsdown": "^0.22.14",
77
- "typescript": "^5.9.2",
77
+ "typescript": "^6.0.3",
78
78
  "zod": "4.3.6"
79
79
  },
80
80
  "dependencies": {