@better-auth/core 1.5.0-beta.8 → 1.5.0-beta.9

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.
@@ -1,19 +1,20 @@
1
1
 
2
- > @better-auth/core@1.5.0-beta.8 build /home/runner/work/better-auth/better-auth/packages/core
2
+ > @better-auth/core@1.5.0-beta.9 build /home/runner/work/better-auth/better-auth/packages/core
3
3
  > tsdown
4
4
 
5
5
  ℹ tsdown v0.19.0 powered by rolldown v1.0.0-beta.59
6
6
  ℹ config file: /home/runner/work/better-auth/better-auth/packages/core/tsdown.config.ts
7
- ℹ entry: src/index.ts, src/api/index.ts, src/async_hooks/index.ts, src/async_hooks/pure.index.ts, src/context/index.ts, src/db/index.ts, src/env/index.ts, src/error/index.ts, src/oauth2/index.ts, src/social-providers/index.ts, src/utils/deprecate.ts, src/utils/error-codes.ts, src/utils/id.ts, src/utils/json.ts, src/utils/string.ts, src/utils/url.ts, src/db/adapter/index.ts
7
+ ℹ entry: src/index.ts, src/api/index.ts, src/async_hooks/index.ts, src/async_hooks/pure.index.ts, src/context/index.ts, src/db/index.ts, src/env/index.ts, src/error/index.ts, src/oauth2/index.ts, src/social-providers/index.ts, src/utils/deprecate.ts, src/utils/error-codes.ts, src/utils/id.ts, src/utils/ip.ts, src/utils/json.ts, src/utils/string.ts, src/utils/url.ts, src/db/adapter/index.ts
8
8
  ℹ tsconfig: tsconfig.json
9
9
  ℹ Build start
10
+ ℹ dist/utils/ip.mjs  3.81 kB │ gzip: 1.29 kB
10
11
  ℹ dist/social-providers/index.mjs  2.32 kB │ gzip: 0.72 kB
11
12
  ℹ dist/api/index.mjs  1.27 kB │ gzip: 0.48 kB
12
13
  ℹ dist/utils/url.mjs  1.14 kB │ gzip: 0.51 kB
13
14
  ℹ dist/async_hooks/index.mjs  1.03 kB │ gzip: 0.54 kB
14
15
  ℹ dist/async_hooks/pure.index.mjs  0.99 kB │ gzip: 0.49 kB
15
16
  ℹ dist/oauth2/index.mjs  0.87 kB │ gzip: 0.27 kB
16
- ℹ dist/context/index.mjs  0.79 kB │ gzip: 0.23 kB
17
+ ℹ dist/context/index.mjs  0.85 kB │ gzip: 0.25 kB
17
18
  ℹ dist/db/adapter/index.mjs  0.71 kB │ gzip: 0.22 kB
18
19
  ℹ dist/error/index.mjs  0.69 kB │ gzip: 0.33 kB
19
20
  ℹ dist/utils/json.mjs  0.59 kB │ gzip: 0.35 kB
@@ -35,8 +36,8 @@
35
36
  ℹ dist/social-providers/line.mjs  3.57 kB │ gzip: 1.20 kB
36
37
  ℹ dist/social-providers/salesforce.mjs  3.50 kB │ gzip: 1.12 kB
37
38
  ℹ dist/social-providers/microsoft-entra-id.mjs  3.46 kB │ gzip: 1.22 kB
39
+ ℹ dist/oauth2/validate-authorization-code.mjs  2.80 kB │ gzip: 1.03 kB
38
40
  ℹ dist/social-providers/figma.mjs  2.78 kB │ gzip: 0.99 kB
39
- ℹ dist/oauth2/validate-authorization-code.mjs  2.76 kB │ gzip: 1.03 kB
40
41
  ℹ dist/social-providers/atlassian.mjs  2.75 kB │ gzip: 0.99 kB
41
42
  ℹ dist/social-providers/twitter.mjs  2.74 kB │ gzip: 0.92 kB
42
43
  ℹ dist/env/color-depth.mjs  2.72 kB │ gzip: 0.99 kB
@@ -46,12 +47,13 @@
46
47
  ℹ dist/social-providers/reddit.mjs  2.63 kB │ gzip: 1.01 kB
47
48
  ℹ dist/social-providers/discord.mjs  2.62 kB │ gzip: 1.04 kB
48
49
  ℹ dist/social-providers/github.mjs  2.61 kB │ gzip: 0.91 kB
50
+ ℹ dist/context/transaction.mjs  2.59 kB │ gzip: 0.83 kB
49
51
  ℹ dist/social-providers/vk.mjs  2.59 kB │ gzip: 0.94 kB
50
52
  ℹ dist/env/env-impl.mjs  2.58 kB │ gzip: 0.90 kB
