@lenne.tech/nest-server 11.36.0 → 11.36.2

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 (45) hide show
  1. package/.claude/rules/configurable-features.md +3 -1
  2. package/.claude/rules/testing.md +24 -1
  3. package/CLAUDE.md +1 -0
  4. package/FRAMEWORK-API.md +3 -2
  5. package/dist/core/common/helpers/logging.helper.js +1 -1
  6. package/dist/core/common/helpers/logging.helper.js.map +1 -1
  7. package/dist/core/common/interfaces/server-options.interface.d.ts +2 -0
  8. package/dist/core/modules/better-auth/better-auth.config.d.ts +5 -2
  9. package/dist/core/modules/better-auth/better-auth.config.js +34 -2
  10. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  11. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.d.ts +15 -1
  12. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +115 -9
  13. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
  14. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.d.ts +1 -1
  15. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js +14 -1
  16. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js.map +1 -1
  17. package/dist/core/modules/better-auth/core-better-auth.module.js +19 -5
  18. package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
  19. package/dist/core/modules/better-auth/core-better-auth.service.js +3 -1
  20. package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
  21. package/dist/core/modules/tenant/core-tenant.service.d.ts +1 -0
  22. package/dist/core/modules/tenant/core-tenant.service.js +11 -2
  23. package/dist/core/modules/tenant/core-tenant.service.js.map +1 -1
  24. package/dist/templates/password-reset-de.ejs +65 -0
  25. package/dist/templates/password-reset-en.ejs +65 -0
  26. package/dist/templates/password-reset.ejs +1 -1
  27. package/dist/tsconfig.build.tsbuildinfo +1 -1
  28. package/docs/security-overrides.md +17 -11
  29. package/migration-guides/11.36.0-to-11.36.1.md +283 -0
  30. package/migration-guides/11.36.1-to-11.36.2.md +67 -0
  31. package/package.json +10 -11
  32. package/src/core/common/helpers/logging.helper.ts +9 -1
  33. package/src/core/common/interfaces/server-options.interface.ts +50 -8
  34. package/src/core/modules/better-auth/CUSTOMIZATION.md +33 -7
  35. package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +38 -0
  36. package/src/core/modules/better-auth/README.md +100 -15
  37. package/src/core/modules/better-auth/better-auth.config.ts +138 -9
  38. package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +265 -20
  39. package/src/core/modules/better-auth/core-better-auth-rate-limiter.service.ts +30 -2
  40. package/src/core/modules/better-auth/core-better-auth.module.ts +45 -13
  41. package/src/core/modules/better-auth/core-better-auth.service.ts +10 -2
  42. package/src/core/modules/tenant/core-tenant.service.ts +41 -4
  43. package/src/templates/password-reset-de.ejs +65 -0
  44. package/src/templates/password-reset-en.ejs +65 -0
  45. package/src/templates/password-reset.ejs +1 -1
@@ -11,18 +11,17 @@ A green `pnpm audit` inside the framework repo says nothing about your tree.
11
11
 
12
12
  ## What this concretely means for you
13
13
 
14
- The framework pulls in three transitive packages that resolve to a **vulnerable** version unless you
14
+ The framework pulls in two transitive packages that resolve to a **vulnerable** version unless you
15
15
  override them yourself:
16
16
 
17
17
  | Package | Advisory | Why it cannot resolve forward on its own |
18
18
  |---------|----------|------------------------------------------|
