@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.
- package/dist/api/index.d.mts +3 -0
- package/dist/context/endpoint-context.d.mts +19 -5
- package/dist/context/endpoint-context.mjs +35 -16
- package/dist/context/global.mjs +5 -2
- package/dist/context/index.d.mts +2 -2
- package/dist/context/index.mjs +2 -2
- package/dist/context/transaction.mjs +3 -0
- package/dist/db/adapter/atomic-fallback.mjs +134 -0
- package/dist/db/adapter/factory.mjs +22 -4
- package/dist/db/adapter/index.d.mts +15 -11
- package/dist/db/get-tables.mjs +1 -9
- package/dist/db/index.d.mts +2 -2
- package/dist/db/index.mjs +2 -2
- package/dist/db/internal.d.mts +3 -1
- package/dist/db/internal.mjs +3 -1
- package/dist/db/schema/account.d.mts +2 -13
- package/dist/db/schema/account.mjs +1 -19
- package/dist/db/schema-check.d.mts +48 -0
- package/dist/db/schema-check.mjs +80 -0
- package/dist/db/schema-diff.d.mts +104 -0
- package/dist/db/schema-diff.mjs +154 -0
- package/dist/env/logger.mjs +16 -1
- package/dist/instrumentation/tracer.mjs +1 -1
- package/dist/oauth2/index.d.mts +2 -2
- package/dist/oauth2/oauth-provider.d.mts +0 -10
- package/dist/oauth2/token-endpoint-auth.d.mts +26 -2
- package/dist/oauth2/token-endpoint-auth.mjs +11 -0
- package/dist/social-providers/apple.d.mts +0 -1
- package/dist/social-providers/apple.mjs +0 -1
- package/dist/social-providers/cloudflare.d.mts +132 -0
- package/dist/social-providers/cloudflare.mjs +85 -0
- package/dist/social-providers/cognito.d.mts +0 -1
- package/dist/social-providers/cognito.mjs +0 -1
- package/dist/social-providers/facebook.d.mts +0 -1
- package/dist/social-providers/facebook.mjs +0 -1
- package/dist/social-providers/google.d.mts +0 -1
- package/dist/social-providers/google.mjs +0 -1
- package/dist/social-providers/index.d.mts +53 -21
- package/dist/social-providers/index.mjs +3 -1
- package/dist/social-providers/line.d.mts +0 -1
- package/dist/social-providers/line.mjs +0 -1
- package/dist/social-providers/microsoft-entra-id.d.mts +0 -3
- package/dist/social-providers/microsoft-entra-id.mjs +0 -1
- package/dist/social-providers/paybin.d.mts +0 -1
- package/dist/social-providers/paybin.mjs +0 -1
- package/dist/social-providers/paypal.d.mts +3 -11
- package/dist/social-providers/paypal.mjs +20 -47
- package/dist/social-providers/reddit.mjs +22 -23
- package/dist/social-providers/roblox.mjs +5 -1
- package/dist/social-providers/tiktok.d.mts +1 -0
- package/dist/social-providers/tiktok.mjs +19 -10
- package/dist/social-providers/twitter.mjs +5 -1
- package/dist/social-providers/wechat.mjs +6 -1
- package/dist/types/context.d.mts +11 -0
- package/dist/types/init-options.d.mts +11 -0
- package/dist/utils/ip.mjs +11 -9
- package/dist/utils/url.d.mts +10 -1
- package/dist/utils/url.mjs +21 -1
- package/package.json +3 -3
- package/src/context/endpoint-context.ts +46 -21
- package/src/context/global.ts +7 -0
- package/src/context/index.ts +2 -0
- package/src/context/transaction.ts +5 -0
- package/src/db/adapter/atomic-fallback.ts +237 -0
- package/src/db/adapter/factory.ts +33 -17
- package/src/db/adapter/index.ts +15 -11
- package/src/db/get-tables.ts +1 -14
- package/src/db/index.ts +0 -2
- package/src/db/internal.ts +19 -0
- package/src/db/schema/account.ts +3 -22
- package/src/db/schema/user.ts +1 -1
- package/src/db/schema-check.ts +107 -0
- package/src/db/schema-diff.ts +270 -0
- package/src/env/logger.ts +22 -1
- package/src/oauth2/index.ts +2 -0
- package/src/oauth2/oauth-provider.ts +0 -10
- package/src/oauth2/token-endpoint-auth.ts +39 -6
- package/src/social-providers/apple.ts +0 -1
- package/src/social-providers/cloudflare.ts +221 -0
- package/src/social-providers/cognito.ts +0 -1
- package/src/social-providers/facebook.ts +0 -1
- package/src/social-providers/google.ts +0 -1
- package/src/social-providers/index.ts +3 -0
- package/src/social-providers/line.ts +0 -1
- package/src/social-providers/microsoft-entra-id.ts +0 -1
- package/src/social-providers/paybin.ts +0 -1
- package/src/social-providers/paypal.ts +30 -71
- package/src/social-providers/reddit.ts +34 -37
- package/src/social-providers/roblox.ts +5 -3
- package/src/social-providers/tiktok.ts +25 -14
- package/src/social-providers/twitter.ts +8 -2
- package/src/social-providers/wechat.ts +6 -6
- package/src/types/context.ts +11 -0
- package/src/types/init-options.ts +11 -0
- package/src/utils/ip.ts +13 -9
- 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
|
-
|
|
35
|
-
|
|
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: {
|
|
50
|
+
options: {},
|
|
44
51
|
tokenEndpoint,
|
|
45
|
-
|
|
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 ||
|
|
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 ||
|
|
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 ||
|
|
77
|
+
email: profile.email || createPlaceholderEmail({
|
|
78
|
+
identifier: userId,
|
|
79
|
+
namespace: "wechat"
|
|
80
|
+
}),
|
|
76
81
|
image: profile.headimgurl,
|
|
77
82
|
emailVerified: false,
|
|
78
83
|
...userMap
|
package/dist/types/context.d.mts
CHANGED
|
@@ -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
|
|
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.
|
|
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 (
|
|
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 &&
|
|
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 (
|
|
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 (
|
|
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)));
|
package/dist/utils/url.d.mts
CHANGED
|
@@ -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 };
|
package/dist/utils/url.mjs
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
24
|
+
return existing;
|
|
18
25
|
}
|
|
19
26
|
const AsyncLocalStorage = await getAsyncLocalStorage();
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
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
|
-
|
|
72
|
+
authEndpointContext: AuthEndpointContext,
|
|
48
73
|
fn: () => T,
|
|
49
74
|
): Promise<T> {
|
|
50
|
-
const
|
|
51
|
-
return
|
|
75
|
+
const storage = await getOrCreateEndpointContextStorage();
|
|
76
|
+
return storage.run(authEndpointContext, fn);
|
|
52
77
|
}
|
package/src/context/global.ts
CHANGED
|
@@ -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
|
}
|
package/src/context/index.ts
CHANGED
|
@@ -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
|
+
}
|