better-auth 1.7.1 → 1.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/dist/api/index.d.mts +6 -22
  2. package/dist/api/index.mjs +2 -0
  3. package/dist/api/middlewares/origin-check.mjs +9 -5
  4. package/dist/api/routes/account.d.mts +1 -9
  5. package/dist/api/routes/account.mjs +0 -6
  6. package/dist/api/routes/callback.mjs +7 -7
  7. package/dist/api/routes/email-verification.mjs +4 -2
  8. package/dist/api/routes/error.mjs +12 -8
  9. package/dist/api/routes/password.mjs +0 -2
  10. package/dist/api/routes/session.mjs +12 -5
  11. package/dist/api/routes/sign-in.d.mts +2 -2
  12. package/dist/api/routes/sign-in.mjs +1 -4
  13. package/dist/api/routes/sign-up.mjs +0 -2
  14. package/dist/api/routes/update-user.mjs +0 -2
  15. package/dist/api/to-auth-endpoints.mjs +2 -0
  16. package/dist/auth/base.mjs +13 -1
  17. package/dist/auth/trusted-origins.mjs +24 -5
  18. package/dist/client/config.d.mts +2 -0
  19. package/dist/client/config.mjs +1 -0
  20. package/dist/client/lynx/index.d.mts +1 -1
  21. package/dist/client/react/index.d.mts +1 -1
  22. package/dist/client/solid/index.d.mts +1 -1
  23. package/dist/client/svelte/index.d.mts +1 -1
  24. package/dist/client/vanilla.d.mts +1 -1
  25. package/dist/client/vue/index.d.mts +32 -21
  26. package/dist/client/vue/index.mjs +23 -5
  27. package/dist/context/create-context.mjs +2 -0
  28. package/dist/cookies/cache.mjs +10 -2
  29. package/dist/cookies/session-store.d.mts +0 -1
  30. package/dist/cookies/session-store.mjs +16 -5
  31. package/dist/db/adapter-kysely.mjs +1 -2
  32. package/dist/db/get-migration.d.mts +1 -0
  33. package/dist/db/get-migration.mjs +49 -56
  34. package/dist/db/internal-adapter.mjs +17 -20
  35. package/dist/db/schema.d.mts +0 -1
  36. package/dist/db/with-hooks.mjs +7 -7
  37. package/dist/oauth2/account-key.mjs +1 -5
  38. package/dist/oauth2/errors.mjs +4 -3
  39. package/dist/oauth2/link-account.d.mts +0 -1
  40. package/dist/oauth2/link-account.mjs +5 -11
  41. package/dist/oauth2/state.mjs +1 -1
  42. package/dist/package.mjs +1 -1
  43. package/dist/plugins/admin/routes.mjs +1 -4
  44. package/dist/plugins/anonymous/index.mjs +5 -1
  45. package/dist/plugins/anonymous/types.d.mts +2 -3
  46. package/dist/plugins/device-authorization/index.d.mts +11 -11
  47. package/dist/plugins/email-otp/routes.mjs +0 -2
  48. package/dist/plugins/generic-oauth/index.mjs +14 -10
  49. package/dist/plugins/generic-oauth/providers/auth0.mjs +2 -4
  50. package/dist/plugins/generic-oauth/providers/keycloak.mjs +6 -9
  51. package/dist/plugins/generic-oauth/providers/line.mjs +0 -1
  52. package/dist/plugins/generic-oauth/providers/microsoft-entra-id.mjs +15 -5
  53. package/dist/plugins/generic-oauth/providers/okta.mjs +6 -9
  54. package/dist/plugins/generic-oauth/providers/slack.mjs +0 -1
  55. package/dist/plugins/generic-oauth/types.d.mts +0 -9
  56. package/dist/plugins/haveibeenpwned/index.d.mts +9 -1
  57. package/dist/plugins/haveibeenpwned/index.mjs +33 -13
  58. package/dist/plugins/index.d.mts +2 -2
  59. package/dist/plugins/index.mjs +2 -2
  60. package/dist/plugins/jwt/verify.mjs +2 -2
  61. package/dist/plugins/last-login-method/index.mjs +1 -0
  62. package/dist/plugins/oauth-proxy/index.d.mts +17 -0
  63. package/dist/plugins/oauth-proxy/index.mjs +145 -99
  64. package/dist/plugins/one-tap/index.mjs +0 -1
  65. package/dist/plugins/open-api/generator.mjs +15 -1
  66. package/dist/plugins/organization/has-permission.mjs +4 -2
  67. package/dist/plugins/phone-number/routes.mjs +0 -2
  68. package/dist/plugins/siwe/index.mjs +5 -6
  69. package/dist/plugins/two-factor/client.d.mts +1 -0
  70. package/dist/plugins/two-factor/error-code.d.mts +1 -0
  71. package/dist/plugins/two-factor/error-code.mjs +1 -0
  72. package/dist/plugins/two-factor/index.d.mts +1 -0
  73. package/dist/plugins/two-factor/index.mjs +3 -2
  74. package/dist/state.d.mts +0 -13
  75. package/dist/test-utils/http-test-instance.d.mts +1 -1
  76. package/dist/test-utils/test-instance.mjs +27 -10
  77. package/package.json +10 -10
