@lenne.tech/nest-server 11.41.3 → 11.41.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/.claude/rules/architecture.md +1 -0
  2. package/.claude/rules/configurable-features.md +1 -0
  3. package/.claude/rules/role-system.md +15 -1
  4. package/.claude/rules/testing.md +16 -4
  5. package/CLAUDE.md +6 -3
  6. package/FRAMEWORK-API.md +4 -1
  7. package/dist/core/common/helpers/graceful-shutdown.helper.js.map +1 -1
  8. package/dist/core/common/helpers/logging.helper.js +2 -0
  9. package/dist/core/common/helpers/logging.helper.js.map +1 -1
  10. package/dist/core/common/helpers/process-diagnostics.helper.js.map +1 -1
  11. package/dist/core/common/interfaces/server-options.interface.d.ts +21 -1
  12. package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.d.ts +2 -2
  13. package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js +46 -6
  14. package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js.map +1 -1
  15. package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.js +7 -1
  16. package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.js.map +1 -1
  17. package/dist/core/modules/api-token/core-api-token.constants.d.ts +6 -0
  18. package/dist/core/modules/api-token/core-api-token.constants.js +11 -0
  19. package/dist/core/modules/api-token/core-api-token.constants.js.map +1 -0
  20. package/dist/core/modules/api-token/core-api-token.decorators.d.ts +1 -0
  21. package/dist/core/modules/api-token/core-api-token.decorators.js +8 -0
  22. package/dist/core/modules/api-token/core-api-token.decorators.js.map +1 -0
  23. package/dist/core/modules/api-token/core-api-token.helpers.d.ts +127 -0
  24. package/dist/core/modules/api-token/core-api-token.helpers.js +398 -0
  25. package/dist/core/modules/api-token/core-api-token.helpers.js.map +1 -0
  26. package/dist/core/modules/api-token/core-api-token.middleware.d.ts +10 -0
  27. package/dist/core/modules/api-token/core-api-token.middleware.js +58 -0
  28. package/dist/core/modules/api-token/core-api-token.middleware.js.map +1 -0
  29. package/dist/core/modules/api-token/core-api-token.model.d.ts +20 -0
  30. package/dist/core/modules/api-token/core-api-token.model.js +199 -0
  31. package/dist/core/modules/api-token/core-api-token.model.js.map +1 -0
  32. package/dist/core/modules/api-token/core-api-token.module.d.ts +11 -0
  33. package/dist/core/modules/api-token/core-api-token.module.js +39 -0
  34. package/dist/core/modules/api-token/core-api-token.module.js.map +1 -0
  35. package/dist/core/modules/api-token/core-api-token.registry.d.ts +9 -0
  36. package/dist/core/modules/api-token/core-api-token.registry.js +17 -0
  37. package/dist/core/modules/api-token/core-api-token.registry.js.map +1 -0
  38. package/dist/core/modules/api-token/core-api-token.service.d.ts +104 -0
  39. package/dist/core/modules/api-token/core-api-token.service.js +550 -0
  40. package/dist/core/modules/api-token/core-api-token.service.js.map +1 -0
  41. package/dist/core/modules/auth/guards/roles.guard.js +17 -1
  42. package/dist/core/modules/auth/guards/roles.guard.js.map +1 -1
  43. package/dist/core/modules/better-auth/better-auth-roles.guard.js +12 -2
  44. package/dist/core/modules/better-auth/better-auth-roles.guard.js.map +1 -1
  45. package/dist/core/modules/better-auth/core-better-auth.middleware.js +4 -0
  46. package/dist/core/modules/better-auth/core-better-auth.middleware.js.map +1 -1
  47. package/dist/core/modules/better-auth/core-better-auth.module.d.ts +4 -0
  48. package/dist/core/modules/better-auth/core-better-auth.module.js +18 -0
  49. package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
  50. package/dist/core/modules/migrate/migration-runner.d.ts +1 -0
  51. package/dist/core/modules/migrate/migration-runner.js +3 -0
  52. package/dist/core/modules/migrate/migration-runner.js.map +1 -1
  53. package/dist/core/modules/tenant/core-tenant-guard.registry.d.ts +2 -0
  54. package/dist/core/modules/tenant/core-tenant-guard.registry.js +19 -0
  55. package/dist/core/modules/tenant/core-tenant-guard.registry.js.map +1 -0
  56. package/dist/core/modules/tenant/core-tenant.guard.d.ts +1 -0
  57. package/dist/core/modules/tenant/core-tenant.guard.js +28 -12
  58. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
  59. package/dist/core/modules/tenant/core-tenant.helpers.d.ts +1 -0
  60. package/dist/core/modules/tenant/core-tenant.helpers.js +19 -0
  61. package/dist/core/modules/tenant/core-tenant.helpers.js.map +1 -1
  62. package/dist/core/modules/tenant/core-tenant.module.d.ts +5 -2
  63. package/dist/core/modules/tenant/core-tenant.module.js +8 -0
  64. package/dist/core/modules/tenant/core-tenant.module.js.map +1 -1
  65. package/dist/core/modules/user/core-user.service.js +7 -0
  66. package/dist/core/modules/user/core-user.service.js.map +1 -1
  67. package/dist/core.module.js +6 -0
  68. package/dist/core.module.js.map +1 -1
  69. package/dist/index.d.ts +7 -0
  70. package/dist/index.js +7 -0
  71. package/dist/index.js.map +1 -1
  72. package/dist/tsconfig.build.tsbuildinfo +1 -1
  73. package/docs/REQUEST-LIFECYCLE.md +49 -1
  74. package/docs/security-overrides.md +21 -13
  75. package/migration-guides/11.41.3-to-11.41.4.md +172 -0
  76. package/migration-guides/11.41.4-to-11.41.5.md +144 -0
  77. package/package.json +35 -34
  78. package/src/core/common/helpers/graceful-shutdown.helper.ts +9 -0
  79. package/src/core/common/helpers/logging.helper.ts +7 -0
  80. package/src/core/common/helpers/process-diagnostics.helper.ts +4 -0
  81. package/src/core/common/interfaces/server-options.interface.ts +122 -6
  82. package/src/core/modules/ai/INTEGRATION-CHECKLIST.md +17 -10
  83. package/src/core/modules/ai/README.md +9 -1
  84. package/src/core/modules/ai/helpers/ai-mcp-oauth.helper.ts +74 -7
  85. package/src/core/modules/ai/services/core-ai-mcp-oauth.service.ts +13 -1
  86. package/src/core/modules/api-token/INTEGRATION-CHECKLIST.md +121 -0
  87. package/src/core/modules/api-token/README.md +212 -0
  88. package/src/core/modules/api-token/core-api-token.constants.ts +27 -0
  89. package/src/core/modules/api-token/core-api-token.decorators.ts +29 -0
  90. package/src/core/modules/api-token/core-api-token.helpers.ts +711 -0
  91. package/src/core/modules/api-token/core-api-token.middleware.ts +57 -0
  92. package/src/core/modules/api-token/core-api-token.model.ts +193 -0
  93. package/src/core/modules/api-token/core-api-token.module.ts +48 -0
  94. package/src/core/modules/api-token/core-api-token.registry.ts +53 -0
  95. package/src/core/modules/api-token/core-api-token.service.ts +822 -0
  96. package/src/core/modules/auth/guards/roles.guard.ts +23 -2
  97. package/src/core/modules/better-auth/better-auth-roles.guard.ts +18 -4
  98. package/src/core/modules/better-auth/core-better-auth.middleware.ts +8 -0
  99. package/src/core/modules/better-auth/core-better-auth.module.ts +33 -0
  100. package/src/core/modules/migrate/README.md +15 -7
  101. package/src/core/modules/migrate/migration-runner.ts +9 -0
  102. package/src/core/modules/tenant/README.md +17 -0
  103. package/src/core/modules/tenant/core-tenant-guard.registry.ts +36 -0
  104. package/src/core/modules/tenant/core-tenant.guard.ts +52 -12
  105. package/src/core/modules/tenant/core-tenant.helpers.ts +30 -0
  106. package/src/core/modules/tenant/core-tenant.module.ts +19 -2
  107. package/src/core/modules/user/core-user.service.ts +14 -0
  108. package/src/core.module.ts +12 -0
  109. package/src/index.ts +12 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "11.41.3",