51
53
  ℹ dist/error/codes.mjs  2.56 kB │ gzip: 1.05 kB
52
54
  ℹ dist/env/logger.mjs  2.42 kB │ gzip: 0.94 kB
53
55
  ℹ dist/social-providers/linear.mjs  2.42 kB │ gzip: 0.90 kB
54
- ℹ dist/social-providers/dropbox.mjs  2.38 kB │ gzip: 0.87 kB
56
+ ℹ dist/social-providers/dropbox.mjs  2.35 kB │ gzip: 0.86 kB
55
57
  ℹ dist/social-providers/kakao.mjs  2.31 kB │ gzip: 0.84 kB
56
58
  ℹ dist/social-providers/notion.mjs  2.28 kB │ gzip: 0.86 kB
57
59
  ℹ dist/social-providers/zoom.mjs  2.27 kB │ gzip: 0.89 kB
@@ -70,7 +72,6 @@
70
72
  ℹ dist/oauth2/create-authorization-url.mjs  1.86 kB │ gzip: 0.73 kB
71
73
  ℹ dist/oauth2/client-credentials-token.mjs  1.83 kB │ gzip: 0.77 kB
72
74
  ℹ dist/context/request-state.mjs  1.63 kB │ gzip: 0.59 kB
73
- ℹ dist/context/transaction.mjs  1.60 kB │ gzip: 0.55 kB
74
75
  ℹ dist/db/adapter/get-default-field-name.mjs  1.41 kB │ gzip: 0.66 kB
75
76
  ℹ dist/db/adapter/get-default-model-name.mjs  1.40 kB │ gzip: 0.65 kB
76
77
  ℹ dist/context/endpoint-context.mjs  1.31 kB │ gzip: 0.53 kB
@@ -89,10 +90,11 @@
89
90
  ℹ dist/social-providers/index.d.mts 45.59 kB │ gzip: 3.09 kB
90
91
  ℹ dist/db/adapter/index.d.mts 15.17 kB │ gzip: 3.60 kB
91
92
  ℹ dist/api/index.d.mts  7.54 kB │ gzip: 1.45 kB
93
+ ℹ dist/utils/ip.d.mts  1.58 kB │ gzip: 0.68 kB
92
94
  ℹ dist/index.d.mts  1.36 kB │ gzip: 0.38 kB
93
95
  ℹ dist/oauth2/index.d.mts  1.05 kB │ gzip: 0.32 kB
94
96
  ℹ dist/db/index.d.mts  1.04 kB │ gzip: 0.34 kB
95
- ℹ dist/context/index.d.mts  0.92 kB │ gzip: 0.27 kB
97
+ ℹ dist/context/index.d.mts  0.97 kB │ gzip: 0.28 kB
96
98
  ℹ dist/utils/error-codes.d.mts  0.89 kB │ gzip: 0.45 kB
97
99
  ℹ dist/utils/url.d.mts  0.84 kB │ gzip: 0.40 kB
98
100
  ℹ dist/error/index.d.mts  0.75 kB │ gzip: 0.34 kB
@@ -103,8 +105,8 @@
103
105
  ℹ dist/utils/string.d.mts  0.14 kB │ gzip: 0.12 kB
104
106
  ℹ dist/utils/json.d.mts  0.13 kB │ gzip: 0.13 kB
105
107
  ℹ dist/utils/id.d.mts  0.12 kB │ gzip: 0.12 kB
106
- ℹ dist/types/init-options.d.mts 38.29 kB │ gzip: 8.53 kB
107
- ℹ dist/types/context.d.mts 10.12 kB │ gzip: 2.96 kB
108
+ ℹ dist/types/init-options.d.mts 38.97 kB │ gzip: 8.80 kB
109
+ ℹ dist/types/context.d.mts 10.15 kB │ gzip: 2.97 kB
108
110
  ℹ dist/social-providers/zoom.d.mts  6.71 kB │ gzip: 2.29 kB
109
111
  ℹ dist/oauth2/oauth-provider.d.mts  5.92 kB │ gzip: 1.67 kB
110
112
  ℹ dist/social-providers/microsoft-entra-id.d.mts  5.59 kB │ gzip: 1.96 kB
@@ -150,13 +152,13 @@
150
152
  ℹ dist/context/request-state.d.mts  1.35 kB │ gzip: 0.59 kB
151
153
  ℹ dist/db/adapter/factory.d.mts  1.30 kB │ gzip: 0.45 kB
152
154
  ℹ dist/env/env-impl.d.mts  1.26 kB │ gzip: 0.47 kB
155
+ ℹ dist/context/transaction.d.mts  1.21 kB │ gzip: 0.51 kB
153
156
  ℹ dist/oauth2/create-authorization-url.d.mts  1.05 kB │ gzip: 0.41 kB
