@lenne.tech/nest-server 11.35.0 → 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.
- package/.claude/rules/configurable-features.md +2 -1
- package/FRAMEWORK-API.md +1 -1
- package/dist/core/common/helpers/file.helper.js +3 -2
- package/dist/core/common/helpers/file.helper.js.map +1 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +1 -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.js +1 -0
- package/dist/core/modules/better-auth/better-auth.config.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/file/core-file-access-audit.initializer.d.ts +13 -0
- package/dist/core/modules/file/core-file-access-audit.initializer.js +109 -0
- package/dist/core/modules/file/core-file-access-audit.initializer.js.map +1 -0
- package/dist/core/modules/file/file-roles.config.d.ts +21 -0
- package/dist/core/modules/file/file-roles.config.js +51 -2
- package/dist/core/modules/file/file-roles.config.js.map +1 -1
- package/dist/core/modules/file/file-roles.helper.d.ts +2 -2
- package/dist/core/modules/file/file-roles.helper.js +10 -16
- package/dist/core/modules/file/file-roles.helper.js.map +1 -1
- package/dist/core.module.js +3 -1
- package/dist/core.module.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/migration-guides/11.34.x-to-11.35.x.md +17 -3
- package/migration-guides/11.35.0-to-11.35.1.md +152 -0
- package/migration-guides/11.35.1-to-11.36.0.md +168 -0
- package/package.json +21 -21
- package/src/core/common/helpers/file.helper.ts +11 -5
- package/src/core/common/interfaces/server-options.interface.ts +24 -0
- package/src/core/common/services/brevo.service.ts +34 -6
- package/src/core/modules/better-auth/better-auth.config.ts +4 -0
- package/src/core/modules/better-auth/core-better-auth.controller.ts +38 -0
- package/src/core/modules/file/README.md +10 -0
- package/src/core/modules/file/core-file-access-audit.initializer.ts +164 -0
- package/src/core/modules/file/file-roles.config.ts +156 -1
- package/src/core/modules/file/file-roles.helper.ts +15 -22
- package/src/core.module.ts +10 -2
- package/src/index.ts +4 -0
|
@@ -49,11 +49,12 @@ Then run the one-time audit in §3.
|
|
|
49
49
|
them is half-wired if you take one side and not the other, and a half-wired enforcement layer is worse
|
|
50
50
|
than none because it reads as protection.
|
|
51
51
|
|
|
52
|
-
**
|
|
52
|
+
**Nine new files** (eight rows — the two file-module files ship as one pair):
|
|
53
53
|
|
|
54
54
|
| File | Belongs with |
|
|
55
55
|
|------|--------------|
|
|
56
56
|
| `common/plugins/mongoose-system-role.plugin.ts` | `common/enums/role.enum.ts` — the plugin is the unconditional layer, the enum holds the predicates (§1) |
|
|
57
|
+
| `modules/tenant/core-role-scope.registry.ts` | `modules/tenant/core-tenant.helpers.ts` + `modules/tenant/core-tenant.module.ts` + `common/enums/role.enum.ts` — the registry holds the global-vs-tenant split the guards resolve against, `core-tenant.helpers.ts` feeds it from configuration, and `core-tenant.module.ts` is where `forRoot()` registers that source and runs `assertRoleVocabularyIsCoherent()`. Without the module wiring the registry stays empty, every role resolves to `RoleScope.UNKNOWN`, and the boot check that refuses an incoherent vocabulary never runs (§5) |
|
|
57
58
|
| `common/decorators/nested-type.registry.ts` | `decorators/restricted.decorator.ts` + `decorators/unified-field.decorator.ts` — the registry is written by one and read by the other (§8) |
|
|
58
59
|
| `common/decorators/restrictions-checked.marker.ts` | `decorators/restricted.decorator.ts` + both interceptors — all four read the marker; an old truthy check anywhere reopens the bypass (§13) |
|
|
59
60
|
| `common/helpers/graphql-ws-context.helper.ts` | `common/services/core-tenant-context.registry.ts` + `core.module.ts` + `modules/tenant/core-tenant.guard.ts` — the context is useless without the resolver, and unreached without the `core.module.ts` wiring (§7) |
|
|
@@ -66,8 +67,21 @@ than none because it reads as protection.
|
|
|
66
67
|
`common/plugins/mongoose-tenant.plugin.ts`, `common/services/core-s3.service.ts`,
|
|
67
68
|
`modules/file/{core-file.service,file-metadata.helper,file-roles.helper}.ts`,
|
|
68
69
|
`modules/tus/{core-tus.service,tus.constants,index}.ts`, `modules/user/inputs/core-user.input.ts`,
|
|
69
|
-
`modules/user/core-user.service.ts`,
|
|
70
|
-
|
|
70
|
+
`modules/user/core-user.service.ts`,
|
|
71
|
+
`modules/tenant/{core-tenant.helpers,core-tenant.module,core-tenant.service}.ts`, and the three
|
|
72
|
+
module READMEs (`file`, `tenant`, `tus`) plus `modules/file/INTEGRATION-CHECKLIST.md`.
|
|
73
|
+
|
|
74
|
+
`core-tenant.module.ts` and `core-tenant.service.ts` are easy to miss and both carry enforcement:
|
|
75
|
+
`forRoot()` registers the role-scope source and asserts the vocabulary is coherent, and
|
|
76
|
+
`core-tenant.service.ts` gains `assertAssignableMembershipRole()`, which refuses a membership role
|
|
77
|
+
named after a system (`s_*`) or global-only role at assignment time (§5).
|
|
78
|
+
|
|
79
|
+
The remaining changed files — `common/enums/role.enum.ts`,
|
|
80
|
+
`common/decorators/{restricted,unified-field}.decorator.ts`, both interceptors
|
|
81
|
+
(`check-security`, `response-model`), and all three guards
|
|
82
|
+
(`modules/auth/guards/roles.guard.ts`, `modules/better-auth/better-auth-roles.guard.ts`,
|
|
83
|
+
`modules/tenant/core-tenant.guard.ts`) — are the counterparts already named in the "Belongs with"
|
|
84
|
+
column above. Take them with the new file they pair with.
|
|
71
85
|
|
|
72
86
|
**Also worth taking:** `src/test/test.helper.ts` (subscription `connectionParams`, and a refused
|
|
73
87
|
subscription now rejects instead of delivering an indistinguishable `null` — without it you cannot test
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# Migration Guide: 11.35.0 → 11.35.1
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
| Category | Details |
|
|
6
|
+
|----------|---------|
|
|
7
|
+
| **Breaking Changes** | None in signatures. One **client-visible** change: a refused upload now answers **400** instead of **500**, with Nest's standard error envelope instead of an internal-error one (§1) |
|
|
8
|
+
| **Bugfixes** | `multerFileFilter` rejections finally map to a 4xx (§1). Seven documented helper names are reachable from the package root for the first time (§2). The file-access boot warning can no longer be fooled by a subclassed controller (§3) |
|
|
9
|
+
| **Migration Effort** | Nothing to configure. Read §1 if a frontend, a test, or an alerting rule branches on the status or body of a rejected upload. **Read §3 if you subclass `CoreFileController` / `CoreFileResolver` and re-declare a member** — you may see a new warning at startup, and it is probably telling you something true |
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Quick Migration
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pnpm update @lenne.tech/nest-server@11.35.1
|
|
17
|
+
pnpm run build
|
|
18
|
+
pnpm test
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
No configuration change is required, and no source change is required in a typical project.
|
|
22
|
+
|
|
23
|
+
**Vendor-mode projects:** §1 and §2 are single-file and independent —
|
|
24
|
+
`src/core/common/helpers/file.helper.ts` and `src/index.ts`. §3 is **not**: it adds
|
|
25
|
+
`src/core/modules/file/core-file-access-audit.initializer.ts` and changes
|
|
26
|
+
`file-roles.config.ts`, `file-roles.helper.ts` and `core.module.ts` together. Taking the initializer
|
|
27
|
+
without the `core.module.ts` provider registration gives you a file that is never constructed — a
|
|
28
|
+
security check that silently does not run, which is worse than not having it. Sync those four
|
|
29
|
+
together.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## 1. A refused upload answers 400, not 500
|
|
34
|
+
|
|
35
|
+
11.32.4 §8 changed `multerFileFilter`'s rejection from a bare string to `new Error(...)`, intending a
|
|
36
|
+
clean 400. It never produced one.
|
|
37
|
+
|
|
38
|
+
`transformException` in `@nestjs/platform-express` returns any **non-`HttpException`** unchanged. Its
|
|
39
|
+
switch matches only multer's own message constants (`LIMIT_FILE_SIZE`, `LIMIT_UNEXPECTED_FILE`, …),
|
|
40
|
+
and a message this helper writes matches none of them — so the rejection fell off the end of the
|
|
41
|
+
switch, reached Nest's exception layer as a plain error, and the client was told the **server** had
|
|
42
|
+
failed for a file the **client** sent wrong.
|
|
43
|
+
|
|
44
|
+
Both rejection paths are affected and both are fixed: the allow-list refusal, and the
|
|
45
|
+
scriptable-type refusal (`text/html`, `image/svg+xml`) added by 11.32.4 §6.
|
|
46
|
+
|
|
47
|
+
| | Before | After |
|
|
48
|
+
|---|---|---|
|
|
49
|
+
| Status | `500 Internal Server Error` | `400 Bad Request` |
|
|
50
|
+
| Body | internal-error envelope | Nest's standard 400 shape |
|
|
51
|
+
| Message text | unchanged | unchanged — same text, still built from the accept list |
|
|
52
|
+
|
|
53
|
+
**What to check:**
|
|
54
|
+
|
|
55
|
+
- **Frontends** that branch on the status of an upload response. A rejected file was previously
|
|
56
|
+
indistinguishable from a storage outage; it now identifies itself, and the message is worth showing
|
|
57
|
+
to the user.
|
|
58
|
+
- **Tests** pinned to the broken output. A test asserting `500` on a refused upload fails and should
|
|
59
|
+
be updated to `400`.
|
|
60
|
+
- **Alerting.** Refused uploads no longer raise 5xx. If a dashboard counted them, its numbers drop —
|
|
61
|
+
that is the fix, not a regression. Conversely, an alert that was tuned to tolerate that noise can
|
|
62
|
+
be tightened again.
|
|
63
|
+
|
|
64
|
+
A consumer catching the rejection in code is unaffected: `BadRequestException extends HttpException
|
|
65
|
+
extends Error`, so an `instanceof Error` check keeps working.
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## 2. Seven documented helpers are now exported from the package root
|
|
70
|
+
|
|
71
|
+
The 11.34.x → 11.35.x guide's *New Exports* block lists names that `src/index.ts` never exported, so
|
|
72
|
+
importing them from `@lenne.tech/nest-server` failed to compile:
|
|
73
|
+
|
|
74
|
+
| Name | Module |
|
|
75
|
+
|---|---|
|
|
76
|
+
| `resolveGuardRequest` | `common/helpers/execution-context-request.helper` |
|
|
77
|
+
| `buildRequestContextAwareExecute` | `common/helpers/graphql-ws-context.helper` |
|
|
78
|
+
| `buildRequestContextAwareSubscribe` | same |
|
|
79
|
+
| `createRequestContextAwareExecute` | same |
|
|
80
|
+
| `createRequestContextAwareSubscribe` | same |
|
|
81
|
+
| `getTenantContextResolver` | `common/services/core-tenant-context.registry` |
|
|
82
|
+
| `setTenantContextResolver` | same |
|
|
83
|
+
|
|
84
|
+
Purely additive — three `export * from` lines. Nothing was renamed or removed, and no existing import
|
|
85
|
+
changes meaning.
|
|
86
|
+
|
|
87
|
+
**Who this affected:** npm-mode consumers only, and only those who followed the guide. In-tree and
|
|
88
|
+
vendor-mode consumers import over relative paths and never touched the barrel, which is also why
|
|
89
|
+
nothing in this repo noticed: no framework module, test or e2e spec imports from `src/index.ts`, so a
|
|
90
|
+
helper could be written, wired, documented and shipped with the full suite green.
|
|
91
|
+
`tests/unit/public-api-barrel.spec.ts` now reads the expected names out of the migration guides
|
|
92
|
+
themselves and walks the re-export graph, so a documented-but-unexported name fails at test time
|
|
93
|
+
rather than at a consumer's `import`.
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## 3. The file-access warning now sees a subclassed controller
|
|
98
|
+
|
|
99
|
+
The boot warning that reports "the file gate is open and nothing declares a per-file policy" read
|
|
100
|
+
**configuration** — `file.downloadRoles` and friends. That is the correct source for a member you
|
|
101
|
+
inherit, and the wrong one for a member you re-declare.
|
|
102
|
+
|
|
103
|
+
```typescript
|
|
104
|
+
@Controller('files')
|
|
105
|
+
export class FileController extends CoreFileController {
|
|
106
|
+
@Get('id/:id')
|
|
107
|
+
@Roles(RoleEnum.S_EVERYONE) // anonymous downloads
|
|
108
|
+
override async getFileById(...) { return super.getFileById(...); }
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`applyFileRoles()` writes the configured roles onto `CoreFileController.prototype.getFileById`, but
|
|
113
|
+
decorator metadata lives on the **function object** — your override is a different function, it keeps
|
|
114
|
+
its own `@Roles()`, and that is the function Nest registers. The configuration never reaches the
|
|
115
|
+
route. The old warning asked `resolveRoles('downloadRoles', config)`, got `[ADMIN]`, and stayed quiet
|
|
116
|
+
while the route served everyone. Two consumer projects shipped exactly that.
|
|
117
|
+
|
|
118
|
+
`CoreFileAccessAuditInitializer` now reads the roles off the class you actually registered, at
|
|
119
|
+
`onApplicationBootstrap` (the earliest point at which the route table exists), and names the member:
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
A registered file endpoint is open beyond platform admins through roles declared in your own class,
|
|
123
|
+
not through configuration (FileController.getFileById → ["s_everyone"]), and no per-file policy is
|
|
124
|
+
declared: file.access is unset and CoreFileService.checkRights() is not overridden. …
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
**Nothing changes about who may reach what.** Your `@Roles()` still wins; the audit reports and does
|
|
128
|
+
not correct. That is deliberate: rewriting an override's metadata would silently relax a route a
|
|
129
|
+
project pinned on purpose — which is why the TUS module's approach, where configuration wins over the
|
|
130
|
+
override, was not adopted here.
|
|
131
|
+
|
|
132
|
+
**What to do if the warning appears:**
|
|
133
|
+
|
|
134
|
+
| If the open access is | Then |
|
|
135
|
+
|---|---|
|
|
136
|
+
| intended, with a per-file rule | declare it: `file.access: 'owner' \| 'tenant' \| …`, or override `CoreFileService.checkRights()`. Either silences the warning, for both halves of the check |
|
|
137
|
+
| intended, genuinely public files | nothing is broken — but the warning is the reminder that ids are enumerable and the routes are not rate-limited |
|
|
138
|
+
| **not** intended | inherit the member instead of re-declaring it, so `file.downloadRoles` governs it again |
|
|
139
|
+
|
|
140
|
+
**No new warning if you do not subclass**, and none if you subclass without re-declaring a governed
|
|
141
|
+
member — the audit only reports roles the configuration cannot account for, so it never duplicates
|
|
142
|
+
the existing configuration-side warning.
|
|
143
|
+
|
|
144
|
+
Governed members: `getFileById`, `getFile` (controller); `getFileInfo`, `uploadFile`, `uploadFiles`,
|
|
145
|
+
`deleteFile` (resolver).
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## Module Documentation
|
|
150
|
+
|
|
151
|
+
- File module, upload filters and access rules: [`src/core/modules/file/README.md`](../src/core/modules/file/README.md)
|
|
152
|
+
- Request lifecycle and exception handling: [`docs/REQUEST-LIFECYCLE.md`](../docs/REQUEST-LIFECYCLE.md)
|
|
@@ -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.
|
|
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.
|
|
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.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
|
|
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.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.
|
|
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.
|
|
198
|
-
"oxlint": "1.
|
|
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.
|
|
202
|
+
"tsx": "4.23.12",
|
|
203
203
|
"tus-js-client": "4.3.1",
|
|
204
204
|
"typescript": "5.9.3",
|
|
205
|
-
"unplugin-swc": "1.5.
|
|
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"
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { BadRequestException } from '@nestjs/common';
|
|
1
2
|
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
|
|
2
3
|
import { randomBytes } from 'crypto';
|
|
3
4
|
import { diskStorage, memoryStorage } from 'multer';
|
|
@@ -147,9 +148,14 @@ function normalizeMimeType(value: string): string {
|
|
|
147
148
|
* `options.allowScriptableTypes` is set — that is what closes the hole for
|
|
148
149
|
* expressions that already exist in consumer projects.
|
|
149
150
|
*
|
|
150
|
-
* Rejections are reported as a
|
|
151
|
-
*
|
|
152
|
-
* NestJS's `transformException`
|
|
151
|
+
* Rejections are reported as a `BadRequestException`, so the caller gets a 400
|
|
152
|
+
* with a readable reason. An `HttpException` is required, not merely tidier:
|
|
153
|
+
* NestJS's `transformException` returns any NON-`HttpException` unchanged — its
|
|
154
|
+
* switch matches only multer's own message constants (`LIMIT_FILE_SIZE`,
|
|
155
|
+
* `LIMIT_UNEXPECTED_FILE`, …), and a message written here matches none of them.
|
|
156
|
+
* A bare `new Error()` therefore still surfaced to the client as a 500, which
|
|
157
|
+
* reads as "the server broke" for what is in fact a refused file. See
|
|
158
|
+
* `tests/unit/file-upload-rejection-status.spec.ts`.
|
|
153
159
|
*/
|
|
154
160
|
export function multerFileFilter(
|
|
155
161
|
accept: RegExp | UploadAllowList = IMAGE_UPLOAD_ALLOW_LIST,
|
|
@@ -163,7 +169,7 @@ export function multerFileFilter(
|
|
|
163
169
|
!options?.allowScriptableTypes &&
|
|
164
170
|
(SCRIPTABLE_UPLOAD_MIME_TYPES.includes(mimeType) || SCRIPTABLE_UPLOAD_EXTENSIONS.includes(extension))
|
|
165
171
|
) {
|
|
166
|
-
return cb(new
|
|
172
|
+
return cb(new BadRequestException(`File upload rejected: ${mimeType || 'unknown type'} may execute as script`));
|
|
167
173
|
}
|
|
168
174
|
|
|
169
175
|
const accepted =
|
|
@@ -174,7 +180,7 @@ export function multerFileFilter(
|
|
|
174
180
|
if (accepted) {
|
|
175
181
|
return cb(null, true);
|
|
176
182
|
}
|
|
177
|
-
cb(new
|
|
183
|
+
cb(new BadRequestException(`File upload only supports the following filetypes - ${describeAccept(accept)}`));
|
|
178
184
|
};
|
|
179
185
|
}
|
|
180
186
|
|
|
@@ -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
|
|
57
|
-
|
|
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
|
|
102
|
-
|
|
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,
|