3
+ "version": "11.41.5",
4
4
  "description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).",
5
5
  "keywords": [
6
6
  "node",
@@ -16,8 +16,8 @@
16
16
  "scripts": {
17
17
  "build": "rimraf dist && nest build && pnpm run build:copy-types && pnpm run build:copy-templates && pnpm run build:add-type-references && pnpm run build:framework-api",
18
18
  "build:add-type-references": "node scripts/add-type-references.js",
19
- "build:copy-templates": "mkdir -p dist/core/modules/migrate/templates && cp src/core/modules/migrate/templates/migration-project.template.ts dist/core/modules/migrate/templates/",
20
- "build:copy-types": "mkdir -p dist/types && cp src/types/*.d.ts dist/types/",
19
+ "build:copy-templates": "node scripts/copy-build-assets.mjs templates",
20
+ "build:copy-types": "node scripts/copy-build-assets.mjs types",
21
21
  "build:dev": "pnpm run build",
22
22
  "build:framework-api": "tsx scripts/generate-framework-api.ts",
23
23
  "build:pack": "pnpm pack && echo \"use file:/ROOT_PATH_TO_TGZ_FILE to integrate the package\"",
@@ -25,12 +25,13 @@
25
25
  "cf": "pnpm run check:fix",
26
26
  "check": "node scripts/check.mjs",
27
27
  "check:consumer": "node scripts/check-consumer.mjs",
28
- "check:fix": "pnpm install && pnpm run spectaql:sync && pnpm audit --fix && pnpm run check:overrides && pnpm peers check && pnpm run format && pnpm run lint:fix && pnpm run typecheck:tests && pnpm run check:swc-tdz && pnpm test && pnpm run build && pnpm run check:manifest && bash scripts/check-server-start.sh",
28
+ "check:fix": "pnpm install && pnpm run spectaql:sync && pnpm audit --fix && pnpm run check:overrides && pnpm peers check && pnpm run format && pnpm run lint:fix && pnpm run typecheck:tests && pnpm run check:swc-tdz && pnpm test && pnpm run build && pnpm run check:manifest && pnpm run check:server-start",
29
29
  "check:manifest": "node scripts/check-package-manifest.mjs",
30
30
  "check:mutations": "node scripts/check-mutations.mjs",
31
- "check:naf": "pnpm install && pnpm run spectaql:sync && pnpm run check:overrides && pnpm peers check && pnpm run format && pnpm run lint:fix && pnpm run typecheck:tests && pnpm run check:swc-tdz && pnpm test && pnpm run build && pnpm run check:manifest && bash scripts/check-server-start.sh",
31
+ "check:naf": "pnpm install && pnpm run spectaql:sync && pnpm run check:overrides && pnpm peers check && pnpm run format && pnpm run lint:fix && pnpm run typecheck:tests && pnpm run check:swc-tdz && pnpm test && pnpm run build && pnpm run check:manifest && pnpm run check:server-start",
32
32
  "check:overrides": "node scripts/check-overrides.mjs",
33
- "check:raw": "pnpm install --frozen-lockfile && pnpm run spectaql:sync && pnpm audit && pnpm run check:overrides && pnpm peers check && pnpm run format:check && pnpm run lint && pnpm run typecheck:tests && pnpm run check:swc-tdz && pnpm test && pnpm run build && pnpm run check:manifest && bash scripts/check-server-start.sh",
33
+ "check:raw": "pnpm install --frozen-lockfile && pnpm run spectaql:sync && pnpm audit && pnpm run check:overrides && pnpm peers check && pnpm run format:check && pnpm run lint && pnpm run typecheck:tests && pnpm run check:swc-tdz && pnpm test && pnpm run build && pnpm run check:manifest && pnpm run check:server-start",
34
+ "check:server-start": "cross-env NODE_ENV=local node scripts/check-server-start.mjs --entry=dist/main.js --port-env=NSC__PORT --ready=\"Server starte[dt] at\" --path=/",
34
35
  "check:swc-tdz": "nest build -b swc -p tsconfig.swc-tdz.json && node scripts/check-swc-tdz.mjs",
35
36
  "cnaf": "pnpm run check:naf",
36
37
  "docs": "pnpm run docs:ci && open http://127.0.0.1:8080/ && open ./public/index.html && compodoc -p tsconfig.json -s ",
@@ -92,50 +93,50 @@
92
93
  "@apollo/server": "5.5.1",
93
94
  "@as-integrations/express5": "1.1.2",
94
95
  "@getbrevo/brevo": "6.0.3",
95
- "@modelcontextprotocol/sdk": "1.30.0",
96
+ "@modelcontextprotocol/sdk": "1.31.0",
96
97
  "@nestjs/apollo": "13.4.5",
97
- "@nestjs/common": "11.2.1",
98
- "@nestjs/core": "11.2.1",
98
+ "@nestjs/common": "11.2.6",
99
+ "@nestjs/core": "11.2.6",
99
100
  "@nestjs/graphql": "13.4.5",
100
101
  "@nestjs/jwt": "11.0.2",
101
102
  "@nestjs/mongoose": "11.0.4",
102
103
  "@nestjs/passport": "11.0.5",
103
- "@nestjs/platform-express": "11.2.1",
104
+ "@nestjs/platform-express": "11.2.6",
104
105
  "@nestjs/schedule": "6.1.3",
105
106
  "@nestjs/swagger": "11.4.7",
106
107
  "@nestjs/terminus": "11.1.1",
107
108
  "@tus/file-store": "2.1.1",
108
- "@tus/server": "2.4.4",
109
+ "@tus/server": "2.4.5",
109
110
  "@types/supertest": "7.2.1",
110
111
  "bcrypt": "6.0.0",
111
112
  "class-transformer": "0.5.1",
112
113
  "class-validator": "0.15.1",
113
- "compression": "1.8.1",
114
+ "compression": "1.8.2",
114
115
  "cookie-parser": "1.4.7",
115
116
  "cron": "4.4.0",
116
117
  "dotenv": "17.4.2",
117
118
  "ejs": "6.0.1",
118
119
  "express": "5.2.1",
119
- "graphql": "16.14.0",
120
+ "graphql": "16.14.2",
120
121
  "graphql-query-complexity": "2.0.0",
121
122
  "graphql-subscriptions": "3.0.0",
122
123
  "graphql-upload": "15.0.2",
123
124
  "graphql-ws": "6.2.1",
124
- "jose": "6.2.9",
125
+ "jose": "6.2.12",
125
126
  "js-sha256": "1.0.0",
126
127
  "json-to-graphql-query": "2.3.0",
127
128
  "lodash": "4.18.1",
128
- "mongodb": "7.5.0",
129
- "mongoose": "9.9.3",
130
- "multer": "2.3.0",
129
+ "mongodb": "7.6.0",
130
+ "mongoose": "9.10.3",
131
+ "multer": "2.4.0",
131
132
  "node-mailjet": "6.0.11",
132
- "nodemailer": "9.1.1",
133
+ "nodemailer": "10.0.13",
133
134
  "passport": "0.7.0",
134
135
  "passport-jwt": "4.0.1",
135
136
  "reflect-metadata": "0.2.2",
136
137
  "rfdc": "1.4.1",
137
138
  "rxjs": "7.8.2",
138
- "supertest": "7.2.2",
139
+ "supertest": "7.3.0",
139
140
  "ts-morph": "28.0.0",
140
141
  "ws": "8.21.3",
141
142
  "yuml-diagram": "1.2.0"
@@ -177,47 +178,47 @@
177
178
  }
178
179
  },
