@lenne.tech/nest-server 11.28.0 → 11.29.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/better-auth.md +36 -0
- package/.claude/rules/configurable-features.md +1 -0
- package/.claude/rules/testing.md +4 -2
- package/FRAMEWORK-API.md +9 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +1 -0
- package/dist/core/modules/better-auth/better-auth.config.js +19 -0
- 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.js.map +1 -1
- package/dist/core/modules/user/core-user.service.js +7 -1
- package/dist/core/modules/user/core-user.service.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +12 -0
- package/migration-guides/11.28.0-to-11.28.1.md +155 -0
- package/migration-guides/11.28.1-to-11.29.0.md +231 -0
- package/package.json +31 -26
- package/src/core/common/interfaces/server-options.interface.ts +12 -0
- package/src/core/modules/better-auth/README.md +22 -0
- package/src/core/modules/better-auth/better-auth.config.ts +72 -1
- package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +1 -1
- package/src/core/modules/user/core-user.service.ts +14 -1
|
@@ -528,6 +528,18 @@ Authenticates the request using three strategies in priority order:
|
|
|
528
528
|
|
|
529
529
|
If authentication succeeds, `req.user` is set with the authenticated user (including `hasRole()` method).
|
|
530
530
|
|
|
531
|
+
> **Native `/iam/*` routes bypass the `@Roles()`/`checkRoles` layer.** Better-Auth's own endpoints
|
|
532
|
+
> (e.g. `POST /iam/update-user`, `POST /iam/sign-up/email`) that are **not** in
|
|
533
|
+
> `CONTROLLER_HANDLED_PATHS` are forwarded raw by `CoreBetterAuthApiMiddleware` to Better-Auth's
|
|
534
|
+
> native handler under its `sessionMiddleware` — i.e. reachable by **any authenticated user**,
|
|
535
|
+
> independent of the controller's class-level `@Roles(ADMIN)` and nest-server's `checkRoles`. This is
|
|
536
|
+
> why server-managed user fields (`roles`, `verified`, `verifiedAt`, `twoFactorEnabled`, `iamId`) are
|
|
537
|
+
> locked at the Better-Auth schema layer with `input: false` (see `betterAuth.additionalUserFields[].input`
|
|
538
|
+
> in `.claude/rules/configurable-features.md` and `.claude/rules/better-auth.md` §2): field-level
|
|
539
|
+
> input rejection is the correct control here because the guard layer does not run on these
|
|
540
|
+
> raw-forwarded routes. A forged `POST /iam/update-user {"roles":["admin"]}` is rejected with
|
|
541
|
+
> `FIELD_NOT_ALLOWED` (HTTP 400) at the input-parse stage, before any persistence.
|
|
542
|
+
|
|
531
543
|
#### 3. graphqlUploadExpress
|
|
532
544
|
|
|
533
545
|
Only for GraphQL routes. Handles multipart file upload requests according to the [GraphQL multipart request specification](https://github.com/jaydenseric/graphql-multipart-request-spec).
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# Migration Guide: 11.28.0 → 11.28.1
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
| Category | Details |
|
|
6
|
+
|----------|---------|
|
|
7
|
+
| **Breaking Changes** | None |
|
|
8
|
+
| **Bugfixes** | Internal only — the `ejs` import in the BetterAuth email-verification service was normalized to the CommonJS style (`import ejs = require('ejs')`), silencing a static-analysis warning. No runtime behavior change. |
|
|
9
|
+
| **New Features** | None |
|
|
10
|
+
| **Maintenance** | Dependency housekeeping: 17 within-major package updates, 4 previously-undeclared runtime dependencies now declared explicitly, security overrides pruned 9 → 6. `pnpm audit` remains at **0 vulnerabilities**. |
|
|
11
|
+
| **Migration Effort** | **0 minutes for npm-mode consumers** (`pnpm update`). **~2 minutes for vendor-mode consumers** — ensure 4 runtime dependencies are present (see below). |
|
|
12
|
+
|
|
13
|
+
This is a **pure maintenance patch**. There is no API change, no configuration change, and no
|
|
14
|
+
behavioral change. Every one of the framework's 1381 tests passes unchanged.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Quick Migration (npm mode)
|
|
19
|
+
|
|
20
|
+
No code changes required.
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
# Update the package
|
|
24
|
+
pnpm add @lenne.tech/nest-server@11.28.1
|
|
25
|
+
|
|
26
|
+
# Verify
|
|
27
|
+
pnpm run build
|
|
28
|
+
pnpm test
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
All transitive dependency changes resolve automatically. Nothing in your project needs to change.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## What Changed
|
|
36
|
+
|
|
37
|
+
### 1. Framework dependency updates (all within NestJS 11)
|
|
38
|
+
|
|
39
|
+
The MAJOR version still mirrors NestJS 11 — no NestJS major boundary was crossed. Notable updates:
|
|
40
|
+
|
|
41
|
+
| Package | 11.28.0 | 11.28.1 | Note |
|
|
42
|
+
|---------|---------|---------|------|
|
|
43
|
+
| `@nestjs/common` / `core` / `platform-express` / `websockets` | 11.1.23 | 11.1.28 | NestJS 11 patch |
|
|
44
|
+
| `mongoose` | 9.6.2 | 9.7.4 | Minor (mongodb stays 7.2.0 — coupled) |
|
|
45
|
+
| `nodemailer` | 8.0.8 | 9.0.3 | **Major bump** — internal email transport (see Compatibility Notes) |
|
|
46
|
+
| `multer` | 2.1.1 | 2.2.0 | Security (unhandled multipart DoS) |
|
|
47
|
+
| `graphql-query-complexity` | 1.1.0 | 1.1.1 | Patch |
|
|
48
|
+
|
|
49
|
+
Dev-only tooling was also refreshed (`vitest`/`@vitest/*` 4.1.7 → 4.1.10, `vite` 8.0.14 → 8.1.4,
|
|
50
|
+
`oxlint` 1.66.0 → 1.74.0, `@swc/core` 1.15.40 → 1.15.43, `tsx` 4.22.3 → 4.23.1, plus `@types/*`).
|
|
51
|
+
Dev-tooling changes have **zero** effect on consuming projects.
|
|
52
|
+
|
|
53
|
+
### 2. Newly declared runtime dependencies (relevant for vendor mode)
|
|
54
|
+
|
|
55
|
+
Four packages that the framework's shipped code **imports directly** were previously only resolved
|
|
56
|
+
**transitively** (phantom dependencies), relying on pnpm hoisting. They are now declared explicitly
|
|
57
|
+
and pinned to the exact versions that were already resolving — **this is a declaration change, not a
|
|
58
|
+
version bump, so nothing new is installed for npm-mode consumers.**
|
|
59
|
+
|
|
60
|
+
| Package | Version | Imported by | Reached transitively before via |
|
|
61
|
+
|---------|---------|-------------|----------------------------------|
|
|
62
|
+
| `cron` | 4.4.0 | `src/core/common/services/core-cron-jobs.service.ts` (`new CronJob`) | `@nestjs/schedule` |
|
|
63
|
+
| `jose` | 6.2.1 | `src/core/modules/better-auth/core-better-auth.service.ts` (`importJWK`/`jwtVerify`) | `better-auth`, `@modelcontextprotocol/sdk` |
|
|
64
|
+
| `ws` | 8.21.0 | `src/test/test.helper.ts` (`require('ws')`) | `@nestjs/graphql`, `@nestjs/apollo` |
|
|
65
|
+
| `graphql-ws` | 6.0.8 | `src/test/test.helper.ts` (`createClient`) | `@nestjs/graphql`, `@nestjs/apollo` |
|
|
66
|
+
|
|
67
|
+
Why this matters: relying on a transitive package means its presence and **version** are controlled
|
|
68
|
+
by someone else's dependency tree. `ws` already resolved to two versions in the tree (`8.21.0` and
|
|
69
|
+
a nested `7.5.11`); an explicit declaration removes the hoisting lottery.
|
|
70
|
+
|
|
71
|
+
### 3. Security override cleanup (9 → 6)
|
|
72
|
+
|
|
73
|
+
Three overrides in `pnpm-workspace.yaml` became genuine no-ops after the direct-dependency updates
|
|
74
|
+
above and were removed (verified with `pnpm audit`, which stays at 0 vulnerabilities):
|
|
75
|
+
|
|
76
|
+
| Removed override | Now resolved by |
|
|
77
|
+
|------------------|-----------------|
|
|
78
|
+
| `nodemailer@<9.0.1` | direct dependency is now `nodemailer@9.0.3` (the old override was silently patching a vulnerable `8.0.8` direct pin) |
|
|
79
|
+
| `multer@<2.2.0` | `@nestjs/platform-express@11.1.28` now pins `multer@2.2.0` exactly |
|
|
80
|
+
| `vite@>=8.0.0 <8.0.16` | all `vite` now resolves to `8.1.4` |
|
|
81
|
+
|
|
82
|
+
Six overrides remain, each proven still load-bearing (removing them reintroduces a vulnerability):
|
|
83
|
+
`ajv`, `picomatch`, `ws`, `uuid`, `@babel/core`, `js-yaml`. Each carries its CVE/GHSA rationale as
|
|
84
|
+
an inline comment.
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## Compatibility Notes
|
|
89
|
+
|
|
90
|
+
- **npm-mode consumers:** Nothing to do. `pnpm update @lenne.tech/nest-server` is sufficient.
|
|
91
|
+
- **`nodemailer` 8 → 9 (internal):** `nodemailer` is used internally by the framework's
|
|
92
|
+
`EmailService`/SMTP transport. Projects configure it declaratively through
|
|
93
|
+
`email.smtp` in `config.env.ts`, and those options are unchanged across the 8 → 9 boundary — all
|
|
94
|
+
email tests pass unmodified. If (and only if) your project constructs a **custom `nodemailer`
|
|
95
|
+
transport object directly** and passes it in, review the
|
|
96
|
+
[nodemailer 9 release notes](https://github.com/nodemailer/nodemailer/releases) for the transport
|
|
97
|
+
API. The declarative `email.smtp` path needs no changes.
|
|
98
|
+
- **Projects without BetterAuth / cron / GraphQL subscriptions:** Unaffected — the newly declared
|
|
99
|
+
packages were already in your tree transitively.
|
|
100
|
+
|
|
101
|
+
### Vendor-mode consumers (`src/core/` copied into your project)
|
|
102
|
+
|
|
103
|
+
Vendor-mode projects do **not** install `@lenne.tech/nest-server` as an npm dependency, so they do
|
|
104
|
+
not inherit its `dependencies`. After syncing this release into your vendored `src/core/`, make sure
|
|
105
|
+
these four runtime packages exist in **your** `package.json` (pinned, per the fixed-version rule):
|
|
106
|
+
|
|
107
|
+
```jsonc
|
|
108
|
+
{
|
|
109
|
+
"dependencies": {
|
|
110
|
+
"cron": "4.4.0", // required — imported by core-cron-jobs.service.ts
|
|
111
|
+
"jose": "6.2.1" // required — imported by core-better-auth.service.ts
|
|
112
|
+
// ws / graphql-ws: required only if you use the exported TestHelper (src/test/test.helper.ts)
|
|
113
|
+
// in your own tests — most projects do:
|
|
114
|
+
// "graphql-ws": "6.0.8",
|
|
115
|
+
// "ws": "8.21.0"
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
In practice these are almost always already present transitively (via `@nestjs/schedule`,
|
|
121
|
+
`better-auth`, `@nestjs/graphql`). Declaring them explicitly protects you from a future transitive
|
|
122
|
+
change silently removing them. The `lt-dev:nest-server-core-updater` agent surfaces this during a
|
|
123
|
+
core sync.
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## Troubleshooting
|
|
128
|
+
|
|
129
|
+
### `Cannot find module 'jose'` / `'cron'` after a vendor-mode sync
|
|
130
|
+
|
|
131
|
+
You copied the updated `src/core/` but did not add the runtime dependency. Add the package to your
|
|
132
|
+
project's `package.json` (see the vendor-mode block above) and run `pnpm install`.
|
|
133
|
+
|
|
134
|
+
### `pnpm audit` reports a vulnerability I thought an override covered
|
|
135
|
+
|
|
136
|
+
The `nodemailer`, `multer`, and `vite` overrides were removed because the direct/transitive
|
|
137
|
+
resolutions now land on fixed versions on their own. If your project maintains its **own**
|
|
138
|
+
`pnpm-workspace.yaml` overrides (vendor mode or a monorepo root), re-verify with a with/without
|
|
139
|
+
lockfile diff — do not blindly copy this repo's removals; your tree may still resolve a vulnerable
|
|
140
|
+
version back into range.
|
|
141
|
+
|
|
142
|
+
### Build tool (`nest build`) fails with a permission error after `pnpm install`
|
|
143
|
+
|
|
144
|
+
Unrelated to your code — a known pnpm 11 hoisted-linker store-dedup artifact can drop the executable
|
|
145
|
+
bit on `node_modules/.bin/nest`. This release deliberately keeps `@nestjs/cli` at `11.0.21` to avoid
|
|
146
|
+
re-linking it. If you still hit it, `chmod +x node_modules/@nestjs/cli/bin/nest.js` restores it and
|
|
147
|
+
survives subsequent installs.
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## References
|
|
152
|
+
|
|
153
|
+
- [Package Management Rules](../.claude/rules/package-management.md) — fixed-version policy, override target rules
|
|
154
|
+
- [Migration Guide 11.27.7 → 11.28.0](./11.27.7-to-11.28.0.md) — previous release (the 401/403 policy + S_SELF/S_CREATOR ownership fixes)
|
|
155
|
+
- [nest-server-starter](https://github.com/lenneTech/nest-server-starter) (reference implementation)
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# Migration Guide: 11.28.1 → 11.29.0
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
| Category | Details |
|
|
6
|
+
|----------|---------|
|
|
7
|
+
| **Breaking Changes** | Server-managed Better-Auth user fields (`roles`, `verified`, `verifiedAt`, `twoFactorEnabled`, `iamId`, and by default `termsAndPrivacyAcceptedAt`) are now registered with `input: false`, so Better-Auth's **native** input parsing rejects client-supplied values. Behavioral change on `POST /iam/update-user` and `POST /iam/sign-up/email`. |
|
|
8
|
+
| **Security Fix** | Closes a confirmed **vertical privilege-escalation** vulnerability (OWASP A01): any authenticated user could self-grant `admin` via `POST /iam/update-user {"roles":["admin"]}`. |
|
|
9
|
+
| **New Features** | New per-field `input?: boolean` option on `IBetterAuthUserField` (`betterAuth.additionalUserFields`) so projects can mark their own fields as server-managed. New parallel-safe e2e test infrastructure (machine-wide run governor, startup sweep, `retry: 2`) — optional adoption, see below. |
|
|
10
|
+
| **Bugfix** | `CoreUserService.create()` now checks email uniqueness at application level (the unique index alone misses duplicates on a fresh database while autoIndex is still building — same window on freshly deployed production DBs). **Behavioral note:** a duplicate email via `userService.create()` now always yields **400 "Email address already in use"** — previously, callers other than `signUp` could see a raw **422 Unprocessable Entity** when the error surfaced as a Mongo duplicate-key error. Adjust tests that asserted the 422. |
|
|
11
|
+
| **Migration Effort** | **~0 minutes for most projects** (`pnpm update`). **Audit required** only if your project relied on client-setting one of the locked fields via the Better-Auth input path — see below. |
|
|
12
|
+
|
|
13
|
+
MINOR was incremented because this repo's scheme treats behavioral/breaking changes as MINOR (MAJOR mirrors the NestJS version, still 11).
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Why this change
|
|
18
|
+
|
|
19
|
+
Better-Auth registers `betterAuth.additionalUserFields` as native `additionalFields`, and Better-Auth
|
|
20
|
+
defaults each field to `input: true` — i.e. **client-settable** via `POST /iam/sign-up/email` and
|
|
21
|
+
`POST /iam/update-user`. Critically, `/iam/update-user` is **not** in `CONTROLLER_HANDLED_PATHS`, so
|
|
22
|
+
`CoreBetterAuthApiMiddleware` forwards it raw to Better-Auth's native handler under its
|
|
23
|
+
`sessionMiddleware` — reachable by **any authenticated user**, bypassing the controller's class-level
|
|
24
|
+
`@Roles(ADMIN)` and nest-server's `checkRoles`.
|
|
25
|
+
|
|
26
|
+
Before 11.29.0 the server-managed `roles` field was registered **without** `input: false`. The result:
|
|
27
|
+
|
|
28
|
+
```http
|
|
29
|
+
POST /iam/update-user
|
|
30
|
+
Authorization: Bearer <any authenticated user's token>
|
|
31
|
+
|
|
32
|
+
{ "roles": ["admin"] }
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
…wrote `roles: ["admin"]` onto the caller's own user row, which then became authoritative for
|
|
36
|
+
`@Restricted(RoleEnum.ADMIN)`. A freshly self-registered user could make themselves admin.
|
|
37
|
+
|
|
38
|
+
## What changed
|
|
39
|
+
|
|
40
|
+
Server-managed core fields are now registered with `input: false` and **re-asserted after** any
|
|
41
|
+
project `additionalUserFields` are merged, so a project override cannot silently re-open them. The
|
|
42
|
+
security-critical, hard-locked set (`PROTECTED_INPUT_FALSE_KEYS`) is:
|
|
43
|
+
|
|
44
|
+
| Field | Prevented attack |
|
|
45
|
+
|-------|------------------|
|
|
46
|
+
| `roles` | vertical privilege escalation (self-granting `admin`) |
|
|
47
|
+
| `verified` / `verifiedAt` | email-verification bypass |
|
|
48
|
+
| `twoFactorEnabled` | self-toggling the 2FA state |
|
|
49
|
+
| `iamId` | identity / account-linking hijack |
|
|
50
|
+
|
|
51
|
+
The lock is **column-scoped**: a shadow field whose `fieldName` maps to one of these columns
|
|
52
|
+
(e.g. `{ customRoles: { fieldName: 'roles', input: true } }`) is also forced to `input: false`.
|
|
53
|
+
|
|
54
|
+
Runtime behavior after the change:
|
|
55
|
+
|
|
56
|
+
- `POST /iam/update-user {"roles":[...]}` (or `verified`, etc.) → **`FIELD_NOT_ALLOWED` (HTTP 400)**.
|
|
57
|
+
- `POST /iam/sign-up/email {"roles":[...]}` → account created with the **server default** (`roles: []`);
|
|
58
|
+
the forged value is silently dropped, not persisted.
|
|
59
|
+
- `termsAndPrivacyAcceptedAt` is `input: false` by **default** but is intentionally **not** in the
|
|
60
|
+
hard-lock set (it is a consent timestamp, not a privilege boundary) — a project may re-open it with
|
|
61
|
+
`additionalUserFields: { termsAndPrivacyAcceptedAt: { type: 'date', input: true } }`.
|
|
62
|
+
|
|
63
|
+
**Unaffected:** nest-server's own role-assignment path — `UserService.setRoles`,
|
|
64
|
+
`CrudService.update` (via `checkRoles`), and the Better-Auth user mapper's native `$set` writes — does
|
|
65
|
+
**not** use Better-Auth input parsing and keeps working exactly as before. `input: false` only gates
|
|
66
|
+
the Better-Auth native sign-up/update-user routes.
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## Quick Migration (npm mode)
|
|
71
|
+
|
|
72
|
+
For the overwhelming majority of projects, **no code change is required**:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
pnpm add @lenne.tech/nest-server@11.29.0
|
|
76
|
+
pnpm run build
|
|
77
|
+
pnpm test
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Your existing role assignment (admin panels calling `updateUser`/`setRoles`, sign-up flows,
|
|
81
|
+
verification flows) continues to work — those go through nest-server's service layer, not the
|
|
82
|
+
Better-Auth native input path.
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## New: Parallel-Safe e2e Test Infrastructure (Optional Adoption)
|
|
87
|
+
|
|
88
|
+
This release also overhauls the e2e test infrastructure so that MANY parallel sessions
|
|
89
|
+
(`lt ticket` worktrees, several projects, agent sessions) can run test suites on one machine
|
|
90
|
+
without starving each other or leaving database garbage behind. **These are repository files, not
|
|
91
|
+
npm-package code** — `pnpm update` does NOT bring them into your project. Adopt them by syncing the
|
|
92
|
+
test files from [nest-server-starter](https://github.com/lenneTech/nest-server-starter) (or via the
|
|
93
|
+
lt-dev fullstack update agent):
|
|
94
|
+
|
|
95
|
+
| File | What it adds |
|
|
96
|
+
|------|--------------|
|
|
97
|
+
| `tests/e2e-run-slots.ts` (new) | **Machine-wide run governor**: at most N concurrent e2e runs across ALL lt projects (slot files in the OS temp dir, PID-liveness crash recovery, fail-open). Knobs: `LT_E2E_MAX_RUNS` (0 disables), `LT_E2E_SLOT_DIR`, `LT_E2E_SLOT_TIMEOUT`. |
|
|
98
|
+
| `tests/global-setup.ts` | **Startup sweep** — stale test DBs of this project AND leftover upload-test artifacts (`tests/*.txt` / `*.bin` from aborted file-upload specs) are removed when the next run STARTS, so cleanup survives SIGKILL (watchdog) and `--reporter` CLI overrides; then acquires a governor slot. |
|
|
99
|
+
| `tests/db-lifecycle.reporter.ts` | Exports the shared `isStaleTestDb()` predicate used by the sweep; failure message now points to the startup-sweep semantics. |
|
|
100
|
+
| `vitest-e2e.config.ts` | Auto low-resource mode when **another e2e run is active** (deterministic slot signal — the 1-minute load average structurally lags short runs) or load is high; **`retry: 2`** (a higher retry turns one broken spec file into an hour-long 0%-CPU grind that looks like a deadlock). |
|
|
101
|
+
| `tests/unit/e2e-run-slots.spec.ts` (new) | Unit tests for the governor. |
|
|
102
|
+
|
|
103
|
+
Measured effect (12-core machine): two overlapping full-speed runs previously drove load to 30 with
|
|
104
|
+
spurious 401 failures; with the governor, 6 parallel project checks all pass in 109–125s. A run
|
|
105
|
+
printing `[e2e-governor] waiting for a free e2e slot` every 15s is **queued, not hung**.
|
|
106
|
+
|
|
107
|
+
Rule carried over from the reporter docs: specs that need an extra database must derive it via
|
|
108
|
+
`deriveTestDbUri('<suffix>')` — never a hardcoded or `Date.now()`-based name — and drop their DB
|
|
109
|
+
**after** `app.close()` (async module init can re-create a database dropped while the app is alive).
|
|
110
|
+
|
|
111
|
+
**Related: `scripts/check.mjs` (canonical version distributed by the lt CLI).** The report-driven
|
|
112
|
+
check wrapper gained the same robustness set across all lt base repos: an **idle-watchdog** that
|
|
113
|
+
kills a test step after 300s of silence and diagnoses it as a hang (`CHECK_IDLE_TIMEOUT` /
|
|
114
|
+
`--idle-timeout=<s>`, 0 disables), whole-process-tree kills (no orphaned fork workers), a
|
|
115
|
+
SIGTERM/SIGKILL exit hint (a killed step is not an assertion failure), summed vitest metrics
|
|
116
|
+
(unit + e2e runs are no longer under-reported), and the audit rendered like every other step.
|
|
117
|
+
Consumer projects receive it via `lt fullstack update` (self-heal; skips a `scripts/check.mjs`
|
|
118
|
+
with uncommitted local edits) — `lt dev doctor` warns when the local copy drifts from the
|
|
119
|
+
canonical version.
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## Audit Before Upgrading
|
|
124
|
+
|
|
125
|
+
> **Mirrors the v11.28.x precedent in [`.claude/rules/role-system.md`](../.claude/rules/role-system.md)
|
|
126
|
+
> ("Audit every `S_SELF`/`S_CREATOR` on an input type before upgrading").**
|
|
127
|
+
|
|
128
|
+
You only need to act if **both** are true for your project:
|
|
129
|
+
|
|
130
|
+
1. You send `roles`, `verified`, `verifiedAt`, `twoFactorEnabled`, `iamId` (or
|
|
131
|
+
`termsAndPrivacyAcceptedAt`) in the **body of `POST /iam/update-user` or `POST /iam/sign-up/email`**
|
|
132
|
+
(i.e. through Better-Auth's native input path), **and**
|
|
133
|
+
2. You expected that value to be persisted.
|
|
134
|
+
|
|
135
|
+
If so, those requests will now receive `400 FIELD_NOT_ALLOWED` (update-user) or silently get the
|
|
136
|
+
server default (sign-up). **Move the assignment to the correct server-side path:**
|
|
137
|
+
|
|
138
|
+
```typescript
|
|
139
|
+
// BEFORE (relied on Better-Auth native input — now rejected):
|
|
140
|
+
// POST /iam/update-user { "roles": ["admin"] }
|
|
141
|
+
|
|
142
|
+
// AFTER — assign roles through nest-server's service layer (authorized + audited):
|
|
143
|
+
await this.userService.setRoles(userId, [RoleEnum.ADMIN]);
|
|
144
|
+
// or, from an admin-driven update that runs checkRoles:
|
|
145
|
+
await this.userService.update(userId, { roles: [RoleEnum.ADMIN] }, { currentUser: adminUser });
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
If you deliberately need one of your **own** `additionalUserFields` to be server-managed, set
|
|
149
|
+
`input: false` on it:
|
|
150
|
+
|
|
151
|
+
```typescript
|
|
152
|
+
betterAuth: {
|
|
153
|
+
additionalUserFields: {
|
|
154
|
+
internalScore: { type: 'number', defaultValue: 0, input: false }, // rejects client input
|
|
155
|
+
},
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
**Note:** You cannot re-open a hard-locked key (`roles`, `verified`, `verifiedAt`, `twoFactorEnabled`,
|
|
160
|
+
`iamId`) via `additionalUserFields` — the re-assertion re-locks it by design. This is intentional and
|
|
161
|
+
is the security guarantee of this release.
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## Compatibility Notes
|
|
166
|
+
|
|
167
|
+
| Pattern | Status |
|
|
168
|
+
|---------|--------|
|
|
169
|
+
| Admin panel updating roles via `UserService`/`CrudService`/GraphQL `updateUser` | ✅ Unaffected — service layer, not Better-Auth input |
|
|
170
|
+
| Sign-up / email verification / 2FA enable-disable flows | ✅ Unaffected — server-managed |
|
|
171
|
+
| Reading `roles`/`verified` in responses | ✅ Unaffected — `input: false` only gates writes |
|
|
172
|
+
| Client sending `roles`/`verified` in `/iam/update-user` or `/iam/sign-up/email` body | ⚠️ Now rejected/dropped — this was the vulnerability |
|
|
173
|
+
| Custom `additionalUserFields` (non-protected keys) | ✅ Unaffected — still client-settable unless you set `input: false` |
|
|
174
|
+
|
|
175
|
+
### Toolchain: `engines.pnpm` now advertised
|
|
176
|
+
|
|
177
|
+
`package.json` now declares `engines.pnpm: "^11.0.0"` (and drops the repo-internal `packageManager`
|
|
178
|
+
pin). Impact on consumers is **low and pnpm-only**:
|
|
179
|
+
|
|
180
|
+
- **npm / yarn consumers:** unaffected — they ignore `engines.pnpm`.
|
|
181
|
+
- **pnpm consumers on pnpm ≥ 11:** unaffected.
|
|
182
|
+
- **pnpm consumers on pnpm < 11:** you may see an `EBADENGINE` / `ERR_PNPM_UNSUPPORTED_ENGINE`
|
|
183
|
+
**warning** on install (a hard error only if you run with `engine-strict=true`). Upgrade to pnpm 11
|
|
184
|
+
(`corepack use pnpm@11` or `npm i -g pnpm@11`) to silence it. The framework's runtime does not
|
|
185
|
+
depend on pnpm — this only concerns the install step for pnpm-based projects.
|
|
186
|
+
|
|
187
|
+
No `engines.node` change (still `>= 22`).
|
|
188
|
+
|
|
189
|
+
### Vendor-mode consumers (`src/core/` copied into your project)
|
|
190
|
+
|
|
191
|
+
This release touches only `src/core/modules/better-auth/better-auth.config.ts`,
|
|
192
|
+
`src/core/common/interfaces/server-options.interface.ts` and
|
|
193
|
+
`src/core/modules/user/core-user.service.ts` (bugfix: application-level email-uniqueness check in
|
|
194
|
+
`create()` — the unique index alone misses duplicates on a fresh database while autoIndex is still
|
|
195
|
+
building; raw Mongo duplicate-key errors now also map to the correct "Email address already in use"
|
|
196
|
+
message) — no new files, no new imports, no new runtime dependencies. After syncing `src/core/` (via `/lt-dev:backend:update-nest-server-core` /
|
|
197
|
+
`lt-dev:nest-server-core-updater`), no additional package or config work is required. Run your
|
|
198
|
+
Better-Auth tests to confirm.
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
## Troubleshooting
|
|
203
|
+
|
|
204
|
+
### A client integration started getting `400 FIELD_NOT_ALLOWED` on `/iam/update-user`
|
|
205
|
+
|
|
206
|
+
Working as intended. The client was setting a server-managed field (`roles`/`verified`/…) through the
|
|
207
|
+
Better-Auth native input path. Move that assignment to the server (`UserService.setRoles` /
|
|
208
|
+
`CrudService.update` with an authorized `currentUser`), or — for your own field — decide whether it
|
|
209
|
+
should really be `input: false`.
|
|
210
|
+
|
|
211
|
+
### A user's `roles` no longer update after sign-up
|
|
212
|
+
|
|
213
|
+
If you relied on passing `roles` in the sign-up body, that value is now dropped (server default
|
|
214
|
+
applied). Assign roles explicitly after account creation via `UserService.setRoles`, or through your
|
|
215
|
+
admin-provisioning flow.
|
|
216
|
+
|
|
217
|
+
### I need one of my own additional fields to reject client input
|
|
218
|
+
|
|
219
|
+
Add `input: false` to it in `betterAuth.additionalUserFields`. See the "Audit" section above.
|
|
220
|
+
|
|
221
|
+
---
|
|
222
|
+
|
|
223
|
+
## References
|
|
224
|
+
|
|
225
|
+
- [Role System — S_ roles, 401/403 policy, S_SELF/S_CREATOR ownership](../.claude/rules/role-system.md)
|
|
226
|
+
- [Better-Auth Module Rules — §2 server-managed fields must be `input: false`](../.claude/rules/better-auth.md)
|
|
227
|
+
- [Configurable Features — BetterAuth Server-Managed User Fields](../.claude/rules/configurable-features.md)
|
|
228
|
+
- [Request Lifecycle — native `/iam/*` routes bypass the guard layer](../docs/REQUEST-LIFECYCLE.md)
|
|
229
|
+
- Regression test: `tests/stories/better-auth-privilege-escalation.e2e-spec.ts`
|
|
230
|
+
- [Migration Guide 11.28.0 → 11.28.1](./11.28.0-to-11.28.1.md) — previous release
|
|
231
|
+
- [nest-server-starter](https://github.com/lenneTech/nest-server-starter) (reference implementation)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lenne.tech/nest-server",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.29.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",
|
|
@@ -23,9 +23,10 @@
|
|
|
23
23
|
"build:dev": "pnpm run build",
|
|
24
24
|
"c": "pnpm run check",
|
|
25
25
|
"check": "node scripts/check.mjs",
|
|
26
|
-
"check:raw": "pnpm audit && pnpm run format:check && pnpm run lint && pnpm run check:swc-tdz && pnpm test && pnpm run build && bash scripts/check-server-start.sh",
|
|
27
|
-
"check:fix": "pnpm install && pnpm audit --fix && pnpm run format && pnpm run lint:fix && pnpm run check:swc-tdz && pnpm test && pnpm run build && bash scripts/check-server-start.sh",
|
|
28
|
-
"check:naf": "pnpm install && pnpm run format && pnpm run lint:fix && pnpm run check:swc-tdz && pnpm test && pnpm run build && bash scripts/check-server-start.sh",
|
|
26
|
+
"check:raw": "pnpm install --frozen-lockfile && pnpm audit && pnpm run format:check && pnpm run lint && pnpm run check:swc-tdz && pnpm test && pnpm run build && pnpm run check:manifest && bash scripts/check-server-start.sh",
|
|
27
|
+
"check:fix": "pnpm install && pnpm audit --fix && pnpm run format && pnpm run lint:fix && pnpm run check:swc-tdz && pnpm test && pnpm run build && pnpm run check:manifest && bash scripts/check-server-start.sh",
|
|
28
|
+
"check:naf": "pnpm install && pnpm run format && pnpm run lint:fix && pnpm run check:swc-tdz && pnpm test && pnpm run build && pnpm run check:manifest && bash scripts/check-server-start.sh",
|
|
29
|
+
"check:manifest": "node scripts/check-package-manifest.mjs",
|
|
29
30
|
"check:swc-tdz": "nest build -b swc -p tsconfig.swc-tdz.json && node scripts/check-swc-tdz.mjs",
|
|
30
31
|
"cf": "pnpm run check:fix",
|
|
31
32
|
"cnaf": "pnpm run check:naf",
|
|
@@ -75,7 +76,8 @@
|
|
|
75
76
|
"url": "https://github.com/lenneTech/nest-server/issues"
|
|
76
77
|
},
|
|
77
78
|
"engines": {
|
|
78
|
-
"node": ">= 22"
|
|
79
|
+
"node": ">= 22",
|
|
80
|
+
"pnpm": "^11.0.0"
|
|
79
81
|
},
|
|
80
82
|
"dependencies": {
|
|
81
83
|
"@apollo/server": "5.5.1",
|
|
@@ -84,41 +86,44 @@
|
|
|
84
86
|
"@getbrevo/brevo": "3.0.1",
|
|
85
87
|
"@modelcontextprotocol/sdk": "1.29.0",
|
|
86
88
|
"@nestjs/apollo": "13.4.2",
|
|
87
|
-
"@nestjs/common": "11.1.
|
|
88
|
-
"@nestjs/core": "11.1.
|
|
89
|
+
"@nestjs/common": "11.1.28",
|
|
90
|
+
"@nestjs/core": "11.1.28",
|
|
89
91
|
"@nestjs/graphql": "13.4.2",
|
|
90
92
|
"@nestjs/jwt": "11.0.2",
|
|
91
93
|
"@nestjs/mongoose": "11.0.4",
|
|
92
94
|
"@nestjs/passport": "11.0.5",
|
|
93
|
-
"@nestjs/platform-express": "11.1.
|
|
95
|
+
"@nestjs/platform-express": "11.1.28",
|
|
94
96
|
"@nestjs/schedule": "6.1.3",
|
|
95
97
|
"@nestjs/swagger": "11.4.2",
|
|
96
98
|
"@nestjs/terminus": "11.1.1",
|
|
97
|
-
"@nestjs/websockets": "11.1.
|
|
99
|
+
"@nestjs/websockets": "11.1.28",
|
|
98
100
|
"@tus/file-store": "2.1.0",
|
|
99
101
|
"@tus/server": "2.4.1",
|
|
100
|
-
"@types/supertest": "7.2.
|
|
102
|
+
"@types/supertest": "7.2.1",
|
|
101
103
|
"bcrypt": "6.0.0",
|
|
102
104
|
"better-auth": "1.6.23",
|
|
103
105
|
"class-transformer": "0.5.1",
|
|
104
106
|
"class-validator": "0.15.1",
|
|
105
107
|
"compression": "1.8.1",
|
|
106
108
|
"cookie-parser": "1.4.7",
|
|
109
|
+
"cron": "4.4.0",
|
|
107
110
|
"dotenv": "17.4.2",
|
|
108
111
|
"ejs": "5.0.2",
|
|
109
112
|
"express": "5.2.1",
|
|
110
113
|
"graphql": "16.14.0",
|
|
111
|
-
"graphql-query-complexity": "1.1.
|
|
114
|
+
"graphql-query-complexity": "1.1.1",
|
|
112
115
|
"graphql-subscriptions": "3.0.0",
|
|
113
116
|
"graphql-upload": "15.0.2",
|
|
117
|
+
"graphql-ws": "6.0.8",
|
|
118
|
+
"jose": "6.2.1",
|
|
114
119
|
"js-sha256": "0.11.1",
|
|
115
120
|
"json-to-graphql-query": "2.3.0",
|
|
116
121
|
"lodash": "4.18.1",
|
|
117
122
|
"mongodb": "7.2.0",
|
|
118
|
-
"mongoose": "9.
|
|
119
|
-
"multer": "2.
|
|
123
|
+
"mongoose": "9.7.4",
|
|
124
|
+
"multer": "2.2.0",
|
|
120
125
|
"node-mailjet": "6.0.11",
|
|
121
|
-
"nodemailer": "
|
|
126
|
+
"nodemailer": "9.0.3",
|
|
122
127
|
"passport": "0.7.0",
|
|
123
128
|
"passport-jwt": "4.0.1",
|
|
124
129
|
"reflect-metadata": "0.2.2",
|
|
@@ -126,26 +131,27 @@
|
|
|
126
131
|
"rxjs": "7.8.2",
|
|
127
132
|
"supertest": "7.2.2",
|
|
128
133
|
"ts-morph": "27.0.2",
|
|
134
|
+
"ws": "8.21.0",
|
|
129
135
|
"yuml-diagram": "1.2.0"
|
|
130
136
|
},
|
|
131
137
|
"devDependencies": {
|
|
132
138
|
"@compodoc/compodoc": "1.2.1",
|
|
133
139
|
"@nestjs/cli": "11.0.21",
|
|
134
140
|
"@nestjs/schematics": "11.1.0",
|
|
135
|
-
"@nestjs/testing": "11.1.
|
|
141
|
+
"@nestjs/testing": "11.1.28",
|
|
136
142
|
"@swc/cli": "0.8.1",
|
|
137
|
-
"@swc/core": "1.15.
|
|
143
|
+
"@swc/core": "1.15.43",
|
|
138
144
|
"@types/compression": "1.8.1",
|
|
139
145
|
"@types/cookie-parser": "1.4.10",
|
|
140
146
|
"@types/ejs": "3.1.5",
|
|
141
147
|
"@types/express": "5.0.6",
|
|
142
148
|
"@types/lodash": "4.17.24",
|
|
143
|
-
"@types/multer": "2.
|
|
149
|
+
"@types/multer": "2.2.0",
|
|
144
150
|
"@types/node": "25.9.1",
|
|
145
|
-
"@types/nodemailer": "8.0.
|
|
151
|
+
"@types/nodemailer": "8.0.1",
|
|
146
152
|
"@types/passport": "1.0.17",
|
|
147
|
-
"@vitest/coverage-v8": "4.1.
|
|
148
|
-
"@vitest/ui": "4.1.
|
|
153
|
+
"@vitest/coverage-v8": "4.1.10",
|
|
154
|
+
"@vitest/ui": "4.1.10",
|
|
149
155
|
"ansi-colors": "4.1.3",
|
|
150
156
|
"find-file-up": "2.0.1",
|
|
151
157
|
"husky": "9.1.7",
|
|
@@ -153,17 +159,17 @@
|
|
|
153
159
|
"npm-watch": "0.13.0",
|
|
154
160
|
"otpauth": "9.5.1",
|
|
155
161
|
"oxfmt": "0.51.0",
|
|
156
|
-
"oxlint": "1.
|
|
162
|
+
"oxlint": "1.74.0",
|
|
157
163
|
"rimraf": "6.1.3",
|
|
158
164
|
"ts-node": "10.9.2",
|
|
159
165
|
"tsconfig-paths": "4.2.0",
|
|
160
|
-
"tsx": "4.
|
|
166
|
+
"tsx": "4.23.1",
|
|
161
167
|
"tus-js-client": "4.3.1",
|
|
162
168
|
"typescript": "5.9.3",
|
|
163
169
|
"unplugin-swc": "1.5.9",
|
|
164
|
-
"vite": "8.
|
|
170
|
+
"vite": "8.1.4",
|
|
165
171
|
"vite-plugin-node": "8.0.0",
|
|
166
|
-
"vitest": "4.1.
|
|
172
|
+
"vitest": "4.1.10"
|
|
167
173
|
},
|
|
168
174
|
"main": "dist/index.js",
|
|
169
175
|
"types": "dist/index.d.ts",
|
|
@@ -183,6 +189,5 @@
|
|
|
183
189
|
],
|
|
184
190
|
"watch": {
|
|
185
191
|
"build:dev": "src"
|
|
186
|
-
}
|
|
187
|
-
"packageManager": "pnpm@11.13.0"
|
|
192
|
+
}
|
|
188
193
|
}
|
|
@@ -727,6 +727,18 @@ export interface IBetterAuthUserField {
|
|
|
727
727
|
*/
|
|
728
728
|
fieldName?: string;
|
|
729
729
|
|
|
730
|
+
/**
|
|
731
|
+
* Whether a client may supply this field's value via Better-Auth's native input parsing
|
|
732
|
+
* (sign-up create / update-user).
|
|
733
|
+
*
|
|
734
|
+
* When `false`, Better-Auth rejects client-supplied values: it throws `FIELD_NOT_ALLOWED`
|
|
735
|
+
* on the update-user route and silently substitutes the server-side default on sign-up.
|
|
736
|
+
* Use this to mark server-managed fields that must never be set from client input.
|
|
737
|
+
*
|
|
738
|
+
* @default true (Better-Auth default when omitted)
|
|
739
|
+
*/
|
|
740
|
+
input?: boolean;
|
|
741
|
+
|
|
730
742
|
/**
|
|
731
743
|
* Whether this field is required
|
|
732
744
|
*/
|
|
@@ -641,6 +641,8 @@ const config = {
|
|
|
641
641
|
department: { type: 'string', required: true },
|
|
642
642
|
preferences: { type: 'string', defaultValue: '{}' },
|
|
643
643
|
isActive: { type: 'boolean', defaultValue: true },
|
|
644
|
+
// Server-managed field: reject any client-supplied value (sign-up / update-user)
|
|
645
|
+
internalScore: { type: 'number', defaultValue: 0, input: false },
|
|
644
646
|
},
|
|
645
647
|
},
|
|
646
648
|
};
|
|
@@ -648,6 +650,26 @@ const config = {
|
|
|
648
650
|
|
|
649
651
|
**Available field types:** `'string'`, `'number'`, `'boolean'`, `'date'`, `'json'`, `'string[]'`, `'number[]'`
|
|
650
652
|
|
|
653
|
+
**Field options:**
|
|
654
|
+
|
|
655
|
+
| Option | Type | Default | Description |
|
|
656
|
+
| -------------- | ---------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
657
|
+
| `type` | field type | — | Required. One of the field types above. |
|
|
658
|
+
| `defaultValue` | any | — | Value used when the client does not (or may not) supply one. |
|
|
659
|
+
| `required` | boolean | `false` | Whether the field is required. |
|
|
660
|
+
| `input` | boolean | `true` (Better-Auth default) | When `false`, Better-Auth rejects client-supplied values: it throws `FIELD_NOT_ALLOWED` (HTTP 400) on `POST /iam/update-user` and substitutes the server default on sign-up. Use it to mark **server-managed** fields that must never be set from client input. |
|
|
661
|
+
|
|
662
|
+
> **⚠️ Security — server-managed core fields are hard-locked.** The core fields `roles`, `verified`,
|
|
663
|
+
> `verifiedAt`, `twoFactorEnabled` and `iamId` are registered with `input: false` and **re-asserted
|
|
664
|
+
> after** your `additionalUserFields` are merged, so a project override **cannot** re-open them for
|
|
665
|
+
> client input. This closes a vertical privilege-escalation path (a client can no longer self-grant
|
|
666
|
+
> `roles: ['admin']`, self-verify, or toggle 2FA via `POST /iam/update-user`). The lock also applies
|
|
667
|
+
> to any shadow field whose `fieldName` maps to one of those columns. `roles`/`verified` etc. are
|
|
668
|
+
> still assigned server-side through nest-server's own service layer (`UserService.setRoles`,
|
|
669
|
+
> `CrudService.update` with `checkRoles`) — that path does not use Better-Auth input parsing and is
|
|
670
|
+
> unaffected. (`termsAndPrivacyAcceptedAt` is `input: false` by default but is intentionally NOT
|
|
671
|
+
> hard-locked — it is a consent timestamp, not a privilege boundary, so a project may re-open it.)
|
|
672
|
+
|
|
651
673
|
### Module Integration (Recommended Pattern)
|
|
652
674
|
|
|
653
675
|
By default (`autoRegister: false`), projects integrate BetterAuth via an **extended module** in their project. This follows the same pattern as Legacy Auth and allows for custom resolvers, controllers, and project-specific authentication logic.
|