@lenne.tech/nest-server 11.36.0 → 11.36.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/rules/configurable-features.md +3 -1
- package/.claude/rules/testing.md +24 -1
- 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 +2 -0
- package/dist/core/modules/better-auth/better-auth.config.d.ts +5 -2
- package/dist/core/modules/better-auth/better-auth.config.js +34 -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.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.36.0-to-11.36.1.md +283 -0
- package/migration-guides/11.36.1-to-11.36.2.md +67 -0
- package/package.json +10 -11
- package/src/core/common/helpers/logging.helper.ts +9 -1
- package/src/core/common/interfaces/server-options.interface.ts +50 -8
- 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 +138 -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.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,283 @@
|
|
|
1
|
+
# Migration Guide: 11.36.0 → 11.36.1
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
| Category | Details |
|
|
6
|
+
|----------|---------|
|
|
7
|
+
| **Breaking Changes** | None in signature. But **behaviour changes without any config change**: `POST /iam/request-password-reset` goes from inert to live and starts sending mail to real users (§1). Read §1 and §2 before upgrading |
|
|
8
|
+
| **New Features** | `betterAuth.emailAndPassword.passwordReset` off switch (§1). `betterAuth.emailVerification.passwordResetBrevoTemplateId` (§3). Branded, locale-aware reset templates (§4). `CoreTenantService.getActiveMembership()` (§9) |
|
|
9
|
+
| **Bugfixes** | `betterAuth.rateLimit: {}` / `{ max: N }` now actually enables the limiter, as documented (§2). Reset URLs are no longer written to logs in staging, and no longer survive log redaction (§5). `options.emailAndPassword` can no longer drop the scrypt password pair (§6). Expired `verification` documents are now removed by a TTL index (§7) |
|
|
10
|
+
| **Migration Effort** | **§1, §2 and §9 are mandatory reading.** §4 matters only if you ship your own `password-reset.ejs`. The rest is automatic |
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Quick Migration
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pnpm update @lenne.tech/nest-server@11.36.1
|
|
18
|
+
pnpm run build
|
|
19
|
+
pnpm test
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Then decide §1 (do you want the reset flow?) and check §2 (is your rate limiter actually on?).
|
|
23
|
+
|
|
24
|
+
**Vendor-mode projects:** the changes span
|
|
25
|
+
`modules/tenant/core-tenant.service.ts` (§9),
|
|
26
|
+
`modules/better-auth/better-auth.config.ts`,
|
|
27
|
+
`modules/better-auth/core-better-auth-email-verification.service.ts`,
|
|
28
|
+
`modules/better-auth/core-better-auth.module.ts`,
|
|
29
|
+
`modules/better-auth/core-better-auth.service.ts`,
|
|
30
|
+
`modules/better-auth/core-better-auth-rate-limiter.service.ts`,
|
|
31
|
+
`common/helpers/logging.helper.ts`,
|
|
32
|
+
`common/interfaces/server-options.interface.ts` and `templates/password-reset*.ejs`.
|
|
33
|
+
§1 and §2 each need their interface half AND their implementation half — taking the config field
|
|
34
|
+
without the implementation gives you a setting that type-checks and does nothing.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## 1. Native password reset is now ON (behaviour change, no config needed)
|
|
39
|
+
|
|
40
|
+
Better-Auth treats the **presence** of an `emailAndPassword.sendResetPassword` hook as the enable
|
|
41
|
+
flag for `POST /iam/request-password-reset`. Until now nest-server wired no hook, so that route
|
|
42
|
+
answered `RESET_PASSWORD_DISABLED` and no reset mail was ever sent — a password-reset page built
|
|
43
|
+
against it could not work.
|
|
44
|
+
|
|
45
|
+
`CoreBetterAuthModule` now always injects the hook, delegating to
|
|
46
|
+
`CoreBetterAuthEmailVerificationService.sendPasswordResetEmail()`.
|
|
47
|
+
|
|
48
|
+
**What changes for you on `pnpm update` alone:**
|
|
49
|
+
|
|
50
|
+
- The route becomes a live, **unauthenticated**, token-minting, mail-sending endpoint.
|
|
51
|
+
- Real users receive real reset mail — including anyone an attacker names.
|
|
52
|
+
- If your `email.smtp` uses `jsonTransport` outside production, that mail is silently discarded on a
|
|
53
|
+
flow that previously failed loudly.
|
|
54
|
+
|
|
55
|
+
**If you do not want the flow**, turn it off explicitly — there is no other off switch:
|
|
56
|
+
|
|
57
|
+
```typescript
|
|
58
|
+
// config.env.ts
|
|
59
|
+
betterAuth: {
|
|
60
|
+
emailAndPassword: { passwordReset: false },
|
|
61
|
+
},
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
**If you do want it**, verify before shipping:
|
|
65
|
+
|
|
66
|
+
1. Your frontend reset page submits `POST /iam/reset-password` with `{ token, newPassword }`.
|
|
67
|
+
2. `betterAuth.rateLimit` is genuinely on — see §2.
|
|
68
|
+
3. `betterAuth.trustedOrigins` contains no wildcard. `redirectTo` is validated against it and the
|
|
69
|
+
reset redirect carries the token, so a wildcard hands a live token to any origin it admits. The
|
|
70
|
+
framework now warns at boot if it finds one.
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## 2. `betterAuth.rateLimit` presence now enables the limiter (bugfix — check your config)
|
|
75
|
+
|
|
76
|
+
`.claude/rules/configurable-features.md` has always documented this key as *"Presence Implies
|
|
77
|
+
Enabled"*, and `LegacyAuthRateLimiter` has always implemented it that way. `CoreBetterAuthRateLimiter`
|
|
78
|
+
did not: it spread your config over an `enabled: false` default without recomputing, and the
|
|
79
|
+
middleware mount checked `.enabled` separately. So this silently limited **nothing**:
|
|
80
|
+
|
|
81
|
+
```typescript
|
|
82
|
+
betterAuth: {
|
|
83
|
+
rateLimit: { max: 20, windowSeconds: 60 }, // before 11.36.1: NO middleware, NO limiter
|
|
84
|
+
},
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Only an explicit `enabled: true` worked. Both layers now follow the documented contract.
|
|
88
|
+
|
|
89
|
+
**What this means for you:** if you wrote a `rateLimit` block without `enabled: true` believing your
|
|
90
|
+
auth endpoints were throttled, **they were not, and now they are**. Requests that previously always
|
|
91
|
+
passed can now receive 429. Check your `max` / `windowSeconds` are values you actually want before
|
|
92
|
+
upgrading — particularly on machines that share an egress IP, and particularly if `trustProxy` is
|
|
93
|
+
unset (all clients then collapse onto one bucket).
|
|
94
|
+
|
|
95
|
+
To keep the previous no-op behaviour, be explicit:
|
|
96
|
+
|
|
97
|
+
```typescript
|
|
98
|
+
betterAuth: {
|
|
99
|
+
rateLimit: { enabled: false, max: 20 },
|
|
100
|
+
},
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`strictEndpoints` also gained the routes Better-Auth actually serves. `/request-password-reset` and
|
|
104
|
+
`/forget-password` now receive the halved limit; `/forgot-password` (a spelling Better-Auth uses
|
|
105
|
+
nowhere) is kept only for projects that route it themselves.
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## 3. Brevo template for the reset mail (new, optional)
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
betterAuth: {
|
|
113
|
+
emailVerification: {
|
|
114
|
+
passwordResetBrevoTemplateId: 42,
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Deliberately separate from `brevoTemplateId` and with **no fallback to it** — that one is the
|
|
120
|
+
verification mail, and reusing it would tell somebody who asked to reset their password to confirm
|
|
121
|
+
their address instead. A Brevo send that reports failure by resolving to `null` falls through to
|
|
122
|
+
SMTP rather than returning, so an outage does not leave a locked-out user with no mail at all.
|
|
123
|
+
|
|
124
|
+
Template variables: `name`, `link`, `appName`.
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## 4. Reset mail templates — check yours renders (action required if you ship one)
|
|
129
|
+
|
|
130
|
+
nest-server now ships `password-reset-de.ejs` and `password-reset-en.ejs` as branded HTML mails, and
|
|
131
|
+
keeps `password-reset.ejs` as the plain fallback.
|
|
132
|
+
|
|
133
|
+
**Project templates still win** via the usual project-first, locale-aware lookup, so if you ship your
|
|
134
|
+
own `password-reset.ejs` you keep it and will not see the new design. Add
|
|
135
|
+
`password-reset-<locale>.ejs` if you want per-locale variants.
|
|
136
|
+
|
|
137
|
+
**The rule that matters:** the un-suffixed `password-reset.ejs` must render from `{ link, name }`
|
|
138
|
+
alone. The legacy `POST /users/password/reset-request` flow
|
|
139
|
+
(`UserService.sendPasswordResetMail()`) resolves that exact name and passes nothing else, so a
|
|
140
|
+
template that references `appName` unguarded turns legacy password recovery into an HTTP 500. Locale
|
|
141
|
+
variants are only reached by the IAM flow and may use `appName` freely.
|
|
142
|
+
|
|
143
|
+
`logoSrc` is supported by the shipped templates but is **not** passed by the framework — supply it
|
|
144
|
+
from a subclass that overrides `sendPasswordResetEmail()` if you want a logo.
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## 5. Reset URLs no longer leak into logs (security fix, automatic)
|
|
149
|
+
|
|
150
|
+
Two problems, both fixed:
|
|
151
|
+
|
|
152
|
+
- The development log line was gated on `process.env.NODE_ENV !== 'production'`. A staging
|
|
153
|
+
deployment sets `NODE_ENV=staging` so `getEnvironmentConfig()` loads its block — which passes that
|
|
154
|
+
test while holding real users. The gate is now `isProductionLikeEnv()`, the same two-layer check
|
|
155
|
+
(`config.env === 'production' || 'staging'`, or `NODE_ENV === 'production'`) that `EmailService`
|
|
156
|
+
and the cookie helpers already use. The address is masked, and `LT_LOG_AUTH_URLS=0` suppresses the
|
|
157
|
+
line entirely.
|
|
158
|
+
- `redactSensitiveText()` masked `/reset/<token>` but not `/reset-password/<token>`, which is what
|
|
159
|
+
Better-Auth actually emits — so a reset token reached the ADMIN-readable Hub log panel verbatim.
|
|
160
|
+
The path rule now covers `request-password-reset`, `reset-password` and `forget-password`.
|
|
161
|
+
|
|
162
|
+
The same treatment was applied to the verification-mail log line and to the module's fallback
|
|
163
|
+
branches, which previously printed the URL through the NestJS logger with **no** environment gate at
|
|
164
|
+
all.
|
|
165
|
+
|
|
166
|
+
Nothing to configure. If you parse `[PASSWORD RESET]` / `[EMAIL VERIFICATION]` lines out of your own
|
|
167
|
+
logs, note the address is now masked (`re***@test.com`).
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
## 6. `options.emailAndPassword` no longer drops the password hashing (security fix, automatic)
|
|
172
|
+
|
|
173
|
+
`config.options` was merged with a shallow spread (only `advanced` was deep-merged). An
|
|
174
|
+
`options.emailAndPassword` therefore did not add a key — it **replaced the whole block**, including
|
|
175
|
+
the `password: { hash, verify }` scrypt pair nest-server installs. Better-Auth fell back to its own
|
|
176
|
+
hasher, every stored credential stopped verifying, and every user of that deployment was locked out
|
|
177
|
+
at once, at runtime and with nothing in the logs. The module README recommended exactly that shape.
|
|
178
|
+
|
|
179
|
+
`emailAndPassword` is now deep-merged, with `password` merged key-by-key on top of the framework's
|
|
180
|
+
pair. An explicit override still wins; an explicitly-`undefined` half is ignored (Better-Auth
|
|
181
|
+
resolves `password?.hash || hashPassword`, so honouring `undefined` would switch the hasher for
|
|
182
|
+
writes while scrypt still handled reads — an asymmetric pair, and anyone who reset their password
|
|
183
|
+
could never sign in again).
|
|
184
|
+
|
|
185
|
+
**If you worked around the old behaviour** by re-specifying `password.hash` / `password.verify`
|
|
186
|
+
defensively in `options.emailAndPassword`, you can remove that now. **If you set `sendResetPassword`
|
|
187
|
+
there**, note it still wins over the framework hook — your callback replaces
|
|
188
|
+
`sendPasswordResetEmail()` entirely and loses the fire-and-forget wrapper that keeps response time
|
|
189
|
+
from revealing whether an address exists. Prefer the service override.
|
|
190
|
+
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
## 7. `verification` documents now expire (automatic)
|
|
194
|
+
|
|
195
|
+
Better-Auth writes a `verification` document per email-verification and per password-reset request,
|
|
196
|
+
and removes it only when the token is **consumed**. With an unauthenticated reset route, every
|
|
197
|
+
un-clicked request left a row that nothing would ever delete.
|
|
198
|
+
|
|
199
|
+
`CoreBetterAuthService.onModuleInit()` now creates a TTL index on `verification.expiresAt`
|
|
200
|
+
(`expireAfterSeconds: 0`) alongside an `identifier` lookup index. Both are idempotent.
|
|
201
|
+
|
|
202
|
+
**Existing deployments:** MongoDB removes already-expired documents within ~60 s of the index being
|
|
203
|
+
built. If your `verification` collection has grown large, expect a one-off background deletion pass
|
|
204
|
+
after the first boot on 11.36.1.
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## 8. Per-address cooldown on reset mail (automatic)
|
|
209
|
+
|
|
210
|
+
`sendPasswordResetEmail()` reuses the existing `emailVerification.resendCooldownSeconds` window
|
|
211
|
+
(default 60 s) under its own namespace, so a pending verification mail cannot block a reset and vice
|
|
212
|
+
versa. A send that fails releases the slot, so a transient SMTP error does not lock the user out of
|
|
213
|
+
retrying.
|
|
214
|
+
|
|
215
|
+
This is the axis that matters: an attacker rotating IPs to mail-bomb one victim is bounded by the
|
|
216
|
+
recipient-address cooldown, not by the IP-keyed limiter.
|
|
217
|
+
|
|
218
|
+
---
|
|
219
|
+
|
|
220
|
+
## 9. Tenant membership: use `getActiveMembership()` for authorization (audit required)
|
|
221
|
+
|
|
222
|
+
`CoreTenantService.getMembership()` answers **regardless of status**. A removal is a status change
|
|
223
|
+
to `SUSPENDED`, not a delete — so it still returns a row for somebody who was thrown out. That is
|
|
224
|
+
deliberate and `addMember()` depends on it (it reactivates the existing row instead of creating a
|
|
225
|
+
duplicate).
|
|
226
|
+
|
|
227
|
+
It is therefore the **wrong method for an authorization decision**. Asking "is this user a member
|
|
228
|
+
with role X?" through it answers yes for a removed member, so a route that guards itself this way
|
|
229
|
+
keeps granting a suspended administrator the right to invite, remove and re-role — the very rights
|
|
230
|
+
that removing them was supposed to take away.
|
|
231
|
+
|
|
232
|
+
**A new method makes the distinction explicit:**
|
|
233
|
+
|
|
234
|
+
```typescript
|
|
235
|
+
// Deciding what somebody MAY DO → active only
|
|
236
|
+
const membership = await this.tenantService.getActiveMembership(tenantId, userId);
|
|
237
|
+
if (!membership) throw accessDeniedException(currentUser);
|
|
238
|
+
|
|
239
|
+
// Looking up a row REGARDLESS of status (reactivation, audit views, admin UIs)
|
|
240
|
+
const anyMembership = await this.tenantService.getMembership(tenantId, userId);
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
`getActiveMembership()` returns `null` for a suspended or invited membership, and `null` for an
|
|
244
|
+
empty/blank id rather than matching an arbitrary row. Its query is identical to the one
|
|
245
|
+
`CoreTenantGuard` already uses internally, so the two now agree by construction.
|
|
246
|
+
|
|
247
|
+
**Who needs to act.** `CoreTenantGuard` always filtered on `ACTIVE`, so anything guarded by it was
|
|
248
|
+
never affected. The exposure is in routes that carry `@SkipTenantCheck()` and decide for themselves —
|
|
249
|
+
the guard never sees those. **Audit your project for `getMembership(` used in a permission decision**
|
|
250
|
+
and switch those call sites:
|
|
251
|
+
|
|
252
|
+
```bash
|
|
253
|
+
grep -rn "getMembership(" src/ | grep -v "getActiveMembership"
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
**Behaviour change inside the framework.** `updateMemberRole()` and `assertNotLastOwner()` now resolve
|
|
257
|
+
the membership with `getActiveMembership()`. Previously `assertNotLastOwner()` was internally
|
|
258
|
+
inconsistent: it counted owners with `status: ACTIVE` but resolved the membership without a status
|
|
259
|
+
filter, so a **suspended** owner could trigger `"Cannot remove or demote the last owner of a tenant"`
|
|
260
|
+
while not being counted as one. That error no longer fires for a non-active membership.
|
|
261
|
+
|
|
262
|
+
---
|
|
263
|
+
|
|
264
|
+
## Troubleshooting
|
|
265
|
+
|
|
266
|
+
| Symptom | Cause | Fix |
|
|
267
|
+
|---------|-------|-----|
|
|
268
|
+
| Users suddenly receive password-reset mail | §1 — the flow is now on | Intended. Set `emailAndPassword.passwordReset: false` to opt out |
|
|
269
|
+
| `POST /iam/request-password-reset` returns 429 | §2 — your `rateLimit` block is now effective | Raise `max`, or set `enabled: false` to restore the previous no-op |
|
|
270
|
+
| Legacy `POST /users/password/reset-request` returns 500 | Your own `password-reset.ejs` references a variable the legacy caller does not pass | §4 — keep the un-suffixed template renderable from `{ link, name }` alone |
|
|
271
|
+
| Reset mail no longer shows the app name | Your template uses `appName` and is reached by the legacy flow | §4 — guard with `typeof appName !== 'undefined'` |
|
|
272
|
+
| `[PASSWORD RESET]` log line is gone | §5 — you are on a production-like env, or `LT_LOG_AUTH_URLS=0` | Intended. Use the Hub mailbox in development |
|
|
273
|
+
| Boot warning about `trustedOrigins` wildcard | §1 — the reset redirect carries the token | List exact origins |
|
|
274
|
+
| `"Cannot remove or demote the last owner"` stopped firing | §9 — it no longer counts a suspended membership | Intended; the membership was not an active owner |
|
|
275
|
+
|
|
276
|
+
---
|
|
277
|
+
|
|
278
|
+
## Module Documentation
|
|
279
|
+
|
|
280
|
+
- [BetterAuth README](../src/core/modules/better-auth/README.md) — password-reset configuration, rate limiting, template branding
|
|
281
|
+
- [BetterAuth INTEGRATION-CHECKLIST](../src/core/modules/better-auth/INTEGRATION-CHECKLIST.md) — the four-point password-reset checklist
|
|
282
|
+
- [BetterAuth CUSTOMIZATION](../src/core/modules/better-auth/CUSTOMIZATION.md) — `sendPasswordResetEmail()` override point, template variables
|
|
283
|
+
- [Configurable Features](../.claude/rules/configurable-features.md) — `passwordReset`, `passwordResetBrevoTemplateId`
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Migration Guide: 11.36.1 → 11.36.2
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
| Category | Details |
|
|
6
|
+
|----------|---------|
|
|
7
|
+
| **Breaking Changes** | None |
|
|
8
|
+
| **New Features** | `check:mutations --jobs=N` — the regression-evidence gate now runs several mutations at once (§1) |
|
|
9
|
+
| **Bugfixes** | None affecting runtime |
|
|
10
|
+
| **Migration Effort** | **None.** Tooling-only release; nothing in `src/` changed |
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Quick Migration
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pnpm update @lenne.tech/nest-server@11.36.2
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
That is the whole migration. This release changes no framework code, no configuration and no public
|
|
21
|
+
API — `src/` is untouched. It is published so consuming projects stay on a current version and pick
|
|
22
|
+
up the maintenance below.
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## 1. Faster regression-evidence gate (framework repo only)
|
|
27
|
+
|
|
28
|
+
Relevant if you run `pnpm run check:mutations`, or maintain a fork of this repo's tooling.
|
|
29
|
+
|
|
30
|
+
`scripts/check-mutations.mjs` can now run several mutations in parallel:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pnpm run check:mutations # auto: 2 workers, or 4 on a machine with >= 12 cores
|
|
34
|
+
pnpm run check:mutations -- --jobs=4 # explicit
|
|
35
|
+
pnpm run check:mutations -- --jobs=1 # force the previous sequential behaviour
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
**Measured: 744s → 399s (1.87×) at 4 jobs.** All 49 verdicts were diffed against a sequential run
|
|
39
|
+
and are identical — a parallel mode that changes a verdict is a broken safety net, not a speedup.
|
|
40
|
+
|
|
41
|
+
**Why it was slow in the first place, since it is easy to optimise the wrong thing:** the specs
|
|
42
|
+
behind all 29 e2e mutations add up to about 40 seconds. The rest is paying vitest's cold start once
|
|
43
|
+
per mutation, 49 times. That work is largely single-threaded I/O and barely scales with cores — the
|
|
44
|
+
registry measures 744s on a 12-core laptop and 777s on a 4-vCPU CI runner.
|
|
45
|
+
|
|
46
|
+
**How the isolation works.** A mutation writes into the source tree, so N cannot simply run at once:
|
|
47
|
+
two mutations in one tree would see each other's edits. Each worker gets its own `git worktree`, with
|
|
48
|
+
`node_modules` symlinked from the main checkout. A worktree is checked out at **HEAD**, so parallel
|
|
49
|
+
mode tests committed code — when `src/` or `tests/` is dirty, or `--allow-dirty` is passed, the run
|
|
50
|
+
prints why and falls back to sequential.
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## 2. Dependency maintenance
|
|
55
|
+
|
|
56
|
+
`@vitest/ui` was removed: no script passes `--ui` and no config imports it. Development-only, no
|
|
57
|
+
runtime effect.
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## Troubleshooting
|
|
62
|
+
|
|
63
|
+
| Symptom | Cause | Fix |
|
|
64
|
+
|---------|-------|-----|
|
|
65
|
+
| `check:mutations` prints "(sequential: …)" | `src/` or `tests/` is dirty, or `--allow-dirty` was passed | Intended — a worktree at HEAD would test different code. Commit or stash to get the fast path |
|
|
66
|
+
| `check:mutations` seems slower than before | More workers than cores helps nothing; each worker starts a vitest that forks again | Lower `--jobs`, or leave it unset and let it size itself |
|
|
67
|
+
| A stale worktree is left behind | The run was killed with SIGKILL | `git worktree prune` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lenne.tech/nest-server",
|
|
3
|
-
"version": "11.36.
|
|
3
|
+
"version": "11.36.2",
|
|
4
4
|
"description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"node",
|
|
@@ -166,14 +166,14 @@
|
|
|
166
166
|
}
|
|
167
167
|
},
|
|
168
168
|
"devDependencies": {
|
|
169
|
-
"@aws-sdk/client-s3": "3.
|
|
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
174
|
"@nestjs/testing": "11.2.1",
|
|
175
175
|
"@swc/cli": "0.8.1",
|
|
176
|
-
"@swc/core": "1.16.
|
|
176
|
+
"@swc/core": "1.16.1",
|
|
177
177
|
"@tus/s3-store": "2.0.6",
|
|
178
178
|
"@types/compression": "1.8.1",
|
|
179
179
|
"@types/cookie-parser": "1.4.10",
|
|
@@ -184,18 +184,17 @@
|
|
|
184
184
|
"@types/node": "26.2.0",
|
|
185
185
|
"@types/nodemailer": "8.0.1",
|
|
186
186
|
"@types/passport": "1.0.17",
|
|
187
|
-
"@vitest/coverage-v8": "4.1.
|
|
188
|
-
"@vitest/ui": "4.1.10",
|
|
187
|
+
"@vitest/coverage-v8": "4.1.11",
|
|
189
188
|
"ansi-colors": "4.1.3",
|
|
190
|
-
"bullmq": "6.
|
|
189
|
+
"bullmq": "6.2.0",
|
|
191
190
|
"find-file-up": "2.0.1",
|
|
192
191
|
"husky": "9.1.7",
|
|
193
192
|
"ioredis": "6.0.0",
|
|
194
193
|
"nodemon": "3.1.14",
|
|
195
194
|
"npm-watch": "0.13.0",
|
|
196
195
|
"otpauth": "9.5.1",
|
|
197
|
-
"oxfmt": "0.
|
|
198
|
-
"oxlint": "1.
|
|
196
|
+
"oxfmt": "0.64.0",
|
|
197
|
+
"oxlint": "1.79.0",
|
|
199
198
|
"rimraf": "6.1.3",
|
|
200
199
|
"ts-node": "10.9.2",
|
|
201
200
|
"tsconfig-paths": "4.2.0",
|
|
@@ -203,9 +202,9 @@
|
|
|
203
202
|
"tus-js-client": "4.3.1",
|
|
204
203
|
"typescript": "5.9.3",
|
|
205
204
|
"unplugin-swc": "1.5.11",
|
|
206
|
-
"vite": "8.2.
|
|
205
|
+
"vite": "8.2.2",
|
|
207
206
|
"vite-plugin-node": "8.0.0",
|
|
208
|
-
"vitest": "4.1.
|
|
207
|
+
"vitest": "4.1.11"
|
|
209
208
|
},
|
|
210
209
|
"main": "dist/index.js",
|
|
211
210
|
"types": "dist/index.d.ts",
|
|
@@ -159,8 +159,16 @@ export function redactSensitiveText(text: string): string {
|
|
|
159
159
|
.replace(/eyJ[\w-]{6,}\.[\w-]{6,}\.[\w-]{4,}/g, (match) => maskToken(match))
|
|
160
160
|
// reset/verification/invite tokens carried as a URL PATH segment (e.g. /verify/<token>,
|
|
161
161
|
// /reset/<token>) — these have no `key=value`, so the query-style rule below misses them.
|
|
162
|
+
//
|
|
163
|
+
// The COMPOUND spellings must come first and must be listed at all: Better-Auth emits
|
|
164
|
+
// `/reset-password/<token>`, and a bare `reset` alternative does not match it (`reset` is
|
|
165
|
+
// followed by `-password`, not `/`). That gap let a full account-takeover URL through
|
|
166
|
+
// `HubLogBufferService` into the ADMIN-readable Hub log panel verbatim, while the
|
|
167
|
+
// verification token — which travels as `?token=` and is caught by the rule below — was
|
|
168
|
+
// correctly masked. Order matters: alternation is first-match, so `reset-password` has to
|
|
169
|
+
// precede `reset`.
|
|
162
170
|
.replace(
|
|
163
|
-
/(\/(?:
|
|
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
|
-
*
|
|
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`.
|
|
3431
|
-
* `options
|
|
3432
|
-
*
|
|
3433
|
-
*
|
|
3434
|
-
*
|
|
3435
|
-
*
|
|
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
|
|