179
180
  "devDependencies": {
180
- "@aws-sdk/client-s3": "3.1115.0",
181
- "@aws-sdk/s3-request-presigner": "3.1115.0",
181
+ "@aws-sdk/client-s3": "3.1143.0",
182
+ "@aws-sdk/s3-request-presigner": "3.1143.0",
182
183
  "@better-auth/core": "1.7.1",
183
184
  "@better-auth/passkey": "1.7.1",
184
185
  "@compodoc/compodoc": "2.0.0",
185
186
  "@nestjs/cli": "11.0.24",
186
187
  "@nestjs/schematics": "11.1.0",
187
- "@nestjs/testing": "11.2.1",
188
+ "@nestjs/testing": "11.2.6",
188
189
  "@swc/cli": "0.8.1",
189
- "@swc/core": "1.16.1",
190
- "@tus/s3-store": "2.0.6",
190
+ "@swc/core": "1.16.12",
191
+ "@tus/s3-store": "2.0.7",
191
192
  "@types/compression": "1.8.1",
192
193
  "@types/cookie-parser": "1.4.10",
193
194
  "@types/ejs": "3.1.5",
194
195
  "@types/express": "5.0.6",
195
196
  "@types/lodash": "4.17.25",
196
- "@types/multer": "2.2.0",
197
- "@types/node": "26.2.0",
198
- "@types/nodemailer": "8.0.1",
197
+ "@types/multer": "2.3.0",
198
+ "@types/node": "26.6.3",
199
+ "@types/nodemailer": "8.0.2",
199
200
  "@types/passport": "1.0.17",
200
201
  "@vitest/coverage-v8": "4.1.11",
201
202
  "ansi-colors": "4.1.3",
202
203
  "better-auth": "1.7.1",
203
- "bullmq": "6.2.0",
204
+ "bullmq": "6.3.10",
204
205
  "cross-env": "10.1.0",
205
206
  "find-file-up": "2.0.1",
206
207
  "husky": "9.1.7",
207
208
  "ioredis": "6.0.0",
208
209
  "nodemon": "3.1.14",
209
210
  "npm-watch": "0.13.0",
210
- "otpauth": "9.5.1",
211
- "oxfmt": "0.64.0",
212
- "oxlint": "1.79.0",
211
+ "otpauth": "9.5.2",
212
+ "oxfmt": "0.71.0",
213
+ "oxlint": "1.86.0",
213
214
  "rimraf": "6.1.3",
214
215
  "ts-node": "10.9.2",
215
216
  "tsconfig-paths": "4.2.0",
216
- "tsx": "4.23.12",
217
+ "tsx": "4.23.15",
217
218
  "tus-js-client": "4.3.1",
218
219
  "typescript": "5.9.3",
219
- "unplugin-swc": "1.5.11",
220
- "vite": "8.2.2",
220
+ "unplugin-swc": "2.0.0",
221
+ "vite": "8.3.1",
221
222
  "vite-plugin-node": "8.0.0",
222
223
  "vitest": "4.1.11"
223
224
  },
