@better-auth/core 1.7.0-beta.4 → 1.7.0-beta.6

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 (170) hide show
  1. package/dist/api/index.d.mts +47 -4
  2. package/dist/api/index.mjs +40 -1
  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 +57 -31
  7. package/dist/db/adapter/index.d.mts +54 -10
  8. package/dist/db/adapter/types.d.mts +1 -1
  9. package/dist/db/get-tables.mjs +3 -3
  10. package/dist/db/schema/account.d.mts +1 -1
  11. package/dist/db/schema/account.mjs +1 -1
  12. package/dist/db/type.d.mts +12 -7
  13. package/dist/env/env-impl.mjs +1 -1
  14. package/dist/error/codes.d.mts +5 -0
  15. package/dist/error/codes.mjs +5 -0
  16. package/dist/index.d.mts +2 -2
  17. package/dist/instrumentation/tracer.mjs +1 -1
  18. package/dist/oauth2/create-authorization-url.d.mts +4 -1
  19. package/dist/oauth2/create-authorization-url.mjs +5 -2
  20. package/dist/oauth2/dpop.d.mts +142 -0
  21. package/dist/oauth2/dpop.mjs +246 -0
  22. package/dist/oauth2/index.d.mts +6 -3
  23. package/dist/oauth2/index.mjs +5 -2
  24. package/dist/oauth2/oauth-provider.d.mts +128 -9
  25. package/dist/oauth2/refresh-access-token.mjs +1 -1
  26. package/dist/oauth2/scopes.d.mts +76 -0
  27. package/dist/oauth2/scopes.mjs +96 -0
  28. package/dist/oauth2/utils.mjs +2 -1
  29. package/dist/oauth2/verify-id-token.d.mts +26 -0
  30. package/dist/oauth2/verify-id-token.mjs +62 -0
  31. package/dist/oauth2/verify.d.mts +88 -15
  32. package/dist/oauth2/verify.mjs +187 -19
  33. package/dist/social-providers/apple.d.mts +14 -2
  34. package/dist/social-providers/apple.mjs +12 -36
  35. package/dist/social-providers/atlassian.d.mts +5 -1
  36. package/dist/social-providers/atlassian.mjs +4 -4
  37. package/dist/social-providers/cognito.d.mts +13 -2
  38. package/dist/social-providers/cognito.mjs +24 -32
  39. package/dist/social-providers/discord.d.mts +5 -1
  40. package/dist/social-providers/discord.mjs +7 -6
  41. package/dist/social-providers/dropbox.d.mts +5 -1
  42. package/dist/social-providers/dropbox.mjs +5 -5
  43. package/dist/social-providers/facebook.d.mts +21 -2
  44. package/dist/social-providers/facebook.mjs +46 -22
  45. package/dist/social-providers/figma.d.mts +5 -1
  46. package/dist/social-providers/figma.mjs +5 -5
  47. package/dist/social-providers/github.d.mts +5 -1
  48. package/dist/social-providers/github.mjs +4 -4
  49. package/dist/social-providers/gitlab.d.mts +5 -1
  50. package/dist/social-providers/gitlab.mjs +6 -6
  51. package/dist/social-providers/google.d.mts +29 -3
  52. package/dist/social-providers/google.mjs +24 -30
  53. package/dist/social-providers/huggingface.d.mts +5 -1
  54. package/dist/social-providers/huggingface.mjs +8 -8
  55. package/dist/social-providers/index.d.mts +222 -42
  56. package/dist/social-providers/kakao.d.mts +5 -1
  57. package/dist/social-providers/kakao.mjs +8 -8
  58. package/dist/social-providers/kick.d.mts +5 -1
  59. package/dist/social-providers/kick.mjs +4 -4
  60. package/dist/social-providers/line.d.mts +8 -2
  61. package/dist/social-providers/line.mjs +12 -14
  62. package/dist/social-providers/linear.d.mts +5 -1
  63. package/dist/social-providers/linear.mjs +4 -4
  64. package/dist/social-providers/linkedin.d.mts +5 -1
  65. package/dist/social-providers/linkedin.mjs +10 -10
  66. package/dist/social-providers/microsoft-entra-id.d.mts +41 -6
  67. package/dist/social-providers/microsoft-entra-id.mjs +40 -36
  68. package/dist/social-providers/naver.d.mts +5 -1
  69. package/dist/social-providers/naver.mjs +4 -4
  70. package/dist/social-providers/notion.d.mts +5 -1
  71. package/dist/social-providers/notion.mjs +4 -4
  72. package/dist/social-providers/paybin.d.mts +5 -1
  73. package/dist/social-providers/paybin.mjs +10 -10
  74. package/dist/social-providers/paypal.d.mts +5 -2
  75. package/dist/social-providers/paypal.mjs +8 -13
  76. package/dist/social-providers/polar.d.mts +5 -1
  77. package/dist/social-providers/polar.mjs +8 -8
  78. package/dist/social-providers/railway.d.mts +5 -1
  79. package/dist/social-providers/railway.mjs +9 -9
  80. package/dist/social-providers/reddit.d.mts +5 -1
  81. package/dist/social-providers/reddit.mjs +9 -8
  82. package/dist/social-providers/roblox.d.mts +5 -1
  83. package/dist/social-providers/roblox.mjs +5 -5
  84. package/dist/social-providers/salesforce.d.mts +5 -1
  85. package/dist/social-providers/salesforce.mjs +8 -8
  86. package/dist/social-providers/slack.d.mts +5 -1
  87. package/dist/social-providers/slack.mjs +9 -9
  88. package/dist/social-providers/spotify.d.mts +5 -1
  89. package/dist/social-providers/spotify.mjs +5 -5
  90. package/dist/social-providers/tiktok.d.mts +5 -1
  91. package/dist/social-providers/tiktok.mjs +9 -5
  92. package/dist/social-providers/twitch.d.mts +5 -1
  93. package/dist/social-providers/twitch.mjs +4 -4
  94. package/dist/social-providers/twitter.d.mts +6 -4
  95. package/dist/social-providers/twitter.mjs +9 -9
  96. package/dist/social-providers/vercel.d.mts +5 -1
  97. package/dist/social-providers/vercel.mjs +4 -7
  98. package/dist/social-providers/vk.d.mts +5 -1
  99. package/dist/social-providers/vk.mjs +5 -5
  100. package/dist/social-providers/wechat.d.mts +5 -1
  101. package/dist/social-providers/wechat.mjs +10 -6
  102. package/dist/social-providers/zoom.d.mts +6 -1
  103. package/dist/social-providers/zoom.mjs +15 -9
  104. package/dist/types/context.d.mts +27 -8
  105. package/dist/types/index.d.mts +1 -1
  106. package/dist/types/init-options.d.mts +137 -6
  107. package/dist/types/plugin-client.d.mts +12 -2
  108. package/dist/utils/host.mjs +4 -0
  109. package/dist/utils/url.mjs +4 -3
  110. package/package.json +7 -7
  111. package/src/api/index.ts +82 -0
  112. package/src/context/transaction.ts +45 -12
  113. package/src/db/adapter/factory.ts +127 -64
  114. package/src/db/adapter/index.ts +54 -9
  115. package/src/db/adapter/types.ts +1 -0
  116. package/src/db/get-tables.ts +8 -3
  117. package/src/db/schema/account.ts +14 -2
  118. package/src/db/type.ts +12 -7
  119. package/src/env/env-impl.ts +1 -2
  120. package/src/error/codes.ts +5 -0
  121. package/src/oauth2/create-authorization-url.ts +2 -2
  122. package/src/oauth2/dpop.ts +568 -0
  123. package/src/oauth2/index.ts +61 -2
  124. package/src/oauth2/oauth-provider.ts +140 -10
  125. package/src/oauth2/refresh-access-token.ts +2 -2
  126. package/src/oauth2/scopes.ts +118 -0
  127. package/src/oauth2/utils.ts +2 -5
  128. package/src/oauth2/verify-id-token.ts +111 -0
  129. package/src/oauth2/verify.ts +372 -58
  130. package/src/social-providers/apple.ts +24 -61
  131. package/src/social-providers/atlassian.ts +12 -8
  132. package/src/social-providers/cognito.ts +25 -47
  133. package/src/social-providers/discord.ts +19 -8
  134. package/src/social-providers/dropbox.ts +13 -7
  135. package/src/social-providers/facebook.ts +97 -51
  136. package/src/social-providers/figma.ts +13 -9
  137. package/src/social-providers/github.ts +12 -8
  138. package/src/social-providers/gitlab.ts +14 -8
  139. package/src/social-providers/google.ts +66 -47
  140. package/src/social-providers/huggingface.ts +12 -8
  141. package/src/social-providers/kakao.ts +16 -8
  142. package/src/social-providers/kick.ts +12 -7
  143. package/src/social-providers/line.ts +37 -37
  144. package/src/social-providers/linear.ts +12 -6
  145. package/src/social-providers/linkedin.ts +14 -10
  146. package/src/social-providers/microsoft-entra-id.ts +103 -59
  147. package/src/social-providers/naver.ts +12 -6
  148. package/src/social-providers/notion.ts +12 -6
  149. package/src/social-providers/paybin.ts +14 -11
  150. package/src/social-providers/paypal.ts +6 -25
  151. package/src/social-providers/polar.ts +12 -8
  152. package/src/social-providers/railway.ts +13 -9
  153. package/src/social-providers/reddit.ts +25 -10
  154. package/src/social-providers/roblox.ts +18 -7
  155. package/src/social-providers/salesforce.ts +12 -8
  156. package/src/social-providers/slack.ts +18 -9
  157. package/src/social-providers/spotify.ts +13 -7
  158. package/src/social-providers/tiktok.ts +13 -7
  159. package/src/social-providers/twitch.ts +12 -8
  160. package/src/social-providers/twitter.ts +17 -8
  161. package/src/social-providers/vercel.ts +16 -10
  162. package/src/social-providers/vk.ts +13 -7
  163. package/src/social-providers/wechat.ts +28 -9
  164. package/src/social-providers/zoom.ts +19 -6
  165. package/src/types/context.ts +26 -8
  166. package/src/types/index.ts +7 -0
  167. package/src/types/init-options.ts +159 -8
  168. package/src/types/plugin-client.ts +16 -2
  169. package/src/utils/host.ts +15 -0
  170. package/src/utils/url.ts +10 -4
