@lenne.tech/nest-server 11.31.2 → 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.
- package/.claude/rules/architecture.md +2 -0
- package/.claude/rules/configurable-features.md +2 -0
- package/CLAUDE.md +28 -1
- package/FRAMEWORK-API.md +4 -1
- package/dist/config.env.d.ts +4 -0
- package/dist/config.env.js +32 -2
- package/dist/config.env.js.map +1 -1
- package/dist/core/common/helpers/logging.helper.d.ts +1 -0
- package/dist/core/common/helpers/logging.helper.js +12 -0
- package/dist/core/common/helpers/logging.helper.js.map +1 -1
- package/dist/core/common/helpers/meta.helper.d.ts +1 -0
- package/dist/core/common/helpers/meta.helper.js +32 -1
- package/dist/core/common/helpers/meta.helper.js.map +1 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +9 -0
- package/dist/core/common/services/email.service.d.ts +3 -1
- package/dist/core/common/services/email.service.js +33 -2
- package/dist/core/common/services/email.service.js.map +1 -1
- package/dist/core/common/services/template.service.js +9 -4
- package/dist/core/common/services/template.service.js.map +1 -1
- package/dist/core/modules/hub/core-hub-actions.controller.d.ts +22 -0
- package/dist/core/modules/hub/core-hub-actions.controller.js +141 -0
- package/dist/core/modules/hub/core-hub-actions.controller.js.map +1 -0
- package/dist/core/modules/hub/core-hub-html.service.d.ts +8 -0
- package/dist/core/modules/hub/core-hub-html.service.js +123 -0
- package/dist/core/modules/hub/core-hub-html.service.js.map +1 -0
- package/dist/core/modules/hub/core-hub.controller.d.ts +56 -0
- package/dist/core/modules/hub/core-hub.controller.js +398 -0
- package/dist/core/modules/hub/core-hub.controller.js.map +1 -0
- package/dist/core/modules/hub/core-hub.module.d.ts +17 -0
- package/dist/core/modules/hub/core-hub.module.js +109 -0
- package/dist/core/modules/hub/core-hub.module.js.map +1 -0
- package/dist/core/modules/hub/core-hub.service.d.ts +18 -0
- package/dist/core/modules/hub/core-hub.service.js +153 -0
- package/dist/core/modules/hub/core-hub.service.js.map +1 -0
- package/dist/core/modules/hub/helpers/hub-client-js.helper.d.ts +1 -0
- package/dist/core/modules/hub/helpers/hub-client-js.helper.js +755 -0
- package/dist/core/modules/hub/helpers/hub-client-js.helper.js.map +1 -0
- package/dist/core/modules/hub/helpers/hub-command-shape.helper.d.ts +1 -0
- package/dist/core/modules/hub/helpers/hub-command-shape.helper.js +33 -0
- package/dist/core/modules/hub/helpers/hub-command-shape.helper.js.map +1 -0
- package/dist/core/modules/hub/helpers/hub-mask.helper.d.ts +1 -0
- package/dist/core/modules/hub/helpers/hub-mask.helper.js +46 -0
- package/dist/core/modules/hub/helpers/hub-mask.helper.js.map +1 -0
- package/dist/core/modules/hub/helpers/hub-mermaid.helper.d.ts +10 -0
- package/dist/core/modules/hub/helpers/hub-mermaid.helper.js +35 -0
- package/dist/core/modules/hub/helpers/hub-mermaid.helper.js.map +1 -0
- package/dist/core/modules/hub/helpers/hub-shell.helper.d.ts +9 -0
- package/dist/core/modules/hub/helpers/hub-shell.helper.js +62 -0
- package/dist/core/modules/hub/helpers/hub-shell.helper.js.map +1 -0
- package/dist/core/modules/hub/hub-action-messages.d.ts +17 -0
- package/dist/core/modules/hub/hub-action-messages.js +21 -0
- package/dist/core/modules/hub/hub-action-messages.js.map +1 -0
- package/dist/core/modules/hub/hub-config.helper.d.ts +11 -0
- package/dist/core/modules/hub/hub-config.helper.js +164 -0
- package/dist/core/modules/hub/hub-config.helper.js.map +1 -0
- package/dist/core/modules/hub/hub-nav.d.ts +12 -0
- package/dist/core/modules/hub/hub-nav.js +43 -0
- package/dist/core/modules/hub/hub-nav.js.map +1 -0
- package/dist/core/modules/hub/hub-ring-buffer.d.ts +22 -0
- package/dist/core/modules/hub/hub-ring-buffer.js +59 -0
- package/dist/core/modules/hub/hub-ring-buffer.js.map +1 -0
- package/dist/core/modules/hub/hub.constants.d.ts +18 -0
- package/dist/core/modules/hub/hub.constants.js +22 -0
- package/dist/core/modules/hub/hub.constants.js.map +1 -0
- package/dist/core/modules/hub/index.d.ts +25 -0
- package/dist/core/modules/hub/index.js +42 -0
- package/dist/core/modules/hub/index.js.map +1 -0
- package/dist/core/modules/hub/interfaces/hub-config.interface.d.ts +115 -0
- package/dist/core/modules/hub/interfaces/hub-config.interface.js +3 -0
- package/dist/core/modules/hub/interfaces/hub-config.interface.js.map +1 -0
- package/dist/core/modules/hub/interfaces/hub-panels.interface.d.ts +231 -0
- package/dist/core/modules/hub/interfaces/hub-panels.interface.js +3 -0
- package/dist/core/modules/hub/interfaces/hub-panels.interface.js.map +1 -0
- package/dist/core/modules/hub/middleware/hub-trace.middleware.d.ts +8 -0
- package/dist/core/modules/hub/middleware/hub-trace.middleware.js +50 -0
- package/dist/core/modules/hub/middleware/hub-trace.middleware.js.map +1 -0
- package/dist/core/modules/hub/services/core-hub-actions.service.d.ts +48 -0
- package/dist/core/modules/hub/services/core-hub-actions.service.js +136 -0
- package/dist/core/modules/hub/services/core-hub-actions.service.js.map +1 -0
- package/dist/core/modules/hub/services/core-hub-db.service.d.ts +19 -0
- package/dist/core/modules/hub/services/core-hub-db.service.js +180 -0
- package/dist/core/modules/hub/services/core-hub-db.service.js.map +1 -0
- package/dist/core/modules/hub/services/core-hub-email.service.d.ts +24 -0
- package/dist/core/modules/hub/services/core-hub-email.service.js +148 -0
- package/dist/core/modules/hub/services/core-hub-email.service.js.map +1 -0
- package/dist/core/modules/hub/services/core-hub-mailbox.service.d.ts +20 -0
- package/dist/core/modules/hub/services/core-hub-mailbox.service.js +103 -0
- package/dist/core/modules/hub/services/core-hub-mailbox.service.js.map +1 -0
- package/dist/core/modules/hub/services/core-hub-migrations.service.d.ts +25 -0
- package/dist/core/modules/hub/services/core-hub-migrations.service.js +115 -0
- package/dist/core/modules/hub/services/core-hub-migrations.service.js.map +1 -0
- package/dist/core/modules/hub/services/core-hub-sources.service.d.ts +28 -0
- package/dist/core/modules/hub/services/core-hub-sources.service.js +187 -0
- package/dist/core/modules/hub/services/core-hub-sources.service.js.map +1 -0
- package/dist/core/modules/hub/services/hub-log-buffer.service.d.ts +24 -0
- package/dist/core/modules/hub/services/hub-log-buffer.service.js +210 -0
- package/dist/core/modules/hub/services/hub-log-buffer.service.js.map +1 -0
- package/dist/core/modules/hub/services/hub-query-profiler.service.d.ts +38 -0
- package/dist/core/modules/hub/services/hub-query-profiler.service.js +235 -0
- package/dist/core/modules/hub/services/hub-query-profiler.service.js.map +1 -0
- package/dist/core/modules/hub/services/hub-trace-buffer.service.d.ts +18 -0
- package/dist/core/modules/hub/services/hub-trace-buffer.service.js +123 -0
- package/dist/core/modules/hub/services/hub-trace-buffer.service.js.map +1 -0
- package/dist/core.module.js +42 -1
- package/dist/core.module.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +1 -0
- package/migration-guides/11.31.2-to-11.31.3.md +135 -0
- package/package.json +5 -4
- package/src/config.env.ts +75 -2
- package/src/core/common/helpers/logging.helper.spec.ts +61 -0
- package/src/core/common/helpers/logging.helper.ts +48 -0
- package/src/core/common/helpers/meta.helper.ts +46 -1
- package/src/core/common/interfaces/server-options.interface.ts +46 -0
- package/src/core/common/services/email.service.ts +33 -1
- package/src/core/common/services/template.service.ts +21 -16
- package/src/core/modules/hub/INTEGRATION-CHECKLIST.md +64 -0
- package/src/core/modules/hub/README.md +159 -0
- package/src/core/modules/hub/core-hub-actions.controller.ts +137 -0
- package/src/core/modules/hub/core-hub-html.service.ts +135 -0
- package/src/core/modules/hub/core-hub.controller.ts +286 -0
- package/src/core/modules/hub/core-hub.module.spec.ts +108 -0
- package/src/core/modules/hub/core-hub.module.ts +159 -0
- package/src/core/modules/hub/core-hub.service.ts +169 -0
- package/src/core/modules/hub/helpers/hub-client-js.helper.ts +768 -0
- package/src/core/modules/hub/helpers/hub-command-shape.helper.spec.ts +48 -0
- package/src/core/modules/hub/helpers/hub-command-shape.helper.ts +47 -0
- package/src/core/modules/hub/helpers/hub-mask.helper.spec.ts +67 -0
- package/src/core/modules/hub/helpers/hub-mask.helper.ts +78 -0
- package/src/core/modules/hub/helpers/hub-mermaid.helper.spec.ts +54 -0
- package/src/core/modules/hub/helpers/hub-mermaid.helper.ts +62 -0
- package/src/core/modules/hub/helpers/hub-shell.helper.spec.ts +106 -0
- package/src/core/modules/hub/helpers/hub-shell.helper.ts +90 -0
- package/src/core/modules/hub/hub-action-messages.ts +47 -0
- package/src/core/modules/hub/hub-config.helper.spec.ts +108 -0
- package/src/core/modules/hub/hub-config.helper.ts +233 -0
- package/src/core/modules/hub/hub-nav.ts +66 -0
- package/src/core/modules/hub/hub-ring-buffer.spec.ts +95 -0
- package/src/core/modules/hub/hub-ring-buffer.ts +101 -0
- package/src/core/modules/hub/hub.constants.ts +84 -0
- package/src/core/modules/hub/index.ts +25 -0
- package/src/core/modules/hub/interfaces/hub-config.interface.ts +265 -0
- package/src/core/modules/hub/interfaces/hub-panels.interface.ts +186 -0
- package/src/core/modules/hub/middleware/hub-trace.middleware.ts +45 -0
- package/src/core/modules/hub/services/core-hub-actions.service.ts +133 -0
- package/src/core/modules/hub/services/core-hub-db.service.ts +185 -0
- package/src/core/modules/hub/services/core-hub-email.service.ts +158 -0
- package/src/core/modules/hub/services/core-hub-mailbox.service.spec.ts +116 -0
- package/src/core/modules/hub/services/core-hub-mailbox.service.ts +121 -0
- package/src/core/modules/hub/services/core-hub-migrations.service.ts +112 -0
- package/src/core/modules/hub/services/core-hub-sources.service.ts +194 -0
- package/src/core/modules/hub/services/hub-log-buffer.service.ts +252 -0
- package/src/core/modules/hub/services/hub-query-profiler.service.ts +274 -0
- package/src/core/modules/hub/services/hub-trace-buffer.service.spec.ts +112 -0
- package/src/core/modules/hub/services/hub-trace-buffer.service.ts +134 -0
- package/src/core.module.ts +61 -1
- package/src/index.ts +6 -0
|
@@ -213,6 +213,7 @@ JWT-based authentication for existing projects:
|
|
|
213
213
|
| **Health Check Module** | `GET /health` + GraphQL `healthCheck` query |
|
|
214
214
|
| **Error Code Module** | Centralized error registry with unique IDs |
|
|
215
215
|
| **Permissions Report** | Interactive HTML dashboard, JSON, and Markdown reports |
|
|
216
|
+
| **Hub (Operator Cockpit)** | Build-free ADMIN-gated dashboard at `/hub` (config-gated per environment). Adds an optional HTTP trace middleware (registered by `CoreHubModule.configure()` only when traces are enabled), a chaining `Logger.overrideLogger()` delegate for the log buffer, an optional `EmailService` capture hook (`HUB_EMAIL_CAPTURE` token) for the mailbox, and — when the query profiler is enabled — opts the MongoDB driver into `monitorCommands` from `core.module.ts`. See `src/core/modules/hub/README.md`. |
|
|
216
217
|
| **System Setup Module** | Initial admin creation for fresh deployments |
|
|
217
218
|
| **Cron Jobs** | `CoreCronJobsService` with timezone/UTC offset support |
|
|
218
219
|
| **Model Documentation** | Auto-generated model docs via `ModelDocService` |
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# Migration Guide: 11.31.2 → 11.31.3
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
| Category | Effort | Required? |
|
|
6
|
+
|----------|--------|-----------|
|
|
7
|
+
| **New Feature** | Opt-in | No — purely additive |
|
|
8
|
+
| Breaking Changes | None | — |
|
|
9
|
+
| Config Changes | Optional | Only if you enable the Hub |
|
|
10
|
+
|
|
11
|
+
This release adds the **Hub** — a build-free, ADMIN-gated operator cockpit served directly by the
|
|
12
|
+
framework at `/hub`. It is **disabled by default** and must be switched on per environment, so
|
|
13
|
+
existing projects are unaffected until they opt in. No code changes are required to update.
|
|
14
|
+
|
|
15
|
+
It also includes a **security patch**: three moderate `axios` advisories (recursion DoS + prototype
|
|
16
|
+
pollution, transitive via the Brevo and Mailjet mail SDKs) are closed by pinning `axios` to `1.18.1`.
|
|
17
|
+
Nothing to do on your side — just update.
|
|
18
|
+
|
|
19
|
+
## Quick Migration
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# Update package
|
|
23
|
+
pnpm update @lenne.tech/nest-server
|
|
24
|
+
|
|
25
|
+
# Verify build
|
|
26
|
+
pnpm run build
|
|
27
|
+
|
|
28
|
+
# Run tests
|
|
29
|
+
pnpm test
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Nothing else is required — the Hub stays dormant (every `/hub` route answers 404) until you add a
|
|
33
|
+
`hub` block to a config environment.
|
|
34
|
+
|
|
35
|
+
## What's New in 11.31.3
|
|
36
|
+
|
|
37
|
+
### Hub — admin area / operator cockpit
|
|
38
|
+
|
|
39
|
+
A dependency-free dashboard of runtime information and admin tools, rendered as server-side HTML +
|
|
40
|
+
a vanilla-JS SPA (no React, no build step), so it works identically in npm- and vendor-mode.
|
|
41
|
+
|
|
42
|
+
**16 panels:** Dashboard, Diagnostics, Logs, Request Traces, Query Performance, Cron Jobs, Database,
|
|
43
|
+
Models/ERD, Migrations, Files (GridFS), Config (secrets masked), Auth Migration (Legacy→IAM), Error
|
|
44
|
+
Codes, Email Preview, Mailbox, AI. Each has a `*.json` sidecar (the stable data contract) the client
|
|
45
|
+
polls; optional sources degrade to an "unavailable" state instead of erroring.
|
|
46
|
+
|
|
47
|
+
**3 runtime collectors** (in-memory ring buffers, per-app-instance, parallel-test-safe):
|
|
48
|
+
- **Logs** — a chaining `Logger.overrideLogger()` delegate (no main.ts change), secrets redacted.
|
|
49
|
+
- **Traces** — an Express middleware bound only when the collector is enabled (zero cost otherwise).
|
|
50
|
+
- **Queries** — MongoDB driver command monitoring; records value-free query SHAPES (N+1 templates),
|
|
51
|
+
never values. Enabling it opts the driver into `monitorCommands`.
|
|
52
|
+
|
|
53
|
+
**Built-in Mailbox** — a Mailpit-style capture of outgoing mail hooked into `EmailService`. In
|
|
54
|
+
`mode: 'capture'` mail is recorded and NOT sent (dev/test); in `mode: 'copy'` it is sent AND a copy
|
|
55
|
+
is recorded (with tokens/links redacted in the stored copy). `capture` throws at startup in
|
|
56
|
+
`production`/`staging` so it can never silently swallow real mail.
|
|
57
|
+
|
|
58
|
+
**Admin actions** (opt-out via `hub.actions: false`) — migrations run/rollback, GridFS file delete,
|
|
59
|
+
cron start/stop/trigger, collector-buffer clear, test mail. Every mutating request requires the
|
|
60
|
+
`X-Hub-Request: 1` header (CSRF defense) and destructive ones a server-validated type-to-confirm
|
|
61
|
+
keyword; each writes a `[HUB-ACTION] <action> by user <id>` audit line.
|
|
62
|
+
|
|
63
|
+
#### Enable it
|
|
64
|
+
|
|
65
|
+
The Hub is **never enabled implicitly** — switch it on per environment in `config.env.ts`:
|
|
66
|
+
|
|
67
|
+
```typescript
|
|
68
|
+
// config.env.ts (development / local)
|
|
69
|
+
hub: {
|
|
70
|
+
collectors: { queries: true }, // opt-in query profiler
|
|
71
|
+
mailbox: { mode: 'capture' }, // capture outgoing mail locally (Mailpit replacement)
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
// production — usually omit `hub` entirely. If you do want it (still ADMIN-gated),
|
|
75
|
+
// never use mailbox capture mode there:
|
|
76
|
+
// hub: { mailbox: false },
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Boolean shorthand `hub: true` enables it at `/hub`, admin-only, with default collectors. See
|
|
80
|
+
`IHubConfig` for every option. Env-var equivalents exist via `NSC__HUB__*` (see `.env.example`).
|
|
81
|
+
|
|
82
|
+
Then sign in as a user with `RoleEnum.ADMIN` and open `/hub`. The shell is self-sufficient: if you
|
|
83
|
+
are not authenticated it shows a login form that posts to `loginEndpoint` (default
|
|
84
|
+
`/iam/sign-in/email`), so an admin can sign in directly at the API without the frontend. In a
|
|
85
|
+
fullstack setup, a cross-subdomain session cookie from the app login already authenticates `/hub`.
|
|
86
|
+
|
|
87
|
+
#### Customization (optional)
|
|
88
|
+
|
|
89
|
+
Replace any Hub controller/service with a project subclass via `CoreModule.forRoot`:
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
CoreModule.forRoot(envConfig, {
|
|
93
|
+
hub: {
|
|
94
|
+
controller: MyHubController, // extends CoreHubController
|
|
95
|
+
actionsController: MyHubActionsController, // extends CoreHubActionsController
|
|
96
|
+
service: MyHubService, // extends CoreHubService
|
|
97
|
+
htmlService: MyHubHtmlService, // extends CoreHubHtmlService
|
|
98
|
+
actionsService: MyHubActionsService, // extends CoreHubActionsService
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Breaking Changes
|
|
104
|
+
|
|
105
|
+
None. The Hub is opt-in and disabled by default.
|
|
106
|
+
|
|
107
|
+
## Compatibility Notes
|
|
108
|
+
|
|
109
|
+
| Pattern | Status | Notes |
|
|
110
|
+
|---------|--------|-------|
|
|
111
|
+
| Projects that never set `hub` | Unaffected | Every `/hub` route answers 404 |
|
|
112
|
+
| Custom `EmailService` subclass | Compatible | The mailbox capture hook is injected optionally (`@Optional()`) — absent when the Hub/mailbox is off, so there is zero cost and no behavior change |
|
|
113
|
+
| No auth system enabled | ⚠️ Action required | The Hub relies on the app-wide `RolesGuard`/`BetterAuthRolesGuard` to enforce `@Roles(ADMIN)`. With BetterAuth and legacy auth both disabled, no guard runs and the gate is inert — keep an auth module enabled or leave the Hub disabled |
|
|
114
|
+
| `queries` collector enabled | Compatible | Opts the MongoDB driver into `monitorCommands` from `core.module.ts` when the consumer has not set it |
|
|
115
|
+
|
|
116
|
+
## Troubleshooting
|
|
117
|
+
|
|
118
|
+
| Issue | Fix |
|
|
119
|
+
|-------|-----|
|
|
120
|
+
| `/hub` → 404 everywhere | `hub` is not set in that environment's config block — add `hub: true` (or an options object) |
|
|
121
|
+
| Startup error mentioning mailbox `capture` | `mailbox.mode: 'capture'` is forbidden in `production`/`staging` — use `copy` or disable the mailbox there |
|
|
122
|
+
| Query panel empty | The queries collector is opt-in — set `hub.collectors.queries: true` |
|
|
123
|
+
| Cron panel empty | Import `ScheduleModule.forRoot()` in your ServerModule |
|
|
124
|
+
| Hub reachable without login | You disabled all auth systems, so no roles guard runs — see the Compatibility note above |
|
|
125
|
+
|
|
126
|
+
## Module Documentation
|
|
127
|
+
|
|
128
|
+
- `src/core/modules/hub/README.md` — full panel/action/collector/security reference
|
|
129
|
+
- `src/core/modules/hub/INTEGRATION-CHECKLIST.md` — config-only setup + common mistakes
|
|
130
|
+
- `.claude/rules/configurable-features.md` — Hub row in the configurable-features table
|
|
131
|
+
|
|
132
|
+
## References
|
|
133
|
+
|
|
134
|
+
- Configurable-features pattern: `.claude/rules/configurable-features.md`
|
|
135
|
+
- Request lifecycle (middleware + logger override + email hook): `docs/REQUEST-LIFECYCLE.md`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lenne.tech/nest-server",
|
|
3
|
-
"version": "11.31.
|
|
3
|
+
"version": "11.31.3",
|
|
4
4
|
"description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"node",
|
|
@@ -23,9 +23,9 @@
|
|
|
23
23
|
"build:dev": "pnpm run build",
|
|
24
24
|
"c": "pnpm run check",
|
|
25
25
|
"check": "node scripts/check.mjs",
|
|
26
|
-
"check:raw": "pnpm install --frozen-lockfile && pnpm audit && pnpm run format:check && pnpm run lint && pnpm run check:swc-tdz && pnpm test && pnpm run build && pnpm run check:manifest && bash scripts/check-server-start.sh",
|
|
27
|
-
"check:fix": "pnpm install && pnpm audit --fix && pnpm run format && pnpm run lint:fix && pnpm run check:swc-tdz && pnpm test && pnpm run build && pnpm run check:manifest && bash scripts/check-server-start.sh",
|
|
28
|
-
"check:naf": "pnpm install && pnpm run format && pnpm run lint:fix && pnpm run check:swc-tdz && pnpm test && pnpm run build && pnpm run check:manifest && bash scripts/check-server-start.sh",
|
|
26
|
+
"check:raw": "pnpm install --frozen-lockfile && pnpm audit && pnpm run format:check && pnpm run lint && pnpm run typecheck:tests && pnpm run check:swc-tdz && pnpm test && pnpm run build && pnpm run check:manifest && bash scripts/check-server-start.sh",
|
|
27
|
+
"check:fix": "pnpm install && pnpm audit --fix && pnpm run format && pnpm run lint:fix && pnpm run typecheck:tests && pnpm run check:swc-tdz && pnpm test && pnpm run build && pnpm run check:manifest && bash scripts/check-server-start.sh",
|
|
28
|
+
"check:naf": "pnpm install && pnpm run format && pnpm run lint:fix && pnpm run typecheck:tests && pnpm run check:swc-tdz && pnpm test && pnpm run build && pnpm run check:manifest && bash scripts/check-server-start.sh",
|
|
29
29
|
"check:manifest": "node scripts/check-package-manifest.mjs",
|
|
30
30
|
"check:swc-tdz": "nest build -b swc -p tsconfig.swc-tdz.json && node scripts/check-swc-tdz.mjs",
|
|
31
31
|
"cf": "pnpm run check:fix",
|
|
@@ -65,6 +65,7 @@
|
|
|
65
65
|
"vitest:unit:cov": "vitest run --coverage --config vitest.config.ts",
|
|
66
66
|
"test:unit:watch": "vitest --config vitest.config.ts",
|
|
67
67
|
"test:types": "tsc --noEmit --skipLibCheck -p tests/types/tsconfig.json",
|
|
68
|
+
"typecheck:tests": "tsc --noEmit -p tsconfig.tests.json",
|
|
68
69
|
"test:cleanup": "find tests -type f \\( -name '*.txt' -o -name '*.bin' \\) -not -name '.gitkeep' -delete && echo 'Test artifacts cleaned up'",
|
|
69
70
|
"watch": "npm-watch"
|
|
70
71
|
},
|
package/src/config.env.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { IServerOptions } from './core/common/interfaces/server-options.interfac
|
|
|
20
20
|
// which is purely cosmetic and carries promotional content. Warnings (`⚠`) for genuine
|
|
21
21
|
// misconfiguration still surface.
|
|
22
22
|
dotenv.config({ quiet: true });
|
|
23
|
+
|
|
23
24
|
const config: { [env: string]: IServerOptions } = {
|
|
24
25
|
// ===========================================================================
|
|
25
26
|
// CI environment
|
|
@@ -141,6 +142,10 @@ const config: { [env: string]: IServerOptions } = {
|
|
|
141
142
|
modelDocumentation: false,
|
|
142
143
|
uri: process.env.MONGODB_URI || 'mongodb://127.0.0.1/nest-server-ci',
|
|
143
144
|
},
|
|
145
|
+
hub: {
|
|
146
|
+
collectors: { queries: { warnMs: 1 } },
|
|
147
|
+
mailbox: { mode: 'capture' },
|
|
148
|
+
},
|
|
144
149
|
permissions: true,
|
|
145
150
|
port: Number(process.env.PORT) || 3000,
|
|
146
151
|
security: {
|
|
@@ -260,6 +265,14 @@ const config: { [env: string]: IServerOptions } = {
|
|
|
260
265
|
modelDocumentation: false,
|
|
261
266
|
uri: process.env.MONGODB_URI || 'mongodb://127.0.0.1/nest-server-dev',
|
|
262
267
|
},
|
|
268
|
+
// Hub (operator cockpit) — OPTIONAL and enabled PER ENVIRONMENT (it is never on implicitly).
|
|
269
|
+
// `queries` (query profiler) and `mailbox` are opt-in; the whole thing stays ADMIN-gated.
|
|
270
|
+
// See src/core/modules/hub/README.md. In production it is off by default (see that block for how
|
|
271
|
+
// to enable it safely). Also toggleable via `NSC__HUB__*` env vars (see .env.example).
|
|
272
|
+
hub: {
|
|
273
|
+
collectors: { queries: true },
|
|
274
|
+
mailbox: { mode: 'capture' },
|
|
275
|
+
},
|
|
263
276
|
permissions: true,
|
|
264
277
|
port: Number(process.env.PORT) || 3000,
|
|
265
278
|
security: {
|
|
@@ -405,6 +418,10 @@ const config: { [env: string]: IServerOptions } = {
|
|
|
405
418
|
modelDocumentation: false,
|
|
406
419
|
uri: process.env.MONGODB_URI || 'mongodb://127.0.0.1/nest-server-e2e',
|
|
407
420
|
},
|
|
421
|
+
hub: {
|
|
422
|
+
collectors: { queries: { warnMs: 1 } },
|
|
423
|
+
mailbox: { mode: 'capture' },
|
|
424
|
+
},
|
|
408
425
|
permissions: true,
|
|
409
426
|
port: Number(process.env.PORT) || 3000,
|
|
410
427
|
security: {
|
|
@@ -559,9 +576,14 @@ const config: { [env: string]: IServerOptions } = {
|
|
|
559
576
|
modelDocumentation: true,
|
|
560
577
|
uri: process.env.MONGODB_URI || 'mongodb://127.0.0.1/nest-server-local',
|
|
561
578
|
},
|
|
562
|
-
|
|
563
|
-
|
|
579
|
+
hub: {
|
|
580
|
+
collectors: { queries: true },
|
|
581
|
+
mailbox: { mode: 'capture' },
|
|
564
582
|
},
|
|
583
|
+
// Authored intent: ADMIN-gated (reach it via the Hub's "Routes / Permissions" panel). The
|
|
584
|
+
// permissions-follows-hub invariant is enforced centrally by enforcePermissionsSecurity() below —
|
|
585
|
+
// e.g. if the Hub here is disabled, that pass flips this to a public localhost-only report.
|
|
586
|
+
permissions: true,
|
|
565
587
|
port: Number(process.env.PORT) || 3000,
|
|
566
588
|
security: {
|
|
567
589
|
checkResponseInterceptor: {
|
|
@@ -665,6 +687,18 @@ const config: { [env: string]: IServerOptions } = {
|
|
|
665
687
|
},
|
|
666
688
|
enabled: true,
|
|
667
689
|
},
|
|
690
|
+
// Hub (operator cockpit) is OFF in production by default (never enabled implicitly). To turn it
|
|
691
|
+
// on here — it stays ADMIN-gated — uncomment the block below. Note the production guard-rails:
|
|
692
|
+
// - mailbox: NEVER 'capture' in production/staging (it suppresses real mail; it throws at
|
|
693
|
+
// startup). Use `false`, or `{ mode: 'copy' }` if you want a redacted audit copy.
|
|
694
|
+
// - collectors.queries: opt-in (it opts the MongoDB driver into command monitoring).
|
|
695
|
+
// The standalone permissions report follows the Hub (see enforcePermissionsSecurity below), so
|
|
696
|
+
// enabling the Hub also makes the ADMIN-gated "Routes / Permissions" panel available.
|
|
697
|
+
// See src/core/modules/hub/README.md; also configurable via NSC__HUB__* (see .env.example).
|
|
698
|
+
// hub: {
|
|
699
|
+
// collectors: { queries: false },
|
|
700
|
+
// mailbox: false,
|
|
701
|
+
// },
|
|
668
702
|
ignoreSelectionsForPopulate: true,
|
|
669
703
|
jwt: {
|
|
670
704
|
refresh: {
|
|
@@ -716,6 +750,45 @@ const config: { [env: string]: IServerOptions } = {
|
|
|
716
750
|
},
|
|
717
751
|
};
|
|
718
752
|
|
|
753
|
+
/**
|
|
754
|
+
* Enforce the permissions-report security invariant across every environment.
|
|
755
|
+
*
|
|
756
|
+
* The report is the full authorization map (every route + its required roles + `@Restricted` field
|
|
757
|
+
* rules) — a reconnaissance goldmine. The Hub does NOT need it (its "Routes / Permissions" panel
|
|
758
|
+
* reuses the scanner but degrades gracefully when the module is absent), so the rule is about the
|
|
759
|
+
* DANGEROUS direction: the report must never become a standalone, reachable surface.
|
|
760
|
+
*
|
|
761
|
+
* Derived per environment from THAT env's Hub state:
|
|
762
|
+
* - Hub OFF, non-`local` → report NOT registered (no in-app consumer → pure attack surface). This
|
|
763
|
+
* makes the combination "non-local + Hub off + visible permissions" impossible to configure.
|
|
764
|
+
* - Hub OFF, `local` → public (`role: false`) report allowed — frictionless, safe ONLY because
|
|
765
|
+
* `local` is a non-reachable localhost env with no admin surface.
|
|
766
|
+
* - Hub ON → left as authored (ADMIN when enabled, or off — the panel just degrades),
|
|
767
|
+
* EXCEPT a public (`role: false`) report is forced back to ADMIN in every non-`local` stage.
|
|
768
|
+
*
|
|
769
|
+
* NOTE: runs on the statically-authored config; `NSC__HUB__*` / `NSC__PERMISSIONS__*` env overrides
|
|
770
|
+
* are applied afterwards by getEnvironmentConfig and take precedence.
|
|
771
|
+
*/
|
|
772
|
+
export function enforcePermissionsSecurity(cfg: { [env: string]: IServerOptions }): void {
|
|
773
|
+
for (const [envName, opts] of Object.entries(cfg)) {
|
|
774
|
+
const hub = opts.hub;
|
|
775
|
+
const hubOn =
|
|
776
|
+
hub === true || (typeof hub === 'object' && hub !== null && (hub as { enabled?: boolean }).enabled !== false);
|
|
777
|
+
const perms = opts.permissions;
|
|
778
|
+
const isPublic = typeof perms === 'object' && perms !== null && (perms as { role?: unknown }).role === false;
|
|
779
|
+
|
|
780
|
+
if (!hubOn) {
|
|
781
|
+
// Hub off: only a public localhost report (in `local`) is allowed; never register it elsewhere.
|
|
782
|
+
opts.permissions = envName === 'local' ? { role: false } : false;
|
|
783
|
+
} else if (envName !== 'local' && isPublic) {
|
|
784
|
+
// Reachable stage with the Hub on: the report must never be public — force the ADMIN gate.
|
|
785
|
+
opts.permissions = true;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
enforcePermissionsSecurity(config);
|
|
791
|
+
|
|
719
792
|
/**
|
|
720
793
|
* Export config merged with other configs and environment variables as default
|
|
721
794
|
*/
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { redactSensitiveText } from './logging.helper';
|
|
4
|
+
|
|
5
|
+
describe('redactSensitiveText', () => {
|
|
6
|
+
it('redacts JWTs appearing anywhere in a line', () => {
|
|
7
|
+
const jwt = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U';
|
|
8
|
+
const out = redactSensitiveText(`token issued: ${jwt} done`);
|
|
9
|
+
|
|
10
|
+
expect(out).not.toContain(jwt);
|
|
11
|
+
expect(out).toContain('token issued:');
|
|
12
|
+
expect(out).toContain('done');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('redacts Authorization Bearer values', () => {
|
|
16
|
+
const out = redactSensitiveText('Authorization: Bearer abcdef1234567890secret');
|
|
17
|
+
|
|
18
|
+
expect(out).not.toContain('abcdef1234567890secret');
|
|
19
|
+
expect(out.toLowerCase()).toContain('authorization');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('redacts key/value secrets regardless of separator or quoting', () => {
|
|
23
|
+
expect(redactSensitiveText('password=SuperSecret123')).not.toContain('SuperSecret123');
|
|
24
|
+
expect(redactSensitiveText('token: "abcd1234efgh"')).not.toContain('abcd1234efgh');
|
|
25
|
+
expect(redactSensitiveText("apiKey='pk_live_9999'")).not.toContain('pk_live_9999');
|
|
26
|
+
expect(redactSensitiveText('client_secret=verySecretValue')).not.toContain('verySecretValue');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('redacts cookie headers', () => {
|
|
30
|
+
const out = redactSensitiveText('cookie: session=abc123; other=xyz789');
|
|
31
|
+
|
|
32
|
+
expect(out).not.toContain('abc123');
|
|
33
|
+
expect(out).not.toContain('xyz789');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('redacts reset/verification tokens carried as a URL PATH segment (not key=value)', () => {
|
|
37
|
+
const link = 'https://app.example.com/verify/AbCdEf0123456789XyZtoken please click';
|
|
38
|
+
const out = redactSensitiveText(link);
|
|
39
|
+
expect(out).not.toContain('AbCdEf0123456789XyZtoken');
|
|
40
|
+
expect(out).toContain('/verify/');
|
|
41
|
+
// also for reset / set-password style links
|
|
42
|
+
expect(redactSensitiveText('/reset/SUPERSECRETVALUE1234567890')).not.toContain('SUPERSECRETVALUE1234567890');
|
|
43
|
+
expect(redactSensitiveText('/set-password/ZZZ1112223334445556667')).not.toContain('ZZZ1112223334445556667');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('leaves harmless short path segments untouched', () => {
|
|
47
|
+
// Only long token-like segments (>= 16 chars) are masked — normal routes stay readable.
|
|
48
|
+
const text = 'GET /verify/email done';
|
|
49
|
+
expect(redactSensitiveText(text)).toBe(text);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('leaves harmless text untouched', () => {
|
|
53
|
+
const text = 'User signed in successfully with id 507f1f77bcf86cd799439011';
|
|
54
|
+
expect(redactSensitiveText(text)).toBe(text);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('handles empty / non-secret input without throwing', () => {
|
|
58
|
+
expect(redactSensitiveText('')).toBe('');
|
|
59
|
+
expect(redactSensitiveText('plain message')).toBe('plain message');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -132,3 +132,51 @@ export function maskToken(token: null | string | undefined): string {
|
|
|
132
132
|
}
|
|
133
133
|
return `${token.substring(0, 4)}...${token.substring(token.length - 4)}`;
|
|
134
134
|
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Redacts secrets from a free-form log line.
|
|
138
|
+
*
|
|
139
|
+
* Composes the individual maskers above into a single regex-based pass for text where the sensitive
|
|
140
|
+
* value is not at a known position (log messages, error strings). Applied by the Hub log collector
|
|
141
|
+
* before truncation, so a truncation cut can never leak the prefix of a secret.
|
|
142
|
+
*
|
|
143
|
+
* Covers: JWTs anywhere in the line, `Authorization: Bearer …`, common `key=value` secrets
|
|
144
|
+
* (password/token/secret/apiKey/client_secret/refresh_token/…), and cookie headers.
|
|
145
|
+
*
|
|
146
|
+
* @param text - The log line to redact
|
|
147
|
+
* @returns The line with secret values replaced by masked placeholders
|
|
148
|
+
*
|
|
149
|
+
* @example
|
|
150
|
+
* redactSensitiveText('password=hunter2') // 'password=***'
|
|
151
|
+
*/
|
|
152
|
+
export function redactSensitiveText(text: string): string {
|
|
153
|
+
if (!text) {
|
|
154
|
+
return text;
|
|
155
|
+
}
|
|
156
|
+
return (
|
|
157
|
+
text
|
|
158
|
+
// JWTs anywhere in the line
|
|
159
|
+
.replace(/eyJ[\w-]{6,}\.[\w-]{6,}\.[\w-]{4,}/g, (match) => maskToken(match))
|
|
160
|
+
// reset/verification/invite tokens carried as a URL PATH segment (e.g. /verify/<token>,
|
|
161
|
+
// /reset/<token>) — these have no `key=value`, so the query-style rule below misses them.
|
|
162
|
+
.replace(
|
|
163
|
+
/(\/(?:verify|reset|confirm|activate|invite|magic-?link|set-password|change-email)\/)([A-Za-z0-9._~-]{16,})/gi,
|
|
164
|
+
(_m, prefix, token) => `${prefix}${maskToken(token)}`,
|
|
165
|
+
)
|
|
166
|
+
// authorization: Bearer xyz / Authorization=xyz
|
|
167
|
+
.replace(
|
|
168
|
+
/(authorization["']?\s*[:=]\s*["']?)(?:Bearer\s+)?([^\s"',;]+)/gi,
|
|
169
|
+
(_m, prefix, value) => `${prefix}${maskToken(value)}`,
|
|
170
|
+
)
|
|
171
|
+
// cookie headers (mask every value, keep names)
|
|
172
|
+
.replace(
|
|
173
|
+
/((?:^|[\s,;])cookie["']?\s*[:=]\s*)([^\n]+)/gi,
|
|
174
|
+
(_m, prefix, value) => `${prefix}${maskCookieHeader(value)}`,
|
|
175
|
+
)
|
|
176
|
+
// key/value secrets: password=..., token: "...", apiKey=..., secret=..., client_secret=..., refresh_token=...
|
|
177
|
+
.replace(
|
|
178
|
+
/((?:password|passwd|pwd|token|secret|api[-_]?key|client[-_]?secret|refresh[-_]?token|access[-_]?token|private[-_]?key)["']?\s*[:=]\s*["']?)([^\s"',;&}]+)/gi,
|
|
179
|
+
(_m, prefix, value) => `${prefix}${maskSensitive(value)}`,
|
|
180
|
+
)
|
|
181
|
+
);
|
|
182
|
+
}
|
|
@@ -15,12 +15,18 @@
|
|
|
15
15
|
* @see getBuildInfo
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
import { existsSync, readFileSync } from 'fs';
|
|
19
|
+
import { join } from 'path';
|
|
20
|
+
|
|
18
21
|
/** Default environment variable the build commit SHA is baked into. */
|
|
19
22
|
export const DEFAULT_COMMIT_ENV = 'APP_VERSION_COMMIT';
|
|
20
23
|
|
|
21
24
|
/** Defined value returned when no commit could be resolved. */
|
|
22
25
|
export const UNKNOWN_COMMIT = 'unknown';
|
|
23
26
|
|
|
27
|
+
/** Cached package.json version lookup (`null` = looked up and not found). */
|
|
28
|
+
let cachedVersion: null | string | undefined;
|
|
29
|
+
|
|
24
30
|
/**
|
|
25
31
|
* Build identity of the running process.
|
|
26
32
|
*/
|
|
@@ -64,6 +70,45 @@ export function getBuildInfo(options: { commitEnvName?: string; env?: string; ve
|
|
|
64
70
|
return {
|
|
65
71
|
commit: getCommit(options.commitEnvName),
|
|
66
72
|
env: options.env,
|
|
67
|
-
version: options.version ||
|
|
73
|
+
version: options.version || getVersion(),
|
|
68
74
|
};
|
|
69
75
|
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Resolve the semantic version of the running app from its `package.json`.
|
|
79
|
+
*
|
|
80
|
+
* Reads the `version` field of the nearest `package.json` (searching up from the current working
|
|
81
|
+
* directory), cached after the first lookup. This is the deployed app's own version — in a consumer
|
|
82
|
+
* project that is the API's version, in this repo it is the framework version. Falls back to
|
|
83
|
+
* `'unknown'` when no `package.json` / version can be resolved.
|
|
84
|
+
*
|
|
85
|
+
* Prefer an explicit `IServerOptions.version` when set; this is the zero-config default.
|
|
86
|
+
*/
|
|
87
|
+
export function getVersion(): string {
|
|
88
|
+
if (cachedVersion !== undefined) {
|
|
89
|
+
return cachedVersion ?? UNKNOWN_COMMIT;
|
|
90
|
+
}
|
|
91
|
+
cachedVersion = null;
|
|
92
|
+
try {
|
|
93
|
+
// Walk up from cwd so a monorepo `projects/api` started from the repo root still resolves.
|
|
94
|
+
let dir = process.cwd();
|
|
95
|
+
for (let i = 0; i < 6; i++) {
|
|
96
|
+
const pkgPath = join(dir, 'package.json');
|
|
97
|
+
if (existsSync(pkgPath)) {
|
|
98
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { version?: string };
|
|
99
|
+
if (typeof pkg.version === 'string' && pkg.version) {
|
|
100
|
+
cachedVersion = pkg.version;
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const parent = join(dir, '..');
|
|
105
|
+
if (parent === dir) {
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
dir = parent;
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
/* ignore — fall back to 'unknown' */
|
|
112
|
+
}
|
|
113
|
+
return cachedVersion ?? UNKNOWN_COMMIT;
|
|
114
|
+
}
|
|
@@ -18,6 +18,7 @@ import * as SMTPTransport from 'nodemailer/lib/smtp-transport';
|
|
|
18
18
|
import type * as StreamTransport from 'nodemailer/lib/stream-transport';
|
|
19
19
|
|
|
20
20
|
import { Falsy } from '../types/falsy.type';
|
|
21
|
+
import { IHubConfig } from '../../modules/hub/interfaces/hub-config.interface';
|
|
21
22
|
import { IPermissions } from '../../modules/permissions/interfaces/permissions.interface';
|
|
22
23
|
import { CronJobConfigWithTimeZone } from './cron-job-config-with-time-zone.interface';
|
|
23
24
|
import { CronJobConfigWithUtcOffset } from './cron-job-config-with-utc-offset.interface';
|
|
@@ -1847,6 +1848,24 @@ export interface IServerOptions {
|
|
|
1847
1848
|
*/
|
|
1848
1849
|
hostname?: string;
|
|
1849
1850
|
|
|
1851
|
+
/**
|
|
1852
|
+
* Hub admin area (operator cockpit).
|
|
1853
|
+
*
|
|
1854
|
+
* A build-free, ADMIN-gated dashboard of runtime information and admin tools (dashboard,
|
|
1855
|
+
* diagnostics, logs, request traces, query profiler, migrations, DB stats, models/ERD, files,
|
|
1856
|
+
* cron jobs, error codes, masked config, auth-migration progress, email preview, built-in mailbox,
|
|
1857
|
+
* AI panel).
|
|
1858
|
+
*
|
|
1859
|
+
* Follows "presence implies enabled" — but is NEVER enabled implicitly. It must be switched on per
|
|
1860
|
+
* environment (here or via `NSC__HUB__*`); no environment inherits it.
|
|
1861
|
+
* - `true` / `{}`: enabled at `/hub`, admin-only, default collectors (logs + traces on, queries off)
|
|
1862
|
+
* - `{ path: 'admin/hub', collectors: { queries: true } }`: custom path + query profiling
|
|
1863
|
+
* - `{ enabled: false }` / `undefined`: disabled — every Hub route answers 404
|
|
1864
|
+
*
|
|
1865
|
+
* @default undefined (disabled)
|
|
1866
|
+
*/
|
|
1867
|
+
hub?: boolean | IHubConfig;
|
|
1868
|
+
|
|
1850
1869
|
/**
|
|
1851
1870
|
* Ignore selections in fieldSelection
|
|
1852
1871
|
* [ConfigService must be integrated in ModuleService]
|
|
@@ -3312,4 +3331,31 @@ export interface ICoreModuleOverrides {
|
|
|
3312
3331
|
controller?: Type<any>;
|
|
3313
3332
|
service?: Type<any>;
|
|
3314
3333
|
};
|
|
3334
|
+
|
|
3335
|
+
/**
|
|
3336
|
+
* Override Hub controllers and/or services with project-specific subclasses.
|
|
3337
|
+
*
|
|
3338
|
+
* - `actionsController` must extend `CoreHubActionsController`
|
|
3339
|
+
* - `actionsService` must extend `CoreHubActionsService`
|
|
3340
|
+
* - `controller` must extend `CoreHubController`
|
|
3341
|
+
* - `htmlService` must extend `CoreHubHtmlService`
|
|
3342
|
+
* - `service` must extend `CoreHubService`
|
|
3343
|
+
*
|
|
3344
|
+
* @example
|
|
3345
|
+
* ```typescript
|
|
3346
|
+
* {
|
|
3347
|
+
* hub: {
|
|
3348
|
+
* service: MyHubService,
|
|
3349
|
+
* htmlService: MyHubHtmlService,
|
|
3350
|
+
* },
|
|
3351
|
+
* }
|
|
3352
|
+
* ```
|
|
3353
|
+
*/
|
|
3354
|
+
hub?: {
|
|
3355
|
+
actionsController?: Type<any>;
|
|
3356
|
+
actionsService?: Type<any>;
|
|
3357
|
+
controller?: Type<any>;
|
|
3358
|
+
htmlService?: Type<any>;
|
|
3359
|
+
service?: Type<any>;
|
|
3360
|
+
};
|
|
3315
3361
|
}
|
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
51
|
+
res(this.templates[filePath]);
|
|
43
52
|
return;
|
|
44
53
|
}
|
|
45
54
|
|
|
46
55
|
// Get template file
|
|
47
|
-
fs.readFile(
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
}
|