@better-auth/core 1.7.1 → 1.7.3

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.
Files changed (96) hide show
  1. package/dist/api/index.d.mts +3 -0
  2. package/dist/context/endpoint-context.d.mts +19 -5
  3. package/dist/context/endpoint-context.mjs +35 -16
  4. package/dist/context/global.mjs +5 -2
  5. package/dist/context/index.d.mts +2 -2
  6. package/dist/context/index.mjs +2 -2
  7. package/dist/context/transaction.mjs +3 -0
  8. package/dist/db/adapter/atomic-fallback.mjs +134 -0
  9. package/dist/db/adapter/factory.mjs +22 -4
  10. package/dist/db/adapter/index.d.mts +15 -11
  11. package/dist/db/get-tables.mjs +1 -9
  12. package/dist/db/index.d.mts +2 -2
  13. package/dist/db/index.mjs +2 -2
  14. package/dist/db/internal.d.mts +3 -1
  15. package/dist/db/internal.mjs +3 -1
  16. package/dist/db/schema/account.d.mts +2 -13
  17. package/dist/db/schema/account.mjs +1 -19
  18. package/dist/db/schema-check.d.mts +48 -0
  19. package/dist/db/schema-check.mjs +80 -0
  20. package/dist/db/schema-diff.d.mts +104 -0
  21. package/dist/db/schema-diff.mjs +154 -0
  22. package/dist/env/logger.mjs +16 -1
  23. package/dist/instrumentation/tracer.mjs +1 -1
  24. package/dist/oauth2/index.d.mts +2 -2
  25. package/dist/oauth2/oauth-provider.d.mts +0 -10
  26. package/dist/oauth2/token-endpoint-auth.d.mts +26 -2
  27. package/dist/oauth2/token-endpoint-auth.mjs +11 -0
  28. package/dist/social-providers/apple.d.mts +0 -1
  29. package/dist/social-providers/apple.mjs +0 -1
  30. package/dist/social-providers/cloudflare.d.mts +132 -0
  31. package/dist/social-providers/cloudflare.mjs +85 -0
  32. package/dist/social-providers/cognito.d.mts +0 -1
  33. package/dist/social-providers/cognito.mjs +0 -1
  34. package/dist/social-providers/facebook.d.mts +0 -1
  35. package/dist/social-providers/facebook.mjs +0 -1
  36. package/dist/social-providers/google.d.mts +0 -1
  37. package/dist/social-providers/google.mjs +0 -1
  38. package/dist/social-providers/index.d.mts +53 -21
  39. package/dist/social-providers/index.mjs +3 -1
  40. package/dist/social-providers/line.d.mts +0 -1
  41. package/dist/social-providers/line.mjs +0 -1
  42. package/dist/social-providers/microsoft-entra-id.d.mts +0 -3
  43. package/dist/social-providers/microsoft-entra-id.mjs +0 -1
  44. package/dist/social-providers/paybin.d.mts +0 -1
  45. package/dist/social-providers/paybin.mjs +0 -1
  46. package/dist/social-providers/paypal.d.mts +3 -11
  47. package/dist/social-providers/paypal.mjs +20 -47
  48. package/dist/social-providers/reddit.mjs +22 -23
  49. package/dist/social-providers/roblox.mjs +5 -1
  50. package/dist/social-providers/tiktok.d.mts +1 -0
  51. package/dist/social-providers/tiktok.mjs +19 -10
  52. package/dist/social-providers/twitter.mjs +5 -1
  53. package/dist/social-providers/wechat.mjs +6 -1
  54. package/dist/types/context.d.mts +11 -0
  55. package/dist/types/init-options.d.mts +11 -0
  56. package/dist/utils/ip.mjs +11 -9
  57. package/dist/utils/url.d.mts +10 -1
  58. package/dist/utils/url.mjs +21 -1
  59. package/package.json +3 -3
  60. package/src/context/endpoint-context.ts +46 -21
  61. package/src/context/global.ts +7 -0
  62. package/src/context/index.ts +2 -0
  63. package/src/context/transaction.ts +5 -0
  64. package/src/db/adapter/atomic-fallback.ts +237 -0
  65. package/src/db/adapter/factory.ts +33 -17
  66. package/src/db/adapter/index.ts +15 -11
  67. package/src/db/get-tables.ts +1 -14
  68. package/src/db/index.ts +0 -2
  69. package/src/db/internal.ts +19 -0
  70. package/src/db/schema/account.ts +3 -22
  71. package/src/db/schema/user.ts +1 -1
  72. package/src/db/schema-check.ts +107 -0
  73. package/src/db/schema-diff.ts +270 -0
  74. package/src/env/logger.ts +22 -1
  75. package/src/oauth2/index.ts +2 -0
  76. package/src/oauth2/oauth-provider.ts +0 -10
  77. package/src/oauth2/token-endpoint-auth.ts +39 -6
  78. package/src/social-providers/apple.ts +0 -1
  79. package/src/social-providers/cloudflare.ts +221 -0
  80. package/src/social-providers/cognito.ts +0 -1
  81. package/src/social-providers/facebook.ts +0 -1
  82. package/src/social-providers/google.ts +0 -1
  83. package/src/social-providers/index.ts +3 -0
  84. package/src/social-providers/line.ts +0 -1
  85. package/src/social-providers/microsoft-entra-id.ts +0 -1
  86. package/src/social-providers/paybin.ts +0 -1
  87. package/src/social-providers/paypal.ts +30 -71
  88. package/src/social-providers/reddit.ts +34 -37
  89. package/src/social-providers/roblox.ts +5 -3
  90. package/src/social-providers/tiktok.ts +25 -14
  91. package/src/social-providers/twitter.ts +8 -2
  92. package/src/social-providers/wechat.ts +6 -6
  93. package/src/types/context.ts +11 -0
  94. package/src/types/init-options.ts +11 -0
  95. package/src/utils/ip.ts +13 -9
  96. package/src/utils/url.ts +43 -0