@@ -47,6 +47,15 @@ const SHUTDOWN_DELAY_ADVISORY_MS = 10_000;
47
47
  * Without a configured delay this is exactly `app.enableShutdownHooks()`, which is what the
48
48
  * framework did before.
49
49
  *
50
+ * **Windows: a termination from outside runs none of this.** Windows has no SIGTERM to deliver.
51
+ * Ending a process from outside, whether by `child.kill()`, `taskkill /F` (what `lt dev down` and
52
+ * the `check` watchdog use) or the Task Manager, terminates it at once. Measured on the Windows CI
53
+ * runner (`tests/unit/process-diagnostics-signal.spec.ts`): the process exits and the handler
54
+ * never runs. So on Windows neither `shutdownDelayMs` nor any `onModuleDestroy` /
55
+ * `onApplicationShutdown` hook runs on such a termination. Ctrl+C in a console window reaches Node
56
+ * as SIGINT and SHOULD reach this handler — that path is **unmeasured**. Do not rely on a graceful
57
+ * drain there; production targets Linux containers, where all of the above holds.
58
+ *
50
59
  * @param app the Nest application to shut down
51
60
  * @returns the same app, so it can be chained
52
61
  */
@@ -171,6 +171,13 @@ export function redactSensitiveText(text: string): string {
171
171
  /(\/(?:request-password-reset|reset-password|forget-password|forgot-password|set-password|change-email|magic-?link|verify|reset|confirm|activate|invite)\/)([A-Za-z0-9._~-]{16,})/gi,
172
172
  (_m, prefix, token) => `${prefix}${maskToken(token)}`,
173
173
  )
174
+ // tenant API tokens (`<prefix>_<24 hex>_<64 hex>`) and their signed assertions
175
+ // (`<prefix>s_<payload>.<43-char HMAC>`) anywhere in the line — they are long-lived bearer
176
+ // credentials, and a line that quotes one outside an Authorization header would pass the rule below
177
+ .replace(/\b[a-z][a-z0-9]{1,15}_[0-9a-f]{24}_[0-9a-f]{64}\b/g, (match) => maskToken(match))
178
+ .replace(/\b[a-z][a-z0-9]{1,15}s_[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])/g, (match) =>
179
+ maskToken(match),
180
+ )
174
181
  // authorization: Bearer xyz / Authorization=xyz
