@lenne.tech/nest-server 11.37.0 → 11.39.0

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 (139) hide show
  1. package/.claude/rules/configurable-features.md +51 -0
  2. package/.claude/rules/module-deprecation.md +25 -1
  3. package/.claude/rules/package-management.md +66 -0
  4. package/.claude/rules/testing.md +81 -5
  5. package/FRAMEWORK-API.md +4 -2
  6. package/dist/config.env.js +5 -3
  7. package/dist/config.env.js.map +1 -1
  8. package/dist/core/common/helpers/config.helper.d.ts +2 -0
  9. package/dist/core/common/helpers/config.helper.js +18 -0
  10. package/dist/core/common/helpers/config.helper.js.map +1 -1
  11. package/dist/core/common/helpers/cookies.helper.d.ts +3 -0
  12. package/dist/core/common/helpers/cookies.helper.js +9 -0
  13. package/dist/core/common/helpers/cookies.helper.js.map +1 -1
  14. package/dist/core/common/helpers/input.helper.d.ts +1 -0
  15. package/dist/core/common/helpers/input.helper.js +4 -0
  16. package/dist/core/common/helpers/input.helper.js.map +1 -1
  17. package/dist/core/common/helpers/service.helper.js +6 -1
  18. package/dist/core/common/helpers/service.helper.js.map +1 -1
  19. package/dist/core/common/interceptors/check-security.interceptor.js +1 -0
  20. package/dist/core/common/interceptors/check-security.interceptor.js.map +1 -1
  21. package/dist/core/common/interfaces/server-options.interface.d.ts +21 -0
  22. package/dist/core/common/middlewares/security-headers.middleware.d.ts +18 -0
  23. package/dist/core/common/middlewares/security-headers.middleware.js +90 -0
  24. package/dist/core/common/middlewares/security-headers.middleware.js.map +1 -0
  25. package/dist/core/common/services/email.service.d.ts +4 -1
  26. package/dist/core/common/services/email.service.js +25 -2
  27. package/dist/core/common/services/email.service.js.map +1 -1
  28. package/dist/core/common/services/module.service.js +1 -0
  29. package/dist/core/common/services/module.service.js.map +1 -1
  30. package/dist/core/modules/auth/core-auth.controller.js +2 -5
  31. package/dist/core/modules/auth/core-auth.controller.js.map +1 -1
  32. package/dist/core/modules/auth/core-auth.module.js +2 -0
  33. package/dist/core/modules/auth/core-auth.module.js.map +1 -1
  34. package/dist/core/modules/auth/core-auth.resolver.js +2 -5
  35. package/dist/core/modules/auth/core-auth.resolver.js.map +1 -1
  36. package/dist/core/modules/auth/core-legacy-auth-deprecation.initializer.d.ts +12 -0
  37. package/dist/core/modules/auth/core-legacy-auth-deprecation.initializer.js +83 -0
  38. package/dist/core/modules/auth/core-legacy-auth-deprecation.initializer.js.map +1 -0
  39. package/dist/core/modules/auth/helpers/legacy-endpoints.helper.d.ts +3 -0
  40. package/dist/core/modules/auth/helpers/legacy-endpoints.helper.js +14 -0
  41. package/dist/core/modules/auth/helpers/legacy-endpoints.helper.js.map +1 -0
  42. package/dist/core/modules/better-auth/better-auth.config.d.ts +12 -0
  43. package/dist/core/modules/better-auth/better-auth.config.js +40 -1
  44. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  45. package/dist/core/modules/better-auth/core-better-auth-api.middleware.d.ts +9 -1
  46. package/dist/core/modules/better-auth/core-better-auth-api.middleware.js +53 -4
  47. package/dist/core/modules/better-auth/core-better-auth-api.middleware.js.map +1 -1
  48. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.d.ts +5 -2
  49. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +57 -4
  50. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
  51. package/dist/core/modules/better-auth/core-better-auth-error-codes.helper.d.ts +2 -0
  52. package/dist/core/modules/better-auth/core-better-auth-error-codes.helper.js +54 -0
  53. package/dist/core/modules/better-auth/core-better-auth-error-codes.helper.js.map +1 -0
  54. package/dist/core/modules/better-auth/core-better-auth-password-reset.registry.d.ts +2 -0
  55. package/dist/core/modules/better-auth/core-better-auth-password-reset.registry.js +19 -0
  56. package/dist/core/modules/better-auth/core-better-auth-password-reset.registry.js.map +1 -0
  57. package/dist/core/modules/better-auth/core-better-auth-user.mapper.d.ts +4 -1
  58. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +13 -10
  59. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
  60. package/dist/core/modules/better-auth/core-better-auth.module.d.ts +8 -2
  61. package/dist/core/modules/better-auth/core-better-auth.module.js +34 -4
  62. package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
  63. package/dist/core/modules/better-auth/index.d.ts +1 -0
  64. package/dist/core/modules/better-auth/index.js +1 -0
  65. package/dist/core/modules/better-auth/index.js.map +1 -1
  66. package/dist/core/modules/error-code/error-codes.d.ts +27 -0
  67. package/dist/core/modules/error-code/error-codes.js +24 -0
  68. package/dist/core/modules/error-code/error-codes.js.map +1 -1
  69. package/dist/core/modules/hub/core-hub.service.js +1 -0
  70. package/dist/core/modules/hub/core-hub.service.js.map +1 -1
  71. package/dist/core/modules/user/core-user.model.d.ts +1 -0
  72. package/dist/core/modules/user/core-user.model.js +11 -1
  73. package/dist/core/modules/user/core-user.model.js.map +1 -1
  74. package/dist/core/modules/user/core-user.service.d.ts +10 -1
  75. package/dist/core/modules/user/core-user.service.js +124 -15
  76. package/dist/core/modules/user/core-user.service.js.map +1 -1
  77. package/dist/core/modules/user/inputs/core-user.input.js +1 -1
  78. package/dist/core/modules/user/inputs/core-user.input.js.map +1 -1
  79. package/dist/core.module.js +2 -0
  80. package/dist/core.module.js.map +1 -1
  81. package/dist/index.d.ts +3 -0
  82. package/dist/index.js +3 -0
  83. package/dist/index.js.map +1 -1
  84. package/dist/server/modules/error-code/error-codes.d.ts +3 -0
  85. package/dist/server/modules/user/user.controller.js +2 -1
  86. package/dist/server/modules/user/user.controller.js.map +1 -1
  87. package/dist/server/modules/user/user.model.d.ts +5 -0
  88. package/dist/server/modules/user/user.resolver.js +2 -1
  89. package/dist/server/modules/user/user.resolver.js.map +1 -1
  90. package/dist/server/modules/user/user.service.d.ts +1 -1
  91. package/dist/server/modules/user/user.service.js +18 -5
  92. package/dist/server/modules/user/user.service.js.map +1 -1
  93. package/dist/templates/password-reset-de.ejs +12 -0
  94. package/dist/templates/password-reset-en.ejs +12 -0
  95. package/dist/templates/password-reset.ejs +1 -0
  96. package/dist/tsconfig.build.tsbuildinfo +1 -1
  97. package/docs/REQUEST-LIFECYCLE.md +49 -1
  98. package/migration-guides/11.37.x-to-11.38.x.md +528 -0
  99. package/migration-guides/11.38.x-to-11.39.x.md +456 -0
  100. package/package.json +1 -1
  101. package/src/config.env.ts +28 -4
  102. package/src/core/common/helpers/config.helper.ts +79 -0
  103. package/src/core/common/helpers/cookies.helper.ts +38 -0
  104. package/src/core/common/helpers/input.helper.ts +37 -0
  105. package/src/core/common/helpers/service.helper.ts +9 -1
  106. package/src/core/common/interceptors/check-security.interceptor.ts +1 -0
  107. package/src/core/common/interfaces/server-options.interface.ts +326 -10
  108. package/src/core/common/middlewares/security-headers.middleware.ts +155 -0
  109. package/src/core/common/services/email.service.ts +46 -1
  110. package/src/core/common/services/module.service.ts +1 -0
  111. package/src/core/modules/auth/README.md +104 -0
  112. package/src/core/modules/auth/core-auth.controller.ts +4 -11
  113. package/src/core/modules/auth/core-auth.module.ts +5 -0
  114. package/src/core/modules/auth/core-auth.resolver.ts +4 -11
  115. package/src/core/modules/auth/core-legacy-auth-deprecation.initializer.ts +128 -0
  116. package/src/core/modules/auth/helpers/legacy-endpoints.helper.ts +69 -0
  117. package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +22 -0
  118. package/src/core/modules/better-auth/README.md +32 -59
  119. package/src/core/modules/better-auth/better-auth.config.ts +131 -4
  120. package/src/core/modules/better-auth/core-better-auth-api.middleware.ts +126 -3
  121. package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +166 -7
  122. package/src/core/modules/better-auth/core-better-auth-error-codes.helper.ts +146 -0
  123. package/src/core/modules/better-auth/core-better-auth-password-reset.registry.ts +92 -0
  124. package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +42 -10
  125. package/src/core/modules/better-auth/core-better-auth.module.ts +67 -4
  126. package/src/core/modules/better-auth/index.ts +1 -0
  127. package/src/core/modules/error-code/error-codes.ts +55 -0
  128. package/src/core/modules/hub/core-hub.service.ts +1 -0
  129. package/src/core/modules/user/core-user.model.ts +28 -1
  130. package/src/core/modules/user/core-user.service.ts +389 -22
  131. package/src/core/modules/user/inputs/core-user.input.ts +16 -1
  132. package/src/core.module.ts +8 -0
  133. package/src/index.ts +3 -0
  134. package/src/server/modules/user/user.controller.ts +7 -1
  135. package/src/server/modules/user/user.resolver.ts +7 -1
  136. package/src/server/modules/user/user.service.ts +58 -13
  137. package/src/templates/password-reset-de.ejs +12 -0
  138. package/src/templates/password-reset-en.ejs +12 -0
  139. package/src/templates/password-reset.ejs +1 -0