package/src/api/index.ts CHANGED
@@ -12,6 +12,25 @@ import { runWithEndpointContext } from "../context";
12
12
  import type { AuthContext } from "../types";
13
13
  import { isAPIError } from "../utils/is-api-error";
14
14
 
15
+ /**
16
+ * Response headers that forbid any intermediary (proxy, CDN, browser) from
17
+ * caching a response body. Credential-bearing responses (access/refresh tokens,
18
+ * ID tokens, client secrets, device codes) must carry them.
19
+ *
20
+ * Set `metadata: { noStore: true }` on an endpoint and {@link createAuthEndpoint}
21
+ * applies these to the responses its handler produces: the success body and any
22
+ * error the handler throws. A request rejected by schema or media-type
23
+ * validation before the handler runs is not covered, and carries no credentials
24
+ * to protect. Spread them into a hand-built `Response` or `APIError`'s headers
25
+ * for the rare endpoint that constructs its own response.
26
+ *
27
+ * @see https://datatracker.ietf.org/doc/html/rfc6749#section-5.1
28
+ */
29
+ export const NO_STORE_HEADERS = {
30
+ "Cache-Control": "no-store",
31
+ Pragma: "no-cache",
32
+ } as const;
33
+
15
34
  /**
16
35
  * Better-call's createEndpoint re-throws APIError without exposing the headers
17
36
  * accumulated on ctx.responseHeaders (e.g. Set-Cookie from deleteSessionCookie
@@ -101,8 +120,23 @@ export function createAuthEndpoint<
101
120
  const handler: EndpointHandler<Path, Opts, R> =
102
121
  typeof handlerOrOptions === "function" ? handlerOrOptions : handlerOrNever;
103
122
 
123
+ // Endpoints that return credentials declare `metadata: { noStore: true }`.
124
+ // Emit the no-store headers at the boundary, before the handler runs, so they
125
+ // land on every response the handler produces: a success harvests
126
+ // `responseHeaders`, and a thrown error carries the same headers through
127
+ // `attachResponseHeadersToAPIError`. Validation that rejects the request
128
+ // before the handler runs is not covered (and returns no credentials).
129
+ const noStore =
130
+ (options as { metadata?: { noStore?: boolean } }).metadata?.noStore ===
131
+ true;
132
+
104
133
  // todo: prettify the code, we want to call `runWithEndpointContext` to top level
105
134
  const wrapped: EndpointHandler<Path, Opts, R> = async (ctx) => {
135
+ if (noStore) {
136
+ for (const [name, value] of Object.entries(NO_STORE_HEADERS)) {
137
+ ctx.setHeader(name, value);
138
+ }
139
+ }
106
140
  const runtimeCtx = ctx as unknown as { responseHeaders?: Headers };
107
141
  try {
108
142
  return await runWithEndpointContext(ctx as any, () => handler(ctx));
@@ -132,6 +166,54 @@ export function createAuthEndpoint<
132
166
  );
133
167
  }
134
168
 
169
+ /**
170
+ * Set `metadata.SERVER_ONLY` while preserving any existing metadata
171
+ * (`$Infer`, `openapi`, ...).
172
+ */
173
+ function withServerOnly<Options extends EndpointOptions>(
174
+ options: Options,
175
+ ): Options {
176
+ return {
177
+ ...options,
178
+ metadata: { ...options.metadata, SERVER_ONLY: true },
179
+ } as Options;
180
+ }
181
+
182
+ export namespace createAuthEndpoint {
183
+ /**
184
+ * Declare a **server-only** endpoint.
185
+ *
186
+ * The endpoint is callable through `auth.api.*` from trusted server code but is
187
+ * never registered on the HTTP router and never emitted into the OpenAPI
188
+ * schema. It takes no path because it has no URL to be reached at.
189
+ *
190
+ * Prefer this over the path-less `createAuthEndpoint({ ... }, handler)` form.
191
+ * Setting `metadata.SERVER_ONLY` makes the intent explicit at the call site and
192
+ * keeps the endpoint off the HTTP surface even if a path is later added by
193
+ * mistake: better-call's router skips an endpoint when its path is missing *or*
194
+ * when `SERVER_ONLY` is set, so the two together are defense in depth. Relying
195
+ * on path omission alone is invisible and one keystroke away from exposure.
196
+ *
197
+ * @example
198
+ * ```ts
199
+ * viewBackupCodes: createAuthEndpoint.serverOnly(
200
+ * { method: "POST", body: schema },
201
+ * async (ctx) => { ... },
202
+ * )
203
+ * ```
204
+ */
205
+ export function serverOnly<
206
+ Path extends string,
207
+ Options extends EndpointOptions,
208
+ R,
209
+ >(
210
+ options: Options,
211
+ handler: EndpointHandler<Path, Options, R>,
212
+ ): StrictEndpoint<Path, Options, R> {
213
+ return createAuthEndpoint(withServerOnly(options), handler);
214
+ }
215
+ }
216
+
135
217
  export type AuthEndpoint<
