@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.
- package/.claude/rules/configurable-features.md +3 -0
- package/CLAUDE.md +1 -0
- package/FRAMEWORK-API.md +3 -2
- package/dist/core/common/helpers/logging.helper.js +1 -1
- package/dist/core/common/helpers/logging.helper.js.map +1 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +3 -0
- package/dist/core/common/services/brevo.service.d.ts +1 -0
- package/dist/core/common/services/brevo.service.js +10 -2
- package/dist/core/common/services/brevo.service.js.map +1 -1
- package/dist/core/modules/better-auth/better-auth.config.d.ts +5 -2
- package/dist/core/modules/better-auth/better-auth.config.js +35 -2
- package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.d.ts +15 -1
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +115 -9
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.d.ts +1 -1
- package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js +14 -1
- package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.controller.js +19 -0
- package/dist/core/modules/better-auth/core-better-auth.controller.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.module.js +19 -5
- package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.service.js +3 -1
- package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.service.d.ts +1 -0
- package/dist/core/modules/tenant/core-tenant.service.js +11 -2
- package/dist/core/modules/tenant/core-tenant.service.js.map +1 -1
- package/dist/templates/password-reset-de.ejs +65 -0
- package/dist/templates/password-reset-en.ejs +65 -0
- package/dist/templates/password-reset.ejs +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/security-overrides.md +17 -11
- package/migration-guides/11.35.1-to-11.36.0.md +168 -0
- package/migration-guides/11.36.0-to-11.36.1.md +283 -0
- package/package.json +25 -25
- package/src/core/common/helpers/logging.helper.ts +9 -1
- package/src/core/common/interfaces/server-options.interface.ts +68 -2
- package/src/core/common/services/brevo.service.ts +34 -6
- package/src/core/modules/better-auth/CUSTOMIZATION.md +33 -7
- package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +38 -0
- package/src/core/modules/better-auth/README.md +100 -15
- package/src/core/modules/better-auth/better-auth.config.ts +142 -9
- package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +265 -20
- package/src/core/modules/better-auth/core-better-auth-rate-limiter.service.ts +30 -2
- package/src/core/modules/better-auth/core-better-auth.controller.ts +38 -0
- package/src/core/modules/better-auth/core-better-auth.module.ts +45 -13
- package/src/core/modules/better-auth/core-better-auth.service.ts +10 -2
- package/src/core/modules/tenant/core-tenant.service.ts +41 -4
- package/src/templates/password-reset-de.ejs +65 -0
- package/src/templates/password-reset-en.ejs +65 -0
- 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
|
|
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.
|
|
25
|
-
|
|
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.
|
|
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.
|
|
94
|
+
"@getbrevo/brevo": "6.0.3",
|
|
95
95
|
"@modelcontextprotocol/sdk": "1.30.0",
|
|
96
|
-
"@nestjs/apollo": "13.4.
|
|
97
|
-
"@nestjs/common": "11.1
|
|
98
|
-
"@nestjs/core": "11.1
|
|
99
|
-
"@nestjs/graphql": "13.4.
|
|
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
|
|
103
|
+
"@nestjs/platform-express": "11.2.1",
|
|
104
104
|
"@nestjs/schedule": "6.1.3",
|
|
105
|
-
"@nestjs/swagger": "11.4.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
170
|
-
"@aws-sdk/s3-request-presigner": "3.
|
|
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
|
|
174
|
+
"@nestjs/testing": "11.2.1",
|
|
175
175
|
"@swc/cli": "0.8.1",
|
|
176
|
-
"@swc/core": "1.
|
|
177
|
-
"@tus/s3-store": "2.0.
|
|
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.
|
|
188
|
-
"@vitest/ui": "4.1.
|
|
187
|
+
"@vitest/coverage-v8": "4.1.11",
|
|
188
|
+
"@vitest/ui": "4.1.11",
|
|
189
189
|
"ansi-colors": "4.1.3",
|
|
190
|
-
"bullmq": "6.0
|
|
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.
|
|
198
|
-
"oxlint": "1.
|
|
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.
|
|
202
|
+
"tsx": "4.23.12",
|
|
203
203
|
"tus-js-client": "4.3.1",
|
|
204
204
|
"typescript": "5.9.3",
|
|
205
|
-
"unplugin-swc": "1.5.
|
|
206
|
-
"vite": "8.2.
|
|
205
|
+
"unplugin-swc": "1.5.11",
|
|
206
|
+
"vite": "8.2.2",
|
|
207
207
|
"vite-plugin-node": "8.0.0",
|
|
208
|
-
"vitest": "4.1.
|
|
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
|
-
/(\/(?:
|
|
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
|