@better-auth/core 1.7.0-beta.5 → 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 (44) hide show
  1. package/dist/api/index.d.mts +44 -1
  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 +56 -30
  7. package/dist/db/adapter/index.d.mts +54 -10
  8. package/dist/db/adapter/types.d.mts +1 -1
  9. package/dist/db/type.d.mts +12 -7
  10. package/dist/instrumentation/tracer.mjs +1 -1
  11. package/dist/oauth2/dpop.d.mts +142 -0
  12. package/dist/oauth2/dpop.mjs +246 -0
  13. package/dist/oauth2/index.d.mts +3 -2
  14. package/dist/oauth2/index.mjs +3 -2
  15. package/dist/oauth2/verify.d.mts +74 -15
  16. package/dist/oauth2/verify.mjs +172 -20
  17. package/dist/social-providers/index.d.mts +1 -0
  18. package/dist/social-providers/microsoft-entra-id.d.mts +10 -0
  19. package/dist/social-providers/microsoft-entra-id.mjs +17 -2
  20. package/dist/social-providers/reddit.mjs +1 -1
  21. package/dist/social-providers/wechat.mjs +1 -1
  22. package/dist/types/context.d.mts +17 -0
  23. package/dist/types/init-options.d.mts +45 -5
  24. package/dist/types/plugin-client.d.mts +12 -2
  25. package/dist/utils/host.mjs +4 -0
  26. package/dist/utils/url.mjs +4 -3
  27. package/package.json +5 -5
  28. package/src/api/index.ts +82 -0
  29. package/src/context/transaction.ts +45 -12
  30. package/src/db/adapter/factory.ts +127 -72
  31. package/src/db/adapter/index.ts +54 -9
  32. package/src/db/adapter/types.ts +1 -0
  33. package/src/db/type.ts +12 -7
  34. package/src/oauth2/dpop.ts +568 -0
  35. package/src/oauth2/index.ts +44 -1
  36. package/src/oauth2/verify.ts +329 -66
  37. package/src/social-providers/microsoft-entra-id.ts +44 -1
  38. package/src/social-providers/reddit.ts +5 -1
  39. package/src/social-providers/wechat.ts +8 -1
  40. package/src/types/context.ts +18 -0
  41. package/src/types/init-options.ts +40 -8
  42. package/src/types/plugin-client.ts +16 -2
  43. package/src/utils/host.ts +15 -0
  44. package/src/utils/url.ts +10 -4
@@ -9,6 +9,13 @@ import { base64 } from "@better-auth/utils/base64";
9
9
  import { decodeJwt, importJWK } from "jose";
10
10
  import { betterFetch } from "@better-fetch/fetch";
11
11
  //#region src/social-providers/microsoft-entra-id.ts