@@ -1,6 +1,9 @@
1
1
  import { wildcardMatch } from "../utils/wildcard.mjs";
2
2
  import { getHost, getOrigin, getProtocol } from "../utils/url.mjs";
3
3
  //#region src/auth/trusted-origins.ts
4
+ const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f-\u009f]/;
5
+ const RELATIVE_URL_PARSER_ORIGIN = "https://better-auth.invalid";
6
+ const ENCODED_PATH_SEPARATOR_PATTERN = /%2[fF]|%5[cC]/;
4
7
  /**
5
8
  * Resolves `.` and `..` segments in a path after percent-decoding so a
6
9
  * path-pinned pattern cannot be bypassed with traversal: e.g.
@@ -31,6 +34,7 @@ const normalizePath = (path) => {
31
34
  * a path-pinned pattern.
32
35
  */
33
36
  const parseCustomSchemeOrigin = (value) => {
37
+ if (CONTROL_CHARACTER_PATTERN.test(value)) return null;
34
38
  const schemeEnd = value.indexOf(":");
35
39
  if (schemeEnd <= 0) return null;
36
40
  const scheme = value.slice(0, schemeEnd).toLowerCase();
@@ -47,7 +51,8 @@ const parseCustomSchemeOrigin = (value) => {
47
51
  rest = rest.slice(authorityEnd);
48
52
  }
49
53
  }
