@lenne.tech/nest-server 11.31.3 → 11.32.1
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/versioning.md +5 -8
- package/CLAUDE.md +3 -3
- package/FRAMEWORK-API.md +4 -2
- package/README.md +1 -0
- 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 +3 -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/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/migrate/migration-runner.js +4 -0
- package/dist/core/modules/migrate/migration-runner.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/main.js +5 -2
- package/dist/main.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +1 -0
- package/docs/brevo-manual-test.md +166 -0
- package/docs/security-overrides.md +90 -0
- package/migration-guides/11.31.3-to-11.32.0.md +254 -0
- package/migration-guides/11.32.0-to-11.32.1.md +84 -0
- package/package.json +15 -14
- 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 +32 -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/modules/better-auth/core-better-auth-email-verification.service.ts +14 -4
- package/src/core/modules/hub/helpers/hub-mermaid.helper.spec.ts +8 -1
- package/src/core/modules/migrate/migration-runner.ts +17 -0
- package/src/index.ts +1 -0
- package/src/main.ts +22 -3
|
@@ -1,27 +1,50 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { Injectable, Logger } from '@nestjs/common';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
import type { Brevo, BrevoClient } from '@getbrevo/brevo';
|
|
3
5
|
|
|
4
6
|
import { ConfigService } from './config.service';
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
9
|
* Brevo service to send transactional emails
|
|
10
|
+
*
|
|
11
|
+
* ## Return contract
|
|
12
|
+
*
|
|
13
|
+
* Both send methods resolve to one of four things, and callers on security-critical paths
|
|
14
|
+
* (verification, password reset, magic link) MUST distinguish them:
|
|
15
|
+
*
|
|
16
|
+
* | Value | Meaning |
|
|
17
|
+
* |-------|---------|
|
|
18
|
+
* | `false` | Rejected before sending — a required argument was missing |
|
|
19
|
+
* | `'TEST_USER!'` | Recipient matched `brevo.exclude`, nothing was sent (by design) |
|
|
20
|
+
* | `null` | The send FAILED. The error was logged, not thrown |
|
|
21
|
+
* | otherwise | The Brevo `SendTransacEmailResponse` (`{ messageId?, messageIds? }`) |
|
|
22
|
+
*
|
|
23
|
+
* A `null` is the one that bites: treating "did not throw" as "was delivered" silently drops mail.
|
|
24
|
+
* Set `brevo.throwOnError: true` if you would rather have the exception propagate.
|
|
8
25
|
*/
|
|
9
26
|
@Injectable()
|
|
10
27
|
export class BrevoService {
|
|
11
28
|
brevoConfig: ConfigService['configFastButReadOnly']['brevo'];
|
|
12
|
-
|
|
29
|
+
protected readonly logger = new Logger(BrevoService.name);
|
|
30
|
+
private client: BrevoClient | undefined;
|
|
13
31
|
|
|
14
32
|
constructor(protected configService: ConfigService) {
|
|
15
33
|
this.brevoConfig = configService.configFastButReadOnly.brevo;
|
|
16
34
|
if (!this.brevoConfig) {
|
|
17
35
|
throw new Error('Brevo configuration not set!');
|
|
18
36
|
}
|
|
19
|
-
this.apiInstance = new TransactionalEmailsApi();
|
|
20
|
-
this.apiInstance.setApiKey(TransactionalEmailsApiApiKeys.apiKey, this.brevoConfig.apiKey);
|
|
21
37
|
}
|
|
22
38
|
|
|
23
39
|
/**
|
|
24
40
|
* Send a transactional email via Brevo
|
|
41
|
+
*
|
|
42
|
+
* @param to - Recipient email address
|
|
43
|
+
* @param templateId - Brevo template id
|
|
44
|
+
* @param params - Template parameters. These are rendered SERVER-SIDE by the Brevo template
|
|
45
|
+
* engine, so any user-controlled value placed here is content-injection surface owned by
|
|
46
|
+
* whoever wrote the template — escape it there, or do not pass it.
|
|
47
|
+
* @returns The Brevo response, `false` on missing input, `'TEST_USER!'` when excluded, `null` on failure
|
|
25
48
|
*/
|
|
26
49
|
async sendMail(to: string, templateId: number, params?: object): Promise<unknown> {
|
|
27
50
|
try {
|
|
@@ -37,25 +60,31 @@ export class BrevoService {
|
|
|
37
60
|
}
|
|
38
61
|
|
|
39
62
|
// Prepare data
|
|
40
|
-
const
|
|
41
|
-
|
|
63
|
+
const request: Brevo.SendTransacEmailRequest = {
|
|
64
|
+
headers: this.buildIdempotencyHeaders(),
|
|
65
|
+
// The public signature keeps the wider `object` so existing callers stay source-compatible;
|
|
66
|
+
// the SDK narrowed its own field to an index-signature type in v6.
|
|
67
|
+
params: params as Record<string, unknown>,
|
|
42
68
|
templateId,
|
|
43
69
|
to: [{ email: to }],
|
|
44
70
|
};
|
|
45
71
|
|
|
46
72
|
// Send email
|
|
47
|
-
const
|
|
48
|
-
return
|
|
73
|
+
const client = await this.getClient();
|
|
74
|
+
return await client.transactionalEmails.sendTransacEmail(request);
|
|
49
75
|
} catch (error) {
|
|
50
|
-
|
|
76
|
+
return this.handleSendError(error, to);
|
|
51
77
|
}
|
|
52
|
-
|
|
53
|
-
// Return null if error
|
|
54
|
-
return null;
|
|
55
78
|
}
|
|
56
79
|
|
|
57
80
|
/**
|
|
58
81
|
* Send HTML mail
|
|
82
|
+
*
|
|
83
|
+
* @param to - Recipient email address
|
|
84
|
+
* @param subject - Email subject
|
|
85
|
+
* @param html - HTML body
|
|
86
|
+
* @param options - Optional template parameters
|
|
87
|
+
* @returns The Brevo response, `false` on missing input, `'TEST_USER!'` when excluded, `null` on failure
|
|
59
88
|
*/
|
|
60
89
|
async sendHtmlMail(
|
|
61
90
|
to: string,
|
|
@@ -76,7 +105,8 @@ export class BrevoService {
|
|
|
76
105
|
}
|
|
77
106
|
|
|
78
107
|
// Prepare data
|
|
79
|
-
const
|
|
108
|
+
const request: Brevo.SendTransacEmailRequest = {
|
|
109
|
+
headers: this.buildIdempotencyHeaders(),
|
|
80
110
|
htmlContent: html,
|
|
81
111
|
params: options?.params,
|
|
82
112
|
sender: this.brevoConfig.sender,
|
|
@@ -85,12 +115,65 @@ export class BrevoService {
|
|
|
85
115
|
};
|
|
86
116
|
|
|
87
117
|
// Send email
|
|
88
|
-
const
|
|
89
|
-
return
|
|
118
|
+
const client = await this.getClient();
|
|
119
|
+
return await client.transactionalEmails.sendTransacEmail(request);
|
|
90
120
|
} catch (error) {
|
|
91
|
-
|
|
121
|
+
return this.handleSendError(error, to);
|
|
92
122
|
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Builds the per-send idempotency header.
|
|
127
|
+
*
|
|
128
|
+
* The SDK retries POSTs on 408/429/5xx. Without a key, a retry issued after a response that was
|
|
129
|
+
* actually delivered (but whose reply was lost) sends the mail twice. Brevo deduplicates on
|
|
130
|
+
* `Idempotency-Key`.
|
|
131
|
+
*
|
|
132
|
+
* @returns Custom headers for the send request
|
|
133
|
+
*/
|
|
134
|
+
protected buildIdempotencyHeaders(): Record<string, unknown> {
|
|
135
|
+
return { 'Idempotency-Key': randomUUID() };
|
|
136
|
+
}
|
|
93
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Lazily constructs (and memoises) the Brevo SDK client.
|
|
140
|
+
*
|
|
141
|
+
* The import is dynamic on purpose: `@getbrevo/brevo` pulls in ~580 CommonJS modules, and
|
|
142
|
+
* `BrevoService` is re-exported from the package barrel. A static import would put that cost on
|
|
143
|
+
* every consumer's cold start, including the majority that never configure Brevo at all.
|
|
144
|
+
*
|
|
145
|
+
* @returns The memoised SDK client
|
|
146
|
+
*/
|
|
147
|
+
protected async getClient(): Promise<BrevoClient> {
|
|
148
|
+
if (!this.client) {
|
|
149
|
+
const { BrevoClient: BrevoClientCtor } = await import('@getbrevo/brevo');
|
|
150
|
+
this.client = new BrevoClientCtor({
|
|
151
|
+
apiKey: this.brevoConfig.apiKey,
|
|
152
|
+
// The SDK defaults to 2 retries honouring `Retry-After` with a 60 s cap PER attempt, and to
|
|
153
|
+
// no timeout at all. Both send methods are awaited inside request handlers, so those
|
|
154
|
+
// defaults let a rate-limited Brevo park a user-facing request for roughly two minutes.
|
|
155
|
+
maxRetries: this.brevoConfig.maxRetries ?? 0,
|
|
156
|
+
timeoutInSeconds: this.brevoConfig.timeoutInSeconds ?? 10,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return this.client;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Logs a failed send through the Nest logger and applies the configured failure policy.
|
|
164
|
+
*
|
|
165
|
+
* @param error - The thrown SDK error
|
|
166
|
+
* @param to - Recipient, for correlation
|
|
167
|
+
* @returns `null` (the historical contract) unless `brevo.throwOnError` is set
|
|
168
|
+
* @throws The original error when `brevo.throwOnError` is `true`
|
|
169
|
+
*/
|
|
170
|
+
protected handleSendError(error: unknown, to: string): null {
|
|
171
|
+
this.logger.error(
|
|
172
|
+
`Brevo sendTransacEmail failed for ${to}: ${error instanceof Error ? error.message : String(error)}`,
|
|
173
|
+
);
|
|
174
|
+
if (this.brevoConfig.throwOnError) {
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
94
177
|
// Return null if error
|
|
95
178
|
return null;
|
|
96
179
|
}
|
|
@@ -180,15 +180,25 @@ export class CoreBetterAuthEmailVerificationService {
|
|
|
180
180
|
if (this.config.brevoTemplateId && this.brevoService) {
|
|
181
181
|
try {
|
|
182
182
|
const appName = this.getAppName();
|
|
183
|
-
await this.brevoService.sendMail(user.email, this.config.brevoTemplateId, {
|
|
183
|
+
const result = await this.brevoService.sendMail(user.email, this.config.brevoTemplateId, {
|
|
184
184
|
appName,
|
|
185
185
|
expiresIn: this.formatExpiresIn(this.config.expiresIn),
|
|
186
186
|
link: url,
|
|
187
187
|
name: user.name || user.email.split('@')[0],
|
|
188
188
|
});
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
189
|
+
|
|
190
|
+
// `sendMail()` swallows SDK errors and resolves to `null` (unless `brevo.throwOnError` is
|
|
191
|
+
// set), so "did not throw" is NOT "was delivered". Recording a send here on a null would
|
|
192
|
+
// mark the address as mailed, log success, and skip the SMTP fallback below — leaving the
|
|
193
|
+
// user with no verification email at all on a Brevo outage or a revoked key.
|
|
194
|
+
if (result === null) {
|
|
195
|
+
this.logger.error(`Brevo verification send failed for ${this.maskEmail(user.email)} — falling back to SMTP`);
|
|
196
|
+
// Deliberately no `return`: fall through to the EmailService path.
|
|
197
|
+
} else {
|
|
198
|
+
this.trackSend(user.email);
|
|
199
|
+
this.logger.debug(`Verification email sent via Brevo to ${this.maskEmail(user.email)}`);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
192
202
|
} catch (error) {
|
|
193
203
|
this.logger.error(
|
|
194
204
|
`Failed to send verification email via Brevo to ${this.maskEmail(user.email)}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// Value and type imports are deliberately split into two statements. The lt CLI's vendor
|
|
4
|
+
// conversion drops an INLINE `type` specifier from a mixed import — `{ buildErDiagram, type
|
|
5
|
+
// HubModelDescriptor }` arrives in a vendored project as `{ buildErDiagram }`, and the file then
|
|
6
|
+
// fails to compile with TS2304. Keeping the two forms separate survives the conversion.
|
|
7
|
+
// (The CLI defect is tracked separately; this keeps src/core/ vendor-safe meanwhile.)
|
|
8
|
+
import type { HubModelDescriptor } from './hub-mermaid.helper';
|
|
9
|
+
|
|
10
|
+
import { buildErDiagram } from './hub-mermaid.helper';
|
|
4
11
|
|
|
5
12
|
describe('buildErDiagram', () => {
|
|
6
13
|
const models: HubModelDescriptor[] = [
|
|
@@ -159,6 +159,23 @@ export class MigrationRunner {
|
|
|
159
159
|
* compiled-production intent; the duplicate is skipped with a warning.
|
|
160
160
|
*/
|
|
161
161
|
private async loadMigrationFiles(): Promise<MigrationFile[]> {
|
|
162
|
+
// A MISSING directory means the same thing as an EMPTY one: there are no migrations.
|
|
163
|
+
// Treat it that way instead of throwing ENOENT.
|
|
164
|
+
//
|
|
165
|
+
// This is a boot blocker otherwise: `pnpm start` is `migrate:up && start:local`, so the `&&`
|
|
166
|
+
// turns a readdirSync ENOENT into a server that will not start — with an error that does not
|
|
167
|
+
// point at the cause. And it is a state people produce routinely: "delete all migrations"
|
|
168
|
+
// reads to most as "throw the folder away".
|
|
169
|
+
//
|
|
170
|
+
// The runner already tolerates the RELATED case — a migration recorded in the database whose
|
|
171
|
+
// file is gone is non-fatal unless `NSC__MIGRATE__STRICT` is set. Only the wholly absent
|
|
172
|
+
// directory fell outside that tolerance. `down()` stays hard, consistent with its own
|
|
173
|
+
// reasoning. See DEV-2634.
|
|
174
|
+
if (!fs.existsSync(this.options.migrationsDirectory)) {
|
|
175
|
+
console.warn(`[migrate] migrations directory not found — treating as empty: ${this.options.migrationsDirectory}`);
|
|
176
|
+
return [];
|
|
177
|
+
}
|
|
178
|
+
|
|
162
179
|
const files = fs
|
|
163
180
|
.readdirSync(this.options.migrationsDirectory)
|
|
164
181
|
.filter((file) => this.pattern.test(file))
|
package/src/index.ts
CHANGED
|
@@ -43,6 +43,7 @@ export * from './core/common/helpers/input.helper';
|
|
|
43
43
|
export * from './core/common/helpers/logging.helper';
|
|
44
44
|
export * from './core/common/helpers/meta.helper';
|
|
45
45
|
export * from './core/common/helpers/model.helper';
|
|
46
|
+
export * from './core/common/helpers/process-diagnostics.helper';
|
|
46
47
|
export * from './core/common/helpers/register-enum.helper';
|
|
47
48
|
export * from './core/common/helpers/scim.helper';
|
|
48
49
|
export * from './core/common/helpers/service.helper';
|
package/src/main.ts
CHANGED
|
@@ -9,6 +9,7 @@ import envConfig from './config.env';
|
|
|
9
9
|
import { FilterArgs } from './core/common/args/filter.args';
|
|
10
10
|
import { buildCorsConfig, isCookiesEnabled, isCorsDisabled } from './core/common/helpers/cookies.helper';
|
|
11
11
|
import { HttpExceptionLogFilter } from './core/common/filters/http-exception-log.filter';
|
|
12
|
+
import { handleFatalBootstrapError, installProcessDiagnostics } from './core/common/helpers/process-diagnostics.helper';
|
|
12
13
|
import { CorePersistenceModel } from './core/common/models/core-persistence.model';
|
|
13
14
|
import { CoreAuthModel } from './core/modules/auth/core-auth.model';
|
|
14
15
|
import { CoreUserModel } from './core/modules/user/core-user.model';
|
|
@@ -21,6 +22,11 @@ import { ServerModule } from './server/server.module';
|
|
|
21
22
|
* Preparations for server start
|
|
22
23
|
*/
|
|
23
24
|
async function bootstrap() {
|
|
25
|
+
// Make the exit reason diagnosable: log unhandled rejections without crashing, log uncaught
|
|
26
|
+
// exceptions before the restart, and label external termination signals so a silent
|
|
27
|
+
// "app crashed" always has a reason. See process-diagnostics.helper.ts for the rationale.
|
|
28
|
+
installProcessDiagnostics();
|
|
29
|
+
|
|
24
30
|
// Create a new server based on express
|
|
25
31
|
const server = await NestFactory.create<NestExpressApplication>(
|
|
26
32
|
// Include server module, with all necessary modules for the project
|
|
@@ -106,9 +112,20 @@ async function bootstrap() {
|
|
|
106
112
|
jsonDocumentUrl: '/api-docs-json',
|
|
107
113
|
});
|
|
108
114
|
|
|
115
|
+
// Drain the event loop on SIGTERM/SIGINT so the process actually exits.
|
|
116
|
+
//
|
|
117
|
+
// This is load-bearing in a container, where `docker-entrypoint.sh` runs node under `exec` and it
|
|
118
|
+
// therefore becomes PID 1. A PID-namespace init is SIGNAL_UNKILLABLE: a userspace signal whose
|
|
119
|
+
// disposition is the default is silently discarded by the kernel, so re-raising is a no-op there.
|
|
120
|
+
// Meanwhile the listening HTTP server keeps the event loop non-empty, so nothing exits on its own
|
|
121
|
+
// and `docker stop` waits out its full grace period before SIGKILL — dropping in-flight requests
|
|
122
|
+
// and skipping every onModuleDestroy(). enableShutdownHooks() is what closes the app and drains
|
|
123
|
+
// the loop; installProcessDiagnostics() then correctly defers to it instead of re-raising.
|
|
124
|
+
server.enableShutdownHooks();
|
|
125
|
+
|
|
109
126
|
// Start server on configured port
|
|
110
127
|
await server.listen(envConfig.port, envConfig.hostname);
|
|
111
|
-
console.debug(`Server
|
|
128
|
+
console.debug(`Server started at ${await server.getUrl()}`);
|
|
112
129
|
|
|
113
130
|
// Run command after server init
|
|
114
131
|
if (envConfig.execAfterInit) {
|
|
@@ -126,5 +143,7 @@ async function bootstrap() {
|
|
|
126
143
|
}
|
|
127
144
|
}
|
|
128
145
|
|
|
129
|
-
// Start server
|
|
130
|
-
|
|
146
|
+
// Start server. A rejection here is a fatal startup failure (e.g. port already in use, DB
|
|
147
|
+
// unreachable) — surface it and exit rather than let it become a silent unhandledRejection
|
|
148
|
+
// that leaves a zombie process "alive" but listening on nothing.
|
|
149
|
+
bootstrap().catch(handleFatalBootstrapError);
|