@lenne.tech/nest-server 11.38.0 → 11.40.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 (91) hide show
  1. package/.claude/rules/configurable-features.md +24 -1
  2. package/.claude/rules/module-inheritance.md +2 -0
  3. package/.claude/rules/package-management.md +117 -2
  4. package/.claude/rules/testing.md +238 -5
  5. package/CLAUDE.md +13 -1
  6. package/FRAMEWORK-API.md +2 -2
  7. package/dist/config.env.js +4 -2
  8. package/dist/config.env.js.map +1 -1
  9. package/dist/core/common/helpers/config.helper.d.ts +2 -0
  10. package/dist/core/common/helpers/config.helper.js +18 -0
  11. package/dist/core/common/helpers/config.helper.js.map +1 -1
  12. package/dist/core/common/helpers/cookies.helper.d.ts +3 -0
  13. package/dist/core/common/helpers/cookies.helper.js +9 -0
  14. package/dist/core/common/helpers/cookies.helper.js.map +1 -1
  15. package/dist/core/common/helpers/input.helper.d.ts +1 -0
  16. package/dist/core/common/helpers/input.helper.js +4 -0
  17. package/dist/core/common/helpers/input.helper.js.map +1 -1
  18. package/dist/core/common/helpers/service.helper.js +6 -1
  19. package/dist/core/common/helpers/service.helper.js.map +1 -1
  20. package/dist/core/common/interceptors/check-security.interceptor.js +1 -0
  21. package/dist/core/common/interceptors/check-security.interceptor.js.map +1 -1
  22. package/dist/core/common/interfaces/server-options.interface.d.ts +2 -1
  23. package/dist/core/common/services/email.service.d.ts +4 -1
  24. package/dist/core/common/services/email.service.js +25 -2
  25. package/dist/core/common/services/email.service.js.map +1 -1
  26. package/dist/core/common/services/module.service.js +1 -0
  27. package/dist/core/common/services/module.service.js.map +1 -1
  28. package/dist/core/modules/ai/providers/openai-compatible.provider.d.ts +2 -0
  29. package/dist/core/modules/ai/providers/openai-compatible.provider.js +28 -3
  30. package/dist/core/modules/ai/providers/openai-compatible.provider.js.map +1 -1
  31. package/dist/core/modules/better-auth/better-auth.config.js +7 -0
  32. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  33. package/dist/core/modules/better-auth/core-better-auth-api.middleware.js +3 -1
  34. package/dist/core/modules/better-auth/core-better-auth-api.middleware.js.map +1 -1
  35. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.d.ts +3 -1
  36. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +30 -5
  37. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
  38. package/dist/core/modules/better-auth/core-better-auth-error-codes.helper.d.ts +2 -0
  39. package/dist/core/modules/better-auth/core-better-auth-error-codes.helper.js +54 -0
  40. package/dist/core/modules/better-auth/core-better-auth-error-codes.helper.js.map +1 -0
  41. package/dist/core/modules/better-auth/index.d.ts +1 -0
  42. package/dist/core/modules/better-auth/index.js +1 -0
  43. package/dist/core/modules/better-auth/index.js.map +1 -1
  44. package/dist/core/modules/error-code/error-codes.d.ts +27 -0
  45. package/dist/core/modules/error-code/error-codes.js +24 -0
  46. package/dist/core/modules/error-code/error-codes.js.map +1 -1
  47. package/dist/core/modules/hub/core-hub.service.js +1 -0
  48. package/dist/core/modules/hub/core-hub.service.js.map +1 -1
  49. package/dist/core/modules/user/core-user.model.d.ts +1 -0
  50. package/dist/core/modules/user/core-user.model.js +11 -1
  51. package/dist/core/modules/user/core-user.model.js.map +1 -1
  52. package/dist/core/modules/user/core-user.service.d.ts +9 -0
  53. package/dist/core/modules/user/core-user.service.js +96 -4
  54. package/dist/core/modules/user/core-user.service.js.map +1 -1
  55. package/dist/server/modules/error-code/error-codes.d.ts +3 -0
  56. package/dist/server/modules/user/user.model.d.ts +5 -0
  57. package/dist/server/modules/user/user.service.js +12 -6
  58. package/dist/server/modules/user/user.service.js.map +1 -1
  59. package/dist/templates/password-reset-de.ejs +12 -0
  60. package/dist/templates/password-reset-en.ejs +12 -0
  61. package/dist/templates/password-reset.ejs +1 -0
  62. package/dist/tsconfig.build.tsbuildinfo +1 -1
  63. package/docs/REQUEST-LIFECYCLE.md +14 -0
  64. package/migration-guides/11.37.x-to-11.38.x.md +18 -1
  65. package/migration-guides/11.38.x-to-11.39.x.md +456 -0
  66. package/migration-guides/11.39.0-to-11.40.0.md +186 -0
  67. package/package.json +5 -4
  68. package/src/config.env.ts +19 -3
  69. package/src/core/common/helpers/config.helper.ts +79 -0
  70. package/src/core/common/helpers/cookies.helper.ts +38 -0
  71. package/src/core/common/helpers/input.helper.ts +37 -0
  72. package/src/core/common/helpers/service.helper.ts +9 -1
  73. package/src/core/common/interceptors/check-security.interceptor.ts +1 -0
  74. package/src/core/common/interfaces/server-options.interface.ts +120 -4
  75. package/src/core/common/services/email.service.ts +46 -1
  76. package/src/core/common/services/module.service.ts +1 -0
  77. package/src/core/modules/ai/README.md +33 -0
  78. package/src/core/modules/ai/providers/openai-compatible.provider.ts +76 -3
  79. package/src/core/modules/better-auth/better-auth.config.ts +25 -0
  80. package/src/core/modules/better-auth/core-better-auth-api.middleware.ts +8 -1
  81. package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +101 -6
  82. package/src/core/modules/better-auth/core-better-auth-error-codes.helper.ts +146 -0
  83. package/src/core/modules/better-auth/index.ts +1 -0
  84. package/src/core/modules/error-code/error-codes.ts +55 -0
  85. package/src/core/modules/hub/core-hub.service.ts +1 -0
  86. package/src/core/modules/user/core-user.model.ts +28 -1
  87. package/src/core/modules/user/core-user.service.ts +267 -5
  88. package/src/server/modules/user/user.service.ts +26 -7
  89. package/src/templates/password-reset-de.ejs +12 -0
  90. package/src/templates/password-reset-en.ejs +12 -0
  91. package/src/templates/password-reset.ejs +1 -0