154
157
  ℹ dist/db/schema/account.d.mts  1.05 kB │ gzip: 0.43 kB
155
158
  ℹ dist/db/adapter/get-id-field.d.mts  1.04 kB │ gzip: 0.47 kB
156
159
  ℹ dist/oauth2/refresh-access-token.d.mts  1.01 kB │ gzip: 0.41 kB
157
160
  ℹ dist/context/endpoint-context.d.mts  0.99 kB │ gzip: 0.43 kB
158
161
  ℹ dist/oauth2/client-credentials-token.d.mts  0.91 kB │ gzip: 0.36 kB
159
- ℹ dist/context/transaction.d.mts  0.86 kB │ gzip: 0.38 kB
160
162
  ℹ dist/types/index.d.mts  0.86 kB │ gzip: 0.34 kB
161
163
  ℹ dist/db/schema/session.d.mts  0.75 kB │ gzip: 0.40 kB
162
164
  ℹ dist/db/adapter/get-field-attributes.d.mts  0.72 kB │ gzip: 0.34 kB
@@ -176,5 +178,5 @@
176
178
  ℹ dist/db/schema/shared.d.mts  0.25 kB │ gzip: 0.18 kB
177
179
  ℹ dist/context/global.d.mts  0.20 kB │ gzip: 0.16 kB
178
180
  ℹ dist/env/color-depth.d.mts  0.12 kB │ gzip: 0.11 kB
179
- ℹ 169 files, total: 447.03 kB
180
- ✔ Build complete in 6053ms
181
+ ℹ 171 files, total: 454.58 kB
182
+ ✔ Build complete in 6303ms
@@ -2,7 +2,7 @@
2
2
  const symbol = Symbol.for("better-auth:global");
3
3
  let bind = null;
4
4
  const __context = {};
5
- const __betterAuthVersion = "1.5.0-beta.8";
5
+ const __betterAuthVersion = "1.5.0-beta.9";
6
6
  /**
7
7
  * We store context instance in the globalThis.
8
8
  *
@@ -1,5 +1,5 @@
1
1
  import { AuthEndpointContext, getCurrentAuthContext, getCurrentAuthContextAsyncLocalStorage, runWithEndpointContext } from "./endpoint-context.mjs";
2
2
  import { getBetterAuthVersion } from "./global.mjs";
3
3
  import { RequestState, RequestStateWeakMap, defineRequestState, getCurrentRequestState, getRequestStateAsyncLocalStorage, hasRequestState, runWithRequestState } from "./request-state.mjs";
4
- import { getCurrentAdapter, getCurrentDBAdapterAsyncLocalStorage, runWithAdapter, runWithTransaction } from "./transaction.mjs";
5
- export { type AuthEndpointContext, type RequestState, type RequestStateWeakMap, defineRequestState, getBetterAuthVersion, getCurrentAdapter, getCurrentAuthContext, getCurrentAuthContextAsyncLocalStorage, getCurrentDBAdapterAsyncLocalStorage, getCurrentRequestState, getRequestStateAsyncLocalStorage, hasRequestState, runWithAdapter, runWithEndpointContext, runWithRequestState, runWithTransaction };
4
+ import { getCurrentAdapter, getCurrentDBAdapterAsyncLocalStorage, queueAfterTransactionHook, runWithAdapter, runWithTransaction } from "./transaction.mjs";
5
+ export { type AuthEndpointContext, type RequestState, type RequestStateWeakMap, defineRequestState, getBetterAuthVersion, getCurrentAdapter, getCurrentAuthContext, getCurrentAuthContextAsyncLocalStorage, getCurrentDBAdapterAsyncLocalStorage, getCurrentRequestState, getRequestStateAsyncLocalStorage, hasRequestState, queueAfterTransactionHook, runWithAdapter, runWithEndpointContext, runWithRequestState, runWithTransaction };
@@ -1,6 +1,6 @@
1
1
  import { getBetterAuthVersion } from "./global.mjs";
2
2
  import { getCurrentAuthContext, getCurrentAuthContextAsyncLocalStorage, runWithEndpointContext } from "./endpoint-context.mjs";
3
3
  import { defineRequestState, getCurrentRequestState, getRequestStateAsyncLocalStorage, hasRequestState, runWithRequestState } from "./request-state.mjs";
4
- import { getCurrentAdapter, getCurrentDBAdapterAsyncLocalStorage, runWithAdapter, runWithTransaction } from "./transaction.mjs";
4
+ import { getCurrentAdapter, getCurrentDBAdapterAsyncLocalStorage, queueAfterTransactionHook, runWithAdapter, runWithTransaction } from "./transaction.mjs";
5
5
 
6
- export { defineRequestState, getBetterAuthVersion, getCurrentAdapter, getCurrentAuthContext, getCurrentAuthContextAsyncLocalStorage, getCurrentDBAdapterAsyncLocalStorage, getCurrentRequestState, getRequestStateAsyncLocalStorage, hasRequestState, runWithAdapter, runWithEndpointContext, runWithRequestState, runWithTransaction };
6
+ export { defineRequestState, getBetterAuthVersion, getCurrentAdapter, getCurrentAuthContext, getCurrentAuthContextAsyncLocalStorage, getCurrentDBAdapterAsyncLocalStorage, getCurrentRequestState, getRequestStateAsyncLocalStorage, hasRequestState, queueAfterTransactionHook, runWithAdapter, runWithEndpointContext, runWithRequestState, runWithTransaction };
@@ -2,15 +2,23 @@ import { DBAdapter, DBTransactionAdapter } from "../db/adapter/index.mjs";
2
2
  import { AsyncLocalStorage } from "node:async_hooks";
3
3
 
4
4
  //#region src/context/transaction.d.ts
5
-
5
+ type HookContext = {
6
+ adapter: DBTransactionAdapter;
7
+ pendingHooks: Array<() => Promise<void>>;
8
+ };
6
9
  /**
7
10
  * This is for internal use only. Most users should use `getCurrentAdapter` instead.
8
11
  *
9
12
  * It is exposed for advanced use cases where you need direct access to the AsyncLocalStorage instance.
10
13
  */
