@lenne.tech/nest-server 11.36.5 → 11.38.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 (101) hide show
  1. package/.claude/rules/better-auth.md +32 -0
  2. package/.claude/rules/configurable-features.md +29 -0
  3. package/.claude/rules/framework-compatibility.md +1 -0
  4. package/.claude/rules/module-deprecation.md +25 -1
  5. package/.claude/rules/testing.md +26 -4
  6. package/.claude/rules/versioning.md +6 -0
  7. package/CLAUDE.md +22 -5
  8. package/FRAMEWORK-API.md +4 -2
  9. package/dist/config.env.js +1 -1
  10. package/dist/config.env.js.map +1 -1
  11. package/dist/core/common/interfaces/server-options.interface.d.ts +20 -0
  12. package/dist/core/common/middlewares/security-headers.middleware.d.ts +18 -0
  13. package/dist/core/common/middlewares/security-headers.middleware.js +90 -0
  14. package/dist/core/common/middlewares/security-headers.middleware.js.map +1 -0
  15. package/dist/core/modules/auth/core-auth.controller.js +2 -5
  16. package/dist/core/modules/auth/core-auth.controller.js.map +1 -1
  17. package/dist/core/modules/auth/core-auth.module.js +2 -0
  18. package/dist/core/modules/auth/core-auth.module.js.map +1 -1
  19. package/dist/core/modules/auth/core-auth.resolver.js +2 -5
  20. package/dist/core/modules/auth/core-auth.resolver.js.map +1 -1
  21. package/dist/core/modules/auth/core-legacy-auth-deprecation.initializer.d.ts +12 -0
  22. package/dist/core/modules/auth/core-legacy-auth-deprecation.initializer.js +83 -0
  23. package/dist/core/modules/auth/core-legacy-auth-deprecation.initializer.js.map +1 -0
  24. package/dist/core/modules/auth/helpers/legacy-endpoints.helper.d.ts +3 -0
  25. package/dist/core/modules/auth/helpers/legacy-endpoints.helper.js +14 -0
  26. package/dist/core/modules/auth/helpers/legacy-endpoints.helper.js.map +1 -0
  27. package/dist/core/modules/better-auth/better-auth.config.d.ts +12 -0
  28. package/dist/core/modules/better-auth/better-auth.config.js +33 -1
  29. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  30. package/dist/core/modules/better-auth/core-better-auth-api.middleware.d.ts +9 -1
  31. package/dist/core/modules/better-auth/core-better-auth-api.middleware.js +51 -4
  32. package/dist/core/modules/better-auth/core-better-auth-api.middleware.js.map +1 -1
  33. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.d.ts +2 -1
  34. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +29 -1
  35. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
  36. package/dist/core/modules/better-auth/core-better-auth-password-reset.registry.d.ts +2 -0
  37. package/dist/core/modules/better-auth/core-better-auth-password-reset.registry.js +19 -0
  38. package/dist/core/modules/better-auth/core-better-auth-password-reset.registry.js.map +1 -0
  39. package/dist/core/modules/better-auth/core-better-auth-user.mapper.d.ts +4 -1
  40. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +15 -10
  41. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
  42. package/dist/core/modules/better-auth/core-better-auth.constants.d.ts +4 -0
  43. package/dist/core/modules/better-auth/core-better-auth.constants.js +5 -1
  44. package/dist/core/modules/better-auth/core-better-auth.constants.js.map +1 -1
  45. package/dist/core/modules/better-auth/core-better-auth.module.d.ts +8 -2
  46. package/dist/core/modules/better-auth/core-better-auth.module.js +34 -4
  47. package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
  48. package/dist/core/modules/better-auth/core-better-auth.service.d.ts +2 -0
  49. package/dist/core/modules/better-auth/core-better-auth.service.js +68 -0
  50. package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
  51. package/dist/core/modules/system-setup/core-system-setup.service.js +3 -1
  52. package/dist/core/modules/system-setup/core-system-setup.service.js.map +1 -1
  53. package/dist/core/modules/user/core-user.service.d.ts +1 -1
  54. package/dist/core/modules/user/core-user.service.js +29 -12
  55. package/dist/core/modules/user/core-user.service.js.map +1 -1
  56. package/dist/core/modules/user/inputs/core-user.input.js +1 -1
  57. package/dist/core/modules/user/inputs/core-user.input.js.map +1 -1
  58. package/dist/core.module.js +2 -0
  59. package/dist/core.module.js.map +1 -1
  60. package/dist/index.d.ts +3 -0
  61. package/dist/index.js +3 -0
  62. package/dist/index.js.map +1 -1
  63. package/dist/server/modules/user/user.controller.js +2 -1
  64. package/dist/server/modules/user/user.controller.js.map +1 -1
  65. package/dist/server/modules/user/user.resolver.js +2 -1
  66. package/dist/server/modules/user/user.resolver.js.map +1 -1
  67. package/dist/server/modules/user/user.service.d.ts +1 -1
  68. package/dist/server/modules/user/user.service.js +8 -1
  69. package/dist/server/modules/user/user.service.js.map +1 -1
  70. package/dist/tsconfig.build.tsbuildinfo +1 -1
  71. package/docs/REQUEST-LIFECYCLE.md +35 -1
  72. package/migration-guides/11.36.x-to-11.37.0.md +344 -0
  73. package/migration-guides/11.37.x-to-11.38.x.md +511 -0
  74. package/package.json +17 -4
  75. package/src/config.env.ts +9 -1
  76. package/src/core/common/interfaces/server-options.interface.ts +225 -9
  77. package/src/core/common/middlewares/security-headers.middleware.ts +155 -0
  78. package/src/core/modules/auth/README.md +104 -0
  79. package/src/core/modules/auth/core-auth.controller.ts +4 -11
  80. package/src/core/modules/auth/core-auth.module.ts +5 -0
  81. package/src/core/modules/auth/core-auth.resolver.ts +4 -11
  82. package/src/core/modules/auth/core-legacy-auth-deprecation.initializer.ts +128 -0
  83. package/src/core/modules/auth/helpers/legacy-endpoints.helper.ts +69 -0
  84. package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +46 -1
  85. package/src/core/modules/better-auth/README.md +96 -59
  86. package/src/core/modules/better-auth/better-auth.config.ts +106 -4
  87. package/src/core/modules/better-auth/core-better-auth-api.middleware.ts +119 -3
  88. package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +67 -3
  89. package/src/core/modules/better-auth/core-better-auth-password-reset.registry.ts +92 -0
  90. package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +49 -10
  91. package/src/core/modules/better-auth/core-better-auth.constants.ts +26 -0
  92. package/src/core/modules/better-auth/core-better-auth.module.ts +67 -4
  93. package/src/core/modules/better-auth/core-better-auth.service.ts +179 -3
  94. package/src/core/modules/system-setup/core-system-setup.service.ts +33 -5
  95. package/src/core/modules/user/core-user.service.ts +123 -18
  96. package/src/core/modules/user/inputs/core-user.input.ts +16 -1
  97. package/src/core.module.ts +8 -0
  98. package/src/index.ts +3 -0
  99. package/src/server/modules/user/user.controller.ts +7 -1
  100. package/src/server/modules/user/user.resolver.ts +7 -1
  101. package/src/server/modules/user/user.service.ts +35 -9
