@lenne.tech/nest-server 11.35.1 → 11.36.0

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.
@@ -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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "11.35.1",
3
+ "version": "11.36.0",
4
4
  "description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).",
5
5
  "keywords": [
6
6
  "node",
@@ -91,21 +91,21 @@
91
91
  "@apollo/server": "5.5.1",
92
92
  "@as-integrations/express5": "1.1.2",
93
93
  "@better-auth/passkey": "1.6.26",
94
- "@getbrevo/brevo": "6.0.2",
94
+ "@getbrevo/brevo": "6.0.3",
95
95
  "@modelcontextprotocol/sdk": "1.30.0",
96
- "@nestjs/apollo": "13.4.2",
97
- "@nestjs/common": "11.1.29",
98
- "@nestjs/core": "11.1.29",
99
- "@nestjs/graphql": "13.4.2",
96
+ "@nestjs/apollo": "13.4.5",
97
+ "@nestjs/common": "11.2.1",
98
+ "@nestjs/core": "11.2.1",
99
+ "@nestjs/graphql": "13.4.5",
100
100
  "@nestjs/jwt": "11.0.2",
101
101
  "@nestjs/mongoose": "11.0.4",
102
102
  "@nestjs/passport": "11.0.5",
103
- "@nestjs/platform-express": "11.1.29",
103
+ "@nestjs/platform-express": "11.2.1",
104
104
  "@nestjs/schedule": "6.1.3",
105
- "@nestjs/swagger": "11.4.6",
105
+ "@nestjs/swagger": "11.4.7",
106
106
  "@nestjs/terminus": "11.1.1",
107
107
  "@tus/file-store": "2.1.1",
108
- "@tus/server": "2.4.3",
108
+ "@tus/server": "2.4.4",
109
109
  "@types/supertest": "7.2.1",
110
110
  "bcrypt": "6.0.0",
111
111
  "better-auth": "1.6.26",
@@ -122,12 +122,12 @@
122
122
  "graphql-subscriptions": "3.0.0",
123
123
  "graphql-upload": "15.0.2",
124
124
  "graphql-ws": "6.2.1",
125
- "jose": "6.2.8",
125
+ "jose": "6.2.9",
126
126
  "js-sha256": "1.0.0",
127
127
  "json-to-graphql-query": "2.3.0",
128
128
  "lodash": "4.18.1",
129
129
  "mongodb": "7.5.0",
130
- "mongoose": "9.9.1",
130
+ "mongoose": "9.9.3",
131
131
  "multer": "2.2.0",
132
132
  "node-mailjet": "6.0.11",
133
133
  "nodemailer": "9.0.5",
@@ -166,15 +166,15 @@
166
166
  }
167
167
  },
