@better-auth/core 1.7.0-beta.1 → 1.7.0-beta.10

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 (218) hide show
  1. package/dist/api/index.d.mts +44 -1
  2. package/dist/api/index.mjs +69 -4
  3. package/dist/context/global.mjs +1 -1
  4. package/dist/context/transaction.d.mts +7 -4
  5. package/dist/context/transaction.mjs +6 -3
  6. package/dist/db/adapter/factory.mjs +91 -3
  7. package/dist/db/adapter/get-id-field.mjs +1 -1
  8. package/dist/db/adapter/index.d.mts +87 -3
  9. package/dist/db/adapter/types.d.mts +1 -1
  10. package/dist/db/get-tables.mjs +2 -1
  11. package/dist/db/type.d.mts +17 -0
  12. package/dist/env/env-impl.mjs +1 -1
  13. package/dist/error/codes.d.mts +1 -0
  14. package/dist/error/codes.mjs +1 -0
  15. package/dist/error/index.d.mts +7 -0
  16. package/dist/index.d.mts +2 -2
  17. package/dist/instrumentation/api.mjs +12 -0
  18. package/dist/instrumentation/noop.mjs +42 -0
  19. package/dist/instrumentation/pure.index.d.mts +7 -0
  20. package/dist/instrumentation/pure.index.mjs +7 -0
  21. package/dist/instrumentation/tracer.mjs +6 -3
  22. package/dist/oauth2/authorization-params.d.mts +12 -0
  23. package/dist/oauth2/authorization-params.mjs +12 -0
  24. package/dist/oauth2/basic-credentials.d.mts +30 -0
  25. package/dist/oauth2/basic-credentials.mjs +64 -0
  26. package/dist/oauth2/client-assertion.d.mts +38 -22
  27. package/dist/oauth2/client-assertion.mjs +63 -28
  28. package/dist/oauth2/client-credentials-token.d.mts +19 -40
  29. package/dist/oauth2/client-credentials-token.mjs +20 -31
  30. package/dist/oauth2/create-authorization-url.d.mts +11 -1
  31. package/dist/oauth2/create-authorization-url.mjs +27 -7
  32. package/dist/oauth2/dpop.d.mts +142 -0
  33. package/dist/oauth2/dpop.mjs +246 -0
  34. package/dist/oauth2/index.d.mts +14 -9
  35. package/dist/oauth2/index.mjs +12 -8
  36. package/dist/oauth2/oauth-provider.d.mts +144 -10
  37. package/dist/oauth2/refresh-access-token.d.mts +20 -40
  38. package/dist/oauth2/refresh-access-token.mjs +38 -36
  39. package/dist/oauth2/reject-redirects.mjs +65 -0
  40. package/dist/oauth2/token-endpoint-auth.d.mts +17 -0
  41. package/dist/oauth2/token-endpoint-auth.mjs +89 -0
  42. package/dist/oauth2/utils.d.mts +23 -1
  43. package/dist/oauth2/utils.mjs +48 -2
  44. package/dist/oauth2/validate-authorization-code.d.mts +17 -52
  45. package/dist/oauth2/validate-authorization-code.mjs +28 -34
  46. package/dist/oauth2/verify-id-token.d.mts +27 -0
  47. package/dist/oauth2/verify-id-token.mjs +62 -0
  48. package/dist/oauth2/verify.d.mts +88 -15
  49. package/dist/oauth2/verify.mjs +203 -25
  50. package/dist/social-providers/apple.d.mts +16 -4
  51. package/dist/social-providers/apple.mjs +17 -24
  52. package/dist/social-providers/atlassian.d.mts +5 -1
  53. package/dist/social-providers/atlassian.mjs +6 -3
  54. package/dist/social-providers/cognito.d.mts +27 -3
  55. package/dist/social-providers/cognito.mjs +15 -25
  56. package/dist/social-providers/discord.d.mts +7 -3
  57. package/dist/social-providers/discord.mjs +16 -3
  58. package/dist/social-providers/dropbox.d.mts +5 -1
  59. package/dist/social-providers/dropbox.mjs +5 -4
  60. package/dist/social-providers/facebook.d.mts +24 -5
  61. package/dist/social-providers/facebook.mjs +54 -20
  62. package/dist/social-providers/figma.d.mts +5 -1
  63. package/dist/social-providers/figma.mjs +4 -3
  64. package/dist/social-providers/github.d.mts +6 -2
  65. package/dist/social-providers/github.mjs +5 -4
  66. package/dist/social-providers/gitlab.d.mts +5 -1
  67. package/dist/social-providers/gitlab.mjs +3 -2
  68. package/dist/social-providers/google.d.mts +53 -5
  69. package/dist/social-providers/google.mjs +69 -25
  70. package/dist/social-providers/huggingface.d.mts +5 -1
  71. package/dist/social-providers/huggingface.mjs +3 -2
  72. package/dist/social-providers/index.d.mts +221 -45
  73. package/dist/social-providers/index.mjs +2 -2
  74. package/dist/social-providers/kakao.d.mts +5 -1
  75. package/dist/social-providers/kakao.mjs +3 -2
  76. package/dist/social-providers/kick.d.mts +5 -1
  77. package/dist/social-providers/kick.mjs +3 -2
  78. package/dist/social-providers/line.d.mts +8 -2
  79. package/dist/social-providers/line.mjs +5 -6
  80. package/dist/social-providers/linear.d.mts +5 -1
  81. package/dist/social-providers/linear.mjs +3 -2
  82. package/dist/social-providers/linkedin.d.mts +7 -3
  83. package/dist/social-providers/linkedin.mjs +4 -3
  84. package/dist/social-providers/microsoft-entra-id.d.mts +41 -7
  85. package/dist/social-providers/microsoft-entra-id.mjs +37 -28
  86. package/dist/social-providers/naver.d.mts +5 -1
  87. package/dist/social-providers/naver.mjs +3 -2
  88. package/dist/social-providers/notion.d.mts +5 -1
  89. package/dist/social-providers/notion.mjs +5 -2
  90. package/dist/social-providers/paybin.d.mts +5 -1
  91. package/dist/social-providers/paybin.mjs +4 -3
  92. package/dist/social-providers/paypal.d.mts +6 -2
  93. package/dist/social-providers/paypal.mjs +19 -14
  94. package/dist/social-providers/polar.d.mts +5 -1
  95. package/dist/social-providers/polar.mjs +3 -2
  96. package/dist/social-providers/railway.d.mts +5 -1
  97. package/dist/social-providers/railway.mjs +3 -2
  98. package/dist/social-providers/reddit.d.mts +5 -1
  99. package/dist/social-providers/reddit.mjs +7 -5
  100. package/dist/social-providers/roblox.d.mts +6 -2
  101. package/dist/social-providers/roblox.mjs +12 -2
  102. package/dist/social-providers/salesforce.d.mts +5 -1
  103. package/dist/social-providers/salesforce.mjs +4 -3
  104. package/dist/social-providers/slack.d.mts +6 -2
  105. package/dist/social-providers/slack.mjs +11 -8
  106. package/dist/social-providers/spotify.d.mts +5 -1
  107. package/dist/social-providers/spotify.mjs +3 -2
  108. package/dist/social-providers/tiktok.d.mts +5 -1
  109. package/dist/social-providers/tiktok.mjs +14 -2
  110. package/dist/social-providers/twitch.d.mts +5 -1
  111. package/dist/social-providers/twitch.mjs +3 -2
  112. package/dist/social-providers/twitter.d.mts +4 -1
  113. package/dist/social-providers/twitter.mjs +2 -1
  114. package/dist/social-providers/vercel.d.mts +5 -1
  115. package/dist/social-providers/vercel.mjs +3 -2
  116. package/dist/social-providers/vk.d.mts +5 -1
  117. package/dist/social-providers/vk.mjs +3 -2
  118. package/dist/social-providers/wechat.d.mts +5 -1
  119. package/dist/social-providers/wechat.mjs +8 -2
  120. package/dist/social-providers/zoom.d.mts +6 -2
  121. package/dist/social-providers/zoom.mjs +10 -17
  122. package/dist/types/context.d.mts +55 -6
  123. package/dist/types/index.d.mts +1 -1
  124. package/dist/types/init-options.d.mts +182 -12
  125. package/dist/types/plugin-client.d.mts +12 -2
  126. package/dist/utils/async.d.mts +22 -0
  127. package/dist/utils/async.mjs +32 -0
  128. package/dist/utils/host.d.mts +147 -0
  129. package/dist/utils/host.mjs +298 -0
  130. package/dist/utils/ip.d.mts +28 -5
  131. package/dist/utils/ip.mjs +118 -4
  132. package/dist/utils/is-api-error.d.mts +6 -0
  133. package/dist/utils/is-api-error.mjs +8 -0
  134. package/dist/utils/redirect-uri.d.mts +20 -0
  135. package/dist/utils/redirect-uri.mjs +48 -0
  136. package/dist/utils/string.d.mts +5 -1
  137. package/dist/utils/string.mjs +20 -1
  138. package/dist/utils/url.d.mts +18 -1
  139. package/dist/utils/url.mjs +34 -4
  140. package/package.json +26 -16
  141. package/src/api/index.ts +121 -5
  142. package/src/context/transaction.ts +45 -12
  143. package/src/db/adapter/factory.ts +193 -7
  144. package/src/db/adapter/get-id-field.ts +2 -2
  145. package/src/db/adapter/index.ts +85 -2
  146. package/src/db/adapter/types.ts +2 -0
  147. package/src/db/get-tables.ts +9 -1
  148. package/src/db/schema/account.ts +4 -1
  149. package/src/db/schema/user.ts +3 -0
  150. package/src/db/type.ts +17 -0
  151. package/src/env/env-impl.ts +1 -2
  152. package/src/error/codes.ts +1 -0
  153. package/src/error/index.ts +9 -0
  154. package/src/instrumentation/api.ts +17 -0
  155. package/src/instrumentation/noop.ts +74 -0
  156. package/src/instrumentation/pure.index.ts +31 -0
  157. package/src/instrumentation/tracer.ts +8 -3
  158. package/src/oauth2/authorization-params.ts +28 -0
  159. package/src/oauth2/basic-credentials.ts +87 -0
  160. package/src/oauth2/client-assertion.ts +131 -58
  161. package/src/oauth2/client-credentials-token.ts +50 -74
  162. package/src/oauth2/create-authorization-url.ts +33 -7
  163. package/src/oauth2/dpop.ts +568 -0
  164. package/src/oauth2/index.ts +81 -11
  165. package/src/oauth2/oauth-provider.ts +153 -11
  166. package/src/oauth2/refresh-access-token.ts +82 -83
  167. package/src/oauth2/reject-redirects.ts +75 -0
  168. package/src/oauth2/token-endpoint-auth.ts +221 -0
  169. package/src/oauth2/utils.ts +72 -5
  170. package/src/oauth2/validate-authorization-code.ts +69 -89
  171. package/src/oauth2/verify-id-token.ts +115 -0
  172. package/src/oauth2/verify.ts +409 -78
  173. package/src/social-providers/apple.ts +30 -40
  174. package/src/social-providers/atlassian.ts +8 -1
  175. package/src/social-providers/cognito.ts +36 -39
  176. package/src/social-providers/discord.ts +22 -18
  177. package/src/social-providers/dropbox.ts +7 -5
  178. package/src/social-providers/facebook.ts +108 -52
  179. package/src/social-providers/figma.ts +8 -1
  180. package/src/social-providers/github.ts +5 -3
  181. package/src/social-providers/gitlab.ts +2 -0
  182. package/src/social-providers/google.ts +141 -39
  183. package/src/social-providers/huggingface.ts +8 -1
  184. package/src/social-providers/kakao.ts +2 -1
  185. package/src/social-providers/kick.ts +8 -1
  186. package/src/social-providers/line.ts +25 -27
  187. package/src/social-providers/linear.ts +8 -1
  188. package/src/social-providers/linkedin.ts +5 -3
  189. package/src/social-providers/microsoft-entra-id.ts +100 -55
  190. package/src/social-providers/naver.ts +2 -1
  191. package/src/social-providers/notion.ts +8 -1
  192. package/src/social-providers/paybin.ts +2 -0
  193. package/src/social-providers/paypal.ts +28 -17
  194. package/src/social-providers/polar.ts +8 -1
  195. package/src/social-providers/railway.ts +8 -1
  196. package/src/social-providers/reddit.ts +9 -4
  197. package/src/social-providers/roblox.ts +16 -11
  198. package/src/social-providers/salesforce.ts +8 -1
  199. package/src/social-providers/slack.ts +15 -9
  200. package/src/social-providers/spotify.ts +8 -1
  201. package/src/social-providers/tiktok.ts +22 -9
  202. package/src/social-providers/twitch.ts +2 -1
  203. package/src/social-providers/twitter.ts +1 -0
  204. package/src/social-providers/vercel.ts +8 -1
  205. package/src/social-providers/vk.ts +8 -1
  206. package/src/social-providers/wechat.ts +17 -2
  207. package/src/social-providers/zoom.ts +15 -19
  208. package/src/types/context.ts +57 -5
  209. package/src/types/index.ts +7 -0
  210. package/src/types/init-options.ts +204 -14
  211. package/src/types/plugin-client.ts +16 -2
  212. package/src/utils/async.ts +53 -0
  213. package/src/utils/host.ts +425 -0
  214. package/src/utils/ip.ts +197 -13
  215. package/src/utils/is-api-error.ts +10 -0
  216. package/src/utils/redirect-uri.ts +54 -0
  217. package/src/utils/string.ts +37 -0
  218. package/src/utils/url.ts +38 -4
