@lenne.tech/nest-server 11.36.4 → 11.36.5
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 +1 -1
- package/.claude/rules/testing.md +3 -3
- package/FRAMEWORK-API.md +1 -1
- package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
- package/dist/core/modules/auth/guards/roles.guard.d.ts +1 -0
- package/dist/core/modules/auth/guards/roles.guard.js +33 -0
- package/dist/core/modules/auth/guards/roles.guard.js.map +1 -1
- package/dist/core/modules/error-code/error-codes.d.ts +3 -3
- package/dist/core/modules/error-code/error-codes.js +3 -3
- package/dist/core/modules/error-code/error-codes.js.map +1 -1
- package/dist/core/modules/system-setup/core-system-setup.controller.js +4 -1
- package/dist/core/modules/system-setup/core-system-setup.controller.js.map +1 -1
- package/dist/core/modules/system-setup/core-system-setup.service.js +6 -0
- package/dist/core/modules/system-setup/core-system-setup.service.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +25 -1
- package/migration-guides/11.32.x-to-11.33.x.md +37 -4
- package/migration-guides/11.36.4-to-11.36.5.md +184 -0
- package/package.json +1 -1
- package/src/core/modules/ai/core-ai-mcp.controller.ts +9 -3
- package/src/core/modules/auth/guards/roles.guard.ts +68 -0
- package/src/core/modules/error-code/error-codes.ts +13 -3
- package/src/core/modules/system-setup/INTEGRATION-CHECKLIST.md +8 -7
- package/src/core/modules/system-setup/README.md +43 -9
- package/src/core/modules/system-setup/core-system-setup.controller.ts +4 -1
- package/src/core/modules/system-setup/core-system-setup.service.ts +21 -0
|
@@ -620,6 +620,30 @@ async updateUser(...): Promise<User> { ... }
|
|
|
620
620
|
async getPublicUsers(): Promise<User[]> { ... }
|
|
621
621
|
```
|
|
622
622
|
|
|
623
|
+
**Public does not mean anonymous (since 11.36.5).** On a public endpoint (`S_EVERYONE`, or no
|
|
624
|
+
`@Roles` at all) `RolesGuard` still tries to IDENTIFY the caller before granting access —
|
|
625
|
+
Better-Auth first, Passport JWT second, every failure swallowed. Access is granted either way: a
|
|
626
|
+
missing, expired or malformed token can never turn a public endpoint into a 401. The effect is that
|
|
627
|
+
`@CurrentUser()`, `serviceOptions.currentUser` and `securityCheck(user)` see a signed-in caller, so
|
|
628
|
+
a public endpoint can personalise — a search ranking by the caller's own location, a list marking
|
|
629
|
+
the caller's own entries.
|
|
630
|
+
|
|
631
|
+
Before 11.36.5 the guard returned early and `request.user` stayed unset. That mattered **only in
|
|
632
|
+
legacy-JWT deployments**: with Better-Auth enabled, `CoreBetterAuthMiddleware` is applied via
|
|
633
|
+
`forRoutes('(.*)')` and had already set `request.user` before any guard ran. `BetterAuthRolesGuard`
|
|
634
|
+
keeps its early return for exactly that reason — it only runs when Better-Auth is enabled, where the
|
|
635
|
+
middleware has already done the work.
|
|
636
|
+
|
|
637
|
+
Two consequences worth knowing when you own a public endpoint:
|
|
638
|
+
|
|
639
|
+
- **Owner-restricted output widens for its owner.** `@Restricted(S_SELF)` / `@Restricted(S_CREATOR)`
|
|
640
|
+
fields evaluate against `currentUser`; with `undefined` they always stripped. They now appear for
|
|
641
|
+
the caller who owns the record. That is the declared policy finally being applied, not a leak —
|
|
642
|
+
but it may be the first time anyone sees those fields on that route.
|
|
643
|
+
- **An anonymous caller stays ABSENT, not falsy.** Passport answers `false` for "no credentials";
|
|
644
|
+
the guard normalises that away so `@CurrentUser()` yields `undefined` as before. Pinned by
|
|
645
|
+
`tests/public-endpoint-identity.e2e-spec.ts`.
|
|
646
|
+
|
|
623
647
|
**Important:** `@Roles()` already handles JWT authentication internally. Do NOT add `@UseGuards(AuthGuard(JWT))` — it is redundant.
|
|
624
648
|
|
|
625
649
|
#### System Roles (S_ prefix)
|
|
@@ -634,7 +658,7 @@ When `X-Tenant-Id` header is present and a system role grants access, membership
|
|
|
634
658
|
|
|
635
659
|
| System Role | Check Logic | Use Case |
|
|
636
660
|
|-------------|-------------|----------|
|
|
637
|
-
| `S_EVERYONE` | Always true | Public endpoints |
|
|
661
|
+
| `S_EVERYONE` | Always true — access granted unconditionally; the caller is still identified when a valid token is present (see above) | Public endpoints |
|
|
638
662
|
| `S_NO_ONE` | Always false | Permanently locked |
|
|
639
663
|
| `S_USER` | `currentUser` exists | Any authenticated user |
|
|
640
664
|
| `S_VERIFIED` | `user.verified \|\| user.verifiedAt \|\| user.emailVerified` | Email-verified users |
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
| **Bugfixes** | Migration lock gained heartbeat + stale-break + bounded wait; `runOnInit` cron dedup no longer keyed on each replica's boot clock and no longer crashes the process on a failed startup run; BullMQ worker no longer swallows a tick delivered during bootstrap; rate limiters degrade instead of 500-ing on a Redis outage; Hub buffer mirror failures no longer recurse through the Logger they buffer; the email-verification cooldown timer can no longer expire a later cooldown early; a presigned S3 download no longer serves the file under a different name than the streamed one (§14) |
|
|
11
11
|
| **Under the hood (§14)** | Two new MongoDB collections (`s3-files`, `filesystem-files` — include them in backups), new public exports, a bucket-less `s3` block is now ignored, NestJS 11.1.28 → 11.1.29, plus two dependency MAJORS: `js-sha256` 0.12.0 → 1.0.0 (**digests unchanged — no password-hash break**) and `graphql-query-complexity` 1.1.1 → 2.0.0 |
|
|
12
12
|
| **Dependency Housekeeping** | The framework's own `pnpm audit` went from 20 findings to 0. Those are `pnpm-workspace.yaml` overrides, which **do not reach npm consumers** — run your own audit (§13) |
|
|
13
|
-
| **Migration Effort** | **Start with §A and §B — they are the only changes that can break a single-replica project, and they break it silently** (no crash, just 401/403 on `/files/**` and `/tus`). Then **§11a / §11b if your config already contains a `redis` or an `s3` key** — the framework reads both now, and an existing `s3` key makes the boot FAIL until you act. Everything else is opt-in: read §1 if you call or override the rate limiters, §2 if you override Hub services, §4 before your next multi-replica deploy, §10 if you copied the avatar upload from `src/server`, **§11c if you rate-limit behind a reverse proxy** (a boot warning will tell you), §13 for what the security overrides do and do not do for you |
|
|
13
|
+
| **Migration Effort** | **Start with §A and §B — they are the only changes that can break a single-replica project, and they break it silently** (no crash, just 401/403 on `/files/**` and `/tus`). Then **§11a / §11b if your config already contains a `redis` or an `s3` key** — the framework reads both now, and an existing `s3` key makes the boot FAIL until you act. Everything else is opt-in: read §1 if you call or override the rate limiters, §2 if you override Hub services, §4 before your next multi-replica deploy, §10 if you copied the avatar upload from `src/server`, **§11c if you rate-limit behind a reverse proxy** (a boot warning will tell you), §13 for what the security overrides do and do not do for you, and **§5 if any test suite of yours resets the database to replay system setup** — emptying `users` is no longer enough |
|
|
14
14
|
|
|
15
15
|
Most of this release exists to make one deployment shape possible: **more than one replica**. Nothing there is required to keep running on one.
|
|
16
16
|
|
|
@@ -547,9 +547,42 @@ creating, an instance now claims the setup by upserting `{ _id: 'initial-admin'
|
|
|
547
547
|
`system-setup-locks` collection — an atomic operation exactly one instance wins. The others log a
|
|
548
548
|
debug line and skip. If creation then fails, the marker is removed so a later boot can retry.
|
|
549
549
|
|
|
550
|
-
**Action: none.** The unique email index already prevented duplicate *users*; this
|
|
551
|
-
failures and the ambiguity about which replica actually did the setup. A new
|
|
552
|
-
`system-setup-locks` appears in the database with a single small document.
|
|
550
|
+
**Action: none in production.** The unique email index already prevented duplicate *users*; this
|
|
551
|
+
removes the noisy failures and the ambiguity about which replica actually did the setup. A new
|
|
552
|
+
collection `system-setup-locks` appears in the database with a single small document.
|
|
553
|
+
|
|
554
|
+
**Action (test suites only): if you reset the database to replay system setup.** The marker is
|
|
555
|
+
removed only when creation FAILS. After a SUCCESSFUL setup it stays, which is what you want for a
|
|
556
|
+
deployment: setup must never run twice. But it also means that emptying `users` no longer restores
|
|
557
|
+
the fresh-install state, because `createInitialAdmin()` now guards on two conditions, not one. The
|
|
558
|
+
second `POST /system-setup/init` is then refused until the claim goes stale — **five minutes after
|
|
559
|
+
the claim was taken, i.e. after the successful setup, not five minutes after the refusal.** That is
|
|
560
|
+
why the symptom looks intermittent and gets misfiled as flake: a suite resetting ten minutes after
|
|
561
|
+
boot is not blocked at all, one resetting immediately is.
|
|
562
|
+
|
|
563
|
+
Drop the collection wherever you drop `users`:
|
|
564
|
+
|
|
565
|
+
```typescript
|
|
566
|
+
// deleteMany, not drop: dropping `users` would drop its unique email index — the very
|
|
567
|
+
// index that prevents a duplicate admin, credited above. deleteMany preserves indexes.
|
|
568
|
+
for (const col of ['users', 'account', 'session', 'system-setup-locks']) {
|
|
569
|
+
await db.collection(col).deleteMany({});
|
|
570
|
+
}
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
That is the framework's own reset, executed on every test run in
|
|
574
|
+
`tests/stories/system-setup.e2e-spec.ts` — prefer reading it there over copying this snippet, since
|
|
575
|
+
a spec that runs cannot drift. (The whole-database `dropDatabase()` elsewhere in `tests/` is a
|
|
576
|
+
different job: per-run teardown, not a mid-run reset.)
|
|
577
|
+
|
|
578
|
+
Both refusals raise the same `LTNS_0050`. **Up to 11.36.4** its message read *"System setup not
|
|
579
|
+
available - users already exist"*, so a suite hitting the claim was told the users were the problem
|
|
580
|
+
while the users collection was empty. **From 11.36.5** the message names both conditions and the
|
|
581
|
+
server logs a warning naming `system-setup-locks`, the release-on-failure semantics and the stale
|
|
582
|
+
window. If setup fails right after a reset, check this collection before anything else.
|
|
583
|
+
|
|
584
|
+
Note that `GET /system-setup/status` does **not** help here: `needsSetup` is computed from the user
|
|
585
|
+
count alone, so it reports `true` while init is refused by the claim.
|
|
553
586
|
|
|
554
587
|
---
|
|
555
588
|
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# Migration Guide: 11.36.4 → 11.36.5
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
| Category | Details |
|
|
6
|
+
|----------|---------|
|
|
7
|
+
| **Breaking Changes** | None that removes or renames anything. **One behaviour change** on the legacy-JWT path: a signed-in caller is now identified on public endpoints (§2). One **consumer-visible string**: the `LTNS_0050` message and both its translations (§1) |
|
|
8
|
+
| **New Features** | None |
|
|
9
|
+
| **Bugfixes** | A signed-in caller lost their identity on a public endpoint in legacy-JWT deployments (§2). `LTNS_0050` described only one of the two guards that raise it, so a caller that had just emptied `users` was told users already exist while the collection was empty (§1) |
|
|
10
|
+
| **Migration Effort** | **None for most projects.** `pnpm update`. Read **§2 if you run legacy JWT auth WITHOUT Better-Auth and have public endpoints** — that is the only behaviour change here. Read §1 if you assert on the 403 body of `POST /system-setup/init`, or if a test suite of yours resets the database to replay system setup |
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Quick Migration
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pnpm update @lenne.tech/nest-server@11.36.5
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## 1. `LTNS_0050` now names both of the conditions that raise it
|
|
23
|
+
|
|
24
|
+
**Were you affected?** Only if you assert on the exact message text of a 403 from
|
|
25
|
+
`POST /system-setup/init`, or if you reset the database in tests to replay the setup flow.
|
|
26
|
+
|
|
27
|
+
### What changed
|
|
28
|
+
|
|
29
|
+
`createInitialAdmin()` refuses under **two** different conditions, and both raise
|
|
30
|
+
`ErrorCode.SYSTEM_SETUP_NOT_AVAILABLE` / `LTNS_0050`:
|
|
31
|
+
|
|
32
|
+
1. Users already exist.
|
|
33
|
+
2. The initial-admin setup is **claimed** — the marker `{ _id: 'initial-admin' }` in
|
|
34
|
+
`system-setup-locks` is held.
|
|
35
|
+
|
|
36
|
+
Up to 11.36.4 the message described only the first:
|
|
37
|
+
|
|
38
|
+
```diff
|
|
39
|
+
- System setup not available - users already exist
|
|
40
|
+
+ System setup not available - users already exist, or the initial-admin setup is claimed
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Both translations (`de`, `en`) moved with it, as did the `@ApiResponse` description on the
|
|
44
|
+
controller and the sample response in the module README.
|
|
45
|
+
|
|
46
|
+
**The code is unchanged.** It is still `LTNS_0050`, still HTTP 403. If you branch on the error
|
|
47
|
+
**code** — which is what the framework asks you to do — nothing breaks. Only an assertion on the
|
|
48
|
+
literal message string does.
|
|
49
|
+
|
|
50
|
+
### Why this was worth a release
|
|
51
|
+
|
|
52
|
+
The second guard is reachable in an ordinary situation that looks nothing like it: the marker is
|
|
53
|
+
released **only when creation fails**, so it outlives a SUCCESSFUL setup by design. Emptying `users`
|
|
54
|
+
to replay the flow therefore leaves setup refused — and the response said *"users already exist"*
|
|
55
|
+
while `users` was empty. That is a message pointing away from the cause, in a flow that runs once per
|
|
56
|
+
deployment and is rarely instrumented. It cost a consumer project a full CI cycle to find.
|
|
57
|
+
|
|
58
|
+
Since 11.36.5 the claim path also logs a warning naming the collection, the release-on-failure
|
|
59
|
+
semantics and the stale window, so the server log answers the question even when the response body is
|
|
60
|
+
all you kept.
|
|
61
|
+
|
|
62
|
+
### If you reset the database in tests
|
|
63
|
+
|
|
64
|
+
Emptying `users` is not enough. Drop the claim collection alongside it:
|
|
65
|
+
|
|
66
|
+
```typescript
|
|
67
|
+
// deleteMany, not drop: dropping `users` would drop its unique email index,
|
|
68
|
+
// which is what prevents a duplicate admin.
|
|
69
|
+
for (const col of ['users', 'account', 'session', 'system-setup-locks']) {
|
|
70
|
+
await db.collection(col).deleteMany({});
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
That is the framework's own reset, executed on every test run in
|
|
75
|
+
`tests/stories/system-setup.e2e-spec.ts`.
|
|
76
|
+
|
|
77
|
+
Two things that will otherwise mislead you:
|
|
78
|
+
|
|
79
|
+
- **`GET /system-setup/status` does not see the claim.** `needsSetup` is computed from the user
|
|
80
|
+
count alone, so it reports `true` while init is refused.
|
|
81
|
+
- **The stale window is measured from when the claim was taken**, not from your reset — five minutes
|
|
82
|
+
after the successful setup. A suite resetting ten minutes after boot is not blocked at all, one
|
|
83
|
+
resetting immediately is. That is why the symptom looks intermittent and gets misfiled as flake.
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## 2. A signed-in caller is now IDENTIFIED on a public endpoint
|
|
88
|
+
|
|
89
|
+
**Were you affected?** Only if **both** are true: you run **legacy JWT auth without Better-Auth**,
|
|
90
|
+
and you have endpoints carrying `@Roles(RoleEnum.S_EVERYONE)` (or no `@Roles` at all).
|
|
91
|
+
|
|
92
|
+
**With Better-Auth enabled, nothing changes for you.** `CoreBetterAuthMiddleware` is applied via
|
|
93
|
+
`forRoutes('(.*)')`, so it already ran on public routes and set `request.user` before any guard.
|
|
94
|
+
`BetterAuthRolesGuard` is deliberately unchanged for that reason.
|
|
95
|
+
|
|
96
|
+
### What changed
|
|
97
|
+
|
|
98
|
+
`RolesGuard` returned `true` for a public endpoint **without ever looking at the token**, so
|
|
99
|
+
`request.user` stayed unset and a signed-in caller arrived anonymous:
|
|
100
|
+
|
|
101
|
+
```typescript
|
|
102
|
+
@Get('search')
|
|
103
|
+
@Roles(RoleEnum.S_EVERYONE) // public — intended, anonymous visitors may search
|
|
104
|
+
search(@CurrentUser() user) {
|
|
105
|
+
// `user` was ALWAYS undefined, even with a valid Bearer token on the request
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Since 11.36.5 the guard identifies first — Better-Auth, then Passport JWT — with **every** failure
|
|
110
|
+
swallowed. A missing, expired or malformed token still means "anonymous": this can never turn a
|
|
111
|
+
public endpoint into a 401. **Access control is untouched.** Only identification is restored.
|
|
112
|
+
|
|
113
|
+
An anonymous caller still yields `undefined`, not a falsy-but-present value — worth stating because
|
|
114
|
+
Passport itself answers `false`, and code written as `if ('user' in request)` would have noticed.
|
|
115
|
+
|
|
116
|
+
### What to check before updating
|
|
117
|
+
|
|
118
|
+
Nothing needs changing to keep working. This is a list of places where behaviour may legitimately
|
|
119
|
+
widen, in order of how likely they are to surprise you:
|
|
120
|
+
|
|
121
|
+
1. **`@Restricted(S_SELF)` / `@Restricted(S_CREATOR)` fields un-hide for their owner.** They
|
|
122
|
+
evaluate against `currentUser`; with `undefined` they always stripped. On a public endpoint
|
|
123
|
+
returning records the caller owns, those fields now appear in the response — for that caller
|
|
124
|
+
only. The decorator sanctions it, but if you treated "this route is public, therefore always
|
|
125
|
+
anonymous, therefore owner-only fields never appear" as a redaction boundary, that assumption
|
|
126
|
+
no longer holds. Audit public endpoints whose response models carry owner-restricted fields.
|
|
127
|
+
2. **`checkRights()` starts receiving a user on these routes.** Both common shapes flip:
|
|
128
|
+
`if (!user) return data;` was a bypass on public routes and is now enforced; `if (!user) throw …`
|
|
129
|
+
refused and now passes.
|
|
130
|
+
3. **Ownership stamping.** `CrudService` writes `createdBy: serviceOptions?.currentUser?.id`. A
|
|
131
|
+
public create called by a signed-in caller now records ownership where it previously wrote
|
|
132
|
+
`undefined` — which later unlocks `S_CREATOR`-restricted fields on that record.
|
|
133
|
+
4. **`securityCheck(user)` on your models** now sees a real user on public endpoints, so a branch
|
|
134
|
+
you wrote as "the anonymous path" stops being the only one taken.
|
|
135
|
+
|
|
136
|
+
Find the places worth looking at:
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
# public endpoints that branch on the absence of a user
|
|
140
|
+
grep -rn "S_EVERYONE" src/server/ --include="*.ts" | grep -v ".spec.ts"
|
|
141
|
+
grep -rn "if (!currentUser\|if (!user" src/server/ --include="*.ts" | grep -v ".spec.ts"
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### If you relied on the old behaviour
|
|
145
|
+
|
|
146
|
+
A public endpoint that must be anonymous for **everyone** should not read `currentUser` at all.
|
|
147
|
+
If you built on "public implies anonymous", make it explicit — the guard no longer enforces it
|
|
148
|
+
for you as a side effect.
|
|
149
|
+
|
|
150
|
+
### Also reachable now: `/ai/mcp` in legacy-JWT deployments
|
|
151
|
+
|
|
152
|
+
`POST /ai/mcp` is `@Roles(S_EVERYONE)` and self-authenticating. In a legacy-JWT deployment it
|
|
153
|
+
previously answered 401 for everyone, because `req.user` was unset and no other resolution path
|
|
154
|
+
applied. It now authenticates any holder of a valid legacy access token. This is not an elevation —
|
|
155
|
+
the MCP server is still built per user, with the tool registry's role filter and the per-owner
|
|
156
|
+
session binding intact — but a surface you may have considered unreachable is now live. If you do
|
|
157
|
+
not want it, set `ai.mcp: false`.
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## Compatibility Notes
|
|
162
|
+
|
|
163
|
+
| Pattern | Status |
|
|
164
|
+
|---------|--------|
|
|
165
|
+
| Branching on `error.code === 'LTNS_0050'` | Unaffected — the code did not change |
|
|
166
|
+
| Asserting the literal 403 message string | **Update the expected string** (§1) |
|
|
167
|
+
| Displaying the translated message to users | Unaffected, but the text is longer and now mentions the claim |
|
|
168
|
+
| Overriding `CoreSystemSetupService.createInitialAdmin()` | Unaffected — the guard order and the thrown exception are unchanged |
|
|
169
|
+
| A test suite that resets only `users` | Was already broken since 11.33; now the message and the server log say so (§1) |
|
|
170
|
+
| Legacy JWT + public endpoints reading `currentUser` | **Now receives a user** (§2) |
|
|
171
|
+
| Better-Auth enabled (hybrid or IAM-only) | Unaffected by §2 — the middleware already identified the caller |
|
|
172
|
+
| Public endpoint returning owner-restricted fields | Those fields now reach the owner (§2.1) — audit before shipping |
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## Troubleshooting
|
|
177
|
+
|
|
178
|
+
**`POST /system-setup/init` returns 403 and `users` is empty.**
|
|
179
|
+
The claim is held. Check `db.getCollection('system-setup-locks').find()`. Either another instance is
|
|
180
|
+
running the setup, or a previous successful setup left the marker. Drop the collection, or wait five
|
|
181
|
+
minutes from when the claim was taken.
|
|
182
|
+
|
|
183
|
+
**A test asserting the 403 message fails after the update.**
|
|
184
|
+
Expected — see §1. Prefer asserting the error **code** (`LTNS_0050`), which is stable.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lenne.tech/nest-server",
|
|
3
|
-
"version": "11.36.
|
|
3
|
+
"version": "11.36.5",
|
|
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",
|
|
@@ -18,9 +18,15 @@ import { CoreAiMcpService } from './services/core-ai-mcp.service';
|
|
|
18
18
|
* Exposes the AI tool registry to external MCP clients. The request must carry a
|
|
19
19
|
* valid Bearer token (or session); the handler resolves the user via
|
|
20
20
|
* {@link CoreAiMcpController.resolveUser} (which reuses `req.user` and falls back to
|
|
21
|
-
* verifying the Bearer token directly
|
|
22
|
-
* `req.user`
|
|
23
|
-
*
|
|
21
|
+
* verifying the Bearer token directly). Since 11.36.5 `@Roles(S_EVERYONE)` DOES populate
|
|
22
|
+
* `req.user` — the guard identifies without denying — so the fallback is now a second line
|
|
23
|
+
* of defence rather than the only path. One consequence worth knowing: in a legacy-JWT
|
|
24
|
+
* deployment this endpoint previously answered 401 for everyone, because `req.user` was
|
|
25
|
+
* unset and no other resolution path applied; it now authenticates any holder of a valid
|
|
26
|
+
* legacy access token. That is not an elevation — the server is still built per user with
|
|
27
|
+
* the registry's role filter and the per-owner session binding — but it is a surface that
|
|
28
|
+
* was effectively unreachable before. The MCP session is bound to that user and only their
|
|
29
|
+
* permitted tools are exposed/executed.
|
|
24
30
|
*
|
|
25
31
|
* `@Roles(S_EVERYONE)` lets the request reach the handler (the guard would
|
|
26
32
|
* otherwise reject), and the handler performs the MCP-specific 401 with a
|
|
@@ -147,7 +147,14 @@ export class RolesGuard extends AuthGuard(AuthGuardStrategy.JWT) {
|
|
|
147
147
|
|
|
148
148
|
// If no roles required, or S_EVERYONE is set, allow access without authentication
|
|
149
149
|
// This allows public endpoints (without @Roles decorator or with S_EVERYONE) to work
|
|
150
|
+
//
|
|
151
|
+
// Access is granted either way, but the caller is still IDENTIFIED when they sent a valid
|
|
152
|
+
// token: returning early here left `request.user` unset, so a signed-in user hitting a
|
|
153
|
+
// public endpoint arrived as anonymous. Endpoints that are public AND personalise for
|
|
154
|
+
// signed-in callers — a search ranking by the caller's own location, a list marking the
|
|
155
|
+
// caller's own entries — silently lost that personalisation, with no error anywhere.
|
|
150
156
|
if (!roles || !roles.some((value) => !!value) || roles.includes(RoleEnum.S_EVERYONE)) {
|
|
157
|
+
await this.tryIdentifyOptionalUser(context);
|
|
151
158
|
return true;
|
|
152
159
|
}
|
|
153
160
|
|
|
@@ -339,6 +346,67 @@ export class RolesGuard extends AuthGuard(AuthGuardStrategy.JWT) {
|
|
|
339
346
|
return user;
|
|
340
347
|
}
|
|
341
348
|
|
|
349
|
+
/**
|
|
350
|
+
* Identify the caller on a PUBLIC endpoint without ever denying access.
|
|
351
|
+
*
|
|
352
|
+
* Mirrors the authentication order of the protected path (Better-Auth first, Passport JWT
|
|
353
|
+
* second) but treats every failure as "anonymous" instead of raising: a missing, expired or
|
|
354
|
+
* malformed token must not turn a public endpoint into a 401. It only sets `request.user`,
|
|
355
|
+
* so `@CurrentUser()` and `serviceOptions.currentUser` see a signed-in caller again.
|
|
356
|
+
*
|
|
357
|
+
* ANONYMOUS MUST STAY ABSENT. Passport's verify callback answers `false` — not `undefined` —
|
|
358
|
+
* when a request carries no usable credentials, and `MixinAuthGuard` assigns that verbatim.
|
|
359
|
+
* Without the normalisation at the end, every anonymous request to a public route would come
|
|
360
|
+
* out of here with `request.user === false` where it previously had no `user` at all. That is
|
|
361
|
+
* invisible to `!!user` and to `user?.id`, and visible to a handler that types the parameter
|
|
362
|
+
* `User`, tests `'user' in request`, or forwards it into `serviceOptions.currentUser`. Pinned
|
|
363
|
+
* by the `userType` assertions in tests/public-endpoint-identity.e2e-spec.ts.
|
|
364
|
+
*/
|
|
365
|
+
protected async tryIdentifyOptionalUser(context: ExecutionContext): Promise<void> {
|
|
366
|
+
try {
|
|
367
|
+
const request = this.getRequest(context);
|
|
368
|
+
if (!request || request.user) {
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
this.resolveServices();
|
|
373
|
+
|
|
374
|
+
if (this.betterAuthService?.isEnabled()) {
|
|
375
|
+
const user = await this.verifyBetterAuthTokenFromContext(context);
|
|
376
|
+
if (user) {
|
|
377
|
+
request.user = user;
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const result = super.canActivate(context);
|
|
383
|
+
if (isObservable(result)) {
|
|
384
|
+
await firstValueFrom(result);
|
|
385
|
+
} else {
|
|
386
|
+
await result;
|
|
387
|
+
}
|
|
388
|
+
} catch (error) {
|
|
389
|
+
// Never denies — but "the caller sent no usable token" and "the strategy itself failed" are
|
|
390
|
+
// not the same thing, and a bare `catch {}` makes them indistinguishable. A rotated secret,
|
|
391
|
+
// an unreachable database inside `validate()`, or a throwing user lookup would degrade every
|
|
392
|
+
// PUBLIC endpoint to anonymous while protected ones 401 — a split brain with nothing in the
|
|
393
|
+
// log to explain it. `debug`, not `warn`: the ordinary case (no credentials, expired token)
|
|
394
|
+
// reaches here too and must not be noise.
|
|
395
|
+
this.logger.debug(
|
|
396
|
+
`Optional identification failed on a public endpoint: ${
|
|
397
|
+
error instanceof Error ? error.message : 'Unknown error'
|
|
398
|
+
}`,
|
|
399
|
+
);
|
|
400
|
+
} finally {
|
|
401
|
+
// `finally`, not the try body: the catch above swallows a throw from Passport, and that
|
|
402
|
+
// path can have assigned a falsy user before throwing.
|
|
403
|
+
const request = this.getRequest(context);
|
|
404
|
+
if (request && !request.user && 'user' in request) {
|
|
405
|
+
delete request.user;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
342
410
|
/**
|
|
343
411
|
* Integrate request from GraphQL
|
|
344
412
|
*/
|
|
@@ -468,12 +468,22 @@ export const LtnsErrors = {
|
|
|
468
468
|
// System Setup Errors (LTNS_0050-LTNS_0059)
|
|
469
469
|
// =====================================================
|
|
470
470
|
|
|
471
|
+
/**
|
|
472
|
+
* Raised by BOTH guards in `createInitialAdmin()`, which is why the message names both.
|
|
473
|
+
*
|
|
474
|
+
* The first guard refuses because users exist. The second refuses because the initial-admin
|
|
475
|
+
* claim in `system-setup-locks` is held — and that marker is removed only when creation FAILS,
|
|
476
|
+
* so it OUTLIVES a successful setup by design. A test suite that resets the database by
|
|
477
|
+
* emptying `users` therefore still hits the claim, and until 11.36.5 was told "users already
|
|
478
|
+
* exist" while the users collection was empty. That mismatch cost a consumer project a full CI
|
|
479
|
+
* cycle to diagnose, so the message now covers both conditions.
|
|
480
|
+
*/
|
|
471
481
|
SYSTEM_SETUP_NOT_AVAILABLE: {
|
|
472
482
|
code: 'LTNS_0050',
|
|
473
|
-
message: 'System setup not available - users already exist',
|
|
483
|
+
message: 'System setup not available - users already exist, or the initial-admin setup is claimed',
|
|
474
484
|
translations: {
|
|
475
|
-
de: 'System-Setup nicht verfügbar - es existieren bereits Benutzer.',
|
|
476
|
-
en: 'System setup not available - users already exist.',
|
|
485
|
+
de: 'System-Setup nicht verfügbar - es existieren bereits Benutzer, oder das Initial-Admin-Setup ist bereits beansprucht.',
|
|
486
|
+
en: 'System setup not available - users already exist, or the initial-admin setup is claimed.',
|
|
477
487
|
},
|
|
478
488
|
},
|
|
479
489
|
|
|
@@ -92,13 +92,14 @@ export class SystemSetupController extends CoreSystemSetupController {
|
|
|
92
92
|
|
|
93
93
|
## Common Mistakes
|
|
94
94
|
|
|
95
|
-
| Mistake | Symptom | Fix
|
|
96
|
-
| ------------------------------------------- | -------------------------------- |
|
|
97
|
-
| BetterAuth not enabled | 404 on endpoints or 403 on init | Ensure `betterAuth` is configured
|
|
98
|
-
| Calling init with existing users | 403 "System setup not available" | Init
|
|
99
|
-
|
|
|
100
|
-
|
|
|
101
|
-
|
|
|
95
|
+
| Mistake | Symptom | Fix |
|
|
96
|
+
| ------------------------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
97
|
+
| BetterAuth not enabled | 404 on endpoints or 403 on init | Ensure `betterAuth` is configured |
|
|
98
|
+
| Calling init with existing users | 403 "System setup not available" | Init requires an empty `users` collection |
|
|
99
|
+
| Re-running init after a successful setup | 403, even with an empty `users` | The claim marker in `system-setup-locks` outlives a SUCCESSFUL setup (it is released only on failure) — drop that collection too, or wait 5 min for it to go stale |
|
|
100
|
+
| Password too short | 400 validation error | Password must be at least 8 characters |
|
|
101
|
+
| Missing ENV password | Auto-creation silently skipped | Set both `email` and `password` ENV vars |
|
|
102
|
+
| `systemSetup: { enabled: false }` in config | 404 on endpoints | Remove the explicit disable |
|
|
102
103
|
|
|
103
104
|
---
|
|
104
105
|
|
|
@@ -94,7 +94,7 @@ Creates the initial admin user. Only works when zero users exist.
|
|
|
94
94
|
|
|
95
95
|
```json
|
|
96
96
|
{
|
|
97
|
-
"message": "LTNS_0050: System setup not available - users already exist"
|
|
97
|
+
"message": "LTNS_0050: System setup not available - users already exist, or the initial-admin setup is claimed"
|
|
98
98
|
}
|
|
99
99
|
```
|
|
100
100
|
|
|
@@ -169,6 +169,9 @@ NEST_SERVER_CONFIG='{ "systemSetup": { "initialAdmin": { "email": "admin@example
|
|
|
169
169
|
instance claims the setup by upserting the marker `{ _id: 'initial-admin' }` into the
|
|
170
170
|
`system-setup-locks` collection — an atomic operation only one instance wins. The others log a
|
|
171
171
|
debug message and skip. If creation fails, the marker is removed again so a later boot can retry.
|
|
172
|
+
After a SUCCESSFUL setup the marker REMAINS — setup must never run twice in a deployment. Test
|
|
173
|
+
suites that reset the database to replay the setup flow therefore have to drop `system-setup-locks`
|
|
174
|
+
alongside `users`; clearing only `users` leaves setup refused until the claim goes stale (5 min).
|
|
172
175
|
|
|
173
176
|
### Security Best Practices
|
|
174
177
|
|
|
@@ -181,7 +184,9 @@ NEST_SERVER_CONFIG='{ "systemSetup": { "initialAdmin": { "email": "admin@example
|
|
|
181
184
|
|
|
182
185
|
## Security
|
|
183
186
|
|
|
184
|
-
1. **Zero-user guard** - Init
|
|
187
|
+
1. **Zero-user guard** - Init requires `countDocuments({}) === 0`. Necessary but not sufficient:
|
|
188
|
+
the atomic claim below must also be winnable, so an empty `users` collection alone does not
|
|
189
|
+
re-open setup
|
|
185
190
|
2. **Enabled by default** - Safe because endpoints are permanently locked once any user exists
|
|
186
191
|
3. **Race condition protection** - MongoDB unique email index prevents duplicates; auto-creation on
|
|
187
192
|
bootstrap is additionally claimed atomically via the `system-setup-locks` marker
|
|
@@ -226,13 +231,42 @@ if (needsSetup) {
|
|
|
226
231
|
|
|
227
232
|
### Init returns 403 "System setup not available"
|
|
228
233
|
|
|
229
|
-
**
|
|
234
|
+
**Two different guards raise this, and the response cannot tell you which.**
|
|
230
235
|
|
|
231
|
-
**
|
|
236
|
+
**Cause 1 - users already exist.** The ordinary case on a deployment that is already set up.
|
|
232
237
|
|
|
233
238
|
1. Check `GET /system-setup/status` - `needsSetup` should be `true`
|
|
234
239
|
2. If this is a fresh deployment, verify the database is empty
|
|
235
240
|
|
|
241
|
+
**Cause 2 - the initial-admin setup is claimed.** The marker `{ _id: 'initial-admin' }` in
|
|
242
|
+
`system-setup-locks` is held. Either another instance is running the setup right now, or a previous
|
|
243
|
+
SUCCESSFUL setup left it behind: it is removed only when creation FAILS.
|
|
244
|
+
|
|
245
|
+
This is the one that costs time, because the obvious checks all point the other way. `needsSetup` is
|
|
246
|
+
computed from the **user count alone**, so it reports `true` while init is refused - step 1 above
|
|
247
|
+
confirms a state that is not the problem, and step 2 confirms an empty database that is genuinely
|
|
248
|
+
empty.
|
|
249
|
+
|
|
250
|
+
1. Look at the server log. Since 11.36.5 the service logs a warning naming the collection, the
|
|
251
|
+
release-on-failure semantics and the stale window.
|
|
252
|
+
2. Check the collection directly: `db.getCollection('system-setup-locks').find()`
|
|
253
|
+
3. If you reset the database to replay the setup, drop `system-setup-locks` alongside `users` -
|
|
254
|
+
see **Resetting for tests** below. Otherwise wait for the claim to go stale (5 minutes after it
|
|
255
|
+
was taken).
|
|
256
|
+
|
|
257
|
+
### Resetting for tests
|
|
258
|
+
|
|
259
|
+
Emptying `users` is not enough - the claim outlives a successful setup. The framework's own reset,
|
|
260
|
+
executed on every test run in `tests/stories/system-setup.e2e-spec.ts`:
|
|
261
|
+
|
|
262
|
+
```typescript
|
|
263
|
+
// deleteMany, not drop: dropping `users` would drop its unique email index,
|
|
264
|
+
// which is what prevents a duplicate admin.
|
|
265
|
+
for (const col of ['users', 'account', 'session', 'system-setup-locks']) {
|
|
266
|
+
await db.collection(col).deleteMany({});
|
|
267
|
+
}
|
|
268
|
+
```
|
|
269
|
+
|
|
236
270
|
### Init returns 403 "System setup requires BetterAuth"
|
|
237
271
|
|
|
238
272
|
**Cause:** BetterAuth is not configured or not enabled.
|
|
@@ -265,11 +299,11 @@ if (needsSetup) {
|
|
|
265
299
|
|
|
266
300
|
## Error Codes
|
|
267
301
|
|
|
268
|
-
| Code | Key | Description
|
|
269
|
-
| --------- | ---------------------------------- |
|
|
270
|
-
| LTNS_0050 | `SYSTEM_SETUP_NOT_AVAILABLE` | Users already exist, setup
|
|
271
|
-
| LTNS_0051 | `SYSTEM_SETUP_DISABLED` | System setup is disabled in config
|
|
272
|
-
| LTNS_0052 | `SYSTEM_SETUP_BETTERAUTH_REQUIRED` | BetterAuth must be enabled
|
|
302
|
+
| Code | Key | Description |
|
|
303
|
+
| --------- | ---------------------------------- | ---------------------------------------------------------- |
|
|
304
|
+
| LTNS_0050 | `SYSTEM_SETUP_NOT_AVAILABLE` | Users already exist, OR the initial-admin setup is claimed |
|
|
305
|
+
| LTNS_0051 | `SYSTEM_SETUP_DISABLED` | System setup is disabled in config |
|
|
306
|
+
| LTNS_0052 | `SYSTEM_SETUP_BETTERAUTH_REQUIRED` | BetterAuth must be enabled |
|
|
273
307
|
|
|
274
308
|
---
|
|
275
309
|
|
|
@@ -60,7 +60,10 @@ export class CoreSystemSetupController {
|
|
|
60
60
|
summary: 'Create initial admin',
|
|
61
61
|
})
|
|
62
62
|
@ApiResponse({ description: 'Initial admin created', status: 201 })
|
|
63
|
-
@ApiResponse({
|
|
63
|
+
@ApiResponse({
|
|
64
|
+
description: 'System setup not available - users already exist, or the initial-admin setup is claimed',
|
|
65
|
+
status: 403,
|
|
66
|
+
})
|
|
64
67
|
@Post('init')
|
|
65
68
|
@Roles(RoleEnum.S_EVERYONE)
|
|
66
69
|
async createInitialAdmin(@Body() input: SystemSetupInitDto): Promise<SystemSetupInitResult> {
|
|
@@ -239,6 +239,27 @@ export class CoreSystemSetupService implements OnApplicationBootstrap {
|
|
|
239
239
|
// racing a fresh deployment can no longer obtain an admin account ALONGSIDE the
|
|
240
240
|
// configured one.
|
|
241
241
|
if (!(await this.claimInitialAdminSetup())) {
|
|
242
|
+
// Say WHICH guard refused. This one and the user-count guard above share
|
|
243
|
+
// ErrorCode.SYSTEM_SETUP_NOT_AVAILABLE, and the response cannot tell them apart — so
|
|
244
|
+
// without this line a caller that just emptied `users` is told users exist while the
|
|
245
|
+
// collection is empty. That is a genuinely expensive thing to diagnose from the outside:
|
|
246
|
+
// the marker outlives a SUCCESSFUL setup (it is released only on failure), so the usual
|
|
247
|
+
// "reset the database" reflex does not clear it.
|
|
248
|
+
// `log`, not `warn`. Both entry points reach this line, and for one of them a refusal is
|
|
249
|
+
// the EXPECTED outcome: on a multi-replica fresh rollout every replica but one loses the
|
|
250
|
+
// claim race, so `warn` would fire N-1 times per normal deployment — each time immediately
|
|
251
|
+
// followed by onApplicationBootstrap's own INFO line calling the same event a normal skip.
|
|
252
|
+
// Two records with contradictory severity for one non-event trains operators to ignore the
|
|
253
|
+
// louder one. The HTTP caller is not left without a diagnosis: the message is unchanged and
|
|
254
|
+
// an operator investigating a 403 reads it at the default level.
|
|
255
|
+
this.logger.log(
|
|
256
|
+
`System setup refused: the initial-admin claim in "${SETUP_LOCK_COLLECTION}" is held. ` +
|
|
257
|
+
'Either another instance is running the setup right now, or a previous SUCCESSFUL setup ' +
|
|
258
|
+
'left the marker behind — it is removed only when creation FAILS. If you reset the ' +
|
|
259
|
+
`database to replay the setup, drop "${SETUP_LOCK_COLLECTION}" alongside "users"; ` +
|
|
260
|
+
`otherwise setup stays refused until the claim goes stale after ` +
|
|
261
|
+
`${INITIAL_ADMIN_CLAIM_STALE_AFTER_MS / 60_000} minutes.`,
|
|
262
|
+
);
|
|
242
263
|
throw new ForbiddenException(ErrorCode.SYSTEM_SETUP_NOT_AVAILABLE);
|
|
243
264
|
}
|
|
244
265
|
|