@lenne.tech/nest-server 12.2.0 → 12.3.1

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 (160) hide show
  1. package/.claude/rules/better-auth.md +31 -4
  2. package/.claude/rules/configurable-features.md +8 -7
  3. package/.claude/rules/package-management.md +18 -2
  4. package/.claude/rules/persistence.md +2 -2
  5. package/.claude/rules/testing.md +5 -5
  6. package/.claude/rules/versioning.md +13 -2
  7. package/CLAUDE.md +5 -0
  8. package/FRAMEWORK-API.md +4 -3
  9. package/README.md +2 -2
  10. package/dist/config.env.js +2 -2
  11. package/dist/config.env.js.map +1 -1
  12. package/dist/core/common/helpers/graphql-ws-connection.helper.d.ts +4 -0
  13. package/dist/core/common/helpers/graphql-ws-connection.helper.js +17 -3
  14. package/dist/core/common/helpers/graphql-ws-connection.helper.js.map +1 -1
  15. package/dist/core/common/interceptors/check-security.interceptor.js +19 -2
  16. package/dist/core/common/interceptors/check-security.interceptor.js.map +1 -1
  17. package/dist/core/common/interfaces/server-options.interface.d.ts +8 -0
  18. package/dist/core/modules/ai/core-ai-mcp.controller.d.ts +3 -0
  19. package/dist/core/modules/ai/core-ai-mcp.controller.js +29 -0
  20. package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
  21. package/dist/core/modules/ai/core-ai.constants.d.ts +3 -0
  22. package/dist/core/modules/ai/core-ai.constants.js +3 -0
  23. package/dist/core/modules/ai/core-ai.constants.js.map +1 -1
  24. package/dist/core/modules/ai/core-ai.controller.d.ts +9 -0
  25. package/dist/core/modules/ai/core-ai.controller.js +48 -1
  26. package/dist/core/modules/ai/core-ai.controller.js.map +1 -1
  27. package/dist/core/modules/ai/core-ai.module.d.ts +2 -0
  28. package/dist/core/modules/ai/core-ai.module.js +9 -1
  29. package/dist/core/modules/ai/core-ai.module.js.map +1 -1
  30. package/dist/core/modules/ai/core-ai.resolver.d.ts +6 -0
  31. package/dist/core/modules/ai/core-ai.resolver.js +32 -0
  32. package/dist/core/modules/ai/core-ai.resolver.js.map +1 -1
  33. package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.d.ts +65 -1
  34. package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.js +6 -3
  35. package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.js.map +1 -1
  36. package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js +48 -1
  37. package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js.map +1 -1
  38. package/dist/core/modules/ai/helpers/ai-tool-authorization.helper.d.ts +9 -0
  39. package/dist/core/modules/ai/helpers/ai-tool-authorization.helper.js +54 -0
  40. package/dist/core/modules/ai/helpers/ai-tool-authorization.helper.js.map +1 -0
  41. package/dist/core/modules/ai/index.d.ts +8 -0
  42. package/dist/core/modules/ai/index.js +7 -0
  43. package/dist/core/modules/ai/index.js.map +1 -1
  44. package/dist/core/modules/ai/inputs/core-ai-settings.input.d.ts +3 -0
  45. package/dist/core/modules/ai/inputs/core-ai-settings.input.js +31 -0
  46. package/dist/core/modules/ai/inputs/core-ai-settings.input.js.map +1 -0
  47. package/dist/core/modules/ai/interfaces/ai-tool.interface.d.ts +2 -0
  48. package/dist/core/modules/ai/models/core-ai-settings.model.d.ts +4 -0
  49. package/dist/core/modules/ai/models/core-ai-settings.model.js +30 -0
  50. package/dist/core/modules/ai/models/core-ai-settings.model.js.map +1 -0
  51. package/dist/core/modules/ai/models/core-ai-status.model.d.ts +3 -0
  52. package/dist/core/modules/ai/models/core-ai-status.model.js +25 -0
  53. package/dist/core/modules/ai/models/core-ai-status.model.js.map +1 -0
  54. package/dist/core/modules/ai/models/core-ai-user-setting.model.d.ts +8 -0
  55. package/dist/core/modules/ai/models/core-ai-user-setting.model.js +45 -0
  56. package/dist/core/modules/ai/models/core-ai-user-setting.model.js.map +1 -0
  57. package/dist/core/modules/ai/services/core-ai-conversation.service.d.ts +4 -1
  58. package/dist/core/modules/ai/services/core-ai-conversation.service.js +16 -4
  59. package/dist/core/modules/ai/services/core-ai-conversation.service.js.map +1 -1
  60. package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.js +6 -6
  61. package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.js.map +1 -1
  62. package/dist/core/modules/ai/services/core-ai-mcp.service.d.ts +27 -2
  63. package/dist/core/modules/ai/services/core-ai-mcp.service.js +118 -11
  64. package/dist/core/modules/ai/services/core-ai-mcp.service.js.map +1 -1
  65. package/dist/core/modules/ai/services/core-ai-prompt-builder.service.js +9 -6
  66. package/dist/core/modules/ai/services/core-ai-prompt-builder.service.js.map +1 -1
  67. package/dist/core/modules/ai/services/core-ai-user-setting.service.d.ts +17 -0
  68. package/dist/core/modules/ai/services/core-ai-user-setting.service.js +72 -0
  69. package/dist/core/modules/ai/services/core-ai-user-setting.service.js.map +1 -0
  70. package/dist/core/modules/ai/services/core-ai.service.d.ts +8 -3
  71. package/dist/core/modules/ai/services/core-ai.service.js +68 -29
  72. package/dist/core/modules/ai/services/core-ai.service.js.map +1 -1
  73. package/dist/core/modules/better-auth/better-auth.config.js +10 -4
  74. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  75. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +2 -2
  76. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
  77. package/dist/core/modules/better-auth/core-better-auth-path-guard.middleware.d.ts +8 -0
  78. package/dist/core/modules/better-auth/core-better-auth-path-guard.middleware.js +64 -0
  79. package/dist/core/modules/better-auth/core-better-auth-path-guard.middleware.js.map +1 -0
  80. package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js +2 -1
  81. package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js.map +1 -1
  82. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.d.ts +1 -1
  83. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js +5 -3
  84. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js.map +1 -1
  85. package/dist/core/modules/better-auth/core-better-auth.module.js +2 -0
  86. package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
  87. package/dist/core/modules/better-auth/index.d.ts +1 -0
  88. package/dist/core/modules/better-auth/index.js +1 -0
  89. package/dist/core/modules/better-auth/index.js.map +1 -1
  90. package/dist/core/modules/error-code/error-codes.d.ts +18 -0
  91. package/dist/core/modules/error-code/error-codes.js +16 -0
  92. package/dist/core/modules/error-code/error-codes.js.map +1 -1
  93. package/dist/core/modules/sync/core-sync.service.d.ts +1 -0
  94. package/dist/core/modules/sync/core-sync.service.js +31 -2
  95. package/dist/core/modules/sync/core-sync.service.js.map +1 -1
  96. package/dist/core/modules/tenant/core-tenant.guard.js +10 -0
  97. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
  98. package/dist/core/modules/tenant/core-tenant.service.d.ts +21 -1
  99. package/dist/core/modules/tenant/core-tenant.service.js +153 -1
  100. package/dist/core/modules/tenant/core-tenant.service.js.map +1 -1
  101. package/dist/server/modules/error-code/error-codes.d.ts +2 -0
  102. package/dist/test/test.helper.d.ts +1 -0
  103. package/dist/test/test.helper.js +9 -2
  104. package/dist/test/test.helper.js.map +1 -1
  105. package/docs/REQUEST-LIFECYCLE.md +5 -4
  106. package/docs/error-codes.md +14 -0
  107. package/docs/offline-sync-protocol.md +17 -8
  108. package/migration-guides/12.0.x-to-12.1.x.md +0 -1
  109. package/migration-guides/12.2.x-to-12.3.x.md +440 -0
  110. package/migration-guides/12.3.0-to-12.3.1.md +136 -0
  111. package/package.json +26 -26
  112. package/src/config.env.ts +6 -2
  113. package/src/core/common/helpers/graphql-ws-connection.helper.ts +32 -4
  114. package/src/core/common/interceptors/check-security.interceptor.ts +26 -2
  115. package/src/core/common/interfaces/server-options.interface.ts +84 -8
  116. package/src/core/common/persistence/sync-model.ts +1 -1
  117. package/src/core/modules/ai/INTEGRATION-CHECKLIST.md +36 -15
  118. package/src/core/modules/ai/README.md +178 -25
  119. package/src/core/modules/ai/core-ai-mcp.controller.ts +48 -1
  120. package/src/core/modules/ai/core-ai.constants.ts +10 -0
  121. package/src/core/modules/ai/core-ai.controller.ts +55 -1
  122. package/src/core/modules/ai/core-ai.module.ts +14 -0
  123. package/src/core/modules/ai/core-ai.resolver.ts +36 -0
  124. package/src/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.ts +35 -4
  125. package/src/core/modules/ai/helpers/ai-mcp-oauth.helper.ts +68 -1
  126. package/src/core/modules/ai/helpers/ai-tool-authorization.helper.ts +99 -0
  127. package/src/core/modules/ai/index.ts +19 -0
  128. package/src/core/modules/ai/inputs/core-ai-settings.input.ts +24 -0
  129. package/src/core/modules/ai/interfaces/ai-hook.interface.ts +1 -1
  130. package/src/core/modules/ai/interfaces/ai-tool.interface.ts +38 -26
  131. package/src/core/modules/ai/models/core-ai-mode.model.ts +1 -1
  132. package/src/core/modules/ai/models/core-ai-settings.model.ts +23 -0
  133. package/src/core/modules/ai/models/core-ai-status.model.ts +16 -0
  134. package/src/core/modules/ai/models/core-ai-tool-policy.model.ts +1 -1
  135. package/src/core/modules/ai/models/core-ai-user-setting.model.ts +46 -0
  136. package/src/core/modules/ai/services/core-ai-conversation.service.ts +31 -6
  137. package/src/core/modules/ai/services/core-ai-mcp-oauth.service.ts +12 -9
  138. package/src/core/modules/ai/services/core-ai-mcp.service.ts +206 -13
  139. package/src/core/modules/ai/services/core-ai-prompt-builder.service.ts +13 -6
  140. package/src/core/modules/ai/services/core-ai-user-setting.service.ts +85 -0
  141. package/src/core/modules/ai/services/core-ai.service.ts +134 -35
  142. package/src/core/modules/api-token/README.md +4 -2
  143. package/src/core/modules/better-auth/CUSTOMIZATION.md +1 -1
  144. package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +12 -2
  145. package/src/core/modules/better-auth/README.md +32 -1
  146. package/src/core/modules/better-auth/better-auth.config.ts +21 -5
  147. package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +5 -2
  148. package/src/core/modules/better-auth/core-better-auth-path-guard.middleware.ts +85 -0
  149. package/src/core/modules/better-auth/core-better-auth-rate-limit.middleware.ts +5 -2
  150. package/src/core/modules/better-auth/core-better-auth-rate-limiter.service.ts +14 -7
  151. package/src/core/modules/better-auth/core-better-auth.module.ts +12 -2
  152. package/src/core/modules/better-auth/index.ts +1 -0
  153. package/src/core/modules/error-code/error-codes.ts +18 -0
  154. package/src/core/modules/sync/README.md +11 -1
  155. package/src/core/modules/sync/core-sync.service.ts +48 -3
  156. package/src/core/modules/tenant/INTEGRATION-CHECKLIST.md +12 -1
  157. package/src/core/modules/tenant/README.md +49 -3
  158. package/src/core/modules/tenant/core-tenant.guard.ts +18 -2
  159. package/src/core/modules/tenant/core-tenant.service.ts +287 -2
  160. package/src/test/test.helper.ts +17 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "12.2.0",