@@ -1,10 +1,10 @@
1
- import { createLogger, getColorDepth, TTY_COLORS } from "../../env";
2
- import { BetterAuthError } from "../../error";
3
1
  import {
4
2
  ATTR_DB_COLLECTION_NAME,
5
3
  ATTR_DB_OPERATION_NAME,
6
4
  withSpan,
7
- } from "../../instrumentation";
5
+ } from "@better-auth/core/instrumentation";
6
+ import { createLogger, getColorDepth, TTY_COLORS } from "../../env";
7
+ import { BetterAuthError } from "../../error";
8
8
  import type { BetterAuthOptions } from "../../types";
9
9
  import { safeJSONParse } from "../../utils/json";
10
10
  import { getAuthTables } from "../get-tables";
@@ -29,15 +29,15 @@ import type {
29
29
  } from "./types";
30
30
  import { withApplyDefault } from "./utils";
31
31
 
32
+ export * from "./types";
32
33
  export {
33
- initGetDefaultModelName,
34
34
  initGetDefaultFieldName,
35
- initGetModelName,
36
- initGetFieldName,
35
+ initGetDefaultModelName,
37
36
  initGetFieldAttributes,
37
+ initGetFieldName,
38
38
  initGetIdField,
39
+ initGetModelName,
39
40
  };
