@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
@@ -10,6 +10,7 @@ import {
10
10
  } from '../../../common/services/rate-limit-store.js';
11
11
  import { RequestContext } from '../../../common/services/request-context.service.js';
12
12
  import { ErrorCode } from '../../error-code/index.js';
13
+ import { authorizeToolCall, toolRequiresConfirmation } from '../helpers/ai-tool-authorization.helper.js';
13
14
  import { AiToolAuthorization, AiToolContext, AiToolResult, IAiTool } from '../interfaces/ai-tool.interface.js';
14
15
  import { LlmMessage, LlmResponse, LlmToolCall } from '../interfaces/llm-provider.interface.js';
15
16
  import { ResolvedAiConnection } from '../interfaces/resolved-ai-connection.interface.js';
@@ -107,6 +108,9 @@ export interface AiRunContext {
107
108
  export class CoreAiService {
108
109
  protected readonly logger = new Logger(CoreAiService.name);
109
110
 
111
+ /** Connections already warned about a crowded context window; created on first use, at most 1000 entries. */
112
+ protected crowdedContextWarned?: Set<string>;
113
+
110
114
  /** Rate-limit counters keyed by user id (Redis-backed when configured). */
111
115
  protected rateLimitStore?: RateLimitStore;
112
116
 
@@ -142,7 +146,10 @@ export class CoreAiService {
142
146
  hooks?: { onAction?: (action: CoreAiAction) => void },
143
147
  ): Promise<CoreAiResponse> {
144
148
  const mode = input.mode || ConfigService.get<string>('ai.defaultMode') || 'auto';
145
- const resolvedInput = await this.resolvePromptPlaceholders(input, serviceOptions);
149
+ const resolvedInput = await this.scopeConversation(
150
+ await this.resolvePromptPlaceholders(input, serviceOptions),
151
+ serviceOptions,
152
+ );
146
153
  const run = await this.prepareRun(resolvedInput, serviceOptions);
147
154
  // No usable connection → AI handling is effectively disabled.
148
155
  if (!run) {
@@ -157,6 +164,28 @@ export class CoreAiService {
157
164
  return response;
158
165
  }
159
166
 
167
+ /**
168
+ * Keep `conversationId` only when the caller owns that conversation; otherwise the run has no conversation at all:
169
+ * no history, no persisted turns, no conversation-scoped tool grants, and no `conversationId` in the response.
170
+ *
171
+ * Every conversation use of the run reads the id from the input, so it is decided once, before the run starts. A
172
+ * foreign id must not reach any of them: its turns would land in another user's history and feed that user's next
173
+ * runs, where tools act with the owner's rights, and a "remember my decision" grant stored on it would let the
174
+ * owner's assistant run a mutating tool unconfirmed. Admins included — they may read other users' conversations,
175
+ * never continue them. An unknown id is answered exactly like a foreign one, so the response tells nobody which
176
+ * conversation ids exist.
177
+ */
178
+ protected async scopeConversation(
179
+ input: CoreAiPromptInput,
180
+ serviceOptions: ServiceOptions,
181
+ ): Promise<CoreAiPromptInput> {
182
+ if (!input?.conversationId) {
183
+ return input;
184
+ }
185
+ const owned = await this.conversationService?.isOwnedBy(input.conversationId, serviceOptions?.currentUser?.id);
186
+ return owned ? input : { ...input, conversationId: undefined };
187
+ }
188
+
160
189
  /**
161
190
  * Resolve `{{placeholder}}` tokens in the user's prompt text via the
162
191
  * runtime placeholder registry. Returns the input untouched when no
@@ -403,6 +432,10 @@ export class CoreAiService {
403
432
  },
404
433
  );
405
434
  const toolSchemas = this.promptBuilder.buildToolSchemas(tools);
435
+ // A native connection carries the tool definitions in the payload, outside the messages: they occupy the context
436
+ // window all the same, so they come off the input budget (12.3.0).
437
+ const toolTokens = provider.capabilities.nativeTools ? this.estimateTokens(JSON.stringify(toolSchemas)) : 0;
438
+ this.warnOnCrowdedContext(connection, this.estimateTokens(systemPrompt) + toolTokens);
406
439
  // The JSON output contract belongs to the EMULATED protocol only: `output_contract`
407
440
  // and `tool_protocol_emulated` both carry `capability: 'emulated'`, so a NATIVE-tools
408
441
  // run is asked for PROSE and is handed no JSON shape to fill. Forcing
@@ -475,8 +508,8 @@ export class CoreAiService {
475
508
  iterations++;
476
509
  // Keep the session within the model's context window before every call:
477
510
  // LLM-driven compaction first (summarize), then hard trim as a fallback.
478
- await this.compactMessages(messages, connection);
479
- this.fitMessagesToContext(messages, connection);
511
+ await this.compactMessages(messages, connection, toolTokens);
512
+ this.fitMessagesToContext(messages, connection, toolTokens);
480
513
  const completion = await provider.chat(messages, toolSchemas, {
481
514
  ...jsonMode,
482
515
  maxTokens: connection.defaultMaxTokens,
@@ -642,8 +675,17 @@ export class CoreAiService {
642
675
  // Persist the turn pair for multi-turn conversations (not while awaiting confirmation
643
676
  // or a pending clarifying question — the user hasn't given a real answer yet).
644
677
  if (input.conversationId && this.conversationService && !requiresConfirmation && !pendingQuestion) {
645
- await this.conversationService.appendMessage(input.conversationId, { content: input.prompt, role: 'user' });
646
- await this.conversationService.appendMessage(input.conversationId, { content: finalText, role: 'assistant' });
678
+ const owner = currentUser?.id;
679
+ await this.conversationService.appendMessage(
680
+ input.conversationId,
681
+ { content: input.prompt, role: 'user' },
682
+ owner,
683
+ );
684
+ await this.conversationService.appendMessage(
685
+ input.conversationId,
686
+ { content: finalText, role: 'assistant' },
687
+ owner,
688
+ );
647
689
  }
648
690
 
649
691
  await this.audit({
@@ -676,6 +718,7 @@ export class CoreAiService {
676
718
 
677
719
  // 1. Planning call — the model returns the full ordered plan, executes nothing.
678
720
  const planSystemPrompt = await this.promptBuilder.buildPlanSystemPrompt(tools, currentUser, { language });
721
+ this.warnOnCrowdedContext(connection, this.estimateTokens(planSystemPrompt));
679
722
  const messages: LlmMessage[] = [{ content: planSystemPrompt, role: 'system' }];
680
723
  for (const turn of history) {
681
724
  messages.push({ content: turn.content, role: turn.role === 'assistant' ? 'assistant' : 'user' });
@@ -782,7 +825,7 @@ export class CoreAiService {
782
825
  response.text = finalText;
783
826
  response.usage = Object.assign(new CoreAiUsage(), usage);
784
827
 
785
- await this.persistTurn(input, response);
828
+ await this.persistTurn(input, response, run);
786
829
  await this.audit(this.auditRecord(input, run, response, usage));
787
830
  return response;
788
831
  }
@@ -895,9 +938,10 @@ export class CoreAiService {
895
938
  }
896
939
 
897
940
  /**
898
- * Pre-flight authorization for a planned/requested tool call. Combines the
899
- * registry role filter (tool absent from the user's set → denied) with the
900
- * tool's optional `authorize()` data-level check. Never mutates anything.
941
+ * Authorization for one tool call: the registry role filter (tool absent from the user's set → denied) and the
942
+ * tool's optional `authorize()` data-level check, fail-closed ({@link authorizeToolCall}). Runs before every
943
+ * execution (`executeToolCall()`) and, in plan mode, once more for the whole plan before any step runs. Never
944
+ * mutates anything.
901
945
  */
902
946
  protected async authorizeCall(
903
947
  call: LlmToolCall,
@@ -908,11 +952,7 @@ export class CoreAiService {
908
952
  if (!tool) {
909
953
  return { allowed: false, reason: `Unknown or not permitted tool: ${call.name}` };
910
954
  }
911
- if (!tool.authorize) {
912
- return { allowed: true };
913
- }
914
- const result = await tool.authorize(call.arguments ?? {}, context);
915
- return typeof result === 'boolean' ? { allowed: result } : result;
955
+ return authorizeToolCall(tool, call.arguments ?? {}, context);
916
956
  }
917
957
 
918
958
  /**
@@ -921,18 +961,7 @@ export class CoreAiService {
921
961
  * client override unless enforced).
922
962
  */
923
963
  protected confirmationRequiredFor(tool: IAiTool, input: CoreAiPromptInput): boolean {
924
- if (tool.destructive) {
925
- return true;
926
- }
927
- if (!tool.mutating) {
928
- return false;
929
- }
930
- const cfg = ConfigService.get<{ enabled?: boolean; default?: boolean; enforced?: boolean }>(
931
- 'ai.confirmation.mutating',
932
- );
933
- const enforced = cfg?.enforced === true;
934
- const adminDefault = cfg?.default === true;
935
- return enforced ? true : (input.requireConfirmation ?? adminDefault);
964
+ return toolRequiresConfirmation(tool, input.requireConfirmation);
936
965
  }
937
966
 
938
967
  /**
@@ -1051,13 +1080,22 @@ export class CoreAiService {
1051
1080
  }
1052
1081
 
1053
1082
  /**
1054
- * Append the user + assistant turns to the conversation (skipped while awaiting
1083
+ * Append the user + assistant turns to the conversation of the run's user (skipped while awaiting
1055
1084
  * confirmation or when denied).
1056
1085
  */
1057
- protected async persistTurn(input: CoreAiPromptInput, response: CoreAiResponse): Promise<void> {
1086
+ protected async persistTurn(input: CoreAiPromptInput, response: CoreAiResponse, run: AiRunContext): Promise<void> {
1058
1087
  if (input.conversationId && this.conversationService && !response.requiresConfirmation && !response.denied) {
1059
- await this.conversationService.appendMessage(input.conversationId, { content: input.prompt, role: 'user' });
1060
- await this.conversationService.appendMessage(input.conversationId, { content: response.text, role: 'assistant' });
1088
+ const owner = run?.currentUser?.id;
1089
+ await this.conversationService.appendMessage(
1090
+ input.conversationId,
1091
+ { content: input.prompt, role: 'user' },
1092
+ owner,
1093
+ );
1094
+ await this.conversationService.appendMessage(
1095
+ input.conversationId,
1096
+ { content: response.text, role: 'assistant' },
1097
+ owner,
1098
+ );
1061
1099
  }
1062
1100
  }
1063
1101
 
@@ -1195,8 +1233,24 @@ export class CoreAiService {
1195
1233
  }
1196
1234
  }
1197
1235
 
1236
+ // Authorize the arguments that will actually run — after the hooks rewrote them. No learning signal: a refusal
1237
+ // is a decision about this user and these arguments, not a mistake the model should be taught to avoid.
1238
+ const authorization = await this.authorizeCall(call, availableTools, context);
1239
+ if (!authorization.allowed) {
1240
+ action.success = false;
1241
+ action.result = {
1242
+ error: {
1243
+ code: 'TOOL_NOT_AVAILABLE',
1244
+ hint: 'The user may not run this tool with these arguments. Do not retry it; explain this to the user.',
1245
+ message: authorization.reason || `Not permitted: ${call.name}`,
1246
+ },
1247
+ };
1248
+ return action;
1249
+ }
1250
+ const toolContext: AiToolContext = tool.authorize ? { ...context, authorization } : context;
1251
+
1198
1252
  try {
1199
- const raw = await tool.execute(call.arguments ?? {}, context);
1253
+ const raw = await tool.execute(call.arguments ?? {}, toolContext);
1200
1254
  let success = true;
1201
1255
  let payload: unknown = raw;
1202
1256
  let message: string | undefined;
@@ -1468,6 +1522,47 @@ export class CoreAiService {
1468
1522
  return Math.max(512, window - reserve - margin);
1469
1523
  }
1470
1524
 
1525
+ /**
1526
+ * The share of {@link contextBudget} the messages may take: what remains after `reservedTokens` — the native tool
1527
+ * definitions, which travel beside the messages but occupy the same window.
1528
+ */
1529
+ protected messageBudget(connection: ResolvedAiConnection, reservedTokens = 0): number {
1530
+ return Math.max(512, this.contextBudget(connection) - Math.max(0, reservedTokens));
1531
+ }
1532
+
1533
+ /** Share of the input budget the system prompt and tool definitions may take before {@link warnOnCrowdedContext} warns. */
1534
+ protected crowdedContextShare(): number {
1535
+ return 0.5;
1536
+ }
1537
+
1538
+ /**
1539
+ * Warns once per connection and process when the system prompt and the native tool definitions take more than
1540
+ * {@link crowdedContextShare} of the input budget (12.3.0). What remains is all the conversation has: older turns
1541
+ * are dropped early, and a long user message is cut to fit — which, logged on debug only, read as an assistant that
1542
+ * answers generically. Names the remedy: a context window for the connection, or a smaller catalog.
1543
+ */
1544
+ protected warnOnCrowdedContext(connection: ResolvedAiConnection, fixedTokens: number): void {
1545
+ const budget = this.contextBudget(connection);
1546
+ const key = connection.id ?? connection.name ?? '';
1547
+ const warned = (this.crowdedContextWarned ??= new Set<string>());
1548
+ if (fixedTokens <= budget * this.crowdedContextShare() || warned.has(key)) {
1549
+ return;
1550
+ }
1551
+ if (warned.size >= 1000) {
1552
+ warned.clear();
1553
+ }
1554
+ warned.add(key);
1555
+ const window = connection.contextWindow
1556
+ ? `its context window of ${connection.contextWindow}`
1557
+ : `the ai.contextWindow fallback of ${ConfigService.get<number>('ai.contextWindow') ?? 8192} (the connection has no context window of its own)`;
1558
+ this.logger.warn(
1559
+ `AI connection "${connection.name ?? key}": the system prompt and tool definitions take ~${fixedTokens} of ` +
1560
+ `${budget} input tokens (${window}). Little room is left for the conversation: older turns are dropped ` +
1561
+ `early and a long message is cut. Set the connection's contextWindow, or shrink the tool catalog ` +
1562
+ `(ai.deferToolSchemas, fewer tools per role).`,
1563
+ );
1564
+ }
1565
+
1471
1566
  /**
1472
1567
  * Keep the assembled session messages within the model's context window. Operates
1473
1568
  * per user/session: the system prompt (index 0) and the most recent message (the
@@ -1483,14 +1578,18 @@ export class CoreAiService {
1483
1578
  *
1484
1579
  * Enabled by `ai.compaction !== false`. When OFF, only the hard trim is used.
1485
1580
  */
1486
- protected async compactMessages(messages: LlmMessage[], connection: ResolvedAiConnection): Promise<number> {
1581
+ protected async compactMessages(
1582
+ messages: LlmMessage[],
1583
+ connection: ResolvedAiConnection,
1584
+ reservedTokens = 0,
1585
+ ): Promise<number> {
1487
1586
  if (ConfigService.get<boolean>('ai.compaction') === false) {
1488
1587
  return 0;
1489
1588
  }
1490
1589
  if (messages.length <= 3) {
1491
1590
  return 0;
1492
1591
  }
1493
- const budget = this.contextBudget(connection);
1592
+ const budget = this.messageBudget(connection, reservedTokens);
1494
1593
  const total = (): number => messages.reduce((sum, m) => sum + this.estimateTokens(m.content) + 4, 0);
1495
1594
  if (total() <= budget) {
1496
1595
  return 0;
@@ -1539,11 +1638,11 @@ export class CoreAiService {
1539
1638
  }
1540
1639
  }
1541
1640
 
1542
- protected fitMessagesToContext(messages: LlmMessage[], connection: ResolvedAiConnection): void {
1641
+ protected fitMessagesToContext(messages: LlmMessage[], connection: ResolvedAiConnection, reservedTokens = 0): void {
1543
1642
  if (messages.length === 0) {
1544
1643
  return;
1545
1644
  }
1546
- const budget = this.contextBudget(connection);
1645
+ const budget = this.messageBudget(connection, reservedTokens);
1547
1646
  const total = (): number => messages.reduce((sum, m) => sum + this.estimateTokens(m.content) + 4, 0);
1548
1647
  let dropped = 0;
1549
1648
  // Drop oldest non-system, non-last messages until within budget.
@@ -199,8 +199,10 @@ it has no signed assertions.
199
199
  `betterAuth.emailAndPassword.revokeSessionsOnPasswordReset` is on. With it off — and on a password
200
200
  CHANGE — user tokens survive, like personal access tokens elsewhere; after a suspected compromise
201
201
  call `revokeAllForUser(userId)`. Tenant tokens are never touched by a user's reset.
202
- - **Deleting a tenant.** Call `deleteAllForTenant(tenantId)` — nothing does it for you, and until you
203
- do, the tenant's tokens keep authenticating (the core has no tenant model to notice the deletion).
202
+ - **Deleting a tenant.** Call `CoreTenantService.purgeTenantData(tenantId)`, which deletes the tokens
203
+ with the rest of the tenant's data, or `deleteAllForTenant(tenantId)` for the tokens alone — nothing
204
+ does it for you, and until you do, the tenant's tokens keep authenticating (the core has no tenant
205
+ model to notice the deletion).
204
206
  - **Banned users.** If you add Better-Auth's admin plugin, override `loadTokenUser()` so a banned
205
207
  user's tokens are refused as well — the framework does not know that flag.
206
208
  - **Brute force.** Failed attempts are not limited per IP (guessing 256 bits is not the risk); add an
@@ -472,7 +472,7 @@ const config = {
472
472
  template: 'email-verification', // Default: 'email-verification'
473
473
  expiresIn: 86400, // Default: 86400 (24 hours)
474
474
  callbackURL: '/auth/verify-email', // Frontend verification page
475
- autoSignInAfterVerification: true, // Default: true
475
+ autoSignInAfterVerification: false, // Default: false — true signs in whoever opens the link (see README)
476
476
  resendCooldownSeconds: 60, // Default: 60
477
477
  },
478
478
  },
@@ -18,14 +18,17 @@ From `@lenne.tech/nest-server` 11.37.0, better-auth is **not** a dependency of t
18
18
  non-optional **peer dependency**, so your project declares and owns the version:
19
19
 
20
20
  ```bash
21
- pnpm add better-auth@1.7.7 @better-auth/passkey@1.7.7 @better-auth/core@1.7.7
21
+ pnpm add better-auth@1.7.16 @better-auth/passkey@1.7.16 @better-auth/core@1.7.16
22
22
  ```
23
23
 
24
+ 1.7.16 is the floor since nest-server 12.3.1: it closes the better-auth advisories published on 2026-11-03 (social
25
+ sign-in, passkeys, e-mail change links, revoked sessions with a session cache).
26
+
24
27
  **Pin all three to the same exact version** — no `^`, no `~`. They are one release train, and a
25
28
  mixed set fails in ways the type checker does not catch.
26
29
 
27
30
  > **Do not rely on `pnpm install` to remind you.** On pnpm's default `autoInstallPeers: true` the
28
- > packages are installed silently at whatever version satisfies `>=1.7.7 <1.8.0` — your build stays
31
+ > packages are installed silently at whatever version satisfies `>=1.7.16 <1.8.0` — your build stays
29
32
  > green while the version is unpinned and free to drift. Verify with `pnpm why better-auth`.
30
33
 
31
34
  **Why peers rather than dependencies:** in a fullstack project the frontend talks to better-auth
@@ -273,6 +276,10 @@ Email verification is **enabled by default** via Better-Auth's `emailVerificatio
273
276
  - Users receive a verification email after sign-up
274
277
  - Email contains a verification link with token
275
278
  - After verification, `verifiedAt` is automatically set on the user
279
+ - The user is **not** signed in by the verification link and signs in once afterwards (12.3.0+, as in Better-Auth).
280
+ `emailVerification: { autoSignInAfterVerification: true }` signs in whoever opens the link, to the link owner's
281
+ account — a forwarded link signs its recipient in to the sender's account. Your frontend's verify page should lead
282
+ to the sign-in rather than expect a session
276
283
  - Templates are provided in German and English
277
284
 
278
285
  ### Configuration
@@ -448,6 +455,9 @@ After integration, verify:
448
455
  - [ ] GraphQL `betterAuthSignUp` returns error `LTNS_0026`
449
456
  - [ ] `GET /iam/features` reports `signUpEnabled: false`
450
457
  - [ ] Sign-in still works for existing users
458
+ - [ ] With a social provider: a first social sign-in of an unknown user creates no account (`error=signup_disabled`),
459
+ an existing user still signs in through it. A provider meant for self-registration carries
460
+ `disableSignUp: false` (12.3.0+)
451
461
 
452
462
  ### Additional checks for Migration scenario:
453
463
 
@@ -550,6 +550,13 @@ const config = {
550
550
 
551
551
  **Brevo Integration:** When `brevoTemplateId` is set and Brevo is configured (`config.brevo`), verification emails are sent via Brevo's transactional API instead of SMTP/EJS templates. The same template variables (`name`, `link`, `appName`, `expiresIn`) are passed to the Brevo template.
552
552
 
553
+ **Auto sign-in after verification (`autoSignInAfterVerification`, default `false` since 12.3.0):** with it on,
554
+ `GET /iam/verify-email` signs in the browser that opens the link — to the account the link belongs to, whoever opens
555
+ it. Someone who signs up with their own address and forwards the unopened link then has the recipient's browser signed
556
+ in to their account, and what the recipient enters there lands with them (login CSRF). Off, the user verifies and then
557
+ signs in once, as in Better-Auth's own default. Switch it on (`emailVerification: { autoSignInAfterVerification: true }`)
558
+ only where a verification link reaches nobody but its owner.
559
+
553
560
  ### Sign-Up Checks (v11.13.0+)
554
561
 
555
562
  Sign-up validation is **enabled by default** requiring `termsAndPrivacyAccepted: true`.
@@ -622,13 +629,31 @@ When disabled:
622
629
  - REST `POST /iam/sign-up/email` returns `400 Bad Request` with error `LTNS_0026`
623
630
  - GraphQL `betterAuthSignUp` mutation returns error `LTNS_0026`
624
631
  - `betterAuthFeatures` reports `signUpEnabled: false`
625
- - Sign-in continues to work for existing users
632
+ - A first sign-in through a social provider creates no account (since 12.3.0): the OAuth callback redirects with
633
+ `error=signup_disabled`, `/sign-in/social` with an `idToken` answers 401 `signup disabled`
634
+ - Sign-in continues to work for existing users, through e-mail and every social provider
626
635
 
627
636
  **Defense in Depth:** The flag is enforced at two layers:
628
637
 
629
638
  1. **Custom check** (`CoreBetterAuthService.ensureSignUpEnabled()`) runs in Controller/Resolver _before_ any BetterAuth API call and returns a structured `LTNS_0026` error.
630
639
  2. **Native BetterAuth** `emailAndPassword.disableSignUp` acts as a safety net for any direct API access that bypasses the custom check.
631
640
 
641
+ **Social providers:** Better-Auth reads `emailAndPassword.disableSignUp` for `/sign-up/email` only and decides a social
642
+ sign-up per provider, so `buildSocialProviders()` hands every provider the flag as its own `disableSignUp`. One provider
643
+ can deviate on purpose:
644
+
645
+ ```typescript
646
+ socialProviders: {
647
+ google: { clientId: '...', clientSecret: '...', disableSignUp: false }, // new users through Google despite the global flag
648
+ github: { clientId: '...', clientSecret: '...', disableSignUp: true }, // closed for GitHub even while sign-up is open
649
+ },
650
+ ```
651
+
652
+ Only a boolean counts on the provider; any other value follows `emailAndPassword.disableSignUp`, so a string from
653
+ `NEST_SERVER_CONFIG` cannot open a closed registration. Plugins a project adds itself through `betterAuth.options.plugins`
654
+ (Generic OAuth, Magic Link, …) are not covered and need their own sign-up options. Before 12.3.0 every provider created
655
+ accounts whatever `disableSignUp` said.
656
+
632
657
  **Default:** `false` (sign-up enabled) - fully backward compatible.
633
658
 
634
659
  ### Additional User Fields
@@ -2205,6 +2230,12 @@ These endpoints bypass rate limiting entirely:
2205
2230
  - `/session` - Session checks (frequent client-side calls)
2206
2231
  - `/callback` - OAuth callbacks
2207
2232
 
2233
+ Entries match whole path segments of the path the request is served under (dot segments resolved, case-insensitive, as Express routes): `/sign-in` covers
2234
+ `/sign-in/email`, `/session` does not cover `/sessions-export`. A request whose path under the base path carries a
2235
+ `.` or `..` segment (also `%2e`-encoded), a backslash, an encoded `/` or `\`, or a `#` is refused with 400
2236
+ (`#LTNS_0305: Invalid request path`) before the limiter runs — no route needs such a path, and the limiter and
2237
+ Better-Auth would otherwise read it as two different routes.
2238
+
2208
2239
  ### Response Headers
2209
2240
 
2210
2241
  When rate limiting is enabled, the following headers are added to responses:
@@ -444,6 +444,7 @@ interface PasskeyNormalizationResult {
444
444
  interface SocialProviderConfig {
445
445
  clientId: string;
446
446
  clientSecret: string;
447
+ disableSignUp: boolean;
447
448
  }
448
449
 
449
450
  /**
@@ -955,7 +956,9 @@ function buildEmailVerificationConfig(
955
956
  // Get configuration values (with defaults)
956
957
  const configObj = typeof emailVerificationConfig === 'object' ? emailVerificationConfig : {};
957
958
  const expiresIn = configObj.expiresIn ?? 86400; // 24 hours
958
- const autoSignInAfterVerification = configObj.autoSignInAfterVerification ?? true;
959
+ // Off unless switched on, as Better-Auth has it: on verification Better-Auth signs in the browser that opens the
960
+ // link, to the account the link belongs to — a forwarded link signs its recipient in to the sender's account
961
+ const autoSignInAfterVerification = configObj.autoSignInAfterVerification === true;
959
962
 
960
963
  const result: Record<string, unknown> = {
961
964
  autoSignInAfterVerification,
@@ -1062,15 +1065,20 @@ function buildPlugins(
1062
1065
  rpName: normalizedPasskey.rpName,
1063
1066
  };
1064
1067
 
1065
- // Add optional WebAuthn configuration if specified
1068
+ // The plugin reads these three from `authenticatorSelection` only. Until 12.3.1 they were set at the top level and
1069
+ // ignored, so `userVerification: 'required'` enforced nothing; the plugin's own defaults fill what is not set
1070
+ const authenticatorSelection: Record<string, string> = {};
1066
1071
  if (normalizedPasskey.authenticatorAttachment) {
1067
- passkeyOptions.authenticatorAttachment = normalizedPasskey.authenticatorAttachment;
1072
+ authenticatorSelection.authenticatorAttachment = normalizedPasskey.authenticatorAttachment;
1068
1073
  }
1069
1074
  if (normalizedPasskey.residentKey) {
1070
- passkeyOptions.residentKey = normalizedPasskey.residentKey;
1075
+ authenticatorSelection.residentKey = normalizedPasskey.residentKey;
1071
1076
  }
1072
1077
  if (normalizedPasskey.userVerification) {
1073
- passkeyOptions.userVerification = normalizedPasskey.userVerification;
1078
+ authenticatorSelection.userVerification = normalizedPasskey.userVerification;
1079
+ }
1080
+ if (Object.keys(authenticatorSelection).length > 0) {
1081
+ passkeyOptions.authenticatorSelection = authenticatorSelection;
1074
1082
  }
1075
1083
  if (normalizedPasskey.webAuthnChallengeCookie) {
1076
1084
  // The plugin reads the name from `advanced`; a top-level option is ignored
@@ -1118,9 +1126,16 @@ function serverOnlyPlugin<T extends BetterAuthPlugin>(plugin: T): T {
1118
1126
  * Builds the social providers configuration object dynamically.
1119
1127
  * Iterates over all configured providers and includes those that are enabled
1120
1128
  * with valid clientId and clientSecret.
1129
+ *
1130
+ * `emailAndPassword.disableSignUp: true` closes every way to a new account (12.3.0). Better-Auth reads that flag for
1131
+ * `/sign-up/email` only and decides a social sign-up per provider (`provider.options.disableSignUp`, in the OAuth
1132
+ * callback and in `/sign-in/social` with an `idToken`), so each provider receives it explicitly. A boolean
1133
+ * `disableSignUp` on the provider is the deliberate exception for that provider; any other value follows the global
1134
+ * flag, so a string arriving through `NEST_SERVER_CONFIG` cannot open a closed registration.
1121
1135
  */
1122
1136
  function buildSocialProviders(config: IBetterAuth): Record<string, SocialProviderConfig> {
1123
1137
  const socialProvidersConfig: Record<string, SocialProviderConfig> = {};
1138
+ const signUpClosed = config.emailAndPassword?.disableSignUp === true;
1124
1139
 
1125
1140
  // Iterate over all configured social providers dynamically
1126
1141
  // A provider is enabled by default if it has credentials configured
@@ -1131,6 +1146,7 @@ function buildSocialProviders(config: IBetterAuth): Record<string, SocialProvide
1131
1146
  socialProvidersConfig[name] = {
1132
1147
  clientId: provider.clientId,
1133
1148
  clientSecret: provider.clientSecret,
1149
+ disableSignUp: typeof provider.disableSignUp === 'boolean' ? provider.disableSignUp : signUpClosed,
1134
1150
  };
1135
1151
  }
1136
1152
  }
@@ -38,7 +38,7 @@ type ResolvedEmailVerificationConfig = Pick<
38
38
  * Default configuration for email verification
39
39
  */
40
40
  const DEFAULT_CONFIG: ResolvedEmailVerificationConfig = {
41
- autoSignInAfterVerification: true,
41
+ autoSignInAfterVerification: false,
42
42
  callbackURL: '/auth/verify-email',
43
43
  enabled: true,
44
44
  expiresIn: 86400, // 24 hours in seconds
@@ -192,9 +192,12 @@ export class CoreBetterAuthEmailVerificationService {
192
192
 
193
193
  /**
194
194
  * Check if auto sign-in after verification is enabled
195
+ *
196
+ * `=== true` as in `better-auth.config.ts`, which decides what Better-Auth does: a value arriving as
197
+ * a string in `NEST_SERVER_CONFIG` must not report a sign-in that Better-Auth never performs.
195
198
  */
196
199
  shouldAutoSignIn(): boolean {
197
- return this.config.autoSignInAfterVerification;
200
+ return this.config.autoSignInAfterVerification === true;
198
201
  }
199
202
 
200
203
  /**
@@ -0,0 +1,85 @@
1
+ import { BadRequestException, Injectable, NestMiddleware } from '@nestjs/common';
2
+ import type { NextFunction, Request, Response } from 'express';
3
+
4
+ import { ErrorCode } from '../error-code/error-codes.js';
5
+
6
+ /**
7
+ * Whether a raw request path contains something URL resolution rewrites: a `.` or `..` segment (also
8
+ * percent-encoded), a backslash (a segment separator for WHATWG URLs), an encoded slash or backslash, or a `#`
9
+ * (Express routes the part before it, the forwarding to Better-Auth reads the whole string).
10
+ *
11
+ * Every check in front of Better-Auth (rate limiter, controller routes, password routes) reads the RAW path, while
12
+ * Better-Auth routes the path `new URL()` resolved. A path the two read differently passes those checks as one route
13
+ * and runs as another. No route under the Better-Auth base path needs any of these, so such a request is refused.
14
+ *
15
+ * A loop over segments, no regex over the whole path: the path comes from an unauthenticated request and may be as
16
+ * long as Node's header limit allows.
17
+ */
18
+ export function hasNonCanonicalPathSegments(path: string): boolean {
19
+ if (path.includes('\\') || path.includes('#')) {
20
+ return true;
21
+ }
22
+ for (const segment of path.split('/')) {
23
+ if (segment.includes('%')) {
24
+ const lower = segment.toLowerCase();
25
+ if (lower.includes('%2f') || lower.includes('%5c')) {
26
+ return true;
27
+ }
28
+ }
29
+ // The longest dot segment is `%2e%2e`
30
+ if (segment.length === 0 || segment.length > 6) {
31
+ continue;
32
+ }
33
+ const dots = segment.replace(/%2e/gi, '.');
34
+ if (dots === '.' || dots === '..') {
35
+ return true;
36
+ }
37
+ }
38
+ return false;
39
+ }
40
+
41
+ /**
42
+ * The path as Better-Auth routes it: dot segments resolved (also percent-encoded ones), backslashes read as slashes —
43
+ * what `new URL()` makes of it. Query string and fragment are dropped. A path `new URL()` refuses comes back as given.
44
+ */
45
+ export function canonicalRequestPath(path: string): string {
46
+ try {
47
+ return new URL(path, 'http://canonical.invalid').pathname;
48
+ } catch {
49
+ return path;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Whether `path` contains the segments of `endpoint` as a contiguous run of WHOLE segments: `/sign-in` matches
55
+ * `/sign-in/email` and `/phone-number/sign-in`, never `/sign-in-legacy`. Segments are compared as they are; pass a
56
+ * {@link canonicalRequestPath} result, so `..` is already resolved.
57
+ */
58
+ export function pathContainsSegments(path: string, endpoint: string): boolean {
59
+ const wanted = endpoint.split('/').filter(Boolean);
60
+ if (wanted.length === 0) {
61
+ return false;
62
+ }
63
+ const segments = path.split('/').filter(Boolean);
64
+ for (let start = 0; start + wanted.length <= segments.length; start++) {
65
+ if (wanted.every((segment, offset) => segments[start + offset] === segment)) {
66
+ return true;
67
+ }
68
+ }
69
+ return false;
70
+ }
71
+
72
+ /**
73
+ * First middleware under the Better-Auth base path: refuses a request whose path is not in canonical form (see
74
+ * {@link hasNonCanonicalPathSegments}) with 400, before any check reads that path.
75
+ */
76
+ @Injectable()
77
+ export class CoreBetterAuthPathGuardMiddleware implements NestMiddleware {
78
+ use(req: Request, _res: Response, next: NextFunction) {
79
+ const path = (req.originalUrl || req.url || '').split('?')[0];
80
+ if (hasNonCanonicalPathSegments(path)) {
81
+ throw new BadRequestException(ErrorCode.INVALID_REQUEST_PATH);
82
+ }
83
+ next();
84
+ }
85
+ }
@@ -50,9 +50,12 @@ export class CoreBetterAuthRateLimitMiddleware implements NestMiddleware {
50
50
  // Get client IP
51
51
  const ip = this.getClientIp(req);
52
52
 
53
- // Get the path relative to basePath
53
+ // The path relative to basePath, read from `originalUrl`: Nest mounts this middleware on `${basePath}/*path`, and
54
+ // Express moves the whole match into `req.baseUrl`, so `req.path` is "/" for every request here. Reading it made
55
+ // every IAM request of one client share a single counter with the full limit — no skip list, no strict limit
54
56
  const basePath = this.betterAuthService.getBasePath();
55
- const path = req.path.startsWith(basePath) ? req.path.substring(basePath.length) : req.path;
57
+ const requestPath = (req.originalUrl || req.url || '').split('?')[0];
58
+ const path = requestPath.startsWith(basePath) ? requestPath.substring(basePath.length) : requestPath;
56
59
 
57
60
  // Check rate limit
58
61
  const result = await this.rateLimiter.check(ip, path);