3
+ "version": "12.3.1",
4
4
  "type": "module",
5
5
  "description": "Server framework in TypeScript on top of NestJS: GraphQL and REST APIs on MongoDB, PostgreSQL, MySQL or MariaDB, with authentication, field-level permissions, multi-tenancy, offline sync and an AI assistant.",
6
6
  "keywords": [
@@ -104,18 +104,18 @@
104
104
  "@as-integrations/express5": "1.1.2",
105
105
  "@getbrevo/brevo": "6.2.0",
106
106
  "@modelcontextprotocol/sdk": "1.32.2",
107
- "@nestjs/apollo": "14.1.1",
107
+ "@nestjs/apollo": "14.1.2",
108
108
  "@nestjs/common": "12.3.2",
109
109
  "@nestjs/core": "12.3.2",
110
- "@nestjs/graphql": "14.1.0",
110
+ "@nestjs/graphql": "14.1.2",
111
111
  "@nestjs/jwt": "12.0.3",
112
112
  "@nestjs/passport": "12.0.0",
113
113
  "@nestjs/platform-express": "12.3.2",
114
114
  "@nestjs/schedule": "12.0.2",
115
- "@nestjs/swagger": "12.0.5",
115
+ "@nestjs/swagger": "12.1.0",
116
116
  "@nestjs/terminus": "12.1.0",
117
- "@tus/file-store": "2.1.1",
118
- "@tus/server": "2.4.6",
117
+ "@tus/file-store": "2.1.2",
118
+ "@tus/server": "2.4.7",
119
119
  "@types/supertest": "7.2.1",
120
120
  "bcrypt": "6.0.0",
121
121
  "class-transformer": "0.5.1",
@@ -131,13 +131,13 @@
131
131
  "graphql-subscriptions": "3.0.0",
132
132
  "graphql-upload": "17.1.0",
133
133
  "graphql-ws": "6.3.0",
134
- "jose": "6.2.13",
134
+ "jose": "6.2.15",
135
135
  "js-sha256": "1.0.0",
136
136
  "json-to-graphql-query": "2.3.0",
137
137
  "lodash": "4.18.1",
138
138
  "multer": "2.5.0",
139
139
  "node-mailjet": "6.0.11",
140
- "nodemailer": "10.3.0",
140
+ "nodemailer": "10.3.1",
141
141
  "passport": "0.7.0",
142
142
  "passport-jwt": "4.0.1",
143
143
  "reflect-metadata": "0.2.2",
@@ -153,12 +153,12 @@
153
153
  "@aws-sdk/client-s3": ">=3.1045.0 <4",
154
154
  "@aws-sdk/lib-storage": ">=3.1045.0 <4",
155
155
  "@aws-sdk/s3-request-presigner": ">=3.1045.0 <4",
156
- "@better-auth/core": ">=1.7.12 <1.8.0",
157
- "@better-auth/passkey": ">=1.7.12 <1.8.0",
156
+ "@better-auth/core": ">=1.7.16 <1.8.0",
157
+ "@better-auth/passkey": ">=1.7.16 <1.8.0",
158
158
  "@nestjs/mongoose": ">=12.0.0 <13",
159
159
  "@swc/core": ">=1.10.0 <2",
160
160
  "@tus/s3-store": ">=2.0.5 <3",
161
- "better-auth": ">=1.7.12 <1.8.0",
161
+ "better-auth": ">=1.7.16 <1.8.0",
162
162
  "bullmq": ">=5.16.0 <7",
163
163
  "drizzle-orm": "1.0.0-rc.5",
164
164
  "ioredis": ">=5.0.0 <7",
@@ -226,19 +226,19 @@
226
226
  }
