@lenne.tech/nest-server 11.31.2 → 11.32.0

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 (181) hide show
  1. package/.claude/rules/architecture.md +2 -0
  2. package/.claude/rules/configurable-features.md +2 -0
  3. package/.claude/rules/versioning.md +5 -8
  4. package/CLAUDE.md +31 -4
  5. package/FRAMEWORK-API.md +7 -2
  6. package/README.md +1 -0
  7. package/dist/config.env.d.ts +4 -0
  8. package/dist/config.env.js +32 -2
  9. package/dist/config.env.js.map +1 -1
  10. package/dist/core/common/helpers/logging.helper.d.ts +1 -0
  11. package/dist/core/common/helpers/logging.helper.js +12 -0
  12. package/dist/core/common/helpers/logging.helper.js.map +1 -1
  13. package/dist/core/common/helpers/meta.helper.d.ts +1 -0
  14. package/dist/core/common/helpers/meta.helper.js +32 -1
  15. package/dist/core/common/helpers/meta.helper.js.map +1 -1
  16. package/dist/core/common/helpers/process-diagnostics.helper.d.ts +18 -0
  17. package/dist/core/common/helpers/process-diagnostics.helper.js +88 -0
  18. package/dist/core/common/helpers/process-diagnostics.helper.js.map +1 -0
  19. package/dist/core/common/interfaces/server-options.interface.d.ts +12 -0
  20. package/dist/core/common/services/brevo.service.d.ts +7 -1
  21. package/dist/core/common/services/brevo.service.js +37 -16
  22. package/dist/core/common/services/brevo.service.js.map +1 -1
  23. package/dist/core/common/services/email.service.d.ts +3 -1
  24. package/dist/core/common/services/email.service.js +33 -2
  25. package/dist/core/common/services/email.service.js.map +1 -1
  26. package/dist/core/common/services/template.service.js +9 -4
  27. package/dist/core/common/services/template.service.js.map +1 -1
  28. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +9 -4
  29. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
  30. package/dist/core/modules/hub/core-hub-actions.controller.d.ts +22 -0
  31. package/dist/core/modules/hub/core-hub-actions.controller.js +141 -0
  32. package/dist/core/modules/hub/core-hub-actions.controller.js.map +1 -0
  33. package/dist/core/modules/hub/core-hub-html.service.d.ts +8 -0
  34. package/dist/core/modules/hub/core-hub-html.service.js +123 -0
  35. package/dist/core/modules/hub/core-hub-html.service.js.map +1 -0
  36. package/dist/core/modules/hub/core-hub.controller.d.ts +56 -0
  37. package/dist/core/modules/hub/core-hub.controller.js +398 -0
  38. package/dist/core/modules/hub/core-hub.controller.js.map +1 -0
  39. package/dist/core/modules/hub/core-hub.module.d.ts +17 -0
  40. package/dist/core/modules/hub/core-hub.module.js +109 -0
  41. package/dist/core/modules/hub/core-hub.module.js.map +1 -0
  42. package/dist/core/modules/hub/core-hub.service.d.ts +18 -0
  43. package/dist/core/modules/hub/core-hub.service.js +153 -0
  44. package/dist/core/modules/hub/core-hub.service.js.map +1 -0
  45. package/dist/core/modules/hub/helpers/hub-client-js.helper.d.ts +1 -0
  46. package/dist/core/modules/hub/helpers/hub-client-js.helper.js +755 -0
  47. package/dist/core/modules/hub/helpers/hub-client-js.helper.js.map +1 -0
  48. package/dist/core/modules/hub/helpers/hub-command-shape.helper.d.ts +1 -0
  49. package/dist/core/modules/hub/helpers/hub-command-shape.helper.js +33 -0
  50. package/dist/core/modules/hub/helpers/hub-command-shape.helper.js.map +1 -0
  51. package/dist/core/modules/hub/helpers/hub-mask.helper.d.ts +1 -0
  52. package/dist/core/modules/hub/helpers/hub-mask.helper.js +46 -0
  53. package/dist/core/modules/hub/helpers/hub-mask.helper.js.map +1 -0
  54. package/dist/core/modules/hub/helpers/hub-mermaid.helper.d.ts +10 -0
  55. package/dist/core/modules/hub/helpers/hub-mermaid.helper.js +35 -0
  56. package/dist/core/modules/hub/helpers/hub-mermaid.helper.js.map +1 -0
  57. package/dist/core/modules/hub/helpers/hub-shell.helper.d.ts +9 -0
  58. package/dist/core/modules/hub/helpers/hub-shell.helper.js +62 -0
  59. package/dist/core/modules/hub/helpers/hub-shell.helper.js.map +1 -0
  60. package/dist/core/modules/hub/hub-action-messages.d.ts +17 -0
  61. package/dist/core/modules/hub/hub-action-messages.js +21 -0
  62. package/dist/core/modules/hub/hub-action-messages.js.map +1 -0
  63. package/dist/core/modules/hub/hub-config.helper.d.ts +11 -0
  64. package/dist/core/modules/hub/hub-config.helper.js +164 -0
  65. package/dist/core/modules/hub/hub-config.helper.js.map +1 -0
  66. package/dist/core/modules/hub/hub-nav.d.ts +12 -0
  67. package/dist/core/modules/hub/hub-nav.js +43 -0
  68. package/dist/core/modules/hub/hub-nav.js.map +1 -0
  69. package/dist/core/modules/hub/hub-ring-buffer.d.ts +22 -0
  70. package/dist/core/modules/hub/hub-ring-buffer.js +59 -0
  71. package/dist/core/modules/hub/hub-ring-buffer.js.map +1 -0
  72. package/dist/core/modules/hub/hub.constants.d.ts +18 -0
  73. package/dist/core/modules/hub/hub.constants.js +22 -0
  74. package/dist/core/modules/hub/hub.constants.js.map +1 -0
  75. package/dist/core/modules/hub/index.d.ts +25 -0
  76. package/dist/core/modules/hub/index.js +42 -0
  77. package/dist/core/modules/hub/index.js.map +1 -0
  78. package/dist/core/modules/hub/interfaces/hub-config.interface.d.ts +115 -0
  79. package/dist/core/modules/hub/interfaces/hub-config.interface.js +3 -0
  80. package/dist/core/modules/hub/interfaces/hub-config.interface.js.map +1 -0
  81. package/dist/core/modules/hub/interfaces/hub-panels.interface.d.ts +231 -0
  82. package/dist/core/modules/hub/interfaces/hub-panels.interface.js +3 -0
  83. package/dist/core/modules/hub/interfaces/hub-panels.interface.js.map +1 -0
  84. package/dist/core/modules/hub/middleware/hub-trace.middleware.d.ts +8 -0
  85. package/dist/core/modules/hub/middleware/hub-trace.middleware.js +50 -0
  86. package/dist/core/modules/hub/middleware/hub-trace.middleware.js.map +1 -0
  87. package/dist/core/modules/hub/services/core-hub-actions.service.d.ts +48 -0
  88. package/dist/core/modules/hub/services/core-hub-actions.service.js +136 -0
  89. package/dist/core/modules/hub/services/core-hub-actions.service.js.map +1 -0
  90. package/dist/core/modules/hub/services/core-hub-db.service.d.ts +19 -0
  91. package/dist/core/modules/hub/services/core-hub-db.service.js +180 -0
  92. package/dist/core/modules/hub/services/core-hub-db.service.js.map +1 -0
  93. package/dist/core/modules/hub/services/core-hub-email.service.d.ts +24 -0
  94. package/dist/core/modules/hub/services/core-hub-email.service.js +148 -0
  95. package/dist/core/modules/hub/services/core-hub-email.service.js.map +1 -0
  96. package/dist/core/modules/hub/services/core-hub-mailbox.service.d.ts +20 -0
  97. package/dist/core/modules/hub/services/core-hub-mailbox.service.js +103 -0
  98. package/dist/core/modules/hub/services/core-hub-mailbox.service.js.map +1 -0
  99. package/dist/core/modules/hub/services/core-hub-migrations.service.d.ts +25 -0
  100. package/dist/core/modules/hub/services/core-hub-migrations.service.js +115 -0
  101. package/dist/core/modules/hub/services/core-hub-migrations.service.js.map +1 -0
  102. package/dist/core/modules/hub/services/core-hub-sources.service.d.ts +28 -0
  103. package/dist/core/modules/hub/services/core-hub-sources.service.js +187 -0
  104. package/dist/core/modules/hub/services/core-hub-sources.service.js.map +1 -0
  105. package/dist/core/modules/hub/services/hub-log-buffer.service.d.ts +24 -0
  106. package/dist/core/modules/hub/services/hub-log-buffer.service.js +210 -0
  107. package/dist/core/modules/hub/services/hub-log-buffer.service.js.map +1 -0
  108. package/dist/core/modules/hub/services/hub-query-profiler.service.d.ts +38 -0
  109. package/dist/core/modules/hub/services/hub-query-profiler.service.js +235 -0
  110. package/dist/core/modules/hub/services/hub-query-profiler.service.js.map +1 -0
  111. package/dist/core/modules/hub/services/hub-trace-buffer.service.d.ts +18 -0
  112. package/dist/core/modules/hub/services/hub-trace-buffer.service.js +123 -0
  113. package/dist/core/modules/hub/services/hub-trace-buffer.service.js.map +1 -0
  114. package/dist/core.module.js +42 -1
  115. package/dist/core.module.js.map +1 -1
  116. package/dist/index.d.ts +2 -0
  117. package/dist/index.js +2 -0
  118. package/dist/index.js.map +1 -1
  119. package/dist/main.js +5 -2
  120. package/dist/main.js.map +1 -1
  121. package/dist/tsconfig.build.tsbuildinfo +1 -1
  122. package/docs/REQUEST-LIFECYCLE.md +2 -0
  123. package/docs/brevo-manual-test.md +166 -0
  124. package/docs/security-overrides.md +90 -0
  125. package/migration-guides/11.31.2-to-11.31.3.md +135 -0
  126. package/migration-guides/11.31.3-to-11.32.0.md +254 -0
  127. package/package.json +16 -14
  128. package/src/config.env.ts +75 -2
  129. package/src/core/common/helpers/logging.helper.spec.ts +61 -0
  130. package/src/core/common/helpers/logging.helper.ts +48 -0
  131. package/src/core/common/helpers/meta.helper.ts +46 -1
  132. package/src/core/common/helpers/process-diagnostics.helper.spec.ts +310 -0
  133. package/src/core/common/helpers/process-diagnostics.helper.ts +321 -0
  134. package/src/core/common/interfaces/server-options.interface.ts +78 -0
  135. package/src/core/common/services/brevo.service.spec.ts +266 -0
  136. package/src/core/common/services/brevo.service.ts +100 -17
  137. package/src/core/common/services/email.service.ts +33 -1
  138. package/src/core/common/services/template.service.ts +21 -16
  139. package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +14 -4
  140. package/src/core/modules/hub/INTEGRATION-CHECKLIST.md +64 -0
  141. package/src/core/modules/hub/README.md +159 -0
  142. package/src/core/modules/hub/core-hub-actions.controller.ts +137 -0
  143. package/src/core/modules/hub/core-hub-html.service.ts +135 -0
  144. package/src/core/modules/hub/core-hub.controller.ts +286 -0
  145. package/src/core/modules/hub/core-hub.module.spec.ts +108 -0
  146. package/src/core/modules/hub/core-hub.module.ts +159 -0
  147. package/src/core/modules/hub/core-hub.service.ts +169 -0
  148. package/src/core/modules/hub/helpers/hub-client-js.helper.ts +768 -0
  149. package/src/core/modules/hub/helpers/hub-command-shape.helper.spec.ts +48 -0
  150. package/src/core/modules/hub/helpers/hub-command-shape.helper.ts +47 -0
  151. package/src/core/modules/hub/helpers/hub-mask.helper.spec.ts +67 -0
  152. package/src/core/modules/hub/helpers/hub-mask.helper.ts +78 -0
  153. package/src/core/modules/hub/helpers/hub-mermaid.helper.spec.ts +54 -0
  154. package/src/core/modules/hub/helpers/hub-mermaid.helper.ts +62 -0
  155. package/src/core/modules/hub/helpers/hub-shell.helper.spec.ts +106 -0
  156. package/src/core/modules/hub/helpers/hub-shell.helper.ts +90 -0
  157. package/src/core/modules/hub/hub-action-messages.ts +47 -0
  158. package/src/core/modules/hub/hub-config.helper.spec.ts +108 -0
  159. package/src/core/modules/hub/hub-config.helper.ts +233 -0
  160. package/src/core/modules/hub/hub-nav.ts +66 -0
  161. package/src/core/modules/hub/hub-ring-buffer.spec.ts +95 -0
  162. package/src/core/modules/hub/hub-ring-buffer.ts +101 -0
  163. package/src/core/modules/hub/hub.constants.ts +84 -0
  164. package/src/core/modules/hub/index.ts +25 -0
  165. package/src/core/modules/hub/interfaces/hub-config.interface.ts +265 -0
  166. package/src/core/modules/hub/interfaces/hub-panels.interface.ts +186 -0
  167. package/src/core/modules/hub/middleware/hub-trace.middleware.ts +45 -0
  168. package/src/core/modules/hub/services/core-hub-actions.service.ts +133 -0
  169. package/src/core/modules/hub/services/core-hub-db.service.ts +185 -0
  170. package/src/core/modules/hub/services/core-hub-email.service.ts +158 -0
  171. package/src/core/modules/hub/services/core-hub-mailbox.service.spec.ts +116 -0
  172. package/src/core/modules/hub/services/core-hub-mailbox.service.ts +121 -0
  173. package/src/core/modules/hub/services/core-hub-migrations.service.ts +112 -0
  174. package/src/core/modules/hub/services/core-hub-sources.service.ts +194 -0
  175. package/src/core/modules/hub/services/hub-log-buffer.service.ts +252 -0
  176. package/src/core/modules/hub/services/hub-query-profiler.service.ts +274 -0
  177. package/src/core/modules/hub/services/hub-trace-buffer.service.spec.ts +112 -0
  178. package/src/core/modules/hub/services/hub-trace-buffer.service.ts +134 -0
  179. package/src/core.module.ts +61 -1
  180. package/src/index.ts +7 -0
  181. package/src/main.ts +22 -3
