@lenne.tech/nest-server 11.36.0 → 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 (43) hide show
  1. package/.claude/rules/configurable-features.md +3 -1
  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 +2 -0
  7. package/dist/core/modules/better-auth/better-auth.config.d.ts +5 -2
  8. package/dist/core/modules/better-auth/better-auth.config.js +34 -2
  9. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  10. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.d.ts +15 -1
  11. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +115 -9
  12. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
  13. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.d.ts +1 -1
  14. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js +14 -1
  15. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js.map +1 -1
  16. package/dist/core/modules/better-auth/core-better-auth.module.js +19 -5
  17. package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
  18. package/dist/core/modules/better-auth/core-better-auth.service.js +3 -1
  19. package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
  20. package/dist/core/modules/tenant/core-tenant.service.d.ts +1 -0
  21. package/dist/core/modules/tenant/core-tenant.service.js +11 -2
  22. package/dist/core/modules/tenant/core-tenant.service.js.map +1 -1
  23. package/dist/templates/password-reset-de.ejs +65 -0
  24. package/dist/templates/password-reset-en.ejs +65 -0
  25. package/dist/templates/password-reset.ejs +1 -1
  26. package/dist/tsconfig.build.tsbuildinfo +1 -1
  27. package/docs/security-overrides.md +17 -11
  28. package/migration-guides/11.36.0-to-11.36.1.md +283 -0
  29. package/package.json +11 -11
  30. package/src/core/common/helpers/logging.helper.ts +9 -1
  31. package/src/core/common/interfaces/server-options.interface.ts +50 -8
  32. package/src/core/modules/better-auth/CUSTOMIZATION.md +33 -7
  33. package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +38 -0
  34. package/src/core/modules/better-auth/README.md +100 -15
  35. package/src/core/modules/better-auth/better-auth.config.ts +138 -9
  36. package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +265 -20
  37. package/src/core/modules/better-auth/core-better-auth-rate-limiter.service.ts +30 -2
  38. package/src/core/modules/better-auth/core-better-auth.module.ts +45 -13
  39. package/src/core/modules/better-auth/core-better-auth.service.ts +10 -2
  40. package/src/core/modules/tenant/core-tenant.service.ts +41 -4
  41. package/src/templates/password-reset-de.ejs +65 -0
  42. package/src/templates/password-reset-en.ejs +65 -0
  43. 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`
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.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",
@@ -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,18 @@
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.1.2",
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.63.0",
198
- "oxlint": "1.78.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",
@@ -203,9 +203,9 @@
203
203
  "tus-js-client": "4.3.1",
204
204
  "typescript": "5.9.3",
205
205
  "unplugin-swc": "1.5.11",
206
- "vite": "8.2.1",
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
@@ -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
 
@@ -270,6 +270,44 @@ Available variables: `name`, `link`, `expiresIn`, `appName`
270
270
 
271
271
  ---
272
272
 
273
+ ## Password Reset (v11.36.1+)
274
+
275
+ **Native password reset is ON by default and needs no configuration.**
276
+ `CoreBetterAuthModule` injects the `emailAndPassword.sendResetPassword` hook, so
277
+ `POST /iam/request-password-reset` is live from the first boot. Before 11.36.1 no hook was wired
278
+ and the route answered `RESET_PASSWORD_DISABLED`, so **this changes behaviour on upgrade**: reset
279
+ mail starts going out to real users.
280
+
281
+ Check these four things when integrating:
282
+
283
+ - [ ] **Rate limiting is on.** The route is unauthenticated and sends mail. `betterAuth.rateLimit`
284
+ is off unless you configure it — providing the object at all (even `{}`) enables it. The
285
+ mailer additionally holds a per-ADDRESS cooldown (`emailVerification.resendCooldownSeconds`,
286
+ 60 s), which is the axis that matters against mail-bombing one victim from rotating IPs.
287
+ - [ ] **Your reset page exists and points at `POST /iam/reset-password`** with `{ token, newPassword }`.
288
+ Tokens are valid for 1 h by default.
289
+ - [ ] **`trustedOrigins` contains no wildcard.** `redirectTo` is validated against it and the reset
290
+ redirect carries the token, so a wildcard hands a live token to any origin it admits. The
291
+ framework warns at boot if it finds one.
292
+ - [ ] **If you ship your own `password-reset.ejs`, it must render from `{ link, name }` alone.**
293
+ The legacy `POST /users/password/reset-request` flow resolves that exact template name and
294
+ passes nothing else. Locale variants (`password-reset-de.ejs` / `-en.ejs`) are only reached
295
+ by the IAM flow and may use `appName`.
296
+
297
+ To turn the flow off entirely (support-mediated or SSO-primary reset policies):
298
+
299
+ ```typescript
300
+ betterAuth: {
301
+ emailAndPassword: { passwordReset: false },
302
+ },
303
+ ```
304
+
305
+ Optional: `betterAuth.emailVerification.passwordResetBrevoTemplateId` routes the mail through
306
+ Brevo. It deliberately does **not** fall back to `brevoTemplateId` — that is the verification
307
+ template, which would tell the user to confirm their address instead of resetting their password.
308
+
309
+ ---
310
+
273
311
  ## Sign-Up Checks (v11.13.0+)
274
312
 
275
313
  Sign-up validation is **enabled by default** requiring `termsAndPrivacyAccepted`.