50
- const path = normalizePath(rest.replace(/[?#].*$/, ""));
54
+ const pathEnd = rest.search(/[?#]/);
55
+ const path = normalizePath(pathEnd === -1 ? rest : rest.slice(0, pathEnd));
51
56
  return {
52
57
  scheme,
53
58
  authority: authority.toLowerCase(),
@@ -55,6 +60,23 @@ const parseCustomSchemeOrigin = (value) => {
55
60
  };
56
61
  };
57
62
  /**
63
+ * Validates root-relative redirects against ambiguous browser and router parsing.
64
+ *
65
+ * @see https://www.rfc-editor.org/rfc/rfc3986.html#section-4.2
66
+ * @see https://url.spec.whatwg.org/#concept-basic-url-parser
67
+ */
68
+ const isSafeRelativeURL = (value) => {
69
+ if (!value.startsWith("/") || value.startsWith("//") || value.includes("\\") || CONTROL_CHARACTER_PATTERN.test(value)) return false;
70
+ const pathEnd = value.search(/[?#]/);
71
+ const path = pathEnd === -1 ? value : value.slice(0, pathEnd);
72
+ if (ENCODED_PATH_SEPARATOR_PATTERN.test(path)) return false;
73
+ try {
74
+ return new URL(value, RELATIVE_URL_PARSER_ORIGIN).origin === RELATIVE_URL_PARSER_ORIGIN;
75
+ } catch {
76
+ return false;
77
+ }
78
+ };
79
+ /**
58
80
  * Matches the given url against an origin or origin pattern
59
81
  * See "options.trustedOrigins" for details of supported patterns
60
82
  *
@@ -64,10 +86,7 @@ const parseCustomSchemeOrigin = (value) => {
64
86
  * @returns {boolean} true if the URL matches the origin pattern, false otherwise.
65
87
  */
66
88
  const matchesOriginPattern = (url, pattern, settings) => {
67
- if (url.startsWith("/")) {
68
- if (settings?.allowRelativePaths) return url.startsWith("/") && /^\/(?!\/|\\|%2f|%5c)[\w\-.\+/@]*(?:\?[\w\-.\+/=&%@]*)?$/.test(url);
69
- return false;
70
- }
89
+ if (url.startsWith("/")) return settings?.allowRelativePaths === true && isSafeRelativeURL(url);
71
90
  if (pattern.includes("*") || pattern.includes("?")) {
72
91
  if (pattern.includes("://")) return wildcardMatch(pattern)(getOrigin(url) || url);
73
92
  const host = getHost(url);
@@ -1,5 +1,6 @@
1
1
  import { SessionData } from "./session-atom.mjs";
2
2
  import { BetterAuthClientOptions, ClientAtomListener } from "@better-auth/core";
3
+ import * as _$nanostores from "nanostores";
3
4
  import { WritableAtom } from "nanostores";
4
5
  import * as _$_better_fetch_fetch0 from "@better-fetch/fetch";
5
6
 
@@ -11,6 +12,7 @@ declare const getClientConfig: (options?: BetterAuthClientOptions | undefined, l
11
12
  pluginPathMethods: Record<string, "GET" | "POST">;
12
13
  atomListeners: ClientAtomListener[];
13
14
  hydrateSession: (sessionData: SessionData | null) => void;
15
+ $sessionSignal: _$nanostores.PreinitializedWritableAtom<boolean> & object;
14
16
  $fetch: _$_better_fetch_fetch0.BetterFetch<{
15
17
  plugins: (_$_better_fetch_fetch0.BetterFetchPlugin<Record<string, any>> | {
16
18
  id: string;
@@ -104,6 +104,7 @@ const getClientConfig = (options, loadEnv) => {
104
104
  pluginPathMethods,
105
105
  atomListeners,
106
106
  hydrateSession,
107
+ $sessionSignal,
107
108
  $fetch,
108
109
  $store
109
110
  };
@@ -22,7 +22,7 @@ type ClientSession<Option extends BetterAuthClientOptions> = InferClientAPI<Opti
22
22
  * Lynx client returned by `createAuthClient`.
23
23
  */
24
24
  type LynxAuthClient<Option extends BetterAuthClientOptions> = UnionToIntersection<InferResolvedHooks<Option>> & InferClientAPI<Option> & InferActions<Option> & {
25
- hydrateSession: (session: NonNullable<ClientSession<Option>> | null) => void;
25
+ hydrateSession(session: NonNullable<ClientSession<Option>> | null): void;
26
26
  useSession: () => {
27
27
  data: ClientSession<Option>;
28
28
  isPending: boolean;
@@ -22,7 +22,7 @@ type ClientSession<Option extends BetterAuthClientOptions> = InferClientAPI<Opti
22
22
  * React client returned by `createAuthClient`.
23
23
  */
24
24
  type ReactAuthClient<Option extends BetterAuthClientOptions> = UnionToIntersection<InferResolvedHooks<Option>> & InferClientAPI<Option> & InferActions<Option> & {
25
- hydrateSession: (session: NonNullable<ClientSession<Option>> | null) => void;
25
+ hydrateSession(session: NonNullable<ClientSession<Option>> | null): void;
26
26
  useSession: () => {
27
27
  data: ClientSession<Option>;
28
28
  isPending: boolean;
@@ -22,7 +22,7 @@ type ClientSession<Option extends BetterAuthClientOptions> = InferClientAPI<Opti
22
22
  * Solid client returned by `createAuthClient`.
23
23
  */
24
24
  type SolidAuthClient<Option extends BetterAuthClientOptions> = UnionToIntersection<InferResolvedHooks<Option>> & InferClientAPI<Option> & InferActions<Option> & {
25
- hydrateSession: (session: NonNullable<ClientSession<Option>> | null) => void;
25
+ hydrateSession(session: NonNullable<ClientSession<Option>> | null): void;
26
26
  useSession: () => Accessor<{
27
27
  data: ClientSession<Option>;
28
28
  isPending: boolean;
@@ -22,7 +22,7 @@ type ClientSession<Option extends BetterAuthClientOptions> = InferClientAPI<Opti
22
22
  * Svelte client returned by `createAuthClient`.
23
23
  */
24
24
  type SvelteAuthClient<Option extends BetterAuthClientOptions> = UnionToIntersection<InferResolvedHooks<Option>> & InferClientAPI<Option> & InferActions<Option> & {
25
- hydrateSession: (session: NonNullable<ClientSession<Option>> | null) => void;
25
+ hydrateSession(session: NonNullable<ClientSession<Option>> | null): void;
26
26
  useSession: () => Atom<{
27
27
  data: ClientSession<Option>;
28
28
  error: BetterFetchError | null;
@@ -20,7 +20,7 @@ type ClientSession<Option extends BetterAuthClientOptions> = InferClientAPI<Opti
20
20
  * Client returned by `createAuthClient`.
21
21
  */
22
22
  type AuthClient<Option extends BetterAuthClientOptions> = UnionToIntersection<InferResolvedHooks<Option>> & InferClientAPI<Option> & InferActions<Option> & {
23
- hydrateSession: (session: NonNullable<ClientSession<Option>> | null) => void;
23
+ hydrateSession(session: NonNullable<ClientSession<Option>> | null): void;
24
24
  useSession: Atom<{
25
25
  data: ClientSession<Option>;
26
26
  error: BetterFetchError | null;
@@ -4,7 +4,7 @@ import { getClientConfig } from "../config.mjs";
4
4
  import { BetterAuthClientOptions } from "@better-auth/core";
5
5
  import { BASE_ERROR_CODES } from "@better-auth/core/error";
6
6
  import { BetterFetchError, BetterFetchResponse } from "@better-fetch/fetch";
7
- import { DeepReadonly, Ref } from "vue";
7
+ import { DeepReadonly, Ref, WatchSource } from "vue";
8
8
  export * from "nanostores";
9
9
  export * from "@better-fetch/fetch";
10
10
 
@@ -18,32 +18,43 @@ type ClientConfig = ReturnType<typeof getClientConfig>;
18
18
  type ClientSession<Option extends BetterAuthClientOptions> = InferClientAPI<Option> extends {
19
19
  getSession: () => Promise<infer Res>;
20
20
  } ? Res extends BetterFetchResponse<infer S> ? S : Res : never;
21
- type VueUseSession<Option extends BetterAuthClientOptions> = {
22
- (): DeepReadonly<Ref<{
23
- data: ClientSession<Option>;
24
- isPending: boolean;
25
- isRefetching: boolean;
26
- error: BetterFetchError | null;
27
- refetch: (queryParams?: {
28
- query?: SessionQueryParams;
29
- } | undefined) => Promise<void>;
30
- }>>;
31
- <F extends (...args: any) => any>(useFetch: F): Promise<{
32
- data: Ref<ClientSession<Option>>;
33
- isPending: false;
34
- error: Ref<{
35
- message?: string | undefined;
36
- status: number;
37
- statusText: string;
38
- }>;
21
+ /**
22
+ * Minimal Nuxt-compatible fetch contract for `useSession(useFetch)`.
23
+ * Compatibility with Nuxt's `useFetch` and `UseFetchOptions` is checked by the Nuxt fixture type test.
24
+ */
25
+ type SessionFetch = (url: string, options: {
26
+ headers?: HeadersInit;
27
+ key: string;
28
+ watch: WatchSource<unknown>[];
29
+ }) => Promise<{
30
+ data: Ref<unknown>;
31
+ error: Ref<unknown>;
32
+ }>;
33
+ type VueSessionState<Option extends BetterAuthClientOptions> = DeepReadonly<Ref<{
34
+ data: ClientSession<Option>;
35
+ isPending: boolean;
36
+ isRefetching: boolean;
37
+ error: BetterFetchError | null;
38
+ refetch: (queryParams?: {
39
+ query?: SessionQueryParams;
40
+ } | undefined) => Promise<void>;
41
+ }>>;
42
+ type SessionFetchResult<Option extends BetterAuthClientOptions> = {
43
+ data: Ref<ClientSession<Option>>;
44
+ isPending: false;
45
+ error: Ref<{
46
+ message?: string | undefined;
47
+ status: number;
48
+ statusText: string;
39
49
  }>;
40
50
  };
41
51
  /**
42
52
  * Vue client returned by `createAuthClient`.
43
53
  */
44
54
  type VueAuthClient<Option extends BetterAuthClientOptions> = UnionToIntersection<InferResolvedHooks<Option>> & InferClientAPI<Option> & InferActions<Option> & {
45
- hydrateSession: (session: NonNullable<ClientSession<Option>> | null) => void;
46
- useSession: VueUseSession<Option>;
55
+ hydrateSession(session: NonNullable<ClientSession<Option>> | null): void;
56
+ useSession(): VueSessionState<Option>;
57
+ useSession(useFetch: SessionFetch): Promise<SessionFetchResult<Option>>;
47
58
  $Infer: {
48
59
  Session: NonNullable<ClientSession<Option>>;
49
60
  };
@@ -6,18 +6,36 @@ import { capitalizeFirstLetter } from "@better-auth/core/utils/string";
6
6
  function getAtomKey(str) {
7
7
  return `use${capitalizeFirstLetter(str)}`;
8
8
  }
9
+ /** Preserves standard `HeadersInit` values and removes undefined record entries. */
10
+ function toHeadersInit(headers) {
11
+ if (!headers) return void 0;
12
+ if (headers instanceof Headers || Array.isArray(headers)) return headers;
13
+ const normalizedHeaders = {};
14
+ for (const [name, value] of Object.entries(headers)) if (value !== void 0) normalizedHeaders[name] = value;
15
+ return normalizedHeaders;
16
+ }
9
17
  function createAuthClient(options) {
10
- const { baseURL, pluginPathMethods, pluginsActions, pluginsAtoms, hydrateSession, $fetch, $store, atomListeners } = getClientConfig(options, false);
18
+ const { baseURL, pluginPathMethods, pluginsActions, pluginsAtoms, hydrateSession, $sessionSignal, $fetch, $store, atomListeners } = getClientConfig(options, false);
19
+ const sessionCacheKey = [
20
+ "better-auth",
21
+ "session",
22
+ options?.baseURL || "inferred",
23
+ options?.basePath ?? "/api/auth"
24
+ ].join(":");
11
25
  const resolvedHooks = {};
12
26
  for (const [key, value] of Object.entries(pluginsAtoms)) resolvedHooks[getAtomKey(key)] = () => useStore(value);
13
27
  function useSession(useFetch) {
14
28
  if (useFetch) {
15
- const ref = useStore(pluginsAtoms.$sessionSignal);
16
- return useFetch(`${baseURL}/get-session`, { ref }).then((res) => {
29
+ const sessionSignal = useStore($sessionSignal);
30
+ return useFetch(`${baseURL}/get-session`, {
31
+ headers: toHeadersInit(options?.fetchOptions?.headers),
32
+ key: sessionCacheKey,
33
+ watch: [sessionSignal]
34
+ }).then((result) => {
17
35
  return {
18
- data: res.data,
36
+ data: result.data,
19
37
  isPending: false,
20
- error: res.error
38
+ error: result.error
21
39
  };
22
40
  });
23
41
  }
@@ -15,6 +15,7 @@ import { getAuthTables } from "@better-auth/core/db";
15
15
  import { createLogger, env, isProduction, isTest } from "@better-auth/core/env";
16
16
  import { BetterAuthError } from "@better-auth/core/error";
17
17
  import { generateId } from "@better-auth/core/utils/id";
18
+ import { schemaCheckFor } from "@better-auth/core/db/internal";
18
19
  import { socialProviders } from "@better-auth/core/social-providers";
19
20
  import { findInvalidTrustedProxies } from "@better-auth/core/utils/ip";
20
21
  import { createTelemetry } from "@better-auth/telemetry";
@@ -227,6 +228,7 @@ Most of the features of Better Auth will not work correctly.`);
227
228
  };
228
229
  const initOrPromise = runPluginInit(ctx);
229
230
  if (isPromise(initOrPromise)) await initOrPromise;
231
+ ctx.checkSchema = schemaCheckFor(ctx.adapter);
230
232
  return ctx;
231
233
  }
232
234
  //#endregion
@@ -1,4 +1,5 @@
1
1
  import { sessionSchema, userSchema } from "@better-auth/core/db";
2
+ import { logger } from "@better-auth/core/env";
2
3
  import { safeJSONParse } from "@better-auth/core/utils/json";
3
4
  import * as z from "zod";
4
5
  //#region src/cookies/cache.ts
@@ -14,8 +15,15 @@ const compactCookieCacheSchema = z.object({
14
15
  signature: z.string()
15
16
  });
16
17
  function parseCookieCachePayload(value) {
17
- const result = cookieCachePayloadSchema.safeParse(safeJSONParse(value));
18
- return result.success ? result.data : null;
18
+ const parsed = safeJSONParse(value);
19
+ if (parsed === null) return null;
20
+ const result = cookieCachePayloadSchema.safeParse(parsed);
21
+ if (result.success) return result.data;
22
+ logger.warn("Cookie cache payload failed schema validation", { issues: result.error.issues.map(({ code, path }) => ({
23
+ code,
24
+ path
25
+ })) });
26
+ return null;
19
27
  }
20
28
  function parseCompactCookieCache(value) {
21
29
  const result = compactCookieCacheSchema.safeParse(value);
@@ -18,7 +18,6 @@ declare function getAccountCookie(c: GenericEndpointContext): Promise<{
18
18
  createdAt: Date;
19
19
  updatedAt: Date;
20
20
  providerId: string;
21
- issuer: string;
22
21
  accountId: string;
23
22
  userId: string;
24
23
  accessToken?: string | null | undefined;
@@ -26,13 +26,24 @@ const MAX_COOKIE_CHUNKS = 100;
26
26
  function getMaxCookieValueSize(name, options) {
27
27
  return MAX_COOKIE_SIZE - serializeCookie(name, "", { ...options }).length;
28
28
  }
29
+ function parseCookieChunkIndex(cookieName, name) {
30
+ const prefix = `${cookieName}.`;
31
+ if (!name.startsWith(prefix)) return null;
32
+ const suffix = name.slice(prefix.length);
33
+ const index = Number(suffix);
34
+ if (!Number.isSafeInteger(index) || index < 0 || String(index) !== suffix) return null;
35
+ return index;
36
+ }
29
37
  /**
30
38
  * Read all existing chunks from cookies
31
39
  */
32
40
  function readExistingChunks(cookieName, ctx) {
33
41
  const chunks = {};
34
42
  const cookies = parseCookies(ctx.headers?.get("cookie") || "");
35
- for (const [name, value] of cookies) if (name.startsWith(cookieName)) chunks[name] = value;
43
+ for (const [name, value] of cookies) {
44
+ if (name !== cookieName && parseCookieChunkIndex(cookieName, name) === null) continue;
45
+ chunks[name] = value;
46
+ }
36
47
  return chunks;
37
48
  }
38
49
  /**
@@ -128,10 +139,10 @@ function getChunkedCookie(ctx, cookieName) {
128
139
  const chunks = [];
129
140
  const cookieHeader = ctx.headers?.get("cookie");
130
141
  if (!cookieHeader) return null;
131
- for (const [name, val] of parseCookies(cookieHeader)) if (name.startsWith(cookieName + ".")) {
132
- const indexStr = name.split(".").at(-1);
133
- const index = parseInt(indexStr || "0", 10);
134
- if (!isNaN(index)) chunks.push({
142
+ for (const [name, val] of parseCookies(cookieHeader)) {
143
+ const index = parseCookieChunkIndex(cookieName, name);
144
+ if (index === null) continue;
145
+ chunks.push({
135
146
  index,
136
147
  value: val
137
148
  });
@@ -3,10 +3,9 @@ import { BetterAuthError } from "@better-auth/core/error";
3
3
  //#region src/db/adapter-kysely.ts
4
4
  async function getAdapter(options) {
5
5
  return getBaseAdapter(options, async (opts) => {
6
- const { createKyselyAdapter } = await import("../adapters/kysely-adapter/index.mjs");
6
+ const { createKyselyAdapter, kyselyAdapter } = await import("../adapters/kysely-adapter/index.mjs");
7
7
  const { kysely, databaseType, transaction } = await createKyselyAdapter(opts);
8
8
  if (!kysely) throw new BetterAuthError("Failed to initialize database adapter");
9
- const { kyselyAdapter } = await import("../adapters/kysely-adapter/index.mjs");
10
9
  return kyselyAdapter(kysely, {
11
10
  type: databaseType || "sqlite",
12
11
  debugLogs: opts.database && "debugLogs" in opts.database ? opts.database.debugLogs : false,
@@ -48,6 +48,7 @@ declare function getMigrations(config: BetterAuthOptions, {
48
48
  name: string;
49
49
  }[];
50
50
  unsafeChanges: string[];
51
+ schemaProblems: string[];
51
52
  runMigrations: () => Promise<void>;
52
53
  compileMigrations: () => Promise<string>;
53
54
  }>;
@@ -2,9 +2,9 @@ import { getSchema } from "./get-schema.mjs";
2
2
  import { getAuthTables } from "@better-auth/core/db";
3
3
  import { createLogger } from "@better-auth/core/env";
4
4
  import { BetterAuthError } from "@better-auth/core/error";
5
- import { createKyselyAdapter } from "@better-auth/kysely-adapter";
5
+ import { createKyselyAdapter, getMssqlSchema, getPostgresSchema, toIntrospectedTables, toPhysicalSchema } from "@better-auth/kysely-adapter";
6
6
  import { initGetFieldName, initGetModelName } from "@better-auth/core/db/adapter";
7
- import { getDatabaseFieldIndexName, getDatabaseIndexStringLength, getPortableDatabaseIdentifierKey } from "@better-auth/core/db/internal";
7
+ import { diffSchema, formatSchemaFinding, getDatabaseFieldIndexName, getDatabaseIndexStringLength, getPortableDatabaseIdentifierKey, invalidateSchemaChecks } from "@better-auth/core/db/internal";
8
8
  import { sql } from "kysely";
9
9
  //#region src/db/get-migration.ts
10
10
  const map = {
@@ -103,7 +103,20 @@ function databaseValueIsTrue(value) {
103
103
  if (typeof value === "number") return value !== 0;
104
104
  return value === "1" || value?.toLowerCase() === "true" || value === "t";
105
105
  }
106
- async function getDatabaseIndexes(db, dbType, schemaName) {
106
+ function toDatabaseIndexMap(indexes) {
107
+ return new Map(indexes.map((index) => {
108
+ const columns = [...index.columns].sort((left, right) => left.position - right.position);
109
+ return [createDatabaseIndexKey(index.table, index.name), {
110
+ columns: columns.flatMap((column) => column.name === null ? [] : [column.name]),
111
+ name: index.name,
112
+ table: index.table,
113
+ unique: index.unique,
114
+ validFullColumns: index.valid && !index.partial && columns.length > 0 && columns.every((column) => column.name !== null && column.fullLength)
115
+ }];
116
+ }));
117
+ }
118
+ async function getDatabaseIndexMap(db, dbType, schemaName, tableNames, introspectIndexes) {
119
+ if (introspectIndexes) return toDatabaseIndexMap(await introspectIndexes(tableNames));
107
120
  let rows;
108
121
  if (dbType === "sqlite") rows = (await sql`
109
122
  SELECT
@@ -151,7 +164,8 @@ async function getDatabaseIndexes(db, dbType, schemaName) {
151
164
  column_name AS columnName,
152
165
  non_unique AS nonUnique,
153
166
  seq_in_index AS columnPosition,
154
- sub_part AS prefixLength
167
+ sub_part AS prefixLength,
168
+ COALESCE(LOWER(comment) = 'disabled', FALSE) AS isDisabled
155
169
  FROM information_schema.statistics
156
170
  WHERE table_schema = DATABASE()
157
171
  `.execute(db)).rows;
@@ -180,7 +194,7 @@ async function getDatabaseIndexes(db, dbType, schemaName) {
180
194
  AND indexes.name IS NOT NULL
181
195
  AND index_columns.key_ordinal > 0
182
196
  `.execute(db)).rows;
183
- const indexRows = /* @__PURE__ */ new Map();
197
+ const indexMetadata = /* @__PURE__ */ new Map();
184
198
  for (const row of rows) {
185
199
  const table = row.tableName ?? row.table_name ?? row.TABLE_NAME ?? row.tablename ?? row.tbl_name;
186
200
  const name = row.indexName ?? row.index_name ?? row.INDEX_NAME ?? row.name;
@@ -190,28 +204,29 @@ async function getDatabaseIndexes(db, dbType, schemaName) {
190
204
  const nonUnique = row.nonUnique ?? row.non_unique ?? row.NON_UNIQUE;
191
205
  const unique = nonUnique === void 0 ? databaseValueIsTrue(row.isUnique ?? row.is_unique) : !databaseValueIsTrue(nonUnique);
192
206
  const position = Number(row.columnPosition ?? row.column_position ?? row.keyOrdinal ?? row.key_ordinal ?? row.ordinality ?? row.seqInIndex ?? row.seq_in_index ?? row.SEQ_IN_INDEX ?? row.seqno ?? 0);
193
- const index = indexRows.get(key) ?? {
194
- columns: [],
207
+ const indexColumn = {
208
+ fullLength: column !== void 0 && column !== null && (row.prefixLength === void 0 || row.prefixLength === null),
209
+ name: column ?? null,
210
+ position
211
+ };
212
+ const partial = databaseValueIsTrue(row.isPartial);
213
+ const valid = !databaseValueIsTrue(row.isDisabled) && !databaseValueIsTrue(row.isHypothetical) && (row.isValid === void 0 || databaseValueIsTrue(row.isValid));
214
+ const existing = indexMetadata.get(key);
215
+ indexMetadata.set(key, existing ? {
216
+ ...existing,
217
+ columns: [...existing.columns, indexColumn],
218
+ partial: existing.partial || partial,
219
+ valid: existing.valid && valid
220
+ } : {
221
+ columns: [indexColumn],
195
222
  name,
223
+ partial,
196
224
  table,
197
225
  unique,
198
- validFullColumns: true
199
- };
200
- if (column) index.columns.push({
201
- name: column,
202
- position
226
+ valid
203
227
  });
204
- else index.validFullColumns = false;
205
- if (databaseValueIsTrue(row.isPartial) || databaseValueIsTrue(row.isDisabled) || databaseValueIsTrue(row.isHypothetical) || row.isValid !== void 0 && !databaseValueIsTrue(row.isValid) || row.prefixLength !== void 0 && row.prefixLength !== null) index.validFullColumns = false;
206
- indexRows.set(key, index);
207
228
  }
208
- return new Map([...indexRows].map(([key, index]) => [key, {
209
- columns: index.columns.sort((left, right) => left.position - right.position).map((column) => column.name),
210
- name: index.name,
211
- table: index.table,
212
- unique: index.unique,
213
- validFullColumns: index.validFullColumns
214
- }]));
229
+ return toDatabaseIndexMap([...indexMetadata.values()]);
215
230
  }
216
231
  async function getDatabaseColumnBounds(db, dbType, schemaName) {
217
232
  if (dbType !== "mysql" && dbType !== "mssql") return /* @__PURE__ */ new Map();
@@ -278,7 +293,6 @@ function assertExistingTableIndexFits({ columnBounds, dbType, existingColumns, f
278
293
  }
279
294
  if (requiredBytes > byteBudget) throw new BetterAuthError(`Cannot create database index "${index.name}" on existing table "${table}" because its columns can exceed ${dbType === "mysql" ? "MySQL" : "SQL Server"}'s ${byteBudget}-byte index-key limit. Bound the indexed string columns to the generated schema lengths, resolve oversized values, then run the migration again.`);
280
295
  }
281
- const columnBackfillGuideUrl = "https://better-auth.com/docs/guides/1-7-upgrade-guide#account-identity-is-scoped-by-issuer";
282
296
  /**
283
297
  * Thrown when {@link getMigrations} refuses to add a required column with no
284
298
  * default value to a populated table. Distinct from the plain
@@ -305,27 +319,6 @@ function matchType(columnDataType, fieldType, dbType) {
305
319
  return (Array.isArray(fieldType) ? types["string"].map((t) => t.toLowerCase()) : types[fieldType].map((t) => t.toLowerCase())).includes(normalize(columnDataType));
306
320
  }
307
321
  /**
308
- * Get the current PostgreSQL schema (search_path) for the database connection
309
- * Returns the first schema in the search_path, defaulting to 'public' if not found
310
- */
311
- async function getPostgresSchema(db) {
312
- try {
313
- const result = await sql`SHOW search_path`.execute(db);
314
- const searchPath = result.rows[0]?.search_path ?? result.rows[0]?.searchPath;
315
- if (searchPath) return searchPath.split(",").map((s) => s.trim()).map((s) => s.replace(/^["']|["']$/g, "")).filter((s) => !s.startsWith("$") && !s.startsWith("\\$"))[0] || "public";
316
- } catch {}
317
- return "public";
318
- }
319
- async function getMssqlSchema(db) {
320
- try {
321
- return (await sql`
322
- SELECT SCHEMA_NAME() AS "schemaName"
323
- `.execute(db)).rows[0]?.schemaName || "dbo";
324
- } catch {
325
- return "dbo";
326
- }
327
- }
328
- /**
329
322
  * Build the migration plan that `auth migrate` executes and `auth generate`
330
323
  * prints for the Kysely adapter.
331
324
  *
@@ -342,18 +335,13 @@ async function getMssqlSchema(db) {
342
335
  async function getMigrations(config, { throwOnUnsafe = true } = {}) {
343
336
  const betterAuthSchema = getSchema(config);
344
337
  const authTables = getAuthTables(config);
345
- const accountIssuer = authTables.account && {
346
- table: authTables.account.modelName,
347
- column: authTables.account.fields.issuer?.fieldName || "issuer"
348
- };
349
- const isAccountIssuerColumn = (table, column) => table === accountIssuer?.table && column === accountIssuer.column;
350
338
  const logger = createLogger(config.logger);
351
339
  const unsafeChanges = [];
352
340
  const reportUnsafeChange = (message) => {
353
341
  if (throwOnUnsafe) throw new UnsafeMigrationError(message);
354
342
  unsafeChanges.push(message);
355
343
  };
356
- let { kysely: db, databaseType: dbType } = await createKyselyAdapter(config);
344
+ let { kysely: db, databaseType: dbType, introspectIndexes } = await createKyselyAdapter(config);
357
345
  if (!dbType) {
358
346
  logger.warn("Could not determine database type, defaulting to sqlite. Please provide a type in the database options to avoid this.");
359
347
  dbType = "sqlite";
@@ -378,7 +366,7 @@ async function getMigrations(config, { throwOnUnsafe = true } = {}) {
378
366
  }
379
367
  } else if (dbType === "mssql") logger.debug(`SQL Server migration: Using schema '${currentSchema}' (from the current user's default schema)`);
380
368
  const allTableMetadata = await db.introspection.getTables();
381
- const databaseIndexes = await getDatabaseIndexes(db, dbType, currentSchema);
369
+ const databaseIndexMap = await getDatabaseIndexMap(db, dbType, currentSchema, allTableMetadata.map((table) => table.name), introspectIndexes);
382
370
  const databaseColumnBounds = await getDatabaseColumnBounds(db, dbType, currentSchema);
383
371
  let tableMetadata = allTableMetadata;
384
372
  if (dbType === "postgres") try {
@@ -395,6 +383,7 @@ async function getMigrations(config, { throwOnUnsafe = true } = {}) {
395
383
  logger.warn(`Could not filter tables by schema. Using all discovered tables. Error: ${error instanceof Error ? error.message : String(error)}`);
396
384
  }
397
385
  else if (dbType === "mssql") tableMetadata = allTableMetadata.filter((table) => table.schema === currentSchema);
386
+ const schemaProblems = diffSchema(toPhysicalSchema(db, betterAuthSchema), toIntrospectedTables(tableMetadata)).filter((finding) => finding.kind === "unexpected-required-column").map((finding) => formatSchemaFinding(finding, "database"));
398
387
  const toBeCreated = [];
399
388
  const toBeAdded = [];
400
389
  const toBeAddedIndexes = [];
@@ -405,13 +394,13 @@ async function getMigrations(config, { throwOnUnsafe = true } = {}) {
405
394
  for (const index of value.indexes ?? []) {
406
395
  const name = index.name;
407
396
  const indexKey = createDatabaseIndexKey(key, name);
408
- const existingIndex = databaseIndexes.get(indexKey);
397
+ const existingIndex = databaseIndexMap.get(indexKey);
409
398
  if (existingIndex) {
410
399
  if (!databaseIndexMatches(existingIndex, index)) throw new BetterAuthError(`Database index "${name}" on table "${key}" does not match the configured fields and uniqueness. Rename or replace the existing index, then run the migration again.`);
411
400
  continue;
412
401
  }
413
402
  if (dbType === "sqlite" || dbType === "postgres") {
414
- const indexOnAnotherTable = [...databaseIndexes.values()].find((databaseIndex) => getPortableDatabaseIdentifierKey(databaseIndex.name) === getPortableDatabaseIdentifierKey(name) && getPortableDatabaseIdentifierKey(databaseIndex.table) !== getPortableDatabaseIdentifierKey(key));
403
+ const indexOnAnotherTable = [...databaseIndexMap.values()].find((databaseIndex) => getPortableDatabaseIdentifierKey(databaseIndex.name) === getPortableDatabaseIdentifierKey(name) && getPortableDatabaseIdentifierKey(databaseIndex.table) !== getPortableDatabaseIdentifierKey(key));
415
404
  if (indexOnAnotherTable) throw new BetterAuthError(`Database index name "${name}" is already used by table "${indexOnAnotherTable.table}". Index names must be unique across the schema.`);
416
405
  }
417
406
  const plannedIndex = plannedIndexes.get(indexKey);
@@ -587,8 +576,7 @@ async function getMigrations(config, { throwOnUnsafe = true } = {}) {
587
576
  }
588
577
  if (populated) {
589
578
  const textDetail = field.type === "string" ? " For a text column, every existing row ends up with the same empty string." : "";
590
- const guideLink = isAccountIssuerColumn(table.table, fieldName) ? ` See ${columnBackfillGuideUrl}` : "";
591
- reportUnsafeChange(`Cannot add required column "${fieldName}" to populated table "${table.table}": the schema declares no default value, so existing rows have no value to backfill. MySQL accepts this statement instead of rejecting it and fills every existing row with an implicit default for the column type, reporting a successful migration over corrupted data.${textDetail} Add the column as nullable, backfill a correct value for every row, then make it NOT NULL.${guideLink}`);
579
+ reportUnsafeChange(`Cannot add required column "${fieldName}" to populated table "${table.table}": the schema declares no default value, so existing rows have no value to backfill. MySQL accepts this statement instead of rejecting it and fills every existing row with an implicit default for the column type, reporting a successful migration over corrupted data.${textDetail} Add the column as nullable, backfill a correct value for every row, then make it NOT NULL.`);
592
580
  }
593
581
  }
594
582
  const type = getType(field, fieldName, getTableIndexStringLength(table.table, fieldName));
@@ -653,7 +641,11 @@ async function getMigrations(config, { throwOnUnsafe = true } = {}) {
653
641
  }
654
642
  for (const index of deferredIndexes) migrations.push(index);
655
643
  async function runMigrations() {
656
- for (const migration of migrations) await migration.execute();
644
+ try {
645
+ for (const migration of migrations) await migration.execute();
646
+ } finally {
647
+ if (migrations.length && config.database) invalidateSchemaChecks(config.database);
648
+ }
657
649
  }
658
650
  async function compileMigrations() {
659
651
  return migrations.map((m) => m.compile().sql).join(";\n\n") + ";";
@@ -663,6 +655,7 @@ async function getMigrations(config, { throwOnUnsafe = true } = {}) {
663
655
  toBeAdded,
664
656
  toBeAddedIndexes,
665
657
  unsafeChanges,
658
+ schemaProblems,
666
659
  runMigrations,
667
660
  compileMigrations
668
661
  };