12
+ /**
13
+ * Microsoft's fixed tenant id for personal (consumer) Microsoft accounts. Every
14
+ * personal-account token carries it as the `tid` claim, so it distinguishes the
15
+ * consumer account class from work/school tenants.
16
+ * @see https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference
17
+ */
18
+ const MICROSOFT_CONSUMER_TENANT_ID = "9188040d-6c67-4c5b-b112-36a304b66dad";
12
19
  const MICROSOFT_ENTRA_ID_DEFAULT_SCOPES = [
13
20
  "openid",
14
21
  "profile",
@@ -18,7 +25,8 @@ const MICROSOFT_ENTRA_ID_DEFAULT_SCOPES = [
18
25
  ];
19
26
  const microsoft = (options) => {
20
27
  const tenant = options.tenantId || "common";
21
- const authority = options.authority || "https://login.microsoftonline.com";
28
+ let authority = options.authority || "https://login.microsoftonline.com";
29
+ while (authority.endsWith("/")) authority = authority.slice(0, -1);
22
30
  const authorizationEndpoint = `${authority}/${tenant}/oauth2/v2.0/authorize`;
23
31
  const tokenEndpoint = `${authority}/${tenant}/oauth2/v2.0/token`;
24
32
  if (options.clientSecret && options.clientAssertion) throw new BetterAuthError("Microsoft Entra ID clientAssertion cannot be combined with clientSecret");
@@ -63,7 +71,14 @@ const microsoft = (options) => {
63
71
  jwks: (header) => getMicrosoftPublicKey(header.kid, tenant, authority),
64
72
  audience: options.clientId,
65
73
  maxTokenAge: "1h",
66
- issuer: tenant !== "common" && tenant !== "organizations" && tenant !== "consumers" ? `${authority}/${tenant}/v2.0` : void 0
74
+ issuer: tenant !== "common" && tenant !== "organizations" && tenant !== "consumers" ? `${authority}/${tenant}/v2.0` : void 0,
75
+ verifyClaims: (claims) => {
76
+ const tid = claims.tid;
77
+ if (typeof tid !== "string" || claims.iss !== `${authority}/${tid}/v2.0`) return false;
78
+ if (tenant === "organizations" && tid === MICROSOFT_CONSUMER_TENANT_ID) return false;
79
+ if (tenant === "consumers" && tid !== MICROSOFT_CONSUMER_TENANT_ID) return false;
80
+ return true;
81
+ }
67
82
  },
68
83
  async getUserInfo(token) {
69
84
  if (options.getUserInfo) return options.getUserInfo(token);
@@ -62,7 +62,7 @@ const reddit = (options) => {
62
62
  } });
63
63
  if (error) return null;
64
64
  const userMap = await options.mapProfileToUser?.(profile);
65
- const email = userMap?.email || `${profile.id}@reddit.com`;
65
+ const email = userMap?.email || `${profile.id}@reddit.invalid`;
66
66
  return {
67
67
  user: {
68
68
  id: profile.id,
@@ -76,7 +76,7 @@ const wechat = (options) => {
76
76
  user: {
77
77
  id: profile.unionid || profile.openid || openid,
78
78
  name: profile.nickname,
79
- email: profile.email || null,
79
+ email: profile.email || `${profile.unionid || profile.openid || openid}@wechat.invalid`,
80
80
  image: profile.headimgurl,
81
81
  emailVerified: false,
82
82
  ...userMap
@@ -136,6 +136,23 @@ interface InternalAdapter<_Options extends BetterAuthOptions = BetterAuthOptions
136
136
  * pair at single-use credential consumption sites.
137
137
  */
138
138
  consumeVerificationValue(identifier: string): Promise<Verification | null>;
139
+ /**
140
+ * First-writer-wins create keyed by a deterministic primary key derived from
141
+ * `identifier`. Returns `true` when this caller created the row and `false`
142
+ * when a row for the same identifier already existed.
143
+ *
144
+ * The dual of `consumeVerificationValue`: reserve races to create a marker
145
+ * exactly once, where consume races to delete one exactly once. Use it for
146
+ * replay tombstones (a SAML assertion id, a JWT `jti`) where the first caller
147
+ * wins. The database path is atomic via the primary key. Secondary-storage-only
148
+ * verification is not supported for reservation and runtime implementations
149
+ * should fail closed unless verification is backed by the database.
150
+ */
151
+ reserveVerificationValue(data: {
152
+ identifier: string;
153
+ value: string;
154
+ expiresAt: Date;
155
+ }): Promise<boolean>;
139
156
  updateVerificationByIdentifier(identifier: string, data: Partial<Verification>): Promise<Verification>;
140
157
  refreshUserSessions(user: User): Promise<void>;
141
158
  }
@@ -1,6 +1,6 @@
1
1
  import { DBFieldAttribute, ModelNames, SecondaryStorage } from "../db/type.mjs";
2
2
  import { DBAdapterDebugLogOption, DBAdapterInstance } from "../db/adapter/index.mjs";
3
- import { BaseRateLimit, RateLimit } from "../db/schema/rate-limit.mjs";
3
+ import { BaseRateLimit } from "../db/schema/rate-limit.mjs";
4
4
  import { BaseSession, Session } from "../db/schema/session.mjs";
5
5
  import { BaseUser, User } from "../db/schema/user.mjs";
6
6
  import { BaseVerification, Verification } from "../db/schema/verification.mjs";
@@ -138,8 +138,30 @@ type DynamicBaseURLConfig = {
138
138
  */
139
139
  type BaseURLConfig = string | DynamicBaseURLConfig;
140
140
  interface BetterAuthRateLimitStorage {
141
- get: (key: string) => Promise<RateLimit | null | undefined>;
142
- set: (key: string, value: RateLimit, update?: boolean | undefined) => Promise<void>;
141
+ /**
142
+ * Atomically records one request against `key` within the rolling `window`
143
+ * (in seconds) and reports whether it is allowed.
144
+ *
145
+ * When `allowed` is true the count was incremented within the active window,
146
+ * or the window had elapsed and was reset to start at 1. When `allowed` is
147
+ * false the limit was already reached and `retryAfter` is the number of
148
+ * seconds until the window frees up.
149
+ *
150
+ * Performing the check and the increment in a single step closes the
151
+ * concurrent-bypass gap of the separate `get`/`set` path: N simultaneous
152
+ * requests can no longer all pass a stale read before any increment lands.
153
+ *
154
+ * Custom storages must implement this operation directly. Better Auth no
155
+ * longer accepts separate `get`/`set` rate-limit storage because that shape
156
+ * cannot enforce a distributed limit under concurrent requests.
157
+ */
158
+ consume: (key: string, rule: {
159
+ window: number;
160
+ max: number;
161
+ }) => Promise<{
162
+ allowed: boolean;
163
+ retryAfter: number | null;
164
+ }>;
143
165
  }
144
166
  type BetterAuthRateLimitRule = {
145
167
  /**
@@ -916,6 +938,20 @@ type BetterAuthOptions = {
916
938
  * @default "compact"
917
939
  */
918
940
  strategy?: "compact" | "jwt" | "jwe";
941
+ /**
942
+ * JWT-specific configuration for `strategy: "jwt"`.
943
+ */
944
+ jwt?: {
945
+ /**
946
+ * Which signing key is used for cookie-cache JWTs.
947
+ *
948
+ * - `"secret"`: uses the Better Auth secret with HS256.
949
+ * - `"jwt-plugin"`: uses the installed `jwt()` plugin's asymmetric signing keys.
950
+ *
951
+ * @default "secret"
952
+ */
953
+ signingKey?: "secret" | "jwt-plugin";
954
+ };
919
955
  /**
920
956
  * Controls stateless cookie cache refresh behavior.
921
957
  *
@@ -1095,9 +1131,13 @@ type BetterAuthOptions = {
1095
1131
  */
1096
1132
  storeStateStrategy?: "database" | "cookie";
1097
1133
  /**
1098
- * Store account data after oauth flow on a cookie
1134
+ * Store provider account data after an OAuth flow in an encrypted
1135
+ * cookie. This includes OAuth token material such as access tokens,
1136
+ * refresh tokens, ID tokens, scopes, and token expiry.
1099
1137
  *
1100
- * This is useful for database-less flow
1138
+ * This is useful for database-less flows, but large provider tokens can
1139
+ * still hit browser or proxy cookie/header limits even though Better Auth
1140
+ * chunks oversized account cookies.
1101
1141
  *
1102
1142
  * @default false
1103
1143
  *
@@ -1,10 +1,20 @@
1
1
  import { LiteralString } from "./helper.mjs";
2
- import { BetterAuthPlugin } from "./plugin.mjs";
3
2
  import { BetterAuthOptions } from "./init-options.mjs";
4
3
  import { BetterFetch, BetterFetchOption, BetterFetchPlugin } from "@better-fetch/fetch";
5
4
  import { Atom, WritableAtom } from "nanostores";
6
5
 
7
6
  //#region src/types/plugin-client.d.ts
7
+ type InferableServerPlugin = {
8
+ id?: LiteralString | undefined;
9
+ endpoints?: Record<string, unknown> | undefined;
10
+ schema?: Record<string, {
11
+ fields: Record<string, unknown>;
12
+ }> | undefined;
13
+ $ERROR_CODES?: Record<string, {
14
+ readonly code: string;
15
+ message: string;
16
+ }> | undefined;
17
+ };
8
18
  interface ClientStore {
9
19
  notify: (signal: string) => void;
10
20
  listen: (signal: string, listener: () => void) => void;
@@ -71,7 +81,7 @@ interface BetterAuthClientPlugin {
71
81
  * only used for type inference. don't pass the
72
82
  * actual plugin
73
83
  */
74
- $InferServerPlugin?: BetterAuthPlugin | undefined;
84
+ $InferServerPlugin?: InferableServerPlugin | undefined;
75
85
  /**
76
86
  * Custom actions
77
87
  */
@@ -126,6 +126,7 @@ function classifyIPv6(expanded) {
126
126
  if (firstByte === 254 && (secondByte & 192) === 128) return "linkLocal";
127
127
  if ((firstByte & 254) === 252) return "private";
128
128
  if (expanded.startsWith("2001:0db8:")) return "documentation";
129
+ if (expanded.startsWith("2001:0002:0000:")) return "benchmarking";
129
130
  if (expanded.startsWith("2002:")) {
130
131
  const embedded = extractEmbeddedIPv4(expanded, 1);
131
132
  if (embedded && classifyIPv4(embedded) !== "public") return "reserved";
@@ -136,12 +137,15 @@ function classifyIPv6(expanded) {
136
137
  if (embedded && classifyIPv4(embedded) !== "public") return "reserved";
137
138
  return "reserved";
138
139
  }
140
+ if (expanded.startsWith("0064:ff9b:0001:")) return "reserved";
139
141
  if (expanded.startsWith("2001:0000:")) {
140
142
  const embedded = extractEmbeddedIPv4(expanded, 6, { xor: true });
141
143
  if (embedded && classifyIPv4(embedded) !== "public") return "reserved";
142
144
  return "reserved";
143
145
  }
144
146
  if (expanded.startsWith("0100:0000:0000:0000:")) return "reserved";
147
+ if (expanded.startsWith("3fff:0")) return "documentation";
148
+ if (expanded.startsWith("5f00:")) return "reserved";
145
149
  return "public";
146
150
  }
147
151
  /**
@@ -22,9 +22,10 @@ function normalizePathname(requestUrl, basePath) {
22
22
  } catch {
23
23
  return "/";
24
24
  }
25
- if (basePath === "/" || basePath === "") return pathname;
26
- if (pathname === basePath) return "/";
27
- if (pathname.startsWith(basePath + "/")) return pathname.slice(basePath.length).replace(/\/+$/, "") || "/";
25
+ const normalizedBasePath = basePath.replace(/\/+$/, "");
26
+ if (normalizedBasePath === "") return pathname;
27
+ if (pathname === normalizedBasePath) return "/";
28
+ if (pathname.startsWith(normalizedBasePath + "/")) return pathname.slice(normalizedBasePath.length).replace(/\/+$/, "") || "/";
28
29
  return pathname;
29
30
  }
30
31
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@better-auth/core",
3
- "version": "1.7.0-beta.5",
3
+ "version": "1.7.0-beta.6",
4
4
  "description": "The most comprehensive authentication framework for TypeScript.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -152,8 +152,8 @@
152
152
  "zod": "^4.3.6"
153
153
  },
154
154
  "devDependencies": {
155
- "@better-auth/utils": "0.4.1",
156
- "@better-fetch/fetch": "1.2.2",
155
+ "@better-auth/utils": "0.4.2",
156
+ "@better-fetch/fetch": "1.3.1",
157
157
  "@opentelemetry/api": "^1.9.0",
158
158
  "@opentelemetry/sdk-trace-base": "^1.30.0",
159
159
  "@opentelemetry/sdk-trace-node": "^1.30.0",
@@ -165,8 +165,8 @@
165
165
  "tsdown": "0.21.1"
166
166
  },
167
167
  "peerDependencies": {
168
- "@better-auth/utils": "0.4.1",
169
- "@better-fetch/fetch": "1.2.2",
168
+ "@better-auth/utils": "0.4.2",
169
+ "@better-fetch/fetch": "1.3.1",
170
170
  "@opentelemetry/api": "^1.9.0",
171
171
  "better-call": "1.3.6",
172
172
  "@cloudflare/workers-types": ">=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;