136
218
  Path extends string,
137
219
  Opts extends EndpointOptions,
@@ -1,11 +1,15 @@
1
1
  import type { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { getAsyncLocalStorage } from "@better-auth/core/async_hooks";
3
3
  import type { DBAdapter, DBTransactionAdapter } from "../db/adapter";
4
+ import type { BetterAuthOptions } from "../types";
4
5
  import { __getBetterAuthGlobal } from "./global";
5
6
 
7
+ type StoredAdapter = DBTransactionAdapter<BetterAuthOptions>;
8
+
6
9
  type HookContext = {
7
- adapter: DBTransactionAdapter;
10
+ adapter: StoredAdapter;
8
11
  pendingHooks: Array<() => Promise<void>>;
12
+ isTransactionActive: boolean;
9
13
  };
10
14
 
11
15
  const ensureAsyncStorage = async () => {
@@ -27,21 +31,29 @@ export const getCurrentDBAdapterAsyncLocalStorage = async () => {
27
31
  return ensureAsyncStorage();
28
32
  };
29
33
 
30
- export const getCurrentAdapter = async (
31
- fallback: DBTransactionAdapter,
32
- ): Promise<DBTransactionAdapter> => {
34
+ export const getCurrentAdapter = async <
35
+ Options extends BetterAuthOptions = BetterAuthOptions,
36
+ >(
37
+ fallback: DBTransactionAdapter<Options>,
38
+ ): Promise<DBTransactionAdapter<Options>> => {
33
39
  return ensureAsyncStorage()
34
40
  .then((als) => {
35
41
  const store = als.getStore();
36
- return store?.adapter || fallback;
42
+ return (
43
+ (store?.adapter as DBTransactionAdapter<Options> | undefined) ||
44
+ fallback
45
+ );
37
46
  })
38
47
  .catch(() => {
39
48
  return fallback;
40
49
  });
41
50
  };
42
51
 
43
- export const runWithAdapter = async <R>(
44
- adapter: DBAdapter,
52
+ export const runWithAdapter = async <
53
+ R,
54
+ Options extends BetterAuthOptions = BetterAuthOptions,
55
+ >(
56
+ adapter: DBAdapter<Options>,
45
57
  fn: () => R,
46
58
  ): Promise<R> => {
47
59
  let called = false;
@@ -53,7 +65,14 @@ export const runWithAdapter = async <R>(
53
65
  let error: unknown;
54
66
  let hasError = false;
55
67
  try {
56
- result = await als.run({ adapter, pendingHooks }, fn);
68
+ result = await als.run(
69
+ {
70
+ adapter: adapter as unknown as StoredAdapter,
71
+ pendingHooks,
72
+ isTransactionActive: false,
73
+ },
74
+ fn,
75
+ );
57
76
  } catch (err) {
58
77
  error = err;
59
78
  hasError = true;
@@ -75,21 +94,35 @@ export const runWithAdapter = async <R>(
75
94
  });
76
95
  };
77
96
 
78
- export const runWithTransaction = async <R>(
79
- adapter: DBAdapter,
97
+ export const runWithTransaction = async <
98
+ R,
99
+ Options extends BetterAuthOptions = BetterAuthOptions,
100
+ >(
101
+ adapter: DBAdapter<Options>,
80
102
  fn: () => R,
81
103
  ): Promise<R> => {
82
- let called = true;
104
+ let called = false;
83
105
  return ensureAsyncStorage()
84
106
  .then(async (als) => {
85
107
  called = true;
108
+ const store = als.getStore();
109
+ if (store?.isTransactionActive) {
110
+ return fn();
111
+ }
86
112
  const pendingHooks: Array<() => Promise<void>> = [];
87
113
  let result: Awaited<R>;
88
114
  let error: unknown;
89
115
  let hasError = false;
90
116
  try {
91
117
  result = await adapter.transaction(async (trx) => {
92
- return als.run({ adapter: trx, pendingHooks }, fn);
118
+ return als.run(
119
+ {
120
+ adapter: trx as unknown as StoredAdapter,
121
+ pendingHooks,
122
+ isTransactionActive: true,
123
+ },
124
+ fn,
125
+ );
93
126
  });
94
127
  } catch (e) {
95
128
  hasError = true;
@@ -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;
@@ -1057,6 +1063,14 @@ export const createAdapterFactory =
1057
1063
  update: data,
1058
1064
  }),
1059
1065
  );
1066
+ if (
1067
+ typeof updatedCount !== "number" ||
1068
+ !Number.isFinite(updatedCount)
1069
+ ) {
1070
+ throw new BetterAuthError(
1071
+ `Adapter "${config.adapterId}" updateMany must return a finite number affected row count.`,
1072
+ );
1073
+ }
1060
1074
  debugLog(
1061
1075
  { method: "updateMany" },
1062
1076
  `${formatTransactionId(thisTransactionId)} ${formatStep(3, 4)}`,
@@ -1341,67 +1355,19 @@ export const createAdapterFactory =
1341
1355
  { model, where },
1342
1356
  );
1343
1357
 
1344
- let res: T | null;
1345
- let resultNeedsOutputTransform = true;
1346
- if (adapterInstance.consumeOne) {
1347
- res = await withSpan(
1348
- `db consumeOne ${model}`,
1349
- {
1350
- [ATTR_DB_OPERATION_NAME]: "consumeOne",
1351
- [ATTR_DB_COLLECTION_NAME]: model,
1352
- },
1353
- () => adapterInstance.consumeOne!<T>({ model, where }),
1354
- );
1355
- } else {
1356
- // TODO(consume-one-required): adapters without native `consumeOne`
1357
- // fall back to `transaction(findMany + deleteMany)`. Race-safe on
1358
- // engines with real transaction isolation; race window narrows
1359
- // (does not close) on adapters that fall through to sequential
1360
- // execution. Remove this branch when consumeOne becomes required.
1361
- // FIXME(consume-one-nested-transaction): custom adapters without a
1362
- // native consumeOne have no portable signal for "already inside a
1363
- // transaction". First-party adapters mark transaction-scoped
1364
- // adapters as as-is; make that capability explicit in the next
1365
- // breaking adapter contract.
1366
- res = await withSpan(
1367
- `db consumeOne ${model}`,
1368
- {
1369
- [ATTR_DB_OPERATION_NAME]: "consumeOne",
1370
- [ATTR_DB_COLLECTION_NAME]: model,
1371
- },
1372
- () =>
1373
- adapter.transaction(async (trx) => {
1374
- const rows = await trx.findMany<Record<string, any>>({
1375
- model: unsafeModel,
1376
- where: unsafeWhere,
1377
- limit: 1,
1378
- });
1379
- const target = rows[0];
1380
- if (!target) return null;
1381
- const deleted = await trx.deleteMany({
1382
- model: unsafeModel,
1383
- where: [
1384
- ...unsafeWhere,
1385
- {
1386
- field: "id",
1387
- value: target.id,
1388
- operator: "eq",
1389
- connector: "AND",
1390
- mode: "sensitive",
1391
- },
1392
- ],
1393
- });
1394
- // A non-numeric count coerces to a false miss, so fail loud.
1395
- if (typeof deleted !== "number") {
1396
- throw new BetterAuthError(
1397
- `Adapter "${config.adapterId}" returned a non-numeric value from deleteMany during the consumeOne fallback. Return the number of deleted rows, or implement a native consumeOne for atomic single-use consumption.`,
1398
- );
1399
- }
1400
- return deleted > 0 ? (target as T) : null;
1401
- }),
1358
+ if (typeof adapterInstance.consumeOne !== "function") {
1359
+ throw new BetterAuthError(
1360
+ `Adapter "${config.adapterId}" must implement consumeOne for atomic single-use credential consumption.`,
1402
1361
  );
1403
- resultNeedsOutputTransform = false;
1404
1362
  }
1363
+ const res = await withSpan(
1364
+ `db consumeOne ${model}`,
1365
+ {
1366
+ [ATTR_DB_OPERATION_NAME]: "consumeOne",
1367
+ [ATTR_DB_COLLECTION_NAME]: model,
1368
+ },
1369
+ () => adapterInstance.consumeOne<T>({ model, where }),
1370
+ );
1405
1371
 
1406
1372
  debugLog(
1407
1373
  { method: "consumeOne" },
@@ -1410,11 +1376,7 @@ export const createAdapterFactory =
1410
1376
  { model, data: res },
1411
1377
  );
1412
1378
  let transformed: any = res;
1413
- if (
1414
- !config.disableTransformOutput &&
1415
- resultNeedsOutputTransform &&
1416
- res
1417
- ) {
1379
+ if (!config.disableTransformOutput && res) {
1418
1380
  transformed = await transformOutput(
1419
1381
  res as Record<string, any>,
1420
1382
  unsafeModel,
@@ -1430,6 +1392,107 @@ export const createAdapterFactory =
1430
1392
  );
1431
1393
  return transformed as T | null;
1432
1394
  },
1395
+ incrementOne: async <T>({
1396
+ model: unsafeModel,
1397
+ where: unsafeWhere,
1398
+ increment: unsafeIncrement,
1399
+ set: unsafeSet,
1400
+ }: {
1401
+ model: string;
1402
+ where: Where[];
1403
+ increment: Record<string, number>;
1404
+ set?: Record<string, unknown> | undefined;
1405
+ }): Promise<T | null> => {
1406
+ const hasIncrement = Object.keys(unsafeIncrement).length > 0;
1407
+ const hasSet = !!unsafeSet && Object.keys(unsafeSet).length > 0;
1408
+ if (!hasIncrement && !hasSet) {
1409
+ // An empty `increment` and empty `set` compiles to `UPDATE ... SET `
1410
+ // with no assignments, which is a syntax error on kysely, drizzle, and
1411
+ // Prisma. Fail fast with an actionable message instead.
1412
+ throw new BetterAuthError(
1413
+ "incrementOne requires a non-empty `increment` or `set`; both were empty.",
1414
+ );
1415
+ }
1416
+ transactionId++;
1417
+ const thisTransactionId = transactionId;
1418
+ const model = getModelName(unsafeModel);
1419
+ const where = transformWhereClause({
1420
+ model: unsafeModel,
1421
+ where: unsafeWhere,
1422
+ action: "incrementOne",
1423
+ });
1424
+ unsafeModel = getDefaultModelName(unsafeModel);
1425
+ debugLog(
1426
+ { method: "incrementOne" },
1427
+ `${formatTransactionId(thisTransactionId)} ${formatStep(1, 3)}`,
1428
+ `${formatMethod("incrementOne")} ${formatAction("IncrementOne")}:`,
1429
+ { model, where, increment: unsafeIncrement, set: unsafeSet },
1430
+ );
1431
+
1432
+ if (typeof adapterInstance.incrementOne !== "function") {
1433
+ throw new BetterAuthError(
1434
+ `Adapter "${config.adapterId}" must implement incrementOne for atomic guarded counter updates.`,
1435
+ );
1436
+ }
1437
+ const mappedKeys = config.mapKeysTransformInput ?? {};
1438
+ const increment: Record<string, number> = {};
1439
+ for (const [field, delta] of Object.entries(unsafeIncrement)) {
1440
+ increment[
1441
+ mappedKeys[field] || getFieldName({ model: unsafeModel, field })
1442
+ ] = delta;
1443
+ }
1444
+ let set: Record<string, unknown> | undefined;
1445
+ if (unsafeSet && !config.disableTransformInput) {
1446
+ set = await transformInput(unsafeSet, unsafeModel, "update");
1447
+ } else {
1448
+ set = unsafeSet;
1449
+ }
1450
+ if (
1451
+ Object.keys(increment).length === 0 &&
1452
+ (!set || Object.keys(set).length === 0)
1453
+ ) {
1454
+ throw new BetterAuthError(
1455
+ "incrementOne resolved to an empty update: every increment/set field was unknown to the schema or transformed away.",
1456
+ );
1457
+ }
1458
+ const res = await withSpan(
1459
+ `db incrementOne ${model}`,
1460
+ {
1461
+ [ATTR_DB_OPERATION_NAME]: "incrementOne",
1462
+ [ATTR_DB_COLLECTION_NAME]: model,
1463
+ },
1464
+ () =>
1465
+ adapterInstance.incrementOne<T>({
1466
+ model,
1467
+ where,
1468
+ increment,
1469
+ set,
1470
+ }),
1471
+ );
1472
+
1473
+ debugLog(
1474
+ { method: "incrementOne" },
1475
+ `${formatTransactionId(thisTransactionId)} ${formatStep(2, 3)}`,
1476
+ `${formatMethod("incrementOne")} ${formatAction("DB Result")}:`,
1477
+ { model, data: res },
1478
+ );
1479
+ let transformed: any = res;
1480
+ if (!config.disableTransformOutput && res) {
1481
+ transformed = await transformOutput(
1482
+ res as Record<string, any>,
1483
+ unsafeModel,
1484
+ undefined,
1485
+ undefined,
1486
+ );
1487
+ }
1488
+ debugLog(
1489
+ { method: "incrementOne" },
1490
+ `${formatTransactionId(thisTransactionId)} ${formatStep(3, 3)}`,
1491
+ `${formatMethod("incrementOne")} ${formatAction("Parsed Result")}:`,
1492
+ { model, data: transformed },
1493
+ );
1494
+ return transformed as T | null;
1495
+ },
1433
1496
  count: async ({
1434
1497
  model: unsafeModel,
1435
1498
  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.
@@ -458,12 +460,40 @@ export type DBAdapter<Options extends BetterAuthOptions = BetterAuthOptions> = {
458
460
  * race-safe primitive for consuming single-use credentials
459
461
  * (verification tokens, authorization codes, one-time tokens).
460
462
  *
461
- * Always defined on the factory-wrapped adapter. When the underlying
462
- * `CustomAdapter` does not implement `consumeOne`, the factory provides
463
- * a fallback that wraps `findMany + deleteMany` in `transaction(...)`
464
- * and returns the row only when the delete reports an affected row.
463
+ * Always defined on the factory-wrapped adapter. The underlying
464
+ * `CustomAdapter` must implement this natively; there is no portable
465
+ * fallback that can guarantee cross-process single-use semantics.
465
466
  */
466
467
  consumeOne: <T>(data: { model: string; where: Where[] }) => Promise<T | null>;
468
+ /**
469
+ * Atomically apply signed numeric deltas to a single row matching the where
470
+ * clause. For each entry in `increment`, the operation applies
471
+ * `field = field + delta` in one atomic step; a negative delta decrements.
472
+ *
473
+ * The `where` clause is both the selector AND the guard: comparison
474
+ * operators are honored, so passing `{ field: "remaining", operator: "gt",
475
+ * value: 0 }` only mutates the row while `remaining` is still above zero.
476
+ * When the guard matches no row, the operation makes no change and returns
477
+ * `null`.
478
+ *
479
+ * The optional `set` map assigns absolute values to fields in the same
480
+ * atomic operation, alongside the increments.
481
+ *
482
+ * Returns the updated row, or `null` when the guard matched no row. Under
483
+ * concurrent invocation against the same row, this is the race-safe
484
+ * primitive for guarded counter updates (e.g. decrementing a remaining-uses
485
+ * counter only while it is still positive).
486
+ *
487
+ * Always defined on the factory-wrapped adapter. The underlying
488
+ * `CustomAdapter` must implement this natively; there is no portable
489
+ * fallback that can guarantee guarded counter semantics across runtimes.
490
+ */
491
+ incrementOne: <T>(data: {
492
+ model: string;
493
+ where: Where[];
494
+ increment: Record<string, number>;
495
+ set?: Record<string, unknown> | undefined;
496
+ }) => Promise<T | null>;
467
497
  /**
468
498
  * Execute multiple operations in a transaction.
469
499
  * If the adapter doesn't support transactions, operations will be executed sequentially.
@@ -551,17 +581,32 @@ export interface CustomAdapter {
551
581
  where: CleanedWhere[];
552
582
  }) => Promise<number>;
553
583
  /**
554
- * Optional native atomic single-row consume. When omitted, the adapter
555
- * factory falls back to `transaction(findMany + deleteMany)`.
584
+ * Native atomic single-row consume.
556
585
  * Implementing this method natively (e.g. `DELETE ... RETURNING *`,
557
586
  * `findOneAndDelete`, `OUTPUT deleted.*`) gives one round trip and the
558
587
  * strongest race-safety guarantee. Implementations must delete at most
559
- * one matching row. TODO(consume-one-required): tighten to required in the
560
- * next minor on `next`.
588
+ * one matching row.
589
+ */
590
+ consumeOne: <T>(data: {
591
+ model: string;
592
+ where: CleanedWhere[];
593
+ }) => Promise<T | null>;
594
+ /**
595
+ * Native atomic guarded counter mutation. Applies
596
+ * `field = field + delta` for each entry in `increment` (negative deltas
597
+ * decrement), with `where` acting as both selector and guard and `set`
598
+ * assigning absolute values in the same operation. Returns the updated row,
599
+ * or `null` when the guard matched no row.
600
+ *
601
+ * Implementing this natively (e.g. `UPDATE ... SET n = n + $delta WHERE ...
602
+ * RETURNING *`) gives one round trip and the strongest race-safety
603
+ * guarantee.
561
604
  */
562
- consumeOne?: <T>(data: {
605
+ incrementOne: <T>(data: {
563
606
  model: string;
564
607
  where: CleanedWhere[];
608
+ increment: Record<string, number>;
609
+ set?: Record<string, unknown> | undefined;
565
610
  }) => Promise<T | null>;
566
611
  count: ({
567
612
  model,
@@ -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;
@@ -261,10 +261,15 @@ export const getAuthTables = (
261
261
  options.account?.fields?.refreshTokenExpiresAt ||
262
262
  "refreshTokenExpiresAt",
263
263
  },
264
- scope: {
265
- type: "string",
264
+ // Renamed from the legacy `scope` column. The migration generator
265
+ // only adds this column; it does not transform the legacy `scope`
266
+ // value. Upgrading installs need a manual data migration (split
267
+ // legacy `scope` on comma/space, trim, drop empties, dedupe). Order
268
+ // is insignificant per RFC 6749 §3.3.
269
+ grantedScopes: {
270
+ type: "string[]",
266
271
  required: false,
267
- fieldName: options.account?.fields?.scope || "scope",
272
+ fieldName: options.account?.fields?.grantedScopes || "grantedScopes",
268
273
  },
269
274
  password: {
270
275
  type: "string",
@@ -23,9 +23,21 @@ 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 scopes the user has granted, as last observed (durable, per-account,
27
+ * the unit of revocation and the refresh ceiling). A native array, not a
28
+ * delimited string: scope order is insignificant per RFC 6749 §3.3, so the
29
+ * value is normalized (trimmed, deduped, sorted) on write.
30
+ *
31
+ * Renamed from the legacy comma-joined `scope` string. Breaking, with no
32
+ * automatic data migration (and no read-time shim): the migration generator
33
+ * only adds the new `grantedScopes` column, so legacy accounts read as empty
34
+ * here until an upgrade backfills `grantedScopes` from the old `scope` values
35
+ * (split on comma/space, trim, drop empties, dedupe). See the release
36
+ * changeset for the backfill.
37
+ *
38
+ * @see https://www.rfc-editor.org/rfc/rfc6749#section-3.3
27
39
  */
28
- scope: z.string().nullish(),
40
+ grantedScopes: z.array(z.string()).nullish(),
29
41
  /**
30
42
  * Password is only stored in the credential provider
31
43
  */
package/src/db/type.ts CHANGED
@@ -313,16 +313,21 @@ export interface SecondaryStorage {
313
313
  get: (key: string) => Awaitable<unknown>;
314
314
  /**
315
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.
316
321
  *
317
- * This is optional for backwards compatibility with existing secondary
318
- * storage implementations. Single-use credential consumers use it when
319
- * present to avoid a read-then-delete race.
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.
320
326
  *
321
- * TODO(secondary-storage-atomic-consume): make this required in the next
322
- * breaking release, or require database-backed verification storage for
323
- * security-sensitive consume paths.
327
+ * Required so secondary-storage-backed rate limiting can enforce the limit
328
+ * in one distributed-safe operation.
324
329
  */
325
- getAndDelete?: (key: string) => Awaitable<unknown>;
330
+ increment: (key: string, ttl: number) => Awaitable<number>;
326
331
  set: (
327
332
  /**
328
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";
@@ -29,6 +29,11 @@ export const BASE_ERROR_CODES = defineErrorCodes({
29
29
  TOKEN_EXPIRED: "Token expired",
30
30
  ID_TOKEN_NOT_SUPPORTED: "id_token not supported",
31
31
  FAILED_TO_GET_USER_INFO: "Failed to get user info",
32
+ PROVIDER_NOT_SUPPORTED: "Provider not supported",
33
+ TOKEN_REFRESH_NOT_SUPPORTED: "Token refresh not supported",
34
+ REFRESH_TOKEN_NOT_FOUND: "Refresh token not found",
35
+ FAILED_TO_GET_ACCESS_TOKEN: "Failed to get a valid access token",
36
+ FAILED_TO_REFRESH_ACCESS_TOKEN: "Failed to refresh access token",
32
37
  USER_EMAIL_NOT_FOUND: "User email not found",
33
38
  EMAIL_NOT_VERIFIED: "Email not verified",
34
39
  PASSWORD_TOO_SHORT: "Password too short",
@@ -70,7 +70,7 @@ export async function createAuthorizationURL({
70
70
  }
71
71
  url.searchParams.set("client_id", primaryClientId);
72
72
  url.searchParams.set("state", state);
73
- if (scopes) {
73
+ if (scopes?.length) {
74
74
  url.searchParams.set("scope", scopes.join(scopeJoiner || " "));
75
75
  }
76
76
  url.searchParams.set("redirect_uri", options.redirectURI || redirectURI);
@@ -107,5 +107,5 @@ export async function createAuthorizationURL({
107
107
  url.searchParams.set(key, value);
108
108
  }
109
109
  }
110
- return url;
110
+ return { url, requestedScopes: scopes ?? [] };
111
111
  }