11
- declare const getCurrentDBAdapterAsyncLocalStorage: () => Promise<AsyncLocalStorage<DBTransactionAdapter>>;
14
+ declare const getCurrentDBAdapterAsyncLocalStorage: () => Promise<AsyncLocalStorage<HookContext>>;
12
15
  declare const getCurrentAdapter: (fallback: DBTransactionAdapter) => Promise<DBTransactionAdapter>;
13
16
  declare const runWithAdapter: <R>(adapter: DBAdapter, fn: () => R) => Promise<R>;
14
17
  declare const runWithTransaction: <R>(adapter: DBAdapter, fn: () => R) => Promise<R>;
18
+ /**
19
+ * Queue a hook to be executed after the current transaction commits.
20
+ * If not in a transaction, the hook will execute immediately.
21
+ */
22
+ declare const queueAfterTransactionHook: (hook: () => Promise<void>) => Promise<void>;
15
23
  //#endregion
16
- export { getCurrentAdapter, getCurrentDBAdapterAsyncLocalStorage, runWithAdapter, runWithTransaction };
24
+ export { getCurrentAdapter, getCurrentDBAdapterAsyncLocalStorage, queueAfterTransactionHook, runWithAdapter, runWithTransaction };
@@ -20,16 +20,31 @@ const getCurrentDBAdapterAsyncLocalStorage = async () => {
20
20
  };
21
21
  const getCurrentAdapter = async (fallback) => {
22
22
  return ensureAsyncStorage().then((als) => {
23
- return als.getStore() || fallback;
23
+ return als.getStore()?.adapter || fallback;
24
24
  }).catch(() => {
25
25
  return fallback;
26
26
  });
27
27
  };