@@ -0,0 +1,274 @@
1
+ import { Inject, Injectable, Logger, OnModuleDestroy, OnModuleInit, Optional } from '@nestjs/common';
2
+ import { InjectConnection } from '@nestjs/mongoose';
3
+ import { Connection } from 'mongoose';
4
+
5
+ import { redactSensitiveText } from '../../../common/helpers/logging.helper';
6
+ import { HUB_CONFIG, HUB_QUERY_PENDING_LIMIT } from '../hub.constants';
7
+ import { HubRingBuffer } from '../hub-ring-buffer';
8
+ import { normalizeCommandShape } from '../helpers/hub-command-shape.helper';
9
+ import { HubQueriesData, HubQueryRecord, HubQueryTemplate } from '../interfaces/hub-panels.interface';
10
+ import { ResolvedHubConfig } from '../interfaces/hub-config.interface';
11
+
12
+ interface ProfilerRecord extends HubQueryRecord {
13
+ timestamp: number;
14
+ }
15
+
16
+ interface PendingCommand {
17
+ collection: string;
18
+ operation: string;
19
+ shape: string;
20
+ }
21
+
22
+ /**
23
+ * Admin/heartbeat/metadata commands that are pure noise for a query profiler.
24
+ *
25
+ * `dbstats` / `listcollections` / `listindexes` are metadata reads (issued, among others, by the
26
+ * Hub's own DB panel) and are never app-level queries worth profiling. `aggregate` and `find` are
27
+ * deliberately NOT ignored — they carry real query shapes — so the Hub's own `$collStats`/`find`
28
+ * self-noise on the DB/Files panels is inherent and cannot be filtered by command name alone.
29
+ */
30
+ const DEFAULT_IGNORE = new Set([
31
+ 'authenticate',
32
+ 'buildinfo',
33
+ 'connectionstatus',
34
+ 'dbstats',
35
+ 'getnonce',
36
+ 'getparameter',
37
+ 'hello',
38
+ 'ismaster',
39
+ 'listcollections',
40
+ 'listindexes',
41
+ 'logout',
42
+ 'ping',
43
+ 'saslcontinue',
44
+ 'saslstart',
45
+ ]);
46
+
47
+ /** Only these command names carry a query shape worth normalizing. */
48
+ const SHAPED_FIELDS = ['filter', 'query', 'pipeline', 'sort', 'projection', 'q', 'u', 'updates', 'deletes'];
49
+
50
+ /**
51
+ * MongoDB query profiler backed by driver command monitoring.
52
+ *
53
+ * Requires the connection to be created with `monitorCommands: true` (opted in from core.module.ts
54
+ * only when this collector is enabled). Records command SHAPES (values → '?') — never values — which
55
+ * doubles as the N+1 template key. Zero cost when disabled: no `getClient`, no listeners.
56
+ */
57
+ @Injectable()
58
+ export class HubQueryProfilerService implements OnModuleDestroy, OnModuleInit {
59
+ protected readonly logger = new Logger(HubQueryProfilerService.name);
60
+
61
+ private buffer?: HubRingBuffer<ProfilerRecord>;
62
+ private client?: {
63
+ off?: (e: string, l: (...a: any[]) => void) => void;
64
+ on: (e: string, l: (...a: any[]) => void) => void;
65
+ options?: { monitorCommands?: boolean };
66
+ removeListener?: (e: string, l: (...a: any[]) => void) => void;
67
+ };
68
+ private cfg?: Exclude<ResolvedHubConfig['collectors']['queries'], false>;
69
+ private ignore = DEFAULT_IGNORE;
70
+ // Hot-path driver-event handlers. Each is fully guarded: an exception here runs inside the MongoDB
71
+ // driver's synchronous `emit()`, so it would surface as an UNCAUGHT exception and take the whole
72
+ // process down. Observability must never crash the app — swallow everything.
73
+ private readonly onFailed = (e: any): void => this.safe(() => this.handleFinished(e, e?.failure?.message));
74
+ private readonly onStarted = (e: any): void => this.safe(() => this.handleStarted(e));
75
+ private readonly onSucceeded = (e: any): void => this.safe(() => this.handleFinished(e, undefined));
76
+ private readonly pending = new Map<number, PendingCommand>();
77
+
78
+ constructor(
79
+ @Inject(HUB_CONFIG) protected readonly config: ResolvedHubConfig,
80
+ @Optional() @InjectConnection() protected readonly connection?: Connection,
81
+ ) {}
82
+
83
+ /** Clear the buffer (Hub action). */
84
+ clear(): void {
85
+ this.buffer?.clear();
86
+ this.pending.clear();
87
+ }
88
+
89
+ get enabled(): boolean {
90
+ return this.config.collectors.queries !== false;
91
+ }
92
+
93
+ getData(): HubQueriesData {
94
+ const records = this.buffer?.recent() ?? [];
95
+ return {
96
+ cursor: this.buffer?.lastSeq ?? -1,
97
+ recent: records.slice(-100),
98
+ slowest: [...records].sort((a, b) => b.durationMs - a.durationMs).slice(0, 10),
99
+ summary: this.summary(records),
100
+ topTemplates: this.topTemplates(records),
101
+ };
102
+ }
103
+
104
+ onModuleDestroy(): void {
105
+ if (!this.client) {
106
+ return;
107
+ }
108
+ const off = this.client.removeListener ?? this.client.off;
109
+ off?.call(this.client, 'commandStarted', this.onStarted);
110
+ off?.call(this.client, 'commandSucceeded', this.onSucceeded);
111
+ off?.call(this.client, 'commandFailed', this.onFailed);
112
+ this.pending.clear();
113
+ this.client = undefined;
114
+ }
115
+
116
+ onModuleInit(): void {
117
+ if (this.config.collectors.queries === false) {
118
+ return; // zero cost when disabled
119
+ }
120
+ this.cfg = this.config.collectors.queries;
121
+ this.buffer = new HubRingBuffer<ProfilerRecord>(this.cfg.capacity);
122
+ if (this.cfg.ignoreCommands?.length) {
123
+ this.ignore = new Set(this.cfg.ignoreCommands.map((c) => c.toLowerCase()));
124
+ }
125
+ const client = this.connection?.getClient?.() as unknown as HubQueryProfilerService['client'];
126
+ if (!client) {
127
+ return;
128
+ }
129
+ if (client.options?.monitorCommands !== true) {
130
+ this.logger.warn(
131
+ 'hub.collectors.queries is enabled but the MongoDB driver was not started with monitorCommands — query profiling is inactive.',
132
+ );
133
+ return;
134
+ }
135
+ this.client = client;
136
+ client.on('commandStarted', this.onStarted);
137
+ client.on('commandSucceeded', this.onSucceeded);
138
+ client.on('commandFailed', this.onFailed);
139
+ }
140
+
141
+ /** Run a hot-path handler, swallowing any error so it can never escape into the driver's emit(). */
142
+ protected safe(fn: () => void): void {
143
+ try {
144
+ fn();
145
+ } catch {
146
+ /* observability must never crash the app */
147
+ }
148
+ }
149
+
150
+ protected classify(durationMs: number): HubQueryRecord['classification'] {
151
+ if (!this.cfg) {
152
+ return 'ok';
153
+ }
154
+ if (durationMs > this.cfg.criticalMs) {
155
+ return 'critical';
156
+ }
157
+ if (durationMs > this.cfg.warnMs) {
158
+ return 'warn';
159
+ }
160
+ return 'ok';
161
+ }
162
+
163
+ protected handleFinished(event: { duration?: number; requestId?: number }, errorMessage?: string): void {
164
+ const requestId = event?.requestId;
165
+ if (requestId === undefined) {
166
+ return;
167
+ }
168
+ const pending = this.pending.get(requestId);
169
+ this.pending.delete(requestId);
170
+ if (!pending || !this.buffer) {
171
+ return;
172
+ }
173
+ const durationMs = Number(event.duration ?? 0);
174
+ this.buffer.add({
175
+ classification: this.classify(durationMs),
176
+ collection: pending.collection,
177
+ commandSummary: pending.shape,
178
+ durationMs,
179
+ errorMessage: errorMessage ? redactSensitiveText(String(errorMessage)).slice(0, 300) : undefined,
180
+ failed: !!errorMessage,
181
+ operation: pending.operation,
182
+ requestId,
183
+ } as Omit<ProfilerRecord, 'seq' | 'timestamp'>);
184
+ }
185
+
186
+ protected handleStarted(event: {
187
+ command?: Record<string, unknown>;
188
+ commandName?: string;
189
+ requestId?: number;
190
+ }): void {
191
+ const operation = String(event?.commandName ?? '').toLowerCase();
192
+ if (!operation || this.ignore.has(operation) || event.requestId === undefined) {
193
+ return;
194
+ }
195
+ const command = event.command ?? {};
196
+ const collection = this.extractCollection(command, operation);
197
+ const shape = this.buildShape(operation, collection, command);
198
+
199
+ // Bound the pending map even if succeeded/failed events are lost (connection drop).
200
+ if (this.pending.size >= HUB_QUERY_PENDING_LIMIT) {
201
+ const oldest = this.pending.keys().next().value;
202
+ if (oldest !== undefined) {
203
+ this.pending.delete(oldest);
204
+ }
205
+ }
206
+ this.pending.set(event.requestId, { collection, operation, shape });
207
+ }
208
+
209
+ private buildShape(operation: string, collection: string, command: Record<string, unknown>): string {
210
+ const sections: Record<string, unknown> = {};
211
+ for (const field of SHAPED_FIELDS) {
212
+ if (command[field] !== undefined) {
213
+ sections[field] = normalizeCommandShape(command[field]);
214
+ }
215
+ }
216
+ if (Array.isArray(command.documents)) {
217
+ sections.documents = `documents(${command.documents.length})`;
218
+ }
219
+ const maxLen = this.cfg?.maxShapeLength ?? 512;
220
+ return `${operation} ${collection} ${JSON.stringify(sections)}`.slice(0, maxLen);
221
+ }
222
+
223
+ private extractCollection(command: Record<string, unknown>, operation: string): string {
224
+ const byName = command[operation] ?? command[Object.keys(command)[0]];
225
+ if (typeof byName === 'string') {
226
+ return byName;
227
+ }
228
+ if (typeof command.collection === 'string') {
229
+ return command.collection;
230
+ }
231
+ return '(unknown)';
232
+ }
233
+
234
+ private summary(records: ProfilerRecord[]): HubQueriesData['summary'] {
235
+ let total = 0;
236
+ let warnCount = 0;
237
+ let criticalCount = 0;
238
+ let failedCount = 0;
239
+ for (const r of records) {
240
+ total += r.durationMs;
241
+ if (r.classification === 'warn') {
242
+ warnCount++;
243
+ }
244
+ if (r.classification === 'critical') {
245
+ criticalCount++;
246
+ }
247
+ if (r.failed) {
248
+ failedCount++;
249
+ }
250
+ }
251
+ return {
252
+ avgMs: records.length ? total / records.length : 0,
253
+ criticalCount,
254
+ failedCount,
255
+ total: records.length,
256
+ warnCount,
257
+ };
258
+ }
259
+
260
+ private topTemplates(records: ProfilerRecord[]): HubQueryTemplate[] {
261
+ const map = new Map<string, { count: number; max: number; total: number }>();
262
+ for (const r of records) {
263
+ const entry = map.get(r.commandSummary) ?? { count: 0, max: 0, total: 0 };
264
+ entry.count++;
265
+ entry.total += r.durationMs;
266
+ entry.max = Math.max(entry.max, r.durationMs);
267
+ map.set(r.commandSummary, entry);
268
+ }
269
+ return [...map.entries()]
270
+ .map(([template, e]) => ({ avgMs: e.total / e.count, count: e.count, maxMs: e.max, template }))
271
+ .sort((a, b) => b.count - a.count)
272
+ .slice(0, 10);
273
+ }
274
+ }
@@ -0,0 +1,112 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { HubTraceBufferService } from './hub-trace-buffer.service';
4
+ import { ResolvedHubConfig } from '../interfaces/hub-config.interface';
5
+
6
+ function makeConfig(overrides: Partial<ResolvedHubConfig> = {}): ResolvedHubConfig {
7
+ return {
8
+ actions: true,
9
+ allowPublicAccessInProduction: false,
10
+ collectors: {
11
+ logs: false,
12
+ queries: false,
13
+ traces: { capacity: 50, captureGraphQlOperation: true, excludePaths: [], slowMs: 1000 },
14
+ },
15
+ db: false,
16
+ emailPreview: true,
17
+ env: 'local',
18
+ links: {},
19
+ loginEndpoint: '/iam/sign-in/email',
20
+ logoutEndpoint: '/iam/sign-out',
21
+ mailbox: false,
22
+ migrations: false,
23
+ path: 'hub',
24
+ pollIntervalMs: 5000,
25
+ roles: ['admin'],
26
+ version: '1.0.0',
27
+ ...overrides,
28
+ };
29
+ }
30
+
31
+ /** Minimal Express req/res doubles for record(). */
32
+ function reqRes(path: string, method = 'GET', statusCode = 200) {
33
+ return {
34
+ req: { baseUrl: '', method, path, route: undefined } as never,
35
+ res: { getHeader: () => undefined, statusCode, writableFinished: true } as never,
36
+ };
37
+ }
38
+
39
+ describe('HubTraceBufferService', () => {
40
+ describe('isExcluded (boundary-aware)', () => {
41
+ it('excludes the Hub base path and its sub-paths', () => {
42
+ const service = new HubTraceBufferService(makeConfig());
43
+ expect(service.isExcluded('/hub')).toBe(true);
44
+ expect(service.isExcluded('/hub/logs.json')).toBe(true);
45
+ expect(service.isExcluded('/hub/actions/collectors/traces/clear')).toBe(true);
46
+ });
47
+
48
+ it('does NOT exclude a sibling route that merely shares the prefix string', () => {
49
+ const service = new HubTraceBufferService(makeConfig());
50
+ // Regression guard for the over-broad `startsWith(prefix)` bug: `/hubble` is not a Hub route.
51
+ expect(service.isExcluded('/hubble')).toBe(false);
52
+ expect(service.isExcluded('/hub-admin')).toBe(false);
53
+ expect(service.isExcluded('/hubbard/x')).toBe(false);
54
+ });
55
+
56
+ it('honours additional configured exclude prefixes with the same boundary rule', () => {
57
+ const service = new HubTraceBufferService(
58
+ makeConfig({
59
+ collectors: {
60
+ logs: false,
61
+ queries: false,
62
+ traces: { capacity: 50, captureGraphQlOperation: true, excludePaths: ['/health'], slowMs: 1000 },
63
+ },
64
+ }),
65
+ );
66
+ expect(service.isExcluded('/health')).toBe(true);
67
+ expect(service.isExcluded('/health/live')).toBe(true);
68
+ expect(service.isExcluded('/healthcheck')).toBe(false);
69
+ });
70
+ });
71
+
72
+ describe('record + getData', () => {
73
+ it('records a completed request with method, path and duration, and summarises', () => {
74
+ const service = new HubTraceBufferService(makeConfig());
75
+ const { req, res } = reqRes('/permissions/json');
76
+ service.record(req, res, 12.5, false, '/permissions/json');
77
+
78
+ const data = service.getData();
79
+ expect(data.traces).toHaveLength(1);
80
+ expect(data.traces[0].method).toBe('GET');
81
+ expect(data.traces[0].path).toBe('/permissions/json');
82
+ expect(data.traces[0].durationMs).toBe(12.5);
83
+ expect(data.summary.total).toBe(1);
84
+ expect(data.summary.avgMs).toBe(12.5);
85
+ });
86
+
87
+ it('flags slow and error traces in the summary', () => {
88
+ const service = new HubTraceBufferService(makeConfig());
89
+ const slow = reqRes('/slow');
90
+ service.record(slow.req, slow.res, 5000, false, '/slow');
91
+ const err = reqRes('/boom', 'GET', 500);
92
+ service.record(err.req, err.res, 3, false, '/boom');
93
+
94
+ const data = service.getData();
95
+ expect(data.summary.slowCount).toBe(1);
96
+ expect(data.summary.errorCount).toBe(1);
97
+ });
98
+
99
+ it('supports cursor-based polling via since', () => {
100
+ const service = new HubTraceBufferService(makeConfig());
101
+ const a = reqRes('/a');
102
+ service.record(a.req, a.res, 1, false, '/a');
103
+ const cursor = service.getData().cursor;
104
+ const b = reqRes('/b');
105
+ service.record(b.req, b.res, 2, false, '/b');
106
+
107
+ const next = service.getData(cursor);
108
+ expect(next.traces).toHaveLength(1);
109
+ expect(next.traces[0].path).toBe('/b');
110
+ });
111
+ });
112
+ });
@@ -0,0 +1,134 @@
1
+ import { Inject, Injectable } from '@nestjs/common';
2
+ import { Request, Response } from 'express';
3
+
4
+ import { HUB_CONFIG } from '../hub.constants';
5
+ import { HubRingBuffer } from '../hub-ring-buffer';
6
+ import { HubTraceRecord, HubTracesData } from '../interfaces/hub-panels.interface';
7
+ import { ResolvedHubConfig } from '../interfaces/hub-config.interface';
8
+
9
+ interface TraceRecord extends HubTraceRecord {
10
+ timestamp: number;
11
+ }
12
+
13
+ /**
14
+ * Ring buffer for HTTP request traces, fed by {@link HubTraceMiddleware}.
15
+ *
16
+ * Structurally incapable of leaking secrets: it stores only method, route pattern (never the query
17
+ * string), status, duration, an optional GraphQL operation name and the user id — no headers,
18
+ * cookies, bodies or GraphQL variables.
19
+ */
20
+ @Injectable()
21
+ export class HubTraceBufferService {
22
+ private readonly buffer?: HubRingBuffer<TraceRecord>;
23
+ private readonly cfg?: Exclude<ResolvedHubConfig['collectors']['traces'], false>;
24
+ private readonly excludePrefixes: string[];
25
+
26
+ constructor(@Inject(HUB_CONFIG) protected readonly config: ResolvedHubConfig) {
27
+ if (config.collectors.traces !== false) {
28
+ this.cfg = config.collectors.traces;
29
+ this.buffer = new HubRingBuffer<TraceRecord>(this.cfg.capacity);
30
+ }
31
+ // Always exclude the Hub's own routes so polling does not flood the trace list with self-noise.
32
+ this.excludePrefixes = [...(this.cfg?.excludePaths ?? []), '/' + config.path];
33
+ }
34
+
35
+ clear(): void {
36
+ this.buffer?.clear();
37
+ }
38
+
39
+ get enabled(): boolean {
40
+ return this.config.collectors.traces !== false;
41
+ }
42
+
43
+ getData(since?: number): HubTracesData {
44
+ const records = !this.buffer ? [] : since === undefined ? this.buffer.recent() : this.buffer.since(since);
45
+ let totalMs = 0;
46
+ let slowCount = 0;
47
+ let errorCount = 0;
48
+ for (const r of records) {
49
+ totalMs += r.durationMs;
50
+ if (r.slow) {
51
+ slowCount++;
52
+ }
53
+ if (r.error) {
54
+ errorCount++;
55
+ }
56
+ }
57
+ return {
58
+ cursor: this.buffer?.lastSeq ?? -1,
59
+ dropped: this.buffer?.firstRetainedSeq ?? -1,
60
+ summary: { avgMs: records.length ? totalMs / records.length : 0, errorCount, slowCount, total: records.length },
61
+ traces: records,
62
+ };
63
+ }
64
+
65
+ /** True when the path is excluded (Hub routes, configured prefixes). */
66
+ isExcluded(path: string): boolean {
67
+ // Boundary-aware: a prefix of `/hub` excludes `/hub` and `/hub/...` but NOT `/hubble` — a bare
68
+ // `startsWith(prefix)` would wrongly drop sibling routes that merely share the prefix string.
69
+ return this.excludePrefixes.some((prefix) => path === prefix || path.startsWith(prefix + '/'));
70
+ }
71
+
72
+ /** Record a completed request. Called from the middleware at `finish`/`close`. */
73
+ record(req: Request, res: Response, durationMs: number, aborted: boolean, fullPath?: string): void {
74
+ if (!this.buffer || !this.cfg) {
75
+ return;
76
+ }
77
+ const statusCode = res.statusCode;
78
+ const contentLength = Number(res.getHeader('content-length')) || undefined;
79
+ this.buffer.add({
80
+ aborted: aborted || undefined,
81
+ contentLength,
82
+ durationMs,
83
+ error: statusCode >= 500 || undefined,
84
+ graphqlOperation: this.cfg.captureGraphQlOperation ? this.graphqlOperation(req) : undefined,
85
+ method: req.method,
86
+ path: this.routePattern(req, fullPath),
87
+ slow: durationMs > this.cfg.slowMs || undefined,
88
+ statusCode,
89
+ userId: (req as unknown as { user?: { id?: string } }).user?.id,
90
+ } as Omit<TraceRecord, 'seq' | 'timestamp'>);
91
+ }
92
+
93
+ /** Collapse high-cardinality ids so the buffer does not explode on 404s / unmatched routes. */
94
+ private collapseParams(path: string): string {
95
+ return path
96
+ .replace(/\/[a-f0-9]{24}(?=\/|$)/gi, '/:id')
97
+ .replace(/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?=\/|$)/gi, '/:id')
98
+ .replace(/\/\d+(?=\/|$)/g, '/:n');
99
+ }
100
+
101
+ private graphqlOperation(req: Request): string | undefined {
102
+ if (req.method !== 'POST' || !req.path.endsWith('/graphql')) {
103
+ return undefined;
104
+ }
105
+ const body = (req as unknown as { body?: unknown }).body;
106
+ if (Array.isArray(body)) {
107
+ return `batch(${body.length})`;
108
+ }
109
+ const op = (body as { operationName?: string; query?: string })?.operationName;
110
+ if (op) {
111
+ return op;
112
+ }
113
+ const query = (body as { query?: string })?.query;
114
+ if (typeof query === 'string') {
115
+ const match = /\b(query|mutation|subscription)\s+([A-Za-z_]\w*)/.exec(query.slice(0, 500));
116
+ if (match) {
117
+ return match[2];
118
+ }
119
+ }
120
+ return 'anonymous';
121
+ }
122
+
123
+ /** Route pattern when the router resolved one, else a param-collapsed raw path (never the query string). */
124
+ private routePattern(req: Request, fullPath?: string): string {
125
+ const route = (req as unknown as { route?: { path?: string } }).route?.path;
126
+ const base = req.baseUrl || '';
127
+ if (route && base) {
128
+ return base + (route === '/' ? '' : route) || '/';
129
+ }
130
+ // Fallback: the mount-stripped `req.path` is unreliable under `forRoutes('*')`; prefer the full
131
+ // path captured by the middleware, with high-cardinality ids collapsed.
132
+ return this.collapseParams(fullPath || req.baseUrl || req.path);
133
+ }
134
+ }
@@ -43,6 +43,8 @@ import { CoreBetterAuthModule } from './core/modules/better-auth/core-better-aut
43
43
  import { CoreBetterAuthService } from './core/modules/better-auth/core-better-auth.service';
