@lenne.tech/nest-server 11.42.12 → 11.42.13
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/testing.md +3 -3
- package/FRAMEWORK-API.md +1 -1
- package/dist/core/modules/error-code/error-codes.d.ts +45 -0
- package/dist/core/modules/error-code/error-codes.js +40 -0
- package/dist/core/modules/error-code/error-codes.js.map +1 -1
- package/dist/core/modules/file/core-file.service.d.ts +1 -0
- package/dist/core/modules/file/core-file.service.js +27 -0
- package/dist/core/modules/file/core-file.service.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.guard.js +7 -7
- package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
- package/dist/core/modules/tus/core-tus.service.d.ts +1 -0
- package/dist/core/modules/tus/core-tus.service.js +10 -4
- package/dist/core/modules/tus/core-tus.service.js.map +1 -1
- package/dist/core/modules/tus/tus.constants.d.ts +1 -0
- package/dist/core/modules/tus/tus.constants.js +2 -1
- package/dist/core/modules/tus/tus.constants.js.map +1 -1
- package/dist/server/modules/error-code/error-codes.d.ts +5 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +1 -1
- package/docs/error-codes.md +70 -0
- package/migration-guides/11.42.12-to-11.42.13.md +154 -0
- package/package.json +2 -2
- package/src/core/modules/error-code/error-codes.ts +49 -0
- package/src/core/modules/file/README.md +1 -0
- package/src/core/modules/file/core-file.service.ts +39 -0
- package/src/core/modules/tenant/INTEGRATION-CHECKLIST.md +2 -2
- package/src/core/modules/tenant/README.md +17 -1
- package/src/core/modules/tenant/core-tenant.guard.ts +7 -7
- package/src/core/modules/tus/INTEGRATION-CHECKLIST.md +2 -1
- package/src/core/modules/tus/README.md +42 -3
- package/src/core/modules/tus/core-tus.service.ts +32 -6
- 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
|
|
package/docs/error-codes.md
CHANGED
|
@@ -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"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lenne.tech/nest-server",
|
|
3
|
-
"version": "11.42.
|
|
3
|
+
"version": "11.42.13",
|
|
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",
|
|
@@ -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.
|
|
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",
|
|
@@ -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
|
|
186
|
-
- [ ] Unauthenticated + header →
|
|
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**
|
|
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 |
|
|
@@ -470,7 +470,7 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
470
470
|
}
|
|
471
471
|
const isVerified = !!(user.verified || user.verifiedAt || user.emailVerified);
|
|
472
472
|
if (!isVerified) {
|
|
473
|
-
throw new ForbiddenException(
|
|
473
|
+
throw new ForbiddenException(ErrorCode.VERIFICATION_REQUIRED);
|
|
474
474
|
}
|
|
475
475
|
if (headerTenantId && !hasSkipDecorator) {
|
|
476
476
|
return this.handleSystemRoleWithTenantHeader(user, headerTenantId, request, isAdmin);
|
|
@@ -536,7 +536,7 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
536
536
|
const membership = await this.findMembershipCached(user.id, headerTenantId);
|
|
537
537
|
|
|
538
538
|
if (!membership) {
|
|
539
|
-
throw new ForbiddenException(
|
|
539
|
+
throw new ForbiddenException(ErrorCode.NOT_TENANT_MEMBER);
|
|
540
540
|
}
|
|
541
541
|
await this.assertTenantActive(headerTenantId);
|
|
542
542
|
|
|
@@ -567,7 +567,7 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
567
567
|
tenantRoles.length > 0 && memberRole !== null && checkRoleAccess(tenantRoles, undefined, memberRole);
|
|
568
568
|
|
|
569
569
|
if (!satisfiedGlobally && !satisfiedByTenant) {
|
|
570
|
-
throw new ForbiddenException(
|
|
570
|
+
throw new ForbiddenException(ErrorCode.INSUFFICIENT_TENANT_ROLE);
|
|
571
571
|
}
|
|
572
572
|
}
|
|
573
573
|
|
|
@@ -595,7 +595,7 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
595
595
|
|
|
596
596
|
// Check role access against user.roles (hierarchy: level comparison, normal: exact match)
|
|
597
597
|
if (!checkRoleAccess(checkableRoles, user.roles, undefined)) {
|
|
598
|
-
throw new ForbiddenException(
|
|
598
|
+
throw new ForbiddenException(ErrorCode.INSUFFICIENT_ROLE);
|
|
599
599
|
}
|
|
600
600
|
|
|
601
601
|
// Resolve tenant IDs filtered by minimum required hierarchy level
|
|
@@ -904,7 +904,7 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
904
904
|
/** 403 for a deactivated tenant. */
|
|
905
905
|
private async assertTenantActive(tenantId: string): Promise<void> {
|
|
906
906
|
if (!(await this.isTenantActive(tenantId))) {
|
|
907
|
-
throw new ForbiddenException(
|
|
907
|
+
throw new ForbiddenException(ErrorCode.TENANT_INACTIVE);
|
|
908
908
|
}
|
|
909
909
|
}
|
|
910
910
|
|
|
@@ -946,7 +946,7 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
946
946
|
|
|
947
947
|
const membership = await this.findMembershipCached(user.id, headerTenantId);
|
|
948
948
|
if (!membership) {
|
|
949
|
-
throw new ForbiddenException(
|
|
949
|
+
throw new ForbiddenException(ErrorCode.NOT_TENANT_MEMBER);
|
|
950
950
|
}
|
|
951
951
|
await this.assertTenantActive(headerTenantId);
|
|
952
952
|
request.tenantId = headerTenantId;
|
|
@@ -965,7 +965,7 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
965
965
|
throw new UnauthorizedException(ErrorCode.UNAUTHORIZED);
|
|
966
966
|
}
|
|
967
967
|
if (!isAdmin && !checkRoleAccess(checkableRoles, user.roles, undefined)) {
|
|
968
|
-
throw new ForbiddenException(
|
|
968
|
+
throw new ForbiddenException(ErrorCode.INSUFFICIENT_ROLE);
|
|
969
969
|
}
|
|
970
970
|
}
|
|
971
971
|
return true;
|
|
@@ -151,7 +151,7 @@ export class TusService extends CoreTusService {
|
|
|
151
151
|
|
|
152
152
|
// After the last byte.
|
|
153
153
|
protected override async onUploadComplete(upload: Upload): Promise<void> {
|
|
154
|
-
await super.onUploadComplete(upload); //
|
|
154
|
+
await super.onUploadComplete(upload); // stores the file and records its ID for X-File-Id (11.42.13+)
|
|
155
155
|
// custom logic
|
|
156
156
|
}
|
|
157
157
|
}
|
|
@@ -243,6 +243,7 @@ TusModule.forRoot({ config: false });
|
|
|
243
243
|
| Forgot to register custom service | `onUploadCreate` / `onUploadComplete` overrides never run | Add `service: TusService` to `forRoot()` (11.41.9+) — Step 2 |
|
|
244
244
|
| Constructor dependencies on the service subclass | `undefined` dependencies, or a crash at boot | Resolve them at call time via `this.options?.moduleRef` — Step 2 |
|
|
245
245
|
| Override of `onUploadCreate` without `super` | Uploads have no owner or tenant; `allowedTypes` ignored | `return super.onUploadCreate(req, upload)` — Step 2 |
|
|
246
|
+
| Override of `onUploadComplete` without `super` | No `X-File-Id`; links built from the TUS URL answer `404` | Call `super.onUploadComplete(upload)` with the upload received (11.42.13+) — Step 2 |
|
|
246
247
|
| Upload succeeds but the file cannot be downloaded | `403` on `GET /files/id/:id` | `file.downloadRoles` defaults to `[ADMIN]` — widen it, or authorize per file in `checkRights()` |
|
|
247
248
|
| Using wrong endpoint path | 404 on upload | Ensure client uses same path as config |
|
|
248
249
|
|
|
@@ -111,6 +111,7 @@ The TUS server automatically handles CORS headers for browser-based clients:
|
|
|
111
111
|
- `Upload-Length`
|
|
112
112
|
- `Upload-Offset`
|
|
113
113
|
- `Upload-Metadata`
|
|
114
|
+
- `X-File-Id` (11.42.13+) — the ID of the stored file, see [The ID of the Stored File](#the-id-of-the-stored-file-114213)
|
|
114
115
|
|
|
115
116
|
**`Access-Control-Allow-Origin` follows the API's CORS configuration (11.42.1+).** The tus handler
|
|
116
117
|
writes its responses itself, and without `allowedOrigins` `@tus/server` answers `*`. With cookie
|
|
@@ -265,11 +266,19 @@ const upload = new Upload(file, {
|
|
|
265
266
|
const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(2);
|
|
266
267
|
console.log(`${percentage}%`);
|
|
267
268
|
},
|
|
269
|
+
// The response that completes the upload names the stored file (11.42.13+).
|
|
270
|
+
onAfterResponse: (_req, res) => {
|
|
271
|
+
const id = res.getHeader('X-File-Id');
|
|
272
|
+
if (id) {
|
|
273
|
+
fileId = id;
|
|
274
|
+
}
|
|
275
|
+
},
|
|
268
276
|
onSuccess: () => {
|
|
269
|
-
|
|
270
|
-
console.log('
|
|
277
|
+
// `upload.url` is the TUS upload URL — its last segment is NOT the file id.
|
|
278
|
+
console.log('Upload complete, file:', `/files/id/${fileId}`);
|
|
271
279
|
},
|
|
272
280
|
});
|
|
281
|
+
let fileId: string | undefined;
|
|
273
282
|
|
|
274
283
|
// Start or resume upload
|
|
275
284
|
upload.start();
|
|
@@ -423,6 +432,10 @@ export class TusService extends CoreTusService {
|
|
|
423
432
|
}
|
|
424
433
|
```
|
|
425
434
|
|
|
435
|
+
Call `super.onUploadComplete(upload)` with the `upload` you received: it stores the file and records
|
|
436
|
+
its ID for the `X-File-Id` response header (11.42.13+). An override that never calls `super` still
|
|
437
|
+
completes the upload, but the client receives no `X-File-Id`.
|
|
438
|
+
|
|
426
439
|
### Tenant Scoping (11.41.9+)
|
|
427
440
|
|
|
428
441
|
**Only with `multiTenancy` configured.** Without it nothing changes: no header is read, no field is
|
|
@@ -487,6 +500,31 @@ query {
|
|
|
487
500
|
|
|
488
501
|
**Recommendation:** Use the ID-based endpoint (`/files/id/:id`) for TUS uploads as filenames may not be unique.
|
|
489
502
|
|
|
503
|
+
### The ID of the Stored File (11.42.13+)
|
|
504
|
+
|
|
505
|
+
The response that completes an upload — the last `PATCH`, or the `POST` of a creation-with-upload —
|
|
506
|
+
carries the ID of the stored file:
|
|
507
|
+
|
|
508
|
+
```
|
|
509
|
+
HTTP/1.1 204 No Content
|
|
510
|
+
Upload-Offset: 52428800
|
|
511
|
+
X-File-Id: 6720c1f0a2b4c8e9d0f1a2b3
|
|
512
|
+
Access-Control-Expose-Headers: …, X-File-Id
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
Read it from that response and use it for `/files/id/:id`. **Do not take the last segment of the
|
|
516
|
+
TUS URL** (`upload.url`): that is the TUS UPLOAD id, a key of the staging store, and `/files/id/` answers
|
|
517
|
+
it with 404. Until 11.42.13 the header did not exist and the TUS URL was all a client had.
|
|
518
|
+
|
|
519
|
+
The header is sent on the completing request only — creation and partial `PATCH`es store nothing yet.
|
|
520
|
+
It is listed in `Access-Control-Expose-Headers`, so a browser on a separate app origin can read it. The
|
|
521
|
+
name is exported as `TUS_FILE_ID_HEADER`.
|
|
522
|
+
|
|
523
|
+
A project override of `onUploadComplete()` keeps the header as long as it calls `super` — see
|
|
524
|
+
[Custom Upload Handler](#custom-upload-handler). The hook's signature did not change. A client must
|
|
525
|
+
still cope with a missing header (an older server, or an override that stores the file itself): treat
|
|
526
|
+
it as "no file id known", never as a reason to fall back to the TUS URL.
|
|
527
|
+
|
|
490
528
|
> **These download routes are gated.** They require `file.downloadRoles`, which defaults to
|
|
491
529
|
> `[RoleEnum.ADMIN]` — while uploading here only requires `tus.roles` (default `S_USER`). So out of
|
|
492
530
|
> the box a signed-in user can upload but cannot read their own file back.
|
|
@@ -634,7 +672,8 @@ calls `getServer().handle()`. No change to `main.ts` is needed.
|
|
|
634
672
|
3. Client: PATCH /tus/:id (send chunks)
|
|
635
673
|
4. Server: Returns Upload-Offset
|
|
636
674
|
5. Client: Repeat PATCH until complete
|
|
637
|
-
6. Server: Migrate to
|
|
675
|
+
6. Server: Migrate to the configured file storage, cleanup temp file
|
|
676
|
+
7. Server: Answer the completing PATCH with X-File-Id (the stored file's ID)
|
|
638
677
|
```
|
|
639
678
|
|
|
640
679
|
### File Storage
|