@@ -608,6 +608,20 @@ If authentication succeeds, `req.user` is set with the authenticated user (inclu
608
608
  > A project subclassing the middleware must know this: the body it forwards is no longer
609
609
  > byte-identical to the body it received. `normalizeResetPassword()` is `protected`.
610
610
 
611
+ > **Response side, since 11.38.0 — `wrapBetterAuthErrorResponse()`.** The mirror image of the note
612
+ > above. Immediately after `authInstance.handler()` returns, every **failed** response passes
613
+ > through a single choke point that rewrites its `message` to carry nest-server's `#LTNS_XXXX:`
614
+ > marker, so frontends can translate it instead of showing Better-Auth's English. The `code` field
615
+ > is left untouched, successful responses are returned **by identity** (the body is a single-read
616
+ > stream, so rebuilding one would break every later branch), and 3xx is skipped — Better-Auth
617
+ > reports some failures by redirecting with `?error=<CODE>`, and rewriting that query string would
618
+ > invent a second contract over its documented one.
619
+ >
620
+ > It sits at ONE point on purpose: every branch below it acts only on `response.ok`, so all error
621
+ > exits inherit the rewrite without any of them being touched. A subclass overriding the middleware
622
+ > must know the counterpart of the rule above — the response body it forwards is no longer
623
+ > byte-identical to the one Better-Auth produced.
624
+
611
625
  #### 2b. SecurityHeadersMiddleware
612
626
 
613
627
  Sets the browser security headers on **every** response — `X-Content-Type-Options`,
@@ -181,6 +181,21 @@ needed, and no data migration.
181
181
  There is nothing to do proactively. Affected users are not locked out: the password that works is
182
182
  the one from *before* the failed reset.
183
183
 
184
+ ### On an IAM-only deployment, "both stores" means one store
185
+
186
+ The mirror into the legacy bcrypt column runs only where a legacy surface exists — gated on the same
187
+ `auth.legacyEndpoints` resolver the endpoints themselves use. That is deliberate: on an IAM-only
188
+ deployment nothing ever reads that column.
189
+
190
+ The visible effect surprises people: after an IAM reset, `users.password` still holds the OLD hash.
191
+ It is inert — sign-in goes through IAM — but a database inspection shows two different passwords on
192
+ one account and looks like the bug this release fixes.
193
+
194
+ **The part that is not inert:** if you later switch legacy endpoints back on (§1), every account that
195
+ reset while they were off becomes reachable with its **pre-reset** password — including any account
196
+ reset *because* the old password leaked. Before enabling `auth.legacyEndpoints` on a deployment that
197
+ has been IAM-only, force a password change for affected users or clear the stale `password` field.
198
+
184
199
  ---
185
200
 
186
201
  ## 3. Bugfix: native IAM reset routes normalize the password
@@ -372,7 +387,9 @@ The reference implementation is `src/server/modules/user/user.service.ts`; copy
372
387
  Two things to check:
373
388
 
374
389
  1. **The return type widened to `null | TUser`.** If you override
375
- `setPasswordResetTokenForEmail()` or call it directly, handle `null`.
390
+ `setPasswordResetTokenForEmail()` or call it directly, handle `null`. **If your endpoint returned
391
+ `!!user`, it now answers `false` for an unknown address and `true` for a known one** — the same
392
+ oracle, one layer up, reintroduced by this very fix. Return a constant instead.
376
393
  2. **Your reset form can no longer say "we do not know this address".** That is the only UX cost —
377
394
  the endpoint carries no password, so the "wrong password vs unknown address" distinction is not
378
395
  affected (that lives at sign-in, governed by the separate `auth.preventUserEnumeration`).
@@ -0,0 +1,456 @@
1
+ # Migration Guide: 11.38.x → 11.39.x
2
+
3
+ ## Overview
4
+
5
+ | Category | Change | Effort |
6
+ |----------|--------|--------|
7
+ | **Security (Critical)** | A NoSQL operator sent where a token string belongs could take over an account | None here — **but audit your own controllers** (§1) |
8
+ | **Security** | The legacy password-reset token now expires (default 60 min); it never did | Low — unredeemed old links stop working (§2) |
9
+ | **Bugfix (critical path)** | The reset mail can finally contain a token, and a validated link | **Required** if you send that mail yourself (§3) |
10
+ | **Changed** | `redirectTo` from the caller outranks the framework default | None — unless you relied on the default (§4) |
11
+ | **Changed** | Both reset flows resolve the app URL identically | None — the IAM flow gains behaviours it lacked (§4) |
12
+ | **Changed** | IAM error messages carry nest-server's `#LTNS_XXXX:` code | None — unless you match on the English text (§5) |
13
+ | **Bugfix** | The production SMTP default could not send mail at all | **Required** — the fix lives in *your* `config.env.ts` (§6) |
14
+ | **Security** | SMTP now requires STARTTLS instead of accepting a downgrade | Low — one env var if your relay cannot do TLS (§6) |
15
+ | **Changed** | The verification link carries `&email=` so a resend button can render | None (§7) |
16
+ | **Bugfix** | A `Set-Cookie` clear on an IAM failure path was being dropped | None (§8) |
17
+ | **New** | Partial index on `passwordResetToken` | None — created at boot (§9) |
18
+
19
+ Nothing here changes the API surface of `CoreModule.forRoot()`.
20
+
21
+ **This is a MINOR, and it contains breaking changes.** In this package the MAJOR digit states which
22
+ NestJS major is targeted and moves only when NestJS does, so a breaking change ships as a minor. Two
23
+ things can stop working for an existing deployment: unredeemed password-reset links (§2) and SMTP
24
+ against a relay that offers no STARTTLS (§6).
25
+
26
+ ---
27
+
28
+ ## Quick Migration
29
+
30
+ ```bash
31
+ pnpm update @lenne.tech/nest-server
32
+ pnpm run build
33
+ pnpm test
34
+ ```
35
+
36
+ Then do three things, in this order of consequence:
37
+
38
+ 1. **§6 — edit your own `config.env.ts`.** The SMTP fix does not arrive with the package. Without
39
+ this edit a production deployment on the default port sends **no mail at all**, silently.
40
+ 2. **§3 — switch your `sendPasswordResetMail()` to the two new methods**, if you send that mail
41
+ yourself. Anything still reading `user.passwordResetToken` off a `process()` result is mailing
42
+ the word `undefined` today.
43
+ 3. **§1 — grep your controllers** for `@Body('x') x: string` / `@Query('x') x: string` whose value
44
+ reaches a database query.
45
+
46
+ ---
47
+
48
+ ## 1. Security: an operator where a string belongs
49
+
50
+ `POST /users/password/reset` accepted a JSON **object** in place of the token string:
51
+
52
+ ```jsonc
53
+ { "token": { "$ne": null }, "password": "…" }
54
+ ```
55
+
56
+ `findOne({ passwordResetToken: { $ne: null } })` then selected the first user holding *any* live
57
+ reset token and set that account's password. The attacker never saw the mail. Confirmed against the
58
+ live route, not inferred: the victim's token was consumed and their password hash changed.
59
+
60
+ **Why nothing caught it.** A parameter declared `@Body('token') token: string` has
61
+ `metatype === String`, and `MapAndValidatePipe` returns basic-type values verbatim — the declared
62
+ type is erased at runtime, so nothing validates it. Express parses query strings with `qs` in
63
+ extended mode, so `?token[$ne]=` reaches a `@Query('token') token: string` identically.
64
+
65
+ This is **pre-existing**, not introduced by 11.38.0. The framework's five affected sinks are now
66
+ guarded.
67
+
68
+ ### What you must do
69
+
70
+ **The structural answer is not the guard — it is the DTO.** A parameter typed as a class is
71
+ validated by the pipe; a basic-type parameter is not:
72
+
73
+ ```typescript
74
+ // Exposed: metatype is String, the pipe returns whatever arrived
75
+ async resetPassword(@Body('token') token: string) { … }
76
+
77
+ // Safe: metatype is a class, the pipe validates, an object is rejected before your code runs
78
+ async resetPassword(@Body() input: ResetPasswordInput) { … } // token: @IsString()
79
+ ```
80
+
81
+ For an existing signature you do not want to change, `isQueryableString` is exported:
82
+
83
+ ```typescript
84
+ import { isQueryableString } from '@lenne.tech/nest-server';
85
+
86
+ if (!isQueryableString(token)) {
87
+ throw new NotFoundException(ErrorCode.LINK_INVALID_OR_EXPIRED);
88
+ }
89
+ ```
90
+
91
+ Guard in the **service**, not the controller, if anything other than HTTP reaches it — an MCP tool
92
+ or a cron job calling the service directly bypasses a controller-level check.
93
+
94
+ **Deliberately NOT done:** `mongoose.set('sanitizeFilter', true)` globally. It wraps every
95
+ object-valued filter in `$eq`, which breaks the framework's own operator-bearing queries unless each
96
+ is wrapped in `mongoose.trusted()`. That is a fleet-wide audit, not a fix.
97
+
98
+ ---
99
+
100
+ ## 2. Security: the legacy password-reset token now expires
101
+
102
+ It never did. `resetPassword()` looked the token up by value and compared no time at all — while the
103
+ exception it threw read `Invalid or expired password reset token`, claiming a check that did not
104
+ exist.
105
+
106
+ A reset link takes over an **existing** account, which makes it the most powerful token this
107
+ framework mints. Unbounded, it means a mail in an archive, a forwarded message or a restored backup
108
+ opens that account years later. The IAM half already expired after an hour; only the legacy half had
109
+ nothing.
110
+
111
+ ```typescript
112
+ auth: {
113
+ passwordReset: { tokenExpiresInMinutes: 60 }, // the default; 0 disables expiry entirely
114
+ },
115
+ ```
116
+
117
+ | Value | Meaning |
118
+ |-------|---------|
119
+ | unset | 60 minutes |
120
+ | a positive number | that many minutes |
121
+ | `0` | no expiry — restores the previous behaviour |
122
+ | negative, `NaN`, empty, non-numeric | **60 minutes**, not "unbounded" |
123
+
124
+ That last row is deliberate. Switching off the expiry of an account-takeover credential is a decision
125
+ somebody has to state; a typo must never be the thing that states it. (`Number('')` is `0`, so an
126
+ empty variable would otherwise read as a deliberate opt-out.)
127
+
128
+ An expired token is answered exactly like an unknown one — **404**, same message — and deleted on
129
+ sight. Distinguishing them would confirm to somebody holding a stale token that it was once real.
130
+
131
+ ### Are you affected?
132
+
133
+ | Situation | Effect |
134
+ |-----------|--------|
135
+ | A user holds an unredeemed reset mail from before the upgrade | It stops working. They request a new one — which, for most projects, is the first link that actually works (§3) |
136
+ | Your tests write a `passwordResetToken` directly into the database | **Set `passwordResetTokenExpiresAt` too**, or they now get 404 |
137
+ | You need the old behaviour | `tokenExpiresInMinutes: 0` |
138
+
139
+ `CoreUserModel` gains `passwordResetTokenExpiresAt`, `@Restricted(S_NO_ONE)` like the token itself.
140
+
141
+ ### The mail now states the deadline
142
+
143
+ An expiry the recipient is not told about is no better than a broken link — both arrive, look right,
144
+ and fail on click. All three shipped templates state it, from a new optional `templateData` field:
145
+
146
+ ```typescript
147
+ templateData: { link, linkExpiresInMinutes: 60, name } // omit it, or pass 0, and no sentence renders
148
+ ```
149
+
150
+ Take the value from the flow you are in: `auth.passwordReset.tokenExpiresInMinutes` for legacy,
151
+ Better-Auth's `resetPasswordTokenExpiresIn` (seconds) for IAM. They expire independently, and
152
+ announcing the other one's deadline is worse than announcing none.
153
+
154
+ ---
155
+
156
+ ## 3. Bugfix: the reset mail can finally carry a token and a working link
157
+
158
+ Two production incidents, one after the other, both reaching real recipients.
159
+
160
+ **The token was never in the link.** `setPasswordResetTokenForEmail()` returns the user through
161
+ `process()`, and the security interceptor strips `passwordResetToken` there — correctly: a reset
162
+ token in a response body is one in a log, a proxy cache and a browser history. So the method named
163
+ after setting the token could not hand it over, and callers mailed `undefined`.
164
+
165
+ **The base was never validated.** `email.passwordResetLink` had no default and callers concatenated
166
+ it anyway, so a project that never set it mailed `undefined/<token>`.
167
+
168
+ Every test stayed green both times, because each checked a piece: the config spec asserted the base,
169
+ the story test read the token from the *database*, the enumeration spec asserted status and timing.
170
+ Nothing asserted the one value the recipient actually receives.
171
+
172
+ ### What you must do
173
+
174
+ ```typescript
175
+ async sendPasswordResetMail(email: string, serviceOptions?: ServiceOptions): Promise<null | User> {
176
+ // NOT setPasswordResetTokenForEmail — that one cannot return the token, by design.
177
+ const created = await this.createPasswordResetToken(email, serviceOptions);
178
+ if (!created) {
179
+ return null; // unknown address — answer exactly as for a known one
180
+ }
181
+
182
+ const link = this.buildPasswordResetLink(created.token);
183
+ if (!link) {
184
+ // Sending a mail whose link cannot work is worse than sending none.
185
+ this.userServiceLogger.error('Password reset mail not sent: set `email.passwordResetLink` or `appUrl`.');
186
+ return created.user;
187
+ }
188
+
189
+ // NOT awaited: awaiting it makes the known path visibly slower whatever the status code says.
190
+ void this.emailService.sendMail(created.user.email, 'Password reset', {
191
+ htmlTemplate: 'password-reset',
192
+ templateData: { link, linkExpiresInMinutes: this.passwordResetTokenExpiryMinutes(), name: created.user.username },
193
+ }).catch((error: unknown) => this.userServiceLogger.error(`Failed to send: ${String(error)}`));
194
+
195
+ return created.user;
196
+ }
197
+ ```
198
+
199
+ The reference implementation is `src/server/modules/user/user.service.ts`; copy that shape.
200
+
201
+ ### Two link conventions, and which applies depends on whether you set the option at all
202
+
203
+ | `email.passwordResetLink` | Resulting link |
204
+ |---------------------------|----------------|
205
+ | not set | `<appUrl>/auth/reset-password?token=<token>` |
206
+ | set, containing `{token}` | your value, placeholder substituted |
207
+ | set, **without** `{token}` | your value + `/<token>` as a **path segment** |
208
+
209
+ Writing the default out by hand therefore gives a **different** link than leaving the line out. That
210
+ looks like a trap and is kept deliberately: a project that configures such a value has a page built
211
+ for a path segment, and moving it silently would break the very flow this release repairs. The unset
212
+ default instead matches the page `nuxt-base-starter` ships, which reads `route.query.token`.
213
+
214
+ Because the rule is not guessable, **the framework warns once at boot** when the option is set
215
+ without `{token}`. The warning names a precondition: `{token}` is substituted by
216
+ `buildPasswordResetLink()` and nothing else, so a caller still concatenating by hand must switch
217
+ first or it mails the placeholder verbatim.
218
+
219
+ ---
220
+
221
+ ## 4. Changed: `redirectTo` wins, and both flows resolve the app URL identically
222
+
223
+ **Your own `redirectTo` now outranks the framework default.** If the client passed one to
224
+ `requestPasswordReset` — `nuxt-base-starter` does on every request — the mail links there.
225
+ Resolution order: explicit config → the caller's `redirectTo` → `<appUrl>/auth/reset-password` →
226
+ Better-Auth's own link. `passwordResetLink: false` still wins over a `redirectTo`.
227
+
228
+ This matters for a project that renamed its reset route: before, the mail carried the framework's
229
+ guess and the recipient landed on a route that does not exist. It also restores exact equivalence
230
+ with Better-Auth's redirect hop, which sends the user to `redirectTo?token=`.
231
+
232
+ **On safety:** `redirectTo` arrives in the body of an unauthenticated endpoint, so putting it into a
233
+ mail would be a token-exfiltration vector — were it not validated first. Better-Auth runs
234
+ `originCheck` on it at request time, so a value outside your `trustedOrigins` is rejected before any
235
+ mail is built. That makes `trustedOrigins` load-bearing for this path as well as for CORS; keep
236
+ `cors.allowedOrigins` as narrow as your frontends need.
237
+
238
+ **Both flows now resolve the app URL through one function.** The IAM flow previously read `appUrl`
239
+ straight off the configuration while the legacy flow had been moved to the shared resolver — two
240
+ hand-maintained copies of one decision, and they drifted. The IAM flow gains three behaviours it
241
+ lacked: the localhost default that `local`/`ci`/`e2e` depend on (none of them set `appUrl`),
242
+ derivation from a host-split `baseUrl` such as `https://api.crm.localhost`, and the
243
+ `cors.deriveAppUrl` opt-out that keeps a reset token out of an untrusted apex domain.
244
+
245
+ In practice this changes the IAM link only where `appUrl` is unset — local and CI. Production, where
246
+ it is set, is unaffected.
247
+
248
+ ---
249
+
250
+ ## 5. Changed: IAM error messages carry nest-server's error code
251
+
252
+ Better-Auth answers with its own vocabulary — `{ code: 'INVALID_TOKEN', message: 'Invalid token' }` —
253
+ while the frontends in this stack translate by parsing nest-server's marker. The parser found no
254
+ code and passed the raw string through, so **every** IAM error reached end users as English
255
+ developer text. It was felt hardest where it could least be afforded: an expired reset link answers
256
+ `INVALID_TOKEN`, so the person who already cannot sign in was the one reading it.
257
+
258
+ ```jsonc
259
+ // before
260
+ { "code": "INVALID_TOKEN", "message": "Invalid token" }
261
+ // after
262
+ { "code": "INVALID_TOKEN", "message": "#LTNS_0027: Link is invalid or expired" }
263
+ ```
264
+
265
+ **The `code` field is unchanged**, so anything branching on it keeps working.
266
+
267
+ New codes: `LTNS_0027 LINK_INVALID_OR_EXPIRED`, `LTNS_0028 PASSWORD_TOO_LONG`,
268
+ `LTNS_0029 PASSWORD_TOO_SHORT`.
269
+
270
+ Three deliberate absences, each for a reason worth knowing:
271
+
272
+ - **`INVALID_TOKEN` and `TOKEN_EXPIRED` map to the same message.** Better-Auth throws
273
+ `TOKEN_EXPIRED` in exactly one place — an expired verification *link*, one line above the
274
+ `INVALID_TOKEN` it throws for a broken one. Same user action, so the same instruction. The
275
+ session-oriented "sign in again" would be a dead end there: at that moment the user is usually not
276
+ signed in at all.
277
+ - **`USER_NOT_FOUND` is left untranslated.** It is an account-enumeration signal. Better-Auth already
278
+ exposes it as a `code`, but translating it would make the oracle friendlier and more legible — the
279
+ opposite direction from the rest of this release.
280
+ - **`SESSION_EXPIRED` is left untranslated.** It means "re-authenticate for this action", which is
281
+ not what nest-server's `TOKEN_EXPIRED` tells the user to do.
282
+
283
+ Reachable programmatically via `mapBetterAuthErrorCode(code)`.
284
+
285
+ ### Two boundaries
286
+
287
+ - **A redirect is not rewritten.** Better-Auth reports some failures by redirecting to your
288
+ `callbackURL` with `?error=<CODE>` rather than answering with a body — nothing to translate, and
289
+ rewriting that query string would invent a second contract over its documented one. Read `?error=`
290
+ in the frontend. Narrower than it sounds: with `betterAuth.emailVerification.callbackURL` set —
291
+ which the starter does — the mail links straight at the frontend and the redirect route is never
292
+ walked.
293
+ - **`PASSWORD_TOO_SHORT` / `PASSWORD_TOO_LONG` are rarely reachable through the lt frontend**, which
294
+ hashes with `ltSha256` before sending, so every password arrives as 64 hex characters. They remain
295
+ reachable for a client that does not hash, and for a project configuring `minPasswordLength` above
296
+ 64.
297
+
298
+ ### Are you affected?
299
+
300
+ | Situation | Effect |
301
+ |-----------|--------|
302
+ | You use `@lenne.tech/nuxt-extensions` | Errors become translatable; no change needed |
303
+ | You branch on `error.code` | Unaffected |
304
+ | You match on the exact English `message` string | **Update it** — prefer `code`, which is stable |
305
+
306
+ ---
307
+
308
+ ## 6. Bugfix: the production SMTP default could not send mail
309
+
310
+ The `production` profile paired the default port **587** with `secure` resolving to **true**. Those
311
+ two cannot go together: `secure` means implicit TLS, which only port **465** speaks, while 587 opens
312
+ in plaintext and upgrades through STARTTLS. Nodemailer sent a TLS ClientHello to a server answering
313
+ with an SMTP greeting, and OpenSSL reported `wrong version number`.
314
+
315
+ A deployment configuring only `SMTP_HOST` and credentials could therefore send **no mail at all** —
316
+ invisibly, because authentication mail is deliberately not awaited, so the failure never reached a
317
+ response. The API answered 200 while every password-reset message died in transport.
318
+
319
+ ### This does NOT arrive with `pnpm update`
320
+
321
+ The fix landed in the framework's own `src/config.env.ts`; your project has its own copy, seeded from
322
+ the starter at init and never re-synced. Upgrading gives you a boot warning — the repair is an edit
323
+ you make:
324
+
325
+ ```typescript
326
+ import { resolveSmtpSecure } from '@lenne.tech/nest-server';
327
+
328
+ const smtpPort = parseInt(process.env.SMTP_PORT || '587', 10);
329
+ // …
330
+ smtp: {
331
+ host: process.env.SMTP_HOST,
332
+ port: smtpPort,
333
+ - secure: process.env.SMTP_SECURE !== 'false',
334
+ + secure: resolveSmtpSecure(process.env.SMTP_SECURE, smtpPort),
335
+ + requireTLS: process.env.SMTP_REQUIRE_TLS !== 'false',
336
+ },
337
+ ```
338
+
339
+ | `SMTP_SECURE` | Result |
340
+ |---------------|--------|
341
+ | `true` / `false` | honoured, even where it cannot work — and warned about |
342
+ | unset, or anything else (`1`, `yes`, a typo) | derived: `true` on port 465, `false` otherwise |
343
+
344
+ Only the two canonical values override, because `secure` is not an independent setting but a
345
+ consequence of the port. Both obvious string rules have a silent wrong side — `!== 'false'` makes an
346
+ unknown value `true` on 587 (this outage), `=== 'true'` makes `1` resolve to `false` on 465 (the same
347
+ outage mirrored). Deferring to the port is the only rule under which no input produces a pair that
348
+ cannot connect.
349
+
350
+ ### `requireTLS` is the second half, and it is new
351
+
352
+ `secure: false` alone means **opportunistic** STARTTLS: nodemailer upgrades only when the server
353
+ advertises it. An on-path attacker who strips that capability line gets a plaintext session carrying
354
+ your SMTP credentials and a working password-reset link. Before the repair nothing was sent at all,
355
+ so this exposure is created by the fix — which is why the fix has to include the floor.
356
+
357
+ Set `SMTP_REQUIRE_TLS=false` only for an internal relay that genuinely offers no STARTTLS.
358
+ `EmailService` warns once when the resolved pair cannot connect — reported, never overruled.
359
+
360
+ ---
361
+
362
+ ## 7. Changed: the verification link carries the address
363
+
364
+ `{callbackURL}?token=<token>&email=<address>`. The verification page needs it to offer "send a new
365
+ email" once the token has expired, and it cannot recover the address itself: that value lives inside
366
+ the token's JWT payload, and reading it there would mean rendering data from an unverified
367
+ signature. Without it, a user with an expired link is told correctly what happened and given no way
368
+ to fix it.
369
+
370
+ ---
371
+
372
+ ## 8. Bugfix: a `Set-Cookie` clear was being dropped
373
+
374
+ The header rebuild in the new error wrapper used `Headers.forEach` + `set()`. `forEach` yields each
375
+ `set-cookie` entry separately and `set()` overwrites, so all but the last were discarded. Better-Auth
376
+ **clears** session and 2FA cookies on several failure paths — precisely the responses being rewritten
377
+ — so the clear was lost and a stale credential stayed in the browser.
378
+
379
+ No action required. Noted because a failed 2FA attempt against an 11.39.0 pre-release could have left
380
+ a session cookie behind.
381
+
382
+ ---
383
+
384
+ ## 9. New: partial index on `passwordResetToken`
385
+
386
+ `findOne({ passwordResetToken: token })` runs on an unauthenticated, unrated endpoint and had no
387
+ index — a full collection scan of `users` per call. The index is partial
388
+ (`partialFilterExpression: { passwordResetToken: { $type: 'string' } }`) and the reset path now
389
+ `$unset`s the field rather than writing `null`, so it stays small.
390
+
391
+ **Never give `passwordResetTokenExpiresAt` a TTL index.** It is a `Date`, and a TTL index there would
392
+ delete the **user document**, not the token.
393
+
394
+ ---
395
+
396
+ ## 10. Operations: two boot warnings you may now see
397
+
398
+ **`jwks` under a throwaway or fallback secret.** The JWT plugin persists a `jwks` document encrypted
399
+ with whatever secret is active. Booting once without a permanent `betterAuth.secret` — or on the
400
+ `jwt.secret` fallback — leaves a row that a later, different secret cannot decrypt. Nothing fails at
401
+ the time; `/iam/token` starts answering `Failed to decrypt private key` whenever somebody next asks
402
+ for a JWT, which on a low-traffic deployment can be months later. Observed in production with five
403
+ weeks between cause and symptom.
404
+
405
+ Remedy: reuse the same value, or drop the `jwks` collection after setting a permanent secret.
406
+ Better-Auth creates a new key; previously issued JWTs were already unusable.
407
+
408
+ ---
409
+
410
+ ## 11. Vendor mode: one new file
411
+
412
+ | New file | Existing core files that now import it |
413
+ |---|---|
414
+ | `better-auth/core-better-auth-error-codes.helper.ts` | `better-auth/core-better-auth-api.middleware.ts`, `better-auth/index.ts` |
415
+
416
+ Taking the middleware without the helper leaves an unresolvable import.
417
+
418
+ ---
419
+
420
+ ## 12. Dependencies: two security overrides raised
421
+
422
+ `fast-uri` and `qs` were already overridden, and both entries had **aged past their own patch line**.
423
+ A stale override is not inert — it pins matching paths *below* the current fix, and adding a second,
424
+ higher entry does not help because the narrower key claims its paths first and the tree ends up
425
+ carrying both versions.
426
+
427
+ Raised in place: `'fast-uri@<3.1.6': '3.1.6'` and `'qs@<6.16.0': '6.16.0'`. `pnpm audit` is clean.
428
+
429
+ If you maintain your own overrides, the tell is: **`pnpm audit` reporting a package you already
430
+ override is never "transitive, pre-existing, not our problem"** — it means the entry is the cause.
431
+ See `.claude/rules/package-management.md` for both aging modes.
432
+
433
+ ---
434
+
435
+ ## Troubleshooting
436
+
437
+ | Symptom | Cause | Fix |
438
+ |---------|-------|-----|
439
+ | The reset mail link ends in `/undefined` | §3 — the token was read off a `process()` result, where the interceptor strips it | `createPasswordResetToken()`, read `created.token` |
440
+ | The reset mail link STARTS with `undefined/` | §3 — `email.passwordResetLink` unset and the caller concatenated anyway | `buildPasswordResetLink()`, which returns `null` instead of guessing |
441
+ | The reset page reports an invalid token, but the URL looks fine | §3 — the link carries a path segment and the page reads `?token=`, or the reverse | Configure `email.passwordResetLink` with `{token}` where your page expects it |
442
+ | A reset link that worked yesterday now 404s | §2 — tokens expire after 60 minutes | Request a new one, or `tokenExpiresInMinutes: 0` |
443
+ | `POST /users/password/reset` answers 404 for a token that exists | §1 — the value was not a plain string, or §2 — it expired | Both answer identically on purpose |
444
+ | Production sends no mail and the log shows `wrong version number` | §6 — port 587 with `secure: true` | Apply the `config.env.ts` edit |
445
+ | Production sends no mail and the log mentions STARTTLS | §6 — `requireTLS` and a relay that offers none | `SMTP_REQUIRE_TLS=false`, after confirming the relay really cannot |
446
+ | `/iam/token` fails with `Failed to decrypt private key` | §10 — a `jwks` key encrypted under a different secret | Drop the `jwks` collection after setting a permanent secret |
447
+ | Your tests get 404 on a reset token they wrote directly | §2 — the row has no `passwordResetTokenExpiresAt` and counts as expired | Set it in the fixture |
448
+
449
+ ---
450
+
451
+ ## Module Documentation
452
+
453
+ - [BetterAuth README](../src/core/modules/better-auth/README.md)
454
+ - [BetterAuth Integration Checklist](../src/core/modules/better-auth/INTEGRATION-CHECKLIST.md)
455
+ - [Request Lifecycle](../docs/REQUEST-LIFECYCLE.md)
456
+ - [Configurable Features](../.claude/rules/configurable-features.md)