@lenne.tech/nest-server 11.31.1 → 11.31.3

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 (177) hide show
  1. package/.claude/rules/architecture.md +2 -0
  2. package/.claude/rules/configurable-features.md +2 -0
  3. package/CLAUDE.md +28 -1
  4. package/FRAMEWORK-API.md +4 -1
  5. package/dist/config.env.d.ts +4 -0
  6. package/dist/config.env.js +32 -2
  7. package/dist/config.env.js.map +1 -1
  8. package/dist/core/common/helpers/logging.helper.d.ts +1 -0
  9. package/dist/core/common/helpers/logging.helper.js +12 -0
  10. package/dist/core/common/helpers/logging.helper.js.map +1 -1
  11. package/dist/core/common/helpers/meta.helper.d.ts +1 -0
  12. package/dist/core/common/helpers/meta.helper.js +32 -1
  13. package/dist/core/common/helpers/meta.helper.js.map +1 -1
  14. package/dist/core/common/interfaces/server-options.interface.d.ts +9 -0
  15. package/dist/core/common/services/email.service.d.ts +3 -1
  16. package/dist/core/common/services/email.service.js +33 -2
  17. package/dist/core/common/services/email.service.js.map +1 -1
  18. package/dist/core/common/services/template.service.js +9 -4
  19. package/dist/core/common/services/template.service.js.map +1 -1
  20. package/dist/core/modules/ai/core-ai.controller.d.ts +1 -1
  21. package/dist/core/modules/ai/core-ai.controller.js +4 -5
  22. package/dist/core/modules/ai/core-ai.controller.js.map +1 -1
  23. package/dist/core/modules/ai/core-ai.resolver.d.ts +1 -1
  24. package/dist/core/modules/ai/core-ai.resolver.js +7 -6
  25. package/dist/core/modules/ai/core-ai.resolver.js.map +1 -1
  26. package/dist/core/modules/ai/models/core-ai-conversation.model.d.ts +6 -0
  27. package/dist/core/modules/ai/models/core-ai-conversation.model.js +13 -1
  28. package/dist/core/modules/ai/models/core-ai-conversation.model.js.map +1 -1
  29. package/dist/core/modules/ai/services/core-ai-conversation.service.d.ts +8 -2
  30. package/dist/core/modules/ai/services/core-ai-conversation.service.js +53 -2
  31. package/dist/core/modules/ai/services/core-ai-conversation.service.js.map +1 -1
  32. package/dist/core/modules/hub/core-hub-actions.controller.d.ts +22 -0
  33. package/dist/core/modules/hub/core-hub-actions.controller.js +141 -0
  34. package/dist/core/modules/hub/core-hub-actions.controller.js.map +1 -0
  35. package/dist/core/modules/hub/core-hub-html.service.d.ts +8 -0
  36. package/dist/core/modules/hub/core-hub-html.service.js +123 -0
  37. package/dist/core/modules/hub/core-hub-html.service.js.map +1 -0
  38. package/dist/core/modules/hub/core-hub.controller.d.ts +56 -0
  39. package/dist/core/modules/hub/core-hub.controller.js +398 -0
  40. package/dist/core/modules/hub/core-hub.controller.js.map +1 -0
  41. package/dist/core/modules/hub/core-hub.module.d.ts +17 -0
  42. package/dist/core/modules/hub/core-hub.module.js +109 -0
  43. package/dist/core/modules/hub/core-hub.module.js.map +1 -0
  44. package/dist/core/modules/hub/core-hub.service.d.ts +18 -0
  45. package/dist/core/modules/hub/core-hub.service.js +153 -0
  46. package/dist/core/modules/hub/core-hub.service.js.map +1 -0
  47. package/dist/core/modules/hub/helpers/hub-client-js.helper.d.ts +1 -0
  48. package/dist/core/modules/hub/helpers/hub-client-js.helper.js +755 -0
  49. package/dist/core/modules/hub/helpers/hub-client-js.helper.js.map +1 -0
  50. package/dist/core/modules/hub/helpers/hub-command-shape.helper.d.ts +1 -0
  51. package/dist/core/modules/hub/helpers/hub-command-shape.helper.js +33 -0
  52. package/dist/core/modules/hub/helpers/hub-command-shape.helper.js.map +1 -0
  53. package/dist/core/modules/hub/helpers/hub-mask.helper.d.ts +1 -0
  54. package/dist/core/modules/hub/helpers/hub-mask.helper.js +46 -0
  55. package/dist/core/modules/hub/helpers/hub-mask.helper.js.map +1 -0
  56. package/dist/core/modules/hub/helpers/hub-mermaid.helper.d.ts +10 -0
  57. package/dist/core/modules/hub/helpers/hub-mermaid.helper.js +35 -0
  58. package/dist/core/modules/hub/helpers/hub-mermaid.helper.js.map +1 -0
  59. package/dist/core/modules/hub/helpers/hub-shell.helper.d.ts +9 -0
  60. package/dist/core/modules/hub/helpers/hub-shell.helper.js +62 -0
  61. package/dist/core/modules/hub/helpers/hub-shell.helper.js.map +1 -0
  62. package/dist/core/modules/hub/hub-action-messages.d.ts +17 -0
  63. package/dist/core/modules/hub/hub-action-messages.js +21 -0
  64. package/dist/core/modules/hub/hub-action-messages.js.map +1 -0
  65. package/dist/core/modules/hub/hub-config.helper.d.ts +11 -0
  66. package/dist/core/modules/hub/hub-config.helper.js +164 -0
  67. package/dist/core/modules/hub/hub-config.helper.js.map +1 -0
  68. package/dist/core/modules/hub/hub-nav.d.ts +12 -0
  69. package/dist/core/modules/hub/hub-nav.js +43 -0
  70. package/dist/core/modules/hub/hub-nav.js.map +1 -0
  71. package/dist/core/modules/hub/hub-ring-buffer.d.ts +22 -0
  72. package/dist/core/modules/hub/hub-ring-buffer.js +59 -0
  73. package/dist/core/modules/hub/hub-ring-buffer.js.map +1 -0
  74. package/dist/core/modules/hub/hub.constants.d.ts +18 -0
  75. package/dist/core/modules/hub/hub.constants.js +22 -0
  76. package/dist/core/modules/hub/hub.constants.js.map +1 -0
  77. package/dist/core/modules/hub/index.d.ts +25 -0
  78. package/dist/core/modules/hub/index.js +42 -0
  79. package/dist/core/modules/hub/index.js.map +1 -0
  80. package/dist/core/modules/hub/interfaces/hub-config.interface.d.ts +115 -0
  81. package/dist/core/modules/hub/interfaces/hub-config.interface.js +3 -0
  82. package/dist/core/modules/hub/interfaces/hub-config.interface.js.map +1 -0
  83. package/dist/core/modules/hub/interfaces/hub-panels.interface.d.ts +231 -0
  84. package/dist/core/modules/hub/interfaces/hub-panels.interface.js +3 -0
  85. package/dist/core/modules/hub/interfaces/hub-panels.interface.js.map +1 -0
  86. package/dist/core/modules/hub/middleware/hub-trace.middleware.d.ts +8 -0
  87. package/dist/core/modules/hub/middleware/hub-trace.middleware.js +50 -0
  88. package/dist/core/modules/hub/middleware/hub-trace.middleware.js.map +1 -0
  89. package/dist/core/modules/hub/services/core-hub-actions.service.d.ts +48 -0
  90. package/dist/core/modules/hub/services/core-hub-actions.service.js +136 -0
  91. package/dist/core/modules/hub/services/core-hub-actions.service.js.map +1 -0
  92. package/dist/core/modules/hub/services/core-hub-db.service.d.ts +19 -0
  93. package/dist/core/modules/hub/services/core-hub-db.service.js +180 -0
  94. package/dist/core/modules/hub/services/core-hub-db.service.js.map +1 -0
  95. package/dist/core/modules/hub/services/core-hub-email.service.d.ts +24 -0
  96. package/dist/core/modules/hub/services/core-hub-email.service.js +148 -0
  97. package/dist/core/modules/hub/services/core-hub-email.service.js.map +1 -0
  98. package/dist/core/modules/hub/services/core-hub-mailbox.service.d.ts +20 -0
  99. package/dist/core/modules/hub/services/core-hub-mailbox.service.js +103 -0
  100. package/dist/core/modules/hub/services/core-hub-mailbox.service.js.map +1 -0
  101. package/dist/core/modules/hub/services/core-hub-migrations.service.d.ts +25 -0
  102. package/dist/core/modules/hub/services/core-hub-migrations.service.js +115 -0
  103. package/dist/core/modules/hub/services/core-hub-migrations.service.js.map +1 -0
  104. package/dist/core/modules/hub/services/core-hub-sources.service.d.ts +28 -0
  105. package/dist/core/modules/hub/services/core-hub-sources.service.js +187 -0
  106. package/dist/core/modules/hub/services/core-hub-sources.service.js.map +1 -0
  107. package/dist/core/modules/hub/services/hub-log-buffer.service.d.ts +24 -0
  108. package/dist/core/modules/hub/services/hub-log-buffer.service.js +210 -0
  109. package/dist/core/modules/hub/services/hub-log-buffer.service.js.map +1 -0
  110. package/dist/core/modules/hub/services/hub-query-profiler.service.d.ts +38 -0
  111. package/dist/core/modules/hub/services/hub-query-profiler.service.js +235 -0
  112. package/dist/core/modules/hub/services/hub-query-profiler.service.js.map +1 -0
  113. package/dist/core/modules/hub/services/hub-trace-buffer.service.d.ts +18 -0
  114. package/dist/core/modules/hub/services/hub-trace-buffer.service.js +123 -0
  115. package/dist/core/modules/hub/services/hub-trace-buffer.service.js.map +1 -0
  116. package/dist/core.module.js +42 -1
  117. package/dist/core.module.js.map +1 -1
  118. package/dist/index.d.ts +1 -0
  119. package/dist/index.js +1 -0
  120. package/dist/index.js.map +1 -1
  121. package/dist/tsconfig.build.tsbuildinfo +1 -1
  122. package/docs/REQUEST-LIFECYCLE.md +1 -0
  123. package/migration-guides/11.31.1-to-11.31.2.md +127 -0
  124. package/migration-guides/11.31.2-to-11.31.3.md +135 -0
  125. package/package.json +5 -4
  126. package/src/config.env.ts +75 -2
  127. package/src/core/common/helpers/logging.helper.spec.ts +61 -0
  128. package/src/core/common/helpers/logging.helper.ts +48 -0
  129. package/src/core/common/helpers/meta.helper.ts +46 -1
  130. package/src/core/common/interfaces/server-options.interface.ts +46 -0
  131. package/src/core/common/services/email.service.ts +33 -1
  132. package/src/core/common/services/template.service.ts +21 -16
  133. package/src/core/modules/ai/core-ai.controller.ts +12 -9
  134. package/src/core/modules/ai/core-ai.resolver.ts +14 -9
  135. package/src/core/modules/ai/models/core-ai-conversation.model.ts +18 -1
  136. package/src/core/modules/ai/services/core-ai-conversation.service.ts +110 -4
  137. package/src/core/modules/hub/INTEGRATION-CHECKLIST.md +64 -0
  138. package/src/core/modules/hub/README.md +159 -0
  139. package/src/core/modules/hub/core-hub-actions.controller.ts +137 -0
  140. package/src/core/modules/hub/core-hub-html.service.ts +135 -0
  141. package/src/core/modules/hub/core-hub.controller.ts +286 -0
  142. package/src/core/modules/hub/core-hub.module.spec.ts +108 -0
  143. package/src/core/modules/hub/core-hub.module.ts +159 -0
  144. package/src/core/modules/hub/core-hub.service.ts +169 -0
  145. package/src/core/modules/hub/helpers/hub-client-js.helper.ts +768 -0
  146. package/src/core/modules/hub/helpers/hub-command-shape.helper.spec.ts +48 -0
  147. package/src/core/modules/hub/helpers/hub-command-shape.helper.ts +47 -0
  148. package/src/core/modules/hub/helpers/hub-mask.helper.spec.ts +67 -0
  149. package/src/core/modules/hub/helpers/hub-mask.helper.ts +78 -0
  150. package/src/core/modules/hub/helpers/hub-mermaid.helper.spec.ts +54 -0
  151. package/src/core/modules/hub/helpers/hub-mermaid.helper.ts +62 -0
  152. package/src/core/modules/hub/helpers/hub-shell.helper.spec.ts +106 -0
  153. package/src/core/modules/hub/helpers/hub-shell.helper.ts +90 -0
  154. package/src/core/modules/hub/hub-action-messages.ts +47 -0
  155. package/src/core/modules/hub/hub-config.helper.spec.ts +108 -0
  156. package/src/core/modules/hub/hub-config.helper.ts +233 -0
  157. package/src/core/modules/hub/hub-nav.ts +66 -0
  158. package/src/core/modules/hub/hub-ring-buffer.spec.ts +95 -0
  159. package/src/core/modules/hub/hub-ring-buffer.ts +101 -0
  160. package/src/core/modules/hub/hub.constants.ts +84 -0
  161. package/src/core/modules/hub/index.ts +25 -0
  162. package/src/core/modules/hub/interfaces/hub-config.interface.ts +265 -0
  163. package/src/core/modules/hub/interfaces/hub-panels.interface.ts +186 -0
  164. package/src/core/modules/hub/middleware/hub-trace.middleware.ts +45 -0
  165. package/src/core/modules/hub/services/core-hub-actions.service.ts +133 -0
  166. package/src/core/modules/hub/services/core-hub-db.service.ts +185 -0
  167. package/src/core/modules/hub/services/core-hub-email.service.ts +158 -0
  168. package/src/core/modules/hub/services/core-hub-mailbox.service.spec.ts +116 -0
  169. package/src/core/modules/hub/services/core-hub-mailbox.service.ts +121 -0
  170. package/src/core/modules/hub/services/core-hub-migrations.service.ts +112 -0
  171. package/src/core/modules/hub/services/core-hub-sources.service.ts +194 -0
  172. package/src/core/modules/hub/services/hub-log-buffer.service.ts +252 -0
  173. package/src/core/modules/hub/services/hub-query-profiler.service.ts +274 -0
  174. package/src/core/modules/hub/services/hub-trace-buffer.service.spec.ts +112 -0
  175. package/src/core/modules/hub/services/hub-trace-buffer.service.ts +134 -0
  176. package/src/core.module.ts +61 -1
  177. package/src/index.ts +6 -0
