@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
@@ -0,0 +1,137 @@
1
+ import {
2
+ BadRequestException,
3
+ Body,
4
+ Controller,
5
+ Delete,
6
+ ForbiddenException,
7
+ Headers,
8
+ Param,
9
+ Post,
10
+ } from '@nestjs/common';
11
+
12
+ import { HubActionMessage } from './hub-action-messages';
13
+ import { CoreHubActionsService } from './services/core-hub-actions.service';
14
+
15
+ /** Body shape shared by the confirm-guarded actions. */
16
+ interface ConfirmBody {
17
+ confirm?: string;
18
+ }
19
+
20
+ /**
21
+ * The Hub's mutating actions (migrations, file delete, cron control, buffer clears, test mail).
22
+ *
23
+ * Registered as a SEPARATE controller only when `hub.actions !== false`, so disabling actions simply
24
+ * removes the routes (404). Path + roles metadata are assigned at runtime in `CoreHubModule.forRoot()`
25
+ * exactly like the read controller.
26
+ *
27
+ * Every mutating request must carry the `X-Hub-Request: 1` header (CSRF defense in depth: a custom
28
+ * header makes the request non-simple, so a cross-origin attempt triggers a CORS preflight the
29
+ * framework will not approve). Destructive actions additionally require a server-validated `confirm`
30
+ * keyword that the UI makes the operator type.
31
+ *
32
+ * Overridable via `overrides.hub.actionsController`.
33
+ */
34
+ @Controller()
35
+ export class CoreHubActionsController {
36
+ constructor(protected readonly actions: CoreHubActionsService) {}
37
+
38
+ @Post('actions/collectors/:name/clear')
39
+ async clearCollector(
40
+ @Param('name') name: string,
41
+ @Body() body: ConfirmBody,
42
+ @Headers('x-hub-request') hubHeader: string | undefined,
43
+ ): Promise<unknown> {
44
+ this.requireHubRequest(hubHeader);
45
+ this.requireConfirm(body, 'CLEAR');
46
+ if (!['logs', 'mailbox', 'queries', 'traces'].includes(name)) {
47
+ throw new BadRequestException(HubActionMessage.unknownCollector);
48
+ }
49
+ return this.wrap(() =>
50
+ Promise.resolve(this.actions.clearBuffer(name as 'logs' | 'mailbox' | 'queries' | 'traces')),
51
+ );
52
+ }
53
+
54
+ @Post('actions/cron/:name/:action')
55
+ async cron(
56
+ @Param('name') name: string,
57
+ @Param('action') action: string,
58
+ @Body() body: ConfirmBody,
59
+ @Headers('x-hub-request') hubHeader: string | undefined,
60
+ ): Promise<unknown> {
61
+ this.requireHubRequest(hubHeader);
62
+ if (!['start', 'stop', 'trigger'].includes(action)) {
63
+ throw new BadRequestException(HubActionMessage.unknownCronAction);
64
+ }
65
+ this.requireConfirm(body, name);
66
+ return this.wrap(() => Promise.resolve(this.actions.controlCron(name, action as 'start' | 'stop' | 'trigger')));
67
+ }
68
+
69
+ @Delete('actions/files/:id')
70
+ async deleteFile(
71
+ @Param('id') id: string,
72
+ @Body() body: ConfirmBody,
73
+ @Headers('x-hub-request') hubHeader: string | undefined,
74
+ ): Promise<unknown> {
75
+ this.requireHubRequest(hubHeader);
76
+ if (!body?.confirm) {
77
+ throw new BadRequestException(HubActionMessage.confirmationFilenameRequired);
78
+ }
79
+ return this.wrap(() => this.actions.deleteFile(id, body.confirm as string));
80
+ }
81
+
82
+ @Post('actions/email/test')
83
+ async emailTest(
84
+ @Body() body: { locale?: string; template?: string; to?: string },
85
+ @Headers('x-hub-request') hubHeader: string | undefined,
86
+ ): Promise<unknown> {
87
+ this.requireHubRequest(hubHeader);
88
+ if (!body?.to) {
89
+ throw new BadRequestException(HubActionMessage.recipientRequired);
90
+ }
91
+ return this.wrap(() => this.actions.sendTestEmail(body.to as string, body.template, body.locale));
92
+ }
93
+
94
+ @Post('actions/migrations/down')
95
+ async migrationsDown(
96
+ @Body() body: ConfirmBody,
97
+ @Headers('x-hub-request') hubHeader: string | undefined,
98
+ ): Promise<unknown> {
99
+ this.requireHubRequest(hubHeader);
100
+ this.requireConfirm(body, 'DOWN');
101
+ return this.wrap(() => this.actions.rollbackMigration());
102
+ }
103
+
104
+ @Post('actions/migrations/run')
105
+ async migrationsRun(
106
+ @Body() body: ConfirmBody,
107
+ @Headers('x-hub-request') hubHeader: string | undefined,
108
+ ): Promise<unknown> {
109
+ this.requireHubRequest(hubHeader);
110
+ this.requireConfirm(body, 'RUN');
111
+ return this.wrap(() => this.actions.runMigrations());
112
+ }
113
+
114
+ /** Validate the server-side confirmation keyword. */
115
+ protected requireConfirm(body: ConfirmBody, expected: string): void {
116
+ if (body?.confirm !== expected) {
117
+ throw new BadRequestException(HubActionMessage.confirmationKeywordMismatch(expected));
118
+ }
119
+ }
120
+
121
+ /** CSRF defense: mutating requests must carry the X-Hub-Request header. */
122
+ protected requireHubRequest(header: string | undefined): void {
123
+ if (!header) {
124
+ throw new ForbiddenException(HubActionMessage.missingHubRequestHeader);
125
+ }
126
+ }
127
+
128
+ /** Run an action, mapping domain errors to 400 (admin-facing plain message). */
129
+ protected async wrap(fn: () => Promise<unknown>): Promise<unknown> {
130
+ try {
131
+ const result = await fn();
132
+ return { ok: true, timestamp: new Date().toISOString(), ...(result as object) };
133
+ } catch (error) {
134
+ throw new BadRequestException(error instanceof Error ? error.message : HubActionMessage.actionFailed);
135
+ }
136
+ }
137
+ }
@@ -0,0 +1,135 @@
1
+ import { Inject, Injectable } from '@nestjs/common';
2
+
3
+ import { HUB_CONFIG } from './hub.constants';
4
+
5
+ /**
6
+ * Per-process cache-bust token for the client bundle (`hub.js?v=<token>`). It changes on every server
7
+ * start, so the browser refetches the script after a restart/redeploy (a dev code change is never
8
+ * masked by the immutable cache), while a single boot keeps the long-lived cache. The app version is
9
+ * deliberately NOT part of this public URL — it would leak the version to unauthenticated requests
10
+ * (the version is shown only in the ADMIN-gated, client-built topbar from session.json).
11
+ */
12
+ const HUB_ASSET_TOKEN = Date.now().toString(36);
13
+ import { getHubClientJs } from './helpers/hub-client-js.helper';
14
+ import { escapeHtml, escapeJsString, HUB_NONCE_PLACEHOLDER } from './helpers/hub-shell.helper';
15
+ import { ResolvedHubConfig } from './interfaces/hub-config.interface';
16
+
17
+ /**
18
+ * Builds the Hub's single, self-contained HTML shell. Every page route serves the same shell (the
19
+ * client router picks the active panel from the URL), so this is stateless apart from the injected
20
+ * config. All server-provided values are escaped; the auth token (when present) is embedded as a JS
21
+ * string so sidecar fetches carry it in cookie-less setups.
22
+ *
23
+ * Overridable: a project may replace it via `overrides.hub.htmlService` to re-skin the cockpit.
24
+ */
25
+ @Injectable()
26
+ export class CoreHubHtmlService {
27
+ constructor(@Inject(HUB_CONFIG) protected readonly config: ResolvedHubConfig) {}
28
+
29
+ /** The shared client runtime script — served verbatim from `GET /{hub}/hub.js`. */
30
+ buildClientScript(): string {
31
+ return getHubClientJs();
32
+ }
33
+
34
+ /**
35
+ * Build the full shell HTML with `__CSP_NONCE__` placeholders. The controller replaces the
36
+ * placeholders per request via `injectNonce()`.
37
+ *
38
+ * @param authHeader - the incoming `Authorization` header, embedded so client fetches can replay it
39
+ */
40
+ buildShell(authHeader?: string): string {
41
+ const base = '/' + this.config.path;
42
+
43
+ const bootstrap = [
44
+ `window.__HUB_BASE__='${escapeJsString(base)}';`,
45
+ `window.__HUB_POLL_MS__=${Number(this.config.pollIntervalMs) || 5000};`,
46
+ `window.__HUB_LOGIN__='${escapeJsString(this.config.loginEndpoint)}';`,
47
+ authHeader ? `window.__HUB_TOKEN__='${escapeJsString(authHeader)}';` : '',
48
+ ].join('');
49
+
50
+ // SECURITY (self-sufficient login): the shell is PUBLIC because it must render the login form
51
+ // before authentication. It therefore carries NO cockpit chrome — no navigation, no environment
52
+ // badge, no version, no external links. The client builds all of that ONLY after a successful
53
+ // ADMIN auth check, from the ADMIN-gated `session.json` payload. So an unauthenticated request to
54
+ // any /hub page reveals nothing of the Hub's structure; #hub-app stays empty until login.
55
+ return `<!DOCTYPE html>
56
+ <html lang="en">
57
+ <head>
58
+ <meta charset="UTF-8">
59
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
60
+ <title>Hub · nest-server</title>
61
+ <style nonce="${HUB_NONCE_PLACEHOLDER}">${this.css()}</style>
62
+ </head>
63
+ <body class="hub-locked">
64
+ <div id="hub-gate" class="hub-gate"></div>
65
+ <div id="hub-app"></div>
66
+ <dialog id="hub-confirm" class="hub-dialog"></dialog>
67
+ <script nonce="${HUB_NONCE_PLACEHOLDER}">${bootstrap}</script>
68
+ <script nonce="${HUB_NONCE_PLACEHOLDER}" src="${escapeHtml(base)}/hub.js?v=${HUB_ASSET_TOKEN}"></script>
69
+ </body>
70
+ </html>`;
71
+ }
72
+
73
+ protected css(): string {
74
+ return `
75
+ :root{--bg:#0b0d12;--panel:#141821;--panel2:#1b212d;--border:#242c3a;--text:#e6ebf2;--muted:#8b97a8;--accent:#4f8cff;--ok:#3fb950;--warn:#d29922;--err:#f85149}
76
+ *{box-sizing:border-box}
77
+ body{margin:0;background:var(--bg);color:var(--text);font:14px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif}
78
+ .hub-layout{display:flex;min-height:100vh}
79
+ .hub-nav{width:230px;flex:0 0 230px;background:var(--panel);border-right:1px solid var(--border);padding:16px 10px;overflow-y:auto}
80
+ .hub-brand{font-weight:700;font-size:18px;padding:4px 10px 14px;display:flex;align-items:center;gap:8px}
81
+ .hub-env{font-size:10px;text-transform:uppercase;letter-spacing:.05em;padding:2px 6px;border-radius:4px;background:var(--panel2);color:var(--muted)}
82
+ .hub-env-production{background:#3d1418;color:#ff7b72}
83
+ .hub-nav h3{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin:16px 10px 4px}
84
+ .hub-nav a{display:block;padding:6px 10px;border-radius:6px;color:var(--text);text-decoration:none;font-size:13px}
85
+ .hub-nav a:hover{background:var(--panel2)}
86
+ .hub-nav a.active{background:var(--accent);color:#fff}
87
+ .hub-nav a.hub-nav-disabled{opacity:.38;cursor:not-allowed}
88
+ .hub-nav a.hub-nav-disabled:hover{background:none}
89
+ .hub-nav a.hub-ext::after{content:' ↗';color:var(--muted)}
90
+ .hub-main{flex:1;min-width:0;display:flex;flex-direction:column}
91
+ .hub-topbar{display:flex;align-items:center;justify-content:space-between;padding:14px 22px;border-bottom:1px solid var(--border)}
92
+ .hub-topbar h1{font-size:18px;margin:0}
93
+ .hub-meta{color:var(--muted);font-size:12px;display:flex;align-items:center;gap:8px}
94
+ .hub-dot{width:9px;height:9px;border-radius:50%;display:inline-block;background:var(--muted)}
95
+ .hub-dot-ok{background:var(--ok)}.hub-dot-err{background:var(--err)}
96
+ .hub-content{padding:22px;overflow:auto}
97
+ .hub-tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;margin-bottom:20px}
98
+ .hub-tile{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:14px}
99
+ .hub-tile-label{color:var(--muted);font-size:12px}
100
+ .hub-tile-value{font-size:22px;font-weight:600;margin-top:4px}
101
+ .hub-tile-value.ok{color:var(--ok)}.hub-tile-value.warn{color:var(--warn)}.hub-tile-value.err{color:var(--err)}
102
+ .hub-tile-sub{color:var(--muted);font-size:11px;margin-top:4px}
103
+ .hub-table{width:100%;border-collapse:collapse;background:var(--panel);border:1px solid var(--border);border-radius:8px;overflow:hidden}
104
+ .hub-table th{text-align:left;padding:8px 12px;background:var(--panel2);color:var(--muted);font-size:12px;font-weight:600;cursor:pointer}
105
+ .hub-table td{padding:8px 12px;border-top:1px solid var(--border);font-size:13px}
106
+ .hub-table tr:hover td{background:var(--panel2)}
107
+ .hub-json{background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:14px;overflow:auto;font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;white-space:pre;max-width:100%}
108
+ .hub-empty{color:var(--muted);padding:40px;text-align:center;border:1px dashed var(--border);border-radius:8px}
109
+ .hub-btn{background:var(--panel2);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:6px 12px;font-size:13px;cursor:pointer}
110
+ .hub-btn:hover{border-color:var(--accent)}
111
+ .hub-btn:disabled{opacity:.5;cursor:not-allowed}
112
+ .hub-btn-primary{background:var(--accent);border-color:var(--accent);color:#fff}
113
+ .hub-btn-danger{background:var(--err);border-color:var(--err);color:#fff}
114
+ .hub-signout{padding:4px 10px;font-size:12px;margin-left:4px}
115
+ .hub-input{width:100%;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:8px;color:var(--text);margin:8px 0;font-size:13px}
116
+ .hub-dialog{background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:12px;padding:20px;max-width:440px;width:90%}
117
+ .hub-dialog::backdrop{background:rgba(0,0,0,.6)}
118
+ .hub-dialog h3{margin:0 0 8px}
119
+ .hub-dialog-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:12px}
120
+ .hub-hint{color:var(--muted);font-size:12px}
121
+ .hub-chip{display:inline-block;padding:1px 7px;border-radius:10px;font-size:11px;background:var(--panel2);color:var(--muted)}
122
+ .hub-chip-ok{background:#132d1c;color:var(--ok)}.hub-chip-warn{background:#2d2611;color:var(--warn)}.hub-chip-err{background:#3d1418;color:var(--err)}
123
+ .hub-toast{position:fixed;bottom:20px;right:20px;background:var(--panel2);border:1px solid var(--accent);color:var(--text);padding:12px 16px;border-radius:8px;font-size:13px;z-index:9999;box-shadow:0 6px 20px rgba(0,0,0,.4)}
124
+ .hub-toast-err{border-color:var(--err)}
125
+ /* Auth gate: nothing of the app is visible until authenticated. The client builds the layout into
126
+ #hub-app only after a successful auth check, so body.hub-locked just hides the (empty) app root. */
127
+ .hub-gate{display:none;position:fixed;inset:0;background:var(--bg);z-index:100;align-items:center;justify-content:center;padding:20px}
128
+ .hub-locked .hub-gate{display:flex}
129
+ .hub-locked #hub-app{display:none}
130
+ .hub-gate-card{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:28px;width:360px;max-width:100%}
131
+ .hub-gate-card h2{margin:0 0 4px}
132
+ .hub-gate-brand{font-weight:700;font-size:20px;margin-bottom:18px;display:flex;align-items:center;gap:8px}
133
+ `;
134
+ }
135
+ }
@@ -0,0 +1,286 @@
1
+ import { Controller, Get, Headers, Optional, Param, Query, Res } from '@nestjs/common';
2
+ import { Response } from 'express';
3
+
4
+ import { Roles } from '../../common/decorators/roles.decorator';
5
+ import { RoleEnum } from '../../common/enums/role.enum';
6
+
7
+ import { CoreHubHtmlService } from './core-hub-html.service';
8
+ import { CoreHubService } from './core-hub.service';
9
+ import { HUB_PANELS } from './hub-nav';
10
+ import { CoreHubDbService } from './services/core-hub-db.service';
11
+ import { CoreHubEmailService } from './services/core-hub-email.service';
12
+ import { CoreHubMailboxService } from './services/core-hub-mailbox.service';
13
+ import { CoreHubMigrationsService } from './services/core-hub-migrations.service';
14
+ import { CoreHubSourcesService } from './services/core-hub-sources.service';
15
+ import { HubLogBufferService } from './services/hub-log-buffer.service';
16
+ import { HubQueryProfilerService } from './services/hub-query-profiler.service';
17
+ import { HubTraceBufferService } from './services/hub-trace-buffer.service';
18
+ import { buildHubSecurityHeaders, escapeHtml, generateNonce, injectNonce } from './helpers/hub-shell.helper';
19
+
20
+ /** Route sub-paths of every non-root panel — the shell handler answers all of them. */
21
+ const HUB_PAGE_PATHS = HUB_PANELS.filter((panel) => panel.path).map((panel) => panel.path);
22
+
23
+ /**
24
+ * Serves the Hub SPA shell (all panel routes), the shared client runtime and the read-only JSON
25
+ * sidecars. Mutating operations live in `CoreHubActionsController`.
26
+ *
27
+ * The controller path and required roles are assigned at runtime in `CoreHubModule.forRoot()` via
28
+ * `Reflect.defineMetadata` (same pattern as the permissions module), so they follow the configured
29
+ * `hub.path` / `hub.roles`.
30
+ *
31
+ * Every response uses `@Res()` and is written manually: this bypasses the four global response
32
+ * interceptors (which would otherwise walk/mutate the payload — `CheckSecurityInterceptor` deletes
33
+ * secret-named keys in place) and lets the Hub set its own strict CSP + no-store headers.
34
+ *
35
+ * Overridable via `overrides.hub.controller`.
36
+ */
37
+ @Controller()
38
+ export class CoreHubController {
39
+ private cachedClientScript?: string;
40
+
41
+ constructor(
42
+ protected readonly hubService: CoreHubService,
43
+ protected readonly htmlService: CoreHubHtmlService,
44
+ protected readonly dbService: CoreHubDbService,
45
+ protected readonly migrationsService: CoreHubMigrationsService,
46
+ protected readonly sourcesService: CoreHubSourcesService,
47
+ protected readonly emailService: CoreHubEmailService,
48
+ protected readonly logBuffer: HubLogBufferService,
49
+ protected readonly traceBuffer: HubTraceBufferService,
50
+ protected readonly queryProfiler: HubQueryProfilerService,
51
+ // Only provided when the mailbox is enabled.
52
+ @Optional() protected readonly mailboxService?: CoreHubMailboxService,
53
+ ) {}
54
+
55
+ @Get('ai.json')
56
+ async aiJson(@Res() res: Response): Promise<void> {
57
+ this.sendJson(res, await this.sourcesService.getAi());
58
+ }
59
+
60
+ @Get('auth-migration.json')
61
+ async authMigrationJson(@Res() res: Response): Promise<void> {
62
+ this.sendJson(res, await this.sourcesService.getAuthMigration());
63
+ }
64
+
65
+ @Get('config.json')
66
+ configJson(@Res() res: Response): void {
67
+ this.sendJson(res, this.hubService.getConfigMasked());
68
+ }
69
+
70
+ @Get('cron.json')
71
+ cronJson(@Res() res: Response): void {
72
+ this.sendJson(res, this.sourcesService.getCron());
73
+ }
74
+
75
+ @Get('emails.json')
76
+ emailsJson(@Res() res: Response): void {
77
+ if (!this.emailService.previewEnabled) {
78
+ this.sendJson(res, { available: false, hint: 'The email preview panel is disabled (hub.emailPreview: false).' });
79
+ return;
80
+ }
81
+ this.sendJson(res, this.emailService.getTemplates());
82
+ }
83
+
84
+ @Get('error-codes.json')
85
+ errorCodesJson(@Query('locale') locale: string | undefined, @Res() res: Response): void {
86
+ this.sendJson(res, this.sourcesService.getErrorCodes(locale || 'en'));
87
+ }
88
+
89
+ @Get('dashboard.json')
90
+ dashboardJson(@Res() res: Response): void {
91
+ this.sendJson(res, this.hubService.getDashboard());
92
+ }
93
+
94
+ @Get('db.json')
95
+ async dbJson(@Res() res: Response): Promise<void> {
96
+ this.sendJson(res, await this.dbService.getDbStats());
97
+ }
98
+
99
+ @Get('diagnostics.json')
100
+ diagnosticsJson(@Res() res: Response): void {
101
+ this.sendJson(res, this.hubService.getDiagnostics(this.collectorBuffers()));
102
+ }
103
+
104
+ @Get('files.json')
105
+ async filesJson(
106
+ @Query('bucket') bucket: string | undefined,
107
+ @Query('skip') skip: string | undefined,
108
+ @Query('limit') limit: string | undefined,
109
+ @Res() res: Response,
110
+ ): Promise<void> {
111
+ this.sendJson(res, await this.dbService.getFiles(bucket || 'fs', Number(skip) || 0, Number(limit) || 100));
112
+ }
113
+
114
+ @Get('migrations.json')
115
+ async migrationsJson(@Res() res: Response): Promise<void> {
116
+ this.sendJson(res, await this.migrationsService.getStatus());
117
+ }
118
+
119
+ @Get('mailbox.json')
120
+ mailboxJson(@Query('since') since: string | undefined, @Res() res: Response): void {
121
+ if (!this.mailboxService) {
122
+ this.sendJson(res, { available: false, hint: 'The mailbox is not enabled.' });
123
+ return;
124
+ }
125
+ this.sendJson(res, this.mailboxService.getMailbox(since !== undefined ? Number(since) : undefined));
126
+ }
127
+
128
+ @Get('mailbox/:seq/html')
129
+ mailboxHtml(@Param('seq') seq: string, @Res() res: Response): void {
130
+ const html = this.mailboxService?.getMailHtml(Number(seq));
131
+ res.set({
132
+ 'Cache-Control': 'no-store',
133
+ 'Content-Security-Policy': "default-src 'none'; img-src * data:; style-src 'unsafe-inline'; font-src data:",
134
+ 'X-Content-Type-Options': 'nosniff',
135
+ 'X-Frame-Options': 'SAMEORIGIN',
136
+ });
137
+ res
138
+ .type('html')
139
+ .send(
140
+ html ??
141
+ '<!DOCTYPE html><html><body style="font-family:sans-serif;color:#8b97a8;padding:20px">No preview available.</body></html>',
142
+ );
143
+ }
144
+
145
+ @Get('logs.json')
146
+ logsJson(@Query('since') since: string | undefined, @Res() res: Response): void {
147
+ if (!this.logBuffer.enabled) {
148
+ this.sendJson(res, { available: false, hint: 'The logs collector is disabled.' });
149
+ return;
150
+ }
151
+ this.sendJson(res, this.logBuffer.getData(since !== undefined ? Number(since) : undefined));
152
+ }
153
+
154
+ @Get('models.json')
155
+ modelsJson(@Res() res: Response): void {
156
+ this.sendJson(res, this.dbService.getModels());
157
+ }
158
+
159
+ /**
160
+ * ADMIN-gated auth probe AND cockpit-chrome payload: 200 returns the navigation, environment badge,
161
+ * version and external links the client uses to build the layout; 401/403 keeps the shell showing
162
+ * only the login form. The public shell HTML deliberately carries none of this data.
163
+ */
164
+ @Get('session.json')
165
+ sessionJson(@Res() res: Response): void {
166
+ this.sendJson(res, this.hubService.getSessionPayload());
167
+ }
168
+
169
+ @Get('queries.json')
170
+ queriesJson(@Res() res: Response): void {
171
+ if (!this.queryProfiler.enabled) {
172
+ this.sendJson(res, {
173
+ available: false,
174
+ hint: 'The query profiler is disabled (opt-in via hub.collectors.queries).',
175
+ });
176
+ return;
177
+ }
178
+ this.sendJson(res, this.queryProfiler.getData());
179
+ }
180
+
181
+ @Get('traces.json')
182
+ tracesJson(@Query('since') since: string | undefined, @Res() res: Response): void {
183
+ if (!this.traceBuffer.enabled) {
184
+ this.sendJson(res, { available: false, hint: 'The traces collector is disabled.' });
185
+ return;
186
+ }
187
+ this.sendJson(res, this.traceBuffer.getData(since !== undefined ? Number(since) : undefined));
188
+ }
189
+
190
+ @Get('routes.json')
191
+ async routesJson(@Res() res: Response): Promise<void> {
192
+ this.sendJson(res, await this.sourcesService.getRoutes());
193
+ }
194
+
195
+ /**
196
+ * Server-rendered email preview, delivered as sandboxed HTML for an <iframe>. A dedicated,
197
+ * stricter CSP (no scripts) applies to the rendered template markup.
198
+ */
199
+ @Get('emails/preview')
200
+ async emailPreview(
201
+ @Query('template') template: string | undefined,
202
+ @Query('locale') locale: string | undefined,
203
+ @Res() res: Response,
204
+ ): Promise<void> {
205
+ const result = await this.emailService.renderPreview(template || '', locale);
206
+ res.set({
207
+ 'Cache-Control': 'no-store',
208
+ 'Content-Security-Policy': "default-src 'none'; img-src * data:; style-src 'unsafe-inline'; font-src data:",
209
+ 'X-Content-Type-Options': 'nosniff',
210
+ 'X-Frame-Options': 'SAMEORIGIN',
211
+ });
212
+ if ('available' in result && result.available === false) {
213
+ res
214
+ .type('html')
215
+ .send(
216
+ `<!DOCTYPE html><html><body style="font-family:sans-serif;color:#8b97a8;padding:20px">${escapeHtml(result.hint)}</body></html>`,
217
+ );
218
+ return;
219
+ }
220
+ // The rendered template is trusted framework/project EJS output; the sandbox CSP blocks scripts.
221
+ res.type('html').send((result as { html: string }).html);
222
+ }
223
+
224
+ // Public: the shared client runtime carries no secrets, and the public /hub/auth token-paste page
225
+ // must be able to load it in cookie-less setups.
226
+ @Get('hub.js')
227
+ @Roles(RoleEnum.S_EVERYONE)
228
+ hubJs(@Res() res: Response): void {
229
+ if (!this.cachedClientScript) {
230
+ this.cachedClientScript = this.htmlService.buildClientScript();
231
+ }
232
+ res.set(buildHubSecurityHeaders(generateNonce(), { cacheable: true }));
233
+ res.type('application/javascript').send(this.cachedClientScript);
234
+ }
235
+
236
+ /**
237
+ * Root panel (dashboard). PUBLIC — the shell is only the cockpit chrome (no data), and the client
238
+ * shows a login form when the ADMIN-gated data sidecars answer 401. This makes the Hub
239
+ * self-sufficient: an admin can log in directly at the API without the frontend. The panel
240
+ * structure is already discoverable via the public `hub.js`, so serving the shell adds no exposure;
241
+ * every `*.json` sidecar and every action stays ADMIN-gated.
242
+ */
243
+ @Get()
244
+ @Roles(RoleEnum.S_EVERYONE)
245
+ root(@Headers('authorization') authHeader: string | undefined, @Res() res: Response): void {
246
+ this.sendShell(res, authHeader);
247
+ }
248
+
249
+ /** All other panel routes serve the same (public) shell; the client router picks the active panel. */
250
+ @Get(HUB_PAGE_PATHS)
251
+ @Roles(RoleEnum.S_EVERYONE)
252
+ page(@Headers('authorization') authHeader: string | undefined, @Res() res: Response): void {
253
+ this.sendShell(res, authHeader);
254
+ }
255
+
256
+ /** Backward-compatible alias for the login/token-paste entry (now handled inline by the shell). */
257
+ @Get('auth')
258
+ @Roles(RoleEnum.S_EVERYONE)
259
+ authPage(@Res() res: Response): void {
260
+ this.sendShell(res, undefined);
261
+ }
262
+
263
+ /** Buffer fill levels for the diagnostics panel. */
264
+ protected collectorBuffers(): Record<string, { capacity: number; enabled: boolean; size: number }> {
265
+ const logs = this.logBuffer.getData();
266
+ const traces = this.traceBuffer.getData();
267
+ const queries = this.queryProfiler.getData();
268
+ return {
269
+ logs: { capacity: 0, enabled: this.logBuffer.enabled, size: logs.records.length },
270
+ queries: { capacity: 0, enabled: this.queryProfiler.enabled, size: queries.recent.length },
271
+ traces: { capacity: 0, enabled: this.traceBuffer.enabled, size: traces.traces.length },
272
+ };
273
+ }
274
+
275
+ protected sendJson(res: Response, data: unknown): void {
276
+ res.set(buildHubSecurityHeaders(generateNonce()));
277
+ res.type('application/json').send(JSON.stringify(data));
278
+ }
279
+
280
+ protected sendShell(res: Response, authHeader?: string): void {
281
+ const nonce = generateNonce();
282
+ const html = injectNonce(this.htmlService.buildShell(authHeader), nonce);
283
+ res.set(buildHubSecurityHeaders(nonce));
284
+ res.type('html').send(html);
285
+ }
286
+ }
@@ -0,0 +1,108 @@
1
+ import { PATH_METADATA } from '@nestjs/common/constants';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import { CoreHubController } from './core-hub.controller';
5
+ import { CoreHubModule } from './core-hub.module';
6
+
7
+ const ctx = { env: 'local', version: '1.0.0' };
8
+
9
+ describe('CoreHubModule.forRoot', () => {
10
+ it('builds a dynamic module with the hub controller', () => {
11
+ const mod = CoreHubModule.forRoot({ config: {}, ctx });
12
+ expect(mod.module).toBe(CoreHubModule);
13
+ expect(mod.controllers?.length).toBeGreaterThan(0);
14
+ });
15
+
16
+ it('becomes global and provides the email-capture binding when the mailbox is enabled', () => {
17
+ const mod = CoreHubModule.forRoot({ config: { mailbox: { mode: 'capture' } }, ctx });
18
+ expect(mod.global).toBe(true);
19
+ });
20
+
21
+ it('is not global when the mailbox is disabled', () => {
22
+ const mod = CoreHubModule.forRoot({ config: {}, ctx });
23
+ expect(mod.global).toBeFalsy();
24
+ });
25
+
26
+ it('throws when mailbox capture mode is used in production (silent mail suppression guard)', () => {
27
+ expect(() =>
28
+ CoreHubModule.forRoot({ config: { mailbox: { mode: 'capture' } }, ctx: { env: 'production', version: '1' } }),
29
+ ).toThrow(/capture.*not permitted/i);
30
+ });
31
+
32
+ it('allows mailbox copy mode in production', () => {
33
+ expect(() =>
34
+ CoreHubModule.forRoot({ config: { mailbox: { mode: 'copy' } }, ctx: { env: 'production', version: '1' } }),
35
+ ).not.toThrow();
36
+ });
37
+
38
+ describe('public-access guard (roles: false in a reachable env)', () => {
39
+ it('throws when roles:false in production without acknowledgment', () => {
40
+ expect(() =>
41
+ CoreHubModule.forRoot({ config: { roles: false }, ctx: { env: 'production', version: '1' } }),
42
+ ).toThrow(/roles: false.*not permitted/i);
43
+ });
44
+
45
+ it('throws when roles:false in staging without acknowledgment', () => {
46
+ expect(() => CoreHubModule.forRoot({ config: { roles: false }, ctx: { env: 'staging', version: '1' } })).toThrow(
47
+ /not permitted/i,
48
+ );
49
+ });
50
+
51
+ it('throws for CUSTOM reachable env names too (fail-safe — not just production/staging)', () => {
52
+ // Regression guard for the exact-string bypass: prod / live / preprod / production-eu / staging-2
53
+ // must all be treated as reachable.
54
+ for (const env of ['prod', 'live', 'preprod', 'production-eu', 'staging-2']) {
55
+ expect(() => CoreHubModule.forRoot({ config: { roles: false }, ctx: { env, version: '1' } })).toThrow(
56
+ /not permitted/i,
57
+ );
58
+ }
59
+ });
60
+
61
+ it('allows roles:false in production ONLY with explicit allowPublicAccessInProduction', () => {
62
+ expect(() =>
63
+ CoreHubModule.forRoot({
64
+ config: { allowPublicAccessInProduction: true, roles: false },
65
+ ctx: { env: 'production', version: '1' },
66
+ }),
67
+ ).not.toThrow();
68
+ });
69
+
70
+ it('allows roles:false in non-reachable envs (local, development)', () => {
71
+ expect(() => CoreHubModule.forRoot({ config: { roles: false }, ctx })).not.toThrow(); // ctx = local
72
+ expect(() =>
73
+ CoreHubModule.forRoot({ config: { roles: false }, ctx: { env: 'development', version: '1' } }),
74
+ ).not.toThrow();
75
+ });
76
+
77
+ it('allows the default ADMIN gate in production (no roles set)', () => {
78
+ expect(() => CoreHubModule.forRoot({ config: {}, ctx: { env: 'production', version: '1' } })).not.toThrow();
79
+ });
80
+ });
81
+
82
+ describe('runtime path/roles metadata (shared controller class)', () => {
83
+ it('writes the configured base path onto the controller class', () => {
84
+ CoreHubModule.forRoot({ config: { path: 'cockpit' }, ctx });
85
+ expect(Reflect.getMetadata(PATH_METADATA, CoreHubController)).toBe('cockpit');
86
+ });
87
+
88
+ it('fully overwrites stale metadata on a second forRoot (no cross-instance pollution)', () => {
89
+ // The controller class is shared across app instances in the same process (parallel tests). A
90
+ // second forRoot must overwrite the first, never leak the previous path/roles.
91
+ CoreHubModule.forRoot({ config: { path: 'first', roles: 'admin' }, ctx });
92
+ expect(Reflect.getMetadata(PATH_METADATA, CoreHubController)).toBe('first');
93
+ expect(Reflect.getMetadata('roles', CoreHubController)).toEqual(['admin']);
94
+
95
+ CoreHubModule.forRoot({ config: { path: 'second', roles: ['ops', 'admin'] }, ctx });
96
+ expect(Reflect.getMetadata(PATH_METADATA, CoreHubController)).toBe('second');
97
+ expect(Reflect.getMetadata('roles', CoreHubController)).toEqual(['ops', 'admin']);
98
+ });
99
+
100
+ it('clears the roles metadata when roles: false (public opt-out)', () => {
101
+ CoreHubModule.forRoot({ config: { roles: 'admin' }, ctx });
102
+ expect(Reflect.getMetadata('roles', CoreHubController)).toEqual(['admin']);
103
+
104
+ CoreHubModule.forRoot({ config: { roles: false }, ctx });
105
+ expect(Reflect.getMetadata('roles', CoreHubController)).toBeUndefined();
106
+ });
107
+ });
108
+ });