19
19
  | `ws` | [GHSA-96hv-2xvq-fx4p](https://github.com/advisories/GHSA-96hv-2xvq-fx4p) — high: memory-exhaustion DoS + uninitialized memory disclosure. Patched `>=8.21.0` | `@nestjs/graphql` declares `"ws": "8.20.1"` — an **exact pin**, not a caret. No amount of updating moves it |
20
- | `@hono/node-server` | [GHSA-frvp-7c67-39w9](https://github.com/advisories/GHSA-frvp-7c67-39w9) (static-file path traversal) + [GHSA-9mqv-5hh9-4cgg](https://github.com/advisories/GHSA-9mqv-5hh9-4cgg) (unauthenticated memory leak). Patched `>=2.0.10` | `@modelcontextprotocol/sdk` declares `^1.19.9` and ships **no 1.x fix line**, so the fix is only available across a major |
21
20
  | `js-yaml` | [GHSA-pm4m-ph32-ghv5](https://github.com/advisories/GHSA-pm4m-ph32-ghv5) — high: exponential parsing time in flow collections (DoS). Patched `>=5.2.2` | `@nestjs/swagger` declares `"js-yaml": "5.2.1"` — an **exact pin**, the same shape as the `ws` case. It cannot resolve forward |
22
21
 
23
22
  `@nestjs/graphql` is a plain `dependencies` entry, so `ws` is installed even when you run with
24
- `graphQl: false`. `@nestjs/swagger` is likewise a plain `dependencies` entry. None of the three is
25
- optional in practice.
23
+ `graphQl: false`. `@nestjs/swagger` is likewise a plain `dependencies` entry. Neither is optional in
24
+ practice.
26
25
 
27
26
  ## The fix
28
27
 
@@ -37,19 +36,26 @@ overrides:
37
36
  # Remove once @nestjs/graphql stops pinning it.
38
37
  'ws@>=8.0.0 <8.21.0': '8.21.3'
39
38
 
40
- # @modelcontextprotocol/sdk declares @hono/node-server ^1.19.9 with no 1.x fix line
41
- # (GHSA-frvp-7c67-39w9, GHSA-9mqv-5hh9-4cgg). Deliberately a CROSS-MAJOR override.
42
- # Verified safe: the SDK's only consumed symbol is `getRequestListener`, whose signature
43
- # `(fetchCallback, options?)` is unchanged in 2.x. Engines >=20 and peer hono@^4 both fit.
44
- # Remove once @modelcontextprotocol/sdk moves its own range to ^2.
45
- '@hono/node-server@<2.0.10': '2.0.11'
46
-
47
39
  # @nestjs/swagger exact-pins js-yaml@5.2.1 (GHSA-pm4m-ph32-ghv5, high, patched >=5.2.2).
48
40
  # Same shape as the ws entry: an exact pin cannot resolve forward.
49
41
  # Remove once @nestjs/swagger stops pinning it.
50
42
  'js-yaml@>=5.0.0 <5.2.2': '5.2.2'
51
43
  ```
52
44
 
45
+ ### Retired: `@hono/node-server` (removed 2026-08-22, nest-server 11.36.1)
46
+
47
+ This list used to carry a third entry. `@modelcontextprotocol/sdk` declared `@hono/node-server@^1.19.9`
48
+ with no 1.x fix line, so reaching the patched version needed a deliberate cross-major override.
49
+
50
+ SDK `1.30.0` declares `"^1.19.9 || ^2.0.5"`, so a fresh resolve now picks the newest 2.x (2.1.1 at the
51
+ time of writing) on its own — above the `>=2.0.10` that fixes GHSA-frvp-7c67-39w9 and
52
+ GHSA-9mqv-5hh9-4cgg. The override became a **downgrade lock**: an override key is matched against the
53
+ REQUESTED RANGE, not the resolved version, so the entry replaced the SDK's whole spec and pinned the
54
+ tree to `2.0.11` even though nothing needed help any more.
55
+
56
+ **Do not re-add it** unless the SDK narrows its range again. If you still carry it in your own
57
+ `pnpm-workspace.yaml`, remove it and re-run `pnpm audit`.
58
+
53
59
  ### The `ws` target must stay in lockstep with the declared `ws` version
54
60
 
55
61
  `8.21.3` is not an arbitrary "latest patch" — it is the version `@lenne.tech/nest-server` declares as
@@ -0,0 +1,283 @@
1
+ # Migration Guide: 11.36.0 → 11.36.1
2
+
3
+ ## Overview
4
+
5
+ | Category | Details |
6
+ |----------|---------|
7
+ | **Breaking Changes** | None in signature. But **behaviour changes without any config change**: `POST /iam/request-password-reset` goes from inert to live and starts sending mail to real users (§1). Read §1 and §2 before upgrading |
8
+ | **New Features** | `betterAuth.emailAndPassword.passwordReset` off switch (§1). `betterAuth.emailVerification.passwordResetBrevoTemplateId` (§3). Branded, locale-aware reset templates (§4). `CoreTenantService.getActiveMembership()` (§9) |
9
+ | **Bugfixes** | `betterAuth.rateLimit: {}` / `{ max: N }` now actually enables the limiter, as documented (§2). Reset URLs are no longer written to logs in staging, and no longer survive log redaction (§5). `options.emailAndPassword` can no longer drop the scrypt password pair (§6). Expired `verification` documents are now removed by a TTL index (§7) |
10
+ | **Migration Effort** | **§1, §2 and §9 are mandatory reading.** §4 matters only if you ship your own `password-reset.ejs`. The rest is automatic |
11
+
12
+ ---
13
+
14
+ ## Quick Migration
15
+
16
+ ```bash
17
+ pnpm update @lenne.tech/nest-server@11.36.1
18
+ pnpm run build
19
+ pnpm test
20
+ ```
21
+
22
+ Then decide §1 (do you want the reset flow?) and check §2 (is your rate limiter actually on?).
23
+
24
+ **Vendor-mode projects:** the changes span
25
+ `modules/tenant/core-tenant.service.ts` (§9),
26
+ `modules/better-auth/better-auth.config.ts`,
27
+ `modules/better-auth/core-better-auth-email-verification.service.ts`,
28
+ `modules/better-auth/core-better-auth.module.ts`,
29
+ `modules/better-auth/core-better-auth.service.ts`,
30
+ `modules/better-auth/core-better-auth-rate-limiter.service.ts`,
31
+ `common/helpers/logging.helper.ts`,
32
+ `common/interfaces/server-options.interface.ts` and `templates/password-reset*.ejs`.
33
+ §1 and §2 each need their interface half AND their implementation half — taking the config field
34
+ without the implementation gives you a setting that type-checks and does nothing.
35
+
36
+ ---
37
+
38
+ ## 1. Native password reset is now ON (behaviour change, no config needed)
39
+
40
+ Better-Auth treats the **presence** of an `emailAndPassword.sendResetPassword` hook as the enable
41
+ flag for `POST /iam/request-password-reset`. Until now nest-server wired no hook, so that route
42
+ answered `RESET_PASSWORD_DISABLED` and no reset mail was ever sent — a password-reset page built
43
+ against it could not work.
44
+
45
+ `CoreBetterAuthModule` now always injects the hook, delegating to
46
+ `CoreBetterAuthEmailVerificationService.sendPasswordResetEmail()`.
47
+
48
+ **What changes for you on `pnpm update` alone:**
49
+
50
+ - The route becomes a live, **unauthenticated**, token-minting, mail-sending endpoint.
51
+ - Real users receive real reset mail — including anyone an attacker names.
52
+ - If your `email.smtp` uses `jsonTransport` outside production, that mail is silently discarded on a
53
+ flow that previously failed loudly.
54
+
55
+ **If you do not want the flow**, turn it off explicitly — there is no other off switch:
56
+
57
+ ```typescript
58
+ // config.env.ts
59
+ betterAuth: {
60
+ emailAndPassword: { passwordReset: false },
61
+ },
62
+ ```
63
+
64
+ **If you do want it**, verify before shipping:
65
+
66
+ 1. Your frontend reset page submits `POST /iam/reset-password` with `{ token, newPassword }`.
67
+ 2. `betterAuth.rateLimit` is genuinely on — see §2.
68
+ 3. `betterAuth.trustedOrigins` contains no wildcard. `redirectTo` is validated against it and the
69
+ reset redirect carries the token, so a wildcard hands a live token to any origin it admits. The
70
+ framework now warns at boot if it finds one.
71
+
72
+ ---
73
+
74
+ ## 2. `betterAuth.rateLimit` presence now enables the limiter (bugfix — check your config)
75
+
76
+ `.claude/rules/configurable-features.md` has always documented this key as *"Presence Implies
77
+ Enabled"*, and `LegacyAuthRateLimiter` has always implemented it that way. `CoreBetterAuthRateLimiter`
78
+ did not: it spread your config over an `enabled: false` default without recomputing, and the
79
+ middleware mount checked `.enabled` separately. So this silently limited **nothing**:
80
+
81
+ ```typescript
82
+ betterAuth: {
83
+ rateLimit: { max: 20, windowSeconds: 60 }, // before 11.36.1: NO middleware, NO limiter
84
+ },
85
+ ```
86
+
87
+ Only an explicit `enabled: true` worked. Both layers now follow the documented contract.
88
+
89
+ **What this means for you:** if you wrote a `rateLimit` block without `enabled: true` believing your
90
+ auth endpoints were throttled, **they were not, and now they are**. Requests that previously always
91
+ passed can now receive 429. Check your `max` / `windowSeconds` are values you actually want before
92
+ upgrading — particularly on machines that share an egress IP, and particularly if `trustProxy` is
93
+ unset (all clients then collapse onto one bucket).
94
+
95
+ To keep the previous no-op behaviour, be explicit:
96
+
97
+ ```typescript
98
+ betterAuth: {
99
+ rateLimit: { enabled: false, max: 20 },
100
+ },
101
+ ```
102
+
103
+ `strictEndpoints` also gained the routes Better-Auth actually serves. `/request-password-reset` and
104
+ `/forget-password` now receive the halved limit; `/forgot-password` (a spelling Better-Auth uses
105
+ nowhere) is kept only for projects that route it themselves.
106
+
107
+ ---
108
+
109
+ ## 3. Brevo template for the reset mail (new, optional)
110
+
111
+ ```typescript
112
+ betterAuth: {
113
+ emailVerification: {
114
+ passwordResetBrevoTemplateId: 42,
115
+ },
116
+ },
117
+ ```
118
+
119
+ Deliberately separate from `brevoTemplateId` and with **no fallback to it** — that one is the
120
+ verification mail, and reusing it would tell somebody who asked to reset their password to confirm
121
+ their address instead. A Brevo send that reports failure by resolving to `null` falls through to
122
+ SMTP rather than returning, so an outage does not leave a locked-out user with no mail at all.
123
+
124
+ Template variables: `name`, `link`, `appName`.
125
+
126
+ ---
127
+
128
+ ## 4. Reset mail templates — check yours renders (action required if you ship one)
129
+
130
+ nest-server now ships `password-reset-de.ejs` and `password-reset-en.ejs` as branded HTML mails, and
131
+ keeps `password-reset.ejs` as the plain fallback.
132
+
133
+ **Project templates still win** via the usual project-first, locale-aware lookup, so if you ship your
134
+ own `password-reset.ejs` you keep it and will not see the new design. Add
135
+ `password-reset-<locale>.ejs` if you want per-locale variants.
136
+
137
+ **The rule that matters:** the un-suffixed `password-reset.ejs` must render from `{ link, name }`
138
+ alone. The legacy `POST /users/password/reset-request` flow
139
+ (`UserService.sendPasswordResetMail()`) resolves that exact name and passes nothing else, so a
140
+ template that references `appName` unguarded turns legacy password recovery into an HTTP 500. Locale
141
+ variants are only reached by the IAM flow and may use `appName` freely.
142
+
143
+ `logoSrc` is supported by the shipped templates but is **not** passed by the framework — supply it
144
+ from a subclass that overrides `sendPasswordResetEmail()` if you want a logo.
145
+
146
+ ---
147
+
148
+ ## 5. Reset URLs no longer leak into logs (security fix, automatic)
149
+
150
+ Two problems, both fixed:
151
+
152
+ - The development log line was gated on `process.env.NODE_ENV !== 'production'`. A staging
153
+ deployment sets `NODE_ENV=staging` so `getEnvironmentConfig()` loads its block — which passes that
154
+ test while holding real users. The gate is now `isProductionLikeEnv()`, the same two-layer check
155
+ (`config.env === 'production' || 'staging'`, or `NODE_ENV === 'production'`) that `EmailService`
156
+ and the cookie helpers already use. The address is masked, and `LT_LOG_AUTH_URLS=0` suppresses the
157
+ line entirely.
158
+ - `redactSensitiveText()` masked `/reset/<token>` but not `/reset-password/<token>`, which is what
159
+ Better-Auth actually emits — so a reset token reached the ADMIN-readable Hub log panel verbatim.
160
+ The path rule now covers `request-password-reset`, `reset-password` and `forget-password`.
161
+
162
+ The same treatment was applied to the verification-mail log line and to the module's fallback
163
+ branches, which previously printed the URL through the NestJS logger with **no** environment gate at
164
+ all.
165
+
166
+ Nothing to configure. If you parse `[PASSWORD RESET]` / `[EMAIL VERIFICATION]` lines out of your own
167
+ logs, note the address is now masked (`re***@test.com`).
168
+
169
+ ---
170
+
171
+ ## 6. `options.emailAndPassword` no longer drops the password hashing (security fix, automatic)
172
+
173
+ `config.options` was merged with a shallow spread (only `advanced` was deep-merged). An
174
+ `options.emailAndPassword` therefore did not add a key — it **replaced the whole block**, including
175
+ the `password: { hash, verify }` scrypt pair nest-server installs. Better-Auth fell back to its own
176
+ hasher, every stored credential stopped verifying, and every user of that deployment was locked out
177
+ at once, at runtime and with nothing in the logs. The module README recommended exactly that shape.
178
+
179
+ `emailAndPassword` is now deep-merged, with `password` merged key-by-key on top of the framework's
180
+ pair. An explicit override still wins; an explicitly-`undefined` half is ignored (Better-Auth
181
+ resolves `password?.hash || hashPassword`, so honouring `undefined` would switch the hasher for
182
+ writes while scrypt still handled reads — an asymmetric pair, and anyone who reset their password
183
+ could never sign in again).
184
+
185
+ **If you worked around the old behaviour** by re-specifying `password.hash` / `password.verify`
186
+ defensively in `options.emailAndPassword`, you can remove that now. **If you set `sendResetPassword`
187
+ there**, note it still wins over the framework hook — your callback replaces
188
+ `sendPasswordResetEmail()` entirely and loses the fire-and-forget wrapper that keeps response time
189
+ from revealing whether an address exists. Prefer the service override.
190
+
191
+ ---
192
+
193
+ ## 7. `verification` documents now expire (automatic)
194
+
195
+ Better-Auth writes a `verification` document per email-verification and per password-reset request,
196
+ and removes it only when the token is **consumed**. With an unauthenticated reset route, every
197
+ un-clicked request left a row that nothing would ever delete.
198
+
199
+ `CoreBetterAuthService.onModuleInit()` now creates a TTL index on `verification.expiresAt`
200
+ (`expireAfterSeconds: 0`) alongside an `identifier` lookup index. Both are idempotent.
201
+
202
+ **Existing deployments:** MongoDB removes already-expired documents within ~60 s of the index being
203
+ built. If your `verification` collection has grown large, expect a one-off background deletion pass
204
+ after the first boot on 11.36.1.
205
+
206
+ ---
207
+
208
+ ## 8. Per-address cooldown on reset mail (automatic)
209
+
210
+ `sendPasswordResetEmail()` reuses the existing `emailVerification.resendCooldownSeconds` window
211
+ (default 60 s) under its own namespace, so a pending verification mail cannot block a reset and vice
212
+ versa. A send that fails releases the slot, so a transient SMTP error does not lock the user out of
213
+ retrying.
214
+
215
+ This is the axis that matters: an attacker rotating IPs to mail-bomb one victim is bounded by the
216
+ recipient-address cooldown, not by the IP-keyed limiter.
217
+
218
+ ---
219
+
220
+ ## 9. Tenant membership: use `getActiveMembership()` for authorization (audit required)
221
+
222
+ `CoreTenantService.getMembership()` answers **regardless of status**. A removal is a status change
223
+ to `SUSPENDED`, not a delete — so it still returns a row for somebody who was thrown out. That is
224
+ deliberate and `addMember()` depends on it (it reactivates the existing row instead of creating a
225
+ duplicate).
226
+
227
+ It is therefore the **wrong method for an authorization decision**. Asking "is this user a member
228
+ with role X?" through it answers yes for a removed member, so a route that guards itself this way
229
+ keeps granting a suspended administrator the right to invite, remove and re-role — the very rights
230
+ that removing them was supposed to take away.
231
+
232
+ **A new method makes the distinction explicit:**
233
+
234
+ ```typescript
235
+ // Deciding what somebody MAY DO → active only
236
+ const membership = await this.tenantService.getActiveMembership(tenantId, userId);
237
+ if (!membership) throw accessDeniedException(currentUser);
238
+
239
+ // Looking up a row REGARDLESS of status (reactivation, audit views, admin UIs)
240
+ const anyMembership = await this.tenantService.getMembership(tenantId, userId);
241
+ ```
242
+
243
+ `getActiveMembership()` returns `null` for a suspended or invited membership, and `null` for an
244
+ empty/blank id rather than matching an arbitrary row. Its query is identical to the one
245
+ `CoreTenantGuard` already uses internally, so the two now agree by construction.
246
+
247
+ **Who needs to act.** `CoreTenantGuard` always filtered on `ACTIVE`, so anything guarded by it was
248
+ never affected. The exposure is in routes that carry `@SkipTenantCheck()` and decide for themselves —
249
+ the guard never sees those. **Audit your project for `getMembership(` used in a permission decision**
250
+ and switch those call sites:
251
+
252
+ ```bash
253
+ grep -rn "getMembership(" src/ | grep -v "getActiveMembership"
254
+ ```
255
+
256
+ **Behaviour change inside the framework.** `updateMemberRole()` and `assertNotLastOwner()` now resolve
257
+ the membership with `getActiveMembership()`. Previously `assertNotLastOwner()` was internally
258
+ inconsistent: it counted owners with `status: ACTIVE` but resolved the membership without a status
259
+ filter, so a **suspended** owner could trigger `"Cannot remove or demote the last owner of a tenant"`
260
+ while not being counted as one. That error no longer fires for a non-active membership.
261
+
262
+ ---
263
+
264
+ ## Troubleshooting
265
+
266
+ | Symptom | Cause | Fix |
267
+ |---------|-------|-----|
268
+ | Users suddenly receive password-reset mail | §1 — the flow is now on | Intended. Set `emailAndPassword.passwordReset: false` to opt out |
269
+ | `POST /iam/request-password-reset` returns 429 | §2 — your `rateLimit` block is now effective | Raise `max`, or set `enabled: false` to restore the previous no-op |
270
+ | Legacy `POST /users/password/reset-request` returns 500 | Your own `password-reset.ejs` references a variable the legacy caller does not pass | §4 — keep the un-suffixed template renderable from `{ link, name }` alone |
271
+ | Reset mail no longer shows the app name | Your template uses `appName` and is reached by the legacy flow | §4 — guard with `typeof appName !== 'undefined'` |
272
+ | `[PASSWORD RESET]` log line is gone | §5 — you are on a production-like env, or `LT_LOG_AUTH_URLS=0` | Intended. Use the Hub mailbox in development |
273
+ | Boot warning about `trustedOrigins` wildcard | §1 — the reset redirect carries the token | List exact origins |
274
+ | `"Cannot remove or demote the last owner"` stopped firing | §9 — it no longer counts a suspended membership | Intended; the membership was not an active owner |
275
+
276
+ ---
277
+
278
+ ## Module Documentation
279
+
280
+ - [BetterAuth README](../src/core/modules/better-auth/README.md) — password-reset configuration, rate limiting, template branding
281
+ - [BetterAuth INTEGRATION-CHECKLIST](../src/core/modules/better-auth/INTEGRATION-CHECKLIST.md) — the four-point password-reset checklist
282
+ - [BetterAuth CUSTOMIZATION](../src/core/modules/better-auth/CUSTOMIZATION.md) — `sendPasswordResetEmail()` override point, template variables
283
+ - [Configurable Features](../.claude/rules/configurable-features.md) — `passwordReset`, `passwordResetBrevoTemplateId`
@@ -0,0 +1,67 @@
1
+ # Migration Guide: 11.36.1 → 11.36.2
2
+
3
+ ## Overview
4
+
5
+ | Category | Details |
6
+ |----------|---------|
7
+ | **Breaking Changes** | None |
8
+ | **New Features** | `check:mutations --jobs=N` — the regression-evidence gate now runs several mutations at once (§1) |
9
+ | **Bugfixes** | None affecting runtime |
10
+ | **Migration Effort** | **None.** Tooling-only release; nothing in `src/` changed |
11
+
12
+ ---
13
+
14
+ ## Quick Migration
15
+
16
+ ```bash
17
+ pnpm update @lenne.tech/nest-server@11.36.2
18
+ ```
19
+
20
+ That is the whole migration. This release changes no framework code, no configuration and no public
21
+ API — `src/` is untouched. It is published so consuming projects stay on a current version and pick
22
+ up the maintenance below.
23
+
24
+ ---
25
+
26
+ ## 1. Faster regression-evidence gate (framework repo only)
27
+
28
+ Relevant if you run `pnpm run check:mutations`, or maintain a fork of this repo's tooling.
29
+
30
+ `scripts/check-mutations.mjs` can now run several mutations in parallel:
31
+
32
+ ```bash
33
+ pnpm run check:mutations # auto: 2 workers, or 4 on a machine with >= 12 cores
34
+ pnpm run check:mutations -- --jobs=4 # explicit
35
+ pnpm run check:mutations -- --jobs=1 # force the previous sequential behaviour
36
+ ```
37
+
38
+ **Measured: 744s → 399s (1.87×) at 4 jobs.** All 49 verdicts were diffed against a sequential run
39
+ and are identical — a parallel mode that changes a verdict is a broken safety net, not a speedup.
40
+
41
+ **Why it was slow in the first place, since it is easy to optimise the wrong thing:** the specs
42
+ behind all 29 e2e mutations add up to about 40 seconds. The rest is paying vitest's cold start once
43
+ per mutation, 49 times. That work is largely single-threaded I/O and barely scales with cores — the
44
+ registry measures 744s on a 12-core laptop and 777s on a 4-vCPU CI runner.
45
+
46
+ **How the isolation works.** A mutation writes into the source tree, so N cannot simply run at once:
47
+ two mutations in one tree would see each other's edits. Each worker gets its own `git worktree`, with
48
+ `node_modules` symlinked from the main checkout. A worktree is checked out at **HEAD**, so parallel
49
+ mode tests committed code — when `src/` or `tests/` is dirty, or `--allow-dirty` is passed, the run
50
+ prints why and falls back to sequential.
51
+
52
+ ---
53
+
54
+ ## 2. Dependency maintenance
55
+
56
+ `@vitest/ui` was removed: no script passes `--ui` and no config imports it. Development-only, no
57
+ runtime effect.
58
+
59
+ ---
60
+
61
+ ## Troubleshooting
62
+
63
+ | Symptom | Cause | Fix |
64
+ |---------|-------|-----|
65
+ | `check:mutations` prints "(sequential: …)" | `src/` or `tests/` is dirty, or `--allow-dirty` was passed | Intended — a worktree at HEAD would test different code. Commit or stash to get the fast path |
66
+ | `check:mutations` seems slower than before | More workers than cores helps nothing; each worker starts a vitest that forks again | Lower `--jobs`, or leave it unset and let it size itself |
67
+ | A stale worktree is left behind | The run was killed with SIGKILL | `git worktree prune` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "11.36.0",
3
+ "version": "11.36.2",
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",
@@ -166,14 +166,14 @@
166
166
  }
167
167
  },
168
168
  "devDependencies": {
169
- "@aws-sdk/client-s3": "3.1112.0",
170
- "@aws-sdk/s3-request-presigner": "3.1112.0",
169
+ "@aws-sdk/client-s3": "3.1115.0",
170
+ "@aws-sdk/s3-request-presigner": "3.1115.0",
171
171
  "@compodoc/compodoc": "2.0.0",
172
172
  "@nestjs/cli": "11.0.24",
173
173
  "@nestjs/schematics": "11.1.0",
174
174
  "@nestjs/testing": "11.2.1",
175
175
  "@swc/cli": "0.8.1",
176
- "@swc/core": "1.16.0",
176
+ "@swc/core": "1.16.1",
177
177
  "@tus/s3-store": "2.0.6",
178
178
  "@types/compression": "1.8.1",
179
179
  "@types/cookie-parser": "1.4.10",
@@ -184,18 +184,17 @@
184
184
  "@types/node": "26.2.0",
185
185
  "@types/nodemailer": "8.0.1",
186
186
  "@types/passport": "1.0.17",
187
- "@vitest/coverage-v8": "4.1.10",
188
- "@vitest/ui": "4.1.10",
187
+ "@vitest/coverage-v8": "4.1.11",
189
188
  "ansi-colors": "4.1.3",
190
- "bullmq": "6.1.2",
189
+ "bullmq": "6.2.0",
191
190
  "find-file-up": "2.0.1",
192
191
  "husky": "9.1.7",
193
192
  "ioredis": "6.0.0",
194
193
  "nodemon": "3.1.14",
195
194
  "npm-watch": "0.13.0",
196
195
  "otpauth": "9.5.1",
197
- "oxfmt": "0.63.0",
198
- "oxlint": "1.78.0",
196
+ "oxfmt": "0.64.0",
197
+ "oxlint": "1.79.0",
199
198
  "rimraf": "6.1.3",
200
199
  "ts-node": "10.9.2",
201
200
  "tsconfig-paths": "4.2.0",
@@ -203,9 +202,9 @@
203
202
  "tus-js-client": "4.3.1",
204
203
  "typescript": "5.9.3",
205
204
  "unplugin-swc": "1.5.11",
206
- "vite": "8.2.1",
205
+ "vite": "8.2.2",
207
206
  "vite-plugin-node": "8.0.0",
208
- "vitest": "4.1.10"
207
+ "vitest": "4.1.11"
209
208
  },
210
209
  "main": "dist/index.js",
211
210
  "types": "dist/index.d.ts",
@@ -159,8 +159,16 @@ export function redactSensitiveText(text: string): string {
159
159
  .replace(/eyJ[\w-]{6,}\.[\w-]{6,}\.[\w-]{4,}/g, (match) => maskToken(match))
160
160
  // reset/verification/invite tokens carried as a URL PATH segment (e.g. /verify/<token>,
161
161
  // /reset/<token>) — these have no `key=value`, so the query-style rule below misses them.
162
+ //
163
+ // The COMPOUND spellings must come first and must be listed at all: Better-Auth emits
164
+ // `/reset-password/<token>`, and a bare `reset` alternative does not match it (`reset` is
165
+ // followed by `-password`, not `/`). That gap let a full account-takeover URL through
166
+ // `HubLogBufferService` into the ADMIN-readable Hub log panel verbatim, while the
167
+ // verification token — which travels as `?token=` and is caught by the rule below — was
168
+ // correctly masked. Order matters: alternation is first-match, so `reset-password` has to
169
+ // precede `reset`.
162
170
  .replace(
163
- /(\/(?:verify|reset|confirm|activate|invite|magic-?link|set-password|change-email)\/)([A-Za-z0-9._~-]{16,})/gi,
171
+ /(\/(?:request-password-reset|reset-password|forget-password|forgot-password|set-password|change-email|magic-?link|verify|reset|confirm|activate|invite)\/)([A-Za-z0-9._~-]{16,})/gi,
164
172
  (_m, prefix, token) => `${prefix}${maskToken(token)}`,
165
173
  )
166
174
  // authorization: Bearer xyz / Authorization=xyz
@@ -361,6 +361,23 @@ export interface IBetterAuthEmailVerificationConfig {
361
361
  */
362
362
  locale?: string;
363
363
 
364
+ /**
365
+ * Brevo transactional template ID for the PASSWORD-RESET mail.
366
+ *
367
+ * Separate from `brevoTemplateId` on purpose: that one is the verification
368
+ * mail, and reusing it would send "confirm your email address" to someone who
369
+ * asked to reset their password. When this is unset the reset mail goes out
370
+ * over SMTP with the `password-reset[-<locale>].ejs` template instead.
371
+ *
372
+ * Template variables passed to Brevo:
373
+ * - `name`: User display name
374
+ * - `link`: Reset URL
375
+ * - `appName`: Application name
376
+ *
377
+ * @default undefined (uses SMTP/EJS templates)
378
+ */
379
+ passwordResetBrevoTemplateId?: number;
380
+
364
381
  /**
365
382
  * Cooldown in seconds between resend requests for the same email address.
366
383
  * Prevents abuse by limiting how often verification emails can be resent.
@@ -543,8 +560,13 @@ export interface IBetterAuthPasskeyConfig {
543
560
  */
544
561
  export interface IBetterAuthRateLimit {
545
562
  /**
546
- * Whether rate limiting is enabled
547
- * @default false
563
+ * Whether rate limiting is enabled.
564
+ *
565
+ * Follows the "presence implies enabled" pattern: providing a `rateLimit` object at all — even
566
+ * `{}` — turns the limiter ON, and only an explicit `enabled: false` keeps it off while letting
567
+ * you pre-configure the rest. Omitting `rateLimit` entirely leaves it off.
568
+ *
569
+ * @default true when a `rateLimit` object is present, false when it is absent
548
570
  */
549
571
  enabled?: boolean;
550
572
 
@@ -3415,6 +3437,22 @@ interface IBetterAuthBase {
3415
3437
  */
3416
3438
  enabled?: boolean;
3417
3439
 
3440
+ /**
3441
+ * Whether Better-Auth's native password-reset flow is available.
3442
+ *
3443
+ * `CoreBetterAuthModule` wires the `sendResetPassword` hook automatically, and the presence of
3444
+ * that hook is what Better-Auth treats as the on switch — so the flow is ON out of the box and
3445
+ * `POST /iam/request-password-reset` mints a token and sends mail.
3446
+ *
3447
+ * Set `false` to withhold the hook, which makes that route answer `RESET_PASSWORD_DISABLED`
3448
+ * again. For deployments whose reset policy is support-mediated or SSO-primary, and which
3449
+ * therefore do not want an unauthenticated, token-minting, mail-sending endpoint at all.
3450
+ *
3451
+ * @default true
3452
+ * @since 11.36.1
3453
+ */
3454
+ passwordReset?: boolean;
3455
+
3418
3456
  /**
3419
3457
  * End every existing session when the user completes a password reset.
3420
3458
  *
@@ -3427,12 +3465,16 @@ interface IBetterAuthBase {
3427
3465
  * the devices they still hold.
3428
3466
  *
3429
3467
  * Passed through to better-auth's native
3430
- * `emailAndPassword.revokeSessionsOnPasswordReset`. It cannot be set via
3431
- * `options` instead: that object is spread SHALLOWLY over the resolved
3432
- * config, so an `options.emailAndPassword` would replace the whole block
3433
- * — including the scrypt `password.hash` / `password.verify` pair this
3434
- * framework installs — and every credential in the database would stop
3435
- * verifying.
3468
+ * `emailAndPassword.revokeSessionsOnPasswordReset`. Prefer this named field
3469
+ * over `options.emailAndPassword`: it is validated (`=== true`, so a JSON
3470
+ * env string cannot enable a sign-out-everywhere behaviour), typed and
3471
+ * discoverable.
3472
+ *
3473
+ * Until 11.36.1 the reason was harsher — `options` was spread SHALLOWLY, so
3474
+ * an `options.emailAndPassword` replaced the whole block including the
3475
+ * scrypt `password.hash` / `password.verify` pair, and every credential in
3476
+ * the database stopped verifying. `emailAndPassword` is deep-merged now,
3477
+ * with `password` re-applied as the base, so that trap is closed.
3436
3478
  *
3437
3479
  * @default false
3438
3480
  * @since 11.36.0
@@ -295,9 +295,27 @@ export class CustomEmailVerificationService extends CoreBetterAuthEmailVerificat
295
295
  await super.sendVerificationEmail(options);
296
296
  // Custom logic after (e.g., analytics)
297
297
  }
298
+
299
+ // The password-reset mail is a separate override point with the same shape. Better-Auth calls it
300
+ // through the `emailAndPassword.sendResetPassword` hook CoreBetterAuthModule injects.
301
+ override async sendPasswordResetEmail(options: SendPasswordResetEmailOptions): Promise<void> {
302
+ // e.g. supply a logo for the shipped templates, which render an <img> when `logoSrc` is set
303
+ await super.sendPasswordResetEmail(options);
304
+ }
305
+
306
+ // Subject lines are separate protected hooks on both flows.
307
+ protected override getPasswordResetSubject(appName: string): string {
308
+ return `${appName} — choose a new password`;
309
+ }
298
310
  }
299
311
  ```
300
312
 
313
+ **Note:** `sendPasswordResetEmail()` re-throws a Brevo or SMTP send failure after logging it with
314
+ the masked address. The framework's own caller wraps it in `sendAuthEmailSafely`, so a throw never
315
+ reaches the request — an override that calls it directly must handle it. It is also throttled per
316
+ recipient address (see `resendCooldownSeconds`); a send that fails releases the slot so the
317
+ locked-out user may retry immediately.
318
+
301
319
  ### CoreBetterAuthUserMapper
302
320
 
303
321
  Handles user mapping between BetterAuth and nest-server User model. Extend when you need to:
@@ -324,7 +342,7 @@ Email templates are resolved in this order:
324
342
  | Template | Purpose | Default Locales |
325
343
  | -------------------- | ----------------------------------- | --------------- |
326
344
  | `email-verification` | Email verification after sign-up | `en`, `de` |
327
- | `password-reset` | Password reset email | `en` |
345
+ | `password-reset` | Password reset email | `en`, `de` |
328
346
  | `welcome` | Welcome email (not used by default) | `en` |
329
347
 
330
348
  ### How to Override Templates
@@ -357,12 +375,20 @@ const config = {
357
375
 
358
376
  Available variables in email templates:
359
377
 
360
- | Variable | Type | Description |
361
- | ----------- | ------ | ------------------------------------------------- |
362
- | `name` | string | User's name or email prefix |
363
- | `link` | string | Verification/reset URL |
364
- | `appName` | string | Application name from package.json |
365
- | `expiresIn` | string | Human-readable expiration time (e.g., "24 hours") |
378
+ | Variable | Type | Passed by | Description |
379
+ | ----------- | ------ | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
380
+ | `name` | string | both flows | User's name or email prefix |
381
+ | `link` | string | both flows | Verification/reset URL |
382
+ | `appName` | string | both flows | Application name from package.json |
383
+ | `expiresIn` | string | verification only | Human-readable expiration time (e.g., "24 hours") |
384
+ | `logoSrc` | string | **nobody — optional** | Not supplied by the framework. The shipped password-reset templates render an `<img>` when it is present and the app name as text otherwise; pass it from a subclass override to use it. |
385
+
386
+ **A template may only reference what its caller passes.** EJS resolves variables at render time, so
387
+ a missing one is a `ReferenceError` and an HTTP 500, not a build error. Two shapes reach
388
+ `password-reset`: the IAM flow passes `{ name, link, appName }`, and the LEGACY
389
+ `POST /users/password/reset-request` flow (`UserService.sendPasswordResetMail()`) passes
390
+ `{ name, link }` only. Guard anything else with `typeof x !== 'undefined'`, as the shipped
391
+ templates do.
366
392
 
367
393
  ### Example Template
368
394