@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.
Files changed (44) hide show
  1. package/dist/api/index.d.mts +3 -0
  2. package/dist/api/index.mjs +36 -0
  3. package/dist/context/global.mjs +1 -1
  4. package/dist/db/adapter/factory.mjs +82 -0
  5. package/dist/db/adapter/index.d.mts +51 -1
  6. package/dist/db/adapter/types.d.mts +1 -1
  7. package/dist/db/type.d.mts +15 -0
  8. package/dist/env/env-impl.mjs +1 -1
  9. package/dist/instrumentation/tracer.mjs +1 -1
  10. package/dist/oauth2/verify.d.mts +29 -6
  11. package/dist/oauth2/verify.mjs +112 -12
  12. package/dist/social-providers/facebook.mjs +35 -2
  13. package/dist/social-providers/google.d.mts +6 -1
  14. package/dist/social-providers/google.mjs +5 -0
  15. package/dist/social-providers/index.d.mts +2 -2
  16. package/dist/social-providers/index.mjs +2 -2
  17. package/dist/social-providers/microsoft-entra-id.d.mts +1 -1
  18. package/dist/social-providers/microsoft-entra-id.mjs +13 -1
  19. package/dist/social-providers/paypal.d.mts +2 -1
  20. package/dist/social-providers/paypal.mjs +38 -4
  21. package/dist/social-providers/reddit.mjs +4 -3
  22. package/dist/social-providers/wechat.mjs +1 -1
  23. package/dist/types/context.d.mts +16 -0
  24. package/dist/types/init-options.d.mts +29 -0
  25. package/dist/utils/host.mjs +4 -0
  26. package/dist/utils/url.mjs +4 -3
  27. package/package.json +5 -5
  28. package/src/api/index.ts +45 -0
  29. package/src/db/adapter/factory.ts +152 -0
  30. package/src/db/adapter/index.ts +51 -0
  31. package/src/db/adapter/types.ts +1 -0
  32. package/src/db/type.ts +15 -0
  33. package/src/env/env-impl.ts +1 -2
  34. package/src/oauth2/verify.ts +211 -41
  35. package/src/social-providers/facebook.ts +75 -2
  36. package/src/social-providers/google.ts +27 -1
  37. package/src/social-providers/microsoft-entra-id.ts +40 -1
  38. package/src/social-providers/paypal.ts +91 -4
  39. package/src/social-providers/reddit.ts +7 -3
  40. package/src/social-providers/wechat.ts +8 -1
  41. package/src/types/context.ts +17 -0
  42. package/src/types/init-options.ts +26 -0
  43. package/src/utils/host.ts +15 -0
  44. 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
- const authority = options.authority || "https://login.microsoftonline.com";
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 { decodeJwt } from "jose";
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
- return !!decodeJwt(token).sub;
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 || null,
69
+ email: profile.email || `${profile.unionid || profile.openid || openid}@wechat.invalid`,
70
70
  image: profile.headimgurl,
71
71
  emailVerified: false,
72
72
  ...userMap
@@ -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
  /**
@@ -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
  /**
@@ -22,9 +22,10 @@ function normalizePathname(requestUrl, basePath) {
22
22
  } catch {
23
23
  return "/";
24
24
  }
25
- if (basePath === "/" || basePath === "") return pathname;
26
- if (pathname === basePath) return "/";
27
- if (pathname.startsWith(basePath + "/")) return pathname.slice(basePath.length).replace(/\/+$/, "") || "/";
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.15",
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.1.21",
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.5",
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.1.21",
169
+ "@better-fetch/fetch": "1.3.0",
170
170
  "@opentelemetry/api": "^1.9.0",
171
- "better-call": "1.3.5",
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,
@@ -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,
@@ -123,6 +123,7 @@ export type AdapterFactoryCustomizeAdapterCreator = (config: {
123
123
  | "delete"
124
124
  | "deleteMany"
125
125
  | "consumeOne"
126
+ | "incrementOne"
126
127
  | "count";
127
128
  }) => W extends undefined ? undefined : CleanedWhere[];
128
129
  }) => CustomAdapter;
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
@@ -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";