@lenne.tech/nest-server 11.28.1 → 11.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,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)
@@ -0,0 +1,311 @@
1
+ # Migration Guide: 11.29.0 → 11.29.1
2
+
3
+ ## Overview
4
+
5
+ | Category | Details |
6
+ |----------|---------|
7
+ | **Breaking Changes** | None |
8
+ | **Bugfixes** | None — no runtime code changed. `src/core/` is byte-identical to 11.29.0. |
9
+ | **New Features** | None |
10
+ | **Maintenance** | **Toolchain only.** `package.json#packageManager` is restored as the *single* place the pnpm version is pinned. `corepack` is gone from the `Dockerfile` (Node >= 25 no longer ships it); every pnpm-running build stage now provisions the pinned pnpm itself. `pnpm/action-setup` reads the pin instead of carrying its own `version` input. A contract test (`tests/unit/pnpm-pin-contract.spec.ts`) guards the whole chain. |
11
+ | **Migration Effort** | **0 minutes for npm-mode and vendor-mode consumers** — nothing in your project changes. **~10 minutes if you adopt the pattern**, which you should do *before* moving any build to Node >= 25. |
12
+
13
+ This release changes **how the framework repo builds itself**. It ships no API change, no
14
+ configuration change, and no behavioral change. Update with `pnpm add @lenne.tech/nest-server@11.29.1`
15
+ and you are done.
16
+
17
+ The reason to read further: **the `Dockerfile` is not part of the npm package** (it is not in
18
+ `package.json#files`). Your project's own `Dockerfile` and CI came from the starter template, so you
19
+ do **not** inherit this fix automatically. If they still run `corepack enable`, they will break the
20
+ day you move to Node >= 25 — see [Adopting the pattern](#adopting-the-pattern-in-your-project-recommended).
21
+
22
+ ---
23
+
24
+ ## Quick Migration (npm mode)
25
+
26
+ No code changes required.
27
+
28
+ ```bash
29
+ # Update the package
30
+ pnpm add @lenne.tech/nest-server@11.29.1
31
+
32
+ # Verify
33
+ pnpm run build
34
+ pnpm test
35
+ ```
36
+
37
+ ---
38
+
39
+ ## Why this release exists: corepack is going away
40
+
41
+ `corepack` was the mechanism that read `package.json#packageManager` and materialized the right
42
+ package-manager binary. It shipped with Node for years, which is why the framework's `Dockerfile`
43
+ could simply say `corepack enable` and trust that pnpm appeared.
44
+
45
+ **Node >= 25 no longer ships corepack.** A build stage that runs `corepack enable` on such an image
46
+ fails outright:
47
+
48
+ ```
49
+ /bin/sh: corepack: not found
50
+ ```
51
+
52
+ So the framework needed a way to provision an exact pnpm version that does not depend on corepack
53
+ existing — without giving up the single-source-of-truth pin.
54
+
55
+ ### What 11.29.0 did, and why it is reverted here
56
+
57
+ 11.29.0 removed the `packageManager` field and introduced `engines.pnpm: "^11.0.0"`. That left the
58
+ repo with **no exact pin at all**, so the version had to be restated wherever pnpm was needed — the
59
+ workflows grew a hardcoded `with: version: 11`, and Docker's `corepack enable` (with no
60
+ `packageManager` field to follow) would resolve to whatever it considered current.
61
+
62
+ Two sources of truth, neither exact. 11.29.1 collapses them back into one:
63
+
64
+ | | 11.29.0 | 11.29.1 |
65
+ |---|---|---|
66
+ | `package.json#packageManager` | *(removed)* | `pnpm@11.13.1+sha512.…` — **the** pin |
67
+ | `package.json#engines.pnpm` | `^11.0.0` | `^11.0.0` — unchanged, a soft major gate |
68
+ | `.github/workflows/*` | `with: version: 11` | *(no `version` input — reads the pin)* |
69
+ | `Dockerfile` | `corepack enable` | derive-line (below) |
70
+
71
+ `engines.pnpm` stays, but understand what it is: a **soft major-range guard**, not a pin. It warns a
72
+ pnpm 10 user; it does not tell CI which version to install. Only `packageManager` does that.
73
+
74
+ ---
75
+
76
+ ## What Changed
77
+
78
+ ### 1. `packageManager` is back — exact, with an integrity hash
79
+
80
+ ```jsonc
81
+ // package.json
82
+ {
83
+ "packageManager": "pnpm@11.13.1+sha512.b2fc7683b8a6525414e7d13e1ba28caaddde96bf66ec540bfaeb7e702b81f3e0be4d1f295edf7f9fe0396740a8dce4509c582ddf79891f4543fea32d37645f25",
84
+ "engines": {
85
+ "node": ">= 22",
86
+ "pnpm": "^11.0.0"
87
+ }
88
+ }
89
+ ```
90
+
91
+ **This field has no effect on your project.** `packageManager` is only ever read from the *root*
92
+ `package.json` of the project being built — never from a dependency's. You inherit nothing from the
93
+ framework's pin.
94
+
95
+ Note that pnpm itself honours the field, with no corepack involved: run any `pnpm` command in a
96
+ directory whose `package.json` pins a different pnpm, and pnpm switches to it
97
+ (`managePackageManagerVersions`, on by default since pnpm 10). Corepack was never the only thing
98
+ reading this field — which is exactly why dropping it costs nothing.
99
+
100
+ ### 2. The Dockerfile provisions pnpm without corepack
101
+
102
+ Every stage that runs `pnpm` now provisions the pinned version itself, from the pin:
103
+
104
+ ```dockerfile
105
+ # Provision the exact pnpm declared in package.json (single source of truth).
106
+ # No corepack: Node >= 25 no longer ships it. The +sha512 suffix is stripped;
107
+ # npm enforces registry integrity for the tarball itself.
108
+ RUN npm install -g "$(node -p "require('./package.json').packageManager.split('+')[0]")"
109
+ ```
110
+
111
+ The `.split('+')[0]` turns `pnpm@11.13.1+sha512.b2fc…` into `pnpm@11.13.1`, because `npm install -g`
112
+ takes a plain spec. The dropped hash is not a loss of integrity: npm verifies the tarball against the
113
+ registry's own integrity metadata on install.
114
+
115
+ The line appears **once per pnpm-running stage** (`deps` and `builder`) — Docker stages do not inherit
116
+ each other's globally installed binaries, only what is explicitly `COPY --from=`'d.
117
+
118
+ ### 3. `pnpm/action-setup` no longer carries a `version` input
119
+
120
+ ```yaml
121
+ - name: Install pnpm
122
+ # No version input: the exact version is read from package.json's packageManager field.
123
+ uses: pnpm/action-setup@v6
124
+ ```
125
+
126
+ The action reads `packageManager` on its own. Specifying **both** is not redundant-but-harmless — the
127
+ action treats a mismatch as a hard error, so the two sources cannot silently drift.
128
+
129
+ ### 4. A contract test guards the chain
130
+
131
+ `tests/unit/pnpm-pin-contract.spec.ts` (11 assertions, unit suite, no MongoDB) fails the build if
132
+ anyone re-introduces the drift this release removes:
133
+
134
+ - the pin is exact (`x.y.z` + `sha512` hash — never a range),
135
+ - `engines.pnpm` tracks the pin's major,
136
+ - `devEngines.packageManager` never (re)appears — npm/npx abort with `EBADDEVENGINES` on it,
137
+ - the `Dockerfile` contains no `corepack` in any `RUN`,
138
+ - **every** pnpm-running stage runs the derive-line *before* its first `pnpm` command, and only after
139
+ a `COPY` has put `package.json` in place,
140
+ - no workflow passes a `version:` to `pnpm/action-setup` or hardcodes `npm install -g pnpm@…`.
141
+
142
+ A twelfth test proves the chain end-to-end — it derives the spec exactly as the Dockerfile does,
143
+ installs it into a throwaway prefix, and asserts the provisioned binary reports the pinned version.
144
+ It needs network and ~10 MB, so it is gated to `CI` / `PIN_PROVISION_TEST=1` and stays out of your way
145
+ locally.
146
+
147
+ ---
148
+
149
+ ## Breaking Changes
150
+
151
+ **None.** No public API, config key, decorator, or exported symbol changed.
152
+
153
+ ---
154
+
155
+ ## Adopting the pattern in your project (recommended)
156
+
157
+ Do this **before** you move any build to Node >= 25 — not after it breaks. If your project was
158
+ generated from a recent `nest-server-starter` / `lt fullstack init`, check whether it is already done.
159
+
160
+ ### Step 1: Pin pnpm in your root `package.json`
161
+
162
+ ```bash
163
+ # Writes packageManager with the integrity hash for the version you actually use
164
+ pnpm self-update
165
+ ```
166
+
167
+ Or set it by hand — the exact form matters (`pnpm@x.y.z+sha512.<hash>`):
168
+
169
+ ```jsonc
170
+ {
171
+ "packageManager": "pnpm@11.13.1+sha512.b2fc7683…",
172
+ "engines": { "node": ">= 22", "pnpm": "^11.0.0" }
173
+ }
174
+ ```
175
+
176
+ > **Monorepo:** this belongs in the **workspace root** `package.json`, not only in `projects/api/`.
177
+ > The Dockerfile's derive-line reads `/app/package.json`, which in monorepo mode
178
+ > (`--build-arg API_DIR=projects/api`) is the root manifest. See
179
+ > [Troubleshooting](#docker-build-fails-with-typeerror-cannot-read-properties-of-undefined-reading-split).
180
+
181
+ ### Step 2: Replace corepack in your Dockerfile
182
+
183
+ **Before:**
184
+ ```dockerfile
185
+ RUN apk add --no-cache python3 make g++ && corepack enable
186
+ ```
187
+
188
+ **After:**
189
+ ```dockerfile
190
+ RUN apk add --no-cache python3 make g++
191
+
192
+ # … COPY the manifests first — the derive-line reads package.json …
193
+
194
+ # Provision the exact pnpm declared in package.json (single source of truth).
195
+ # No corepack: Node >= 25 no longer ships it. The +sha512 suffix is stripped;
196
+ # npm enforces registry integrity for the tarball itself.
197
+ RUN npm install -g "$(node -p "require('./package.json').packageManager.split('+')[0]")"
198
+ ```
199
+
200
+ Two ordering rules, both easy to get wrong:
201
+
202
+ 1. The derive-line must come **after** the `COPY` that puts `package.json` into the WORKDIR — it
203
+ reads that file.
204
+ 2. It must be repeated in **each** stage that runs `pnpm`. A global install in `deps` does not reach
205
+ `builder`.
206
+
207
+ ### Step 3: Drop the hardcoded version in CI
208
+
209
+ **GitHub Actions** — remove the `version` input:
210
+
211
+ ```yaml
212
+ - name: Install pnpm
213
+ uses: pnpm/action-setup@v6 # reads packageManager; no `with: version:`
214
+ ```
215
+
216
+ **GitLab CI** (or any runner without the action) — use the same derive-line:
217
+
218
+ ```yaml
219
+ before_script:
220
+ # Provision the exact pnpm pinned in package.json (single source of truth).
221
+ - npm install -g "$(node -p "require('./package.json').packageManager.split('+')[0]")"
222
+ - pnpm install --frozen-lockfile
223
+ ```
224
+
225
+ ### Step 4 (optional): Guard it with a test
226
+
227
+ Copy `tests/unit/pnpm-pin-contract.spec.ts` from this repo and trim it to the files your project has.
228
+ The value is not the assertions — it is that the next person who "helpfully" re-adds
229
+ `with: version: 11` or a second `npm install -g pnpm@…` gets a red build instead of a silent drift
230
+ that surfaces months later as an unreproducible container.
231
+
232
+ ---
233
+
234
+ ## Compatibility Notes
235
+
236
+ | Pattern | Status |
237
+ |---------|--------|
238
+ | Any application code, decorator, service, or config using the framework | ✅ Unaffected — no runtime code changed |
239
+ | **npm-mode** consumers (`@lenne.tech/nest-server` as a dependency) | ✅ Unaffected — `pnpm add …@11.29.1` and done |
240
+ | **Vendor-mode** consumers (`src/core/` copied in) | ✅ Unaffected — `src/core/` is byte-identical to 11.29.0; a sync produces no delta |
241
+ | The framework's `packageManager` pin leaking into your project | ✅ Impossible — the field is read only from the root manifest, never from a dependency |
242
+ | Your project's `Dockerfile` still using `corepack enable` | ⚠️ Works on Node <= 24, **breaks on Node >= 25** — apply Step 2 |
243
+ | Your CI passing both `with: version:` **and** having a `packageManager` field | ⚠️ Hard error on version mismatch — apply Step 3 |
244
+ | Node version | ✅ Unchanged (`engines.node: ">= 22"`); the framework's own images stay on Node 24 LTS |
245
+ | pnpm 10 or older as your project's package manager | ⚠️ `engines.pnpm: "^11.0.0"` warns (`EBADENGINE`); a hard failure only with `engine-strict=true` |
246
+
247
+ ---
248
+
249
+ ## Troubleshooting
250
+
251
+ ### `corepack: not found` in a Docker build or CI job
252
+
253
+ You moved to a Node >= 25 image while your build still calls `corepack enable`. This is exactly the
254
+ failure this release prevents — apply [Step 2](#step-2-replace-corepack-in-your-dockerfile). Do not
255
+ "fix" it with `npm install -g corepack`: that reintroduces the indirection the derive-line removes.
256
+
257
+ ### Docker build fails with `TypeError: Cannot read properties of undefined (reading 'split')`
258
+
259
+ The derive-line found a `package.json` **without** a `packageManager` field. Almost always a
260
+ monorepo: in monorepo mode the build context is the workspace root, so `/app/package.json` is the
261
+ **root** manifest — and the pin was only added to `projects/api/package.json`.
262
+
263
+ Fix: add `packageManager` to the workspace **root** manifest (Step 1). Keeping the root and the API
264
+ package on the same pin is the point — one pnpm builds the whole workspace.
265
+
266
+ ### `ERR_PNPM_BAD_PM_VERSION`, or the action reports multiple pnpm versions
267
+
268
+ Two sources disagree about the version. Either your workflow still passes `with: version:` alongside
269
+ a `packageManager` field (remove the input — [Step 3](#step-3-drop-the-hardcoded-version-in-ci)), or a
270
+ stray `npm install -g pnpm@<other>` runs before the derive-line.
271
+
272
+ ### `EBADENGINE` / `ERR_PNPM_UNSUPPORTED_ENGINE` warning mentioning pnpm
273
+
274
+ Unchanged from 11.29.0 — `engines.pnpm: "^11.0.0"` warns when installing with pnpm 10 or older.
275
+ Upgrade with `npm i -g pnpm@11`, or pin your project via `packageManager` and let pnpm switch itself.
276
+ The framework's *runtime* does not depend on pnpm; this concerns the install step only.
277
+
278
+ ### `EBADDEVENGINES` from npm or npx
279
+
280
+ Something added a `devEngines.packageManager` block. npm and npx abort on it, and corepack rejects
281
+ ranges inside it. Use `packageManager` (exact pin) plus `engines.pnpm` (soft range) instead — the
282
+ contract test asserts `devEngines.packageManager` stays absent.
283
+
284
+ ---
285
+
286
+ ## Affected Files
287
+
288
+ No core module changed in this release, so there is no module documentation to revisit. For
289
+ completeness, the full change surface:
290
+
291
+ | File | Change |
292
+ |------|--------|
293
+ | `package.json` | `packageManager` restored (exact pin + `sha512`); version → 11.29.1 |
294
+ | `Dockerfile` | `corepack enable` removed; derive-line added to the `deps` and `builder` stages |
295
+ | `.github/workflows/build.yml`, `publish.yml` | `with: version: 11` removed from `pnpm/action-setup` |
296
+ | `tests/unit/pnpm-pin-contract.spec.ts` | **New** — 11 structural assertions + 1 CI-gated provisioning proof |
297
+ | `spectaql.yml`, `FRAMEWORK-API.md` | Version bump only (kept in sync per the release process) |
298
+ | `.claude/rules/package-management.md` | Documents the corepack-free pin contract |
299
+
300
+ ---
301
+
302
+ ## References
303
+
304
+ - [Package Management Rules — fixed versions, overrides, the pnpm pin contract](../.claude/rules/package-management.md)
305
+ - [Versioning Strategy — release process, `package.json` / `spectaql.yml` version sync](../.claude/rules/versioning.md)
306
+ - Contract test: `tests/unit/pnpm-pin-contract.spec.ts`
307
+ - [Migration Guide 11.28.1 → 11.29.0](./11.28.1-to-11.29.0.md) — previous release (introduced the `engines.pnpm` gate this guide keeps)
308
+ - [Migration Guide 11.27.6 → 11.27.7](./11.27.6-to-11.27.7.md) — the pnpm 11 move, when corepack was still the mechanism
309
+ - [nest-server-starter](https://github.com/lenneTech/nest-server-starter) (reference implementation)
310
+ </content>
311
+ </invoke>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "11.28.1",
3
+ "version": "11.29.1",
4
4
  "description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).",
5
5
  "keywords": [
6
6
  "node",
@@ -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,8 +76,10 @@
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
  },
82
+ "packageManager": "pnpm@11.13.1+sha512.b2fc7683b8a6525414e7d13e1ba28caaddde96bf66ec540bfaeb7e702b81f3e0be4d1f295edf7f9fe0396740a8dce4509c582ddf79891f4543fea32d37645f25",
80
83
  "dependencies": {
81
84
  "@apollo/server": "5.5.1",
82
85
  "@as-integrations/express5": "1.1.2",
@@ -187,6 +190,5 @@
187
190
  ],
188
191
  "watch": {
189
192
  "build:dev": "src"
190
- },
191
- "packageManager": "pnpm@11.13.0"
193
+ }
192
194
  }
@@ -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.