@@ -1,10 +1,18 @@
1
1
  import { RESERVED_AUTHORIZATION_PARAMS_SET } from "../oauth2/create-authorization-url.mjs";
2
2
  import { refreshAccessToken } from "../oauth2/refresh-access-token.mjs";
3
3
  import { validateAuthorizationCode } from "../oauth2/validate-authorization-code.mjs";
4
+ import { createPlaceholderEmail } from "../utils/email.mjs";
4
5
  import { betterFetch } from "@better-fetch/fetch";
5
6
  //#region src/social-providers/tiktok.ts
6
7
  const tiktok = (options) => {
7
8
  const tokenEndpoint = "https://open.tiktokapis.com/v2/oauth/token/";
9
+ const tokenEndpointAuth = {
10
+ method: "custom",
11
+ customizeRequest({ body }) {
12
+ body.set("client_key", options.clientKey);
13
+ body.set("client_secret", options.clientSecret);
14
+ }
15
+ };
8
16
  return {
9
17
  id: "tiktok",
10
18
  name: "TikTok",
@@ -26,24 +34,22 @@ const tiktok = (options) => {
26
34
  }
27
35
  return url;
28
36
  },
29
- validateAuthorizationCode: async ({ code, redirectURI }) => {
37
+ validateAuthorizationCode: async ({ code, codeVerifier, redirectURI }) => {
30
38
  return validateAuthorizationCode({
31
39
  code,
40
+ codeVerifier,
32
41
  redirectURI: options.redirectURI || redirectURI,
33
- options: {
34
- clientKey: options.clientKey,
35
- clientSecret: options.clientSecret
36
- },
37
- tokenEndpoint
42
+ options: {},
43
+ tokenEndpoint,
44
+ tokenEndpointAuth
38
45
  });
39
46
  },
40
47
  refreshAccessToken: options.refreshAccessToken ? options.refreshAccessToken : async (refreshToken) => {
41
48
  return refreshAccessToken({
42
49
  refreshToken,
43
- options: { clientSecret: options.clientSecret },
50
+ options: {},
44
51
  tokenEndpoint,
45
- authentication: "post",
46
- extraParams: { client_key: options.clientKey }
52
+ tokenEndpointAuth
47
53
  });
48
54
  },
49
55
  async getUserInfo(token) {
@@ -57,7 +63,10 @@ const tiktok = (options) => {
57
63
  if (error) return null;
58
64
  return {
59
65
  user: {
60
- email: profile.data.user.email || profile.data.user.username,
66
+ email: profile.data.user.email || createPlaceholderEmail({
67
+ identifier: profile.data.user.open_id,
68
+ namespace: "tiktok"
69
+ }),
61
70
  name: profile.data.user.display_name || profile.data.user.username || "",
62
71
  image: profile.data.user.avatar_large_url,
63
72
  emailVerified: false
@@ -1,6 +1,7 @@
1
1
  import { createAuthorizationURL } from "../oauth2/create-authorization-url.mjs";
2
2
  import { refreshAccessToken } from "../oauth2/refresh-access-token.mjs";
3
3
  import { validateAuthorizationCode } from "../oauth2/validate-authorization-code.mjs";
4
+ import { createPlaceholderEmail } from "../utils/email.mjs";
4
5
  import { betterFetch } from "@better-fetch/fetch";
5
6
  //#region src/social-providers/twitter.ts
6
7
  const twitter = (options) => {
@@ -71,7 +72,10 @@ const twitter = (options) => {
71
72
  return {
72
73
  user: {
73
74
  name: profile.data.name,
74
- email: profile.data.email || profile.data.username || null,
75
+ email: profile.data.email || createPlaceholderEmail({
76
+ identifier: profile.data.id,
77
+ namespace: "twitter"
78
+ }),
75
79
  image: profile.data.profile_image_url,
76
80
  emailVerified,
77
81
  ...userMap
@@ -1,4 +1,5 @@
1
1
  import { RESERVED_AUTHORIZATION_PARAMS_SET } from "../oauth2/create-authorization-url.mjs";
2
+ import { createPlaceholderEmail } from "../utils/email.mjs";
2
3
  import { betterFetch } from "@better-fetch/fetch";
3
4
  //#region src/social-providers/wechat.ts
4
5
  const wechat = (options) => {
@@ -69,10 +70,14 @@ const wechat = (options) => {
69
70
  }).toString(), { method: "GET" });
70
71
  if (error || !profile || profile.errcode) return null;
71
72
  const userMap = await options.mapProfileToUser?.(profile);
73
+ const userId = profile.unionid || profile.openid || openid;
72
74
  return {
73
75
  user: {
74
76
  name: profile.nickname,
75
- email: profile.email || `${profile.unionid || profile.openid || openid}@wechat.invalid`,
77
+ email: profile.email || createPlaceholderEmail({
78
+ identifier: userId,
79
+ namespace: "wechat"
80
+ }),
76
81
  image: profile.headimgurl,
77
82
  emailVerified: false,
78
83
  ...userMap
@@ -8,6 +8,7 @@ import { Awaitable, LiteralString } from "./helper.mjs";
8
8
  import { BetterAuthPlugin } from "./plugin.mjs";
9
9
  import { BetterAuthOptions, BetterAuthRateLimitOptions, UserProvisioningSource } from "./init-options.mjs";
10
10
  import { Account, AccountKey } from "../db/schema/account.mjs";
11
+ import { SchemaCheck } from "../db/schema-check.mjs";
11
12
  import { BetterAuthCookie, BetterAuthCookies, CookieCachePayload } from "./cookie.mjs";
12
13
  import { SecretConfig } from "./secret.mjs";
13
14
  import { OAuthProvider } from "../oauth2/oauth-provider.mjs";
@@ -263,6 +264,16 @@ type AuthContext<Options extends BetterAuthOptions = BetterAuthOptions> = Plugin
263
264
  storage: "memory" | "database" | "secondary-storage";
264
265
  } & Omit<BetterAuthRateLimitOptions, "enabled" | "window" | "max" | "storage">;
265
266
  adapter: DBAdapter<Options>;
267
+ /**
268
+ * Confirms the database can hold what this configuration writes.
269
+ *
270
+ * Shared by initialization and requests for this adapter instance;
271
+ * returns nothing once the schema is known to be clean.
272
+ * Context construction does not await the verdict, so migration
273
+ * tooling can still use a context whose schema needs repair.
274
+ * Absent when the check is disabled or the adapter registers none.
275
+ */
276
+ checkSchema?: SchemaCheck | undefined;
266
277
  internalAdapter: InternalAdapter<Options>;
267
278
  createAuthCookie: CreateCookieGetterFn;
268
279
  secret: string;
@@ -387,6 +387,17 @@ type BetterAuthAdvancedOptions = {
387
387
  * @default false
388
388
  */
389
389
  joins?: boolean;
390
+ /**
391
+ * Validate the schema during initialization and report problems
392
+ * through the configured logger. Authentication requests await
393
+ * the same check and fail when the schema does not match.
394
+ * Kysely introspects the database; Drizzle and Prisma inspect
395
+ * local schema metadata without opening a connection.
396
+ * Set `false` to disable runtime schema validation.
397
+ *
398
+ * @default true
399
+ */
400
+ validateSchema?: boolean;
390
401
  } | undefined;
391
402
  /**
392
403
  * Trusted proxy headers
package/dist/utils/ip.mjs CHANGED
@@ -1,17 +1,19 @@
1
1
  import { isDevelopment, isTest } from "../env/env-impl.mjs";
2
2
  import * as z from "zod";
3
3
  //#region src/utils/ip.ts
4
+ const ipv4Schema = z.ipv4();
5
+ const ipv6Schema = z.ipv6();
4
6
  /**
5
7
  * Checks if an IP is valid IPv4 or IPv6
6
8
  */
7
9
  function isValidIP(ip) {
8
- return z.ipv4().safeParse(ip).success || z.ipv6().safeParse(ip).success;
10
+ return isIPv4(ip) || isIPv6(ip);
11
+ }
12
+ function isIPv4(ip) {
13
+ return z.validate(ipv4Schema, ip);
9
14
  }
10
- /**
11
- * Checks if an IP is IPv6
12
- */
13
15
  function isIPv6(ip) {
14
- return z.ipv6().safeParse(ip).success;
16
+ return z.validate(ipv6Schema, ip);
15
17
  }
16
18
  /**
17
19
  * Converts IPv4-mapped IPv6 address to IPv4
@@ -21,12 +23,12 @@ function extractIPv4FromMapped(ipv6) {
21
23
  const lower = ipv6.toLowerCase();
22
24
  if (lower.startsWith("::ffff:")) {
23
25
  const ipv4Part = lower.substring(7);
24
- if (z.ipv4().safeParse(ipv4Part).success) return ipv4Part;
26
+ if (isIPv4(ipv4Part)) return ipv4Part;
25
27
  }
26
28
  const parts = ipv6.split(":");
27
29
  if (parts.length === 7 && parts[5]?.toLowerCase() === "ffff") {
28
30
  const ipv4Part = parts[6];
29
- if (ipv4Part && z.ipv4().safeParse(ipv4Part).success) return ipv4Part;
31
+ if (ipv4Part && isIPv4(ipv4Part)) return ipv4Part;
30
32
  }
31
33
  if (lower.includes("::ffff:") || lower.includes(":ffff:")) {
32
34
  const groups = expandIPv6(ipv6);
@@ -96,7 +98,7 @@ function normalizeIPv6(ipv6, subnetPrefix) {
96
98
  * // -> "2001:0db8:0000:0000:0000:0000:0000:0000" (subnet /64)
97
99
  */
98
100
  function normalizeIP(ip, options = {}) {
99
- if (z.ipv4().safeParse(ip).success) return ip.toLowerCase();
101
+ if (isIPv4(ip)) return ip.toLowerCase();
100
102
  if (!isIPv6(ip)) return ip.toLowerCase();
101
103
  const ipv4 = extractIPv4FromMapped(ip);
102
104
  if (ipv4) return ipv4.toLowerCase();
@@ -106,7 +108,7 @@ function normalizeIP(ip, options = {}) {
106
108
  * Raw bytes of an IP for CIDR comparison. Returns `null` for an invalid IP.
107
109
  */
108
110
  function ipToBytes(ip) {
109
- if (z.ipv4().safeParse(ip).success) return Uint8Array.from(ip.split(".").map((octet) => Number(octet)));
111
+ if (isIPv4(ip)) return Uint8Array.from(ip.split(".").map((octet) => Number(octet)));
110
112
  if (!isIPv6(ip)) return null;
111
113
  const mapped = extractIPv4FromMapped(ip);
112
114
  if (mapped) return Uint8Array.from(mapped.split(".").map((octet) => Number(octet)));
@@ -16,6 +16,15 @@
16
16
  * // Returns: "/sso/saml2/callback/provider1"
17
17
  */
18
18
  declare function normalizePathname(requestUrl: string, basePath: string): string;
19
+ /**
20
+ * Appends query parameters before the fragment of an absolute or root-relative URL.
21
+ * Existing query text is retained without parsing it into name-value pairs.
22
+ *
23
+ * This function only composes URLs. Callers must validate untrusted input.
24
+ *
25
+ * @throws TypeError if parsing fails or a relative input changes authority.
26
+ */
27
+ declare function appendQueryParams(input: string, params: URLSearchParams): string;
19
28
  /**
20
29
  * Schemes that execute or embed code when navigated to or accepted as a
21
30
  * redirect target. These are never safe as an OAuth `redirect_uri` or as a
@@ -34,4 +43,4 @@ declare const DANGEROUS_URL_SCHEMES: string[];
34
43
  */
35
44
  declare function isSafeUrlScheme(value: string): boolean;
36
45
  //#endregion
37
- export { DANGEROUS_URL_SCHEMES, isSafeUrlScheme, normalizePathname };
46
+ export { DANGEROUS_URL_SCHEMES, appendQueryParams, isSafeUrlScheme, normalizePathname };
@@ -28,6 +28,26 @@ function normalizePathname(requestUrl, basePath) {
28
28
  if (pathname.startsWith(normalizedBasePath + "/")) return pathname.slice(normalizedBasePath.length).replace(/\/+$/, "") || "/";
29
29
  return pathname;
30
30
  }
31
+ const URL_REFERENCE_ORIGIN = "https://better-auth.invalid";
32
+ /**
33
+ * Appends query parameters before the fragment of an absolute or root-relative URL.
34
+ * Existing query text is retained without parsing it into name-value pairs.
35
+ *
36
+ * This function only composes URLs. Callers must validate untrusted input.
37
+ *
38
+ * @throws TypeError if parsing fails or a relative input changes authority.
39
+ */
40
+ function appendQueryParams(input, params) {
41
+ const relative = input.startsWith("/");
42
+ if (input.startsWith("//") || input.startsWith("/\\")) throw new TypeError("Expected an absolute or root-relative URL");
43
+ const parsedURL = relative ? new URL(input, URL_REFERENCE_ORIGIN) : new URL(input);
44
+ if (relative && parsedURL.origin !== URL_REFERENCE_ORIGIN) throw new TypeError("Expected an absolute or root-relative URL");
45
+ const query = params.toString();
46
+ if (!query) return input;
47
+ const separator = parsedURL.search.endsWith("&") ? "" : "&";
48
+ parsedURL.search = parsedURL.search ? `${parsedURL.search}${separator}${query}` : query;
49
+ return relative ? parsedURL.href.slice(parsedURL.origin.length) : parsedURL.href;
50
+ }
31
51
  /**
32
52
  * Schemes that execute or embed code when navigated to or accepted as a
33
53
  * redirect target. These are never safe as an OAuth `redirect_uri` or as a
@@ -58,4 +78,4 @@ function isSafeUrlScheme(value) {
58
78
  return !DANGEROUS_URL_SCHEMES.includes(parsed.protocol);
59
79
  }
60
80
  //#endregion
61
- export { DANGEROUS_URL_SCHEMES, isSafeUrlScheme, normalizePathname };
81
+ export { DANGEROUS_URL_SCHEMES, appendQueryParams, isSafeUrlScheme, normalizePathname };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@better-auth/core",
3
- "version": "1.7.1",
3
+ "version": "1.7.3",
4
4
  "description": "The most comprehensive authentication framework for TypeScript.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -45,8 +45,8 @@
45
45
  "node": "./dist/async_hooks/index.mjs",
46
46
  "deno": "./dist/async_hooks/index.mjs",
47
47
  "bun": "./dist/async_hooks/index.mjs",
48
- "edge": "./dist/async_hooks/pure.index.mjs",
49
48
  "workerd": "./dist/async_hooks/index.mjs",
49
+ "edge": "./dist/async_hooks/pure.index.mjs",
50
50
  "browser": "./dist/async_hooks/pure.index.mjs",
51
51
  "default": "./dist/async_hooks/index.mjs"
52
52
  },
@@ -157,7 +157,7 @@
157
157
  "dependencies": {
158
158
  "@opentelemetry/semantic-conventions": "^1.41.1",
159
159
  "@standard-schema/spec": "^1.1.0",
160
- "zod": "^4.3.6"
160
+ "zod": "^4.5.4"
161
161
  },
162
162
  "devDependencies": {
163
163
  "@better-auth/utils": "0.4.2",
@@ -2,7 +2,7 @@ import type { AsyncLocalStorage } from "@better-auth/core/async_hooks";
2
2
  import { getAsyncLocalStorage } from "@better-auth/core/async_hooks";
3
3
  import type { EndpointContext, InputContext } from "better-call";
4
4
  import type { AuthContext } from "../types";
5
- import { __getBetterAuthGlobal } from "./global";
5
+ import { __getBetterAuthGlobal, __getCurrentEndpointContext } from "./global";
6
6
 
7
7
  export type AuthEndpointContext = Partial<
8
8
  InputContext<string, any> & EndpointContext<string, any>
@@ -10,43 +10,68 @@ export type AuthEndpointContext = Partial<
10
10
  context: AuthContext;
11
11
  };
12
12
 
13
- const ensureAsyncStorage = async () => {
14
- const betterAuthGlobal = __getBetterAuthGlobal();
15
- const existing = betterAuthGlobal.context.endpointContextAsyncStorage;
13
+ type AuthEndpointContextStorage = AsyncLocalStorage<AuthEndpointContext>;
14
+
15
+ const getExistingEndpointContextStorage = () => {
16
+ return __getBetterAuthGlobal().context.endpointContextAsyncStorage as
17
+ | AuthEndpointContextStorage
18
+ | undefined;
19
+ };
20
+
21
+ const getOrCreateEndpointContextStorage = async () => {
22
+ const existing = getExistingEndpointContextStorage();
16
23
  if (existing) {
17
- return existing as AsyncLocalStorage<AuthEndpointContext>;
24
+ return existing;
18
25
  }
19
26
  const AsyncLocalStorage = await getAsyncLocalStorage();
20
- betterAuthGlobal.context.endpointContextAsyncStorage ??=
21
- new AsyncLocalStorage<AuthEndpointContext>();
22
- return betterAuthGlobal.context
23
- .endpointContextAsyncStorage as AsyncLocalStorage<AuthEndpointContext>;
27
+ const globalContext = __getBetterAuthGlobal().context;
28
+ const storage = (globalContext.endpointContextAsyncStorage ??=
29
+ new AsyncLocalStorage<AuthEndpointContext>()) as AuthEndpointContextStorage;
30
+ return storage;
24
31
  };
25
32
 
26
33
  /**
27
- * This is for internal use only. Most users should use `getCurrentAuthContext` instead.
28
- *
29
- * It is exposed for advanced use cases where you need direct access to the AsyncLocalStorage instance.
34
+ * @deprecated Use `getCurrentAuthEndpointContext`,
35
+ * `tryGetCurrentAuthEndpointContext`, or `runWithEndpointContext` instead.
30
36
  */
31
37
  export async function getCurrentAuthContextAsyncLocalStorage() {
32
- return ensureAsyncStorage();
38
+ return getOrCreateEndpointContextStorage();
39
+ }
40
+
41
+ /**
42
+ * Returns the current auth endpoint context, or `undefined` when called outside
43
+ * of `runWithEndpointContext`.
44
+ */
45
+ export function tryGetCurrentAuthEndpointContext() {
46
+ return __getCurrentEndpointContext<AuthEndpointContext>();
33
47
  }
34
48
 
35
- export async function getCurrentAuthContext(): Promise<AuthEndpointContext> {
36
- const als = await ensureAsyncStorage();
37
- const context = als.getStore();
38
- if (!context) {
49
+ /**
50
+ * Returns the current auth endpoint context.
51
+ *
52
+ * @throws When called outside of `runWithEndpointContext`.
53
+ */
54
+ export function getCurrentAuthEndpointContext() {
55
+ const authEndpointContext = tryGetCurrentAuthEndpointContext();
56
+ if (!authEndpointContext) {
39
57
  throw new Error(
40
58
  "No auth context found. Please make sure you are calling this function within a `runWithEndpointContext` callback.",
41
59
  );
42
60
  }
43
- return context;
61
+ return authEndpointContext;
62
+ }
63
+
64
+ /**
65
+ * @deprecated Use `getCurrentAuthEndpointContext` instead.
66
+ */
67
+ export async function getCurrentAuthContext() {
68
+ return getCurrentAuthEndpointContext();
44
69
  }
45
70
 
46
71
  export async function runWithEndpointContext<T>(
47
- context: AuthEndpointContext,
72
+ authEndpointContext: AuthEndpointContext,
48
73
  fn: () => T,
49
74
  ): Promise<T> {
50
- const als = await ensureAsyncStorage();
51
- return als.run(context, fn);
75
+ const storage = await getOrCreateEndpointContextStorage();
76
+ return storage.run(authEndpointContext, fn);
52
77
  }
@@ -52,6 +52,13 @@ export function __getBetterAuthGlobal(): BetterAuthGlobal {
52
52
  return (globalThis as any)[symbol] as BetterAuthGlobal;
53
53
  }
54
54
 
55
+ export function __getCurrentEndpointContext<T>(): T | undefined {
56
+ const storage = __getBetterAuthGlobal().context.endpointContextAsyncStorage as
57
+ | AsyncLocalStorage<T>
58
+ | undefined;
59
+ return storage?.getStore();
60
+ }
61
+
55
62
  export function getBetterAuthVersion(): string {
56
63
  return __getBetterAuthGlobal().version;
57
64
  }
@@ -2,7 +2,9 @@ export {
2
2
  type AuthEndpointContext,
3
3
  getCurrentAuthContext,
4
4
  getCurrentAuthContextAsyncLocalStorage,
5
+ getCurrentAuthEndpointContext,
5
6
  runWithEndpointContext,
7
+ tryGetCurrentAuthEndpointContext,
6
8
  } from "./endpoint-context";
7
9
  export { getBetterAuthVersion } from "./global";
8
10
  export {
@@ -1,6 +1,7 @@
1
1
  import type { AsyncLocalStorage } from "@better-auth/core/async_hooks";
2
2
  import { getAsyncLocalStorage } from "@better-auth/core/async_hooks";
3
3
  import type { DBAdapter, DBTransactionAdapter } from "../db/adapter";
4
+ import { schemaCheckFor } from "../db/schema-check";
4
5
  import type { BetterAuthOptions } from "../types";
5
6
  import { __getBetterAuthGlobal } from "./global";
6
7
 
@@ -114,6 +115,10 @@ export const runWithTransaction = async <
114
115
  if (store?.isTransactionActive) {
115
116
  return fn();
116
117
  }
118
+ // Settle the schema verdict before this transaction holds the
119
+ // connection a single-connection store would need for the lookup.
120
+ const pendingSchemaCheck = schemaCheckFor(adapter)?.();
121
+ if (pendingSchemaCheck) await pendingSchemaCheck;
117
122
  const pendingHooks: Array<() => Promise<void>> = [];
118
123
  let result: Awaited<R>;
119
124
  let error: unknown;
@@ -0,0 +1,237 @@
1
+ import * as z from "zod";
2
+ import { BetterAuthError } from "../../error";
3
+ import type { CleanedWhere, CustomAdapter, Where } from "./index";
4
+
5
+ const MAX_ATTEMPTS = 5;
6
+
7
+ const scalar = z
8
+ .union([z.string(), z.number(), z.boolean(), z.date()])
9
+ .nullable();
10
+ const rowSchema = z.record(z.string(), z.unknown());
11
+ const readSchema = rowSchema.nullish();
12
+ const setSchema = z.record(z.string(), z.unknown()).transform((values) => {
13
+ const assignments: z.output<typeof rowSchema> = {};
14
+ for (const [field, value] of Object.entries(values)) {
15
+ if (value !== undefined) assignments[field] = value;
16
+ }
17
+ return assignments;
18
+ });
19
+ const mutationSchema = z.object({
20
+ increment: z.record(z.string(), z.number()),
21
+ set: setSchema.optional(),
22
+ });
23
+ const counterSchema = z.number().nullish();
24
+
25
+ type StoredRow = z.output<typeof rowSchema>;
26
+
27
+ type FallbackContext = {
28
+ adapter: CustomAdapter;
29
+ adapterId: string;
30
+ mapKeysTransformInput?: Record<string, string> | undefined;
31
+ mapKeysTransformOutput?: Record<string, string> | undefined;
32
+ getFieldName: (input: { model: string; field: string }) => string;
33
+ transformOutput: (
34
+ row: StoredRow,
35
+ model: string,
36
+ select: string[],
37
+ join: undefined,
38
+ ) => Promise<{ id?: unknown } | null>;
39
+ transformWhereClause: (input: {
40
+ model: string;
41
+ where: Where[];
42
+ action: "consumeOne" | "incrementOne";
43
+ }) => CleanedWhere[];
44
+ };
45
+
46
+ type FallbackRequest = {
47
+ model: string;
48
+ logicalModel: string;
49
+ where: CleanedWhere[];
50
+ };
51
+
52
+ // Read a snapshot, then conditionally mutate it. A write succeeds only when
53
+ // an adapter checks and mutates atomically and reports exactly one affected row.
54
+ export function createAtomicFallbacks(context: FallbackContext) {
55
+ const { adapter, adapterId } = context;
56
+ const outputId =
57
+ Object.entries(context.mapKeysTransformOutput ?? {}).find(
58
+ ([, field]) => field === "id",
59
+ )?.[0] ?? "id";
60
+ async function idWhere(
61
+ row: StoredRow,
62
+ model: string,
63
+ action: "consumeOne" | "incrementOne",
64
+ ): Promise<CleanedWhere> {
65
+ const mappedId =
66
+ context.mapKeysTransformInput?.id ||
67
+ context.getFieldName({ model, field: "id" });
68
+ if (row[mappedId] === undefined || row[mappedId] === null) {
69
+ throw new BetterAuthError(
70
+ `Adapter "${context.adapterId}" must return the row id for atomic fallbacks.`,
71
+ );
72
+ }
73
+ const output = await context.transformOutput(
74
+ row,
75
+ model,
76
+ [outputId],
77
+ undefined,
78
+ );
79
+ const id = output?.id;
80
+ if (typeof id !== "string" && typeof id !== "number") {
81
+ throw new BetterAuthError(
82
+ `Adapter "${context.adapterId}" must expose a logical string or number id through its output transform.`,
83
+ );
84
+ }
85
+ const [condition] = context.transformWhereClause({
86
+ model,
87
+ where: [{ field: "id", value: id }],
88
+ action,
89
+ });
90
+ if (!condition)
91
+ throw new BetterAuthError(
92
+ "The atomic fallback id condition was transformed away.",
93
+ );
94
+ return condition;
95
+ }
96
+ async function readRow({
97
+ model,
98
+ where,
99
+ }: FallbackRequest): Promise<StoredRow | null> {
100
+ const result = readSchema.safeParse(
101
+ await adapter.findOne<unknown>({ model, where }),
102
+ );
103
+ if (!result.success) {
104
+ throw new BetterAuthError(
105
+ `Adapter "${adapterId}" must return a row snapshot or null.`,
106
+ );
107
+ }
108
+ return result.data ?? null;
109
+ }
110
+
111
+ async function snapshotGuard(
112
+ row: StoredRow,
113
+ fields: readonly string[],
114
+ request: FallbackRequest,
115
+ action: "consumeOne" | "incrementOne",
116
+ ): Promise<CleanedWhere[]> {
117
+ const id = await idWhere(row, request.logicalModel, action);
118
+ const hasOr = request.where.some((clause) => clause.connector === "OR");
119
+ const guard: CleanedWhere[] = hasOr ? [id] : [...request.where, id];
120
+ const keys = new Set(fields);
121
+ for (const field of keys) {
122
+ if (field === id.field) continue;
123
+ const value = scalar.safeParse(row[field] ?? null);
124
+ if (!value.success) {
125
+ if (hasOr && request.where.some((clause) => clause.field === field)) {
126
+ throw new BetterAuthError(
127
+ `Adapter "${adapterId}" must implement native atomic methods for OR predicates on structured values.`,
128
+ );
129
+ }
130
+ continue;
131
+ }
132
+ guard.push({
133
+ field,
134
+ value: value.data,
135
+ operator: "eq",
136
+ connector: "AND",
137
+ mode: "sensitive",
138
+ });
139
+ }
140
+ return guard;
141
+ }
142
+
143
+ function changedOne(count: number): boolean {
144
+ if (count !== 0 && count !== 1) {
145
+ throw new BetterAuthError(
146
+ `Adapter "${adapterId}" must return an affected row count of 0 or 1 from an atomic fallback.`,
147
+ );
148
+ }
149
+ return count === 1;
150
+ }
151
+
152
+ async function consumeOne(
153
+ request: FallbackRequest,
154
+ ): Promise<StoredRow | null> {
155
+ const { model, where } = request;
156
+ const row = await readRow(request);
157
+ if (row === null) return null;
158
+ // Guard the selected snapshot with AND predicates, without widening an OR selector.
159
+ const guard = await snapshotGuard(
160
+ row,
161
+ [...Object.keys(row), ...where.map(({ field }) => field)],
162
+ request,
163
+ "consumeOne",
164
+ );
165
+ const count = await adapter.deleteMany({ model, where: guard });
166
+ return changedOne(count) ? row : null;
167
+ }
168
+
169
+ async function incrementOne(
170
+ request: FallbackRequest & {
171
+ increment: Record<string, number>;
172
+ set?: Record<string, unknown> | undefined;
173
+ },
174
+ ): Promise<StoredRow | null> {
175
+ const { model, where } = request;
176
+ const mutation = mutationSchema.safeParse(request);
177
+ if (!mutation.success) {
178
+ throw new BetterAuthError(
179
+ "incrementOne requires finite increments and a set object for the atomic fallback.",
180
+ );
181
+ }
182
+ const { increment, set } = mutation.data;
183
+ const deltas = Object.entries(increment);
184
+ const fields = [
185
+ ...where.map(({ field }) => field),
186
+ ...Object.keys(increment),
187
+ ...Object.keys(set ?? {}),
188
+ ];
189
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
190
+ const row = await readRow(request);
191
+ if (row === null) return null;
192
+ const update: z.output<typeof setSchema> = { ...set };
193
+ for (const [field, delta] of deltas) {
194
+ const previous = counterSchema.safeParse(row[field]);
195
+ if (!previous.success) {
196
+ throw new BetterAuthError(
197
+ `Adapter "${adapterId}" must return finite numeric counter values or null for atomic increments.`,
198
+ );
199
+ }
200
+ const current = previous.data ?? 0;
201
+ const next = current + delta;
202
+ if (!Number.isFinite(next) || (delta !== 0 && next === current)) {
203
+ throw new BetterAuthError(
204
+ `Adapter "${adapterId}" cannot represent the requested counter increment safely.`,
205
+ );
206
+ }
207
+ update[field] = next;
208
+ }
209
+ const guard = await snapshotGuard(row, fields, request, "incrementOne");
210
+ // A no-op takes effect at the read. Stores counting changed rows would report zero.
211
+ if (
212
+ Object.entries(update).every(([field, value]) => {
213
+ const previous = row[field];
214
+ if (previous instanceof Date && value instanceof Date) {
215
+ return previous.getTime() === value.getTime();
216
+ }
217
+ return Object.is(previous, value);
218
+ })
219
+ )
220
+ return row;
221
+ const count = await adapter.updateMany({
222
+ model,
223
+ where: guard,
224
+ update,
225
+ });
226
+ if (changedOne(count)) {
227
+ // A second read could observe another writer's result instead of ours.
228
+ return { ...row, ...update };
229
+ }
230
+ }
231
+ throw new BetterAuthError(
232
+ `Adapter "${adapterId}" could not complete an atomic increment due to contention. Retry the operation or implement incrementOne natively.`,
233
+ );
234
+ }
235
+
236
+ return { consumeOne, incrementOne };
237
+ }