@@ -1,12 +1,14 @@
1
1
  import { createHash } from 'crypto';
2
- import { Injectable, OnModuleDestroy } from '@nestjs/common';
2
+ import { Inject, Injectable, OnModuleDestroy, Optional } from '@nestjs/common';
3
3
  import nodemailer = require('nodemailer');
4
4
  import { Attachment } from 'nodemailer/lib/mailer';
5
5
 
6
6
  import { isNonEmptyString, isTrue, returnFalse } from '../helpers/input.helper';
7
7
  import { MailTransportOptions } from '../interfaces/server-options.interface';
8
+ import { HUB_EMAIL_CAPTURE } from '../../modules/hub/hub.constants';
8
9
  import { ConfigService } from './config.service';
9
10
  import { TemplateService } from './template.service';
11
+ import type { IHubEmailCapture } from '../../modules/hub/interfaces/hub-config.interface';
10
12
 
11
13
  /**
12
14
  * Email service
@@ -26,6 +28,9 @@ export class EmailService implements OnModuleDestroy {
26
28
  constructor(
27
29
  protected configService: ConfigService,
28
30
  protected templateService: TemplateService,
31
+ // Optional Hub mailbox hook. When the Hub mailbox is enabled it captures outgoing mail (and, in
32
+ // capture mode, suppresses the actual send). Undefined otherwise — zero cost.
33
+ @Optional() @Inject(HUB_EMAIL_CAPTURE) protected readonly emailCapture?: IHubEmailCapture,
29
34
  ) {}
30
35
 
31
36
  onModuleDestroy(): void {
@@ -104,6 +109,33 @@ export class EmailService implements OnModuleDestroy {
104
109
  }
105
110
  }
106
111
 
112
+ // Hub mailbox capture (Mailpit-style). Runs after templates are rendered, before the transport.
113
+ // In capture mode it records the mail and suppresses the send (returns a jsonTransport-like ack).
114
+ // Fully guarded: a broken mailbox hook must never break (or crash) the mail path.
115
+ if (this.emailCapture) {
116
+ let skipTransport = false;
117
+ try {
118
+ skipTransport = this.emailCapture.capture({
119
+ from: `"${senderName}" <${senderEmail}>`,
120
+ html,
121
+ subject,
122
+ templateName: htmlTemplate ?? textTemplate,
123
+ text,
124
+ to: Array.isArray(recipients) ? recipients.join(', ') : recipients,
125
+ });
126
+ } catch {
127
+ /* mailbox capture must never break mail sending */
128
+ }
129
+ if (skipTransport) {
130
+ return {
131
+ accepted: Array.isArray(recipients) ? recipients : [recipients],
132
+ captured: true,
133
+ messageId: 'hub-mailbox',
134
+ rejected: [],
135
+ };
136
+ }
137
+ }
138
+
107
139
  // Reuse transporter if SMTP config hasn't changed (avoids creating new connections per email)