@@ -0,0 +1,511 @@
1
+ # Migration Guide: 11.37.x → 11.38.x
2
+
3
+ > **This MINOR contains breaking changes.** In this repository the MAJOR digit states which NestJS
4
+ > major the package targets and moves only when NestJS does, so a breaking change ships as a MINOR.
5
+ > Read the version number as "NestJS 11, breaking step 38", not as a semver promise.
6
+
7
+ ## Overview
8
+
9
+ | Category | Change | Effort |
10
+ |----------|--------|--------|
11
+ | **Breaking** | `auth.legacyEndpoints.enabled` now defaults to **`false`** | Low — one config line, only for projects still using Legacy Auth |
12
+ | **Bugfix (security)** | A password reset now reaches **both** credential stores | None — but read *Existing users* below |
13
+ | **Bugfix** | Native IAM reset routes normalize the password like sign-in does | None |
14
+ | **New** | Boot warning naming the IAM migration status while Legacy Auth is open | None |
15
+ | **Breaking (safe direction)** | Security headers are sent by default | None — unless a proxy already sets conflicting ones |
16
+ | **Breaking (safe direction)** | The password-reset request no longer reveals whether an account exists | Low — one config line if you want the old hint back |
17
+ | **Changed** | The password-reset mail links to the app instead of the API | Low — only if your reset page reads a PATH parameter |
18
+
19
+ Nothing here changes the API surface of `CoreModule.forRoot()`. A project created from
20
+ `nest-server-starter` — i.e. `CoreModule.forRoot(envConfig)`, IAM only — is unaffected by the
21
+ breaking change and only gains the fixes.
22
+
23
+ ---
24
+
25
+ ## Quick Migration
26
+
27
+ For an IAM-only project — the one-argument `CoreModule.forRoot(envConfig)` the starter uses —
28
+ this release needs no code change at all:
29
+
30
+ ```bash
31
+ pnpm update @lenne.tech/nest-server
32
+ pnpm run build
33
+ pnpm test
34
+ ```
35
+
36
+ If you call the **three-argument** form, decide one config line first (§1):
37
+
38
+ ```typescript
39
+ // config.env.ts — only if your users still need Legacy Auth
40
+ auth: {
41
+ legacyEndpoints: { enabled: true },
42
+ },
43
+ ```
44
+
45
+ Then upgrade as above. The boot log tells you when you can remove that line again.
46
+
47
+ ---
48
+
49
+ ## 1. Breaking: Legacy Auth endpoints are off unless asked for
50
+
51
+ ### What changed
52
+
53
+ `auth.legacyEndpoints.enabled` defaulted to `true`. A project that registered the legacy auth module
54
+ and never made a decision therefore kept a second, fully functional password-authentication surface
55
+ open indefinitely — `signIn`, `signUp`, `logout`, `refreshToken` over GraphQL and
56
+ `/auth/signin`, `/auth/signup`, `/auth/logout`, `/auth/refresh-token` over REST.
57
+
58
+ Since 11.38.0 the default is `false`. Disabled endpoints answer **HTTP 410 Gone**, as they always
59
+ did when switched off explicitly.
60
+
61
+ ### Are you affected?
62
+
63
+ Only if **all** of these are true:
64
+
65
+ 1. You call the three-argument form
66
+ `CoreModule.forRoot(CoreAuthService, AuthModule.forRoot(envConfig.jwt), envConfig)`, **and**
67
+ 2. you did not set `auth.legacyEndpoints` at all, **and**
68
+ 3. a client still uses a legacy endpoint.
69
+
70
+ ```bash
71
+ # Do you register the legacy module?
72
+ grep -n "CoreModule.forRoot(" src/server/server.module.ts
73
+
74
+ # Did you already decide?
75
+ grep -n "legacyEndpoints" src/config.env.ts
76
+ ```
77
+
78
+ `CoreModule.forRoot(envConfig)` — the one-argument form the starter uses — never registers the
79
+ legacy module, so none of this applies.
80
+
81
+ ### Migration
82
+
83
+ If your users still need Legacy Auth, say so explicitly:
84
+
85
+ ```typescript
86
+ // config.env.ts
87
+ auth: {
88
+ legacyEndpoints: { enabled: true },
89
+ },
90
+ ```
91
+
92
+ If you were already migrating, this is the moment to finish. Ask the server how far it got:
93
+
94
+ ```graphql
95
+ query {
96
+ betterAuthMigrationStatus {
97
+ totalUsers
98
+ fullyMigratedUsers
99
+ migrationPercentage
100
+ canDisableLegacyAuth
101
+ pendingUserEmails
102
+ }
103
+ }
104
+ ```
105
+
106
+ Once `canDisableLegacyAuth` is `true`, leave the new default in place and delete the config line.
107
+
108
+ Since 11.38.0 you no longer have to ask: while any legacy endpoint is open, every boot logs the
109
+ same figure. It is **best-effort** — the read runs detached from the boot so it cannot delay
110
+ readiness, and a failure is swallowed at debug level. So the deprecation warning can appear
111
+ without a percentage line under it; that means the status could not be read, not that there is
112
+ nothing to report.
113
+
114
+ The mirror case is reported too, and it is the one worth watching after this upgrade: when the
115
+ endpoints are closed **while users are still unmigrated**, the boot says so and names the way
116
+ back.
117
+
118
+ ```
119
+ [CoreLegacyAuthDeprecationInitializer] Legacy Auth is ENABLED (GraphQL + REST). It is deprecated
120
+ in favour of IAM (Better-Auth) and will be removed. Close it with
121
+ auth.legacyEndpoints.enabled: false once your users are migrated.
122
+ [CoreLegacyAuthDeprecationInitializer] IAM migration status: 84/97 users migrated (87%).
123
+ ```
124
+
125
+ ### Per-transport flags
126
+
127
+ `graphql` and `rest` still override `enabled` for one transport, with one rule made explicit:
128
+ **an explicit `enabled: false` cannot be reopened by a per-transport `true`.** It is the setting a
129
+ project reached for to close legacy down, and an upgrade must never widen it.
130
+
131
+ | Configuration | Result |
132
+ |---------------|--------|
133
+ | `enabled: false` | off, whatever `graphql` / `rest` say |
134
+ | `graphql: true` (no `enabled`) | GraphQL on, REST off |
135
+ | `enabled: true` | both on |
136
+ | nothing set | **off** (was: on) |
137
+
138
+ Both transports now resolve this through one shared function, `isLegacyEndpointEnabled()`, exported
139
+ from the package. If you override `checkLegacyGraphQLEnabled()` or `checkLegacyRESTEnabled()`, use
140
+ it rather than re-implementing the table.
141
+
142
+ ---
143
+
144
+ ## 2. Security fix: a password reset now reaches both credential stores
145
+
146
+ ### What was wrong
147
+
148
+ Legacy Auth stores `bcrypt(sha256(password))`, IAM stores `scrypt(sha256(password))`. Neither hash
149
+ can be derived from the other, so a deployment running both has to write both on every password
150
+ change. Two halves of that were broken, in opposite directions:
151
+
152
+ **Legacy reset → IAM.** `CoreUserService.resetPassword()` and `.update()` skipped the IAM sync
153
+ whenever the incoming password was already a 64-character hex string, on the reasoning that IAM
154
+ "needs the plain password". It does not — `hashPasswordForBetterAuth()` runs its input through
155
+ `normalizePasswordForIam()`, which passes a sha256 value through unchanged. And the lt auth client
156
+ hashes in the browser (`ltSha256`, seven call sites in `@lenne.tech/nuxt-extensions`), so **the
157
+ skipped branch was the only branch a real frontend ever took**. The legacy store got the new
158
+ password, the IAM credential kept the old one, and the endpoint reported success either way.
159
+
160
+ **IAM reset → legacy.** Nothing mirrored a Better-Auth reset back into the legacy store at all.
161
+
162
+ The failure only surfaced at the next sign-in, to somebody who by definition had already lost their
163
+ way in — and a reset performed *because* a password leaked did not actually revoke it.
164
+
165
+ ### What changed
166
+
167
+ - Both `resetPassword()` and `update()` pass the submitted password to the IAM sync exactly as
168
+ received, whatever its shape.
169
+ - `emailAndPassword.onPasswordReset` is now wired and mirrors an IAM reset into the legacy bcrypt
170
+ store, for every native reset route (token, email-OTP, phone-number).
171
+ - A sync that does **not** happen is now logged as a warning instead of a success line. A silent
172
+ `false` return is how this stayed invisible.
173
+
174
+ ### Existing users
175
+
176
+ The fix is forward-only for accounts that already reset during the broken window: their two stores
177
+ still hold different passwords. **One further reset repairs the account** — `syncPasswordChangeToIam`
178
+ rewrites an existing credential, so both stores converge in a single pass. No repair script is
179
+ needed, and no data migration.
180
+
181
+ There is nothing to do proactively. Affected users are not locked out: the password that works is
182
+ the one from *before* the failed reset.
183
+
184
+ ---
185
+
186
+ ## 3. Bugfix: native IAM reset routes normalize the password
187
+
188
+ `/iam/sign-in/email` and `/iam/sign-up/email` are handled by `CoreBetterAuthController`, which
189
+ normalizes the password (`normalizePasswordForIam`). The native reset routes are forwarded to
190
+ Better-Auth instead, and nothing normalized them.
191
+
192
+ So a client that posted a **plaintext** password to `POST /iam/reset-password` stored
193
+ `scrypt(plaintext)`, while every later sign-in checks `scrypt(sha256(plaintext))` — the account was
194
+ locked out with the password its owner had just chosen, one request after being told the reset
195
+ succeeded. A client that hashes first (every lt frontend) was unaffected, which is why it went
196
+ unnoticed.
197
+
198
+ `CoreBetterAuthApiMiddleware` now normalizes `/reset-password`, `/email-otp/reset-password` and
199
+ `/phone-number/reset-password` before forwarding. No configuration required — with two things
200
+ worth knowing:
201
+
202
+ **Password length is now enforced by nest-server, and only on what the client actually sends.**
203
+ Because the body is normalized to a 64-character sha256 before Better-Auth sees it, Better-Auth's
204
+ own `minPasswordLength` / `maxPasswordLength` can never fire on these routes. nest-server therefore
205
+ checks the submitted value itself (8–128 by default, or your
206
+ `betterAuth.options.emailAndPassword.minPasswordLength` / `maxPasswordLength`) and answers **400**
207
+ on a violation.
208
+
209
+ Two things worth stating exactly, because it is easy to read this as a regression it is not:
210
+
211
+ - **For a client that hashes before sending — every lt frontend does — nothing changed.**
212
+ Better-Auth's length check reads the submitted value, so a 64-character sha256 already defeated
213
+ it on every earlier version. This release does not open that gap; it makes it visible.
214
+ - **For a client that sends plaintext, this is a net gain.** Better-Auth's check used to apply and
215
+ now cannot, so nest-server's replaces it — same enforcement, one layer earlier.
216
+
217
+ Either way, **a hashed password carries no length information, so the policy has to live in your
218
+ form as well.** The starter's `reset-password.vue`, `register.vue` and `setup.vue` all declare
219
+ `v.minLength(8)`; a project using `@lenne.tech/nuxt-extensions` without those forms has to add it.
220
+
221
+ **Normalization needs `CoreBetterAuthUserMapper` resolvable.** Every module variant that registers
222
+ the middleware also provides the mapper, so this is not something you configure. If it is somehow
223
+ absent the middleware now logs an **error** naming the consequence rather than forwarding quietly —
224
+ the previous silence would have stored `scrypt(plaintext)` against a sign-in that checks
225
+ `scrypt(sha256(...))`.
226
+
227
+ ### Sessions are not revoked by a reset unless you ask
228
+
229
+ Related, and easy to assume otherwise now that the parity fix has landed: **a reset lands in both
230
+ stores, but that is not the same as securing the account.** Since 11.38.0 the legacy reset also
231
+ clears `refreshTokens`, so legacy sessions end — but the IAM half is opt-in:
232
+
233
+ ```typescript
234
+ betterAuth: {
235
+ emailAndPassword: {
236
+ // Recommended. A reset is what somebody reaches for after a suspected takeover.
237
+ revokeSessionsOnPasswordReset: true,
238
+ },
239
+ },
240
+ ```
241
+
242
+ Off by default because it is a behaviour change for an existing deployment: after a reset the user
243
+ is signed out everywhere, including on the devices they still hold.
244
+
245
+ ### Rate limiting is part of the definition of done here
246
+
247
+ `POST /iam/request-password-reset` is unauthenticated, mints a token and sends mail, and the reset
248
+ path now performs **two** expensive key derivations instead of one (scrypt for IAM plus bcrypt for
249
+ the legacy mirror), each occupying a libuv threadpool slot. The IP-axis limiter is **off unless
250
+ configured**:
251
+
252
+ ```typescript
253
+ betterAuth: {
254
+ rateLimit: {}, // presence enables it; /request-password-reset gets the halved strict limit
255
+ },
256
+ ```
257
+
258
+ ---
259
+
260
+ ## 4. Client side: use the framework's hashing, never your own
261
+
262
+ The password hashing lives in `@lenne.tech/nuxt-extensions`' auth client, so no call site can forget
263
+ it. Since **`@lenne.tech/nuxt-extensions` 1.16.0** — a different package with its own version
264
+ line, not this one — the composable exposes the full pair:
265
+
266
+ ```typescript
267
+ const { requestPasswordReset, resetPassword } = useLtAuth();
268
+
269
+ await requestPasswordReset({ email, redirectTo: appUrl('/auth/reset-password') });
270
+ await resetPassword({ newPassword, token });
271
+ ```
272
+
273
+ **Do not hash passwords in project code.** A private `sha256` helper next to a `fetch` call is how a
274
+ project ends up on a path the framework does not cover — and it is exactly how the defect in §2 was
275
+ found in the field.
276
+
277
+ If your project posts to the legacy `POST /users/password/reset-request` / `POST /users/password/reset`
278
+ routes, prefer the IAM pair above. The legacy routes keep working (they are on the user module, not
279
+ the auth module, so §1 does not close them), but they are the deprecated path.
280
+
281
+ ---
282
+
283
+ ## 5. Security headers are now sent by default
284
+
285
+ `@lenne.tech/nest-server` set no browser security headers at all. Projects were rebuilding the same
286
+ set by hand — one inline in `main.ts`, one as its own middleware — and a freshly generated project
287
+ sent none. A missing header produces no error anywhere; it surfaces in a pentest report months
288
+ later.
289
+
290
+ Since 11.38.0 every response carries them, with no configuration:
291
+
292
+ | Header | Value |
293
+ |--------|-------|
294
+ | `X-Content-Type-Options` | `nosniff` |
295
+ | `X-Frame-Options` | `DENY` |
296
+ | `Referrer-Policy` | `strict-origin-when-cross-origin` |
297
+ | `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` — **only on an https request** |
298
+ | `X-Powered-By` | removed |
299
+ | `Content-Security-Policy` | **not sent** — opt-in only |
300
+
301
+ They are set as **middleware**, so a request a guard rejects carries them too. That is deliberate:
302
+ a `401` is still a response a browser renders, and those are the responses an attacker generates
303
+ most of.
304
+
305
+ ### What you may need to change
306
+
307
+ - **A reverse proxy already setting these** (Traefik, Caddy, nginx): nothing. The proxy writes
308
+ last, so its values win. This layer exists so a deployment *without* one is not bare.
309
+ - **You embed your app in an iframe**: `X-Frame-Options: DENY` will block it. Set
310
+ `security: { headers: { frameOptions: 'SAMEORIGIN' } }` — or `false` for that field.
311
+ - **You want a CSP**: set `contentSecurityPolicy` explicitly. There is no default because a wrong
312
+ one breaks the application rather than hardening it, and this package serves its own HTML from
313
+ the Hub and the GraphQL playground.
314
+ - **Turn everything off**: `security: { headers: false }`.
315
+
316
+ ### HSTS never follows configuration
317
+
318
+ It is decided by the request protocol — `x-forwarded-proto` first (the first hop of a chain),
319
+ connection protocol as fallback. There is no flag that switches it on regardless, and that is a
320
+ safety property rather than an omission: a browser **remembers** the header, so one sent from a dev
321
+ server over `http://localhost` makes every project on that host unreachable over http for up to a
322
+ year, and nothing on the server can withdraw it.
323
+
324
+ One consequence worth knowing: behind a TLS-terminating proxy the connection reaching Node is plain
325
+ http, so **`trustProxy` has to be configured** or no production request looks secure and no HSTS is
326
+ sent. `preload` is off by default — it commits every subdomain to a browser-vendor list that is
327
+ awkward to reverse, which is the domain owner's decision.
328
+
329
+ ---
330
+
331
+ ## 6. The password-reset request no longer reveals whether an account exists
332
+
333
+ `POST /users/password/reset-request` answered **404** for an unknown address and **201** for a known
334
+ one — a working oracle for "does this person have an account here". In a multi-tenant product that
335
+ also answers who works at which customer. The framework already answered this correctly on the IAM
336
+ path (Better-Auth returns the same body either way), so the two halves of one framework disagreed
337
+ about the same question.
338
+
339
+ Since 11.38.0 both cases answer identically. `CoreUserService.setPasswordResetTokenForEmail()`
340
+ returns `null` for an unknown address instead of throwing.
341
+
342
+ ### The status code is the smaller half — and this affects YOUR code
343
+
344
+ Response **time** separates the two cases far more loudly than the status code: the known path
345
+ sends mail, which is a network round trip orders of magnitude above everything else in the request.
346
+ A fix that changed only the status code would have given up the "unknown address" hint in the UI
347
+ and kept the oracle fully intact.
348
+
349
+ The framework equalises what it controls. The mail send lives in **your** `sendPasswordResetMail()`,
350
+ so that half is yours:
351
+
352
+ ```typescript
353
+ async sendPasswordResetMail(email: string, serviceOptions?: ServiceOptions): Promise<null | User> {
354
+ const user = await super.setPasswordResetTokenForEmail(email, serviceOptions);
355
+
356
+ if (!user) {
357
+ return null; // unknown address — answer exactly as for a known one
358
+ }
359
+
360
+ // NOT awaited. Awaiting it makes the known path visibly slower whatever the status code says.
361
+ void this.emailService.sendMail(user.email, 'Password reset', { /* … */ })
362
+ .catch((error: unknown) => this.userServiceLogger.error(`Failed to send: ${String(error)}`));
363
+
364
+ return user;
365
+ }
366
+ ```
367
+
368
+ The reference implementation is `src/server/modules/user/user.service.ts`; copy that shape.
369
+
370
+ ### Migration
371
+
372
+ Two things to check:
373
+
374
+ 1. **The return type widened to `null | TUser`.** If you override
375
+ `setPasswordResetTokenForEmail()` or call it directly, handle `null`.
376
+ 2. **Your reset form can no longer say "we do not know this address".** That is the only UX cost —
377
+ the endpoint carries no password, so the "wrong password vs unknown address" distinction is not
378
+ affected (that lives at sign-in, governed by the separate `auth.preventUserEnumeration`).
379
+
380
+ To restore the old behaviour:
381
+
382
+ ```typescript
383
+ auth: {
384
+ passwordReset: { preventUserEnumeration: false },
385
+ },
386
+ ```
387
+
388
+ ---
389
+
390
+ ## 7. The password-reset mail now links to the app
391
+
392
+ The mail used to carry the link Better-Auth generates, and that points at the **API**:
393
+
394
+ ```
395
+ https://api.example.com/iam/reset-password/<token>?callbackURL=https://example.com/auth/reset-password
396
+ ```
397
+
398
+ It works — the API validates the token and redirects to the app — but it puts a domain the
399
+ recipient does not recognise into a password mail, which is the one thing people are trained to
400
+ check before clicking. Since 11.38.0 the link points at the app:
401
+
402
+ ```
403
+ https://example.com/auth/reset-password?token=<token>
404
+ ```
405
+
406
+ ### What this gives up, plainly
407
+
408
+ Better-Auth's redirect route validates the token and its expiry **before** forwarding, so an
409
+ expired link produced an error page rather than a form that fails on submit. Linking straight to
410
+ the app moves that error later.
411
+
412
+ It is **not** a security difference: the token ends up in the app URL either way, and the
413
+ `callbackURL` origin check exists only because of the hop that is being removed. If you prefer the
414
+ early error, keep the old behaviour:
415
+
416
+ ```typescript
417
+ betterAuth: { emailVerification: { passwordResetLink: false } },
418
+ ```
419
+
420
+ ### Configuring it
421
+
422
+ ```typescript
423
+ betterAuth: {
424
+ emailVerification: {
425
+ // Absolute, relative (resolved against appUrl), or with a {token} placeholder
426
+ passwordResetLink: 'https://example.com/auth/reset-password/{token}',
427
+ },
428
+ },
429
+ ```
430
+
431
+ `{token}` is substituted wherever it appears — that is what lets a page reading a **path**
432
+ parameter work. Without the placeholder, `?token=` is appended.
433
+
434
+ ### Are you affected?
435
+
436
+ - **Your reset page reads `?token=`** (the starter's shape): nothing to do.
437
+ - **Your reset page reads a path parameter**: configure the `{token}` form above, or the link will
438
+ not match your route.
439
+ - **No `appUrl` resolvable**: the old link is kept. A guessed host would 404, which is worse than a
440
+ working link — so this fails back rather than forward.
441
+
442
+ Note this is separate from `email.passwordResetLink`, which serves the legacy
443
+ `/users/password/reset-request` flow and appends the token as a path segment. A project using both
444
+ should point them at the same page.
445
+
446
+ ---
447
+
448
+ ## 8. Vendor mode: three atomic file sets
449
+
450
+ Projects that vendor `src/core/` into their own tree (no npm dependency, updated via
451
+ `/lt-dev:backend:update-nest-server-core`) sync file by file. This release adds three new files,
452
+ and each one is **already imported** by files it ships alongside — taking the importer without
453
+ its new leaf leaves an unresolvable import.
454
+
455
+ | New file | Existing core files that now import it |
456
+ |---|---|
457
+ | `auth/helpers/legacy-endpoints.helper.ts` | `auth/core-auth.controller.ts`, `auth/core-auth.resolver.ts`, `auth/core-legacy-auth-deprecation.initializer.ts`, `index.ts` |
458
+ | `auth/core-legacy-auth-deprecation.initializer.ts` | `auth/core-auth.module.ts`, `index.ts` |
459
+ | `better-auth/core-better-auth-password-reset.registry.ts` | `better-auth/core-better-auth-api.middleware.ts`, `better-auth/core-better-auth.module.ts` |
460
+ | `common/middlewares/security-headers.middleware.ts` | `core.module.ts`, `index.ts` |
461
+
462
+ **One dependency crosses a module boundary that did not exist before.** `CoreAuthModule` now
463
+ registers `CoreLegacyAuthDeprecationInitializer`, which imports `CoreBetterAuthUserMapper` — so
464
+ `auth/` transitively depends on `better-auth/`. A sync that takes `auth/` but leaves `better-auth/`
465
+ at the old revision will not compile. Take both, or neither.
466
+
467
+ The registry is marked `@internal` because it is not public API — that is not the same as
468
+ optional. It is a mandatory file: two shipped core files import it.
469
+
470
+ ---
471
+
472
+ ## Compatibility Notes
473
+
474
+ | Pattern | Status | Note |
475
+ |---------|--------|------|
476
+ | `CoreModule.forRoot(envConfig)` (IAM only) | Unaffected | The legacy module is never registered, so §1 cannot apply |
477
+ | `CoreModule.forRoot(CoreAuthService, AuthModule.forRoot(...), envConfig)` | **Check §1** | The default flip lands here |
478
+ | Overriding `checkLegacyGraphQLEnabled()` / `checkLegacyRESTEnabled()` | Compatible | Both still `protected`. Call the exported `isLegacyEndpointEnabled()` rather than re-implementing the table |
479
+ | Overriding `CoreUserService.resetPassword()` / `.update()` | Compatible | Signatures unchanged. A project override that added its own IAM sync as a workaround is now redundant — the second write is equivalent, so it can be dropped without a migration |
480
+ | Subclassing `CoreBetterAuthApiMiddleware` | **Check** | `normalizeResetPassword()` is new and `protected`; the middleware now mutates `req.body` on the three reset routes before forwarding |
481
+ | Setting `betterAuth.options.emailAndPassword` | Compatible | `password` and `onPasswordReset` are protected against being clobbered — see below |
482
+ | Setting `betterAuth.options.emailAndPassword.onPasswordReset` | Compatible, behaviour worth knowing | Yours is **chained after** the framework's legacy mirror rather than replacing it. Both run; neither can silently cancel the other |
483
+ | `LEGACY_AUTH_ENABLED=1` / `=yes` | **Breaking** | The value is matched strictly against `'true'`. A truthy-but-not-`'true'` value now resolves to off |
484
+ | Embedding the app in an iframe | **Check §5** | `X-Frame-Options: DENY` is now sent. Set `frameOptions: 'SAMEORIGIN'` or `false` |
485
+ | A reverse proxy already setting security headers | Compatible | The proxy writes last, so its values win |
486
+ | Overriding `setPasswordResetTokenForEmail()` | **Check §6** | Return type widened to `null | TUser`; handle the unknown-address case |
487
+ | A reset form that says "unknown address" | **Check §6** | The endpoint no longer distinguishes. Set `auth.passwordReset.preventUserEnumeration: false` to restore it |
488
+ | A reset page reading `?token=` | Compatible | The new default link uses exactly that shape |
489
+ | A reset page reading a PATH parameter | **Check §7** | Configure `passwordResetLink: '…/reset-password/{token}'` |
490
+
491
+ ---
492
+
493
+ ## Troubleshooting
494
+
495
+ | Symptom | Cause | Fix |
496
+ |---------|-------|-----|
497
+ | `410 Gone` on `/auth/signin` or the `signIn` mutation after upgrading | §1 — the default flipped | Set `auth.legacyEndpoints: { enabled: true }` if you still need Legacy Auth |
498
+ | A user reset their password before upgrading and the new one is refused | The IAM credential kept the old password (§2) | Reset once more; both stores converge |
499
+ | Boot log warns about Legacy Auth on every start | Intended — legacy endpoints are open | Finish the migration, then remove `enabled: true` |
500
+ | `Password reset ... was NOT synced to IAM (no credential account)` | The user exists in Legacy Auth only | Expected while migrating; the account gains a credential on its first IAM sign-in |
501
+ | The app stopped rendering inside an iframe | §5 — `X-Frame-Options: DENY` | `security: { headers: { frameOptions: 'SAMEORIGIN' } }` |
502
+ | No `Strict-Transport-Security` in production | The request does not look secure to the server | Configure `trustProxy` — behind a TLS-terminating proxy the inward connection is plain http (§5) |
503
+ | A reset request for an unknown address returns 201 instead of 404 | §6 — intended | `auth.passwordReset: { preventUserEnumeration: false }` if you need the old behaviour |
504
+ | TypeScript error on `setPasswordResetTokenForEmail()` | §6 — return type widened to `null | TUser` | Handle the `null` case |
505
+ | The reset link in the mail 404s on the app | §7 — the link now points at the app, and your route shape differs | Configure `passwordResetLink` with `{token}`, or set it to `false` |
506
+
507
+ ## Module Documentation
508
+
509
+ - [Better-Auth README](../src/core/modules/better-auth/README.md)
510
+ - [Better-Auth Integration Checklist](../src/core/modules/better-auth/INTEGRATION-CHECKLIST.md)
511
+ - [Module Deprecation Roadmap](../.claude/rules/module-deprecation.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "11.36.5",
3
+ "version": "11.38.0",
4
4
  "description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).",
5
5
  "keywords": [
6
6
  "node",
@@ -83,14 +83,13 @@
83
83
  "url": "https://github.com/lenneTech/nest-server/issues"
84
84
  },
85
85
  "engines": {
86
- "node": ">= 22",
86
+ "node": ">= 22.12",
87
87
  "pnpm": "^11.0.0"
88
88
  },
89
89
  "packageManager": "pnpm@11.13.1+sha512.b2fc7683b8a6525414e7d13e1ba28caaddde96bf66ec540bfaeb7e702b81f3e0be4d1f295edf7f9fe0396740a8dce4509c582ddf79891f4543fea32d37645f25",
90
90
  "dependencies": {
91
91
  "@apollo/server": "5.5.1",
92
92
  "@as-integrations/express5": "1.1.2",
93
- "@better-auth/passkey": "1.6.26",
94
93
  "@getbrevo/brevo": "6.0.3",
95
94
  "@modelcontextprotocol/sdk": "1.30.0",
96
95
  "@nestjs/apollo": "13.4.5",
@@ -108,7 +107,6 @@
108
107
  "@tus/server": "2.4.4",
109
108
  "@types/supertest": "7.2.1",
110
109
  "bcrypt": "6.0.0",
111
- "better-auth": "1.6.26",
112
110
  "class-transformer": "0.5.1",
113
111
  "class-validator": "0.15.1",
114
112
  "compression": "1.8.1",
@@ -144,7 +142,10 @@
144
142
  "peerDependencies": {
145
143
  "@aws-sdk/client-s3": ">=3.1045.0 <4",
146
144
  "@aws-sdk/s3-request-presigner": ">=3.1045.0 <4",
145
+ "@better-auth/core": ">=1.7.1 <1.8.0",
146
+ "@better-auth/passkey": ">=1.7.1 <1.8.0",
147
147
  "@tus/s3-store": ">=2.0.5 <3",
148
+ "better-auth": ">=1.7.1 <1.8.0",
148
149
  "bullmq": ">=5.16.0 <7",
149
150
  "ioredis": ">=5.0.0 <7"
150
151
  },
@@ -155,9 +156,18 @@
155
156
  "@aws-sdk/s3-request-presigner": {
156
157
  "optional": true
157
158
  },
159
+ "@better-auth/core": {
160
+ "optional": false
161
+ },
162
+ "@better-auth/passkey": {
163
+ "optional": false
164
+ },
158
165
  "@tus/s3-store": {
159
166
  "optional": true
160
167
  },
168
+ "better-auth": {
169
+ "optional": false
170
+ },
161
171
  "bullmq": {
162
172
  "optional": true
163
173
  },
@@ -168,6 +178,8 @@
168
178
  "devDependencies": {
169
179
  "@aws-sdk/client-s3": "3.1115.0",
170
180
  "@aws-sdk/s3-request-presigner": "3.1115.0",
181
+ "@better-auth/core": "1.7.1",
182
+ "@better-auth/passkey": "1.7.1",
171
183
  "@compodoc/compodoc": "2.0.0",
172
184
  "@nestjs/cli": "11.0.24",
173
185
  "@nestjs/schematics": "11.1.0",
@@ -186,6 +198,7 @@
186
198
  "@types/passport": "1.0.17",
187
199
  "@vitest/coverage-v8": "4.1.11",
188
200
  "ansi-colors": "4.1.3",
201
+ "better-auth": "1.7.1",
189
202
  "bullmq": "6.2.0",
190
203
  "find-file-up": "2.0.1",
191
204
  "husky": "9.1.7",
package/src/config.env.ts CHANGED
@@ -520,6 +520,10 @@ const config: { [env: string]: IServerOptions } = {
520
520
  maxIterations: 5,
521
521
  rateLimit: { max: 20, windowSeconds: 60 },
522
522
  },
523
+ // Legacy auth is OFF by default since 11.38.0 (see isLegacyEndpointEnabled). This
524
+ // repository's own `src/server` registers the legacy module and its suites exercise
525
+ // BOTH auth systems, so every non-production environment here opts in explicitly —
526
+ // which is exactly what a project still running legacy now has to do.
523
527
  auth: {
524
528
  legacyEndpoints: { enabled: true },
525
529
  },
@@ -653,7 +657,11 @@ const config: { [env: string]: IServerOptions } = {
653
657
  // ===========================================================================
654
658
  production: {
655
659
  auth: {
656
- legacyEndpoints: { enabled: process.env.LEGACY_AUTH_ENABLED !== 'false' },
660
+ // Opt-IN since 11.38.0: an unset LEGACY_AUTH_ENABLED now means "no legacy auth",
661
+ // not "legacy auth, because nobody said otherwise". Deployments that still need
662
+ // the legacy endpoints set LEGACY_AUTH_ENABLED=true until their users are migrated
663
+ // (`betterAuthMigrationStatus.canDisableLegacyAuth` says when they are).
664
+ legacyEndpoints: { enabled: process.env.LEGACY_AUTH_ENABLED === 'true' },
657
665
  },
658
666
  automaticObjectIdFiltering: true,
659
667
  baseUrl: process.env.BASE_URL,