168
168
  "devDependencies": {
169
- "@aws-sdk/client-s3": "3.1106.0",
170
- "@aws-sdk/s3-request-presigner": "3.1106.0",
169
+ "@aws-sdk/client-s3": "3.1112.0",
170
+ "@aws-sdk/s3-request-presigner": "3.1112.0",
171
171
  "@compodoc/compodoc": "2.0.0",
172
172
  "@nestjs/cli": "11.0.24",
173
173
  "@nestjs/schematics": "11.1.0",
174
- "@nestjs/testing": "11.1.28",
174
+ "@nestjs/testing": "11.2.1",
175
175
  "@swc/cli": "0.8.1",
176
- "@swc/core": "1.15.47",
177
- "@tus/s3-store": "2.0.5",
176
+ "@swc/core": "1.16.0",
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",
@@ -187,22 +187,22 @@
187
187
  "@vitest/coverage-v8": "4.1.10",
188
188
  "@vitest/ui": "4.1.10",
189
189
  "ansi-colors": "4.1.3",
190
- "bullmq": "6.0.9",
190
+ "bullmq": "6.1.2",
191
191
  "find-file-up": "2.0.1",
192
192
  "husky": "9.1.7",
193
193
  "ioredis": "6.0.0",
194
194
  "nodemon": "3.1.14",
195
195
  "npm-watch": "0.13.0",
196
196
  "otpauth": "9.5.1",
197
- "oxfmt": "0.62.0",
198
- "oxlint": "1.77.0",
197
+ "oxfmt": "0.63.0",
198
+ "oxlint": "1.78.0",
199
199
  "rimraf": "6.1.3",
200
200
  "ts-node": "10.9.2",
201
201
  "tsconfig-paths": "4.2.0",
202
- "tsx": "4.23.11",
202
+ "tsx": "4.23.12",
203
203
  "tus-js-client": "4.3.1",
204
204
  "typescript": "5.9.3",
205
- "unplugin-swc": "1.5.10",
205
+ "unplugin-swc": "1.5.11",
206
206
  "vite": "8.2.1",
207
207
  "vite-plugin-node": "8.0.0",
208
208
  "vitest": "4.1.10"
@@ -3414,6 +3414,30 @@ interface IBetterAuthBase {
3414
3414
  * @default true
3415
3415
  */
3416
3416
  enabled?: boolean;
3417
+
3418
+ /**
3419
+ * End every existing session when the user completes a password reset.
3420
+ *
3421
+ * A reset is what somebody reaches for when they suspect their account was
3422
+ * taken over, so leaving the older sessions alive defeats the point: the
3423
+ * attacker keeps theirs and the new password changes nothing for them.
3424
+ *
3425
+ * Left off by default because it is a behaviour change for existing
3426
+ * deployments — a reset then signs the user out everywhere, including on
3427
+ * the devices they still hold.
3428
+ *
3429
+ * 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.
3436
+ *
3437
+ * @default false
3438
+ * @since 11.36.0
3439
+ */
3440
+ revokeSessionsOnPasswordReset?: boolean;
3417
3441
  };
3418
3442
 
3419
3443
  /**
@@ -53,9 +53,8 @@ export class BrevoService {
53
53
  return false;
54
54
  }
55
55
 
56
- // Exclude (test) users, must be done via config and not via configFastButReadOnly,
57
- // otherwise the error TypeError: Cannot assign to read only property 'lastIndex' of object '[object RegExp]' occurs
58
- if (this.configService.config?.brevo?.exclude?.test?.(to)) {
56
+ // Exclude (test) users
57
+ if (this.isExcluded(to)) {
59
58
  return 'TEST_USER!';
60
59
  }
61
60
 
@@ -98,9 +97,8 @@ export class BrevoService {
98
97
  return false;
99
98
  }
100
99
 
101
- // Exclude (test) users, must be done via config and not via configFastButReadOnly,
102
- // otherwise the error TypeError: Cannot assign to read only property 'lastIndex' of object '[object RegExp]' occurs
103
- if (this.configService.config?.brevo?.exclude?.test?.(to)) {
100
+ // Exclude (test) users
101
+ if (this.isExcluded(to)) {
104
102
  return 'TEST_USER!';
105
103
  }
106
104
 
@@ -135,6 +133,36 @@ export class BrevoService {
135
133
  return { 'Idempotency-Key': randomUUID() };
136
134
  }
137
135
 
136
+ /**
137
+ * Checks a recipient against `brevo.exclude` without inheriting the pattern's match state.
138
+ *
139
+ * Two traps live in this one line, and both have bitten:
140
+ *
141
+ * 1. `RegExp.prototype.test` ADVANCES `lastIndex` on a pattern carrying `g` or `y`. The config
142
+ * holds a single shared instance and projects declare it as `/…/gi`, so calling `.test()` on
143
+ * it directly answers true, false, true, … for the very same address — every second excluded
144
+ * recipient receives a real mail. Matching against a flagless copy keeps each call
145
+ * independent and leaves the configured pattern untouched.
146
+ * 2. It must be read from `config`, never from `configFastButReadOnly`: assigning `lastIndex` on
147
+ * the frozen copy throws `TypeError: Cannot assign to read only property 'lastIndex'`.
148
+ * Point 1 removes the assignment, but the frozen object may still be a `deepFreeze`d clone
149
+ * whose flags differ, so the mutable side stays the source of truth.
150
+ *
151
+ * @param to - Recipient email address
152
+ * @returns `true` when the recipient matches the configured exclude pattern
153
+ */
154
+ protected isExcluded(to: string): boolean {
155
+ const exclude = this.configService.config?.brevo?.exclude;
156
+ if (typeof exclude?.test !== 'function') {
157
+ return false;
158
+ }
159
+
160
+ const stateless =
161
+ exclude.global || exclude.sticky ? new RegExp(exclude.source, exclude.flags.replace(/[gy]/g, '')) : exclude;
162
+
163
+ return stateless.test(to);
164
+ }
165
+
138
166
  /**
139
167
  * Lazily constructs (and memoises) the Brevo SDK client.
140
168
  *
@@ -444,6 +444,10 @@ export function createBetterAuthInstance(options: CreateBetterAuthOptions): Crea
444
444
  hash: nativeScryptHash,
445
445
  verify: nativeScryptVerify,
446
446
  },
447
+ // Opt-in: a reset then also ends the sessions that already existed, which
448
+ // is the point of resetting after a suspected takeover. Off by default so
449
+ // the behaviour of existing deployments does not change under them.
450
+ revokeSessionsOnPasswordReset: config.emailAndPassword?.revokeSessionsOnPasswordReset === true,
447
451
  },
448
452
  plugins,
449
453
  secret: validation.resolvedSecret || config.secret,
@@ -24,6 +24,7 @@ import {
24
24
  ApiProperty,
25
25
  ApiTags,
26
26
  } from '@nestjs/swagger';
27
+ import { IsBoolean, IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator';
27
28
  import { Request, Response } from 'express';
28
29
 
29
30
  import { Roles } from '../../common/decorators/roles.decorator';
@@ -122,10 +123,18 @@ export class CoreBetterAuthResponse {
122
123
  * Sign-in input DTO
123
124
  */
124
125
  export class CoreBetterAuthSignInInput {
126
+ // Validated, not merely documented. Without these the sign-in endpoint — the
127
+ // most-probed surface of any deployment — answered malformed input with a
128
+ // 500 from the first property read, which tells a client to retry something
129
+ // that can never work and buries real faults in the same bucket.
125
130
  @ApiProperty({ description: 'User email address', example: 'user@example.com' })
131
+ @IsNotEmpty()
132
+ @IsEmail()
126
133
  email: string;
127
134
 
128
135
  @ApiProperty({ description: 'User password' })
136
+ @IsNotEmpty()
137
+ @IsString()
129
138
  password: string;
130
139
  }
131
140
 
@@ -134,15 +143,30 @@ export class CoreBetterAuthSignInInput {
134
143
  */
135
144
  export class CoreBetterAuthSignUpInput {
136
145
  @ApiProperty({ description: 'User email address', example: 'user@example.com' })
146
+ @IsNotEmpty()
147
+ @IsEmail()
137
148
  email: string;
138
149
 
139
150
  @ApiProperty({ description: 'Display name', example: 'John Doe', required: false })
151
+ @IsOptional()
152
+ @IsString()
140
153
  name?: string;
141
154
 
142
155
  @ApiProperty({ description: 'User password (min 8 characters)' })
156
+ @IsNotEmpty()
157
+ @IsString()
143
158
  password: string;
144
159
 
160
+ // Deliberately left without a validator, unlike its three neighbours. Whether this field is
161
+ // REQUIRED is a policy question the framework cannot answer here — it depends on
162
+ // `betterAuth.signUpValidation`, which the handler consults through
163
+ // CoreBetterAuthSignUpValidatorService before anything else happens. A `@IsNotEmpty()` here would
164
+ // hard-code "consent is mandatory" into the DTO and reject a sign-up for a deployment that never
165
+ // asked for consent, with a message that names the wrong cause. The others carry validators
166
+ // because "an email must look like an email" needs no policy.
145
167
  @ApiProperty({ description: 'Whether user accepted terms and privacy policy', required: false })
168
+ @IsBoolean()
169
+ @IsOptional()
146
170
  termsAndPrivacyAccepted?: boolean;
147
171
  }
148
172
 
@@ -329,6 +353,15 @@ export class CoreBetterAuthController {
329
353
  ): Promise<CoreBetterAuthResponse> {
330
354
  this.ensureEnabled();
331
355
 
356
+ // A request with no body at all reaches here as `undefined`, and every line
357
+ // below reads `input.email`. Without this the caller gets a 500 for what is
358
+ // plainly their own malformed request — and a 500 tells a client to retry
359
+ // later, which will never help. The legacy sign-in answered this case with
360
+ // a 400 "Missing input"; keep that contract.
361
+ if (!input) {
362
+ throw new BadRequestException('Missing input');
363
+ }
364
+
332
365
  const api = this.betterAuthService.getApi();
333
366
  if (!api) {
334
367
  throw new BadRequestException(ErrorCode.BETTERAUTH_API_NOT_AVAILABLE);
@@ -494,6 +527,11 @@ export class CoreBetterAuthController {
494
527
  this.ensureEnabled();
495
528
  this.betterAuthService.ensureSignUpEnabled();
496
529
 
530
+ // Same reasoning as in signIn: a body-less request must not become a 500.
531
+ if (!input) {
532
+ throw new BadRequestException('Missing input');
533
+ }
534
+
497
535
  // Validate sign-up input (termsAndPrivacyAccepted is required by default)
498
536
  if (this.signUpValidator) {
499
537
  this.signUpValidator.validateSignUpInput({ termsAndPrivacyAccepted: input.termsAndPrivacyAccepted });