@lenne.tech/nest-server 11.42.8 → 11.42.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/rules/better-auth.md +24 -0
- package/.claude/rules/configurable-features.md +5 -3
- package/.claude/rules/testing.md +4 -4
- package/FRAMEWORK-API.md +3 -2
- package/dist/core/common/decorators/api-methods.decorator.d.ts +2 -0
- package/dist/core/common/decorators/api-methods.decorator.js +8 -0
- package/dist/core/common/decorators/api-methods.decorator.js.map +1 -0
- package/dist/core/common/helpers/swagger.helper.js +312 -1
- package/dist/core/common/helpers/swagger.helper.js.map +1 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +1 -0
- package/dist/core/modules/ai/core-ai-mcp.controller.js +15 -1
- package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
- package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js +8 -1
- package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js.map +1 -1
- package/dist/core/modules/ai/helpers/ai-mcp-oauth.registry.d.ts +7 -0
- package/dist/core/modules/ai/helpers/ai-mcp-oauth.registry.js +12 -0
- package/dist/core/modules/ai/helpers/ai-mcp-oauth.registry.js.map +1 -0
- package/dist/core/modules/better-auth/better-auth.config.js +12 -0
- package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-api.middleware.js +2 -2
- package/dist/core/modules/better-auth/core-better-auth-api.middleware.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-openapi.helper.d.ts +62 -0
- package/dist/core/modules/better-auth/core-better-auth-openapi.helper.js +95 -0
- package/dist/core/modules/better-auth/core-better-auth-openapi.helper.js.map +1 -0
- package/dist/core/modules/better-auth/core-better-auth.service.d.ts +10 -2
- package/dist/core/modules/better-auth/core-better-auth.service.js +89 -0
- package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
- package/dist/core/modules/tus/core-tus.controller.js +3 -0
- package/dist/core/modules/tus/core-tus.controller.js.map +1 -1
- package/dist/core/modules/tus/core-tus.service.d.ts +3 -0
- package/dist/core/modules/tus/core-tus.service.js +58 -25
- package/dist/core/modules/tus/core-tus.service.js.map +1 -1
- package/dist/core/modules/tus/interfaces/tus-config.interface.js +1 -0
- package/dist/core/modules/tus/interfaces/tus-config.interface.js.map +1 -1
- package/dist/core/modules/tus/tus.module.d.ts +2 -0
- package/dist/core/modules/tus/tus.module.js +23 -1
- package/dist/core/modules/tus/tus.module.js.map +1 -1
- package/dist/core.module.js +1 -1
- package/dist/core.module.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +9 -1
- package/migration-guides/11.42.8-to-11.42.9.md +186 -0
- package/package.json +1 -1
- package/src/core/common/decorators/api-methods.decorator.ts +25 -0
- package/src/core/common/helpers/swagger.helper.ts +454 -1
- package/src/core/common/interfaces/server-options.interface.ts +46 -8
- package/src/core/common/interfaces/swagger-setup-options.interface.ts +5 -2
- package/src/core/modules/ai/README.md +7 -0
- package/src/core/modules/ai/core-ai-mcp.controller.ts +19 -2
- package/src/core/modules/ai/helpers/ai-mcp-oauth.helper.ts +12 -2
- package/src/core/modules/ai/helpers/ai-mcp-oauth.registry.ts +29 -0
- package/src/core/modules/better-auth/README.md +34 -0
- package/src/core/modules/better-auth/better-auth.config.ts +27 -1
- package/src/core/modules/better-auth/core-better-auth-api.middleware.ts +4 -17
- package/src/core/modules/better-auth/core-better-auth-openapi.helper.ts +219 -0
- package/src/core/modules/better-auth/core-better-auth.service.ts +166 -2
- package/src/core/modules/tus/README.md +50 -14
- package/src/core/modules/tus/core-tus.controller.ts +6 -0
- package/src/core/modules/tus/core-tus.service.ts +97 -26
- package/src/core/modules/tus/interfaces/tus-config.interface.ts +1 -0
- package/src/core/modules/tus/tus.module.ts +43 -1
- package/src/core.module.ts +2 -1
- package/src/index.ts +1 -0
|
@@ -1,5 +1,19 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
BadRequestException,
|
|
3
|
+
Inject,
|
|
4
|
+
Injectable,
|
|
5
|
+
Logger,
|
|
6
|
+
OnApplicationBootstrap,
|
|
7
|
+
OnModuleInit,
|
|
8
|
+
Optional,
|
|
9
|
+
} from '@nestjs/common';
|
|
2
10
|
import { InjectConnection } from '@nestjs/mongoose';
|
|
11
|
+
import {
|
|
12
|
+
freshSessionMiddleware,
|
|
13
|
+
requestOnlySessionMiddleware,
|
|
14
|
+
sensitiveSessionMiddleware,
|
|
15
|
+
sessionMiddleware,
|
|
16
|
+
} from 'better-auth/api';
|
|
3
17
|
import { Request } from 'express';
|
|
4
18
|
import { importJWK, jwtVerify } from 'jose';
|
|
5
19
|
import { Connection } from 'mongoose';
|
|
@@ -12,6 +26,14 @@ import { ErrorCode } from '../error-code/error-codes';
|
|
|
12
26
|
import { resolveBetterAuthCookiePrefix } from './better-auth-cookie-prefix.helper';
|
|
13
27
|
import { BetterAuthInstance } from './better-auth.config';
|
|
14
28
|
import { legacyCredentialAccountIssuer, usesLegacyAccountIssuer } from './core-better-auth-account-issuer.helper';
|
|
29
|
+
import {
|
|
30
|
+
BUILT_IN_BETTER_AUTH_PLUGIN_IDS,
|
|
31
|
+
HANDLER_SESSION_PATHS,
|
|
32
|
+
isUnavailableOperation,
|
|
33
|
+
toOpenApiPath,
|
|
34
|
+
unavailableBetterAuthOperations,
|
|
35
|
+
} from './core-better-auth-openapi.helper';
|
|
36
|
+
import type { IBetterAuthOpenApiDocument, IBetterAuthRuntime } from './core-better-auth-openapi.helper';
|
|
15
37
|
import { isJwtShaped } from './core-better-auth-token.helper';
|
|
16
38
|
import { BetterAuthSessionUser } from './core-better-auth-user.mapper';
|
|
17
39
|
import { convertExpressHeaders, parseCookieHeader, signCookieValueIfNeeded } from './core-better-auth-web.helper';
|
|
@@ -81,9 +103,11 @@ export { BETTER_AUTH_CONFIG, BETTER_AUTH_COOKIE_DOMAIN } from './core-better-aut
|
|
|
81
103
|
* ```
|
|
82
104
|
*/
|
|
83
105
|
@Injectable()
|
|
84
|
-
export class CoreBetterAuthService implements OnModuleInit {
|
|
106
|
+
export class CoreBetterAuthService implements OnApplicationBootstrap, OnModuleInit {
|
|
85
107
|
private readonly logger = new Logger(CoreBetterAuthService.name);
|
|
86
108
|
private readonly config: IBetterAuth;
|
|
109
|
+
private openApiSchema: IBetterAuthOpenApiDocument | undefined;
|
|
110
|
+
private unavailableOperations: Set<string> | undefined;
|
|
87
111
|
// Cached cookie prefix — frozen on first read so the value cannot drift away
|
|
88
112
|
// from the Better-Auth instance (which captured it at bootstrap). Without
|
|
89
113
|
// this cache a test or fork that mutates `process.env.COOKIE_PREFIX` after
|
|
@@ -131,6 +155,146 @@ export class CoreBetterAuthService implements OnModuleInit {
|
|
|
131
155
|
}
|
|
132
156
|
}
|
|
133
157
|
|
|
158
|
+
/**
|
|
159
|
+
* Prepares the OpenAPI description of Better-Auth's own routes for `setupSwagger()`.
|
|
160
|
+
*
|
|
161
|
+
* Its own hook rather than part of `onModuleInit()`, which returns early without a database
|
|
162
|
+
* connection: the description depends on the configured plugins only. Built once here because the
|
|
163
|
+
* Swagger document factory is synchronous while Better-Auth's generator is not.
|
|
164
|
+
*/
|
|
165
|
+
async onApplicationBootstrap(): Promise<void> {
|
|
166
|
+
await this.prepareOpenApiSchema();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The OpenAPI description of the routes Better-Auth serves itself under the base path — exactly the
|
|
171
|
+
* ones the configured plugins add (paths relative to the base path, OpenAPI 3.1). `undefined` when
|
|
172
|
+
* Better-Auth is disabled or the description could not be generated.
|
|
173
|
+
*/
|
|
174
|
+
getOpenApiSchema(): IBetterAuthOpenApiDocument | undefined {
|
|
175
|
+
return this.openApiSchema;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Operations under the base path that the running Better-Auth options make answer an error for every
|
|
180
|
+
* caller (`unavailableBetterAuthOperations()`), read from the live instance at bootstrap — so options a
|
|
181
|
+
* project passes through `betterAuth.options` count too. `setupSwagger()` applies them to the
|
|
182
|
+
* controller's own routes as well. `undefined` before bootstrap or when Better-Auth is disabled.
|
|
183
|
+
*/
|
|
184
|
+
getUnavailableOperations(): Set<string> | undefined {
|
|
185
|
+
return this.unavailableOperations;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Runs Better-Auth's OpenAPI generator, which it exposes through the `openAPI()` plugin
|
|
190
|
+
* `buildPlugins()` registers. A failure costs the Swagger document its generated routes, never the boot.
|
|
191
|
+
*/
|
|
192
|
+
/**
|
|
193
|
+
* The operations (`<method> <openapi path>`) Better-Auth answers only with a session: a session
|
|
194
|
+
* middleware in the endpoint's `use` chain (directly or nested), a check in its handler
|
|
195
|
+
* (`HANDLER_SESSION_PATHS`), or — unknowable, so assumed — a plugin nest-server does not register
|
|
196
|
+
* itself (`BUILT_IN_BETTER_AUTH_PLUGIN_IDS`).
|
|
197
|
+
*
|
|
198
|
+
* `undefined` when no endpoint carries one of the middlewares — then the instance's middlewares are
|
|
199
|
+
* not the ones imported here (two copies of better-auth), the distinction cannot be made, and every
|
|
200
|
+
* route keeps the global requirement. A public route documented as authenticated costs a header; a
|
|
201
|
+
* session route documented as public sends clients without one.
|
|
202
|
+
*/
|
|
203
|
+
protected sessionGuardedOperations(): Set<string> | undefined {
|
|
204
|
+
const middlewares = new Set<unknown>([
|
|
205
|
+
freshSessionMiddleware,
|
|
206
|
+
requestOnlySessionMiddleware,
|
|
207
|
+
sensitiveSessionMiddleware,
|
|
208
|
+
sessionMiddleware,
|
|
209
|
+
]);
|
|
210
|
+
// A middleware guards when it IS one of them or runs one itself — `organization()` nests
|
|
211
|
+
// sessionMiddleware inside its own. Depth-bounded, so a cyclic chain cannot hang the bootstrap.
|
|
212
|
+
const guards = (middleware: unknown, depth = 0): boolean =>
|
|
213
|
+
depth < 5 &&
|
|
214
|
+
(middlewares.has(middleware) ||
|
|
215
|
+
((middleware as { options?: { use?: unknown[] } })?.options?.use ?? []).some((inner) =>
|
|
216
|
+
guards(inner, depth + 1),
|
|
217
|
+
));
|
|
218
|
+
// Routes of plugins nest-server does not register itself keep the global requirement: their own
|
|
219
|
+
// middleware may check the session in its body (`admin()` does), which nothing here can see.
|
|
220
|
+
const plugins = ((this.getInstance() as unknown as { options?: { plugins?: unknown[] } })?.options?.plugins ??
|
|
221
|
+
[]) as { endpoints?: Record<string, { path?: unknown }>; id?: string }[];
|
|
222
|
+
const foreignPaths = new Set(
|
|
223
|
+
plugins
|
|
224
|
+
.filter((plugin) => !BUILT_IN_BETTER_AUTH_PLUGIN_IDS.includes(String(plugin?.id)))
|
|
225
|
+
.flatMap((plugin) => Object.values(plugin?.endpoints ?? {}).map((endpoint) => endpoint?.path)),
|
|
226
|
+
);
|
|
227
|
+
const endpoints = Object.values((this.getApi() ?? {}) as Record<string, unknown>) as {
|
|
228
|
+
options?: { method?: string | string[]; use?: unknown[] };
|
|
229
|
+
path?: string;
|
|
230
|
+
}[];
|
|
231
|
+
const operations = new Set<string>();
|
|
232
|
+
let middlewareSeen = false;
|
|
233
|
+
for (const endpoint of endpoints) {
|
|
234
|
+
if (typeof endpoint?.path !== 'string') continue;
|
|
235
|
+
const byMiddleware = !!endpoint.options?.use?.some((middleware) => guards(middleware));
|
|
236
|
+
middlewareSeen ||= byMiddleware;
|
|
237
|
+
if (!byMiddleware && !HANDLER_SESSION_PATHS.includes(endpoint.path) && !foreignPaths.has(endpoint.path)) {
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
for (const method of ([] as string[]).concat(endpoint.options?.method ?? [])) {
|
|
241
|
+
operations.add(`${method.toLowerCase()} ${toOpenApiPath(endpoint.path)}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (!middlewareSeen) {
|
|
245
|
+
this.logger.warn(
|
|
246
|
+
"Could not tell which Better-Auth routes need a session (no endpoint uses better-auth's session " +
|
|
247
|
+
'middleware as imported here); Swagger documents all of them as authenticated',
|
|
248
|
+
);
|
|
249
|
+
return undefined;
|
|
250
|
+
}
|
|
251
|
+
return operations;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
protected async prepareOpenApiSchema(): Promise<void> {
|
|
255
|
+
if (!this.isEnabled()) return;
|
|
256
|
+
const api = this.getApi() as { generateOpenAPISchema?: () => Promise<unknown> } | null;
|
|
257
|
+
if (typeof api?.generateOpenAPISchema !== 'function') return;
|
|
258
|
+
try {
|
|
259
|
+
const schema = (await api.generateOpenAPISchema()) as IBetterAuthOpenApiDocument;
|
|
260
|
+
// The generator lists every registered endpoint, including those the running options make
|
|
261
|
+
// answer an error for everybody (POST /get-session without deferSessionRefresh, social routes
|
|
262
|
+
// without a provider, OTP routes without sendOTP, …). Only what can succeed is described.
|
|
263
|
+
const context = (await (this.getInstance() as unknown as { $context?: Promise<IBetterAuthRuntime> })
|
|
264
|
+
?.$context) as IBetterAuthRuntime | undefined;
|
|
265
|
+
const unavailable = unavailableBetterAuthOperations(context);
|
|
266
|
+
this.unavailableOperations = unavailable;
|
|
267
|
+
// The generator also marks every route bearer-only, public ones included. What a route needs is
|
|
268
|
+
// what Better-Auth enforces on it, so that replaces the generator's guess: `security: []` for a
|
|
269
|
+
// public route, none (the document's global requirement) for one that needs a session.
|
|
270
|
+
const sessionGuarded = this.sessionGuardedOperations();
|
|
271
|
+
for (const [path, item] of Object.entries(schema.paths ?? {})) {
|
|
272
|
+
for (const method of Object.keys(item)) {
|
|
273
|
+
if (isUnavailableOperation(unavailable, method, path)) {
|
|
274
|
+
delete item[method];
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
const operation = item[method] as { security?: unknown[] } | undefined;
|
|
278
|
+
if (operation && typeof operation === 'object') {
|
|
279
|
+
if (!sessionGuarded || sessionGuarded.has(`${method} ${path}`)) {
|
|
280
|
+
delete operation.security;
|
|
281
|
+
} else {
|
|
282
|
+
operation.security = [];
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (!Object.keys(item).length) {
|
|
287
|
+
delete schema.paths[path];
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
this.openApiSchema = schema;
|
|
291
|
+
} catch (error) {
|
|
292
|
+
this.logger.warn(
|
|
293
|
+
`Could not describe Better-Auth's routes for Swagger: ${error instanceof Error ? error.message : 'unknown'}`,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
134
298
|
/**
|
|
135
299
|
* Ensure performance indices exist on the session, users, account and verification collections.
|
|
136
300
|
* Indices are idempotent — calling createIndex on an existing index is a no-op.
|
|
@@ -95,6 +95,11 @@ All endpoints are handled by the TUS protocol via `@tus/server`:
|
|
|
95
95
|
| PATCH | `/tus/:id` | Continue upload |
|
|
96
96
|
| DELETE | `/tus/:id` | Terminate upload |
|
|
97
97
|
|
|
98
|
+
The two `@All()` handlers carry `@ApiMethods()` (11.42.9), so the Swagger document built by
|
|
99
|
+
`setupSwagger()` lists exactly POST on `/tus` and HEAD/PATCH/DELETE on `/tus/{id}` — not the eight
|
|
100
|
+
operations `@nestjs/swagger` derives from `@All()`. A project controller that re-declares the handlers
|
|
101
|
+
re-declares `@ApiMethods()` with them.
|
|
102
|
+
|
|
98
103
|
### CORS Headers
|
|
99
104
|
|
|
100
105
|
The TUS server automatically handles CORS headers for browser-based clients:
|
|
@@ -132,6 +137,14 @@ TusModule.forRoot({ config: false });
|
|
|
132
137
|
tus: false;
|
|
133
138
|
```
|
|
134
139
|
|
|
140
|
+
### Where the Configuration Comes From (11.42.9+)
|
|
141
|
+
|
|
142
|
+
`TusModule.forRoot({ config })` wins. Without a `config`, the server configuration's `tus` key applies
|
|
143
|
+
— the block in `config.env.ts` (or `NSC__TUS__*`), the same zero-config rule `BetterAuthModule.forRoot()`
|
|
144
|
+
follows. **Before 11.42.9 that key was never read**: `TusModule.forRoot()` without `config` always
|
|
145
|
+
enabled TUS with the defaults, so `tus: false`, `allowedTypes`, `maxSize` and `roles` in `config.env.ts`
|
|
146
|
+
did nothing. Projects that set them get them applied on upgrade — check that block before you deploy.
|
|
147
|
+
|
|
135
148
|
### Custom Configuration
|
|
136
149
|
|
|
137
150
|
```typescript
|
|
@@ -164,20 +177,21 @@ TusModule.forRoot({
|
|
|
164
177
|
|
|
165
178
|
### Configuration Options
|
|
166
179
|
|
|
167
|
-
| Option | Type | Default | Description
|
|
168
|
-
| -------------------- | ----------------- | ---------------------- |
|
|
169
|
-
| `enabled` | boolean | `true` | Enable/disable TUS
|
|
170
|
-
| `path` | string | `/tus` | Endpoint path
|
|
171
|
-
| `
|
|
172
|
-
| `
|
|
173
|
-
| `
|
|
174
|
-
| `
|
|
175
|
-
| `
|
|
176
|
-
| `
|
|
177
|
-
| `
|
|
178
|
-
| `
|
|
179
|
-
| `
|
|
180
|
-
| `
|
|
180
|
+
| Option | Type | Default | Description |
|
|
181
|
+
| -------------------- | ----------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
182
|
+
| `enabled` | boolean | `true` | Enable/disable TUS |
|
|
183
|
+
| `path` | string | `/tus` | Endpoint path — see [Upload URLs](#upload-urls-11429). A new route (`/uploads`) mounts the controller there; a path ending with the controller's route (`/api/tus`) is the public URL and leaves the controller where it is |
|
|
184
|
+
| `relativeLocation` | boolean | `false` | Hand out upload URLs without scheme and host — for a frontend that reaches TUS through a same-origin proxy (11.42.9+) |
|
|
185
|
+
| `maxSize` | number | 50 GB | Maximum file size in bytes |
|
|
186
|
+
| `allowedTypes` | string[] | undefined | Allowed MIME types (all if undefined) |
|
|
187
|
+
| `allowedHeaders` | string[] | `[]` | Additional custom headers (TUS headers already included) |
|
|
188
|
+
| `uploadDir` | string | `uploads/tus` | Temporary upload directory |
|
|
189
|
+
| `creation` | boolean | `true` | Creation extension. `false`: POST answers 501 and OPTIONS stops advertising it (enforced since 11.42.9) |
|
|
190
|
+
| `creationWithUpload` | boolean | `true` | Creation-with-upload extension. `false`: a POST carrying data answers 501 (11.42.9+) |
|
|
191
|
+
| `termination` | boolean | `true` | Termination extension. `false`: DELETE answers 501 and OPTIONS stops advertising it (11.42.9+; before, the flag was only logged) |
|
|
192
|
+
| `expiration` | boolean \| object | `{ expiresIn: '24h' }` | Expiration configuration |
|
|
193
|
+
| `checksum` | boolean | `true` | No effect: the built-in stores (file system, S3) do not support it |
|
|
194
|
+
| `concatenation` | boolean | `true` | No effect: the built-in stores (file system, S3) do not support it |
|
|
181
195
|
|
|
182
196
|
**Note on `allowedHeaders`:**
|
|
183
197
|
|
|
@@ -190,6 +204,28 @@ TusModule.forRoot({
|
|
|
190
204
|
|
|
191
205
|
The `allowedHeaders` option is only for **project-specific custom headers**.
|
|
192
206
|
|
|
207
|
+
### Upload URLs (11.42.9+)
|
|
208
|
+
|
|
209
|
+
The URL a client receives for a new upload (`Location`) is built from the path the upload was CREATED
|
|
210
|
+
at: it therefore points where the controller actually listens — below a global prefix, a URI version or
|
|
211
|
+
a project controller's own route alike. Before 11.42.9 it was built from `path` alone, so in each of
|
|
212
|
+
those cases every HEAD / PATCH on it answered 404 and no upload could be resumed.
|
|
213
|
+
|
|
214
|
+
`path` decides the rest:
|
|
215
|
+
|
|
216
|
+
| `path` | Effect |
|
|
217
|
+
| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
218
|
+
| unset | upload URLs follow the request, the controller keeps its route |
|
|
219
|
+
| a new route (`'/uploads'`) | the controller is mounted there — below the global prefix, like any route |
|
|
220
|
+
| ends with the controller's route (`'/api/tus'`, `'/files/tus'`) | the public URL: the controller stays, upload URLs use this path. For a global prefix, or a proxy that strips a prefix on the way in — and what `path` had to be set to before 11.42.9, so those setups keep working |
|
|
221
|
+
|
|
222
|
+
A proxy that renames the route (public `/files/upload` forwarded as `/tus`) fits none of these: leave
|
|
223
|
+
`path` unset and override `uploadCollectionPath(req)` in a `CoreTusService` subclass to return the
|
|
224
|
+
public path. For relative upload URLs (`/tus/<id>`, a same-origin frontend proxy) set
|
|
225
|
+
`relativeLocation: true`; the framework builds the URL itself, so `@tus/server`'s own option of that
|
|
226
|
+
name only takes effect through this one — or when passed to the tus server directly, which is
|
|
227
|
+
honoured too.
|
|
228
|
+
|
|
193
229
|
### Expiration Configuration
|
|
194
230
|
|
|
195
231
|
```typescript
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { All, Controller, Logger, Options, Req, Res } from '@nestjs/common';
|
|
2
2
|
import { Request, Response } from 'express';
|
|
3
3
|
|
|
4
|
+
import { ApiMethods } from '../../common/decorators/api-methods.decorator';
|
|
4
5
|
import { Roles } from '../../common/decorators/roles.decorator';
|
|
5
6
|
import { RoleEnum } from '../../common/enums/role.enum';
|
|
6
7
|
import { SkipTenantCheck } from '../tenant/core-tenant.decorators';
|
|
@@ -92,6 +93,8 @@ export class CoreTusController {
|
|
|
92
93
|
* - DELETE: Terminate upload (if termination extension enabled)
|
|
93
94
|
*/
|
|
94
95
|
@All()
|
|
96
|
+
// The collection path creates uploads; every other method answers 404/412 there (@tus/server).
|
|
97
|
+
@ApiMethods('post')
|
|
95
98
|
@Roles(RoleEnum.S_USER)
|
|
96
99
|
async handleTus(@Req() req: Request, @Res() res: Response): Promise<void> {
|
|
97
100
|
const server = this.tusService.getServer();
|
|
@@ -120,6 +123,9 @@ export class CoreTusController {
|
|
|
120
123
|
* Routes like /tus/:id for HEAD, PATCH, DELETE
|
|
121
124
|
*/
|
|
122
125
|
@All(':id')
|
|
126
|
+
// Status, resume and termination of one upload. GET answers 404 in practice (an unfinished upload
|
|
127
|
+
// fails the offset check, a finished one has left staging); PUT/SEARCH have no tus handler.
|
|
128
|
+
@ApiMethods('head', 'patch', 'delete')
|
|
123
129
|
@Roles(RoleEnum.S_USER)
|
|
124
130
|
async handleTusWithId(@Req() req: Request, @Res() res: Response): Promise<void> {
|
|
125
131
|
const server = this.tusService.getServer();
|
|
@@ -112,10 +112,10 @@ export class CoreTusService implements OnModuleDestroy, OnModuleInit {
|
|
|
112
112
|
* Called by TusModule.forRoot() with the resolved configuration
|
|
113
113
|
*/
|
|
114
114
|
configure(config: boolean | ITusConfig | undefined): void {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
115
|
+
// A disabled configuration must reach onModuleInit() as disabled. Keeping the defaults here
|
|
116
|
+
// started a tus server — upload directory, hourly cleanup interval — behind a module that
|
|
117
|
+
// registers no endpoint at all.
|
|
118
|
+
this.config = normalizeTusConfig(config) ?? { ...DEFAULT_TUS_CONFIG, enabled: false };
|
|
119
119
|
}
|
|
120
120
|
|
|
121
121
|
async onModuleInit(): Promise<void> {
|
|
@@ -557,6 +557,72 @@ export class CoreTusService implements OnModuleDestroy, OnModuleInit {
|
|
|
557
557
|
throw Object.assign(new Error('Upload not found'), { body: 'Upload not found', status_code: 404 });
|
|
558
558
|
}
|
|
559
559
|
|
|
560
|
+
/**
|
|
561
|
+
* The tus extensions the configuration switched off. `@tus/server` consults none of these flags:
|
|
562
|
+
* the store advertises creation and termination whatever is configured, and POST / DELETE are
|
|
563
|
+
* served regardless — so before 11.42.9 `termination: false` was a line in the boot log and nothing
|
|
564
|
+
* else.
|
|
565
|
+
*/
|
|
566
|
+
protected disabledExtensions(): Set<string> {
|
|
567
|
+
const disabled = new Set<string>();
|
|
568
|
+
if (this.config.creation === false) {
|
|
569
|
+
['creation', 'creation-with-upload', 'creation-defer-length'].forEach((extension) => disabled.add(extension));
|
|
570
|
+
}
|
|
571
|
+
if (this.config.creationWithUpload === false) {
|
|
572
|
+
disabled.add('creation-with-upload');
|
|
573
|
+
}
|
|
574
|
+
if (this.config.termination === false) {
|
|
575
|
+
disabled.add('termination');
|
|
576
|
+
}
|
|
577
|
+
return disabled;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Refuses a request that needs an extension the configuration switched off, with the 501 `@tus/server`
|
|
582
|
+
* itself answers for an extension it does not support. Runs before the ownership check, so a
|
|
583
|
+
* refusal says nothing about whether the upload exists.
|
|
584
|
+
*/
|
|
585
|
+
protected assertExtensionEnabled(req: any): void {
|
|
586
|
+
const disabled = this.disabledExtensions();
|
|
587
|
+
const method = String(req?.method ?? '').toUpperCase();
|
|
588
|
+
const contentType = this.readRequestHeader(req, 'content-type')?.toLowerCase() ?? '';
|
|
589
|
+
const needed =
|
|
590
|
+
method === 'DELETE'
|
|
591
|
+
? 'termination'
|
|
592
|
+
: method === 'POST'
|
|
593
|
+
? contentType.startsWith('application/offset+octet-stream')
|
|
594
|
+
? 'creation-with-upload'
|
|
595
|
+
: 'creation'
|
|
596
|
+
: undefined;
|
|
597
|
+
if (needed && disabled.has(needed)) {
|
|
598
|
+
throw Object.assign(new Error(`The ${needed} extension is disabled`), {
|
|
599
|
+
body: `The ${needed} extension is disabled\n`,
|
|
600
|
+
status_code: 501,
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* The path a new upload's URL is built on: the one the creation request arrived at, so the URL
|
|
607
|
+
* points where the controller actually listens — under a global prefix, a URI version or a project's
|
|
608
|
+
* own route alike.
|
|
609
|
+
*
|
|
610
|
+
* Except when the configured `path` is longer and ends with it: then a reverse proxy strips a prefix
|
|
611
|
+
* on the way in (public `/files/tus`, forwarded as `/tus`), and the client must be handed the public
|
|
612
|
+
* path — which is what `path` was set to before 11.42.9 for exactly that reason. Override for a proxy
|
|
613
|
+
* that rewrites the path in any other way.
|
|
614
|
+
*/
|
|
615
|
+
protected uploadCollectionPath(req: any): string {
|
|
616
|
+
const configured = (this.config.path || DEFAULT_TUS_CONFIG.path).replace(/\/+$/, '');
|
|
617
|
+
let requested: string;
|
|
618
|
+
try {
|
|
619
|
+
requested = new URL(String(req?.url ?? ''), 'http://localhost').pathname.replace(/\/+$/, '');
|
|
620
|
+
} catch {
|
|
621
|
+
return configured;
|
|
622
|
+
}
|
|
623
|
+
return configured.length > requested.length && configured.endsWith(requested) ? configured : requested;
|
|
624
|
+
}
|
|
625
|
+
|
|
560
626
|
/**
|
|
561
627
|
* Validate file type against allowedTypes configuration
|
|
562
628
|
*
|
|
@@ -751,6 +817,10 @@ export class CoreTusService implements OnModuleDestroy, OnModuleInit {
|
|
|
751
817
|
*/
|
|
752
818
|
private async createTusServer(uploadDir: string): Promise<Server> {
|
|
753
819
|
const datastore = (await this.createS3Store()) || new FileStore({ directory: uploadDir });
|
|
820
|
+
// The store advertises what it CAN do (OPTIONS → Tus-Extension); narrowed to what is configured.
|
|
821
|
+
datastore.extensions = datastore.extensions.filter(
|
|
822
|
+
(extension: string) => !this.disabledExtensions().has(extension),
|
|
823
|
+
);
|
|
754
824
|
|
|
755
825
|
// @tus/server's default locker is in-memory, which is exclusive within ONE process only:
|
|
756
826
|
// behind a load balancer two replicas each hold their own lock for the same upload id and
|
|
@@ -774,8 +844,26 @@ export class CoreTusService implements OnModuleDestroy, OnModuleInit {
|
|
|
774
844
|
// this: it decides who may reach the endpoint, not which upload they may touch, and the tus
|
|
775
845
|
// protocol is built around a per-upload URL. Appending is the sharp end — bytes PATCHed into
|
|
776
846
|
// somebody else's upload are migrated into the file store under THEIR filename.
|
|
777
|
-
|
|
778
|
-
|
|
847
|
+
// The extension check sits here rather than in the overridable hooks, so a project override that
|
|
848
|
+
// forgets `super` cannot reopen what the configuration switched off.
|
|
849
|
+
onIncomingRequest: async (req, uploadId) => {
|
|
850
|
+
this.assertExtensionEnabled(req);
|
|
851
|
+
return this.assertUploadOwnership(req, uploadId);
|
|
852
|
+
},
|
|
853
|
+
onUploadCreate: async (req, upload) => {
|
|
854
|
+
this.assertExtensionEnabled(req);
|
|
855
|
+
return this.onUploadCreate(req, upload);
|
|
856
|
+
},
|
|
857
|
+
// Where the upload was CREATED, not a configured string: the controller may sit below a global
|
|
858
|
+
// prefix, a URI version or a project's own route, none of which `path` knows — and a Location
|
|
859
|
+
// pointing elsewhere breaks every resume (HEAD / PATCH answer 404).
|
|
860
|
+
// Relative when asked for: by `tus.relativeLocation`, or by @tus/server's own `relativeLocation`
|
|
861
|
+
// passed here by a project, which this generateUrl would otherwise silently override.
|
|
862
|
+
generateUrl: (req, { host, id, proto }) => {
|
|
863
|
+
const url = `${this.uploadCollectionPath(req)}/${id}`;
|
|
864
|
+
const relative = this.config.relativeLocation === true || (server as any)?.options?.relativeLocation === true;
|
|
865
|
+
return relative ? url : `${proto}://${host}${url}`;
|
|
866
|
+
},
|
|
779
867
|
onUploadFinish: async (_req, upload) => {
|
|
780
868
|
try {
|
|
781
869
|
await this.onUploadComplete(upload);
|
|
@@ -1004,26 +1092,9 @@ export class CoreTusService implements OnModuleDestroy, OnModuleInit {
|
|
|
1004
1092
|
* Log which features are enabled
|
|
1005
1093
|
*/
|
|
1006
1094
|
private logEnabledFeatures(): void {
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
features.push('creation');
|
|
1011
|
-
}
|
|
1012
|
-
if (this.config.creationWithUpload !== false) {
|
|
1013
|
-
features.push('creation-with-upload');
|
|
1014
|
-
}
|
|
1015
|
-
if (this.config.termination !== false) {
|
|
1016
|
-
features.push('termination');
|
|
1017
|
-
}
|
|
1018
|
-
if (this.config.expiration !== false) {
|
|
1019
|
-
features.push('expiration');
|
|
1020
|
-
}
|
|
1021
|
-
if (this.config.checksum !== false) {
|
|
1022
|
-
features.push('checksum');
|
|
1023
|
-
}
|
|
1024
|
-
if (this.config.concatenation !== false) {
|
|
1025
|
-
features.push('concatenation');
|
|
1026
|
-
}
|
|
1095
|
+
// What the server answers OPTIONS with, i.e. what a client can actually use — not the config flags:
|
|
1096
|
+
// the stores support neither checksum nor concatenation, whatever the configuration says.
|
|
1097
|
+
const features: string[] = [...((this.tusServer as any)?.datastore?.extensions ?? [])];
|
|
1027
1098
|
|
|
1028
1099
|
if (features.length > 0) {
|
|
1029
1100
|
this.logger.log(`TUS extensions: ${features.join(', ')}`);
|
|
@@ -50,6 +50,7 @@ export const DEFAULT_TUS_CONFIG: Required<
|
|
|
50
50
|
expiration: { enabled: true, expiresIn: '24h' },
|
|
51
51
|
maxSize: 50 * 1024 * 1024 * 1024, // 50 GB
|
|
52
52
|
path: '/tus',
|
|
53
|
+
relativeLocation: false,
|
|
53
54
|
// A tus upload writes into the SAME file store that `file.downloadRoles`
|
|
54
55
|
// guards — GridFS or S3, whichever `file.storage` selects — and with the
|
|
55
56
|
// termination extension it can delete from it too. Requiring a session is the
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { DynamicModule, Global, Logger, Module, OnModuleInit, Type } from '@nestjs/common';
|
|
2
|
+
import { PATH_METADATA } from '@nestjs/common/constants';
|
|
2
3
|
import { ModuleRef } from '@nestjs/core';
|
|
3
4
|
import { getConnectionToken } from '@nestjs/mongoose';
|
|
4
5
|
import { Connection } from 'mongoose';
|
|
@@ -126,6 +127,8 @@ export class TusModule implements OnModuleInit {
|
|
|
126
127
|
private static currentConfig: ITusConfig | null = null;
|
|
127
128
|
private static customController: null | Type<CoreTusController> = null;
|
|
128
129
|
private static customService: null | Type<CoreTusService> = null;
|
|
130
|
+
/** The route each controller class declared, before applyPath() moved it. */
|
|
131
|
+
private static declaredPaths = new WeakMap<Type<CoreTusController>, unknown>();
|
|
129
132
|
|
|
130
133
|
constructor(private readonly tusService?: CoreTusService) {}
|
|
131
134
|
|
|
@@ -178,6 +181,37 @@ export class TusModule implements OnModuleInit {
|
|
|
178
181
|
}
|
|
179
182
|
}
|
|
180
183
|
|
|
184
|
+
/**
|
|
185
|
+
* Makes the controller's route and `tus.path` agree. A configured `path` mounts the controller there
|
|
186
|
+
* — config wins over a re-declared `@Controller()`, as it does for the roles; without one, `path`
|
|
187
|
+
* follows the route the controller declared. Before 11.42.9 the two were independent: the core
|
|
188
|
+
* controller always listened at `/tus` while a configured `path` only changed the URLs handed to the
|
|
189
|
+
* client, so every resume after a non-default `path` answered 404.
|
|
190
|
+
*
|
|
191
|
+
* The declared route is remembered per class, so a later forRoot() without `path` restores it.
|
|
192
|
+
*/
|
|
193
|
+
private static applyPath(controller: Type<CoreTusController>, config: ITusConfig, configuredPath: unknown): void {
|
|
194
|
+
if (!this.declaredPaths.has(controller)) {
|
|
195
|
+
this.declaredPaths.set(controller, Reflect.getMetadata(PATH_METADATA, controller));
|
|
196
|
+
}
|
|
197
|
+
const trim = (value: string) => value.replace(/^\/+|\/+$/g, '');
|
|
198
|
+
const configured = typeof configuredPath === 'string' ? trim(configuredPath) : '';
|
|
199
|
+
const declared = this.declaredPaths.get(controller);
|
|
200
|
+
const declaredRoute = typeof declared === 'string' ? trim(declared) : '';
|
|
201
|
+
// A path that ENDS with the controller's own route (`api/tus` for `tus`) names the URL the route is
|
|
202
|
+
// publicly reachable under — a global prefix or a proxy in front of it. That is how `path` had to be
|
|
203
|
+
// used before 11.42.9, and those setups keep working: the controller stays, upload URLs use the
|
|
204
|
+
// path. Mounting it there would put the prefix in twice (`/api/api/tus`).
|
|
205
|
+
const publicUrl = !!declaredRoute && configured !== declaredRoute && configured.endsWith(`/${declaredRoute}`);
|
|
206
|
+
const route = configured && !publicUrl ? configured : declared;
|
|
207
|
+
Reflect.defineMetadata(PATH_METADATA, route, controller);
|
|
208
|
+
if (configured) {
|
|
209
|
+
config.path = `/${configured}`;
|
|
210
|
+
} else if (typeof route === 'string') {
|
|
211
|
+
config.path = `/${trim(route)}`;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
181
215
|
/**
|
|
182
216
|
* Creates a dynamic module for TUS uploads
|
|
183
217
|
*
|
|
@@ -185,7 +219,14 @@ export class TusModule implements OnModuleInit {
|
|
|
185
219
|
* @returns Dynamic module configuration
|
|
186
220
|
*/
|
|
187
221
|
static forRoot(options: TusModuleOptions = {}): DynamicModule {
|
|
188
|
-
const { config:
|
|
222
|
+
const { config: explicitConfig, controller, service } = options;
|
|
223
|
+
|
|
224
|
+
// Without an explicit `config`, the server configuration's `tus` key applies — the zero-config rule
|
|
225
|
+
// BetterAuthModule.forRoot() follows too: CoreModule.forRoot() runs first and fills ConfigService.
|
|
226
|
+
// Before 11.42.9 that key was never read, so `tus: false`, `allowedTypes` or `maxSize` in
|
|
227
|
+
// config.env.ts did nothing at all.
|
|
228
|
+
const rawConfig =
|
|
229
|
+
explicitConfig !== undefined ? explicitConfig : ConfigService.get<boolean | ITusConfig | undefined>('tus');
|
|
189
230
|
|
|
190
231
|
// Normalize config: undefined/true → enabled with defaults, false → disabled
|
|
191
232
|
const config = normalizeTusConfig(rawConfig);
|
|
@@ -243,6 +284,7 @@ export class TusModule implements OnModuleInit {
|
|
|
243
284
|
// touches the members it knows), or register the controller outside
|
|
244
285
|
// TusModule entirely.
|
|
245
286
|
this.applyRoles(this.getControllerClass(), config.roles);
|
|
287
|
+
this.applyPath(this.getControllerClass(), config, typeof rawConfig === 'object' ? rawConfig?.path : undefined);
|
|
246
288
|
|
|
247
289
|
return {
|
|
248
290
|
controllers: [this.getControllerClass()],
|
package/src/core.module.ts
CHANGED
|
@@ -446,7 +446,8 @@ export class CoreModule implements NestModule {
|
|
|
446
446
|
);
|
|
447
447
|
}
|
|
448
448
|
|
|
449
|
-
|
|
449
|
+
// Presence implies enabled; `enabled: false` keeps the configured checks without the endpoints.
|
|
450
|
+
if (config.healthCheck && config.healthCheck.enabled !== false) {
|
|
450
451
|
imports.push(CoreHealthCheckModule);
|
|
451
452
|
}
|
|
452
453
|
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ export * from './core.module';
|
|
|
9
9
|
// =====================================================================================================================
|
|
10
10
|
export * from './core/common/args/filter.args';
|
|
11
11
|
export * from './core/common/args/pagination.args';
|
|
12
|
+
export * from './core/common/decorators/api-methods.decorator';
|
|
12
13
|
export * from './core/common/decorators/common-error.decorator';
|
|
13
14
|
export * from './core/common/decorators/current-user.decorator';
|
|
14
15
|
export * from './core/common/decorators/graphql-populate.decorator';
|