40
- export * from "./types";
41
41
 
42
42
  let debugLogs: { instance: string; args: any[] }[] = [];
43
43
  let transactionId = -1;
@@ -133,6 +133,16 @@ export const createAdapterFactory =
133
133
  !config.debugLogs.deleteMany
134
134
  ) {
135
135
  return;
136
+ } else if (
137
+ method === "consumeOne" &&
138
+ !config.debugLogs.consumeOne
139
+ ) {
140
+ return;
141
+ } else if (
142
+ method === "incrementOne" &&
143
+ !config.debugLogs.incrementOne
144
+ ) {
145
+ return;
136
146
  } else if (method === "count" && !config.debugLogs.count) {
137
147
  return;
138
148
  }
@@ -485,6 +495,8 @@ export const createAdapterFactory =
485
495
  | "updateMany"
486
496
  | "delete"
487
497
  | "deleteMany"
498
+ | "consumeOne"
499
+ | "incrementOne"
488
500
  | "count";
489
501
  }): W extends undefined ? undefined : CleanedWhere[] => {
490
502
  if (!where) return undefined as any;
@@ -951,6 +963,11 @@ export const createAdapterFactory =
951
963
  where: unsafeWhere,
952
964
  action: "update",
953
965
  });
966
+ // `update` targets a single row. Empty predicates have no
967
+ // target, so fail closed and leave bulk writes to `updateMany`.
968
+ if (where.length === 0) {
969
+ return null;
970
+ }
954
971
  debugLog(
955
972
  { method: "update" },
956
973
  `${formatTransactionId(thisTransactionId)} ${formatStep(1, 4)}`,
@@ -1051,6 +1068,14 @@ export const createAdapterFactory =
1051
1068
  update: data,
1052
1069
  }),
