@lenne.tech/nest-server 11.35.1 → 11.36.1

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 (51) hide show
  1. package/.claude/rules/configurable-features.md +3 -0
  2. package/CLAUDE.md +1 -0
  3. package/FRAMEWORK-API.md +3 -2
  4. package/dist/core/common/helpers/logging.helper.js +1 -1
  5. package/dist/core/common/helpers/logging.helper.js.map +1 -1
  6. package/dist/core/common/interfaces/server-options.interface.d.ts +3 -0
  7. package/dist/core/common/services/brevo.service.d.ts +1 -0
  8. package/dist/core/common/services/brevo.service.js +10 -2
  9. package/dist/core/common/services/brevo.service.js.map +1 -1
  10. package/dist/core/modules/better-auth/better-auth.config.d.ts +5 -2
  11. package/dist/core/modules/better-auth/better-auth.config.js +35 -2
  12. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  13. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.d.ts +15 -1
  14. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +115 -9
  15. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
  16. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.d.ts +1 -1
  17. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js +14 -1
  18. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js.map +1 -1
  19. package/dist/core/modules/better-auth/core-better-auth.controller.js +19 -0
  20. package/dist/core/modules/better-auth/core-better-auth.controller.js.map +1 -1
  21. package/dist/core/modules/better-auth/core-better-auth.module.js +19 -5
  22. package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
  23. package/dist/core/modules/better-auth/core-better-auth.service.js +3 -1
  24. package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
  25. package/dist/core/modules/tenant/core-tenant.service.d.ts +1 -0
  26. package/dist/core/modules/tenant/core-tenant.service.js +11 -2
  27. package/dist/core/modules/tenant/core-tenant.service.js.map +1 -1
  28. package/dist/templates/password-reset-de.ejs +65 -0
  29. package/dist/templates/password-reset-en.ejs +65 -0
  30. package/dist/templates/password-reset.ejs +1 -1
  31. package/dist/tsconfig.build.tsbuildinfo +1 -1
  32. package/docs/security-overrides.md +17 -11
  33. package/migration-guides/11.35.1-to-11.36.0.md +168 -0
  34. package/migration-guides/11.36.0-to-11.36.1.md +283 -0
  35. package/package.json +25 -25
  36. package/src/core/common/helpers/logging.helper.ts +9 -1
  37. package/src/core/common/interfaces/server-options.interface.ts +68 -2
  38. package/src/core/common/services/brevo.service.ts +34 -6
  39. package/src/core/modules/better-auth/CUSTOMIZATION.md +33 -7
  40. package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +38 -0
  41. package/src/core/modules/better-auth/README.md +100 -15
  42. package/src/core/modules/better-auth/better-auth.config.ts +142 -9
  43. package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +265 -20
  44. package/src/core/modules/better-auth/core-better-auth-rate-limiter.service.ts +30 -2
  45. package/src/core/modules/better-auth/core-better-auth.controller.ts +38 -0
  46. package/src/core/modules/better-auth/core-better-auth.module.ts +45 -13
  47. package/src/core/modules/better-auth/core-better-auth.service.ts +10 -2
  48. package/src/core/modules/tenant/core-tenant.service.ts +41 -4
  49. package/src/templates/password-reset-de.ejs +65 -0
  50. package/src/templates/password-reset-en.ejs +65 -0
  51. 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,168 @@