44
44
  import { ErrorCodeModule } from './core/modules/error-code/error-code.module';
45
45
  import { CoreHealthCheckModule } from './core/modules/health-check/core-health-check.module';
46
+ import { CoreHubModule } from './core/modules/hub/core-hub.module';
47
+ import { isHubEnabled, isHubQueriesEnabled } from './core/modules/hub/hub-config.helper';
46
48
  import { CorePermissionsModule } from './core/modules/permissions/core-permissions.module';
47
49
  import { CoreSystemSetupModule } from './core/modules/system-setup/core-system-setup.module';
48
50
  import { CoreTenantModule } from './core/modules/tenant/core-tenant.module';
@@ -335,6 +337,16 @@ export class CoreModule implements NestModule {
335
337
  // and: https://mongoosejs.com/docs/guide.html#strictQuery
336
338
  mongoose.set('strictQuery', config.mongoose.strictQuery || false);
337
339
 
340
+ // Opt the MongoDB driver into command monitoring only when the Hub query profiler is enabled
341
+ // (zero cost otherwise). An explicit `monitorCommands: false` by the consumer wins — the profiler
342
+ // then warns once and stays inert.
343
+ if (isHubQueriesEnabled(config.hub)) {
344
+ config.mongoose.options = config.mongoose.options || {};
345
+ if ((config.mongoose.options as { monitorCommands?: boolean }).monitorCommands === undefined) {
346
+ (config.mongoose.options as { monitorCommands?: boolean }).monitorCommands = true;
347
+ }
348
+ }
349
+
338
350
  const imports: any[] = [MongooseModule.forRoot(config.mongoose.uri, config.mongoose.options)];
339
351
 
340
352
  if (isGraphQlEnabled && config.graphQl) {
@@ -375,10 +387,39 @@ export class CoreModule implements NestModule {
375
387
 
376
388
  // Permissions report (development tool)
377
389
  const permissionsConfig = config.permissions;
378
- if (permissionsConfig === true || (typeof permissionsConfig === 'object' && permissionsConfig.enabled !== false)) {
390
+ const permissionsEnabled =
391
+ permissionsConfig === true || (typeof permissionsConfig === 'object' && permissionsConfig.enabled !== false);
392
+ if (permissionsEnabled) {
379
393
  imports.push(CorePermissionsModule.forRoot(permissionsConfig));
380
394
  }
381
395
 
396
+ // Hub admin area (operator cockpit). Presence implies enabled — but NEVER implicitly:
397
+ // it must be switched on per environment. Overrides pass project subclasses through.
398
+ if (isHubEnabled(config.hub)) {
399
+ const permissionsPath =
400
+ permissionsEnabled && typeof permissionsConfig === 'object' && permissionsConfig.path
401
+ ? permissionsConfig.path
402
+ : permissionsEnabled
403
+ ? 'permissions'
404
+ : undefined;
405
+ imports.push(
406
+ CoreHubModule.forRoot({
407
+ actionsController: overrides?.hub?.actionsController,
408
+ actionsService: overrides?.hub?.actionsService,
409
+ config: config.hub,
410
+ controller: overrides?.hub?.controller,
411
+ ctx: {
412
+ env: config.env,
413
+ graphQlEnabled: isGraphQlEnabled,
414
+ permissionsPath,
415
+ version: config.version ?? 'unknown',
416
+ },
417
+ htmlService: overrides?.hub?.htmlService,
418
+ service: overrides?.hub?.service,
419
+ }),
420
+ );
421
+ }
422
+
382
423
  // Add CoreBetterAuthModule based on mode
383
424
  // IAM-only mode: BetterAuth is enabled by default (it's the only auth option)
384
425
  // Legacy mode: Only register if autoRegister is explicitly true
@@ -438,6 +479,25 @@ export class CoreModule implements NestModule {
438
479
  }
439
480
  }
440
481
 
482
+ // SECURITY: the Hub's ADMIN gate is enforced by the app-wide roles guard, which is registered by
483
+ // BetterAuth (IAM mode) or the legacy Auth module. When the Hub is enabled and gated but NEITHER is
484
+ // active, nothing enforces its @Roles(ADMIN) metadata — the whole cockpit, INCLUDING destructive
485
+ // migration/file-delete/cron actions, would be reachable unauthenticated, silently. A project may
486
+ // still register its own APP_GUARD, so this is a loud warning rather than a hard error.
487
+ if (isHubEnabled(config.hub)) {
488
+ const hubIsGated = !(typeof config.hub === 'object' && config.hub.roles === false);
489
+ const noBuiltInRolesGuard = isIamOnlyMode && !isBetterAuthEnabled;
490
+ if (hubIsGated && noBuiltInRolesGuard) {
491
+ console.warn(
492
+ 'CoreModule: the Hub is enabled and ADMIN-gated, but neither BetterAuth (IAM) nor the legacy ' +
493
+ 'Auth module is active — so no built-in guard enforces its @Roles(ADMIN) metadata. Unless you ' +
494
+ 'have registered your own APP_GUARD that reads @Roles, the Hub (including its destructive ' +
495
+ 'migration/file-delete/cron actions) is reachable UNAUTHENTICATED. Enable an auth module or ' +
496
+ 'register a roles guard.',
497
+ );
498
+ }
499
+ }
500
+
441
501
  // Add CoreSystemSetupModule when BetterAuth is active
442
502
  // Enabled by default - disable explicitly via systemSetup: { enabled: false }
443
503
  if (isBetterAuthEnabled && config.systemSetup?.enabled !== false) {
package/src/index.ts CHANGED
@@ -43,6 +43,7 @@ export * from './core/common/helpers/input.helper';
43
43
  export * from './core/common/helpers/logging.helper';
44
44
  export * from './core/common/helpers/meta.helper';
45
45
  export * from './core/common/helpers/model.helper';
46
+ export * from './core/common/helpers/process-diagnostics.helper';
46
47
  export * from './core/common/helpers/register-enum.helper';
47
48
  export * from './core/common/helpers/scim.helper';
48
49
  export * from './core/common/helpers/service.helper';
@@ -181,6 +182,12 @@ export * from './core/modules/health-check/core-health-check.module';
181
182
  export * from './core/modules/health-check/core-health-check.resolver';
182
183
  export * from './core/modules/health-check/core-health-check.service';
183
184
 
185
+ // =====================================================================================================================
186
+ // Core - Modules - Hub
187
+ // =====================================================================================================================
188
+
189
+ export * from './core/modules/hub';
190
+
184
191
  // =====================================================================================================================
185
192
  // Core - Modules - Migrate
186
193
  // =====================================================================================================================
package/src/main.ts CHANGED
@@ -9,6 +9,7 @@ import envConfig from './config.env';
9
9
  import { FilterArgs } from './core/common/args/filter.args';
10
10
  import { buildCorsConfig, isCookiesEnabled, isCorsDisabled } from './core/common/helpers/cookies.helper';
11
11
  import { HttpExceptionLogFilter } from './core/common/filters/http-exception-log.filter';
12
+ import { handleFatalBootstrapError, installProcessDiagnostics } from './core/common/helpers/process-diagnostics.helper';
12
13
  import { CorePersistenceModel } from './core/common/models/core-persistence.model';
13
14
  import { CoreAuthModel } from './core/modules/auth/core-auth.model';
14
15
  import { CoreUserModel } from './core/modules/user/core-user.model';
@@ -21,6 +22,11 @@ import { ServerModule } from './server/server.module';
21
22
  * Preparations for server start
22
23
  */
23
24
  async function bootstrap() {
25
+ // Make the exit reason diagnosable: log unhandled rejections without crashing, log uncaught
26
+ // exceptions before the restart, and label external termination signals so a silent
27
+ // "app crashed" always has a reason. See process-diagnostics.helper.ts for the rationale.
28
+ installProcessDiagnostics();
29
+
24
30
  // Create a new server based on express
25
31
  const server = await NestFactory.create<NestExpressApplication>(
26
32
  // Include server module, with all necessary modules for the project
@@ -106,9 +112,20 @@ async function bootstrap() {
106
112
  jsonDocumentUrl: '/api-docs-json',
107
113
  });
108
114
 
115
+ // Drain the event loop on SIGTERM/SIGINT so the process actually exits.
116
+ //
117
+ // This is load-bearing in a container, where `docker-entrypoint.sh` runs node under `exec` and it
118
+ // therefore becomes PID 1. A PID-namespace init is SIGNAL_UNKILLABLE: a userspace signal whose
119
+ // disposition is the default is silently discarded by the kernel, so re-raising is a no-op there.
120
+ // Meanwhile the listening HTTP server keeps the event loop non-empty, so nothing exits on its own
121
+ // and `docker stop` waits out its full grace period before SIGKILL — dropping in-flight requests
122
+ // and skipping every onModuleDestroy(). enableShutdownHooks() is what closes the app and drains
123
+ // the loop; installProcessDiagnostics() then correctly defers to it instead of re-raising.
124
+ server.enableShutdownHooks();
125
+
109
126
  // Start server on configured port
110
127
  await server.listen(envConfig.port, envConfig.hostname);
111
- console.debug(`Server startet at ${await server.getUrl()}`);
128
+ console.debug(`Server started at ${await server.getUrl()}`);
112
129
 
113
130
  // Run command after server init
114
131
  if (envConfig.execAfterInit) {
@@ -126,5 +143,7 @@ async function bootstrap() {
126
143
  }
127
144
  }
128
145
 
129
- // Start server
130
- bootstrap();
146
+ // Start server. A rejection here is a fatal startup failure (e.g. port already in use, DB
147
+ // unreachable) — surface it and exit rather than let it become a silent unhandledRejection
148
+ // that leaves a zombie process "alive" but listening on nothing.
149
+ bootstrap().catch(handleFatalBootstrapError);