175
182
  .replace(
176
183
  /(authorization["']?\s*[:=]\s*["']?)(?:Bearer\s+)?([^\s"',;]+)/gi,
@@ -208,6 +208,10 @@ function describeError(value: unknown): string {
208
208
  * Call this as the first statement of `bootstrap()`, before `NestFactory.create()`. See the module
209
209
  * docblock for why it must not live inside `CoreModule.forRoot()`.
210
210
  *
211
+ * On Windows the signal labels are not written when the process is ended from outside: there is
212
+ * no signal to deliver, the process is terminated at once and no handler runs (measured on the
213
+ * Windows CI runner, see `installGracefulShutdown()`). Ctrl+C in a console is unmeasured.
214
+ *
211
215
  * @param options - Injectable dependencies; defaults target the real `process`
212
216
  *
213
217
  * @example
@@ -37,19 +37,25 @@ export type BetterAuthFieldType = 'boolean' | 'date' | 'json' | 'number' | 'numb
37
37
  * particularly useful in CI / e2e tests: `{ jsonTransport: true }` serializes
38
38
  * outgoing mail to a JSON string and returns a valid response without any
39
39
  * network I/O — no SMTP server, no credentials, no flakiness.
40
+ *
41
+ * The `X.default` members are the transport INSTANCE types. nodemailer >= 10 ships its own
42
+ * declarations, where each `nodemailer/lib/<transport>` module is `export default class` plus a
43
+ * module-level `Options` alias, so `import type * as X` yields a module namespace — the class is
44
+ * `X.default`. (`@types/nodemailer` used `export =` with a merged namespace, where `X` itself
45
+ * was the class.)
40
46
  */
41
47
  export type MailTransportOptions =
42
- | JSONTransport
48
+ | JSONTransport.default
43
49
  | JSONTransport.Options
44
- | SendmailTransport
50
+ | SendmailTransport.default
45
51
  | SendmailTransport.Options
46
- | SESTransport
52
+ | SESTransport.default
47
53
  | SESTransport.Options
48
- | SMTPPool
54
+ | SMTPPool.default
49
55
  | SMTPPool.Options
50
- | SMTPTransport
56
+ | SMTPTransport.default
51
57
  | SMTPTransport.Options
52
- | StreamTransport
58
+ | StreamTransport.default
53
59
  | StreamTransport.Options
54
60
  | string;
55
61
 
@@ -1473,6 +1479,86 @@ export interface IMultiTenancy {
1473
1479
  cacheTtlMs?: number;
1474
1480
  }
1475
1481
 
1482
+ /**
1483
+ * Configuration of API tokens (`apiTokens`).
1484
+ *
1485
+ * Two kinds share one model and one policy:
1486
+ * - USER tokens act as their user with the user's CURRENT rights (never global roles), optionally
1487
+ * narrowed to scopes, one tenant and a maximum tenant role. Work with and without multi-tenancy.
1488
+ * - TENANT tokens belong to a tenant, are managed by its administrators and act with the lowest tenant
1489
+ * role inside that tenant only. Available while multi-tenancy is active.
1490
+ *
1491
+ * Both are denied on every route that does not declare `@ApiTokenScopes(...)`.
1492
+ *
1493
+ * @since 11.41.4
1494
+ */
1495
+ export interface IApiTokens {
1496
+ /**
1497
+ * Pre-configure without enabling.
1498
+ * @default true (when the object is present)
1499
+ */
1500
+ enabled?: boolean;
1501
+
1502
+ /**
1503
+ * Pass-phrase for the AES-256-GCM encryption of each token's signing key (used for signed
1504
+ * assertions). Falls back to the `SECRETS_ENCRYPTION_KEY` environment variable. REQUIRED in
1505
+ * `production` / `staging` — the boot fails without it; elsewhere an insecure development default
1506
+ * is used with a warning. Rotating it invalidates the signing keys of all existing tokens.
1507
+ */
1508
+ encryptionKey?: string;
1509
+
1510
+ /**
1511
+ * Tenant role required to create, list, change, revoke and delete TENANT tokens. Hierarchy roles
1512
+ * compare by level, so higher roles qualify too. Must be a declared tenant role that the lowest
1513
+ * hierarchy role (the role a tenant token acts with) does not reach. Platform admins qualify while
1514
+ * `multiTenancy.adminBypass` is on.
1515
+ * @default the highest role of `multiTenancy.roleHierarchy`
1516
+ */
1517
+ manageRole?: string;
1518
+
1519
+ /**
1520
+ * Maximum lifetime of a signed assertion, measured from the moment it is presented. A longer-lived
1521
+ * assertion is refused (401). An invalid value falls back to the default — never to "unbounded".
1522
+ * @default 900 (15 minutes)
1523
+ */
1524
+ maxAssertionLifetimeSeconds?: number;
1525
+
1526
+ /**
1527
+ * Recognisable token prefix: 2-16 characters, lowercase letters and digits, starting with a letter.
1528
+ * Tokens read `<prefix>_<publicId>_<secret>`, assertions `<prefix>s_<payload>.<signature>`.
1529
+ * @default 'ltt'
1530
+ */
1531
+ prefix?: string;
1532
+
1533
+ /**
1534
+ * Per-token request limit (fixed window, shared across replicas when `redis` is configured).
1535
+ * An exceeded limit answers 429 with `Retry-After`. `false` switches it off.
1536
+ * @default { max: 600, windowSeconds: 60 }
1537
+ */
1538
+ rateLimit?: boolean | { enabled?: boolean; max?: number; windowSeconds?: number };
1539
+
1540
+ /**
1541
+ * Vocabulary of scopes a token may carry (e.g. `['upload', 'read', 'export']`). Creating or updating
1542
+ * a token with any other scope fails with 400; with an empty vocabulary no token can be created.
1543
+ * A user token created without scopes receives the whole vocabulary.
1544
+ * Scopes: 1-64 characters of letters, digits, `:`, `.`, `_`, `-`.
1545
+ * @default []
1546
+ */
1547
+ scopes?: string[];
1548
+
1549
+ /**
1550
+ * Allow tenant tokens. Only takes effect while multi-tenancy is active.
1551
+ * @default true
1552
+ */
1553
+ tenantTokens?: boolean;
1554
+
1555
+ /**
1556
+ * Allow user tokens.
1557
+ * @default true
1558
+ */
1559
+ userTokens?: boolean;
1560
+ }
1561
+
1476
1562
  /**
1477
1563
  * Cookie configuration for authentication handling.
1478
1564
  *
@@ -2020,6 +2106,20 @@ export interface IServerOptions {
2020
2106
  */
2021
2107
  appUrl?: string;
2022
2108
 
2109
+ /**
2110
+ * API tokens: bearer credentials for machine clients and embedded pages that cannot carry a session
2111
+ * cookie. USER tokens act as their user (without global roles); TENANT tokens belong to a tenant
2112
+ * (multi-tenancy only). Both are denied on every route that does not declare `@ApiTokenScopes(...)`,
2113
+ * and both respect tenant boundaries whenever multi-tenancy is active.
2114
+ *
2115
+ * Boolean shorthand: `true` / `{}` enable with defaults, `{ enabled: false }` pre-configures,
2116
+ * absent = off (no behaviour change). See `src/core/modules/api-token/README.md`.
2117
+ *
2118
+ * @default undefined (disabled)
2119
+ * @since 11.41.4
2120
+ */
2121
+ apiTokens?: boolean | IApiTokens;
2122
+
2023
2123
  /**
2024
2124
  * Authentication system configuration
2025
2125
  *
@@ -4322,6 +4422,22 @@ interface IBetterAuthWithPasskey extends IBetterAuthBase {
4322
4422
  * @since 11.22.0
4323
4423
  */
4324
4424
  export interface ICoreModuleOverrides {
4425
+ /**
4426
+ * Override API token collaborators with project-specific subclasses (`apiTokens` config).
4427
+ *
4428
+ * - `model` must extend `CoreApiTokenModel` (e.g. to bind a token to project data)
4429
+ * - `service` must extend `CoreApiTokenService`
4430
+ *
4431
+ * @example
4432
+ * ```typescript
4433
+ * { apiToken: { model: ApiToken, service: ApiTokenService } }
4434
+ * ```
4435
+ */
4436
+ apiToken?: {
4437
+ model?: Type<any>;
4438
+ service?: Type<any>;
4439
+ };
4440
+
4325
4441
  /**
4326
4442
  * Override AI module collaborators with project-specific subclasses.
4327
4443
  *
@@ -209,9 +209,15 @@ The response body carries the stable `#LTNS_0901` code, never the raw error.
209
209
 
210
210
  ```typescript
211
211
  import { mountAiMcpOAuth } from '@lenne.tech/nest-server';
212
- await mountAiMcpOAuth(app, { baseUrl: process.env.BASE_URL });
212
+ await mountAiMcpOAuth(app);
213
213
  ```
214
214
 
215
+ **WHY no `baseUrl` argument:** the issuer is every URL in the discovery metadata, and MCP clients
216
+ follow them. The helper takes it from the server's `baseUrl` (`NSC__BASE_URL` when deployed) and
217
+ fails the boot in a deployed environment that has none, rather than advertising `localhost` —
218
+ which sends clients to the user's own machine. Pass `{ baseUrl }` only for an issuer that must
219
+ differ from the server's `baseUrl`; never add a `localhost` fallback of your own.
220
+
215
221
  Override `CoreAiMcpOAuthService.authorizeConsent()` with your login/consent UI (the only
216
222
  browser-interactive step). All other OAuth pieces (tokens, PKCE, stores) are built in.
217
223
 
@@ -231,12 +237,13 @@ browser-interactive step). All other OAuth pieces (tokens, PKCE, stores) are bui
231
237
 
232
238
  ## Common Mistakes
233
239
 
234
- | Mistake | Symptom | Fix |
235
- | --------------------------------------------- | ------------------------------------------------------------- | --------------------------------------------------------- |
236
- | No `ai` config block | Module not loaded, `aiPrompt` missing from schema | Add an `ai` block (presence implies enabled) |
237
- | No encryption secret in prod | **App refuses to boot** (throws); dev/local only warns | Set `NSC__AI__ENCRYPTION_SECRET` (32+ chars) |
238
- | Encryption secret changed after keys stored | Boot logs "key(s) could not be decrypted"; those prompts fail | Re-enter the API key for the listed connections |
239
- | Tool returns `.lean()`/aggregate data | `@Restricted` fields leak into the LLM context | Route through `CrudService` with `context.serviceOptions` |
240
- | Tool not registered | Tool never offered to the LLM | Declare it as a provider in a module (extends `AiTool`) |
241
- | Overridden resolver method missing decorators | Method absent from GraphQL schema | Re-declare `@Mutation`/`@Query`/`@Roles` in the override |
242
- | Storing a real API key in `config.env.ts` | Secret committed to the repo | Use `apiKeyEnv` or the runtime connection CRUD |
240
+ | Mistake | Symptom | Fix |
241
+ | ------------------------------------------------ | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
242
+ | No `ai` config block | Module not loaded, `aiPrompt` missing from schema | Add an `ai` block (presence implies enabled) |
243
+ | No encryption secret in prod | **App refuses to boot** (throws); dev/local only warns | Set `NSC__AI__ENCRYPTION_SECRET` (32+ chars) |
244
+ | Encryption secret changed after keys stored | Boot logs "key(s) could not be decrypted"; those prompts fail | Re-enter the API key for the listed connections |
245
+ | Tool returns `.lean()`/aggregate data | `@Restricted` fields leak into the LLM context | Route through `CrudService` with `context.serviceOptions` |
246
+ | Tool not registered | Tool never offered to the LLM | Declare it as a provider in a module (extends `AiTool`) |
247
+ | Overridden resolver method missing decorators | Method absent from GraphQL schema | Re-declare `@Mutation`/`@Query`/`@Roles` in the override |
248
+ | Storing a real API key in `config.env.ts` | Secret committed to the repo | Use `apiKeyEnv` or the runtime connection CRUD |
249
+ | `localhost` fallback for the MCP OAuth `baseUrl` | MCP client fails to register with `ECONNREFUSED`; discovery metadata names `localhost` | Call `mountAiMcpOAuth(app)` without `baseUrl`, set `NSC__BASE_URL` |
@@ -657,9 +657,17 @@ also accepts OAuth access tokens. Mount the discovery/token endpoints in `main.t
657
657
  ```typescript
658
658
  // main.ts, after app.init()
659
659
  import { mountAiMcpOAuth } from '@lenne.tech/nest-server';
660
- await mountAiMcpOAuth(app, { baseUrl: process.env.BASE_URL });
660
+ await mountAiMcpOAuth(app);
661
661
  ```
662
662
 
663
+ The issuer — and with it every endpoint URL in the discovery metadata, which MCP clients follow —
664
+ is the server's `baseUrl` (`NSC__BASE_URL` when deployed), resolved the same way BetterAuth and
665
+ CORS resolve it. `local` / `ci` / `e2e` fall back to `http://localhost:3000`; any other environment
666
+ without a `baseUrl` fails the call instead of guessing, because a deployed API that advertised
667
+ `http://localhost:3000` sent Claude Code off to register the client on the user's own machine.
668
+ Pass `{ baseUrl }` only when the issuer must differ from the server's `baseUrl`, and do not add a
669
+ `localhost` fallback of your own. The resolved issuer is logged once at boot.
670
+
663
671
  Override `CoreAiMcpOAuthService.authorizeConsent()` to wire your login/consent UI.
664
672
  Set `ai.mcp.oauthSecret` (or reuse `ai.encryptionSecret`) to a random 32+ char value.
665
673
 
@@ -1,3 +1,7 @@
1
+ import { Logger } from '@nestjs/common';
2
+
3
+ import { resolveServerUrls } from '../../../common/helpers/cookies.helper';
4
+ import { ConfigService } from '../../../common/services/config.service';
1
5
  import { CoreAiMcpOAuthService } from '../services/core-ai-mcp-oauth.service';
2
6
 
3
7
  /**
@@ -11,25 +15,88 @@ import { CoreAiMcpOAuthService } from '../services/core-ai-mcp-oauth.service';
11
15
  * The interactive consent step requires `CoreAiMcpOAuthService.authorizeConsent`
12
16
  * to be overridden with your login/consent UI (see INTEGRATION-CHECKLIST).
13
17
  *
18
+ * The base URL must be the server's public URL: it becomes the OAuth issuer and
19
+ * every endpoint in the discovery metadata, and MCP clients follow those URLs.
20
+ * Resolution order:
21
+ *
22
+ * 1. `options.baseUrl`, when non-blank — for an issuer that differs from `baseUrl`
23
+ * 2. `baseUrl` from the server config (`NSC__BASE_URL` in deployed environments)
24
+ * 3. `http://localhost:3000` in `local` / `ci` / `e2e` only
25
+ * 4. otherwise the call throws — it never guesses
26
+ *
27
+ * Steps 2 and 3 are `resolveServerUrls()`, the resolver BetterAuth and CORS use, so
28
+ * the issuer agrees with the URL the rest of the server believes it has. Step 4 is
29
+ * the point: a deployed API that advertised `http://localhost:3000` made Claude Code
30
+ * try to register the client on the user's own machine (`ECONNREFUSED`).
31
+ *
14
32
  * @example
15
33
  * ```typescript
16
34
  * // main.ts, after app.init()
17
- * await mountAiMcpOAuth(app, { baseUrl: process.env.BASE_URL });
35
+ * await mountAiMcpOAuth(app);
18
36
  * ```
19
37
  */
20
38
  export async function mountAiMcpOAuth(
21
39
  app: { get: (token: any) => any; use: (...args: any[]) => any },
22
- options: { baseUrl: string; mcpPath?: string },
40
+ options: { baseUrl?: string; mcpPath?: string } = {},
23
41
  ): Promise<void> {
42
+ const { baseUrl, source } = resolveMcpOAuthBaseUrl(options.baseUrl);
43
+ const issuerUrl = parseAbsoluteUrl(baseUrl, source);
24
44
  const { mcpAuthRouter } = await import('@modelcontextprotocol/sdk/server/auth/router.js');
25
45
  const oauthService: CoreAiMcpOAuthService = app.get(CoreAiMcpOAuthService);
26
46
  const mcpPath = options.mcpPath ?? '/ai/mcp';
27
47
 
28
- const router = mcpAuthRouter({
29
- issuerUrl: new URL(options.baseUrl),
30
- provider: oauthService.buildOAuthProvider() as any,
31
- resourceServerUrl: new URL(`${options.baseUrl.replace(/\/$/, '')}${mcpPath}`),
32
- });
48
+ let router: unknown;
49
+ try {
50
+ router = mcpAuthRouter({
51
+ issuerUrl,
52
+ provider: oauthService.buildOAuthProvider() as any,
53
+ resourceServerUrl: new URL(`${baseUrl.replace(/\/$/, '')}${mcpPath}`),
54
+ });
55
+ } catch (error) {
56
+ // The SDK's refusals ("Issuer URL must be HTTPS") do not say which URL they refused. Now
57
+ // that the URL can come from config rather than from the call site, that is the one thing
58
+ // an operator needs to know.
59
+ const reason = (error as Error).message;
60
+ throw new Error(`mountAiMcpOAuth: OAuth issuer "${baseUrl}" (from ${source}) rejected: ${reason}`, {
61
+ cause: error,
62
+ });
63
+ }
33
64
 
34
65
  app.use(router);
66
+ new Logger('mountAiMcpOAuth').log(`MCP OAuth issuer: ${issuerUrl.href} (from ${source})`);
67
+ }
68
+
69
+ /**
70
+ * Resolves the issuer base URL. A blank `explicit` value counts as absent, because
71
+ * `BASE_URL=` in an env file yields `''`, not `undefined`.
72
+ */
73
+ function resolveMcpOAuthBaseUrl(explicit: string | undefined): { baseUrl: string; source: string } {
74
+ const given = explicit?.trim();
75
+ if (given) {
76
+ return { baseUrl: given, source: 'options.baseUrl' };
77
+ }
78
+
79
+ const config = ConfigService.configFastButReadOnly;
80
+ const resolved = resolveServerUrls({ baseUrl: config?.baseUrl, env: config?.env });
81
+ if (resolved.baseUrl) {
82
+ return {
83
+ baseUrl: resolved.baseUrl,
84
+ source:
85
+ resolved.baseUrlSource === 'localhost-default' ? `localhost default (env: ${config?.env})` : 'config.baseUrl',
86
+ };
87
+ }
88
+
89
+ throw new Error(
90
+ `mountAiMcpOAuth: no public server URL for the OAuth issuer (env: ${config?.env ?? 'unset'}). ` +
91
+ 'Set `baseUrl` in the server config (NSC__BASE_URL) or pass `options.baseUrl`. ' +
92
+ 'A guessed localhost URL would send MCP clients to their own machine.',
93
+ );
94
+ }
95
+
96
+ function parseAbsoluteUrl(value: string, source: string): URL {
97
+ try {
98
+ return new URL(value);
99
+ } catch {
100
+ throw new Error(`mountAiMcpOAuth: "${value}" (from ${source}) is not an absolute URL`);
101
+ }
35
102
  }
@@ -5,6 +5,7 @@ import { Connection } from 'mongoose';
5
5
 
6
6
  import { isProductionLikeEnv } from '../../../common/helpers/cookies.helper';
7
7
  import { ConfigService } from '../../../common/services/config.service';
8
+ import { getApiTokenContext } from '../../api-token/core-api-token.helpers';
8
9
 
9
10
  /**
10
11
  * Stored OAuth client (dynamically registered).
@@ -309,7 +310,18 @@ export class CoreAiMcpOAuthService implements OnModuleInit {
309
310
  */
310
311
  buildOAuthProvider(accessTtlSeconds = 3600): Record<string, any> {
311
312
  return {
312
- authorize: (client: any, params: any, res: any) => this.authorizeConsent(client, params, res),
313
+ authorize: async (client: any, params: any, res: any) => {
314
+ // An API token (or its signed assertion) must never approve an OAuth consent. The consent mints
315
+ // an MCP access token with the FULL rights of its user, which would lift a scope-limited user
316
+ // token out of every restriction it carries — and a tenant token has no user to consent for at
317
+ // all. This route is an Express router outside the Nest guards, so the deny-by-default of
318
+ // @ApiTokenScopes() does not reach it; the check sits here rather than in authorizeConsent()
319
+ // so an override of that method cannot drop it.
320
+ if (getApiTokenContext(res?.req?.user)) {
321
+ throw new Error('access_denied: an API token cannot authorize an OAuth client');
322
+ }
323
+ return this.authorizeConsent(client, params, res);
324
+ },
313
325
  challengeForAuthorizationCode: async (_client: any, authorizationCode: string) => {
314
326
  const stored = await this.getAuthorizationCode(authorizationCode);
315
327
  return stored?.codeChallenge ?? '';