1053
1070
  );
1071
+ if (
1072
+ typeof updatedCount !== "number" ||
1073
+ !Number.isFinite(updatedCount)
1074
+ ) {
1075
+ throw new BetterAuthError(
1076
+ `Adapter "${config.adapterId}" updateMany must return a finite number affected row count.`,
1077
+ );
1078
+ }
1054
1079
  debugLog(
1055
1080
  { method: "updateMany" },
1056
1081
  `${formatTransactionId(thisTransactionId)} ${formatStep(3, 4)}`,
@@ -1312,6 +1337,167 @@ export const createAdapterFactory =
1312
1337
  );
1313
1338
  return res;
1314
1339
  },
1340
+ consumeOne: async <T>({
1341
+ model: unsafeModel,
1342
+ where: unsafeWhere,
1343
+ }: {
1344
+ model: string;
1345
+ where: Where[];
1346
+ }): Promise<T | null> => {
1347
+ transactionId++;
1348
+ const thisTransactionId = transactionId;
1349
+ const model = getModelName(unsafeModel);
1350
+ const where = transformWhereClause({
1351
+ model: unsafeModel,
1352
+ where: unsafeWhere,
1353
+ action: "consumeOne",
1354
+ });
1355
+ unsafeModel = getDefaultModelName(unsafeModel);
1356
+ debugLog(
1357
+ { method: "consumeOne" },
1358
+ `${formatTransactionId(thisTransactionId)} ${formatStep(1, 3)}`,
1359
+ `${formatMethod("consumeOne")} ${formatAction("ConsumeOne")}:`,
1360
+ { model, where },
1361
+ );
1362
+
1363
+ if (typeof adapterInstance.consumeOne !== "function") {
1364
+ throw new BetterAuthError(
1365
+ `Adapter "${config.adapterId}" must implement consumeOne for atomic single-use credential consumption.`,
1366
+ );
1367
+ }
1368
+ const res = await withSpan(
1369
+ `db consumeOne ${model}`,
1370
+ {
1371
+ [ATTR_DB_OPERATION_NAME]: "consumeOne",
1372
+ [ATTR_DB_COLLECTION_NAME]: model,
1373
+ },
1374
+ () => adapterInstance.consumeOne<T>({ model, where }),
1375
+ );
1376
+
1377
+ debugLog(
1378
+ { method: "consumeOne" },
1379
+ `${formatTransactionId(thisTransactionId)} ${formatStep(2, 3)}`,
1380
+ `${formatMethod("consumeOne")} ${formatAction("DB Result")}:`,
1381
+ { model, data: res },
1382
+ );
1383
+ let transformed: any = res;
1384
+ if (!config.disableTransformOutput && res) {
1385
+ transformed = await transformOutput(
1386
+ res as Record<string, any>,
1387
+ unsafeModel,
1388
+ undefined,
1389
+ undefined,
1390
+ );
1391
+ }
1392
+ debugLog(
1393
+ { method: "consumeOne" },
1394
+ `${formatTransactionId(thisTransactionId)} ${formatStep(3, 3)}`,
1395
+ `${formatMethod("consumeOne")} ${formatAction("Parsed Result")}:`,
1396
+ { model, data: transformed },
1397
+ );
1398
+ return transformed as T | null;
1399
+ },
1400
+ incrementOne: async <T>({
1401
+ model: unsafeModel,
1402
+ where: unsafeWhere,
1403
+ increment: unsafeIncrement,
1404
+ set: unsafeSet,
1405
+ }: {
1406
+ model: string;
1407
+ where: Where[];
1408
+ increment: Record<string, number>;
1409
+ set?: Record<string, unknown> | undefined;
1410
+ }): Promise<T | null> => {
1411
+ const hasIncrement = Object.keys(unsafeIncrement).length > 0;
1412
+ const hasSet = !!unsafeSet && Object.keys(unsafeSet).length > 0;
1413
+ if (!hasIncrement && !hasSet) {
1414
+ // An empty `increment` and empty `set` compiles to `UPDATE ... SET `
1415
+ // with no assignments, which is a syntax error on kysely, drizzle, and
1416
+ // Prisma. Fail fast with an actionable message instead.
1417
+ throw new BetterAuthError(
1418
+ "incrementOne requires a non-empty `increment` or `set`; both were empty.",
1419
+ );
1420
+ }
1421
+ transactionId++;
1422
+ const thisTransactionId = transactionId;
1423
+ const model = getModelName(unsafeModel);
1424
+ const where = transformWhereClause({
1425
+ model: unsafeModel,
1426
+ where: unsafeWhere,
1427
+ action: "incrementOne",
1428
+ });
1429
+ unsafeModel = getDefaultModelName(unsafeModel);
1430
+ debugLog(
1431
+ { method: "incrementOne" },
1432
+ `${formatTransactionId(thisTransactionId)} ${formatStep(1, 3)}`,
1433
+ `${formatMethod("incrementOne")} ${formatAction("IncrementOne")}:`,
1434
+ { model, where, increment: unsafeIncrement, set: unsafeSet },
1435
+ );
1436
+
1437
+ if (typeof adapterInstance.incrementOne !== "function") {
1438
+ throw new BetterAuthError(
1439
+ `Adapter "${config.adapterId}" must implement incrementOne for atomic guarded counter updates.`,
1440
+ );
1441
+ }
1442
+ const mappedKeys = config.mapKeysTransformInput ?? {};
1443
+ const increment: Record<string, number> = {};
1444
+ for (const [field, delta] of Object.entries(unsafeIncrement)) {
1445
+ increment[
1446
+ mappedKeys[field] || getFieldName({ model: unsafeModel, field })
1447
+ ] = delta;
1448
+ }
1449
+ let set: Record<string, unknown> | undefined;
1450
+ if (unsafeSet && !config.disableTransformInput) {
1451
+ set = await transformInput(unsafeSet, unsafeModel, "update");
1452
+ } else {
1453
+ set = unsafeSet;
1454
+ }
1455
+ if (
1456
+ Object.keys(increment).length === 0 &&
1457
+ (!set || Object.keys(set).length === 0)
1458
+ ) {
1459
+ throw new BetterAuthError(
1460
+ "incrementOne resolved to an empty update: every increment/set field was unknown to the schema or transformed away.",
1461
+ );
1462
+ }
1463
+ const res = await withSpan(
1464
+ `db incrementOne ${model}`,
1465
+ {
1466
+ [ATTR_DB_OPERATION_NAME]: "incrementOne",
1467
+ [ATTR_DB_COLLECTION_NAME]: model,
1468
+ },
1469
+ () =>
1470
+ adapterInstance.incrementOne<T>({
1471
+ model,
1472
+ where,
1473
+ increment,
1474
+ set,
1475
+ }),
1476
+ );
1477
+
1478
+ debugLog(
1479
+ { method: "incrementOne" },
1480
+ `${formatTransactionId(thisTransactionId)} ${formatStep(2, 3)}`,
1481
+ `${formatMethod("incrementOne")} ${formatAction("DB Result")}:`,
1482
+ { model, data: res },
1483
+ );
1484
+ let transformed: any = res;
1485
+ if (!config.disableTransformOutput && res) {
1486
+ transformed = await transformOutput(
1487
+ res as Record<string, any>,
1488
+ unsafeModel,
1489
+ undefined,
1490
+ undefined,
1491
+ );
1492
+ }
1493
+ debugLog(
1494
+ { method: "incrementOne" },
1495
+ `${formatTransactionId(thisTransactionId)} ${formatStep(3, 3)}`,
1496
+ `${formatMethod("incrementOne")} ${formatAction("Parsed Result")}:`,
1497
+ { model, data: transformed },
1498
+ );
1499
+ return transformed as T | null;
1500
+ },
1315
1501
  count: async ({
1316
1502
  model: unsafeModel,
1317
1503
  where: unsafeWhere,
@@ -108,8 +108,6 @@ export const initGetIdField = ({
108
108
  // if it's generated by us, then we should return the value as is.
109
109
  if (shouldGenerateId && !forceAllowId) return value;
110
110
  if (disableIdGeneration) return undefined;
111
- // if DB will handle UUID generation, then we should return undefined.
112
- if (supportsUUIDs) return undefined;
113
111
  // if forceAllowId is true, it means we should be using the ID provided during the adapter call.
114
112
  if (forceAllowId && typeof value === "string") {
115
113
  const uuidRegex =
@@ -129,6 +127,8 @@ export const initGetIdField = ({
129
127
  );
130
128
  }
131
129
  }
130
+ // if DB will handle UUID generation, then we should return undefined.
131
+ if (supportsUUIDs) return undefined;
132
132
  // if the value is not a string, and the database doesn't support generating it's own UUIDs, then we should be generating the UUID.
133
133
  if (typeof value !== "string" && !supportsUUIDs) {
134
134
  return crypto.randomUUID();
@@ -15,6 +15,8 @@ export type DBAdapterDebugLogOption =
15
15
  findMany?: boolean | undefined;
16
16
  delete?: boolean | undefined;
17
17
  deleteMany?: boolean | undefined;
18
+ consumeOne?: boolean | undefined;
19
+ incrementOne?: boolean | undefined;
18
20
  count?: boolean | undefined;
19
21
  }
20
22
  | {
@@ -211,6 +213,8 @@ export interface DBAdapterFactoryConfig<
211
213
  | "updateMany"
212
214
  | "delete"
213
215
  | "deleteMany"
216
+ | "consumeOne"
217
+ | "incrementOne"
214
218
  | "count";
215
219
  /**
216
220
  * The model name.
@@ -430,8 +434,14 @@ export type DBAdapter<Options extends BetterAuthOptions = BetterAuthOptions> = {
430
434
  where?: Where[] | undefined;
431
435
  }) => Promise<number>;
432
436
  /**
433
- * ⚠︎ Update may not return the updated data
434
- * if multiple where clauses are provided
437
+ * Update a single row matching the where clause.
438
+ *
439
+ * Returns the updated row, or `null` when no row matched. Empty `where`
440
+ * clauses return `null`; use `updateMany` for intentional bulk updates.
441
+ *
442
+ * This is not the race-safe primitive for guarded state transitions. Use
443
+ * `incrementOne` when the predicate is both selector and guard, and use
444
+ * `consumeOne` for single-use destructive reads.
435
445
  */
436
446
  update: <T>(data: {
437
447
  model: string;
@@ -445,6 +455,51 @@ export type DBAdapter<Options extends BetterAuthOptions = BetterAuthOptions> = {
445
455
  }) => Promise<number>;
446
456
  delete: <_T>(data: { model: string; where: Where[] }) => Promise<void>;
447
457
  deleteMany: (data: { model: string; where: Where[] }) => Promise<number>;
458
+ /**
459
+ * Atomically consume a single row matching the where clause: delete it and
460
+ * return the deleted row, or return `null` if no row matched.
461
+ * Implementations MUST NOT delete any additional rows that also match a
462
+ * non-unique predicate.
463
+ *
464
+ * Under concurrent invocation against the same row, exactly one caller
465
+ * receives the row; subsequent racers receive `null`. This is the
466
+ * race-safe primitive for consuming single-use credentials
467
+ * (verification tokens, authorization codes, one-time tokens).
468
+ *
469
+ * Always defined on the factory-wrapped adapter. The underlying
470
+ * `CustomAdapter` must implement this natively; there is no portable
471
+ * fallback that can guarantee cross-process single-use semantics.
472
+ */
473
+ consumeOne: <T>(data: { model: string; where: Where[] }) => Promise<T | null>;
474
+ /**
475
+ * Atomically apply signed numeric deltas to a single row matching the where
476
+ * clause. For each entry in `increment`, the operation applies
477
+ * `field = field + delta` in one atomic step; a negative delta decrements.
478
+ *
479
+ * The `where` clause is both the selector AND the guard: comparison
480
+ * operators are honored, so passing `{ field: "remaining", operator: "gt",
481
+ * value: 0 }` only mutates the row while `remaining` is still above zero.
482
+ * When the guard matches no row, the operation makes no change and returns
483
+ * `null`.
484
+ *
485
+ * The optional `set` map assigns absolute values to fields in the same
486
+ * atomic operation, alongside the increments.
487
+ *
488
+ * Returns the updated row, or `null` when the guard matched no row. Under
489
+ * concurrent invocation against the same row, this is the race-safe
490
+ * primitive for guarded counter updates (e.g. decrementing a remaining-uses
491
+ * counter only while it is still positive).
492
+ *
493
+ * Always defined on the factory-wrapped adapter. The underlying
494
+ * `CustomAdapter` must implement this natively; there is no portable
495
+ * fallback that can guarantee guarded counter semantics across runtimes.
496
+ */
497
+ incrementOne: <T>(data: {
498
+ model: string;
499
+ where: Where[];
500
+ increment: Record<string, number>;
501
+ set?: Record<string, unknown> | undefined;
502
+ }) => Promise<T | null>;
448
503
  /**
449
504
  * Execute multiple operations in a transaction.
450
505
  * If the adapter doesn't support transactions, operations will be executed sequentially.
@@ -531,6 +586,34 @@ export interface CustomAdapter {
531
586
  model: string;
532
587
  where: CleanedWhere[];
533
588
  }) => Promise<number>;
589
+ /**
590
+ * Native atomic single-row consume.
591
+ * Implementing this method natively (e.g. `DELETE ... RETURNING *`,
592
+ * `findOneAndDelete`, `OUTPUT deleted.*`) gives one round trip and the
593
+ * strongest race-safety guarantee. Implementations must delete at most
594
+ * one matching row.
595
+ */
596
+ consumeOne: <T>(data: {
597
+ model: string;
598
+ where: CleanedWhere[];
599
+ }) => Promise<T | null>;
600
+ /**
601
+ * Native atomic guarded counter mutation. Applies
602
+ * `field = field + delta` for each entry in `increment` (negative deltas
603
+ * decrement), with `where` acting as both selector and guard and `set`
604
+ * assigning absolute values in the same operation. Returns the updated row,
605
+ * or `null` when the guard matched no row.
606
+ *
607
+ * Implementing this natively (e.g. `UPDATE ... SET n = n + $delta WHERE ...
608
+ * RETURNING *`) gives one round trip and the strongest race-safety
609
+ * guarantee.
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>;
534
617
  count: ({
535
618
  model,
536
619
  where,
@@ -122,6 +122,8 @@ export type AdapterFactoryCustomizeAdapterCreator = (config: {
122
122
  | "updateMany"
123
123
  | "delete"
124
124
  | "deleteMany"
125
+ | "consumeOne"
126
+ | "incrementOne"
125
127
  | "count";
126
128
  }) => W extends undefined ? undefined : CleanedWhere[];
127
129
  }) => CustomAdapter;
@@ -15,13 +15,19 @@ export const getAuthTables = (
15
15
  ...value.fields,
16
16
  },
17
17
  modelName: value.modelName || key,
18
+ disableMigrations:
19
+ value.disableMigration ?? acc[key]?.disableMigrations,
18
20
  };
19
21
  }
20
22
  return acc;
21
23
  },
22
24
  {} as Record<
23
25
  string,
24
- { fields: Record<string, DBFieldAttribute>; modelName: string }
26
+ {
27
+ fields: Record<string, DBFieldAttribute>;
28
+ modelName: string;
29
+ disableMigrations?: boolean | undefined;
30
+ }
25
31
  >,
26
32
  );
27
33
 
@@ -162,6 +168,8 @@ export const getAuthTables = (
162
168
  },
163
169
  email: {
164
170
  type: "string",
171
+ // TODO(#9124): drop required+unique in v2; use a partial unique
172
+ // index where email is not null (see schema/user.ts).
165
173
  unique: true,
166
174
  required: true,
167
175
  fieldName: options.user?.fields?.email || "email",
@@ -23,7 +23,10 @@ export const accountSchema = coreSchema.extend({
23
23
  */
24
24
  refreshTokenExpiresAt: z.date().nullish(),
25
25
  /**
26
- * The scopes that the user has authorized
26
+ * The set of OAuth scopes the user has granted to this account, stored
27
+ * as a comma-separated list. Represents the accumulated grant rather
28
+ * than the latest token's `scope` claim, since per RFC 6749 Section 1.5 a
29
+ * token's scope may be narrower than the user's grant.
27
30
  */
28
31
  scope: z.string().nullish(),
29
32
  /**
@@ -7,6 +7,9 @@ import type {
7
7
  import { coreSchema } from "./shared";
8
8
 
9
9
  export const userSchema = coreSchema.extend({
10
+ // TODO(#9124): widen to nullish in v2. OAuth providers (Discord phone-only,
11
+ // Apple subsequent sign-ins, etc.) can legitimately omit email; identity
12
+ // must key on (providerId, accountId) per OpenID Connect Core §5.7.
10
13
  email: z.string().transform((val) => val.toLowerCase()),
11
14
  emailVerified: z.boolean().default(false),
12
15
  name: z.string(),
package/src/db/type.ts CHANGED
@@ -311,6 +311,23 @@ export interface SecondaryStorage {
311
311
  * @returns - Value of the key
312
312
  */
313
313
  get: (key: string) => Awaitable<unknown>;
314
+ /**
315
+ * Atomically get a value and delete it from storage.
316
+ */
317
+ getAndDelete: (key: string) => Awaitable<unknown>;
318
+ /**
319
+ * Atomically increment the counter at `key` by one, returning the
320
+ * post-increment value.
321
+ *
322
+ * When the key is absent, it is created with a value of `1` and the given
323
+ * `ttl` (in SECONDS). The TTL is applied only on creation; later increments
324
+ * never extend it, so the counter expires a fixed window after it was first
325
+ * created.
326
+ *
327
+ * Required so secondary-storage-backed rate limiting can enforce the limit
328
+ * in one distributed-safe operation.
329
+ */
330
+ increment: (key: string, ttl: number) => Awaitable<number>;
314
331
  set: (
315
332
  /**
316
333
  * 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";
@@ -37,6 +37,7 @@ export const BASE_ERROR_CODES = defineErrorCodes({
37
37
  USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL:
38
38
  "User already exists. Use another email.",
39
39
  EMAIL_CAN_NOT_BE_UPDATED: "Email can not be updated",
40
+ CHANGE_EMAIL_DISABLED: "Change email is disabled",
40
41
  CREDENTIAL_ACCOUNT_NOT_FOUND: "Credential account not found",
41
42
  SESSION_EXPIRED: "Session expired. Re-authenticate to perform this action.",
42
43
  FAILED_TO_UNLINK_LAST_ACCOUNT: "You can't unlink your last account",
@@ -11,7 +11,16 @@ export class BetterAuthError extends Error {
11
11
 
12
12
  export { type APIErrorCode, BASE_ERROR_CODES } from "./codes";
13
13
 
14
+ type BaseAPIErrorInstance = InstanceType<typeof BaseAPIError>;
15
+
14
16
  export class APIError extends BaseAPIError {
17
+ declare status: BaseAPIErrorInstance["status"];
18
+ declare body: BaseAPIErrorInstance["body"];
19
+ declare headers: BaseAPIErrorInstance["headers"];
20
+ declare statusCode: BaseAPIErrorInstance["statusCode"];
21
+ declare message: string;
22
+ declare errorStack: BaseAPIErrorInstance["errorStack"];
23
+
15
24
  constructor(...args: ConstructorParameters<typeof BaseAPIError>) {
16
25
  super(...args);
17
26
  }
@@ -0,0 +1,17 @@
1
+ import type { OpenTelemetryAPI } from "./noop";
2
+ import { noopOpenTelemetryAPI } from "./noop";
3
+
4
+ let openTelemetryAPIPromise: Promise<void> | undefined;
5
+ let openTelemetryAPI: OpenTelemetryAPI | undefined;
6
+
7
+ export function getOpenTelemetryAPI(): OpenTelemetryAPI {
8
+ if (!openTelemetryAPIPromise) {
9
+ openTelemetryAPIPromise = import("@opentelemetry/api")
10
+ .then((mod) => {
11
+ openTelemetryAPI = mod;
12
+ })
13
+ .catch(() => /* ignore failures */ undefined);
14
+ }
15
+
16
+ return openTelemetryAPI ?? noopOpenTelemetryAPI;
17
+ }
@@ -0,0 +1,74 @@
1
+ import type { Span, Tracer } from "@opentelemetry/api";
2
+
3
+ export type OpenTelemetryAPI = Pick<
4
+ typeof import("@opentelemetry/api"),
5
+ "trace" | "SpanStatusCode"
6
+ >;
7
+
8
+ function createNoopSpan(): Span {
9
+ const span = {
10
+ end(): void {},
11
+ setAttribute(_key: string, _value: unknown): void {},
12
+ setStatus(_status: unknown): void {},
13
+ recordException(_exception: unknown): void {},
14
+ updateName(_name: string) {
15
+ return span;
16
+ },
17
+ } as unknown as Span;
18
+ return span;
19
+ }
20
+
21
+ function createNoopTracer(noopSpan: Span): Tracer {
22
+ // OpenTelemetry `Tracer.startActiveSpan` has three overloads:
23
+ // (name, fn)
24
+ // (name, options, fn)
25
+ // (name, options, context, fn)
26
+ // The callback is always the last argument; fish it out by arity so a
27
+ // 2-arg call (options omitted) doesn't try to invoke `undefined`.
28
+ function startActiveSpan<F extends (span: Span) => unknown>(
29
+ _name: string,
30
+ fn: F,
31
+ ): ReturnType<F>;
32
+ function startActiveSpan<F extends (span: Span) => unknown>(
33
+ _name: string,
34
+ _options: { attributes?: Record<string, string | number | boolean> },
35
+ fn: F,
36
+ ): ReturnType<F>;
37
+ function startActiveSpan<F extends (span: Span) => unknown>(
38
+ _name: string,
39
+ _options: { attributes?: Record<string, string | number | boolean> },
40
+ _context: unknown,
41
+ fn: F,
42
+ ): ReturnType<F>;
43
+ function startActiveSpan(_name: string, ...rest: Array<unknown>): unknown {
44
+ const fn = rest[rest.length - 1] as (span: Span) => unknown;
45
+ return fn(noopSpan);
46
+ }
47
+ return { startActiveSpan } as Tracer;
48
+ }
49
+
50
+ function createNoopTraceAPI() {
51
+ const noopTracer = createNoopTracer(createNoopSpan());
52
+ return {
53
+ getTracer(_name?: string, _version?: string) {
54
+ return noopTracer;
55
+ },
56
+ getActiveSpan(): Span | undefined {
57
+ return undefined;
58
+ },
59
+ };
60
+ }
61
+
62
+ function createNoopOpenTelemetryAPI(): OpenTelemetryAPI {
63
+ return {
64
+ SpanStatusCode: {
65
+ UNSET: 0,
66
+ OK: 1,
67
+ ERROR: 2,
68
+ },
69
+ trace: createNoopTraceAPI(),
70
+ } as OpenTelemetryAPI;
71
+ }
72
+
73
+ export const noopOpenTelemetryAPI: OpenTelemetryAPI =
74
+ createNoopOpenTelemetryAPI();
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Noop variant of `./instrumentation` for runtimes where the dynamic
3
+ * `import("@opentelemetry/api")` in `./api` throws synchronously instead of
4
+ * rejecting its returned promise. Convex's V8 isolate is the reproducer: bare
5
+ * specifiers are rejected at resolve time in `get-convex/convex-backend`
6
+ * `crates/isolate/src/request_scope.rs`, so the `.catch()` in
7
+ * `getOpenTelemetryAPI` never runs and every `withSpan` call surfaces an
8
+ * uncaught error.
9
+ *
10
+ * Public surface must stay identical to `./index` (enforced by `pure.test.ts`).
11
+ */
12
+
13
+ export * from "./attributes";
14
+
15
+ export function withSpan<T>(
16
+ name: string,
17
+ attributes: Record<string, string | number | boolean>,
18
+ fn: () => T,
19
+ ): T;
20
+ export function withSpan<T>(
21
+ name: string,
22
+ attributes: Record<string, string | number | boolean>,
23
+ fn: () => Promise<T>,
24
+ ): Promise<T>;
25
+ export function withSpan<T>(
26
+ _name: string,
27
+ _attributes: Record<string, string | number | boolean>,
28
+ fn: () => T | Promise<T>,
29
+ ): T | Promise<T> {
30
+ return fn();
31
+ }
@@ -1,12 +1,10 @@
1
1
  import type { Span } from "@opentelemetry/api";
2
- import { SpanStatusCode, trace } from "@opentelemetry/api";
2
+ import { getOpenTelemetryAPI } from "./api";
3
3
  import { ATTR_HTTP_RESPONSE_STATUS_CODE } from "./attributes";
4
4
 
5
5
  const INSTRUMENTATION_SCOPE = "better-auth";
6
6
  const INSTRUMENTATION_VERSION = import.meta.env?.BETTER_AUTH_VERSION ?? "1.0.0";
7
7
 
8
- const tracer = trace.getTracer(INSTRUMENTATION_SCOPE, INSTRUMENTATION_VERSION);
9
-
10
8
  /**
11
9
  * Better-auth uses `throw ctx.redirect(url)` for flow control (e.g. OAuth
12
10
  * callbacks). These are APIErrors with 3xx status codes and should not be
@@ -27,6 +25,7 @@ function isRedirectError(err: unknown): boolean {
27
25
  }
28
26
 
29
27
  function endSpanWithError(span: Span, err: unknown) {
28
+ const { SpanStatusCode } = getOpenTelemetryAPI();
30
29
  if (isRedirectError(err)) {
31
30
  span.setAttribute(
32
31
  ATTR_HTTP_RESPONSE_STATUS_CODE,
@@ -66,6 +65,12 @@ export function withSpan<T>(
66
65
  attributes: Record<string, string | number | boolean>,
67
66
  fn: () => T | Promise<T>,
68
67
  ): T | Promise<T> {
68
+ const { trace } = getOpenTelemetryAPI();
69
+ const tracer = trace.getTracer(
70
+ INSTRUMENTATION_SCOPE,
71
+ INSTRUMENTATION_VERSION,
72
+ );
73
+
69
74
  return tracer.startActiveSpan(name, { attributes }, (span) => {
70
75
  try {
71
76
  const result = fn();