@lenne.tech/nest-server 11.42.12 → 11.42.14

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.
Files changed (37) hide show
  1. package/.claude/rules/testing.md +4 -4
  2. package/FRAMEWORK-API.md +1 -1
  3. package/dist/core/modules/auth/guards/auth.guard.js +14 -0
  4. package/dist/core/modules/auth/guards/auth.guard.js.map +1 -1
  5. package/dist/core/modules/error-code/error-codes.d.ts +45 -0
  6. package/dist/core/modules/error-code/error-codes.js +40 -0
  7. package/dist/core/modules/error-code/error-codes.js.map +1 -1
  8. package/dist/core/modules/file/core-file.service.d.ts +1 -0
  9. package/dist/core/modules/file/core-file.service.js +27 -0
  10. package/dist/core/modules/file/core-file.service.js.map +1 -1
  11. package/dist/core/modules/tenant/core-tenant.guard.js +7 -7
  12. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
  13. package/dist/core/modules/tus/core-tus.service.d.ts +1 -0
  14. package/dist/core/modules/tus/core-tus.service.js +10 -4
  15. package/dist/core/modules/tus/core-tus.service.js.map +1 -1
  16. package/dist/core/modules/tus/tus.constants.d.ts +1 -0
  17. package/dist/core/modules/tus/tus.constants.js +2 -1
  18. package/dist/core/modules/tus/tus.constants.js.map +1 -1
  19. package/dist/server/modules/error-code/error-codes.d.ts +5 -0
  20. package/dist/tsconfig.build.tsbuildinfo +1 -1
  21. package/docs/REQUEST-LIFECYCLE.md +9 -1
  22. package/docs/error-codes.md +70 -0
  23. package/migration-guides/11.42.12-to-11.42.13.md +154 -0
  24. package/migration-guides/11.42.13-to-11.42.14.md +74 -0
  25. package/package.json +3 -3
  26. package/src/core/modules/api-token/README.md +5 -0
  27. package/src/core/modules/auth/guards/auth.guard.ts +31 -1
  28. package/src/core/modules/error-code/error-codes.ts +49 -0
  29. package/src/core/modules/file/README.md +1 -0
  30. package/src/core/modules/file/core-file.service.ts +39 -0
  31. package/src/core/modules/tenant/INTEGRATION-CHECKLIST.md +2 -2
  32. package/src/core/modules/tenant/README.md +17 -1
  33. package/src/core/modules/tenant/core-tenant.guard.ts +7 -7
  34. package/src/core/modules/tus/INTEGRATION-CHECKLIST.md +2 -1
  35. package/src/core/modules/tus/README.md +42 -3
  36. package/src/core/modules/tus/core-tus.service.ts +32 -6
  37. package/src/core/modules/tus/tus.constants.ts +11 -0
@@ -167,7 +167,7 @@ JWT-based authentication for existing projects:
167
167
  | **REST Endpoints** | `GET /files/id/:id`, `GET /files/:filename` (core, gated by `file.downloadRoles`, default ADMIN); `POST /files/upload`, `DELETE /files/:id` (project-specific) |
168
168
  | **GraphQL Endpoints** | `getFileInfo` (`file.downloadRoles`), `uploadFile` / `uploadFiles` (`file.uploadRoles`), `deleteFile` (`file.deleteRoles`) — all default ADMIN |
169
169
  | **File access control** | Roles are the coarse filter; per-file rules go in `CoreFileService.checkRights()` using metadata written at upload time. Cover BOTH the `id` and the `filename` branch — the filename route authorizes on the by-name lookup alone when presigned S3 downloads are on, and `deleteFileByName()` always does. Working reference: `src/server/modules/file/file.service.ts` (with `file.downloadRoles: [S_USER]` in `src/config.env.ts`, so the rule is actually reached). Both file classes carry `@SkipTenantCheck()` — GridFS is not tenant-scoped |
170
- | **TUS Module** | Resumable uploads via tus.io protocol (creation, termination, expiration), gated by `tus.roles` (default `S_USER`); `OPTIONS` stays public for the CORS preflight. Records owner and, with `multiTenancy` only, the validated tenant at upload creation; a custom service plugs in via `TusModule.forRoot({ service })`. Configured by `TusModule.forRoot({ config })`, else the server config's `tus` key (11.42.9+). Switched-off `creation` / `termination` answer 501 (enforced by `CoreTusService`, not `@tus/server`); upload URLs follow the path the upload was created at |
170
+ | **TUS Module** | Resumable uploads via tus.io protocol (creation, termination, expiration), gated by `tus.roles` (default `S_USER`); `OPTIONS` stays public for the CORS preflight. Records owner and, with `multiTenancy` only, the validated tenant at upload creation; a custom service plugs in via `TusModule.forRoot({ service })`. Configured by `TusModule.forRoot({ config })`, else the server config's `tus` key (11.42.9+). Switched-off `creation` / `termination` answer 501 (enforced by `CoreTusService`, not `@tus/server`); upload URLs follow the path the upload was created at. The response that completes an upload carries the stored file's ID as `X-File-Id` (11.42.13+, listed in `Access-Control-Expose-Headers`) — the last segment of the TUS URL is the upload id, not the file id |
171
171
  | **GridFS Migration** | Completed TUS uploads auto-migrate to GridFS |
