@better-auth/core 1.6.15 → 1.6.17
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/api/index.mjs +36 -0
- package/dist/context/global.mjs +1 -1
- package/dist/db/adapter/factory.mjs +82 -0
- package/dist/db/adapter/index.d.mts +51 -1
- package/dist/db/adapter/types.d.mts +1 -1
- package/dist/db/type.d.mts +15 -0
- package/dist/env/env-impl.mjs +1 -1
- package/dist/instrumentation/tracer.mjs +1 -1
- package/dist/oauth2/verify.d.mts +29 -6
- package/dist/oauth2/verify.mjs +112 -12
- package/dist/social-providers/facebook.mjs +35 -2
- package/dist/social-providers/google.d.mts +6 -1
- package/dist/social-providers/google.mjs +5 -0
- package/dist/social-providers/index.d.mts +2 -2
- package/dist/social-providers/index.mjs +2 -2
- package/dist/social-providers/microsoft-entra-id.d.mts +1 -1
- package/dist/social-providers/microsoft-entra-id.mjs +13 -1
- package/dist/social-providers/paypal.d.mts +2 -1
- package/dist/social-providers/paypal.mjs +38 -4
- package/dist/social-providers/reddit.mjs +4 -3
- package/dist/social-providers/wechat.mjs +1 -1
- package/dist/types/context.d.mts +16 -0
- package/dist/types/init-options.d.mts +29 -0
- package/dist/utils/host.mjs +4 -0
- package/dist/utils/url.mjs +4 -3
- package/package.json +5 -5
- package/src/api/index.ts +45 -0
- package/src/db/adapter/factory.ts +152 -0
- package/src/db/adapter/index.ts +51 -0
- package/src/db/adapter/types.ts +1 -0
- package/src/db/type.ts +15 -0
- package/src/env/env-impl.ts +1 -2
- package/src/oauth2/verify.ts +211 -41
- package/src/social-providers/facebook.ts +75 -2
- package/src/social-providers/google.ts +27 -1
- package/src/social-providers/microsoft-entra-id.ts +40 -1
- package/src/social-providers/paypal.ts +91 -4
- package/src/social-providers/reddit.ts +7 -3
- package/src/social-providers/wechat.ts +8 -1
- package/src/types/context.ts +17 -0
- package/src/types/init-options.ts +26 -0
- package/src/utils/host.ts +15 -0
- package/src/utils/url.ts +10 -4
|
@@ -8,9 +8,17 @@ import { base64 } from "@better-auth/utils/base64";
|
|
|
8
8
|
import { betterFetch } from "@better-fetch/fetch";
|
|
9
9
|
import { decodeJwt, decodeProtectedHeader, importJWK, jwtVerify } from "jose";
|
|
10
10
|
//#region src/social-providers/microsoft-entra-id.ts
|
|
11
|
+
/**
|
|
12
|
+
* Microsoft's fixed tenant id for personal (consumer) Microsoft accounts. Every
|
|
13
|
+
* personal-account token carries it as the `tid` claim, so it distinguishes the
|
|
14
|
+
* consumer account class from work/school tenants.
|
|
15
|
+
* @see https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference
|
|
16
|
+
*/
|
|
17
|
+
const MICROSOFT_CONSUMER_TENANT_ID = "9188040d-6c67-4c5b-b112-36a304b66dad";
|
|
11
18
|
const microsoft = (options) => {
|
|
12
19
|
const tenant = options.tenantId || "common";
|
|
13
|
-
|
|
20
|
+
let authority = options.authority || "https://login.microsoftonline.com";
|
|
21
|
+
while (authority.endsWith("/")) authority = authority.slice(0, -1);
|
|
14
22
|
const authorizationEndpoint = `${authority}/${tenant}/oauth2/v2.0/authorize`;
|
|
15
23
|
const tokenEndpoint = `${authority}/${tenant}/oauth2/v2.0/token`;
|
|
16
24
|
return {
|
|
@@ -70,6 +78,10 @@ const microsoft = (options) => {
|
|
|
70
78
|
if (tenant !== "common" && tenant !== "organizations" && tenant !== "consumers") verifyOptions.issuer = `${authority}/${tenant}/v2.0`;
|
|
71
79
|
const { payload: jwtClaims } = await jwtVerify(token, publicKey, verifyOptions);
|
|
72
80
|
if (nonce && jwtClaims.nonce !== nonce) return false;
|
|
81
|
+
const tid = jwtClaims.tid;
|
|
82
|
+
if (typeof tid !== "string" || jwtClaims.iss !== `${authority}/${tid}/v2.0`) return false;
|
|
83
|
+
if (tenant === "organizations" && tid === MICROSOFT_CONSUMER_TENANT_ID) return false;
|
|
84
|
+
if (tenant === "consumers" && tid !== MICROSOFT_CONSUMER_TENANT_ID) return false;
|
|
73
85
|
return true;
|
|
74
86
|
} catch (error) {
|
|
75
87
|
logger.error("Failed to verify ID token:", error);
|
|
@@ -125,5 +125,6 @@ declare const paypal: (options: PayPalOptions) => {
|
|
|
125
125
|
} | null>;
|
|
126
126
|
options: PayPalOptions;
|
|
127
127
|
};
|
|
128
|
+
declare const getPayPalPublicKey: (kid: string, jwksUri: string) => Promise<Uint8Array<ArrayBufferLike> | CryptoKey>;
|
|
128
129
|
//#endregion
|
|
129
|
-
export { PayPalOptions, PayPalProfile, PayPalTokenResponse, paypal };
|
|
130
|
+
export { PayPalOptions, PayPalProfile, PayPalTokenResponse, getPayPalPublicKey, paypal };
|
|
@@ -1,15 +1,30 @@
|
|
|
1
|
-
import { BetterAuthError } from "../error/index.mjs";
|
|
1
|
+
import { APIError, BetterAuthError } from "../error/index.mjs";
|
|
2
2
|
import { logger } from "../env/logger.mjs";
|
|
3
3
|
import { createAuthorizationURL } from "../oauth2/create-authorization-url.mjs";
|
|
4
4
|
import { base64 } from "@better-auth/utils/base64";
|
|
5
5
|
import { betterFetch } from "@better-fetch/fetch";
|
|
6
|
-
import {
|
|
6
|
+
import { decodeProtectedHeader, importJWK, jwtVerify } from "jose";
|
|
7
7
|
//#region src/social-providers/paypal.ts
|
|
8
|
+
/**
|
|
9
|
+
* ID token signing algorithms advertised by PayPal's OpenID configuration.
|
|
10
|
+
* Anything outside this allowlist is rejected so each token is only ever
|
|
11
|
+
* verified with the algorithm it was issued for.
|
|
12
|
+
*
|
|
13
|
+
* @see https://www.paypal.com/.well-known/openid-configuration
|
|
14
|
+
*/
|
|
15
|
+
const PAYPAL_ID_TOKEN_ALGORITHMS = ["RS256", "HS256"];
|
|
8
16
|
const paypal = (options) => {
|
|
9
17
|
const isSandbox = (options.environment || "sandbox") === "sandbox";
|
|
10
18
|
const authorizationEndpoint = isSandbox ? "https://www.sandbox.paypal.com/signin/authorize" : "https://www.paypal.com/signin/authorize";
|
|
11
19
|
const tokenEndpoint = isSandbox ? "https://api-m.sandbox.paypal.com/v1/oauth2/token" : "https://api-m.paypal.com/v1/oauth2/token";
|
|
12
20
|
const userInfoEndpoint = isSandbox ? "https://api-m.sandbox.paypal.com/v1/identity/oauth2/userinfo" : "https://api-m.paypal.com/v1/identity/oauth2/userinfo";
|
|
21
|
+
/**
|
|
22
|
+
* Issuer and JWKS endpoints used to cryptographically verify ID tokens.
|
|
23
|
+
*
|
|
24
|
+
* @see https://www.paypal.com/.well-known/openid-configuration
|
|
25
|
+
*/
|
|
26
|
+
const issuer = isSandbox ? "https://www.sandbox.paypal.com" : "https://www.paypal.com";
|
|
27
|
+
const jwksEndpoint = isSandbox ? "https://api.sandbox.paypal.com/v1/oauth2/certs" : "https://api.paypal.com/v1/oauth2/certs";
|
|
13
28
|
return {
|
|
14
29
|
id: "paypal",
|
|
15
30
|
name: "PayPal",
|
|
@@ -94,7 +109,19 @@ const paypal = (options) => {
|
|
|
94
109
|
if (options.disableIdTokenSignIn) return false;
|
|
95
110
|
if (options.verifyIdToken) return options.verifyIdToken(token, nonce);
|
|
96
111
|
try {
|
|
97
|
-
|
|
112
|
+
const { kid, alg: jwtAlg } = decodeProtectedHeader(token);
|
|
113
|
+
if (!jwtAlg) return false;
|
|
114
|
+
if (!PAYPAL_ID_TOKEN_ALGORITHMS.includes(jwtAlg)) return false;
|
|
115
|
+
const key = jwtAlg === "HS256" ? new TextEncoder().encode(options.clientSecret) : kid ? await getPayPalPublicKey(kid, jwksEndpoint) : void 0;
|
|
116
|
+
if (!key) return false;
|
|
117
|
+
const { payload: jwtClaims } = await jwtVerify(token, key, {
|
|
118
|
+
algorithms: [jwtAlg],
|
|
119
|
+
issuer,
|
|
120
|
+
audience: options.clientId,
|
|
121
|
+
maxTokenAge: "1h"
|
|
122
|
+
});
|
|
123
|
+
if (nonce && jwtClaims.nonce !== nonce) return false;
|
|
124
|
+
return true;
|
|
98
125
|
} catch (error) {
|
|
99
126
|
logger.error("Failed to verify PayPal ID token:", error);
|
|
100
127
|
return false;
|
|
@@ -136,5 +163,12 @@ const paypal = (options) => {
|
|
|
136
163
|
options
|
|
137
164
|
};
|
|
138
165
|
};
|
|
166
|
+
const getPayPalPublicKey = async (kid, jwksUri) => {
|
|
167
|
+
const { data } = await betterFetch(jwksUri);
|
|
168
|
+
if (!data?.keys) throw new APIError("BAD_REQUEST", { message: "Keys not found" });
|
|
169
|
+
const jwk = data.keys.find((key) => key.kid === kid);
|
|
170
|
+
if (!jwk) throw new Error(`JWK with kid ${kid} not found`);
|
|
171
|
+
return await importJWK(jwk, jwk.alg);
|
|
172
|
+
};
|
|
139
173
|
//#endregion
|
|
140
|
-
export { paypal };
|
|
174
|
+
export { getPayPalPublicKey, paypal };
|
|
@@ -61,14 +61,15 @@ const reddit = (options) => {
|
|
|
61
61
|
} });
|
|
62
62
|
if (error) return null;
|
|
63
63
|
const userMap = await options.mapProfileToUser?.(profile);
|
|
64
|
+
const email = userMap?.email || `${profile.id}@reddit.invalid`;
|
|
64
65
|
return {
|
|
65
66
|
user: {
|
|
66
67
|
id: profile.id,
|
|
67
68
|
name: profile.name,
|
|
68
|
-
email: profile.oauth_client_id,
|
|
69
|
-
emailVerified: profile.has_verified_email,
|
|
70
69
|
image: profile.icon_img?.split("?")[0],
|
|
71
|
-
...userMap
|
|
70
|
+
...userMap,
|
|
71
|
+
email,
|
|
72
|
+
emailVerified: userMap?.emailVerified ?? false
|
|
72
73
|
},
|
|
73
74
|
data: profile
|
|
74
75
|
};
|
|
@@ -66,7 +66,7 @@ const wechat = (options) => {
|
|
|
66
66
|
user: {
|
|
67
67
|
id: profile.unionid || profile.openid || openid,
|
|
68
68
|
name: profile.nickname,
|
|
69
|
-
email: profile.email ||
|
|
69
|
+
email: profile.email || `${profile.unionid || profile.openid || openid}@wechat.invalid`,
|
|
70
70
|
image: profile.headimgurl,
|
|
71
71
|
emailVerified: false,
|
|
72
72
|
...userMap
|
package/dist/types/context.d.mts
CHANGED
|
@@ -134,6 +134,22 @@ interface InternalAdapter<_Options extends BetterAuthOptions = BetterAuthOptions
|
|
|
134
134
|
* pair at single-use credential consumption sites.
|
|
135
135
|
*/
|
|
136
136
|
consumeVerificationValue(identifier: string): Promise<Verification | null>;
|
|
137
|
+
/**
|
|
138
|
+
* First-writer-wins create keyed by a deterministic primary key derived from
|
|
139
|
+
* `identifier`. Returns `true` when this caller created the row and `false`
|
|
140
|
+
* when a row for the same identifier already existed.
|
|
141
|
+
*
|
|
142
|
+
* The dual of `consumeVerificationValue`: reserve races to create a marker
|
|
143
|
+
* exactly once, where consume races to delete one exactly once. Use it for
|
|
144
|
+
* replay tombstones (a SAML assertion id, a JWT `jti`) where the first caller
|
|
145
|
+
* wins. The database path is atomic via the primary key; the
|
|
146
|
+
* secondary-storage-only path is best-effort under concurrency.
|
|
147
|
+
*/
|
|
148
|
+
reserveVerificationValue(data: {
|
|
149
|
+
identifier: string;
|
|
150
|
+
value: string;
|
|
151
|
+
expiresAt: Date;
|
|
152
|
+
}): Promise<boolean>;
|
|
137
153
|
updateVerificationByIdentifier(identifier: string, data: Partial<Verification>): Promise<Verification>;
|
|
138
154
|
refreshUserSessions(user: User): Promise<void>;
|
|
139
155
|
}
|
|
@@ -73,6 +73,35 @@ type BaseURLConfig = string | DynamicBaseURLConfig;
|
|
|
73
73
|
interface BetterAuthRateLimitStorage {
|
|
74
74
|
get: (key: string) => Promise<RateLimit | null | undefined>;
|
|
75
75
|
set: (key: string, value: RateLimit, update?: boolean | undefined) => Promise<void>;
|
|
76
|
+
/**
|
|
77
|
+
* Atomically records one request against `key` within the `window`
|
|
78
|
+
* (in seconds) and reports whether it is allowed.
|
|
79
|
+
*
|
|
80
|
+
* When `allowed` is true the request was counted within the active window;
|
|
81
|
+
* when `allowed` is false the limit was already reached and `retryAfter` is
|
|
82
|
+
* the number of seconds until the window frees up. Whether the window slides
|
|
83
|
+
* or is fixed depends on the backing storage: the database backend resets
|
|
84
|
+
* once the window elapses, while secondary storage uses a fixed time-to-live
|
|
85
|
+
* set when the window first opens.
|
|
86
|
+
*
|
|
87
|
+
* Performing the check and the increment in a single step closes the
|
|
88
|
+
* concurrent-bypass gap of the separate `get`/`set` path: N simultaneous
|
|
89
|
+
* requests can no longer all pass a stale read before any increment lands.
|
|
90
|
+
*
|
|
91
|
+
* Optional for backwards compatibility. A storage without it falls back to
|
|
92
|
+
* the legacy non-atomic `get`/`set` path, which is best-effort under
|
|
93
|
+
* concurrency.
|
|
94
|
+
*
|
|
95
|
+
* TODO(rate-limit-consume-required): make this the sole required member on
|
|
96
|
+
* `next`, dropping `get`/`set` and the non-atomic fallback.
|
|
97
|
+
*/
|
|
98
|
+
consume?: (key: string, rule: {
|
|
99
|
+
window: number;
|
|
100
|
+
max: number;
|
|
101
|
+
}) => Promise<{
|
|
102
|
+
allowed: boolean;
|
|
103
|
+
retryAfter: number | null;
|
|
104
|
+
}>;
|
|
76
105
|
}
|
|
77
106
|
type BetterAuthRateLimitRule = {
|
|
78
107
|
/**
|
package/dist/utils/host.mjs
CHANGED
|
@@ -126,6 +126,7 @@ function classifyIPv6(expanded) {
|
|
|
126
126
|
if (firstByte === 254 && (secondByte & 192) === 128) return "linkLocal";
|
|
127
127
|
if ((firstByte & 254) === 252) return "private";
|
|
128
128
|
if (expanded.startsWith("2001:0db8:")) return "documentation";
|
|
129
|
+
if (expanded.startsWith("2001:0002:0000:")) return "benchmarking";
|
|
129
130
|
if (expanded.startsWith("2002:")) {
|
|
130
131
|
const embedded = extractEmbeddedIPv4(expanded, 1);
|
|
131
132
|
if (embedded && classifyIPv4(embedded) !== "public") return "reserved";
|
|
@@ -136,12 +137,15 @@ function classifyIPv6(expanded) {
|
|
|
136
137
|
if (embedded && classifyIPv4(embedded) !== "public") return "reserved";
|
|
137
138
|
return "reserved";
|
|
138
139
|
}
|
|
140
|
+
if (expanded.startsWith("0064:ff9b:0001:")) return "reserved";
|
|
139
141
|
if (expanded.startsWith("2001:0000:")) {
|
|
140
142
|
const embedded = extractEmbeddedIPv4(expanded, 6, { xor: true });
|
|
141
143
|
if (embedded && classifyIPv4(embedded) !== "public") return "reserved";
|
|
142
144
|
return "reserved";
|
|
143
145
|
}
|
|
144
146
|
if (expanded.startsWith("0100:0000:0000:0000:")) return "reserved";
|
|
147
|
+
if (expanded.startsWith("3fff:0")) return "documentation";
|
|
148
|
+
if (expanded.startsWith("5f00:")) return "reserved";
|
|
145
149
|
return "public";
|
|
146
150
|
}
|
|
147
151
|
/**
|
package/dist/utils/url.mjs
CHANGED
|
@@ -22,9 +22,10 @@ function normalizePathname(requestUrl, basePath) {
|
|
|
22
22
|
} catch {
|
|
23
23
|
return "/";
|
|
24
24
|
}
|
|
25
|
-
|
|
26
|
-
if (
|
|
27
|
-
if (pathname
|
|
25
|
+
const normalizedBasePath = basePath.replace(/\/+$/, "");
|
|
26
|
+
if (normalizedBasePath === "") return pathname;
|
|
27
|
+
if (pathname === normalizedBasePath) return "/";
|
|
28
|
+
if (pathname.startsWith(normalizedBasePath + "/")) return pathname.slice(normalizedBasePath.length).replace(/\/+$/, "") || "/";
|
|
28
29
|
return pathname;
|
|
29
30
|
}
|
|
30
31
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@better-auth/core",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.17",
|
|
4
4
|
"description": "The most comprehensive authentication framework for TypeScript.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -153,11 +153,11 @@
|
|
|
153
153
|
},
|
|
154
154
|
"devDependencies": {
|
|
155
155
|
"@better-auth/utils": "0.4.1",
|
|
156
|
-
"@better-fetch/fetch": "1.
|
|
156
|
+
"@better-fetch/fetch": "1.3.0",
|
|
157
157
|
"@opentelemetry/api": "^1.9.0",
|
|
158
158
|
"@opentelemetry/sdk-trace-base": "^1.30.0",
|
|
159
159
|
"@opentelemetry/sdk-trace-node": "^1.30.0",
|
|
160
|
-
"better-call": "1.3.
|
|
160
|
+
"better-call": "1.3.6",
|
|
161
161
|
"@cloudflare/workers-types": "^4.20250121.0",
|
|
162
162
|
"jose": "^6.1.3",
|
|
163
163
|
"kysely": "^0.28.17 || ^0.29.0",
|
|
@@ -166,9 +166,9 @@
|
|
|
166
166
|
},
|
|
167
167
|
"peerDependencies": {
|
|
168
168
|
"@better-auth/utils": "0.4.1",
|
|
169
|
-
"@better-fetch/fetch": "1.
|
|
169
|
+
"@better-fetch/fetch": "1.3.0",
|
|
170
170
|
"@opentelemetry/api": "^1.9.0",
|
|
171
|
-
"better-call": "1.3.
|
|
171
|
+
"better-call": "1.3.6",
|
|
172
172
|
"@cloudflare/workers-types": ">=4",
|
|
173
173
|
"jose": "^6.1.0",
|
|
174
174
|
"kysely": "^0.28.5 || ^0.29.0",
|
package/src/api/index.ts
CHANGED
|
@@ -132,6 +132,51 @@ export function createAuthEndpoint<
|
|
|
132
132
|
);
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Set `metadata.SERVER_ONLY` while preserving any existing metadata
|
|
137
|
+
* (`$Infer`, `openapi`, ...).
|
|
138
|
+
*/
|
|
139
|
+
function withServerOnly<Options extends EndpointOptions>(
|
|
140
|
+
options: Options,
|
|
141
|
+
): Options {
|
|
142
|
+
return {
|
|
143
|
+
...options,
|
|
144
|
+
metadata: { ...options.metadata, SERVER_ONLY: true },
|
|
145
|
+
} as Options;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Declare a **server-only** endpoint.
|
|
150
|
+
*
|
|
151
|
+
* The endpoint is callable through `auth.api.*` from trusted server code but is
|
|
152
|
+
* never registered on the HTTP router and never emitted into the OpenAPI
|
|
153
|
+
* schema. It takes no path because it has no URL to be reached at.
|
|
154
|
+
*
|
|
155
|
+
* Prefer this over the path-less `createAuthEndpoint({ ... }, handler)` form.
|
|
156
|
+
* Setting `metadata.SERVER_ONLY` makes the intent explicit at the call site and
|
|
157
|
+
* keeps the endpoint off the HTTP surface even if a path is later added by
|
|
158
|
+
* mistake: better-call's router skips an endpoint when its path is missing *or*
|
|
159
|
+
* when `SERVER_ONLY` is set, so the two together are defense in depth. Relying
|
|
160
|
+
* on path omission alone is invisible and one keystroke away from exposure.
|
|
161
|
+
*
|
|
162
|
+
* @example
|
|
163
|
+
* ```ts
|
|
164
|
+
* viewBackupCodes: createAuthEndpoint.serverOnly(
|
|
165
|
+
* { method: "POST", body: schema },
|
|
166
|
+
* async (ctx) => { ... },
|
|
167
|
+
* )
|
|
168
|
+
* ```
|
|
169
|
+
*/
|
|
170
|
+
createAuthEndpoint.serverOnly = <
|
|
171
|
+
Path extends string,
|
|
172
|
+
Options extends EndpointOptions,
|
|
173
|
+
R,
|
|
174
|
+
>(
|
|
175
|
+
options: Options,
|
|
176
|
+
handler: EndpointHandler<Path, Options, R>,
|
|
177
|
+
): StrictEndpoint<Path, Options, R> =>
|
|
178
|
+
createAuthEndpoint(withServerOnly(options), handler);
|
|
179
|
+
|
|
135
180
|
export type AuthEndpoint<
|
|
136
181
|
Path extends string,
|
|
137
182
|
Opts extends EndpointOptions,
|
|
@@ -138,6 +138,11 @@ export const createAdapterFactory =
|
|
|
138
138
|
!config.debugLogs.consumeOne
|
|
139
139
|
) {
|
|
140
140
|
return;
|
|
141
|
+
} else if (
|
|
142
|
+
method === "incrementOne" &&
|
|
143
|
+
!config.debugLogs.incrementOne
|
|
144
|
+
) {
|
|
145
|
+
return;
|
|
141
146
|
} else if (method === "count" && !config.debugLogs.count) {
|
|
142
147
|
return;
|
|
143
148
|
}
|
|
@@ -491,6 +496,7 @@ export const createAdapterFactory =
|
|
|
491
496
|
| "delete"
|
|
492
497
|
| "deleteMany"
|
|
493
498
|
| "consumeOne"
|
|
499
|
+
| "incrementOne"
|
|
494
500
|
| "count";
|
|
495
501
|
}): W extends undefined ? undefined : CleanedWhere[] => {
|
|
496
502
|
if (!where) return undefined as any;
|
|
@@ -1430,6 +1436,152 @@ export const createAdapterFactory =
|
|
|
1430
1436
|
);
|
|
1431
1437
|
return transformed as T | null;
|
|
1432
1438
|
},
|
|
1439
|
+
incrementOne: async <T>({
|
|
1440
|
+
model: unsafeModel,
|
|
1441
|
+
where: unsafeWhere,
|
|
1442
|
+
increment: unsafeIncrement,
|
|
1443
|
+
set: unsafeSet,
|
|
1444
|
+
}: {
|
|
1445
|
+
model: string;
|
|
1446
|
+
where: Where[];
|
|
1447
|
+
increment: Record<string, number>;
|
|
1448
|
+
set?: Record<string, unknown> | undefined;
|
|
1449
|
+
}): Promise<T | null> => {
|
|
1450
|
+
transactionId++;
|
|
1451
|
+
const thisTransactionId = transactionId;
|
|
1452
|
+
const model = getModelName(unsafeModel);
|
|
1453
|
+
const where = transformWhereClause({
|
|
1454
|
+
model: unsafeModel,
|
|
1455
|
+
where: unsafeWhere,
|
|
1456
|
+
action: "incrementOne",
|
|
1457
|
+
});
|
|
1458
|
+
unsafeModel = getDefaultModelName(unsafeModel);
|
|
1459
|
+
debugLog(
|
|
1460
|
+
{ method: "incrementOne" },
|
|
1461
|
+
`${formatTransactionId(thisTransactionId)} ${formatStep(1, 3)}`,
|
|
1462
|
+
`${formatMethod("incrementOne")} ${formatAction("IncrementOne")}:`,
|
|
1463
|
+
{ model, where, increment: unsafeIncrement, set: unsafeSet },
|
|
1464
|
+
);
|
|
1465
|
+
|
|
1466
|
+
let res: T | null;
|
|
1467
|
+
let resultNeedsOutputTransform = true;
|
|
1468
|
+
if (adapterInstance.incrementOne) {
|
|
1469
|
+
// Map each increment key to its DB column name, honoring a custom
|
|
1470
|
+
// `mapKeysTransformInput` override the same way `transformInput`
|
|
1471
|
+
// does, and keep the numeric delta unchanged: deltas are arithmetic
|
|
1472
|
+
// operands, not stored values, so they must never be value-transformed.
|
|
1473
|
+
const mappedKeys = config.mapKeysTransformInput ?? {};
|
|
1474
|
+
const increment: Record<string, number> = {};
|
|
1475
|
+
for (const [field, delta] of Object.entries(unsafeIncrement)) {
|
|
1476
|
+
increment[
|
|
1477
|
+
mappedKeys[field] || getFieldName({ model: unsafeModel, field })
|
|
1478
|
+
] = delta;
|
|
1479
|
+
}
|
|
1480
|
+
let set: Record<string, unknown> | undefined;
|
|
1481
|
+
if (unsafeSet && !config.disableTransformInput) {
|
|
1482
|
+
set = await transformInput(unsafeSet, unsafeModel, "update");
|
|
1483
|
+
} else {
|
|
1484
|
+
set = unsafeSet;
|
|
1485
|
+
}
|
|
1486
|
+
res = await withSpan(
|
|
1487
|
+
`db incrementOne ${model}`,
|
|
1488
|
+
{
|
|
1489
|
+
[ATTR_DB_OPERATION_NAME]: "incrementOne",
|
|
1490
|
+
[ATTR_DB_COLLECTION_NAME]: model,
|
|
1491
|
+
},
|
|
1492
|
+
() =>
|
|
1493
|
+
adapterInstance.incrementOne!<T>({
|
|
1494
|
+
model,
|
|
1495
|
+
where,
|
|
1496
|
+
increment,
|
|
1497
|
+
set,
|
|
1498
|
+
}),
|
|
1499
|
+
);
|
|
1500
|
+
} else {
|
|
1501
|
+
// FIXME(increment-one-required): remove this fallback when
|
|
1502
|
+
// incrementOne becomes required on `next`. Adapters without a native
|
|
1503
|
+
// incrementOne fall back to `transaction(findMany + updateMany)`.
|
|
1504
|
+
res = await withSpan(
|
|
1505
|
+
`db incrementOne ${model}`,
|
|
1506
|
+
{
|
|
1507
|
+
[ATTR_DB_OPERATION_NAME]: "incrementOne",
|
|
1508
|
+
[ATTR_DB_COLLECTION_NAME]: model,
|
|
1509
|
+
},
|
|
1510
|
+
() =>
|
|
1511
|
+
adapter.transaction(async (trx) => {
|
|
1512
|
+
const rows = await trx.findMany<Record<string, any>>({
|
|
1513
|
+
model: unsafeModel,
|
|
1514
|
+
where: unsafeWhere,
|
|
1515
|
+
limit: 1,
|
|
1516
|
+
});
|
|
1517
|
+
const target = rows[0];
|
|
1518
|
+
if (!target) return null;
|
|
1519
|
+
const nextValues: Record<string, unknown> = {
|
|
1520
|
+
...(unsafeSet ?? {}),
|
|
1521
|
+
};
|
|
1522
|
+
for (const [field, delta] of Object.entries(unsafeIncrement)) {
|
|
1523
|
+
const current =
|
|
1524
|
+
typeof target[field] === "number" ? target[field] : 0;
|
|
1525
|
+
nextValues[field] = current + delta;
|
|
1526
|
+
}
|
|
1527
|
+
// Re-applying `unsafeWhere` in the update's where is the
|
|
1528
|
+
// compare-and-swap guard: under an adapter whose transaction
|
|
1529
|
+
// lacks real isolation, it still rejects a racer that
|
|
1530
|
+
// invalidated the guard between the read and the write (e.g.
|
|
1531
|
+
// remaining dropped to 0).
|
|
1532
|
+
const updated = await trx.updateMany({
|
|
1533
|
+
model: unsafeModel,
|
|
1534
|
+
where: [
|
|
1535
|
+
...unsafeWhere,
|
|
1536
|
+
{
|
|
1537
|
+
field: "id",
|
|
1538
|
+
value: target.id,
|
|
1539
|
+
operator: "eq",
|
|
1540
|
+
connector: "AND",
|
|
1541
|
+
mode: "sensitive",
|
|
1542
|
+
},
|
|
1543
|
+
],
|
|
1544
|
+
update: nextValues,
|
|
1545
|
+
});
|
|
1546
|
+
// A non-numeric count coerces to a false miss, so fail loud.
|
|
1547
|
+
if (typeof updated !== "number") {
|
|
1548
|
+
throw new BetterAuthError(
|
|
1549
|
+
`Adapter "${config.adapterId}" returned a non-numeric value from updateMany during the incrementOne fallback. Return the number of updated rows, or implement a native incrementOne for atomic guarded counter updates.`,
|
|
1550
|
+
);
|
|
1551
|
+
}
|
|
1552
|
+
return updated > 0 ? ({ ...target, ...nextValues } as T) : null;
|
|
1553
|
+
}),
|
|
1554
|
+
);
|
|
1555
|
+
resultNeedsOutputTransform = false;
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
debugLog(
|
|
1559
|
+
{ method: "incrementOne" },
|
|
1560
|
+
`${formatTransactionId(thisTransactionId)} ${formatStep(2, 3)}`,
|
|
1561
|
+
`${formatMethod("incrementOne")} ${formatAction("DB Result")}:`,
|
|
1562
|
+
{ model, data: res },
|
|
1563
|
+
);
|
|
1564
|
+
let transformed: any = res;
|
|
1565
|
+
if (
|
|
1566
|
+
!config.disableTransformOutput &&
|
|
1567
|
+
resultNeedsOutputTransform &&
|
|
1568
|
+
res
|
|
1569
|
+
) {
|
|
1570
|
+
transformed = await transformOutput(
|
|
1571
|
+
res as Record<string, any>,
|
|
1572
|
+
unsafeModel,
|
|
1573
|
+
undefined,
|
|
1574
|
+
undefined,
|
|
1575
|
+
);
|
|
1576
|
+
}
|
|
1577
|
+
debugLog(
|
|
1578
|
+
{ method: "incrementOne" },
|
|
1579
|
+
`${formatTransactionId(thisTransactionId)} ${formatStep(3, 3)}`,
|
|
1580
|
+
`${formatMethod("incrementOne")} ${formatAction("Parsed Result")}:`,
|
|
1581
|
+
{ model, data: transformed },
|
|
1582
|
+
);
|
|
1583
|
+
return transformed as T | null;
|
|
1584
|
+
},
|
|
1433
1585
|
count: async ({
|
|
1434
1586
|
model: unsafeModel,
|
|
1435
1587
|
where: unsafeWhere,
|
package/src/db/adapter/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ export type DBAdapterDebugLogOption =
|
|
|
16
16
|
delete?: boolean | undefined;
|
|
17
17
|
deleteMany?: boolean | undefined;
|
|
18
18
|
consumeOne?: boolean | undefined;
|
|
19
|
+
incrementOne?: boolean | undefined;
|
|
19
20
|
count?: boolean | undefined;
|
|
20
21
|
}
|
|
21
22
|
| {
|
|
@@ -213,6 +214,7 @@ export interface DBAdapterFactoryConfig<
|
|
|
213
214
|
| "delete"
|
|
214
215
|
| "deleteMany"
|
|
215
216
|
| "consumeOne"
|
|
217
|
+
| "incrementOne"
|
|
216
218
|
| "count";
|
|
217
219
|
/**
|
|
218
220
|
* The model name.
|
|
@@ -464,6 +466,36 @@ export type DBAdapter<Options extends BetterAuthOptions = BetterAuthOptions> = {
|
|
|
464
466
|
* and returns the row only when the delete reports an affected row.
|
|
465
467
|
*/
|
|
466
468
|
consumeOne: <T>(data: { model: string; where: Where[] }) => Promise<T | null>;
|
|
469
|
+
/**
|
|
470
|
+
* Atomically apply signed numeric deltas to a single row matching the where
|
|
471
|
+
* clause. For each entry in `increment`, the operation applies
|
|
472
|
+
* `field = field + delta` in one atomic step; a negative delta decrements.
|
|
473
|
+
*
|
|
474
|
+
* The `where` clause is both the selector AND the guard: comparison
|
|
475
|
+
* operators are honored, so passing `{ field: "remaining", operator: "gt",
|
|
476
|
+
* value: 0 }` only mutates the row while `remaining` is still above zero.
|
|
477
|
+
* When the guard matches no row, the operation makes no change and returns
|
|
478
|
+
* `null`.
|
|
479
|
+
*
|
|
480
|
+
* The optional `set` map assigns absolute values to fields in the same
|
|
481
|
+
* atomic operation, alongside the increments.
|
|
482
|
+
*
|
|
483
|
+
* Returns the updated row, or `null` when the guard matched no row. Under
|
|
484
|
+
* concurrent invocation against the same row, this is the race-safe
|
|
485
|
+
* primitive for guarded counter updates (e.g. decrementing a remaining-uses
|
|
486
|
+
* counter only while it is still positive).
|
|
487
|
+
*
|
|
488
|
+
* Always defined on the factory-wrapped adapter. When the underlying
|
|
489
|
+
* `CustomAdapter` does not implement `incrementOne`, the factory provides a
|
|
490
|
+
* fallback that wraps `findMany + updateMany` in `transaction(...)` and
|
|
491
|
+
* re-applies the where clause as a compare-and-swap guard on the update.
|
|
492
|
+
*/
|
|
493
|
+
incrementOne: <T>(data: {
|
|
494
|
+
model: string;
|
|
495
|
+
where: Where[];
|
|
496
|
+
increment: Record<string, number>;
|
|
497
|
+
set?: Record<string, unknown> | undefined;
|
|
498
|
+
}) => Promise<T | null>;
|
|
467
499
|
/**
|
|
468
500
|
* Execute multiple operations in a transaction.
|
|
469
501
|
* If the adapter doesn't support transactions, operations will be executed sequentially.
|
|
@@ -563,6 +595,25 @@ export interface CustomAdapter {
|
|
|
563
595
|
model: string;
|
|
564
596
|
where: CleanedWhere[];
|
|
565
597
|
}) => Promise<T | null>;
|
|
598
|
+
/**
|
|
599
|
+
* Optional native atomic guarded counter mutation. Applies
|
|
600
|
+
* `field = field + delta` for each entry in `increment` (negative deltas
|
|
601
|
+
* decrement), with `where` acting as both selector and guard and `set`
|
|
602
|
+
* assigning absolute values in the same operation. Returns the updated row,
|
|
603
|
+
* or `null` when the guard matched no row.
|
|
604
|
+
*
|
|
605
|
+
* Implementing this natively (e.g. `UPDATE ... SET n = n + $delta WHERE ...
|
|
606
|
+
* RETURNING *`) gives one round trip and the strongest race-safety
|
|
607
|
+
* guarantee. When omitted, the adapter factory provides a transaction-based
|
|
608
|
+
* fallback over `findMany + updateMany`. TODO(increment-one-required):
|
|
609
|
+
* tighten to required in the next minor on `next`.
|
|
610
|
+
*/
|
|
611
|
+
incrementOne?: <T>(data: {
|
|
612
|
+
model: string;
|
|
613
|
+
where: CleanedWhere[];
|
|
614
|
+
increment: Record<string, number>;
|
|
615
|
+
set?: Record<string, unknown> | undefined;
|
|
616
|
+
}) => Promise<T | null>;
|
|
566
617
|
count: ({
|
|
567
618
|
model,
|
|
568
619
|
where,
|
package/src/db/adapter/types.ts
CHANGED
package/src/db/type.ts
CHANGED
|
@@ -323,6 +323,21 @@ export interface SecondaryStorage {
|
|
|
323
323
|
* security-sensitive consume paths.
|
|
324
324
|
*/
|
|
325
325
|
getAndDelete?: (key: string) => Awaitable<unknown>;
|
|
326
|
+
/**
|
|
327
|
+
* Atomically increment the counter at `key` by one, returning the
|
|
328
|
+
* post-increment value.
|
|
329
|
+
*
|
|
330
|
+
* When the key is absent, it is created with a value of `1` and the given
|
|
331
|
+
* `ttl` (in SECONDS). The TTL is applied only on creation; later increments
|
|
332
|
+
* never extend it, so the counter expires a fixed window after it was first
|
|
333
|
+
* created.
|
|
334
|
+
*
|
|
335
|
+
* This is optional for backwards compatibility with existing secondary
|
|
336
|
+
* storage implementations. TODO(secondary-storage-increment-required): make
|
|
337
|
+
* this required for secondary-storage-backed rate limiting in the next minor
|
|
338
|
+
* on `next`.
|
|
339
|
+
*/
|
|
340
|
+
increment?: (key: string, ttl: number) => Awaitable<number>;
|
|
326
341
|
set: (
|
|
327
342
|
/**
|
|
328
343
|
* Key to store
|
package/src/env/env-impl.ts
CHANGED
|
@@ -46,8 +46,7 @@ function toBoolean(val: boolean | string | undefined) {
|
|
|
46
46
|
return val ? val !== "false" : false;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
export const nodeENV =
|
|
50
|
-
(typeof process !== "undefined" && process.env && process.env.NODE_ENV) || "";
|
|
49
|
+
export const nodeENV = env.NODE_ENV ?? "";
|
|
51
50
|
|
|
52
51
|
/** Detect if `NODE_ENV` environment variable is `production` */
|
|
53
52
|
export const isProduction = nodeENV === "production";
|