@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.
- package/.claude/rules/architecture.md +2 -0
- package/.claude/rules/configurable-features.md +2 -0
- package/.claude/rules/versioning.md +5 -8
- package/CLAUDE.md +31 -4
- package/FRAMEWORK-API.md +7 -2
- package/README.md +1 -0
- 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/helpers/process-diagnostics.helper.d.ts +18 -0
- package/dist/core/common/helpers/process-diagnostics.helper.js +88 -0
- package/dist/core/common/helpers/process-diagnostics.helper.js.map +1 -0
- package/dist/core/common/interfaces/server-options.interface.d.ts +12 -0
- package/dist/core/common/services/brevo.service.d.ts +7 -1
- package/dist/core/common/services/brevo.service.js +37 -16
- package/dist/core/common/services/brevo.service.js.map +1 -1
- 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/better-auth/core-better-auth-email-verification.service.js +9 -4
- package/dist/core/modules/better-auth/core-better-auth-email-verification.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 +2 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/main.js +5 -2
- package/dist/main.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +2 -0
- package/docs/brevo-manual-test.md +166 -0
- package/docs/security-overrides.md +90 -0
- package/migration-guides/11.31.2-to-11.31.3.md +135 -0
- package/migration-guides/11.31.3-to-11.32.0.md +254 -0
- package/package.json +16 -14
- 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/helpers/process-diagnostics.helper.spec.ts +310 -0
- package/src/core/common/helpers/process-diagnostics.helper.ts +321 -0
- package/src/core/common/interfaces/server-options.interface.ts +78 -0
- package/src/core/common/services/brevo.service.spec.ts +266 -0
- package/src/core/common/services/brevo.service.ts +100 -17
- package/src/core/common/services/email.service.ts +33 -1
- package/src/core/common/services/template.service.ts +21 -16
- package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +14 -4
- 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 +7 -0
- package/src/main.ts +22 -3
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
|
|
2
|
+
import { InjectConnection } from '@nestjs/mongoose';
|
|
3
|
+
import * as mongo from 'mongodb';
|
|
4
|
+
import { Connection, Types } from 'mongoose';
|
|
5
|
+
|
|
6
|
+
import { ModelRegistry } from '../../../common/services/model-registry.service';
|
|
7
|
+
import { HubActionMessage } from '../hub-action-messages';
|
|
8
|
+
import { HUB_CONFIG } from '../hub.constants';
|
|
9
|
+
import { buildErDiagram, HubModelDescriptor, HubModelField } from '../helpers/hub-mermaid.helper';
|
|
10
|
+
import { HubDbData, HubFilesData, HubModelsData, HubUnavailable } from '../interfaces/hub-panels.interface';
|
|
11
|
+
import { ResolvedHubConfig } from '../interfaces/hub-config.interface';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Read-only MongoDB introspection for the DB, Models/ERD and Files panels.
|
|
15
|
+
*
|
|
16
|
+
* Native driver access (`connection.db.*`) is used deliberately and only for admin read-only
|
|
17
|
+
* operations (stats, listings) — allowed per docs/native-driver-security.md. No writes here.
|
|
18
|
+
*/
|
|
19
|
+
@Injectable()
|
|
20
|
+
export class CoreHubDbService {
|
|
21
|
+
protected readonly logger = new Logger(CoreHubDbService.name);
|
|
22
|
+
|
|
23
|
+
constructor(
|
|
24
|
+
@Inject(HUB_CONFIG) protected readonly config: ResolvedHubConfig,
|
|
25
|
+
@Optional() @InjectConnection() protected readonly connection?: Connection,
|
|
26
|
+
) {}
|
|
27
|
+
|
|
28
|
+
/** Database + per-collection statistics. */
|
|
29
|
+
async getDbStats(): Promise<HubDbData | HubUnavailable> {
|
|
30
|
+
const dbConfig = this.config.db;
|
|
31
|
+
if (dbConfig === false) {
|
|
32
|
+
return { available: false, hint: 'The database panel is disabled (hub.db: false).' };
|
|
33
|
+
}
|
|
34
|
+
const db = this.connection?.db;
|
|
35
|
+
if (!db) {
|
|
36
|
+
return { available: false, hint: 'No MongoDB connection is available.' };
|
|
37
|
+
}
|
|
38
|
+
const includeIndexes = dbConfig.includeIndexes;
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const dbStats = await db.stats();
|
|
42
|
+
const collectionInfos = await db.listCollections({}, { nameOnly: true }).toArray();
|
|
43
|
+
|
|
44
|
+
// Read every collection's storage stats concurrently — sequential awaits made the DB panel's
|
|
45
|
+
// latency scale linearly with the collection count.
|
|
46
|
+
const collections = await Promise.all(
|
|
47
|
+
collectionInfos.map(async (info) => {
|
|
48
|
+
try {
|
|
49
|
+
const [stats] = await db
|
|
50
|
+
.collection(info.name)
|
|
51
|
+
.aggregate([{ $collStats: { storageStats: {} } }])
|
|
52
|
+
.toArray();
|
|
53
|
+
const storage = stats?.storageStats ?? {};
|
|
54
|
+
return {
|
|
55
|
+
avgObjSize: storage.avgObjSize,
|
|
56
|
+
count: storage.count ?? 0,
|
|
57
|
+
indexCount: includeIndexes ? storage.nindexes : undefined,
|
|
58
|
+
indexSize: includeIndexes ? storage.totalIndexSize : undefined,
|
|
59
|
+
name: info.name,
|
|
60
|
+
size: storage.size ?? 0,
|
|
61
|
+
storageSize: storage.storageSize ?? 0,
|
|
62
|
+
};
|
|
63
|
+
} catch {
|
|
64
|
+
return { count: 0, name: info.name, size: 0, storageSize: 0 };
|
|
65
|
+
}
|
|
66
|
+
}),
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
collections.sort((a, b) => b.size - a.size);
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
collections,
|
|
73
|
+
stats: {
|
|
74
|
+
collections: dbStats.collections ?? collections.length,
|
|
75
|
+
dataSize: dbStats.dataSize ?? 0,
|
|
76
|
+
indexSize: dbStats.indexSize ?? 0,
|
|
77
|
+
objects: dbStats.objects ?? 0,
|
|
78
|
+
storageSize: dbStats.storageSize ?? 0,
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
} catch (error) {
|
|
82
|
+
this.logger.warn(`Failed to read DB stats: ${error instanceof Error ? error.message : String(error)}`);
|
|
83
|
+
return { available: false, hint: 'Failed to read database statistics.' };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Delete a GridFS file by id. When `expectedFilename` is given it must match the stored filename
|
|
89
|
+
* (the type-to-confirm keyword). Returns the filename; throws when not found or the name mismatches.
|
|
90
|
+
*/
|
|
91
|
+
async deleteFile(id: string, expectedFilename?: string, bucket = 'fs'): Promise<{ filename: string; id: string }> {
|
|
92
|
+
const db = this.connection?.db;
|
|
93
|
+
if (!db) {
|
|
94
|
+
throw new Error(HubActionMessage.mongoUnavailable);
|
|
95
|
+
}
|
|
96
|
+
if (!Types.ObjectId.isValid(id)) {
|
|
97
|
+
throw new Error(HubActionMessage.invalidFileId);
|
|
98
|
+
}
|
|
99
|
+
const objectId = new Types.ObjectId(id);
|
|
100
|
+
const [doc] = await db.collection(`${bucket}.files`).find({ _id: objectId }, { limit: 1 }).toArray();
|
|
101
|
+
if (!doc) {
|
|
102
|
+
throw new Error(HubActionMessage.fileNotFound);
|
|
103
|
+
}
|
|
104
|
+
if (expectedFilename !== undefined && expectedFilename !== doc.filename) {
|
|
105
|
+
throw new Error(HubActionMessage.confirmationFilenameMismatch);
|
|
106
|
+
}
|
|
107
|
+
const gridFs = new mongo.GridFSBucket(db as unknown as mongo.Db, { bucketName: bucket });
|
|
108
|
+
await gridFs.delete(objectId as unknown as mongo.ObjectId);
|
|
109
|
+
return { filename: doc.filename, id };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** GridFS file inventory for the given bucket (default `fs`). */
|
|
113
|
+
async getFiles(bucket = 'fs', skip = 0, limit = 100): Promise<HubFilesData | HubUnavailable> {
|
|
114
|
+
const db = this.connection?.db;
|
|
115
|
+
if (!db) {
|
|
116
|
+
return { available: false, hint: 'No MongoDB connection is available.' };
|
|
117
|
+
}
|
|
118
|
+
const filesCollection = `${bucket}.files`;
|
|
119
|
+
try {
|
|
120
|
+
const cursor = db
|
|
121
|
+
.collection(filesCollection)
|
|
122
|
+
.find({}, { limit: Math.min(limit, 500), skip, sort: { uploadDate: -1 } });
|
|
123
|
+
const docs = await cursor.toArray();
|
|
124
|
+
const total = await db.collection(filesCollection).countDocuments();
|
|
125
|
+
return {
|
|
126
|
+
bucket,
|
|
127
|
+
files: docs.map((doc) => ({
|
|
128
|
+
contentType: doc.contentType ?? doc.metadata?.contentType,
|
|
129
|
+
filename: doc.filename,
|
|
130
|
+
id: String(doc._id),
|
|
131
|
+
length: doc.length ?? 0,
|
|
132
|
+
uploadDate: doc.uploadDate ? new Date(doc.uploadDate).toISOString() : undefined,
|
|
133
|
+
})),
|
|
134
|
+
total,
|
|
135
|
+
};
|
|
136
|
+
} catch (error) {
|
|
137
|
+
this.logger.warn(`Failed to list GridFS files: ${error instanceof Error ? error.message : String(error)}`);
|
|
138
|
+
return { available: false, hint: `No GridFS bucket "${bucket}" or it is empty.` };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Model inventory + a Mermaid ER diagram derived from the registered Mongoose schemas. */
|
|
143
|
+
getModels(): HubModelsData {
|
|
144
|
+
const descriptors = this.collectModelDescriptors();
|
|
145
|
+
const mermaid = buildErDiagram(descriptors);
|
|
146
|
+
const relationCount = descriptors.reduce((sum, m) => sum + m.fields.filter((f) => f.ref).length, 0);
|
|
147
|
+
return {
|
|
148
|
+
entities: descriptors.map((d) => ({ fields: d.fields, name: d.name })),
|
|
149
|
+
mermaid,
|
|
150
|
+
modelCount: descriptors.length,
|
|
151
|
+
relationCount,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Walk the live Mongoose connection's schemas into value-free descriptors for the ERD builder. */
|
|
156
|
+
protected collectModelDescriptors(): HubModelDescriptor[] {
|
|
157
|
+
const models = this.connection?.models ?? {};
|
|
158
|
+
const registered = ModelRegistry.getAll();
|
|
159
|
+
const names = new Set<string>([...Object.keys(models), ...registered.keys()]);
|
|
160
|
+
const descriptors: HubModelDescriptor[] = [];
|
|
161
|
+
|
|
162
|
+
for (const name of names) {
|
|
163
|
+
const schema = models[name]?.schema;
|
|
164
|
+
if (!schema) {
|
|
165
|
+
descriptors.push({ fields: [], name });
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const fields: HubModelField[] = [];
|
|
169
|
+
schema.eachPath((pathName: string, schemaType: any) => {
|
|
170
|
+
if (pathName === '__v') {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
fields.push({
|
|
174
|
+
name: pathName,
|
|
175
|
+
ref: schemaType?.options?.ref ?? schemaType?.caster?.options?.ref,
|
|
176
|
+
type: schemaType?.instance ?? 'Mixed',
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
descriptors.push({ fields, name });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
descriptors.sort((a, b) => a.name.localeCompare(b.name));
|
|
183
|
+
return descriptors;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
|
|
2
|
+
import { existsSync, readdirSync } from 'fs';
|
|
3
|
+
import { join, resolve } from 'path';
|
|
4
|
+
|
|
5
|
+
import { ConfigService } from '../../../common/services/config.service';
|
|
6
|
+
import { TemplateService } from '../../../common/services/template.service';
|
|
7
|
+
import { HUB_CONFIG } from '../hub.constants';
|
|
8
|
+
import { HubEmailsData, HubUnavailable } from '../interfaces/hub-panels.interface';
|
|
9
|
+
import { ResolvedHubConfig } from '../interfaces/hub-config.interface';
|
|
10
|
+
|
|
11
|
+
/** Sample data used to render an email template preview. Values are illustrative, never real user data. */
|
|
12
|
+
const SAMPLE_PAYLOAD: Record<string, unknown> = {
|
|
13
|
+
appName: 'Nest Server',
|
|
14
|
+
email: 'jane.doe@example.com',
|
|
15
|
+
firstName: 'Jane',
|
|
16
|
+
lastName: 'Doe',
|
|
17
|
+
link: 'https://example.com/verify?token=SAMPLE',
|
|
18
|
+
name: 'Jane Doe',
|
|
19
|
+
token: 'SAMPLE-TOKEN',
|
|
20
|
+
url: 'https://example.com/verify?token=SAMPLE',
|
|
21
|
+
username: 'jane.doe',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Enumerates and previews EJS email templates for the Email Preview panel.
|
|
26
|
+
*
|
|
27
|
+
* Templates come from the project templates dir (`config.templates.path`, what `TemplateService`
|
|
28
|
+
* actually renders) and the framework fallback dir; both are scanned so a project sees the full set.
|
|
29
|
+
* Preview is rendered server-side with illustrative sample data.
|
|
30
|
+
*/
|
|
31
|
+
@Injectable()
|
|
32
|
+
export class CoreHubEmailService {
|
|
33
|
+
protected readonly logger = new Logger(CoreHubEmailService.name);
|
|
34
|
+
|
|
35
|
+
constructor(
|
|
36
|
+
@Inject(HUB_CONFIG) protected readonly config: ResolvedHubConfig,
|
|
37
|
+
protected readonly configService: ConfigService,
|
|
38
|
+
@Optional() protected readonly templateService?: TemplateService,
|
|
39
|
+
) {}
|
|
40
|
+
|
|
41
|
+
/** Whether the email preview panel is enabled (`hub.emailPreview`). */
|
|
42
|
+
get previewEnabled(): boolean {
|
|
43
|
+
return this.config.emailPreview !== false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Inventory of available templates, grouped by base name with detected locale variants. */
|
|
47
|
+
getTemplates(): HubEmailsData {
|
|
48
|
+
const projectDir = this.projectTemplatesDir();
|
|
49
|
+
const frameworkDir = this.frameworkTemplatesDir();
|
|
50
|
+
|
|
51
|
+
const grouped = new Map<string, { locales: Set<string>; source: 'framework' | 'project' }>();
|
|
52
|
+
const add = (dir: string, source: 'framework' | 'project'): void => {
|
|
53
|
+
for (const file of this.listEjs(dir)) {
|
|
54
|
+
const { base, locale } = this.parseName(file);
|
|
55
|
+
const entry = grouped.get(base) ?? { locales: new Set<string>(), source };
|
|
56
|
+
if (locale) {
|
|
57
|
+
entry.locales.add(locale);
|
|
58
|
+
}
|
|
59
|
+
// Project templates win the source label (they override framework fallbacks).
|
|
60
|
+
if (source === 'project') {
|
|
61
|
+
entry.source = 'project';
|
|
62
|
+
}
|
|
63
|
+
grouped.set(base, entry);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
add(frameworkDir, 'framework');
|
|
67
|
+
if (projectDir && projectDir !== frameworkDir) {
|
|
68
|
+
add(projectDir, 'project');
|
|
69
|
+
} else if (projectDir === frameworkDir) {
|
|
70
|
+
// Framework acts as its own project (e.g. this repo's e2e run) — label as project.
|
|
71
|
+
for (const [, entry] of grouped) {
|
|
72
|
+
entry.source = 'project';
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
templates: [...grouped.entries()]
|
|
78
|
+
.map(([name, e]) => ({ locales: [...e.locales].sort(), name, source: e.source }))
|
|
79
|
+
.sort((a, b) => a.name.localeCompare(b.name)),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Render a template preview with sample data. The name is validated against the inventory (no traversal). */
|
|
84
|
+
async renderPreview(templateName: string, locale?: string): Promise<HubUnavailable | { html: string }> {
|
|
85
|
+
if (!this.previewEnabled) {
|
|
86
|
+
return { available: false, hint: 'The email preview panel is disabled (hub.emailPreview: false).' };
|
|
87
|
+
}
|
|
88
|
+
if (!this.templateService) {
|
|
89
|
+
return { available: false, hint: 'TemplateService is not available.' };
|
|
90
|
+
}
|
|
91
|
+
const inventory = this.getTemplates().templates;
|
|
92
|
+
const match = inventory.find((t) => t.name === templateName);
|
|
93
|
+
if (!match) {
|
|
94
|
+
return { available: false, hint: `Unknown template "${templateName}".` };
|
|
95
|
+
}
|
|
96
|
+
// Resolve the actual file name: prefer the requested locale variant, then the base file, then
|
|
97
|
+
// fall back to the first available locale variant.
|
|
98
|
+
let fileName = templateName;
|
|
99
|
+
if (locale && match.locales.includes(locale)) {
|
|
100
|
+
fileName = `${templateName}-${locale}`;
|
|
101
|
+
} else if (
|
|
102
|
+
!existsSyncTemplate(this.projectTemplatesDir(), this.frameworkTemplatesDir(), templateName) &&
|
|
103
|
+
match.locales.length
|
|
104
|
+
) {
|
|
105
|
+
fileName = `${templateName}-${match.locales[0]}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
const html = await this.templateService.renderTemplate(fileName, SAMPLE_PAYLOAD);
|
|
110
|
+
return { html };
|
|
111
|
+
} catch (error) {
|
|
112
|
+
this.logger.warn(
|
|
113
|
+
`Failed to render template "${fileName}": ${error instanceof Error ? error.message : String(error)}`,
|
|
114
|
+
);
|
|
115
|
+
return { available: false, hint: `Failed to render template "${templateName}".` };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
protected frameworkTemplatesDir(): string {
|
|
120
|
+
// From src/core/modules/hub/services → src/templates (same relative offset in dist/).
|
|
121
|
+
return resolve(__dirname, '../../../../templates');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
protected listEjs(dir: string): string[] {
|
|
125
|
+
if (!dir || !existsSync(dir)) {
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
return readdirSync(dir)
|
|
130
|
+
.filter((f) => f.endsWith('.ejs'))
|
|
131
|
+
.map((f) => f.slice(0, -4));
|
|
132
|
+
} catch {
|
|
133
|
+
return [];
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Split `email-verification-de` → `{ base: 'email-verification', locale: 'de' }`. */
|
|
138
|
+
protected parseName(file: string): { base: string; locale?: string } {
|
|
139
|
+
const match = /^(.*)-([a-z]{2})$/.exec(file);
|
|
140
|
+
if (match) {
|
|
141
|
+
return { base: match[1], locale: match[2] };
|
|
142
|
+
}
|
|
143
|
+
return { base: file };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
protected projectTemplatesDir(): string {
|
|
147
|
+
const path = this.configService.getFastButReadOnly('templates.path');
|
|
148
|
+
return typeof path === 'string' ? path : '';
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Does a non-locale-suffixed template file exist in either dir? */
|
|
153
|
+
function existsSyncTemplate(projectDir: string, frameworkDir: string, name: string): boolean {
|
|
154
|
+
return (
|
|
155
|
+
(projectDir && existsSync(join(projectDir, `${name}.ejs`))) ||
|
|
156
|
+
(frameworkDir && existsSync(join(frameworkDir, `${name}.ejs`)))
|
|
157
|
+
);
|
|
158
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { CoreHubMailboxService } from './core-hub-mailbox.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: { logs: false, queries: false, traces: false },
|
|
11
|
+
db: false,
|
|
12
|
+
emailPreview: true,
|
|
13
|
+
env: 'local',
|
|
14
|
+
links: {},
|
|
15
|
+
loginEndpoint: '/iam/sign-in/email',
|
|
16
|
+
logoutEndpoint: '/iam/sign-out',
|
|
17
|
+
mailbox: { capacity: 3, maxMailSize: 100, mode: 'capture' },
|
|
18
|
+
migrations: false,
|
|
19
|
+
path: 'hub',
|
|
20
|
+
pollIntervalMs: 5000,
|
|
21
|
+
roles: ['admin'],
|
|
22
|
+
version: '1.0.0',
|
|
23
|
+
...overrides,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('CoreHubMailboxService', () => {
|
|
28
|
+
it('captures a mail and returns true to skip transport in capture mode', () => {
|
|
29
|
+
const service = new CoreHubMailboxService(makeConfig());
|
|
30
|
+
const skip = service.capture({ html: '<b>hi</b>', subject: 'Hello', to: 'a@b.com' });
|
|
31
|
+
|
|
32
|
+
expect(skip).toBe(true);
|
|
33
|
+
const data = service.getMailbox();
|
|
34
|
+
expect(data.mode).toBe('capture');
|
|
35
|
+
expect(data.mails).toHaveLength(1);
|
|
36
|
+
expect(data.mails[0].subject).toBe('Hello');
|
|
37
|
+
expect(data.mails[0].to).toBe('a@b.com');
|
|
38
|
+
expect(data.mails[0].hasHtml).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('returns false in copy mode (mail is still sent)', () => {
|
|
42
|
+
const service = new CoreHubMailboxService(makeConfig({ mailbox: { capacity: 3, maxMailSize: 100, mode: 'copy' } }));
|
|
43
|
+
const skip = service.capture({ subject: 'Copy', to: 'a@b.com' });
|
|
44
|
+
|
|
45
|
+
expect(skip).toBe(false);
|
|
46
|
+
expect(service.getMailbox().mails).toHaveLength(1);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('redacts tokens from the stored body in COPY mode (the real mail was still delivered)', () => {
|
|
50
|
+
const service = new CoreHubMailboxService(
|
|
51
|
+
makeConfig({ mailbox: { capacity: 3, maxMailSize: 5000, mode: 'copy' } }),
|
|
52
|
+
);
|
|
53
|
+
const seq = service.captureAndGetSeq({
|
|
54
|
+
html: '<a href="https://app.example.com/reset?token=SUPERSECRETVALUE123">Reset</a>',
|
|
55
|
+
subject: 'Reset',
|
|
56
|
+
to: 'a@b.com',
|
|
57
|
+
});
|
|
58
|
+
const html = service.getMailHtml(seq);
|
|
59
|
+
expect(html).toBeDefined();
|
|
60
|
+
// The captured audit copy must not expose a replayable reset token.
|
|
61
|
+
expect(html).not.toContain('SUPERSECRETVALUE123');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('keeps the raw body in CAPTURE mode (dev/test needs the clickable link; nothing was sent)', () => {
|
|
65
|
+
const service = new CoreHubMailboxService(makeConfig());
|
|
66
|
+
const seq = service.captureAndGetSeq({
|
|
67
|
+
html: '<a href="https://app.example.com/reset?token=SUPERSECRETVALUE123">Reset</a>',
|
|
68
|
+
subject: 'Reset',
|
|
69
|
+
to: 'a@b.com',
|
|
70
|
+
});
|
|
71
|
+
expect(service.getMailHtml(seq)).toContain('SUPERSECRETVALUE123');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('evicts old mails beyond capacity (ring buffer)', () => {
|
|
75
|
+
const service = new CoreHubMailboxService(makeConfig());
|
|
76
|
+
for (const n of [1, 2, 3, 4, 5]) {
|
|
77
|
+
service.capture({ subject: `Mail ${n}`, to: 'a@b.com' });
|
|
78
|
+
}
|
|
79
|
+
const data = service.getMailbox();
|
|
80
|
+
expect(data.mails).toHaveLength(3);
|
|
81
|
+
expect(data.mails.map((m) => m.subject)).toEqual(['Mail 3', 'Mail 4', 'Mail 5']);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('truncates oversized bodies to the per-mail cap', () => {
|
|
85
|
+
const service = new CoreHubMailboxService(makeConfig());
|
|
86
|
+
const big = 'x'.repeat(500);
|
|
87
|
+
const seq = service.captureAndGetSeq({ html: big, subject: 'Big', to: 'a@b.com' });
|
|
88
|
+
const html = service.getMailHtml(seq);
|
|
89
|
+
expect(html).toBeDefined();
|
|
90
|
+
expect((html as string).length).toBeLessThanOrEqual(100 + 40); // cap + truncation marker
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('serves stored HTML by seq for the preview iframe', () => {
|
|
94
|
+
const service = new CoreHubMailboxService(makeConfig());
|
|
95
|
+
const seq = service.captureAndGetSeq({ html: '<p>Body</p>', subject: 'S', to: 'a@b.com' });
|
|
96
|
+
expect(service.getMailHtml(seq)).toContain('<p>Body</p>');
|
|
97
|
+
expect(service.getMailHtml(9999)).toBeUndefined();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('clear() empties the mailbox', () => {
|
|
101
|
+
const service = new CoreHubMailboxService(makeConfig());
|
|
102
|
+
service.capture({ subject: 'S', to: 'a@b.com' });
|
|
103
|
+
service.clear();
|
|
104
|
+
expect(service.getMailbox().mails).toHaveLength(0);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('supports cursor-based polling via since', () => {
|
|
108
|
+
const service = new CoreHubMailboxService(makeConfig());
|
|
109
|
+
service.capture({ subject: 'A', to: 'a@b.com' });
|
|
110
|
+
const first = service.getMailbox();
|
|
111
|
+
service.capture({ subject: 'B', to: 'a@b.com' });
|
|
112
|
+
const next = service.getMailbox(first.cursor);
|
|
113
|
+
expect(next.mails).toHaveLength(1);
|
|
114
|
+
expect(next.mails[0].subject).toBe('B');
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { Inject, Injectable } from '@nestjs/common';
|
|
2
|
+
|
|
3
|
+
import { redactSensitiveText } from '../../../common/helpers/logging.helper';
|
|
4
|
+
import { HUB_CONFIG } from '../hub.constants';
|
|
5
|
+
import { HubRingBuffer } from '../hub-ring-buffer';
|
|
6
|
+
import { HubMailboxData, HubMailboxEntry } from '../interfaces/hub-panels.interface';
|
|
7
|
+
import { IHubCapturedMailInput, IHubEmailCapture, ResolvedHubConfig } from '../interfaces/hub-config.interface';
|
|
8
|
+
|
|
9
|
+
/** A captured mail: the list metadata plus the (truncated) bodies kept for the preview iframe. */
|
|
10
|
+
interface StoredMail extends HubMailboxEntry {
|
|
11
|
+
html?: string;
|
|
12
|
+
text?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const TRUNCATION_MARKER = '… [truncated]';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The built-in mailbox — a Mailpit-style capture of outgoing mail for local/test use.
|
|
19
|
+
*
|
|
20
|
+
* Implements {@link IHubEmailCapture}: `EmailService` calls `capture()` before the transport. In
|
|
21
|
+
* `mode: 'capture'` the mail is recorded and NOT sent (`capture()` returns true); in `mode: 'copy'`
|
|
22
|
+
* it is recorded and still sent (returns false). Bodies are capped per mail and the buffer is a fixed
|
|
23
|
+
* ring, so memory stays bounded.
|
|
24
|
+
*/
|
|
25
|
+
@Injectable()
|
|
26
|
+
export class CoreHubMailboxService implements IHubEmailCapture {
|
|
27
|
+
protected readonly buffer: HubRingBuffer<StoredMail>;
|
|
28
|
+
protected readonly mode: 'capture' | 'copy';
|
|
29
|
+
|
|
30
|
+
constructor(@Inject(HUB_CONFIG) protected readonly config: ResolvedHubConfig) {
|
|
31
|
+
const mailbox =
|
|
32
|
+
config.mailbox === false ? { capacity: 100, maxMailSize: 262144, mode: 'capture' as const } : config.mailbox;
|
|
33
|
+
this.mode = mailbox.mode;
|
|
34
|
+
this.buffer = new HubRingBuffer<StoredMail>(mailbox.capacity);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Record a mail. Returns true when the caller should SKIP the transport (capture mode). */
|
|
38
|
+
capture(mail: IHubCapturedMailInput): boolean {
|
|
39
|
+
this.store(mail);
|
|
40
|
+
return this.mode === 'capture';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Convenience for tests/actions: capture and return the assigned seq. */
|
|
44
|
+
captureAndGetSeq(mail: IHubCapturedMailInput): number {
|
|
45
|
+
return this.store(mail).seq;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Drop all captured mails. */
|
|
49
|
+
clear(): void {
|
|
50
|
+
this.buffer.clear();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Stored HTML (or text fallback) for a mail, for the sandboxed preview iframe. */
|
|
54
|
+
getMailHtml(seq: number): string | undefined {
|
|
55
|
+
const mail = this.buffer.recent().find((m) => m.seq === seq);
|
|
56
|
+
if (!mail) {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
return mail.html ?? (mail.text ? `<pre>${escapeForPre(mail.text)}</pre>` : undefined);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Mailbox listing (metadata only), oldest→newest, optionally since a cursor. */
|
|
63
|
+
getMailbox(since?: number): HubMailboxData {
|
|
64
|
+
const entries = since === undefined ? this.buffer.recent() : this.buffer.since(since);
|
|
65
|
+
return {
|
|
66
|
+
cursor: this.buffer.lastSeq,
|
|
67
|
+
dropped: this.buffer.firstRetainedSeq,
|
|
68
|
+
mails: entries.map(toEntry),
|
|
69
|
+
mode: this.mode,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
protected store(mail: IHubCapturedMailInput): StoredMail {
|
|
74
|
+
const cap = this.config.mailbox === false ? 262144 : this.config.mailbox.maxMailSize;
|
|
75
|
+
// 'copy' mode: the real mail is delivered to the recipient and the stored copy is only an
|
|
76
|
+
// admin-facing audit view — best-effort redact tokens/links (JWTs, Bearer, `?token=`/`password=`
|
|
77
|
+
// query values) so a captured reset/verification link can't be replayed from the mailbox by an
|
|
78
|
+
// admin. 'capture' mode (dev/test only, never sent, blocked in prod/staging) keeps the raw body so
|
|
79
|
+
// the link stays clickable.
|
|
80
|
+
const redact = (value?: string): string | undefined =>
|
|
81
|
+
this.mode === 'copy' && value ? redactSensitiveText(value) : value;
|
|
82
|
+
return this.buffer.add({
|
|
83
|
+
bcc: mail.bcc,
|
|
84
|
+
cc: mail.cc,
|
|
85
|
+
from: mail.from,
|
|
86
|
+
hasHtml: !!mail.html,
|
|
87
|
+
hasText: !!mail.text,
|
|
88
|
+
html: truncate(redact(mail.html), cap),
|
|
89
|
+
subject: mail.subject,
|
|
90
|
+
templateName: mail.templateName,
|
|
91
|
+
text: truncate(redact(mail.text), cap),
|
|
92
|
+
to: mail.to,
|
|
93
|
+
} as Omit<StoredMail, 'seq' | 'timestamp'>);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function escapeForPre(text: string): string {
|
|
98
|
+
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function toEntry(mail: StoredMail): HubMailboxEntry {
|
|
102
|
+
return {
|
|
103
|
+
bcc: mail.bcc,
|
|
104
|
+
cc: mail.cc,
|
|
105
|
+
from: mail.from,
|
|
106
|
+
hasHtml: mail.hasHtml,
|
|
107
|
+
hasText: mail.hasText,
|
|
108
|
+
seq: mail.seq,
|
|
109
|
+
subject: mail.subject,
|
|
110
|
+
templateName: mail.templateName,
|
|
111
|
+
timestamp: mail.timestamp,
|
|
112
|
+
to: mail.to,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function truncate(value: string | undefined, cap: number): string | undefined {
|
|
117
|
+
if (value === undefined) {
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
return value.length > cap ? value.slice(0, cap) + TRUNCATION_MARKER : value;
|
|
121
|
+
}
|