172
172
  | **CORS Support** | Automatic CORS headers for browser uploads |
173
173
 
@@ -781,6 +781,14 @@ their public-route shortcut, so the policy holds whichever guard runs first or a
781
781
  4. A request that carries a token credential but reaches a guard without a token context (the
782
782
  middleware did not run) is refused with 401 rather than treated as anonymous.
783
783
 
784
+ A route-level `@UseGuards(AuthGuard(AuthGuardStrategy.JWT | BETTER_AUTH))` (redundant next to
785
+ `@Roles()`, but common — nest-server-starter's `UserController` carries it on six routes) accepts the
786
+ token `CoreApiTokenMiddleware` authenticated instead of re-verifying the `ltt_…` credential, which
787
+ neither Passport nor `BetterAuthTokenService` knows; before 11.42.14 that re-verification answered
788
+ 401 on every released route. It calls `enforceApiTokenRoute()` itself, so it never opens a route the
789
+ role guards would refuse, even where it runs without one. `JWT_REFRESH` and project-defined strategies
790
+ never accept a token.
791
+
784
792
  #### System Roles (S_ prefix)
785
793
 
786
794
  System roles are evaluated at runtime and must **never** be stored in `user.roles`.
@@ -162,6 +162,76 @@ All error codes follow the format: `#PREFIX_XXXX: Description`
162
162
 
163
163
  ---
164
164
 
165
+ ### LTNS_0103: notTenantMember
166
+
167
+ **Message:** Not a member of this tenant
168
+
169
+ **Description:** Thrown by the CoreTenantGuard when the `X-Tenant-Id` header names a tenant the user has no active membership in
170
+
171
+ **Solution:** Send the header of a tenant the user belongs to, or add the membership
172
+
173
+ **Translations:**
174
+ - DE: Sie sind kein Mitglied dieses Mandanten.
175
+ - EN: You are not a member of this tenant.
176
+
177
+ ---
178
+
179
+ ### LTNS_0104: insufficientTenantRole
180
+
181
+ **Message:** Insufficient tenant role
182
+
183
+ **Description:** Thrown by the CoreTenantGuard when the membership role in the header's tenant does not satisfy the required tenant role
184
+
185
+ **Solution:** Raise the membership role or use an endpoint with a lower requirement
186
+
187
+ **Translations:**
188
+ - DE: Ihre Rolle in diesem Mandanten reicht für diese Aktion nicht aus.
189
+ - EN: Your role in this tenant does not allow this action.
190
+
191
+ ---
192
+
193
+ ### LTNS_0105: insufficientRole
194
+
195
+ **Message:** Insufficient role
196
+
197
+ **Description:** Thrown by the CoreTenantGuard when `user.roles` does not meet the required role — without a tenant header, or on a `@SkipTenantCheck` route
198
+
199
+ **Solution:** Grant the role, or send the tenant header so the membership role is checked
200
+
201
+ **Translations:**
202
+ - DE: Ihre Rolle reicht für diese Aktion nicht aus.
203
+ - EN: Your role does not allow this action.
204
+
205
+ ---
206
+
207
+ ### LTNS_0106: tenantInactive
208
+
209
+ **Message:** Tenant is inactive
210
+
211
+ **Description:** Thrown by the CoreTenantGuard when `multiTenancy.isTenantActive` reports the header's tenant as switched off
212
+
213
+ **Solution:** Reactivate the tenant, then call `invalidateTenant(tenantId)`
214
+
215
+ **Translations:**
216
+ - DE: Dieser Mandant ist deaktiviert.
217
+ - EN: This tenant is deactivated.
218
+
219
+ ---
220
+
221
+ ### LTNS_0107: verificationRequired
222
+
223
+ **Message:** Verification required
224
+
225
+ **Description:** Thrown by the CoreTenantGuard on an `S_VERIFIED` endpoint for a user who is not verified
226
+
227
+ **Solution:** Complete the verification of the account
228
+
229
+ **Translations:**
230
+ - DE: Für diese Aktion muss Ihr Konto verifiziert sein.
231
+ - EN: Your account must be verified for this action.
232
+
233
+ ---
234
+
165
235
  ## User Errors (LTNS_0200-LTNS_0299)
166
236
 
167
237
  ### LTNS_0200: emailAlreadyExists
@@ -0,0 +1,154 @@
1
+ # Migration Guide: 11.42.12 → 11.42.13
2
+
3
+ ## Overview
4
+
5
+ | Category | Details |
6
+ | -------------------- | -------------------------------------------------------------------------------------------------------- |
7
+ | **Bugfixes** | `CoreTenantGuard` refusals carry translatable error codes (§1); `GET /files/id/:id` answers 404 instead of 500 for an id that is not an ObjectId (§3) |
8
+ | **New Features** | A finished TUS upload names the stored file in the `X-File-Id` response header (§2) |
9
+ | **Framework API** | Additive only: `ErrorCode.NOT_TENANT_MEMBER` … `VERIFICATION_REQUIRED` (`LTNS_0103`–`LTNS_0107`), `TUS_FILE_ID_HEADER`, `CoreFileService.isMalformedFileId()` (protected) |
10
+ | **Dependencies** | None for the package |
11
+ | **Breaking Changes** | None. Status codes and access decisions are unchanged; only the message text of five tenant refusals gains a prefix (§1) |
12
+ | **Migration Effort** | None for most projects. Check §1 if you compare a tenant refusal message for exact equality, and §2 if your frontend derives a file id from the TUS URL |
13
+
14
+ ## Quick Migration
15
+
16
+ ```bash
17
+ pnpm update @lenne.tech/nest-server
18
+ ```
19
+
20
+ Vendor-mode projects pick it up with the core update. No code or configuration change is required unless one of
21
+ the checks below applies to you.
22
+
23
+ ## 1. Tenant guard refusals are translatable
24
+
25
+ Five of the guard's 403 refusals were thrown as plain English strings. Without the `#LTNS_XXXX:` prefix the
26
+ frontend error translation could not map them, so users saw the raw English text in a toast.
27
+
28
+ | Code | Message (unchanged wording) | Refused because |
29
+ | ----------- | ----------------------------- | ---------------------------------------------------------------------------------------- |
30
+ | `LTNS_0103` | `Not a member of this tenant` | the tenant header names a tenant the user has no active membership in |
31
+ | `LTNS_0104` | `Insufficient tenant role` | the membership role does not satisfy the required tenant role |
32
+ | `LTNS_0105` | `Insufficient role` | `user.roles` does not meet the required role (no header, or a `@SkipTenantCheck` route) |
33
+ | `LTNS_0106` | `Tenant is inactive` | `multiTenancy.isTenantActive` reports the header's tenant as switched off |
34
+ | `LTNS_0107` | `Verification required` | an `S_VERIFIED` endpoint, and the user is not verified |
35
+
36
+ The response `message` is now e.g. `#LTNS_0103: Not a member of this tenant`. Status code (403), exception class
37
+ and the decision itself are unchanged. German and English translations ship in the error catalog
38
+ (`GET /i18n/errors/:locale`), so `nuxt-extensions` translates them without any change.
39
+
40
+ **What to check:**
41
+
42
+ - **You compare the message for exact equality** (`toBe('Insufficient role')`, `message === '…'`): switch to a
43
+ substring match or, better, to the code:
44
+
45
+ ```typescript
46
+ // Before
47
+ expect(res.body.message).toBe('Not a member of this tenant');
48
+
49
+ // After
50
+ expect(res.body.message).toBe(ErrorCode.NOT_TENANT_MEMBER);
51
+ // or, tolerant of both versions
52
+ expect(res.body.message).toContain('Not a member of this tenant');
53
+ ```
54
+
55
+ Substring matches (`toContain`, log filters) keep working unchanged — the original wording stays behind the
56
+ code.
57
+ - **Your product does not call them tenants:** override the translations in your own error registry
58
+ (`errorCode.additionalErrorRegistry`). It is registered after the core, and translations are merged per code,
59
+ so your entry for `LTNS_0103` wins.
60
+
61
+ ## 2. A finished TUS upload names the stored file (`X-File-Id`)
62
+
63
+ Until now a client knew only the TUS URL after an upload. Its last segment is the TUS **upload** id — a key of the
64
+ staging store, not of the file store — and `/files/id/<upload-id>` cannot answer with the file. The response that
65
+ completes an upload now carries the ID of the stored file:
66
+
67
+ ```
68
+ HTTP/1.1 204 No Content
69
+ Upload-Offset: 52428800
70
+ X-File-Id: 6720c1f0a2b4c8e9d0f1a2b3
71
+ Access-Control-Expose-Headers: …, X-File-Id
72
+ ```
73
+
74
+ - Sent on the completing request only: the last `PATCH`, or the `POST` of a creation-with-upload. Creation and
75
+ partial `PATCH`es store nothing yet and carry no header.
76
+ - Listed in `Access-Control-Expose-Headers`, so a browser on a separate app origin can read it.
77
+ - The name is exported as `TUS_FILE_ID_HEADER`.
78
+
79
+ With `tus-js-client`:
80
+
81
+ ```typescript
82
+ let fileId: string | undefined;
83
+ const upload = new Upload(file, {
84
+ endpoint: '/tus',
85
+ onAfterResponse: (_req, res) => {
86
+ const id = res.getHeader('X-File-Id');
87
+ if (id) {
88
+ fileId = id;
89
+ }
90
+ },
91
+ onSuccess: () => {
92
+ // use `/files/id/${fileId}` — NOT the last segment of `upload.url`
93
+ },
94
+ });
95
+ ```
96
+
97
+ **What to check:**
98
+
99
+ - **Your frontend takes the last segment of the TUS URL as the file id:** read `X-File-Id` instead. Treat a
100
+ missing header (an older server, see the next point) as "no file id known" — never fall back to the URL.
101
+ - **You override `onUploadComplete()`:** nothing to change as long as the override calls
102
+ `super.onUploadComplete(upload)` with the `upload` it received — both `await super…` and `return super…` keep
103
+ compiling and keep the header. An override that never calls `super` (it stores the file itself) completes the
104
+ upload without `X-File-Id`. The method's signature did not change.
105
+
106
+ ## 3. `GET /files/id/:id` answers 404 for an id that is not an ObjectId
107
+
108
+ An id that is not a 24-character hex string — typically a TUS upload id (32 hex characters) taken for the file id,
109
+ see §2 — reached `new ObjectId()` and threw a `BSONError`, so the route answered **500**. It now answers exactly
110
+ like an unknown id: `404` with `ErrorCode.FILE_NOT_FOUND`.
111
+
112
+ The guard sits in `CoreFileService` (`getFileInfo`, `resolveFile`, `getFileStream`, `getBuffer`,
113
+ `getDownloadUrl`, `getRawFileInfo`, `deleteFile`, `duplicateById`), so every route built on those methods
114
+ benefits — including a project's own `GET /files/info/:id` / `DELETE /files/:id`. Only strings are judged; an
115
+ `ObjectId` instance keeps its previous path.
116
+
117
+ **What to check:**
118
+
119
+ - **Your files use ids that are not ObjectIds** (unusual — every core store keys by ObjectId): override the new
120
+ protected `isMalformedFileId(id)` in your `FileService` to accept them.
121
+ - **A monitor or test expected the 500:** it now gets 404.
122
+
123
+ ## Compatibility Notes
124
+
125
+ | Pattern | Status |
126
+ | ------- | ------ |
127
+ | npm-mode and vendor-mode projects without tenant-message comparisons or TUS overrides | Nothing to do |
128
+ | Tests or frontends matching tenant refusals with `toContain` / a substring | Compatible |
129
+ | Tests comparing a tenant refusal message with `toBe` / `===` | Compare against `ErrorCode.*` (§1) |
130
+ | `TusService extends CoreTusService` overriding `onUploadComplete()` and calling `super` | Compatible, header included |
131
+ | `onUploadComplete()` override that does not call `super` | Compatible, no `X-File-Id` (§2) |
132
+ | `FileService extends CoreFileService` with a `checkRights()` that reads `getRawFileInfo()` | Compatible; a malformed id now reaches it as `null` |
133
+
134
+ ## Troubleshooting
135
+
136
+ **The browser never sees `X-File-Id` although the server sends it**
137
+ The header is exposed by the tus server itself. If a proxy rewrites `Access-Control-Expose-Headers`, add
138
+ `X-File-Id` there as well.
139
+
140
+ **`X-File-Id` is missing on the completing response**
141
+ Check for an `onUploadComplete()` override that does not call `super.onUploadComplete(upload)`, or that passes it a
142
+ copy of `upload` (§2).
143
+
144
+ **A tenant refusal shows in English although the frontend translates errors**
145
+ The frontend translation loads the catalog from `GET /i18n/errors/:locale`. Make sure the error-code module is
146
+ registered (it is by default) and the catalog contains `LTNS_0103`–`LTNS_0107`.
147
+
148
+ ## Module Documentation
149
+
150
+ - [Tenant module README](../src/core/modules/tenant/README.md) — "Refusal Codes"
151
+ - [Error codes](../docs/error-codes.md) — `LTNS_0103`–`LTNS_0107`
152
+ - [TUS module README](../src/core/modules/tus/README.md) — "The ID of the Stored File", "Custom Upload Handler"
153
+ - [TUS integration checklist](../src/core/modules/tus/INTEGRATION-CHECKLIST.md)
154
+ - [File module README](../src/core/modules/file/README.md) — "Error responses"
@@ -0,0 +1,74 @@
1
+ # Migration Guide: 11.42.13 → 11.42.14
2
+
3
+ ## Overview
4
+
5
+ | Category | Details |
6
+ | -------------------- | -------------------------------------------------------------------------------------------------------- |
7
+ | **Bugfixes** | A route-level `@UseGuards(AuthGuard(...))` no longer refuses every API token with 401 on a route released via `@ApiTokenScopes()` (§1) |
8
+ | **New Features** | None |
9
+ | **Framework API** | Unchanged — no new exports, no changed signatures |
10
+ | **Dependencies** | None for the package (development tooling only) |
11
+ | **Breaking Changes** | None. Tokens on routes that do not release them are refused exactly as before |
12
+ | **Migration Effort** | None. Projects that removed an `AuthGuard` to make tokens work may keep it removed (§1) |
13
+
14
+ ## Quick Migration
15
+
16
+ ```bash
17
+ pnpm update @lenne.tech/nest-server
18
+ ```
19
+
20
+ Vendor-mode projects pick it up with the core update. No code or configuration change is required.
21
+
22
+ ## 1. An explicit `AuthGuard` no longer closes a released route to API tokens
23
+
24
+ Many projects put `@UseGuards(AuthGuard(AuthGuardStrategy.BETTER_AUTH))` (or `AuthGuard(AuthGuardStrategy.JWT)`) on
25
+ routes next to `@Roles()`. nest-server-starter's `UserController` does so on six routes. The guard is redundant
26
+ there, because `@Roles()` already authenticates, but it was not harmless: it re-verified every request unless the
27
+ user came from Better-Auth, and a user authenticated by an API token does not. So it read the `ltt_…` credential as a
28
+ session or a JWT, failed, and answered **401** — on a route the role guard had just admitted via
29
+ `@ApiTokenScopes()`. Opening such a route for tokens therefore never worked while the guard was on it.
30
+
31
+ The guard now accepts the token `CoreApiTokenMiddleware` already authenticated, for the `JWT` and `BETTER_AUTH`
32
+ strategies. It applies the same route policy as the role guards (`enforceApiTokenRoute()`: deny by default, scopes,
33
+ tenant binding) itself, so it never admits a token on a route that does not release it — also in a setup where it
34
+ runs without a framework role guard in front of it.
35
+
36
+ ```typescript
37
+ @ApiTokenScopes('read')
38
+ @Get(':id')
39
+ @Roles(RoleEnum.S_USER)
40
+ @UseGuards(AuthGuard(AuthGuardStrategy.BETTER_AUTH)) // 11.42.13: 401 for every token; 11.42.14: works
41
+ async getUser(@CurrentUser() currentUser: User, @Param('id') id: string): Promise<User> { … }
42
+ ```
43
+
44
+ `AuthGuard(AuthGuardStrategy.JWT_REFRESH)`, `RefreshTokenGuard` and project-defined strategies never accept a token.
45
+
46
+ **What to check:**
47
+
48
+ - **You removed an `AuthGuard` from a route to make tokens work:** nothing to do. The guard stays redundant next to
49
+ `@Roles()`; restoring it is optional.
50
+ - **A test expected 401 for a token on a released route that carries an `AuthGuard`:** it now gets the route's
51
+ answer. A token on a route WITHOUT `@ApiTokenScopes()` is still refused with 403, as before.
52
+
53
+ ## Compatibility Notes
54
+
55
+ | Pattern | Status |
56
+ | ------- | ------ |
57
+ | Routes with `@Roles()` only | Unchanged |
58
+ | Routes with `@Roles()` + `@UseGuards(AuthGuard(BETTER_AUTH \| JWT))` + `@ApiTokenScopes()` | Tokens now pass (§1) |
59
+ | Routes with `@UseGuards(AuthGuard(...))` but no `@ApiTokenScopes()` | Tokens refused with 403, as before |
60
+ | `refreshToken` / `AuthGuard(JWT_REFRESH)` / custom Passport strategies | Never accept a token, as before |
61
+ | A project guard that extends `AuthGuard(...)` and overrides `handleRequest()` | Compatible; the override also runs for token users |
62
+
63
+ ## Troubleshooting
64
+
65
+ **A token still gets 401 on a route with an `AuthGuard`**
66
+ Check that the route (or its class) declares `@ApiTokenScopes()` with a scope the token holds — without it, the role
67
+ guard refuses the token with 403 before the `AuthGuard` runs. A 401 means the token itself did not authenticate
68
+ (revoked, expired, wrong prefix); the token middleware answers that before any guard.
69
+
70
+ ## Module Documentation
71
+
72
+ - [API token module README](../src/core/modules/api-token/README.md) — "Opening a route"
73
+ - [API token integration checklist](../src/core/modules/api-token/INTEGRATION-CHECKLIST.md)
74
+ - [Request lifecycle](../docs/REQUEST-LIFECYCLE.md) — "API tokens (`@ApiTokenScopes`)"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "11.42.12",
3
+ "version": "11.42.14",
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",
@@ -192,7 +192,7 @@
192
192
  "@nestjs/schematics": "11.1.0",
193
193
  "@nestjs/testing": "11.2.7",
194
194
  "@swc/cli": "0.8.1",
195
- "@swc/core": "1.16.13",
195
+ "@swc/core": "1.16.18",
196
196
  "@tus/s3-store": "2.0.7",
197
197
  "@types/compression": "1.8.1",
198
198
  "@types/cookie-parser": "1.4.10",
@@ -213,7 +213,7 @@
213
213
  "oxfmt": "0.71.0",
214
214
  "oxlint": "1.87.0",
215
215
  "rimraf": "6.1.3",
216
- "smtp-server": "3.19.19",
216
+ "smtp-server": "3.19.21",
217
217
  "ts-node": "10.9.2",
218
218
  "tsconfig-paths": "4.2.0",
219
219
  "tsx": "4.23.15",
@@ -60,6 +60,11 @@ What an opened route means per kind:
60
60
  membership role. Losing a membership removes the token's access at once — rights are read per
61
61
  request, never copied into the token.
62
62
 
63
+ An explicit `@UseGuards(AuthGuard(AuthGuardStrategy.BETTER_AUTH))` or `AuthGuard(AuthGuardStrategy.JWT)`
64
+ on the route does not close it to tokens (since 11.42.14; before, it answered 401): it accepts the token
65
+ the middleware authenticated and applies the same route policy. `AuthGuard(JWT_REFRESH)` and custom
66
+ strategies never accept a token.
67
+
63
68
  `request.user`:
64
69
 
65
70
  - TENANT token: `{ id: <token id>, name, scopes, tenantId, roles: [], hasRole: () => false }`
@@ -6,6 +6,7 @@ import { defaultOptions } from '@nestjs/passport/dist/options.js';
6
6
  import { memoize } from '@nestjs/passport/dist/utils/memoize.util.js';
7
7
  import passport = require('passport');
8
8
 
9
+ import { enforceApiTokenRoute, getApiTokenContext } from '../../api-token/core-api-token.helpers';
9
10
  import { BetterAuthTokenService } from '../../better-auth/better-auth-token.service';
10
11
  import { BetterAuthenticatedUser } from '../../better-auth/better-auth.types';
11
12
  import { CoreBetterAuthService } from '../../better-auth/core-better-auth.service';
@@ -21,6 +22,21 @@ const NO_STRATEGY_ERROR =
21
22
  'In order to use "defaultStrategy", please, ensure to import PassportModule in each ' +
22
23
  "place where AuthGuard() is being used. Otherwise, passport won't work correctly.";
23
24
 
25
+ /**
26
+ * Strategies that authenticate a person by an access credential. An API token authenticated by
27
+ * `CoreApiTokenMiddleware` satisfies them; `JWT_REFRESH` and project-defined strategies authenticate
28
+ * something else (a refresh token, a custom credential), so a token never stands in for those.
29
+ */
30
+ const API_TOKEN_SATISFIED_STRATEGIES: ReadonlySet<string> = new Set([
31
+ AuthGuardStrategy.BETTER_AUTH,
32
+ AuthGuardStrategy.JWT,
33
+ ]);
34
+
35
+ function isSatisfiedByApiToken(strategy: string | string[] | undefined): boolean {
36
+ const strategies = Array.isArray(strategy) ? strategy : [strategy];
37
+ return strategies.some((value) => !!value && API_TOKEN_SATISFIED_STRATEGIES.has(value));
38
+ }
39
+
24
40
  /**
25
41
  * Interface for auth guard
26
42
  */
@@ -61,6 +77,9 @@ const createPassportContext =
61
77
  * - If not, validates the token via BetterAuthTokenService (JWT or session token)
62
78
  * - Loads the full user from MongoDB with hasRole() capability
63
79
  *
80
+ * API tokens (JWT and BETTER_AUTH only): a token authenticated by CoreApiTokenMiddleware is accepted as is
81
+ * and the route is decided by enforceApiTokenRoute(), exactly as in the role guards.
82
+ *
64
83
  * Can be removed when pull request is merged:
65
84
  * https://github.com/nestjs/passport/pull/66
66
85
  */
@@ -118,11 +137,22 @@ function createAuthGuard(type?: AuthGuardStrategy | string | string[]): Type<IAu
118
137
 
119
138
  const options = { ...defaultOptions, ...this.options };
120
139
  const request = this.getRequest(context);
140
+ const existingUser = request?.[options.property || defaultOptions.property];
141
+
142
+ // An API token was already authenticated by CoreApiTokenMiddleware. Neither Passport nor
143
+ // BetterAuthTokenService knows its credential, so verifying it again here refused every token with
144
+ // 401 — also on a route that released it via @ApiTokenScopes(). The route is decided here with the
145
+ // same function the role guards use (deny by default, scopes, tenant binding), so the policy holds
146
+ // even where this guard runs without a framework role guard in front of it.
147
+ if (getApiTokenContext(existingUser) && isSatisfiedByApiToken(type || this.options?.defaultStrategy)) {
148
+ enforceApiTokenRoute({ controllerClass: context.getClass(), handler: context.getHandler(), request });
149
+ request[options.property || defaultOptions.property] = this.handleRequest(null, existingUser, null, context);
150
+ return true;
151
+ }
121
152
 
122
153
  // Check if user is already authenticated via Better-Auth middleware
123
154
  // Only skip Passport for Better-Auth users (marked with _authenticatedViaBetterAuth)
124
155
  // This ensures JWT_REFRESH guard still validates refresh tokens properly
125
- const existingUser = request?.[options.property || defaultOptions.property];
126
156
  if (existingUser && existingUser._authenticatedViaBetterAuth === true) {
127
157
  // User is authenticated via Better-Auth - skip Passport authentication
128
158
  // Validate through handleRequest to ensure role checks work
@@ -373,6 +373,55 @@ export const LtnsErrors = {
373
373
  },
374
374
  },
375
375
 
376
+ // CoreTenantGuard refusals (LTNS_0103-LTNS_0107). Each `message` is the guard's original
377
+ // plain-string wording, kept verbatim: consumers match on it, and the `#CODE: ` prefix keeps a
378
+ // substring match working. The code is what makes the refusal translatable at all.
379
+
380
+ NOT_TENANT_MEMBER: {
381
+ code: 'LTNS_0103',
382
+ message: 'Not a member of this tenant',
383
+ translations: {
384
+ de: 'Sie sind kein Mitglied dieses Mandanten.',
385
+ en: 'You are not a member of this tenant.',
386
+ },
387
+ },
388
+
389
+ INSUFFICIENT_TENANT_ROLE: {
390
+ code: 'LTNS_0104',
391
+ message: 'Insufficient tenant role',
392
+ translations: {
393
+ de: 'Ihre Rolle in diesem Mandanten reicht für diese Aktion nicht aus.',
394
+ en: 'Your role in this tenant does not allow this action.',
395
+ },
396
+ },
397
+
398
+ INSUFFICIENT_ROLE: {
399
+ code: 'LTNS_0105',
400
+ message: 'Insufficient role',
401
+ translations: {
402
+ de: 'Ihre Rolle reicht für diese Aktion nicht aus.',
403
+ en: 'Your role does not allow this action.',
404
+ },
405
+ },
406
+
407
+ TENANT_INACTIVE: {
408
+ code: 'LTNS_0106',
409
+ message: 'Tenant is inactive',
410
+ translations: {
411
+ de: 'Dieser Mandant ist deaktiviert.',
412
+ en: 'This tenant is deactivated.',
413
+ },
414
+ },
415
+
416
+ VERIFICATION_REQUIRED: {
417
+ code: 'LTNS_0107',
418
+ message: 'Verification required',
419
+ translations: {
420
+ de: 'Für diese Aktion muss Ihr Konto verifiziert sein.',
421
+ en: 'Your account must be verified for this action.',
422
+ },
423
+ },
424
+
376
425
  // =====================================================
377
426
  // User Errors (LTNS_0200-LTNS_0299)
378
427
  // =====================================================
@@ -458,6 +458,7 @@ export class FileController extends CoreFileController {
458
458
  | Situation | Status | Body |
459
459
  | -------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
460
460
  | Unknown id / filename, or `checkRights()` refused | `404` | `NotFoundException` with `ErrorCode.FILE_NOT_FOUND` |
461
+ | An id that is not 24 hex characters (e.g. a TUS upload id taken for the file id) | `404` | Same as an unknown id — the service answers `null` / `NotFoundException` before any lookup. Up to 11.42.12 `new ObjectId()` threw and the route answered `500` |
461
462
  | `deleteFile()` / `deleteFileByName()` / `duplicate*()` on a file that is not there | `404` | `NotFoundException`. Both halves of each id/name pair answer identically — up to 11.33.1 the by-id delete surfaced the driver's `MongoRuntimeError` as a `500` |
462
463
  | Missing id / filename in the route | `400` | `BadRequestException` with `ErrorCode.REQUIRED_FIELD_MISSING` |
463
464
  | An upload's SOURCE stream fails (client aborts, staged read drops) | `500` | The promise rejects with the cause — under **every** driver. It used to be an uncaught exception (i.e. a process exit) on GridFS, and on the S3 streaming path tus finalization uses |
@@ -139,6 +139,21 @@ export abstract class CoreFileService {
139
139
  return this.storageDriver === 'filesystem';
140
140
  }
141
141
 
142
+ /**
143
+ * Whether `id` is a string that cannot name a stored file at all.
144
+ *
145
+ * Every store keys files by an ObjectId, so a string that is not 24 hex characters — a TUS upload
146
+ * id (which clients took for the file id), a filename, a typo in a URL — names no file. It is
147
+ * answered exactly like an unknown id. Handed to `new ObjectId()` it threw a BSONError instead,
148
+ * and `GET /files/id/<tus-upload-id>` answered 500 where an unknown id gets 404.
149
+ *
150
+ * Only strings are judged: anything else keeps its previous path, so an ObjectId from a second
151
+ * copy of `bson` (where `instanceof` fails) is not suddenly reported as missing.
152
+ */
153
+ protected isMalformedFileId(id: unknown): boolean {
154
+ return typeof id === 'string' && !(id.length === 24 && Types.ObjectId.isValid(id));
155
+ }
156
+
142
157
  /**
143
158
  * Find the filesystem metadata of a file by ID (null when it is stored elsewhere)
144
159
  */
@@ -264,6 +279,9 @@ export abstract class CoreFileService {
264
279
  * @returns the id of the copy
265
280
  */
266
281
  async duplicateById(id: string | Types.ObjectId, serviceOptions?: FileServiceOptions): Promise<string> {
282
+ if (this.isMalformedFileId(id)) {
283
+ throw new NotFoundException(`File not found with id ${id}`);
284
+ }
267
285
  const objectId = getObjectIds(id);
268
286
  const source = await this.getFileInfo(objectId, serviceOptions);
269
287
  if (!source) {
@@ -425,6 +443,9 @@ export abstract class CoreFileService {
425
443
  * Get info about file via file ID
426
444
  */
427
445
  async getFileInfo(id: string | Types.ObjectId, serviceOptions?: FileServiceOptions): Promise<CoreFileInfo> {
446
+ if (this.isMalformedFileId(id)) {
447
+ return null;
448
+ }
428
449
  if (!(await this.checkRights(id, { ...serviceOptions, checkInputType: 'id' }))) {
429
450
  return null;
430
451
  }
@@ -459,6 +480,9 @@ export abstract class CoreFileService {
459
480
  serviceOptions?: FileServiceOptions,
460
481
  knownStore?: FileStorageDriver,
461
482
  ): Promise<Readable> {
483
+ if (this.isMalformedFileId(id)) {
484
+ return null;
485
+ }
462
486
  if (!(await this.checkRights(id, { ...serviceOptions, checkInputType: 'id' }))) {
463
487
  return null;
464
488
  }
@@ -515,6 +539,9 @@ export abstract class CoreFileService {
515
539
  id: string | Types.ObjectId,
516
540
  serviceOptions?: FileServiceOptions,
517
541
  ): Promise<{ info: CoreFileInfo; store: FileStorageDriver } | null> {
542
+ if (this.isMalformedFileId(id)) {
543
+ return null;
544
+ }
518
545
  if (!(await this.checkRights(id, { ...serviceOptions, checkInputType: 'id' }))) {
519
546
  return null;
520
547
  }
@@ -552,6 +579,9 @@ export abstract class CoreFileService {
552
579
  * disabled — callers then fall back to streaming the file themselves.
553
580
  */
554
581
  async getDownloadUrl(id: string | Types.ObjectId, serviceOptions?: FileServiceOptions): Promise<string | undefined> {
582
+ if (this.isMalformedFileId(id)) {
583
+ return undefined;
584
+ }
555
585
  if (!(await this.checkRights(id, { ...serviceOptions, checkInputType: 'id' }))) {
556
586
  return undefined;
557
587
  }
@@ -600,6 +630,9 @@ export abstract class CoreFileService {
600
630
  * Get file buffer (for small files) via file ID
601
631
  */
602
632
  async getBuffer(id: string | Types.ObjectId, serviceOptions?: FileServiceOptions): Promise<Buffer> {
633
+ if (this.isMalformedFileId(id)) {
634
+ return null;
635
+ }
603
636
  if (!(await this.checkRights(id, { ...serviceOptions, checkInputType: 'id' }))) {
604
637
  return null;
605
638
  }
@@ -652,6 +685,9 @@ export abstract class CoreFileService {
652
685
  * exact input, so a `null` here is genuinely a missing file.
653
686
  */
654
687
  async deleteFile(id: string | Types.ObjectId, serviceOptions?: FileServiceOptions): Promise<CoreFileInfo> {
688
+ if (this.isMalformedFileId(id)) {
689
+ throw new NotFoundException(`File not found with id ${id}`);
690
+ }
655
691
  if (!(await this.checkRights(id, { ...serviceOptions, checkInputType: 'id' }))) {
656
692
  return null;
657
693
  }
@@ -748,6 +784,9 @@ export abstract class CoreFileService {
748
784
  * @returns the raw document, or null when no file has that id
749
785
  */
750
786
  protected async getRawFileInfo(id: string | Types.ObjectId): Promise<null | Record<string, any>> {
787
+ if (this.isMalformedFileId(id)) {
788
+ return null;
789
+ }
751
790
  const fileInfo =
752
791
  (await this.findS3FileById(id)) ||
753
792
  (await this.findFilesystemFileById(id)) ||
@@ -182,8 +182,8 @@ See the module README: "Deactivated Tenants" and "Service-Level Role Checks".
182
182
  - [ ] Request with `X-Tenant-Id` header only returns tenant data
183
183
  - [ ] Request without header + hierarchy role checks user.roles and filters tenantIds
184
184
  - [ ] Admin user can access any tenant (if adminBypass: true)
185
- - [ ] Non-member gets 403 "Not a member of this tenant"
186
- - [ ] Unauthenticated + header → 403 "Authentication required for tenant access"
185
+ - [ ] Non-member gets 403 `#LTNS_0103: Not a member of this tenant`
186
+ - [ ] Unauthenticated + header → 401 `#LTNS_0100` (re-authentication is the remedy, so it is not a 403)
187
187
  - [ ] Public endpoint accessing tenantId-schema without context throws 403 (Safety Net)
188
188
  - [ ] IAM endpoints work without `X-Tenant-Id` header when `skipTenantCheck: true` (default)
189
189
 
@@ -193,6 +193,22 @@ class UserController {
193
193
  // @Roles(DefaultHR.MANAGER) without header → 200 (user.roles manager(2) >= manager(2))
194
194
  ```
195
195
 
196
+ ### Refusal Codes
197
+
198
+ Every 403 of the guard carries a translatable error code; the text behind the code is the guard's
199
+ original wording, so a substring match on it keeps working:
200
+
201
+ | Code | Message | Refused because |
202
+ | ----------- | ----------------------------- | --------------------------------------------------------------------------------------- |
203
+ | `LTNS_0103` | `Not a member of this tenant` | the header names a tenant the user has no active membership in |
204
+ | `LTNS_0104` | `Insufficient tenant role` | the membership role does not satisfy the required tenant role |
205
+ | `LTNS_0105` | `Insufficient role` | `user.roles` does not meet the required role (no header, or a `@SkipTenantCheck` route) |
206
+ | `LTNS_0106` | `Tenant is inactive` | `multiTenancy.isTenantActive` reports the header's tenant as switched off |
207
+ | `LTNS_0107` | `Verification required` | an `S_VERIFIED` endpoint, and the user is not verified |
208
+
209
+ A project that calls tenants something else overrides the translations in its own error registry
210
+ (`errorCode.additionalErrorRegistry`, registered after the core and therefore winning).
211
+
196
212
  ### Skipping Tenant Checks
197
213
 
198
214
  Use `@SkipTenantCheck()` for endpoints that intentionally work without tenant context:
@@ -238,7 +254,7 @@ multiTenancy: {
238
254
 
239
255
  | Path | Deactivated tenant |
240
256
  | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
241
- | Header + tenant role, or `S_USER` / `S_VERIFIED` with a header | **403** `Tenant is inactive` |
257
+ | Header + tenant role, or `S_USER` / `S_VERIFIED` with a header | **403** `#LTNS_0106: Tenant is inactive` |
242
258
  | Tenant API token | **403** |
243
259
  | `S_EVERYONE` with a header | not blocked, but no tenant context is set |
244
260
  | No header | dropped from the user's tenant list — its data stays out of reach |