28
28
  const runWithAdapter = async (adapter, fn) => {
29
- let called = true;
30
- return ensureAsyncStorage().then((als) => {
29
+ let called = false;
30
+ return ensureAsyncStorage().then(async (als) => {
31
31
  called = true;
32
- return als.run(adapter, fn);
32
+ const pendingHooks = [];
33
+ let result;
34
+ let error;
35
+ let hasError = false;
36
+ try {
37
+ result = await als.run({
38
+ adapter,
39
+ pendingHooks
40
+ }, fn);
41
+ } catch (err) {
42
+ error = err;
43
+ hasError = true;
44
+ }
45
+ for (const hook of pendingHooks) await hook();
46
+ if (hasError) throw error;
47
+ return result;
33
48
  }).catch((err) => {
34
49
  if (!called) return fn();
35
50
  throw err;
@@ -37,16 +52,44 @@ const runWithAdapter = async (adapter, fn) => {
37
52
  };
38
53
  const runWithTransaction = async (adapter, fn) => {
39
54
  let called = true;
40
- return ensureAsyncStorage().then((als) => {
55
+ return ensureAsyncStorage().then(async (als) => {
41
56
  called = true;
42
- return adapter.transaction(async (trx) => {
43
- return als.run(trx, fn);
44
- });
57
+ const pendingHooks = [];
58
+ let result;
59
+ let error;
60
+ let hasError = false;
61
+ try {
62
+ result = await adapter.transaction(async (trx) => {
63
+ return als.run({
64
+ adapter: trx,
65
+ pendingHooks
66
+ }, fn);
67
+ });
68
+ } catch (e) {
69
+ hasError = true;
70
+ error = e;
71
+ }
72
+ for (const hook of pendingHooks) await hook();
73
+ if (hasError) throw error;
74
+ return result;
45
75
  }).catch((err) => {
46
76
  if (!called) return fn();
47
77
  throw err;
48
78
  });
49
79
  };
80
+ /**
81
+ * Queue a hook to be executed after the current transaction commits.
82
+ * If not in a transaction, the hook will execute immediately.
83
+ */
84
+ const queueAfterTransactionHook = async (hook) => {
85
+ return ensureAsyncStorage().then((als) => {
86
+ const store = als.getStore();
87
+ if (store) store.pendingHooks.push(hook);
88
+ else return hook();
89
+ }).catch(() => {
90
+ return hook();
91
+ });
92
+ };
50
93
 
51
94
  //#endregion
52
- export { getCurrentAdapter, getCurrentDBAdapterAsyncLocalStorage, runWithAdapter, runWithTransaction };
95
+ export { getCurrentAdapter, getCurrentDBAdapterAsyncLocalStorage, queueAfterTransactionHook, runWithAdapter, runWithTransaction };
@@ -2,7 +2,7 @@ import { getOAuth2Tokens } from "./utils.mjs";
2
2
  import "./index.mjs";
3
3
  import { base64 } from "@better-auth/utils/base64";
4
4
  import { betterFetch } from "@better-fetch/fetch";
5
- import { jwtVerify } from "jose";
5
+ import { decodeProtectedHeader, importJWK, jwtVerify } from "jose";
6
6
 
7
7
  //#region src/oauth2/validate-authorization-code.ts
8
8
  function createAuthorizationCodeRequest({ code, codeVerifier, redirectURI, options, authentication, deviceId, headers, additionalParams = {}, resource }) {
@@ -61,10 +61,10 @@ async function validateToken(token, jwksEndpoint) {
61
61
  });
62
62
  if (error) throw error;
63
63
  const keys = data["keys"];
64
- const header = JSON.parse(atob(token.split(".")[0]));
65
- const key = keys.find((key$1) => key$1.kid === header.kid);
64
+ const header = decodeProtectedHeader(token);
65
+ const key = keys.find((k) => k.kid === header.kid);
66
66
  if (!key) throw new Error("Key not found");
67
- return await jwtVerify(token, key);
67
+ return await jwtVerify(token, await importJWK(key, header.alg));
68
68
  }
69
69
 
70
70
  //#endregion
@@ -44,7 +44,7 @@ const dropbox = (options) => {
44
44
  clientKey: options.clientKey,
45
45
  clientSecret: options.clientSecret
46
46
  },
47
- tokenEndpoint: "https://api.dropbox.com/oauth2/token"
47
+ tokenEndpoint
48
48
  });
49
49
  },
50
50
  async getUserInfo(token) {
@@ -65,6 +65,7 @@ interface InternalAdapter<_Options extends BetterAuthOptions = BetterAuthOptions
65
65
  deleteSessions(userIdOrSessionTokens: string | string[]): Promise<void>;
66
66
  findOAuthUser(email: string, accountId: string, providerId: string): Promise<{
67
67
  user: User;
68
+ linkedAccount: Account | null;
68
69
  accounts: Account[];
69
70
  } | null>;
70
71
  findUserByEmail(email: string, options?: {
@@ -110,6 +110,25 @@ type BetterAuthAdvancedOptions = {
110
110
  * ⚠︎ This is a security risk and it may expose your application to abuse
111
111
  */
112
112
  disableIpTracking?: boolean;
113
+ /**
114
+ * IPv6 subnet prefix length for rate limiting.
115
+ *
116
+ * IPv6 addresses can be grouped by subnet to prevent attackers from
117
+ * bypassing rate limits by rotating through multiple addresses in
118
+ * their allocation.
119
+ *
120
+ * Common values:
121
+ * - 128 (default): Individual IPv6 address
122
+ * - 64: /64 subnet (typical home/business allocation)
123
+ * - 48: /48 subnet (larger network allocation)
124
+ * - 32: /32 subnet (ISP allocation)
125
+ *
126
+ * Note: This only affects IPv6 addresses. IPv4 addresses are always
127
+ * rate limited individually.
128
+ *
129
+ * @default 64 (/64 subnet)
130
+ */
131
+ ipv6Subnet?: 128 | 64 | 48 | 32 | undefined;
113
132
  } | undefined;
114
133
  /**
115
134
  * Use secure cookies
@@ -0,0 +1,54 @@
1
+ //#region src/utils/ip.d.ts
2
+ /**
3
+ * Normalizes an IP address for consistent rate limiting.
4
+ *
5
+ * Features:
6
+ * - Normalizes IPv6 to canonical lowercase form
7
+ * - Converts IPv4-mapped IPv6 to IPv4
8
+ * - Supports IPv6 subnet extraction
9
+ * - Handles all edge cases (::1, ::, etc.)
10
+ */
11
+ interface NormalizeIPOptions {
12
+ /**
13
+ * For IPv6 addresses, extract the subnet prefix instead of full address.
14
+ * Common values: 32, 48, 64, 128 (default: 128 = full address)
15
+ *
16
+ * @default 128
17
+ */
18
+ ipv6Subnet?: 128 | 64 | 48 | 32;
19
+ }
20
+ /**
21
+ * Checks if an IP is valid IPv4 or IPv6
22
+ */
23
+ declare function isValidIP(ip: string): boolean;
24
+ /**
25
+ * Normalizes an IP address (IPv4 or IPv6) for consistent rate limiting.
26
+ *
27
+ * @param ip - The IP address to normalize
28
+ * @param options - Normalization options
29
+ * @returns Normalized IP address
30
+ *
31
+ * @example
32
+ * normalizeIP("2001:DB8::1")
33
+ * // -> "2001:0db8:0000:0000:0000:0000:0000:0000"
34
+ *
35
+ * @example
36
+ * normalizeIP("::ffff:192.0.2.1")
37
+ * // -> "192.0.2.1" (converted to IPv4)
38
+ *
39
+ * @example
40
+ * normalizeIP("2001:db8::1", { ipv6Subnet: 64 })
41
+ * // -> "2001:0db8:0000:0000:0000:0000:0000:0000" (subnet /64)
42
+ */
43
+ declare function normalizeIP(ip: string, options?: NormalizeIPOptions): string;
44
+ /**
45
+ * Creates a rate limit key from IP and path
46
+ * Uses a separator to prevent collision attacks
47
+ *
48
+ * @param ip - The IP address (should be normalized)
49
+ * @param path - The request path
50
+ * @returns Rate limit key
51
+ */
52
+ declare function createRateLimitKey(ip: string, path: string): string;
53
+ //#endregion
54
+ export { createRateLimitKey, isValidIP, normalizeIP };
@@ -0,0 +1,118 @@
1
+ import * as z from "zod";
2
+
3
+ //#region src/utils/ip.ts
4
+ /**
5
+ * Checks if an IP is valid IPv4 or IPv6
6
+ */
7
+ function isValidIP(ip) {
8
+ return z.ipv4().safeParse(ip).success || z.ipv6().safeParse(ip).success;
9
+ }
10
+ /**
11
+ * Checks if an IP is IPv6
12
+ */
13
+ function isIPv6(ip) {
14
+ return z.ipv6().safeParse(ip).success;
15
+ }
16
+ /**
17
+ * Converts IPv4-mapped IPv6 address to IPv4
18
+ * e.g., "::ffff:192.0.2.1" -> "192.0.2.1"
19
+ */
20
+ function extractIPv4FromMapped(ipv6) {
21
+ const lower = ipv6.toLowerCase();
22
+ if (lower.startsWith("::ffff:")) {
23
+ const ipv4Part = lower.substring(7);
24
+ if (z.ipv4().safeParse(ipv4Part).success) return ipv4Part;
25
+ }
26
+ const parts = ipv6.split(":");
27
+ if (parts.length === 7 && parts[5]?.toLowerCase() === "ffff") {
28
+ const ipv4Part = parts[6];
29
+ if (ipv4Part && z.ipv4().safeParse(ipv4Part).success) return ipv4Part;
30
+ }
31
+ if (lower.includes("::ffff:") || lower.includes(":ffff:")) {
32
+ const groups = expandIPv6(ipv6);
33
+ if (groups.length === 8 && groups[0] === "0000" && groups[1] === "0000" && groups[2] === "0000" && groups[3] === "0000" && groups[4] === "0000" && groups[5] === "ffff" && groups[6] && groups[7]) return `${Number.parseInt(groups[6].substring(0, 2), 16)}.${Number.parseInt(groups[6].substring(2, 4), 16)}.${Number.parseInt(groups[7].substring(0, 2), 16)}.${Number.parseInt(groups[7].substring(2, 4), 16)}`;
34
+ }
35
+ return null;
36
+ }
37
+ /**
38
+ * Expands a compressed IPv6 address to full form
39
+ * e.g., "2001:db8::1" -> ["2001", "0db8", "0000", "0000", "0000", "0000", "0000", "0001"]
40
+ */
41
+ function expandIPv6(ipv6) {
42
+ if (ipv6.includes("::")) {
43
+ const sides = ipv6.split("::");
44
+ const left = sides[0] ? sides[0].split(":") : [];
45
+ const right = sides[1] ? sides[1].split(":") : [];
46
+ const missingGroups = 8 - left.length - right.length;
47
+ const zeros = Array(missingGroups).fill("0000");
48
+ const paddedLeft = left.map((g) => g.padStart(4, "0"));
49
+ const paddedRight = right.map((g) => g.padStart(4, "0"));
50
+ return [
51
+ ...paddedLeft,
52
+ ...zeros,
53
+ ...paddedRight
54
+ ];
55
+ }
56
+ return ipv6.split(":").map((g) => g.padStart(4, "0"));
57
+ }
58
+ /**
59
+ * Normalizes an IPv6 address to canonical form
60
+ * e.g., "2001:DB8::1" -> "2001:0db8:0000:0000:0000:0000:0000:0001"
61
+ */
62
+ function normalizeIPv6(ipv6, subnetPrefix) {
63
+ const groups = expandIPv6(ipv6);
64
+ if (subnetPrefix && subnetPrefix < 128) {
65
+ let bitsRemaining = subnetPrefix;
66
+ return groups.map((group) => {
67
+ if (bitsRemaining <= 0) return "0000";
68
+ if (bitsRemaining >= 16) {
69
+ bitsRemaining -= 16;
70
+ return group;
71
+ }
72
+ const masked = Number.parseInt(group, 16) & (65535 << 16 - bitsRemaining & 65535);
73
+ bitsRemaining = 0;
74
+ return masked.toString(16).padStart(4, "0");
75
+ }).join(":").toLowerCase();
76
+ }
77
+ return groups.join(":").toLowerCase();
78
+ }
79
+ /**
80
+ * Normalizes an IP address (IPv4 or IPv6) for consistent rate limiting.
81
+ *
82
+ * @param ip - The IP address to normalize
83
+ * @param options - Normalization options
84
+ * @returns Normalized IP address
85
+ *
86
+ * @example
87
+ * normalizeIP("2001:DB8::1")
88
+ * // -> "2001:0db8:0000:0000:0000:0000:0000:0000"
89
+ *
90
+ * @example
91
+ * normalizeIP("::ffff:192.0.2.1")
92
+ * // -> "192.0.2.1" (converted to IPv4)
93
+ *
94
+ * @example
95
+ * normalizeIP("2001:db8::1", { ipv6Subnet: 64 })
96
+ * // -> "2001:0db8:0000:0000:0000:0000:0000:0000" (subnet /64)
97
+ */
98
+ function normalizeIP(ip, options = {}) {
99
+ if (z.ipv4().safeParse(ip).success) return ip.toLowerCase();
100
+ if (!isIPv6(ip)) return ip.toLowerCase();
101
+ const ipv4 = extractIPv4FromMapped(ip);
102
+ if (ipv4) return ipv4.toLowerCase();
103
+ return normalizeIPv6(ip, options.ipv6Subnet || 64);
104
+ }
105
+ /**
106
+ * Creates a rate limit key from IP and path
107
+ * Uses a separator to prevent collision attacks
108
+ *
109
+ * @param ip - The IP address (should be normalized)
110
+ * @param path - The request path
111
+ * @returns Rate limit key
112
+ */
113
+ function createRateLimitKey(ip, path) {
114
+ return `${ip}|${path}`;
115
+ }
116
+
117
+ //#endregion
118
+ export { createRateLimitKey, isValidIP, normalizeIP };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@better-auth/core",
3
- "version": "1.5.0-beta.8",
3
+ "version": "1.5.0-beta.9",
4
4
  "description": "The most comprehensive authentication framework for TypeScript.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -117,20 +117,20 @@
117
117
  "devDependencies": {
118
118
  "@better-auth/utils": "0.3.0",
119
119
  "@better-fetch/fetch": "1.1.21",
120
- "better-call": "1.1.8",
120
+ "better-call": "1.2.0",
121
121
  "jose": "^6.1.0",
122
- "kysely": "^0.28.5",
123
- "nanostores": "^1.0.1",
122
+ "kysely": "^0.28.10",
123
+ "nanostores": "^1.1.0",
124
124
  "tsdown": "^0.19.0"
125
125
  },
126
126
  "dependencies": {
127
127
  "@standard-schema/spec": "^1.0.0",
128
- "zod": "^4.1.12"
128
+ "zod": "^4.3.5"
129
129
  },
130
130
  "peerDependencies": {
131
131
  "@better-auth/utils": "0.3.0",
132
132
  "@better-fetch/fetch": "1.1.21",
133
- "better-call": "1.1.8",
133
+ "better-call": "1.2.0",
134
134
  "jose": "^6.1.0",
135
135
  "kysely": "^0.28.5",
136
136
  "nanostores": "^1.0.1"
@@ -17,6 +17,7 @@ export {
17
17
  export {
18
18
  getCurrentAdapter,
19
19
  getCurrentDBAdapterAsyncLocalStorage,
20
+ queueAfterTransactionHook,
20
21
  runWithAdapter,
21
22
  runWithTransaction,
22
23
  } from "./transaction";
@@ -3,6 +3,11 @@ import { getAsyncLocalStorage } from "@better-auth/core/async_hooks";
3
3
  import type { DBAdapter, DBTransactionAdapter } from "../db/adapter";
4
4
  import { __getBetterAuthGlobal } from "./global";
5
5
 
6
+ type HookContext = {
7
+ adapter: DBTransactionAdapter;
8
+ pendingHooks: Array<() => Promise<void>>;
9
+ };
10
+
6
11
  const ensureAsyncStorage = async () => {
7
12
  const betterAuthGlobal = __getBetterAuthGlobal();
8
13
  if (!betterAuthGlobal.context.adapterAsyncStorage) {
@@ -10,7 +15,7 @@ const ensureAsyncStorage = async () => {
10
15
  betterAuthGlobal.context.adapterAsyncStorage = new AsyncLocalStorage();
11
16
  }
12
17
  return betterAuthGlobal.context
13
- .adapterAsyncStorage as AsyncLocalStorage<DBTransactionAdapter>;
18
+ .adapterAsyncStorage as AsyncLocalStorage<HookContext>;
14
19
  };
15
20
 
16
21
  /**
@@ -27,7 +32,8 @@ export const getCurrentAdapter = async (
27
32
  ): Promise<DBTransactionAdapter> => {
28
33
  return ensureAsyncStorage()
29
34
  .then((als) => {
30
- return als.getStore() || fallback;
35
+ const store = als.getStore();
36
+ return store?.adapter || fallback;
31
37
  })
32
38
  .catch(() => {
33
39
  return fallback;
@@ -38,11 +44,28 @@ export const runWithAdapter = async <R>(
38
44
  adapter: DBAdapter,
39
45
  fn: () => R,
40
46
  ): Promise<R> => {
41
- let called = true;
47
+ let called = false;
42
48
  return ensureAsyncStorage()
43
- .then((als) => {
49
+ .then(async (als) => {
44
50
  called = true;
45
- return als.run(adapter, fn);
51
+ const pendingHooks: Array<() => Promise<void>> = [];
52
+ let result: Awaited<R>;
53
+ let error: unknown;
54
+ let hasError = false;
55
+ try {
56
+ result = await als.run({ adapter, pendingHooks }, fn);
57
+ } catch (err) {
58
+ error = err;
59
+ hasError = true;
60
+ }
61
+ // Execute pending hooks after the function completes (even if it threw)
62
+ for (const hook of pendingHooks) {
63
+ await hook();
64
+ }
65
+ if (hasError) {
66
+ throw error;
67
+ }
68
+ return result!;
46
69
  })
47
70
  .catch((err) => {
48
71
  if (!called) {
@@ -58,11 +81,27 @@ export const runWithTransaction = async <R>(
58
81
  ): Promise<R> => {
59
82
  let called = true;
60
83
  return ensureAsyncStorage()
61
- .then((als) => {
84
+ .then(async (als) => {
62
85
  called = true;
63
- return adapter.transaction(async (trx) => {
64
- return als.run(trx, fn);
65
- });
86
+ const pendingHooks: Array<() => Promise<void>> = [];
87
+ let result: Awaited<R>;
88
+ let error: unknown;
89
+ let hasError = false;
90
+ try {
91
+ result = await adapter.transaction(async (trx) => {
92
+ return als.run({ adapter: trx, pendingHooks }, fn);
93
+ });
94
+ } catch (e) {
95
+ hasError = true;
96
+ error = e;
97
+ }
98
+ for (const hook of pendingHooks) {
99
+ await hook();
100
+ }
101
+ if (hasError) {
102
+ throw error;
103
+ }
104
+ return result!;
66
105
  })
67
106
  .catch((err) => {
68
107
  if (!called) {
@@ -71,3 +110,27 @@ export const runWithTransaction = async <R>(
71
110
  throw err;
72
111
  });
73
112
  };
113
+
114
+ /**
115
+ * Queue a hook to be executed after the current transaction commits.
116
+ * If not in a transaction, the hook will execute immediately.
117
+ */
118
+ export const queueAfterTransactionHook = async (
119
+ hook: () => Promise<void>,
120
+ ): Promise<void> => {
121
+ return ensureAsyncStorage()
122
+ .then((als) => {
123
+ const store = als.getStore();
124
+ if (store) {
125
+ // We're in a transaction context, queue the hook
126
+ store.pendingHooks.push(hook);
127
+ } else {
128
+ // Not in a transaction, execute immediately
129
+ return hook();
130
+ }
131
+ })
132
+ .catch(() => {
133
+ // No async storage available, execute immediately
134
+ return hook();
135
+ });
136
+ };