227
227
  },
228
228
  "devDependencies": {
229
- "@aws-sdk/client-s3": "3.1159.0",
230
- "@aws-sdk/lib-storage": "3.1159.0",
231
- "@aws-sdk/s3-request-presigner": "3.1159.0",
232
- "@better-auth/core": "1.7.14",
233
- "@better-auth/passkey": "1.7.14",
229
+ "@aws-sdk/client-s3": "3.1161.0",
230
+ "@aws-sdk/lib-storage": "3.1161.0",
231
+ "@aws-sdk/s3-request-presigner": "3.1161.0",
232
+ "@better-auth/core": "1.7.16",
233
+ "@better-auth/passkey": "1.7.16",
234
234
  "@compodoc/compodoc": "2.0.0",
235
235
  "@nestjs/cli": "12.0.11",
236
236
  "@nestjs/mongoose": "12.0.0",
237
237
  "@nestjs/schematics": "12.0.12",
238
238
  "@nestjs/testing": "12.3.2",
239
239
  "@swc/cli": "0.8.2",
240
- "@swc/core": "1.16.20",
241
- "@tus/s3-store": "2.0.9",
240
+ "@swc/core": "1.16.21",
241
+ "@tus/s3-store": "2.1.0",
242
242
  "@types/compression": "1.8.1",
243
243
  "@types/cookie-parser": "1.4.10",
244
244
  "@types/ejs": "3.1.5",
@@ -248,32 +248,32 @@
248
248
  "@types/node": "26.6.6",
249
249
  "@types/passport": "1.0.17",
250
250
  "@types/pg": "8.23.1",
251
- "@vitest/coverage-v8": "5.0.5",
252
- "better-auth": "1.7.14",
253
- "bullmq": "6.5.1",
251
+ "@vitest/coverage-v8": "5.1.0",
252
+ "better-auth": "1.7.16",
253
+ "bullmq": "6.5.4",
254
254
  "cross-env": "10.1.0",
255
255
  "drizzle-orm": "1.0.0-rc.5",
256
256
  "ioredis": "6.0.0",
257
- "kysely": "0.29.7",
257
+ "kysely": "0.29.8",
258
258
  "mongodb": "7.7.0",
259
259
  "mongoose": "9.11.4",
260
260
  "mysql2": "3.24.6",
261
261
  "nodemon": "3.1.14",
262
- "otpauth": "9.5.2",
262
+ "otpauth": "9.6.0",
263
263
  "oxfmt": "0.75.0",
264
264
  "oxlint": "1.90.0",
265
265
  "oxlint-tsgolint": "7.0.2005",
266
266
  "pg": "8.23.1",
267
267
  "playwright-core": "1.64.1",
268
268
  "rimraf": "6.1.3",
269
- "smtp-server": "3.21.1",
269
+ "smtp-server": "3.21.3",
270
270
  "tsx": "4.23.15",
271
271
  "tus-js-client": "4.3.1",
272
272
  "typeorm": "1.1.2",
273
273
  "typescript": "6.0.3",
274
274
  "unplugin-swc": "2.0.0",
275
- "vite": "8.3.4",
276
- "vitest": "5.0.5"
275
+ "vite": "8.3.5",
276
+ "vitest": "5.1.0"
277
277
  },