1
+ # Migration Guide: 11.35.1 → 11.36.0
2
+
3
+ ## Overview
4
+
5
+ | Category | Details |
6
+ |----------|---------|
7
+ | **Breaking Changes** | Sign-in and sign-up now **validate their input**. An address that does not satisfy `IsEmail` is refused with a 400 before Better-Auth sees it — which can lock out an existing account created outside `CoreUserInput` (§2) |
8
+ | **New Features** | `betterAuth.emailAndPassword.revokeSessionsOnPasswordReset` — opt-in, ends the sessions that already existed when the user resets their password (§1) |
9
+ | **Bugfixes** | `brevo.exclude` stopped excluding every second recipient (§3). Malformed or absent sign-in input answers 400 instead of 500 (§2) |
10
+ | **Migration Effort** | **Read §2 before upgrading** and run the account audit there. §1 is opt-in and off by default; §3 needs nothing |
11
+
12
+ ---
13
+
14
+ ## Quick Migration
15
+
16
+ ```bash
17
+ pnpm update @lenne.tech/nest-server@11.36.0
18
+ pnpm run build
19
+ pnpm test
20
+ ```
21
+
22
+ Then run the audit in §2 — it is the only step that can surprise you.
23
+
24
+ **Vendor-mode projects:** all three changes are independent and single-file —
25
+ `modules/better-auth/better-auth.config.ts` (§1),
26
+ `modules/better-auth/core-better-auth.controller.ts` (§2),
27
+ `common/services/brevo.service.ts` (§3). §1 also adds the option to
28
+ `common/interfaces/server-options.interface.ts`; taking the config field without the
29
+ `better-auth.config.ts` half gives you a setting that type-checks and does nothing, so sync those two
30
+ together.
31
+
32
+ ---
33
+
34
+ ## 1. End existing sessions on a password reset (opt-in)
35
+
36
+ A password reset is what somebody reaches for when they suspect their account was taken over.
37
+ Leaving the older sessions alive defeats the point: the attacker keeps theirs, and the new password
38
+ changes nothing for them.
39
+
40
+ ```typescript
41
+ // config.env.ts
42
+ betterAuth: {
43
+ emailAndPassword: {
44
+ revokeSessionsOnPasswordReset: true,
45
+ },
46
+ },
47
+ ```
48
+
49
+ Forwarded to Better-Auth's native flag, which honours it on **all three** reset routes — password
50
+ reset, email-OTP and phone-number.
51
+
52
+ **Off by default, and staying that way.** It is a behaviour change for an existing deployment: after
53
+ a reset the user is signed out everywhere, including on the devices they still hold. That is worth
54
+ choosing, not worth inflicting on upgrade.
55
+
56
+ The value is matched with `=== true`, so a truthy-but-not-`true` value arriving as JSON through
57
+ `NSC__*` or `NEST_SERVER_CONFIG` (a hand-written `"true"`, say) does **not** silently enable a
58
+ sign-out-everywhere behaviour.
59
+
60
+ > **Do not route this through `betterAuth.options` instead.** That object is spread *shallowly* over
61
+ > the resolved config — only `advanced` is deep-merged — so an `options.emailAndPassword` does not
62
+ > add a key, it **replaces the whole block**, including the scrypt `password.hash` / `password.verify`
63
+ > pair the framework installs. Every credential in your database would stop verifying, at runtime,
64
+ > with no boot error. This is why the flag is a first-class config field.
65
+
66
+ ---
67
+
68
+ ## 2. Sign-in and sign-up validate their input — read this before upgrading
69
+
70
+ ### What changed
71
+
72
+ `CoreBetterAuthSignInInput` and `CoreBetterAuthSignUpInput` carried `@ApiProperty()` only:
73
+ documentation, no validation. Every line of the handler then reads `input.email`, so a body that was
74
+ malformed or absent produced a `TypeError` and reached the client as a **500**.
75
+
76
+ | Request | Before | After |
77
+ |---|---|---|
78
+ | `{"email": "not-an-email", "password": "x"}` | `500` | `400`, naming the field |
79
+ | `{}` | `500` | `400`, naming both fields |
80
+ | no body at all | `500` | `400` `Missing input` |
81
+ | valid credentials | unchanged | unchanged |
82
+
83
+ On the most-probed endpoint any deployment has, a 500 tells the caller to retry something that can
84
+ never succeed and files their mistake in the same bucket as a real outage. The legacy sign-in
85
+ answered these cases with a 400; that contract is restored.
86
+
87
+ ### The part that can lock someone out
88
+
89
+ `@IsEmail()` is now enforced on **sign-in**, not just on sign-up. An account whose stored address
90
+ does not satisfy it can no longer authenticate — the request is refused before Better-Auth is
91
+ reached.
92
+
93
+ This is unlikely but not impossible. `CoreUserInput.email` has always carried the same `IsEmail()`
94
+ validator, so an account created through the normal API cannot be affected. An account can still
95
+ have been created another way:
96
+
97
+ - a seed script or migration writing directly through Mongoose,
98
+ - Better-Auth's own native sign-up route,
99
+ - an import from another system.
100
+
101
+ The addresses at risk are the ones that look fine to a human and fail `validator.js`: no TLD
102
+ (`admin@localhost`), a dotless domain (`test@test`), or trailing whitespace.
103
+
104
+ **Audit before upgrading.** Use the real validator rather than a hand-written pattern — `IsEmail`
105
+ delegates to `validator.js`, whose rules are far more involved than any regex you would write for
106
+ this, and a check that disagrees with the one actually enforced is worse than none:
107
+
108
+ ```js
109
+ // node — run from your project root, where class-validator's `validator` resolves
110
+ const isEmail = require('validator/lib/isEmail');
111
+ const { MongoClient } = require('mongodb');
112
+
113
+ const client = await MongoClient.connect(process.env.MONGODB_URI);
114
+ const users = await client.db().collection('users').find({}, { projection: { email: 1 } }).toArray();
115
+ const locked = users.filter(u => !u.email || !isEmail(u.email));
116
+
117
+ console.log(`${locked.length} account(s) can no longer sign in:`);
118
+ locked.forEach(u => console.log(' ', JSON.stringify(u.email), u._id.toString()));
119
+ await client.close();
120
+ ```
121
+
122
+ Anything this lists needs its address corrected before the upgrade, or those users are refused with a
123
+ validation error rather than an authentication error — and the message will not point at the cause.
124
+
125
+ ### If you subclass the controller
126
+
127
+ The validation lives on the DTOs, so an override that keeps the parameter types inherits it. A
128
+ subclass that declares its **own** input class is responsible for its own validators —
129
+ `MapAndValidatePipe` walks the prototype chain child-first and skips a property once a child class
130
+ has validated it.
131
+
132
+ `termsAndPrivacyAccepted` is deliberately the one sign-up field without a presence validator: whether
133
+ consent is required is a policy question answered by `betterAuth.signUpValidation` through
134
+ `CoreBetterAuthSignUpValidatorService`, not by the DTO. It does now carry `@IsBoolean()`.
135
+
136
+ ---
137
+
138
+ ## 3. `brevo.exclude` excluded only every second recipient
139
+
140
+ `BrevoService` called `.test()` on the configured `RegExp`. Projects declare that pattern with the
141
+ `g` flag:
142
+
143
+ ```typescript
144
+ brevo: { exclude: /@(testuser\.com|test\.de)/gi }
145
+ ```
146
+
147
+ `RegExp.prototype.test` advances `lastIndex` on a `g` or `y` pattern, and the config holds a single
148
+ shared instance — so the same address answered `true`, `false`, `true`, … across calls. **Every
149
+ second excluded recipient was sent a real, billable transactional mail.**
150
+
151
+ Found in production: a CI end-to-end run delivered five mails to a `@testuser.com` address that the
152
+ project's own exclude pattern lists, and the provider flagged the runner's IP before anyone noticed
153
+ the guard was only half working.
154
+
155
+ The check now matches against a flagless copy, so each call is independent and the configured
156
+ pattern's `lastIndex` is left untouched — it is shared process-wide, and mutating it leaked into
157
+ every later reader. Both send paths were affected; both are fixed.
158
+
159
+ **Nothing to do.** If you kept the `g` flag off as a workaround, you can stop — it no longer matters
160
+ either way.
161
+
162
+ ---
163
+
164
+ ## Module Documentation
165
+
166
+ - BetterAuth integration: [`src/core/modules/better-auth/INTEGRATION-CHECKLIST.md`](../src/core/modules/better-auth/INTEGRATION-CHECKLIST.md)
167
+ - BetterAuth customization: [`src/core/modules/better-auth/CUSTOMIZATION.md`](../src/core/modules/better-auth/CUSTOMIZATION.md)
168
+ - Configuration patterns: [`.claude/rules/configurable-features.md`](../.claude/rules/configurable-features.md)
@@ -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`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "11.35.1",
3
+ "version": "11.36.1",
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",
@@ -91,21 +91,21 @@
91
91
  "@apollo/server": "5.5.1",
92
92
  "@as-integrations/express5": "1.1.2",
93
93
  "@better-auth/passkey": "1.6.26",
94
- "@getbrevo/brevo": "6.0.2",
94
+ "@getbrevo/brevo": "6.0.3",
95
95
  "@modelcontextprotocol/sdk": "1.30.0",
96
- "@nestjs/apollo": "13.4.2",
97
- "@nestjs/common": "11.1.29",
98
- "@nestjs/core": "11.1.29",
99
- "@nestjs/graphql": "13.4.2",
96
+ "@nestjs/apollo": "13.4.5",
97
+ "@nestjs/common": "11.2.1",
98
+ "@nestjs/core": "11.2.1",
99
+ "@nestjs/graphql": "13.4.5",
100
100
  "@nestjs/jwt": "11.0.2",
101
101
  "@nestjs/mongoose": "11.0.4",
102
102
  "@nestjs/passport": "11.0.5",
103
- "@nestjs/platform-express": "11.1.29",
103
+ "@nestjs/platform-express": "11.2.1",
104
104
  "@nestjs/schedule": "6.1.3",
105
- "@nestjs/swagger": "11.4.6",
105
+ "@nestjs/swagger": "11.4.7",
106
106
  "@nestjs/terminus": "11.1.1",
107
107
  "@tus/file-store": "2.1.1",
108
- "@tus/server": "2.4.3",
108
+ "@tus/server": "2.4.4",
109
109
  "@types/supertest": "7.2.1",
110
110
  "bcrypt": "6.0.0",
111
111
  "better-auth": "1.6.26",
@@ -122,12 +122,12 @@
122
122
  "graphql-subscriptions": "3.0.0",
123
123
  "graphql-upload": "15.0.2",
124
124
  "graphql-ws": "6.2.1",
125
- "jose": "6.2.8",
125
+ "jose": "6.2.9",
126
126
  "js-sha256": "1.0.0",
127
127
  "json-to-graphql-query": "2.3.0",
128
128
  "lodash": "4.18.1",
129
129
  "mongodb": "7.5.0",
130
- "mongoose": "9.9.1",
130
+ "mongoose": "9.9.3",
131
131
  "multer": "2.2.0",
132
132
  "node-mailjet": "6.0.11",
133
133
  "nodemailer": "9.0.5",
@@ -166,15 +166,15 @@
166
166
  }
167
167
  },
168
168
  "devDependencies": {
169
- "@aws-sdk/client-s3": "3.1106.0",
170
- "@aws-sdk/s3-request-presigner": "3.1106.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
- "@nestjs/testing": "11.1.28",
174
+ "@nestjs/testing": "11.2.1",
175
175
  "@swc/cli": "0.8.1",
176
- "@swc/core": "1.15.47",
177
- "@tus/s3-store": "2.0.5",
176
+ "@swc/core": "1.16.1",
177
+ "@tus/s3-store": "2.0.6",
178
178
  "@types/compression": "1.8.1",
179
179
  "@types/cookie-parser": "1.4.10",
180
180
  "@types/ejs": "3.1.5",
@@ -184,28 +184,28 @@
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",
188
+ "@vitest/ui": "4.1.11",
189
189
  "ansi-colors": "4.1.3",
190
- "bullmq": "6.0.9",
190
+ "bullmq": "6.2.0",
191
191
  "find-file-up": "2.0.1",
192
192
  "husky": "9.1.7",
193
193
  "ioredis": "6.0.0",
194
194
  "nodemon": "3.1.14",
195
195
  "npm-watch": "0.13.0",
196
196
  "otpauth": "9.5.1",
197
- "oxfmt": "0.62.0",
198
- "oxlint": "1.77.0",
197
+ "oxfmt": "0.64.0",
198
+ "oxlint": "1.79.0",
199
199
  "rimraf": "6.1.3",
200
200
  "ts-node": "10.9.2",
201
201
  "tsconfig-paths": "4.2.0",
202
- "tsx": "4.23.11",
202
+ "tsx": "4.23.12",
203
203
  "tus-js-client": "4.3.1",
204
204
  "typescript": "5.9.3",
205
- "unplugin-swc": "1.5.10",
206
- "vite": "8.2.1",
205
+ "unplugin-swc": "1.5.11",
206
+ "vite": "8.2.2",
207
207
  "vite-plugin-node": "8.0.0",
208
- "vitest": "4.1.10"
208
+ "vitest": "4.1.11"
209
209
  },
210
210
  "main": "dist/index.js",
211
211
  "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