@@ -0,0 +1,146 @@
1
+ import { ErrorCode } from '../error-code/error-codes';
2
+
3
+ /**
4
+ * Translate Better-Auth's own error codes into nest-server's `#LTNS_XXXX:` message format.
5
+ *
6
+ * ── Why this exists ────────────────────────────────────────────────────────────
7
+ * Frontends in this stack translate errors by parsing the message for nest-server's marker —
8
+ * `useLtErrorTranslation` matches `/^#([A-Z_]+_\d+):\s*(.+)$/` and nothing else. Better-Auth
9
+ * answers with `{ code: 'INVALID_TOKEN', message: 'Invalid token' }`, which carries no marker, so
10
+ * the parser finds no code and hands the raw string through. The end user is shown English
11
+ * developer text — not as an edge case, but as the NORMAL outcome of every IAM error.
12
+ *
13
+ * That is felt most on the password-reset page: a link that has expired produces `INVALID_TOKEN`,
14
+ * and the person who already cannot sign in is told "Invalid token" in a language the rest of the
15
+ * product does not use.
16
+ *
17
+ * Wrapping the message here fixes it for every consumer at once — including projects that do not
18
+ * use `@lenne.tech/nuxt-extensions` and would otherwise each need their own code table.
19
+ *
20
+ * ── Two rules that keep this safe ──────────────────────────────────────────────
21
+ * 1. The original `code` field is left ALONE. Anything keying on `code` — Better-Auth's own client,
22
+ * a project's error branch — keeps working. Only the human-facing `message` is rewritten.
23
+ * 2. An unknown code is passed through UNCHANGED. A guessed mapping would show a confident,
24
+ * wrong sentence, which is worse than an untranslated true one. New codes are added here
25
+ * deliberately, not inferred.
26
+ *
27
+ * Mapping only where the meaning is unambiguous. Three deliberate absences:
28
+ *
29
+ * - `SESSION_EXPIRED` means "re-authenticate for this sensitive action", which is not what
30
+ * nest-server's `TOKEN_EXPIRED` ("please sign in again") tells the user to do.
31
+ * - `USER_NOT_FOUND` is an account-enumeration signal. Better-Auth already exposes it as a `code`,
32
+ * but translating it into the user's language would make the oracle friendlier and more legible
33
+ * — the opposite direction from the rest of this release, which spent real effort closing the
34
+ * legacy reset endpoint's equivalent. Left untranslated on purpose.
35
+ * - Anything else Better-Auth may add later. An unmapped code passes through unchanged.
36
+ *
37
+ * `PASSWORD_TOO_SHORT` / `PASSWORD_TOO_LONG` ARE mapped, and their reachability is worth stating
38
+ * because it is narrower than it looks: the lt frontend hashes with `ltSha256` before sending, so
39
+ * every password arrives as 64 hex characters and neither bound is crossed. They remain reachable
40
+ * for a client that does not hash (this is a framework, not only the lt stack) and for a project
41
+ * that configures `minPasswordLength` above 64 — which the middleware reads as a passthrough
42
+ * option, so it is a supported configuration rather than a hypothetical.
43
+ */
44
+ const BETTER_AUTH_ERROR_CODE_MAP: Readonly<Record<string, string>> = Object.freeze({
45
+ EMAIL_ALREADY_VERIFIED: ErrorCode.EMAIL_ALREADY_VERIFIED,
46
+ EMAIL_NOT_VERIFIED: ErrorCode.EMAIL_VERIFICATION_REQUIRED,
47
+ INVALID_EMAIL_OR_PASSWORD: ErrorCode.INVALID_CREDENTIALS,
48
+ INVALID_PASSWORD: ErrorCode.INVALID_PASSWORD,
49
+ // Distinct codes, not LINK_INVALID_OR_EXPIRED: Better-Auth answers BOTH with HTTP 400 on
50
+ // `/reset-password`, so a page branching on status alone cannot tell them apart and shows "your
51
+ // link is dead". The user then requests a new link, pastes the same over-long passphrase from
52
+ // their password manager, and fails again — a closed loop that never names the cause.
53
+ PASSWORD_TOO_LONG: ErrorCode.PASSWORD_TOO_LONG,
54
+ PASSWORD_TOO_SHORT: ErrorCode.PASSWORD_TOO_SHORT,
55
+ // NOT ErrorCode.INVALID_TOKEN: that one is the legacy auth service's refresh/session token and
56
+ // reads "sign in again". Every Better-Auth INVALID_TOKEN reaches the user through a LINK in a
57
+ // mail — reset, verification, magic link — and telling somebody who cannot sign in to sign in is
58
+ // the one instruction that helps least.
59
+ INVALID_TOKEN: ErrorCode.LINK_INVALID_OR_EXPIRED,
60
+ // Also NOT ErrorCode.TOKEN_EXPIRED ("please sign in again"), for the same reason as above and
61
+ // with a sharper edge: Better-Auth throws TOKEN_EXPIRED in exactly ONE place — an expired
62
+ // verification LINK (email-verification.mjs:178) — one line above the INVALID_TOKEN it throws
63
+ // for a broken one. Splitting that single user action ("I clicked an old link in my mail") into
64
+ // two opposite instructions would be wrong in the MORE common half, since links expire far more
65
+ // often than they get mangled. Worse, at that point the user is typically not signed in at all,
66
+ // so "sign in again" is a dead end. `LINK_INVALID_OR_EXPIRED` covers expiry in its wording.
67
+ TOKEN_EXPIRED: ErrorCode.LINK_INVALID_OR_EXPIRED,
68
+ USER_ALREADY_EXISTS: ErrorCode.EMAIL_ALREADY_EXISTS,
69
+ USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL: ErrorCode.EMAIL_ALREADY_EXISTS,
70
+ });
71
+
72
+ /**
73
+ * The nest-server message for a Better-Auth error code, or `undefined` when it has no mapping.
74
+ *
75
+ * Exported so a project can reuse the same table rather than build a second one that drifts.
76
+ */
77
+ export function mapBetterAuthErrorCode(code: unknown): string | undefined {
78
+ return typeof code === 'string' ? BETTER_AUTH_ERROR_CODE_MAP[code] : undefined;
79
+ }
80
+
81
+ /**
82
+ * Rewrite a failed Better-Auth response so its `message` carries nest-server's error marker.
83
+ *
84
+ * Returns the ORIGINAL response object whenever nothing should change — a successful response, a
85
+ * body that is not JSON, or a code with no mapping. That matters: the response body is a stream
86
+ * that can be read once, so handing back an untouched original rather than a rebuilt copy keeps
87
+ * every other consumer of it working.
88
+ *
89
+ * Never throws. An error surfacing from the error-formatting path would replace a useful message
90
+ * with a 500, which is the one outcome worse than an untranslated string.
91
+ */
92
+ export async function wrapBetterAuthErrorResponse(response: Response): Promise<Response> {
93
+ if (response.ok) {
94
+ return response;
95
+ }
96
+
97
+ // A redirect is not ours to rewrite. Better-Auth reports some failures by REDIRECTING to the
98
+ // caller's `callbackURL` with `?error=<CODE>` (see `redirectOnError` in its email-verification
99
+ // route) — a 3xx with no JSON body, so there is no message to translate. Rewriting the code in
100
+ // that query string instead would invent a second contract on top of Better-Auth's documented
101
+ // one, which the frontend reads. Those cases stay untranslated on purpose, and the frontend maps
102
+ // `?error=` itself.
103
+ if (response.status >= 300 && response.status < 400) {
104
+ return response;
105
+ }
106
+
107
+ try {
108
+ const body = await response.clone().json();
109
+ const mapped = mapBetterAuthErrorCode(body?.code);
110
+
111
+ // No mapping, or a message that already carries the marker (a nest-server exception that
112
+ // travelled through Better-Auth): leave it exactly as it is.
113
+ if (!mapped || typeof body?.message !== 'string' || body.message.startsWith('#')) {
114
+ return response;
115
+ }
116
+
117
+ const headers = new Headers();
118
+
119
+ // `set-cookie` FIRST, and via getSetCookie/append rather than the forEach below. `forEach`
120
+ // yields each cookie separately and `set()` overwrites, so a response carrying two of them
121
+ // would keep only the last. Better-Auth CLEARS session and 2FA cookies on several failure
122
+ // paths — exactly the responses this function rewrites — so collapsing them leaves a stale
123
+ // credential in the browser. `sendWebResponse` uses getSetCookie() for the same reason.
124
+ for (const cookie of response.headers.getSetCookie?.() ?? []) {
125
+ headers.append('set-cookie', cookie);
126
+ }
127
+
128
+ response.headers.forEach((value, key) => {
129
+ const lower = key.toLowerCase();
130
+ // content-length: the rewritten body has a different length, and a stale one truncates the
131
+ // response. set-cookie: already appended above, and `set()` here would undo that.
132
+ if (lower !== 'content-length' && lower !== 'set-cookie') {
133
+ headers.set(key, value);
134
+ }
135
+ });
136
+
137
+ return new Response(JSON.stringify({ ...body, message: mapped }), {
138
+ headers,
139
+ status: response.status,
140
+ statusText: response.statusText,
141
+ });
142
+ } catch {
143
+ // Not JSON, or an unreadable body. The original is still the best answer available.
144
+ return response;
145
+ }
146
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Carries the password of an in-flight IAM password reset from the API middleware
3
+ * to Better-Auth's `onPasswordReset` hook.
4
+ *
5
+ * WHY THIS EXISTS
6
+ *
7
+ * A reset has to land in BOTH credential stores when Legacy Auth and IAM run next to
8
+ * each other: IAM keeps scrypt, Legacy keeps bcrypt, and neither hash can be derived
9
+ * from the other. Mirroring a reset into the legacy store therefore needs the password
10
+ * itself — and the two halves are known in two different places:
11
+ *
12
+ * | half | known by |
13
+ * |------|----------|
14
+ * | the new password | `CoreBetterAuthApiMiddleware`, which sees the request body |
15
+ * | which user it belongs to | `emailAndPassword.onPasswordReset`, which Better-Auth calls with the user |
16
+ *
17
+ * Better-Auth hands its hook `{ user }` and the original `Request`, whose body is
18
+ * already consumed by then, so the hook cannot recover the password on its own. The
19
+ * alternative — resolving the user in the middleware by parsing Better-Auth's
20
+ * `reset-password:<token>` verification identifier — would couple us to an internal
21
+ * storage format that no contract keeps stable. Using the supported hook for the user
22
+ * and this registry for the password keeps the only coupling inside our own code.
23
+ *
24
+ * The value stored here is ALREADY NORMALIZED (`normalizePasswordForIam`), i.e. exactly
25
+ * what Better-Auth hashed, so the legacy mirror cannot drift from the IAM credential
26
+ * even when the client posts a plaintext password.
27
+ *
28
+ * A true leaf: it imports nothing but a Node built-in, so it can never be
29
+ * mid-evaluation when a cycle-adjacent file reads it (see
30
+ * `.claude/rules/architecture.md` → "DI Token Placement (SWC-Safe)").
31
+ *
32
+ * @internal Not public API — but NOT optional. Two shipped core files import it
33
+ * (`core-better-auth-api.middleware.ts`, `core-better-auth.module.ts`), so a vendor-mode sync that
34
+ * skips this file leaves an unresolvable import. `@internal` means "do not depend on this from a
35
+ * project", never "you may leave it out".
36
+ */
37
+
38
+ import { AsyncLocalStorage } from 'async_hooks';
39
+
40
+ interface PasswordResetContext {
41
+ /**
42
+ * The new password as Better-Auth will hash it — already normalized.
43
+ *
44
+ * Cleared on the first read (see {@link getInFlightResetPassword}), so the property is
45
+ * optional after that point.
46
+ */
47
+ normalizedPassword?: string;
48
+ }
49
+
50
+ const storage = new AsyncLocalStorage<PasswordResetContext>();
51
+
52
+ /**
53
+ * Runs `fn` with the in-flight reset password attached to the async context.
54
+ *
55
+ * Wrap ONLY the Better-Auth handler call: the context has to be alive while
56
+ * `onPasswordReset` runs, and dead everywhere else, so an unrelated request can never
57
+ * pick up a password that was not its own.
58
+ *
59
+ * @internal
60
+ */
61
+ export function runWithResetPassword<T>(normalizedPassword: string, fn: () => T): T {
62
+ return storage.run({ normalizedPassword }, fn);
63
+ }
64
+
65
+ /**
66
+ * The normalized password of the reset currently being handled, or `undefined`
67
+ * outside such a request.
68
+ *
69
+ * `undefined` means "not a reset we saw the body of" — never "no password". Callers
70
+ * must skip the mirror rather than guess, because writing a wrong bcrypt hash would
71
+ * lock the account out of the legacy path.
72
+ *
73
+ * @internal
74
+ */
75
+ export function getInFlightResetPassword(): string | undefined {
76
+ const store = storage.getStore();
77
+ if (!store) {
78
+ return undefined;
79
+ }
80
+
81
+ // ONE-SHOT. The legacy mirror is the only legitimate reader and it reads once, so handing the
82
+ // value out a second time can only serve something that should not have it.
83
+ //
84
+ // Defence in depth rather than a fix for a known hole: an AsyncLocalStorage store is retained
85
+ // by any async resource created INSIDE the `run()` that outlives it — a timer registered during
86
+ // lazy plugin init, a cached promise. Such a resource would otherwise keep observing a
87
+ // password-equivalent from a request that finished long ago. Clearing on read bounds that to
88
+ // the moment the mirror actually runs.
89
+ const { normalizedPassword } = store;
90
+ store.normalizedPassword = undefined;
91
+ return normalizedPassword;
92
+ }
@@ -57,6 +57,22 @@ export interface MappedUser {
57
57
  verified?: boolean;
58
58
  }
59
59
 
60
+ /**
61
+ * Options for {@link CoreBetterAuthUserMapper.getMigrationStatus}.
62
+ */
63
+ export interface GetMigrationStatusOptions {
64
+ /**
65
+ * Whether to collect `pendingUserEmails` (up to 100 addresses).
66
+ *
67
+ * Costs two extra collection-scale queries, one of them a guaranteed COLLSCAN. Pass `false`
68
+ * from callers that only need the counts — they get the same numbers without the scan, and
69
+ * without handling addresses they were going to discard.
70
+ *
71
+ * @default true
72
+ */
73
+ includePendingEmails?: boolean;
74
+ }
75
+
60
76
  /**
61
77
  * Interface for migration status result
62
78
  */
@@ -340,6 +356,11 @@ export class CoreBetterAuthUserMapper {
340
356
  const bcryptHash = await bcrypt.hash(normalizedPassword, saltRounds);
341
357
 
342
358
  // Update the users collection with the bcrypt hash
359
+ // `$or` across two different indexes (`users.email` unique, `users.iamId` sparse) forces an
360
+ // index-union plan rather than a single-index point lookup. Deliberate — the caller may know
361
+ // only one of the two — and irrelevant at password-change frequency. It would NOT be
362
+ // irrelevant in a bulk or migration loop; anything calling this in one should look up the
363
+ // user once and address it by `_id`.
343
364
  const result = await usersCollection.updateOne(
344
365
  { $or: [{ email: userEmail }, { iamId: iamUserId }] },
345
366
  { $set: { password: bcryptHash, updatedAt: new Date() } },
@@ -976,7 +997,13 @@ export class CoreBetterAuthUserMapper {
976
997
  *
977
998
  * @returns Migration status object with counts and percentage
978
999
  */
979
- async getMigrationStatus(): Promise<MigrationStatus> {
1000
+ async getMigrationStatus(options?: GetMigrationStatusOptions): Promise<MigrationStatus> {
1001
+ // Building `pendingUserEmails` costs two collection-scale queries, one of them a
1002
+ // guaranteed COLLSCAN (`$exists: false` cannot use the sparse `iamId` index) that scans
1003
+ // the WHOLE collection precisely in the all-migrated steady state, where it finds nothing.
1004
+ // A caller that only needs the counts — the boot-time deprecation warning does — should
1005
+ // not pay for a field it discards.
1006
+ const includePendingEmails = options?.includePendingEmails !== false;
980
1007
  if (!this.connection) {
981
1008
  this.logger.warn('No database connection available - cannot get migration status');
982
1009
  return {
@@ -1004,10 +1031,13 @@ export class CoreBetterAuthUserMapper {
1004
1031
  });
1005
1032
 
1006
1033
  // Get unique userIds that have credential accounts
1007
- const credentialAccounts = await accountCollection
1008
- .aggregate([{ $match: { providerId: 'credential' } }, { $group: { _id: '$userId' } }])
1034
+ // Counted server-side. The previous form materialized one `{_id}` object per distinct
1035
+ // migrated user in the Node heap only to read `.length` an allocation that grows with
1036
+ // the user count, at boot, before the first request.
1037
+ const credentialAccountCount = await accountCollection
1038
+ .aggregate([{ $match: { providerId: 'credential' } }, { $group: { _id: '$userId' } }, { $count: 'count' }])
1009
1039
  .toArray();
1010
- const usersWithIamAccount = credentialAccounts.length;
1040
+ const usersWithIamAccount = credentialAccountCount[0]?.count ?? 0;
1011
1041
 
1012
1042
  // Get users that are fully migrated (have both iamId AND credential account)
1013
1043
  // We need to find users where iamId exists AND there's a matching account
@@ -1047,15 +1077,17 @@ export class CoreBetterAuthUserMapper {
1047
1077
  // Get emails of pending users (limit to 100)
1048
1078
  // Two-phase approach: first get users without iamId (no $lookup needed),
1049
1079
  // then check users with iamId but missing credential account
1050
- const usersWithoutIamId = await usersCollection
1051
- .find({ $or: [{ iamId: { $exists: false } }, { iamId: null }] })
1052
- .limit(100)
1053
- .project({ email: 1 })
1054
- .toArray();
1080
+ const usersWithoutIamId = !includePendingEmails
1081
+ ? []
1082
+ : await usersCollection
1083
+ .find({ $or: [{ iamId: { $exists: false } }, { iamId: null }] })
1084
+ .limit(100)
1085
+ .project({ email: 1 })
1086
+ .toArray();
1055
1087
 
1056
1088
  const remaining = 100 - usersWithoutIamId.length;
1057
1089
  let usersWithIamButNoAccount: { email?: string }[] = [];
1058
- if (remaining > 0) {
1090
+ if (includePendingEmails && remaining > 0) {
1059
1091
  usersWithIamButNoAccount = await usersCollection
1060
1092
  .aggregate([
1061
1093
  { $match: { iamId: { $exists: true, $ne: null } } },
@@ -18,6 +18,7 @@ import { IBetterAuth, ICorsConfig } from '../../common/interfaces/server-options
18
18
  import { BrevoService } from '../../common/services/brevo.service';
19
19
  import { ConfigService } from '../../common/services/config.service';
20
20
  import { RolesGuardRegistry } from '../auth/guards/roles-guard-registry';
21
+ import { isLegacyEndpointEnabled } from '../auth/helpers/legacy-endpoints.helper';
21
22
  import { BetterAuthRolesGuard } from './better-auth-roles.guard';
22
23
  import { BetterAuthTokenService } from './better-auth-token.service';
23
24
  import {
@@ -25,6 +26,7 @@ import {
25
26
  BetterAuthInstance,
26
27
  CreateBetterAuthResult,
27
28
  createBetterAuthInstance,
29
+ OnPasswordResetCallback,
28
30
  } from './better-auth.config';
29
31
  import { DefaultBetterAuthResolver } from './better-auth.resolver';
30
32
  import { CoreBetterAuthApiMiddleware } from './core-better-auth-api.middleware';
@@ -32,6 +34,7 @@ import { CoreBetterAuthChallengeService } from './core-better-auth-challenge.ser
32
34
  import { CoreBetterAuthEmailVerificationService } from './core-better-auth-email-verification.service';
33
35
  import { CoreBetterAuthRateLimitMiddleware } from './core-better-auth-rate-limit.middleware';
34
36
  import { CoreBetterAuthRateLimiter } from './core-better-auth-rate-limiter.service';
37
+ import { getInFlightResetPassword } from './core-better-auth-password-reset.registry';
35
38
  import { CoreBetterAuthSignUpValidatorService } from './core-better-auth-signup-validator.service';
36
39
  import { CoreBetterAuthUserMapper } from './core-better-auth-user.mapper';
37
40
  import { BETTER_AUTH_CONFIG, BETTER_AUTH_COOKIE_DOMAIN, BETTER_AUTH_INSTANCE } from './core-better-auth.constants';
@@ -261,6 +264,11 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
261
264
  // Static reference to email verification service for Better-Auth hooks (outside DI context)
262
265
  private static emailVerificationService: CoreBetterAuthEmailVerificationService | null = null;
263
266
  private static mongoConnection: Connection | null = null;
267
+ /**
268
+ * Config reference for the Better-Auth callbacks, which run OUTSIDE the DI context.
269
+ * Set from the same factories that build the auth instance.
270
+ */
271
+ private static configServiceInstance: ConfigService | null = null;
264
272
  // Safety Net: Track if forRoot() has already been called to detect duplicate registration
265
273
  private static forRootCalled = false;
266
274
  private static cachedDynamicModule: DynamicModule | null = null;
@@ -662,12 +670,14 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
662
670
  throw new Error('MongoDB database not available');
663
671
  }
664
672
 
673
+ this.configServiceInstance = configService;
674
+
665
675
  // Get JWT secrets from config for backwards compatibility fallback
666
676
  const jwtConfig = configService.get<{ refresh?: { secret?: string }; secret?: string }>('jwt');
667
677
  const fallbackSecrets = [jwtConfig?.secret, jwtConfig?.refresh?.secret];
668
678
 
669
679
  // Create email verification callbacks that delegate to the NestJS service
670
- const { onEmailVerified, sendResetPasswordEmail, sendVerificationEmail } =
680
+ const { onEmailVerified, onPasswordReset, sendResetPasswordEmail, sendVerificationEmail } =
671
681
  this.createEmailVerificationCallbacks();
672
682
 
673
683
  // Note: Secret validation is now handled in createBetterAuthInstance
@@ -677,6 +687,7 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
677
687
  db,
678
688
  fallbackSecrets,
679
689
  onEmailVerified,
690
+ onPasswordReset,
680
691
  sendResetPasswordEmail,
681
692
  sendVerificationEmail,
682
693
  });
@@ -814,8 +825,16 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
814
825
  * They access the service via static reference since Better-Auth hooks run outside DI context.
815
826
  * @internal
816
827
  */
817
- private static createEmailVerificationCallbacks(): {
828
+ /**
829
+ * `protected` rather than `private`: the callbacks it returns include `onPasswordReset`, which
830
+ * decides what mirroring a reset into the legacy store means for a deployment. A project with a
831
+ * different answer (a different store, an extra audit record, skipping it entirely) must be able
832
+ * to say so through the Module Inheritance Pattern rather than by reaching for
833
+ * `options.emailAndPassword` — which cannot replace the hook anyway, since the two are chained.
834
+ */
835
+ protected static createEmailVerificationCallbacks(): {
818
836
  onEmailVerified: (userId: string) => Promise<void>;
837
+ onPasswordReset: OnPasswordResetCallback;
819
838
  sendResetPasswordEmail: (options: AuthEmailCallbackOptions) => Promise<void>;
820
839
  sendVerificationEmail: (options: AuthEmailCallbackOptions) => Promise<void>;
821
840
  } {
@@ -840,6 +859,47 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
840
859
  );
841
860
  }
842
861
  },
862
+ onPasswordReset: async ({ user }) => {
863
+ // Mirror the new password into the legacy bcrypt store, so a deployment running
864
+ // Legacy Auth next to IAM does not keep the OLD password valid on the legacy path
865
+ // after a reset — including a reset performed BECAUSE the old one leaked.
866
+ //
867
+ // The password is not in Better-Auth's callback payload: it comes from the API
868
+ // middleware through the reset registry, already normalized, i.e. byte-identical
869
+ // to what Better-Auth just hashed for IAM.
870
+ // Skip entirely where there is no legacy surface to keep in step. On an IAM-only
871
+ // deployment the mirror would maintain a bcrypt credential nothing ever reads, and its
872
+ // "the legacy password now differs" warning would name a consequence that cannot occur.
873
+ // Gated on the same resolver the endpoints themselves use, so the two cannot drift.
874
+ const legacyConfig = this.configServiceInstance?.getFastButReadOnly('auth')?.legacyEndpoints;
875
+ if (!isLegacyEndpointEnabled(legacyConfig, 'graphql') && !isLegacyEndpointEnabled(legacyConfig, 'rest')) {
876
+ return;
877
+ }
878
+
879
+ const normalizedPassword = getInFlightResetPassword();
880
+ if (!normalizedPassword) {
881
+ // Not a reset whose body we saw. Skipping is the only safe answer — writing a
882
+ // guessed hash would lock the account out of the legacy path.
883
+ this.logger.debug('Password reset without an in-flight password — skipping the legacy mirror.');
884
+ return;
885
+ }
886
+ if (!user?.email) {
887
+ this.logger.warn('Password reset without a user email — cannot mirror it to the legacy store.');
888
+ return;
889
+ }
890
+
891
+ const mirrored = await this.userMapperInstance?.syncPasswordToLegacy(user.id, user.email, normalizedPassword);
892
+ if (mirrored) {
893
+ this.logger.debug(`Mirrored IAM password reset to the legacy store for ${maskEmail(user.email)}`);
894
+ } else {
895
+ // Report the miss. A silent `false` here is exactly how the opposite direction of
896
+ // this sync stayed broken unnoticed: the endpoint answers success either way.
897
+ this.logger.warn(
898
+ `Could not mirror the IAM password reset to the legacy store for ${maskEmail(user.email)} — ` +
899
+ 'the legacy password now differs from the IAM credential.',
900
+ );
901
+ }
902
+ },
843
903
  sendResetPasswordEmail: async (options) => {
844
904
  // Delegate to the NestJS service
845
905
  if (this.emailVerificationService) {
@@ -921,18 +981,20 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
921
981
  {
922
982
  // Inject Mongoose Connection to ensure NestJS waits for it to be ready
923
983
  // Also inject EmailVerificationService to set static reference before Better-Auth init
924
- inject: [getConnectionToken(), CoreBetterAuthEmailVerificationService],
984
+ inject: [getConnectionToken(), CoreBetterAuthEmailVerificationService, ConfigService],
925
985
  provide: BETTER_AUTH_INSTANCE,
926
986
  useFactory: async (
927
987
  connection: Connection,
928
988
  emailVerificationService: CoreBetterAuthEmailVerificationService,
989
+ configService: ConfigService,
929
990
  ) => {
930
991
  // Set static references for callbacks BEFORE creating Better-Auth instance
931
992
  this.setEmailVerificationService(emailVerificationService);
932
993
  this.mongoConnection = connection;
994
+ this.configServiceInstance = configService;
933
995
 
934
996
  // Create email verification callbacks that delegate to the NestJS service
935
- const { onEmailVerified, sendResetPasswordEmail, sendVerificationEmail } =
997
+ const { onEmailVerified, onPasswordReset, sendResetPasswordEmail, sendVerificationEmail } =
936
998
  this.createEmailVerificationCallbacks();
937
999
 
938
1000
  // Build shared instance options
@@ -940,6 +1002,7 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
940
1002
  config,
941
1003
  fallbackSecrets: options?.fallbackSecrets,
942
1004
  onEmailVerified,
1005
+ onPasswordReset,
943
1006
  sendResetPasswordEmail,
944
1007
  sendVerificationEmail,
945
1008
  serverAppUrl: options?.serverAppUrl,
@@ -31,6 +31,7 @@ export * from './core-better-auth-api.middleware';
31
31
  export * from './core-better-auth-auth.model';
32
32
  export * from './core-better-auth-cookie.helper';
33
33
  export * from './core-better-auth-email-verification.service';
34
+ export * from './core-better-auth-error-codes.helper';
34
35
  export * from './core-better-auth-migration-status.model';
35
36
  export * from './core-better-auth-models';
36
37
  export * from './core-better-auth-rate-limit.middleware';
@@ -287,6 +287,61 @@ export const LtnsErrors = {
287
287
  },
288
288
  },
289
289
 
290
+ /**
291
+ * A link the user followed from an email is invalid, already used, or expired.
292
+ *
293
+ * Deliberately SEPARATE from `INVALID_TOKEN` (LTNS_0003), which the legacy auth service uses for
294
+ * refresh/session tokens. Those two look alike to a developer and are opposites to a user: one
295
+ * means "your session ended, sign in again", the other means "the link in your mail is dead,
296
+ * request another". Telling somebody who cannot sign in to sign in is the worse of the two.
297
+ *
298
+ * Deliberately BROADER than `EMAIL_VERIFICATION_TOKEN_INVALID` (LTNS_0024), because the
299
+ * underlying `INVALID_TOKEN` from Better-Auth covers password reset, email verification and
300
+ * magic links alike, and cannot tell them apart.
301
+ *
302
+ * "nicht (mehr) gültig" covers unknown AND expired on purpose: Better-Auth answers both with the
303
+ * same code, so a message claiming to know which one would be guessing half the time.
304
+ */
305
+ /**
306
+ * The submitted password is outside the configured length bounds.
307
+ *
308
+ * Separate codes rather than one "does not meet requirements", because the recipient can only
309
+ * act on a message that names the direction. Better-Auth answers `PASSWORD_TOO_LONG` with the
310
+ * same HTTP 400 it uses for an invalid token, so without a distinct code a reset page shows
311
+ * "this link is no longer valid" — the user requests a new link, pastes the same over-long
312
+ * passphrase from their password manager, and fails again. A closed loop with no hint at the
313
+ * cause.
314
+ *
315
+ * The numbers are Better-Auth's defaults (8 / 128) and a project may configure others; the text
316
+ * therefore names the common case rather than claiming to know the deployment's bounds.
317
+ */
318
+ PASSWORD_TOO_LONG: {
319
+ code: 'LTNS_0028',
320
+ message: 'Password exceeds the maximum length',
321
+ translations: {
322
+ de: 'Das Passwort ist zu lang. Erlaubt sind standardmäßig höchstens 128 Zeichen.',
323
+ en: 'The password is too long. The default maximum is 128 characters.',
324
+ },
325
+ },
326
+
327
+ PASSWORD_TOO_SHORT: {
328
+ code: 'LTNS_0029',
329
+ message: 'Password is below the minimum length',
330
+ translations: {
331
+ de: 'Das Passwort ist zu kurz. Erforderlich sind standardmäßig mindestens 8 Zeichen.',
332
+ en: 'The password is too short. The default minimum is 8 characters.',
333
+ },
334
+ },
335
+
336
+ LINK_INVALID_OR_EXPIRED: {
337
+ code: 'LTNS_0027',
338
+ message: 'Link is invalid or expired',
339
+ translations: {
340
+ de: 'Dieser Link ist nicht (mehr) gültig. Bitte fordern Sie einen neuen an.',
341
+ en: 'This link is no longer valid. Please request a new one.',
342
+ },
343
+ },
344
+
290
345
  // =====================================================
291
346
  // Authorization Errors (LTNS_0100-LTNS_0199)
292
347
  // =====================================================
@@ -20,6 +20,7 @@ const FRAMEWORK_SECRET_FIELDS = [
20
20
  'password',
21
21
  'verificationToken',
22
22
  'passwordResetToken',
23
+ 'passwordResetTokenExpiresAt',
23
24
  'refreshTokens',
24
25
  'tempTokens',
25
26
  'apiKeyEncrypted',
@@ -95,11 +95,38 @@ export abstract class CoreUserModel extends CorePersistenceModel {
95
95
  */
96
96
  @UnifiedField({
97
97
  isOptional: true,
98
- mongoose: true,
98
+ // PARTIAL, not plain and not sparse. `findOne({ passwordResetToken: token })` runs on an
99
+ // unauthenticated, unrated endpoint, and without an index every call is a full collection scan
100
+ // of `users` — the dominant cost of that path and an easy amplifier for a request loop.
101
+ // Sparse would be wrong: the reset path clears the field by UNSETTING it, but a document that
102
+ // once held `null` would still count as present, so the index would accumulate an entry for
103
+ // every user who ever requested a reset. The `$type: 'string'` predicate keeps it to the rows
104
+ // that can actually match.
105
+ //
106
+ // NEVER give the companion `passwordResetTokenExpiresAt` a TTL index: it is a Date, and a TTL
107
+ // index there would delete the USER DOCUMENT rather than the token.
108
+ mongoose: { index: { partialFilterExpression: { passwordResetToken: { $type: 'string' } } } },
99
109
  roles: RoleEnum.S_NO_ONE,
100
110
  })
101
111
  passwordResetToken: string = undefined;
102
112
 
113
+ /**
114
+ * When the current password-reset token stops being valid.
115
+ *
116
+ * `S_NO_ONE` like the token itself: it never belongs in a response, and revealing that a reset is
117
+ * pending is information an account holder gains nothing from and an attacker does.
118
+ *
119
+ * A user document written before 11.38.0 has no value here, and the reset path treats that as
120
+ * expired — see `IAuthPasswordReset.tokenExpiresInMinutes` for why that direction was chosen.
121
+ */
122
+ @UnifiedField({
123
+ isOptional: true,
124
+ mongoose: true,
125
+ roles: RoleEnum.S_NO_ONE,
126
+ type: () => Date,
127
+ })
128
+ passwordResetTokenExpiresAt: Date = undefined;
129
+
103
130
  /**
104
131
  * Refresh tokens (for devices)
105
132
  * key: Token