108
140
  // Use hash instead of raw JSON to avoid keeping credentials as a string in memory
109
141
  const smtpKey = createHash('sha256').update(JSON.stringify(smtp)).digest('hex');
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
2
2
  import { TemplateFunction } from 'ejs';
3
3
  import ejs = require('ejs');
4
4
  import fs = require('fs');
5
- import { join } from 'path';
5
+ import { resolve, sep } from 'path';
6
6
 
7
7
  import { ConfigService } from './config.service';
8
8
 
@@ -36,27 +36,32 @@ export class TemplateService {
36
36
  * @param filePath Directory names (separated via '/' if template is in subdirectory) + name of the template file without extension
37
37
  */
38
38
  protected async getTemplate(filePath: string): Promise<TemplateFunction> {
39
- return new Promise<TemplateFunction>((resolve, reject) => {
39
+ // Resolve the template file to an absolute path and enforce that it stays within the configured
40
+ // templates directory. Legitimate template names never contain '..'; this guard is a defense in
41
+ // depth against path traversal for any caller that forwards user-influenced template names.
42
+ const baseDir = resolve(this.configService.getFastButReadOnly('templates.path'));
43
+ const fullPath = resolve(baseDir, `${filePath}.ejs`);
44
+ if (fullPath !== baseDir && !fullPath.startsWith(baseDir + sep)) {
45
+ throw new Error(`Invalid template path "${filePath}".`);
46
+ }
47
+
48
+ return new Promise<TemplateFunction>((res, reject) => {
40
49
  // Get template from cache
41
50
  if (this.templates[filePath]) {
42
- resolve(this.templates[filePath]);
51
+ res(this.templates[filePath]);
43
52
  return;
44
53
  }
45
54
 
46
55
  // Get template file
47
- fs.readFile(
48
- `${join(this.configService.getFastButReadOnly('templates.path'), filePath)}.ejs`,
49
- { encoding: 'utf8' },
50
- (err, data) => {
51
- if (err) {
52
- reject(err);
53
- } else {
54
- // Compile and return template
55
- this.templates[filePath] = ejs.compile(data);
56
- resolve(this.templates[filePath]);
57
- }
58
- },
59
- );
56
+ fs.readFile(fullPath, { encoding: 'utf8' }, (err, data) => {
57
+ if (err) {
58
+ reject(err);
59
+ } else {
60
+ // Compile and return template
61
+ this.templates[filePath] = ejs.compile(data);
62
+ res(this.templates[filePath]);
63
+ }
64
+ });
60
65
  });
61
66
  }
62
67
  }
@@ -1,4 +1,4 @@
1
- import { Body, Controller, Delete, Get, Param, Post, Put, Res } from '@nestjs/common';
1
+ import { Body, Controller, Delete, Get, Param, Post, Put, Query, Res } from '@nestjs/common';
2
2
  import { Response } from 'express';
3
3
 
4
4
  import { RESTServiceOptions } from '../../common/decorators/rest-service-options.decorator';
@@ -266,7 +266,9 @@ export class CoreAiController {
266
266
  }
267
267
 
268
268
  /**
269
- * Find the current user's AI conversations (admins see all).
269
+ * Find the current user's AI conversations (own only by default). An admin may
270
+ * pass `?all=true` to list every user's conversations; each result carries its
271
+ * `createdBy` owner id for attribution. The flag is ignored for non-admins.
270
272
  *
271
273
  * The `messages` subdocument array is excluded from the list result — clients
272
274
  * fetching the conversation detail via `getConversation` get the full message
@@ -274,13 +276,14 @@ export class CoreAiController {
274
276
  */
275
277
  @Get('conversations')
276
278
  @Roles(RoleEnum.S_USER)
277
- async findConversations(@RESTServiceOptions() serviceOptions: ServiceOptions): Promise<CoreAiConversation[]> {
278
- const currentUser = serviceOptions?.currentUser;
279
- const filterQuery = currentUser?.roles?.includes(RoleEnum.ADMIN) ? {} : { createdBy: currentUser?.id };
280
- return this.conversationService.find(
281
- { filterQuery },
282
- { ...serviceOptions, roles: [RoleEnum.ADMIN, RoleEnum.S_CREATOR, RoleEnum.S_SELF], select: '-messages' },
283
- );
279
+ async findConversations(
280
+ @RESTServiceOptions() serviceOptions: ServiceOptions,
281
+ @Query('all') all?: string,
282
+ ): Promise<CoreAiConversation[]> {
283
+ // Owner-scoped list shared with the GraphQL resolver — see
284
+ // CoreAiConversationService.findForCurrentUser for the role/ownership rationale.
285
+ // Admins default to their own conversations and opt in to the cross-user view via ?all=true.
286
+ return this.conversationService.findForCurrentUser(serviceOptions, { all: all === 'true' });
284
287
  }
285
288
 
286
289
  /**
@@ -270,21 +270,26 @@ export class CoreAiResolver {
270
270
  }
271
271
 
272
272
  /**
273
- * Find the current user's AI conversations (admins see all).
273
+ * Find the current user's AI conversations (own only by default). An admin may
274
+ * pass `all: true` to list every user's conversations; each result carries its
275
+ * `createdBy` owner id for attribution. The argument is ignored for non-admins.
274
276
  *
275
277
  * The `messages` subdocument array is excluded from the list result — clients
276
278
  * fetching the conversation detail via `getAiConversation` get the full message
277
279
  * history. List payloads stay small even for users with many long conversations.
278
280
  */
279
- @Query(() => [CoreAiConversation], { description: 'Find AI conversations of the current user' })
281
+ @Query(() => [CoreAiConversation], {
282
+ description: "Find AI conversations of the current user (admins may pass all: true to see every user's)",
283
+ })
280
284
  @Roles(RoleEnum.S_USER)
281
- async findAiConversations(@GraphQLServiceOptions() serviceOptions: ServiceOptions): Promise<CoreAiConversation[]> {
282
- const currentUser = serviceOptions?.currentUser;
283
- const filterQuery = currentUser?.roles?.includes(RoleEnum.ADMIN) ? {} : { createdBy: currentUser?.id };
284
- return this.conversationService.find(
285
- { filterQuery },
286
- { ...serviceOptions, roles: [RoleEnum.ADMIN, RoleEnum.S_CREATOR, RoleEnum.S_SELF], select: '-messages' },
287
- );
285
+ async findAiConversations(
286
+ @GraphQLServiceOptions() serviceOptions: ServiceOptions,
287
+ @Args('all', { nullable: true, type: () => Boolean }) all?: boolean,
288
+ ): Promise<CoreAiConversation[]> {
289
+ // Owner-scoped list shared with the REST controller — see
290
+ // CoreAiConversationService.findForCurrentUser for the role/ownership rationale.
291
+ // Admins default to their own conversations and opt in to the cross-user view via all: true.
292
+ return this.conversationService.findForCurrentUser(serviceOptions, { all: all === true });
288
293
  }
289
294
 
290
295
  /**
@@ -6,6 +6,7 @@ import { Restricted } from '../../../common/decorators/restricted.decorator';
6
6
  import { UnifiedField } from '../../../common/decorators/unified-field.decorator';
7
7
  import { RoleEnum } from '../../../common/enums/role.enum';
8
8
  import { CorePersistenceModel } from '../../../common/models/core-persistence.model';
9
+ import { JSON } from '../../../common/scalars/json.scalar';
9
10
  import { CoreAiMessage } from './core-ai-message.model';
10
11
 
11
12
  export type AiConversationDocument = CoreAiConversation & Document;
@@ -45,6 +46,22 @@ export class CoreAiConversation extends CorePersistenceModel {
45
46
  })
46
47
  createdBy?: string = undefined;
47
48
 
49
+ /**
50
+ * Resolved creator identity (`{ id, email, firstName, lastName, username }`),
51
+ * attached at read time ONLY for an admin's cross-user list view (`all: true`)
52
+ * so each conversation is attributable to a named user. Not persisted and left
53
+ * unset on a normal own-only fetch, where the owner is the caller.
54
+ */
55
+ @UnifiedField({
56
+ description:
57
+ 'Resolved creator identity (id, email, name) — set only in the admin cross-user list view, not persisted',
58
+ gqlType: JSON,
59
+ isOptional: true,
60
+ roles: RoleEnum.S_USER,
61
+ type: () => Object,
62
+ })
63
+ createdByUser?: Record<string, any> = undefined;
64
+
48
65
  /**
49
66
  * Conversation messages (appended via $push).
50
67
  */
@@ -76,7 +93,7 @@ export class CoreAiConversation extends CorePersistenceModel {
76
93
  if (force) {
77
94
  return this;
78
95
  }
79
- if (user && (user.hasRole?.(RoleEnum.ADMIN) || (this.createdBy && String(this.createdBy) === user.id))) {
96
+ if (user && (user.hasRole?.(RoleEnum.ADMIN) || (this.createdBy && String(this.createdBy) === String(user.id)))) {
80
97
  return this;
81
98
  }
82
99
  // Hide conversations owned by other users (filtered out of list responses).
@@ -1,7 +1,9 @@
1
1
  import { Inject, Injectable } from '@nestjs/common';
2
- import { InjectModel } from '@nestjs/mongoose';
3
- import { Model, Types } from 'mongoose';
2
+ import { InjectConnection, InjectModel } from '@nestjs/mongoose';
3
+ import { Connection, Model, Types } from 'mongoose';
4
4
 
5
+ import { RoleEnum } from '../../../common/enums/role.enum';
6
+ import { ServiceOptions } from '../../../common/interfaces/service-options.interface';
5
7
  import { CrudService } from '../../../common/services/crud.service';
6
8
  import { CoreModelConstructor } from '../../../common/types/core-model-constructor.type';
7
9
  import { CoreAiConversationCreateInput } from '../inputs/core-ai-conversation-create.input';
@@ -21,8 +23,15 @@ export { AI_CONVERSATION_CLASS, AI_CONVERSATION_MODEL } from '../core-ai.constan
21
23
  * CRUD service for multi-turn {@link CoreAiConversation}s.
22
24
  *
23
25
  * `appendMessage()` adds a turn via `$push` (never round-trips the subdocument
24
- * array through `update()`). Ownership is enforced by the model's `securityCheck`
25
- * and by the resolver/controller passing `S_CREATOR`/`S_SELF` roles.
26
+ * array through `update()`). `findForCurrentUser()` is the shared owner-scoped
27
+ * list used by both the REST controller and the GraphQL resolver.
28
+ *
29
+ * Ownership is enforced differently per operation shape:
30
+ * - single-object `get`/`delete`: the controller/resolver pass
31
+ * `S_CREATOR`/`S_SELF`, evaluated against the loaded `dbObject`.
32
+ * - list (`findForCurrentUser`): a list has no single `dbObject`, so the gate
33
+ * uses `S_USER` and ownership is scoped by the server-computed `createdBy`
34
+ * filterQuery plus the model's `securityCheck`.
26
35
  */
27
36
  @Injectable()
28
37
  export class CoreAiConversationService extends CrudService<
@@ -37,10 +46,107 @@ export class CoreAiConversationService extends CrudService<
37
46
  @InjectModel(AI_CONVERSATION_MODEL) protected override readonly mainDbModel: Model<AiConversationDocument>,
38
47
  @Inject(AI_CONVERSATION_CLASS)
39
48
  protected override readonly mainModelConstructor: CoreModelConstructor<CoreAiConversation>,
49
+ @InjectConnection() protected readonly connection: Connection,
40
50
  ) {
41
51
  super();
42
52
  }
43
53
 
54
+ /**
55
+ * List the AI conversations visible to the current user. By default — for
56
+ * every user, admins included — this returns only the caller's own
57
+ * conversations, newest first. An admin may opt in to the cross-user view
58
+ * (every user's conversations) by passing `{ all: true }`; the flag is
59
+ * ignored for non-admins, so it can never widen a regular user's scope.
60
+ *
61
+ * Each result carries its `createdBy` owner id. In the admin cross-user view
62
+ * (`all: true`) each result additionally carries a resolved `createdByUser`
63
+ * (`{ id, email, firstName, lastName, username }`) so the list is attributable
64
+ * to named users; on a normal own-only fetch that lookup is skipped, since the
65
+ * owner is the caller. The heavy `messages` subdocument array is excluded from
66
+ * the list payload — fetch a single conversation via `get()` for the full
67
+ * message history.
68
+ *
69
+ * Shared by the REST controller (`GET /ai/conversations`) and the GraphQL
70
+ * resolver (`findAiConversations`) so both API surfaces enforce ownership
71
+ * identically and cannot drift apart.
72
+ *
73
+ * A LIST operation has no single `dbObject`, so the per-document roles
74
+ * `S_CREATOR` / `S_SELF` can never be satisfied at the operation level —
75
+ * passing them rejected every non-admin with a 403 before ownership scoping
76
+ * ran. The operation gate therefore uses `[S_USER]`; the server-computed
77
+ * `createdBy` filterQuery and the model `securityCheck` scope the result to
78
+ * the owner.
79
+ */
80
+ async findForCurrentUser(
81
+ serviceOptions?: ServiceOptions,
82
+ options?: { all?: boolean },
83
+ ): Promise<CoreAiConversation[]> {
84
+ const currentUser = serviceOptions?.currentUser;
85
+ const isAdmin = !!currentUser?.roles?.includes(RoleEnum.ADMIN);
86
+ // Default is own-only for everyone; only an admin may opt in to all users' conversations.
87
+ const seeAll = isAdmin && options?.all === true;
88
+ const filterQuery = seeAll ? {} : { createdBy: currentUser?.id };
89
+ const conversations = await this.find(
90
+ { filterQuery, queryOptions: { sort: { createdAt: -1 } } },
91
+ { ...serviceOptions, roles: [RoleEnum.S_USER], select: '-messages' },
92
+ );
93
+ // Resolve creators to named users ONLY for the admin cross-user view — a normal
94
+ // fetch returns just the caller's own conversations, so the owner is already known.
95
+ if (seeAll && conversations.length) {
96
+ await this.attachCreators(conversations);
97
+ }
98
+ return conversations;
99
+ }
100
+
101
+ /**
102
+ * Attach a lightweight resolved creator (`{ id, email, firstName, lastName,
103
+ * username }`) as `createdByUser` to each conversation, so an admin's
104
+ * cross-user list is attributable to named users. Read-only lookup of
105
+ * non-sensitive identity fields via the shared `User` model on the same
106
+ * connection; fails soft (leaves `createdByUser` unset) when no `User` model is
107
+ * registered. Never called on a normal own-only fetch.
108
+ */
109
+ protected async attachCreators(conversations: CoreAiConversation[]): Promise<void> {
110
+ const ids = [
111
+ ...new Set(
112
+ conversations
113
+ .map((conversation) => conversation.createdBy)
114
+ .filter(Boolean)
115
+ .map(String),
116
+ ),
117
+ ];
118
+ if (!ids.length) {
119
+ return;
120
+ }
121
+ let userModel: Model<any>;
122
+ try {
123
+ userModel = this.connection.model('User');
124
+ } catch {
125
+ // No `User` model registered on this connection (exotic setup) — skip attribution.
126
+ return;
127
+ }
128
+ const users = await userModel
129
+ .find({ _id: { $in: ids } })
130
+ .select('email firstName lastName username')
131
+ .lean()
132
+ .exec();
133
+ const byId = new Map(
134
+ (users as any[]).map((user) => [
135
+ String(user._id),
136
+ {
137
+ email: user.email,
138
+ firstName: user.firstName,
139
+ id: String(user._id),
140
+ lastName: user.lastName,
141
+ username: user.username,
142
+ },
143
+ ]),
144
+ );
145
+ for (const conversation of conversations) {
146
+ conversation.createdByUser = byId.get(String(conversation.createdBy));
147
+ }
148
+ }
149
+
44
150
  /**
45
151
  * Append a message to a conversation via `$push` (system-internal; the user
46
152
  * already authorized the prompt that produced it). The array is capped at
@@ -0,0 +1,64 @@
1
+ # Hub Integration Checklist
2
+
3
+ The Hub is **config-only** — no files need to be created in the consuming project. It is auto-registered
4
+ by `CoreModule.forRoot()` when `hub` is present in the config.
5
+
6
+ ## Reference Implementation
7
+
8
+ - Local: `node_modules/@lenne.tech/nest-server/src/core/modules/hub/`
9
+ - The framework's own e2e config (`src/config.env.ts`) enables it in `local`/`development`/`e2e`/`ci`.
10
+
11
+ ## Quick Setup
12
+
13
+ ### 1. Enable per environment
14
+
15
+ **Edit:** `src/config.env.ts`
16
+
17
+ ```typescript
18
+ // development / local
19
+ hub: {
20
+ collectors: { queries: true }, // opt-in query profiler
21
+ mailbox: { mode: 'capture' }, // capture outgoing mail locally (Mailpit replacement)
22
+ },
23
+
24
+ // production — usually omit `hub` entirely, or (if you want it, still ADMIN-gated):
25
+ // hub: { collectors: { queries: false }, mailbox: false },
26
+ ```
27
+
28
+ That's it. Sign in as a user with `RoleEnum.ADMIN` and open `/hub`.
29
+
30
+ ## Optional Enhancements
31
+
32
+ | Want | Do |
33
+ | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
34
+ | **Cron panel populated** | Import `ScheduleModule.forRoot()` in your ServerModule (usually already present). |
35
+ | **Query profiler** | Set `hub.collectors.queries: true` (opts the driver into `monitorCommands`). |
36
+ | **Migrations run/rollback** | Set `hub.migrations.dir` to your migrations directory (default `./migrations`). In compiled deployments point it at the built JS. Use `lockCollectionName` for cluster safety. |
37
+ | **Custom look / behavior** | Pass `overrides.hub.{controller,actionsController,service,htmlService,actionsService}` to `CoreModule.forRoot()`. |
38
+
39
+ ## Verification Checklist
40
+
41
+ - [ ] `pnpm run build` succeeds
42
+ - [ ] Sign in as ADMIN → `GET /hub` returns the dashboard HTML
43
+ - [ ] A non-admin user gets `403`; an anonymous request gets `401`
44
+ - [ ] `GET /hub/dashboard.json` returns build/memory/features
45
+ - [ ] `GET /hub/config.json` shows `***` for secrets (never the real values)
46
+ - [ ] Mutating actions without `X-Hub-Request: 1` return `403`
47
+
48
+ ## Common Mistakes
49
+
50
+ | Mistake | Symptom | Fix |
51
+ | ------------------------------------------------ | -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
52
+ | `hub` not set in the target environment | `/hub` → 404 | Add `hub: true` (or an options object) to that env's config block |
53
+ | `mailbox.mode: 'capture'` in production | Startup error | Use `mode: 'copy'` or disable the mailbox in production — capture suppresses all mail |
54
+ | Query panel empty | queries collector off (default) | `hub.collectors.queries: true` |
55
+ | Cron panel empty | `ScheduleModule.forRoot()` not imported | Import it in your ServerModule |
56
+ | Hub path collides with a project route | 404 / wrong page | Set a distinct `hub.path` (e.g. `admin/hub`) |
57
+ | No roles guard registered (auth system disabled) | Every `/hub` sidecar/action returns 200 for anonymous requests | The Hub relies on the framework's `RolesGuard`/`BetterAuthRolesGuard` (registered by the auth/BetterAuth module) to enforce `@Roles(RoleEnum.ADMIN)`. If you disable BetterAuth AND legacy auth, no guard runs and the ADMIN gate is inert. Keep an auth module enabled, or do not expose the Hub. |
58
+
59
+ > **Auth dependency (why):** the Hub does not register its own guard — it assigns `@Roles(hub.roles)`
60
+ > (default `ADMIN`) as metadata and depends on the app-wide `RolesGuard` / `BetterAuthRolesGuard` to
61
+ > read it. That guard ships with the auth/BetterAuth modules. In a normal project one of them is
62
+ > active, so the gate works out of the box; but an app that runs with _no_ auth system has no guard
63
+ > to enforce the roles, and the sidecars/actions would be reachable unauthenticated. Either keep an
64
+ > auth module enabled or leave the Hub disabled in such a setup.
@@ -0,0 +1,159 @@
1
+ # Hub — Admin Area (Operator Cockpit)
2
+
3
+ A build-free, ADMIN-gated dashboard of runtime information and admin tools, served directly by the
4
+ framework. Inspired by the sister project [nest-base](https://github.com/lenneTech/nest-base)'s Hub,
5
+ but adapted to this stack (NestJS + GraphQL + Mongoose) and shipped as **dependency-free server-side
6
+ HTML + a vanilla-JS SPA** (no React, no build step) so it works identically in npm- and vendor-mode.
7
+
8
+ ## Enable it
9
+
10
+ The Hub is **never enabled implicitly** — switch it on per environment:
11
+
12
+ ```typescript
13
+ // config.env.ts
14
+ {
15
+ hub: true, // enabled at /hub, admin-only, default collectors
16
+ // or, with options:
17
+ hub: {
18
+ path: 'hub', // default 'hub'
19
+ collectors: { queries: true }, // query profiler is opt-in (enables driver command monitoring)
20
+ mailbox: { mode: 'capture' }, // built-in Mailpit-style mail capture (dev/test)
21
+ },
22
+ }
23
+ ```
24
+
25
+ Do NOT set `hub` in the `production` block unless you intend the cockpit to be reachable there
26
+ (it stays ADMIN-gated). See `IHubConfig` in `interfaces/hub-config.interface.ts` for every option.
27
+
28
+ ## Panels
29
+
30
+ | Panel | Route | Source |
31
+ | -------------------- | --------------------- | --------------------------------------------------------------- |
32
+ | Dashboard | `/hub` | health, build info, memory, feature matrix, links |
33
+ | Diagnostics | `/hub/diagnostics` | heap/rss, node/platform, collector buffer levels |
34
+ | Logs | `/hub/logs` | in-memory log ring buffer (redacted) |
35
+ | Request Traces | `/hub/traces` | HTTP timing middleware |
36
+ | Query Performance | `/hub/queries` | MongoDB driver command monitoring (opt-in) |
37
+ | Cron Jobs | `/hub/cron` | `@nestjs/schedule` `SchedulerRegistry` (optional) |
38
+ | Database | `/hub/db` | dbStats / per-collection collStats |
39
+ | Models / ERD | `/hub/models` | Mongoose schemas → Mermaid ER diagram |
40
+ | Migrations | `/hub/migrations` | `MigrationRunner` status + run/rollback |
41
+ | Files | `/hub/files` | GridFS listing + delete |
42
+ | Config | `/hub/config` | full config, secrets masked |
43
+ | Auth Migration | `/hub/auth-migration` | Legacy → IAM progress (BetterAuth, optional) |
44
+ | Routes / Permissions | `/hub/routes` | route + role + `@Restricted` map (Permissions module, optional) |
45
+ | Error Codes | `/hub/error-codes` | de/en catalog (ErrorCode module, optional) |
46
+ | Email Preview | `/hub/emails` | EJS templates rendered with sample data |
47
+ | Mailbox | `/hub/mailbox` | captured outgoing mail (Mailpit replacement) |
48
+ | AI | `/hub/ai` | AI usage summary (AI module, optional) |
49
+
50
+ Each panel has a `*.json` sidecar (the stable data contract) that the client polls. Optional sources
51
+ degrade to an "unavailable" state instead of erroring.
52
+
53
+ ## Actions (mutating)
54
+
55
+ Enabled by default (`actions: true`). Every mutating request requires the `X-Hub-Request: 1` header
56
+ (CSRF defense) and destructive ones a server-validated `confirm` keyword:
57
+
58
+ | Action | Endpoint | Confirm |
59
+ | ----------------------- | ------------------------------------------ | ------------ |
60
+ | Run pending migrations | `POST /hub/actions/migrations/run` | `RUN` |
61
+ | Rollback last migration | `POST /hub/actions/migrations/down` | `DOWN` |
62
+ | Delete GridFS file | `DELETE /hub/actions/files/:id` | the filename |
63
+ | Cron start/stop/trigger | `POST /hub/actions/cron/:name/:action` | the job name |
64
+ | Clear collector buffer | `POST /hub/actions/collectors/:name/clear` | `CLEAR` |
65
+ | Send test mail | `POST /hub/actions/email/test` | — |
66
+
67
+ Every action writes an audit line: `[HUB-ACTION] <action> by user <id>`.
68
+
69
+ ## Security
70
+
71
+ - **Auth**: the DATA sidecars (`*.json`) and all actions are `@Roles(RoleEnum.ADMIN)` (configurable
72
+ via `hub.roles`; `false` = public — dangerous). The **shell** (page routes) is public chrome only —
73
+ it shows a **login form** when the data is 401, so the Hub is self-sufficient: an admin can sign in
74
+ directly at the API (email/password → `loginEndpoint`, default `/iam/sign-in/email` → session cookie)
75
+ without the frontend, with a token-paste fallback for cookie-less setups. In fullstack, a
76
+ cross-subdomain session cookie from the app login already authenticates `/hub` — no separate login.
77
+ A **"Sign out"** button in the topbar POSTs to `hub.logoutEndpoint` (default `/iam/sign-out`),
78
+ clears the session cookie + any pasted token, and returns to the login gate. The logout endpoint is
79
+ delivered only in the ADMIN-gated `session.json` payload, never in the public shell.
80
+ - **Public-shell trade-off (by design):** because the shell must render before authentication, its
81
+ HTML source reveals the panel structure, the environment name (shown on the login card) and any
82
+ configured external links (`hub.links.*`) to an unauthenticated request. This is intentional — the
83
+ panel structure is already discoverable via the public `hub.js` — and carries no data: every
84
+ `*.json` sidecar and every action stays ADMIN-gated. Do not place secrets in `hub.links.*` (e.g.
85
+ an internal Mailpit URL) if the shell's origin is reachable by untrusted clients.
86
+ - **Action errors**: mutating actions return plain admin-facing messages (not `ErrorCode` catalog
87
+ entries) by design — the `ErrorCode` i18n module is optional and the Hub cannot hard-depend on it.
88
+ The messages are centralized in `hub-action-messages.ts` (`HubActionMessage`) so the wording stays
89
+ consistent and reviewable in one place.
90
+ - **CSP**: strict per-request nonce; no `unsafe-inline`; `X-Frame-Options: DENY`; `no-store`.
91
+ - **Secrets**: the config viewer deep-clones and masks by key pattern + `security.secretFields`.
92
+ - **Interceptor safety**: sidecars return pre-serialized JSON strings, so the global response
93
+ interceptors never walk/mutate live config.
94
+ - **Mailbox guard**: `mode: 'capture'` throws at startup in any **reachable** environment (anything
95
+ whose `env` is not `local`/`development`/`test`/`ci`/`e2e`) — it suppresses outgoing mail.
96
+ - **Public-access guard**: `roles: false` (no auth check — public config viewer, logs and destructive
97
+ actions) throws at startup in any **reachable** environment unless `hub.allowPublicAccessInProduction:
98
+ true` is set explicitly. This closes the single most dangerous Hub misconfiguration (a `roles: false`
99
+ copied from a local config into a reachable environment). The reachable check is fail-safe: it treats
100
+ every env name except the known local/test set as reachable, so a custom name (`prod`, `preprod`,
101
+ `staging-2`, …) cannot bypass it. Only acknowledge public access behind a fully-controlled network
102
+ boundary (VPN / IP allow-list / authenticating reverse proxy).
103
+ - **No-guard warning**: the Hub registers no guard of its own — its ADMIN gate is enforced by the
104
+ app-wide roles guard from BetterAuth (IAM) or the legacy Auth module. If the Hub is enabled and
105
+ gated but neither is active (and you have not registered your own `APP_GUARD`), `CoreModule` logs a
106
+ loud startup warning, because nothing would then enforce the gate.
107
+ - **CSRF**: mutating actions require the `X-Hub-Request` custom header, which forces a CORS preflight
108
+ that a restrictive allowlist rejects for foreign origins — **that header** (not the type-to-confirm
109
+ keyword, which is public UX safety, not a token) is the CSRF barrier. It holds with the default
110
+ `SameSite=Lax` session cookie or Bearer-token auth. Do NOT combine `cors.allowAll: true` with a
111
+ `SameSite=None` cookie (cross-subdomain fullstack): that lets any origin pass the preflight — pin
112
+ `cors` to your `appUrl` instead.
113
+ - **Redaction is best-effort (ADMIN-only surfaces)**: the config viewer masks by key pattern +
114
+ `security.secretFields` (a secret under an unusual key can slip); the logs/queries collectors and
115
+ the copy-mode mailbox redact patterned secrets (JWT / Bearer / `key=value` / cookie / reset-link
116
+ path tokens) but cannot catch an arbitrary value logged without a recognizable shape. All are
117
+ ADMIN-gated — treat Hub access as equivalent to config/log read access.
118
+
119
+ ### Routes / Permissions panel vs. the standalone `/permissions` endpoint
120
+
121
+ The **Routes / Permissions** panel renders the same data as the standalone permissions module — the
122
+ full security map (every route + its required roles + `@Restricted` field rules) — but _inside_ the
123
+ Hub, so it inherits the Hub's ADMIN gate, per-environment opt-in and strict CSP. It reuses the
124
+ permissions **scanner** (`CorePermissionsService`), so it needs the `permissions` module enabled
125
+ (`config.permissions`); otherwise the panel degrades to an "unavailable" state.
126
+
127
+ The two surfaces are **independent** and answer the common "how do I reach it?" questions:
128
+
129
+ | Situation | How to reach the report |
130
+ | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
131
+ | Hub **on**, `permissions` **on** | Hub → **Routes / Permissions** panel (ADMIN, login-gated by the Hub). The standalone `/permissions` also works per its own `role` (default ADMIN). |
132
+ | Hub **off**, `permissions` **on** (ADMIN) | The standalone `/permissions` is ADMIN-gated but has **no login page** — reach it with an ADMIN **session cookie** (be logged in via the app) or a **bearer token** (`Authorization: Bearer <jwt>` from `POST /iam/sign-in/email`), exactly like any other ADMIN endpoint. |
133
+ | Hub **off**, want frictionless **local** access | Set `permissions: { role: false }` — public, no auth. Legitimate **only** on a local, non-network-reachable machine, as a conscious opt-in. Never ship it to a reachable environment: the report is a reconnaissance goldmine. |
134
+ | `permissions` **off** | No report anywhere: `/permissions` is not registered (404) and the Hub panel shows "unavailable". |
135
+
136
+ **Security note:** the permissions report exposes your entire authorization model. Keep it ADMIN
137
+ (the default) everywhere it could be reachable; use `role: false` only behind a network boundary you
138
+ fully control. In production the framework's own config does not register the permissions module at all.
139
+
140
+ ## Collectors
141
+
142
+ Three in-memory ring buffers (fixed capacity, no timers, per-app-instance — parallel-test-safe):
143
+
144
+ - **Logs** — installs a chaining `Logger.overrideLogger()` delegate (no main.ts change), restored on shutdown.
145
+ - **Traces** — an Express middleware registered only when enabled (zero cost otherwise).
146
+ - **Queries** — MongoDB driver command monitoring; records value-free query SHAPES (N+1 templates), never values.
147
+ Enabling it opts the driver into `monitorCommands` from `core.module.ts`.
148
+
149
+ ## Overrides
150
+
151
+ ```typescript
152
+ CoreModule.forRoot(envConfig, {
153
+ hub: { service: MyHubService, htmlService: MyHubHtmlService },
154
+ });
155
+ ```
156
+
157
+ Fields: `controller`, `actionsController`, `service`, `htmlService`, `actionsService`.
158
+
159
+ See [INTEGRATION-CHECKLIST.md](./INTEGRATION-CHECKLIST.md) for setup in a consumer project.