278
278
  "main": "dist/index.js",
279
279
  "types": "dist/index.d.ts",
package/src/config.env.ts CHANGED
@@ -59,7 +59,9 @@ const config: { [env: string]: IServerOptions } = {
59
59
  rpId: 'localhost',
60
60
  rpName: 'Nest Server Local',
61
61
  },
62
- rateLimit: { enabled: true, max: 100, windowSeconds: 60 },
62
+ // 200: sign-in and sign-up get the strict half (100), the headroom the suites had while the limiter
63
+ // counted every IAM request of a client on one shared counter of 100
64
+ rateLimit: { enabled: true, max: 200, windowSeconds: 60 },
63
65
  secret: process.env.BETTER_AUTH_SECRET || 'BETTER_AUTH_SECRET_LOCAL_32_CHARS_M',
64
66
  // Social providers disabled in local environment (no credentials)
65
67
  socialProviders: {
@@ -367,7 +369,9 @@ const config: { [env: string]: IServerOptions } = {
367
369
  rpId: 'localhost',
368
370
  rpName: 'Nest Server Local',
369
371
  },
370
- rateLimit: { enabled: true, max: 100, windowSeconds: 60 },
372
+ // 200: sign-in and sign-up get the strict half (100), the headroom the suites had while the limiter
373
+ // counted every IAM request of a client on one shared counter of 100
374
+ rateLimit: { enabled: true, max: 200, windowSeconds: 60 },
371
375
  secret: process.env.BETTER_AUTH_SECRET || 'BETTER_AUTH_SECRET_LOCAL_32_CHARS_M',
372
376
  // Social providers disabled in local environment (no credentials)
373
377
  socialProviders: {
@@ -82,6 +82,17 @@ export interface IWsConnectionRegistration {
82
82
  */
83
83
  carrier: object;
84
84
 
85
+ /** How the log names the connection when it is closed. Default `GraphQL WebSocket`. */
86
+ channel?: string;
87
+
88
+ /**
89
+ * The connection cannot be re-checked in place — an HTTP stream has no handshake to repeat — so every re-check ends
90
+ * it, and the client's reconnect passes the guards again. The end the re-check interval brings is planned and logged
91
+ * at debug; one an invalidation brings (sign-out, role or tenant change) is logged like every withdrawn
92
+ * authorization. `reauthenticate` is not called.
93
+ */
94
+ endsOnRecheck?: boolean;
95
+
85
96
  /**
86
97
  * Re-run the handshake's authentication. Resolves to the user the credential authenticates as NOW,
87
98
  * or to a falsy value when it no longer does. A rejection counts as falsy. Without it only the
@@ -105,7 +116,9 @@ export interface IWsConnectionIdentity {
105
116
 
106
117
  /** @internal The registered state of one connection. */
107
118
  export interface WsConnection {
119
+ readonly channel: string;
108
120
  readonly email?: string;
121
+ readonly endsOnRecheck: boolean;
109
122
  readonly iamId?: string;
110
123
  readonly reauthenticate?: () => any;
111
124
  readonly socket?: IWsConnectionSocket;
@@ -206,13 +219,15 @@ export function readWsTenantHeader(contextValue: any): string | undefined {
206
219
  * the handshake recorded on the carrier.
207
220
  */
208
221
  export function registerWsConnection(registration: IWsConnectionRegistration): void {
209
- const { carrier, reauthenticate, socket, user } = registration;
222
+ const { carrier, channel, endsOnRecheck, reauthenticate, socket, user } = registration;
210
223
  if (!carrier || typeof carrier !== 'object') {
211
224
  return;
212
225
  }
213
226
  const userId = user?.id ?? user?._id;
214
227
  const connection: WsConnection = {
228
+ channel: channel || 'GraphQL WebSocket',
215
229
  email: typeof user?.email === 'string' ? user.email.toLowerCase() : undefined,
230
+ endsOnRecheck: endsOnRecheck === true,
216
231
  iamId: typeof user?.iamId === 'string' ? user.iamId : undefined,
217
232
  reauthenticate,
218
233
  socket,
@@ -246,15 +261,20 @@ export function getWsConnection(contextValue: any): undefined | WsConnection {
246
261
 
247
262
  /**
248
263
  * Close a connection whose authorization no longer holds. Idempotent; always answers `false` so a
249
- * caller can `return terminate(...)`.
264
+ * caller can `return terminate(...)`. `debug` is for a planned end, which says nothing about the user.
250
265
  */
251
- function terminate(connection: WsConnection, reason: string): false {
266
+ function terminate(connection: WsConnection, reason: string, level: 'debug' | 'log' = 'log'): false {
252
267
  if (connection.terminated) {
253
268
  return false;
254
269
  }
255
270
  connection.terminated = true;
256
271
  liveConnections.delete(connection);
257
- logger.log(`Closing GraphQL WebSocket of user ${connection.userId ?? '(unknown)'}: ${reason}`);
272
+ const message = `Closing ${connection.channel} of user ${connection.userId ?? '(unknown)'}: ${reason}`;
273
+ if (level === 'debug') {
274
+ logger.debug(message);
275
+ } else {
276
+ logger.log(message);
277
+ }
258
278
  try {
259
279
  // A close reason is limited to 123 bytes on the wire; these are short and ASCII.
260
280
  connection.socket?.close(WS_AUTHORIZATION_CHANGED_CLOSE_CODE, reason);
@@ -265,7 +285,15 @@ function terminate(connection: WsConnection, reason: string): false {
265
285
  }
266
286
 
267
287
  async function revalidate(connection: WsConnection): Promise<boolean> {
288
+ const invalidated = connection.stale;
268
289
  connection.stale = false;
290
+ if (connection.endsOnRecheck) {
291
+ // Nothing to check in place: the end IS the re-check, and the reconnect is authorized afresh. Only an invalidation
292
+ // says that the rights may have changed; the interval is the planned beat and must not read like a lost session.
293
+ return invalidated
294
+ ? terminate(connection, 'authorization invalidated, client reconnects')
295
+ : terminate(connection, 're-check interval reached, client reconnects', 'debug');
296
+ }
269
297
  try {
270
298
  let user = connection.user;
271
299
  if (connection.reauthenticate) {
@@ -6,8 +6,31 @@ import { getContextData } from '../helpers/context.helper.js';
6
6
  import { hasRestrictionsCheckedMarker } from '../decorators/restrictions-checked.marker.js';
7
7
  import { getStringIds } from '../helpers/db.helper.js';
8
8
  import { processDeep } from '../helpers/input.helper.js';
9
+ import { columnName } from '../persistence/sql/sql-naming.js';
9
10
  import { ConfigService } from '../services/config.service.js';
10
11
 
12
+ /**
13
+ * The keys a secret field can arrive under: its own name and its SQL column (12.3.1). A row read through
14
+ * `store.native()` / `store.raw()` carries snake_case columns (`password_reset_token`), and a route returning it
15
+ * unchanged handed every secret but `password` — the one spelled alike — to the client. `id` / `_id` keep their own
16
+ * spelling only (their column is `id`), and a name that is no identifier has no column.
17
+ */
18
+ function secretFieldKeys(fields: string[]): Set<string> {
19
+ const keys = new Set<string>();
20
+ for (const field of fields) {
21
+ keys.add(field);
22
+ if (field === 'id' || field === '_id') {
23
+ continue;
24
+ }
25
+ try {
26
+ keys.add(columnName(field));
27
+ } catch {
28
+ // Not an identifier, so no column carries it
29
+ }
30
+ }
31
+ return keys;
32
+ }
33
+
11
34
  /**
12
35
  * Verification of all outgoing data via securityCheck
13
36
  */
@@ -137,6 +160,7 @@ export class CheckSecurityInterceptor implements NestInterceptor {
137
160
  return proto === null || proto === Object.prototype;
138
161
  };
139
162
  const visited = new WeakSet();
163
+ const secretKeys = secretFieldKeys(this.config.secretFields);
140
164
  const removeSecrets = (data: any) => {
141
165
  if (!this.config.removeSecretFields || !data || typeof data !== 'object') {
142
166
  return data;
@@ -155,7 +179,7 @@ export class CheckSecurityInterceptor implements NestInterceptor {
155
179
  return data;
156
180
  }
157
181
  visited.add(data);
158
- for (const field of this.config.secretFields) {
182
+ for (const field of secretKeys) {
159
183
  if (field in data && data[field] !== undefined) {
160
184
  data[field] = undefined;
161
185
  }
@@ -163,7 +187,7 @@ export class CheckSecurityInterceptor implements NestInterceptor {
163
187
  // Recurse into nested plain objects
164
188
  for (const key of Object.keys(data)) {
165
189
  const value = data[key];
166
- if (value && typeof value === 'object' && !this.config.secretFields.includes(key)) {
190
+ if (value && typeof value === 'object' && !secretKeys.has(key)) {
167
191
  removeSecrets(value);
168
192
  }
169
193
  }
@@ -510,8 +510,14 @@ export type IBetterAuth = IBetterAuthWithoutPasskey | IBetterAuthWithPasskey;
510
510
  */
511
511
  export interface IBetterAuthEmailVerificationConfig {
512
512
  /**
513
- * Whether to automatically sign in the user after email verification.
514
- * @default true
513
+ * Whether `GET /iam/verify-email` signs in the browser that opens the verification link.
514
+ *
515
+ * Off by default, as in Better-Auth (12.3.0+; before, the framework defaulted to `true`). Switching it on signs in
516
+ * WHOEVER opens the link, to the account the link belongs to: someone who signs up with their own address and
517
+ * forwards the unopened link has the recipient's browser signed in to their account, and what the recipient then
518
+ * enters lands there (login CSRF). Only switch it on where verification links reach nobody but their owner.
519
+ *
520
+ * @default false
515
521
  */
516
522
  autoSignInAfterVerification?: boolean;
517
523
 
@@ -795,6 +801,10 @@ export interface IBetterAuthPasskeyConfig {
795
801
  * - 'required': Always require biometric/PIN verification (most secure)
796
802
  * - 'preferred': Request verification if available (default)
797
803
  * - 'discouraged': Skip verification for faster auth (least secure)
804
+ *
805
+ * With 'required', a passkey response without user verification is refused at registration and sign-in. Takes effect
806
+ * since 12.3.1, as do `authenticatorAttachment` and `residentKey`: before, the three reached the passkey plugin where
807
+ * it never reads them.
798
808
  * @default 'preferred'
799
809
  */
800
810
  userVerification?: 'discouraged' | 'preferred' | 'required';
@@ -975,6 +985,17 @@ export interface IBetterAuthSocialProvider {
975
985
  */
976
986
  clientSecret: string;
977
987
 
988
+ /**
989
+ * Whether a first sign-in through this provider may create an account.
990
+ *
991
+ * Unset, the provider follows `betterAuth.emailAndPassword.disableSignUp`: with that set to `true` (since 12.3.0)
992
+ * nobody gets a new account through any provider, while existing users keep signing in. `false` opens sign-up
993
+ * through this provider although `disableSignUp` closes it otherwise; `true` closes it for this provider alone.
994
+ * Only a boolean counts: any other value follows `emailAndPassword.disableSignUp`.
995
+ * @default emailAndPassword.disableSignUp
996
+ */
997
+ disableSignUp?: boolean;
998
+
978
999
  /**
979
1000
  * Whether this provider is enabled.
980
1001
  * Defaults to true when clientId and clientSecret are provided.
@@ -2004,7 +2025,13 @@ export interface IAi {
2004
2025
  * Fallback total context window (input + output tokens) used to budget the
2005
2026
  * assembled session messages when a connection has no `contextWindow` set. When the
2006
2027
  * conversation (per user/session) would overflow, the oldest history turns are
2007
- * dropped and oversized payloads truncated before the LLM call.
2028
+ * dropped and oversized payloads truncated before the LLM call. Native tool
2029
+ * definitions count against that budget too (12.3.0+), and when the system prompt and
2030
+ * tools take more than half of it, a warning names the connection once per process.
2031
+ *
2032
+ * A test that guards the prompt size against this value guards the connections WITHOUT
2033
+ * a window of their own — the worst case — not the window a pinned or detected
2034
+ * connection actually has. Name it that way; raising its limit removes that guard.
2008
2035
  * @default 8192
2009
2036
  */
2010
2037
  contextWindow?: number;
@@ -2061,14 +2088,14 @@ export interface IAi {
2061
2088
  * a caller sees and may run is decided server-side by the registry's role filter
2062
2089
  * (`AiToolRegistry.forUser()`), re-checked at execution time, and the
2063
2090
  * confirmation gate reads the `mutating`/`destructive` FLAGS, never the
2064
- * description text. (`AiTool.authorize()` runs in plan mode only — in auto mode
2065
- * and over MCP, data-level checks belong inside `execute()`.)
2091
+ * description text. (`AiTool.authorize()` runs before every execution — auto
2092
+ * mode, plan mode and MCP — on the call's arguments, never its description.)
2066
2093
  *
2067
2094
  * Applies to the EMULATED tool protocol, not to a provider as such. In auto mode
2068
2095
  * a connection with native tool calling receives every full description and schema
2069
- * through `buildToolSchemas()` regardless, so no truncation is applied there and no
2070
- * `search_tools` banner is emitted — capping would otherwise assert a truncation
2071
- * that the tool payload right next to it contradicts.
2096
+ * through `buildToolSchemas()` regardless, and its catalog lists names only since
2097
+ * 12.3.0 (`nativeToolCatalog`), so this cap has no effect there and no `search_tools`
2098
+ * banner is emitted.
2072
2099
  *
2073
2100
  * **Plan mode always uses the emulated protocol** (it sends no native schemas), so a
2074
2101
  * native connection IS truncated there. Because plan mode returns a complete plan in
@@ -2091,6 +2118,17 @@ export interface IAi {
2091
2118
  */
2092
2119
  deferToolSummaryChars?: number;
2093
2120
 
2121
+ /**
2122
+ * What the system prompt's tool catalog holds for a connection with NATIVE tool calling in auto mode (12.3.0+).
2123
+ * Such a connection receives every description and parameter schema in the tool payload, so by default the catalog
2124
+ * lists names only — repeating the descriptions (and, without `deferToolSchemas`, the schemas) paid for every tool
2125
+ * twice. `'full'` restores the full catalog, for a model that picks tools better with the descriptions in the prompt.
2126
+ *
2127
+ * Emulated connections and plan mode always get the full catalog: there it is the model's only source.
2128
+ * @default 'names'
2129
+ */
2130
+ nativeToolCatalog?: 'full' | 'names';
2131
+
2094
2132
  /** Maximum number of agent-loop iterations (tool round-trips). @default 5 */
2095
2133
  maxIterations?: number;
2096
2134
 
@@ -2142,11 +2180,32 @@ export interface IAi {
2142
2180
  * answers with a built-in consent page: sign-in through Better-Auth (password + 2FA),
2143
2181
  * then Allow / Deny. Replace it via `CoreModule.forRoot(env, { ai: { mcpOAuthService } })`.
2144
2182
  * Without `oauth`, MCP authenticates via the existing Bearer/session token.
2183
+ *
2184
+ * `tools/call` applies the admin's tool policies and a tool's `authorize()`, and asks the user before a
2185
+ * tool that needs confirmation (`destructive`, or `mutating` under `ai.confirmation.mutating`) — see
2186
+ * `confirmation`.
2145
2187
  * @default false
2146
2188
  */
2147
2189
  mcp?:
2148
2190
  | boolean
2149
2191
  | {
2192
+ /**
2193
+ * Whether MCP asks the user — through MCP elicitation, a yes/no form the client shows — before a
2194
+ * `destructive` tool, or a `mutating` one under `ai.confirmation.mutating` (12.3.0+). The tool runs only
2195
+ * on an explicit yes; a client without the `elicitation` capability gets a refusal. `authorize()` runs
2196
+ * either way, and an admin's `ask` tool policy always asks.
2197
+ *
2198
+ * - `true` / `{}` / unset: MCP asks, unless a user switched it off for themselves
2199
+ * (`PUT /ai/settings`, `updateAiSettings` — like a "bypass permissions" mode).
2200
+ * - `false`: MCP asks nobody, unless a user switched it on for themselves.
2201
+ * - `{ default, enforced }`: `default` as above; `enforced: true` ignores every user's choice and
2202
+ * refuses a change with 403.
2203
+ *
2204
+ * Only `false` / `default: false` switch the default off and only `enforced: true` enforces, so a
2205
+ * misspelt value never relaxes the gate.
2206
+ * @default { default: true, enforced: false }
2207
+ */
2208
+ confirmation?: boolean | { default?: boolean; enforced?: boolean };
2150
2209
  enabled?: boolean;
2151
2210
  oauth?: boolean;
2152
2211
  /**
@@ -2164,6 +2223,17 @@ export interface IAi {
2164
2223
  */
2165
2224
  oauthLoginUrl?: string;
2166
2225
  oauthSecret?: string;
2226
+ /**
2227
+ * Serve `/ai/mcp` without sessions: every POST builds its own server and transport for the
2228
+ * authenticated user and ends with the response, no `mcp-session-id` is issued, and GET /
2229
+ * DELETE answer 405. Any replica can answer any request, so no sticky sessions are needed
2230
+ * behind a load balancer. The price is what a session carries: server-initiated messages
2231
+ * (notifications, SSE streams) and server-initiated requests — MCP elicitation among them, so a
2232
+ * tool that needs confirmation (`confirmation`) is refused unless the user or `confirmation`
2233
+ * switched the question off. A server that only offers read tools uses none of it (12.3.0+).
2234
+ * @default false
2235
+ */
2236
+ stateless?: boolean;
2167
2237
  };
2168
2238
 
2169
2239
  /** Rate limiting for prompts. */
@@ -4387,6 +4457,10 @@ interface IBetterAuthBase {
4387
4457
  * Disable user registration (sign-up) via BetterAuth.
4388
4458
  * Passed through to better-auth's native emailAndPassword.disableSignUp.
4389
4459
  * Custom endpoints (GraphQL + REST) also check this flag early.
4460
+ * Since 12.3.0 it closes social sign-up too: every configured social provider receives it as its own
4461
+ * `disableSignUp`, so a first sign-in through Google, GitHub etc. creates no account, while existing users keep
4462
+ * signing in. `socialProviders.<name>.disableSignUp: false` opens one provider deliberately. Plugins a project
4463
+ * registers itself through `options.plugins` are not covered.
4390
4464
  * The Swagger document built by `setupSwagger()` then leaves out `POST /iam/sign-up/email`,
4391
4465
  * which would answer `SIGNUP_DISABLED` to every caller.
4392
4466
  * @default false
@@ -5002,6 +5076,7 @@ export interface ICoreModuleOverrides {
5002
5076
  * - `slotService` must extend `CoreAiSlotService`
5003
5077
  * - `toolGrantService` must extend `CoreAiToolGrantService`
5004
5078
  * - `toolPolicyService` must extend `CoreAiToolPolicyService`
5079
+ * - `userSettingService` must extend `CoreAiUserSettingService`
5005
5080
  *
5006
5081
  * @example
5007
5082
  * ```typescript
@@ -5035,6 +5110,7 @@ export interface ICoreModuleOverrides {
5035
5110
  slotService?: Type<any>;
5036
5111
  toolGrantService?: Type<any>;
5037
5112
  toolPolicyService?: Type<any>;
5113
+ userSettingService?: Type<any>;
5038
5114
  };
5039
5115
 
5040
5116
  /**
@@ -9,7 +9,7 @@ import type {
9
9
  export type { SyncDescriptor, SyncIdFormat } from './model-descriptor.interface.js';
10
10
 
11
11
  /**
12
- * Synchronized models (offline sync, `docs/offline-sync-protocol.md`, plan `.claude/plans/offline-sync/PLAN.md` §3.2).
12
+ * Synchronized models (offline sync, `docs/offline-sync-protocol.md`, offline-sync plan §3.2).
13
13
  *
14
14
  * A model declared with `@PersistedModel({ sync })` carries three system fields every store maintains and no caller
15
15
  * writes — `version`, `deletedAt` and its position in the change order — and its delete leaves a tombstone instead of
@@ -146,9 +146,10 @@ ai: {
146
146
  }
147
147
  ```
148
148
 
149
- - **Plan mode / pre-flight permissions:** give data-mutating tools an `authorize()` that
150
- checks permission WITHOUT mutating (e.g. load the record + verify ownership). Plan mode
151
- runs it for every step before executing anything.
149
+ - **Data-level permissions:** give data-mutating tools an `authorize()` that checks
150
+ permission WITHOUT mutating (e.g. load the record + verify ownership). It runs before every
151
+ execution — auto mode, plan mode (for every step before anything runs) and MCP (12.3.0+);
152
+ return `data` to hand the loaded record to `execute()` (`context.authorization.data`).
152
153
  - **Confirmation:** mark create/update/delete tools `mutating: true` and irreversible ones
153
154
  `destructive: true`.
154
155
  - **Self-optimizing prompts:** the system prompt is built from editable, keyed slots —
@@ -174,8 +175,9 @@ ai: {
174
175
  `deferToolSummaryChars` (~200–400) ALONGSIDE it — otherwise the descriptions alone
175
176
  re-inflate the prompt you just shrank. Both are scoped to the **emulated tool protocol**:
176
177
  in auto mode a connection with `supportsNativeTools: true` gets every full description and
177
- schema in the native `tools` payload regardless, so truncation and the banner are skipped
178
- there. **Plan mode always uses the emulated protocol**, so a native connection IS truncated
178
+ schema in the native `tools` payload regardless, and its prompt catalog lists names only
179
+ (12.3.0+, `ai.nativeToolCatalog: 'full'` restores it), so truncation and the banner are
180
+ skipped there. **Plan mode always uses the emulated protocol**, so a native connection IS truncated
179
181
  there — and since plan mode plans in one call without executing, it cannot follow the
180
182
  `search_tools` hint. Keep the cap generous if you use plan mode.
181
183
  The truncated tail is typically where a tool's preconditions and role limits are written;
@@ -183,10 +185,16 @@ ai: {
183
185
  stays with the registry's role filter, the execution-time re-check, and the
184
186
  `mutating`/`destructive` flags behind the confirmation gate, none of which read the
185
187
  description.
186
- - **Where data-level checks belong:** `authorize()` runs in **plan mode only**. In auto mode
187
- (the default) and over MCP, the orchestrator calls `execute()` directly. Put ownership and
188
- tenant checks INSIDE `execute()` (via `CrudService` + `context.serviceOptions`) — a check
189
- that lives only in `authorize()` will not run for most callers.
188
+ - **Guarding the prompt size in a test?** Measure against `ai.contextWindow`, the fallback for
189
+ connections without a window of their own, and name the test that way: it guards the worst
190
+ case, not the window a pinned or detected connection has. Raising its limit removes the guard;
191
+ shrink the catalog instead. At runtime the framework warns once per connection when the system
192
+ prompt and tool definitions take more than half of the real input budget.
193
+ - **Where data-level checks belong:** `authorize()` decides whether a call may be tried;
194
+ `execute()` still routes every read and write through `CrudService` +
195
+ `context.serviceOptions`, so `@Restricted`, `securityCheck()` and tenant scoping apply.
196
+ Until 12.2 `authorize()` ran in plan mode only — a project that moved its checks into
197
+ `execute()` for that reason keeps working unchanged.
190
198
 
191
199
  ### MCP server (only when `ai.mcp.enabled` or `ai.mcp` is truthy)
192
200
 
@@ -200,10 +208,14 @@ A **503 Service Unavailable** from `/ai/mcp` therefore means the module could no
200
208
  _resolved_, not that it is missing — check the server log for the underlying error.
201
209
  The response body carries the stable `#LTNS_0901` code, never the raw error.
202
210
 
203
- > **No confirmation gate over MCP.** `mcpCallTool` does not consult the
204
- > `mutating`/`destructive` flags, and `authorize()` does not run there either. A
205
- > destructive tool invoked through `/ai/mcp` executes immediately. Only expose MCP to
206
- > clients you trust, and put every permission check inside `execute()`.
211
+ > **Confirmation over MCP (12.3.0+).** `tools/call` applies the admin's tool policies and
212
+ > `authorize()`, and asks the user through MCP elicitation before a `destructive` tool (or a
213
+ > `mutating` one under `ai.confirmation.mutating`) runs. A client without elicitation gets an
214
+ > error result. Each user may switch the question off (and on) for themselves via
215
+ > `PUT /ai/settings` / `updateAiSettings`; `ai.mcp.confirmation` sets the default
216
+ > (`false` = off) and `{ enforced: true }` takes the switch away. **Check your MCP clients**
217
+ > and offer users the switch in your UI (`GET /ai/settings` says whether it is enforced).
218
+ > See the module README, "Authorization and confirmation over MCP".
207
219
 
208
220
  ### MCP OAuth 2.1 (only when `ai.mcp.oauth` is enabled)
209
221
 
@@ -229,9 +241,15 @@ Put `{returnTo}` into it only if that login page can send the user back to the A
229
241
  (the nuxt-base-starter login accepts same-origin targets only, so leave it out there).
230
242
 
231
243
  **Optional — your own consent UI:** a subclass of `CoreAiMcpOAuthService` overriding
232
- `renderConsentPage(view)` (restyle) or `authorizeConsent(client, params, res)` (replace), registered via
244
+ `renderConsentPage(view)` (restyle — `renderMcpOAuthConsentPage(view, { texts, styles })` changes wording and
245
+ CSS without copying markup, scripts or headers; fill both from code or configuration only) or
246
+ `authorizeConsent(client, params, res)` (replace), registered via
233
247
  `CoreModule.forRoot(envConfig, { ai: { mcpOAuthService: McpOAuthService } })`.
234
248
 
249
+ **Optional — no sticky sessions:** `ai: { mcp: { stateless: true } }` serves `/ai/mcp` without sessions, so any
250
+ replica answers any request. Without a session the server cannot ask the user (no MCP elicitation): tools that
251
+ need confirmation are refused there unless the user or `ai.mcp.confirmation` switched the question off.
252
+
235
253
  **WHY a POST, never "signed in, so issue":** anybody can register a client with their own redirect
236
254
  URI and start the flow — PKCE is then theirs too. A GET to `/authorize` is what any link produces,
237
255
  and it carries the session cookie, so issuing on GET hands a code for whoever clicks to the
@@ -242,7 +260,10 @@ with a CSRF token.
242
260
  **WHY refuse with `this.loadOAuthErrors()`:** the SDK maps an error to its OAuth code only when it
243
261
  is one of its own `OAuthError` classes, and turns anything else into `server_error`. A user who
244
262
  declines consent should send the client back with `access_denied` (`AccessDeniedError`), not with
245
- a server error.
263
+ a server error. Check the CSRF token on Deny as on Allow (12.3.0): the refusal redirects the browser to
264
+ the client's registered URI, so an unprotected Deny lets a cross-site POST send a signed-in user there.
265
+ An error before your method runs (a malformed request) is answered with 400 by the framework and never
266
+ redirected.
246
267
 
247
268
  ## Verification Checklist
248
269