@lenne.tech/nest-server 11.27.2 → 11.27.4

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.
@@ -0,0 +1,177 @@
1
+ # Migration Guide: 11.27.2 → 11.27.3
2
+
3
+ ## Overview
4
+
5
+ | Category | Details |
6
+ |----------|---------|
7
+ | **Breaking Changes** | None |
8
+ | **New Features** | None (one new internal helper export: `sendAuthEmailSafely`) |
9
+ | **Bugfixes** | A failed verification-email send (e.g. SMTP not configured, delivery failure) no longer crashes the Node process via an unhandled promise rejection. The send stays fire-and-forget (timing-attack mitigation) but every failure — sync throw or async rejection — is now caught and logged instead. |
10
+ | **Migration Effort** | 0 minutes (automatic) — `pnpm update` is enough. |
11
+
12
+ This is a **stability bugfix release**. No source-code or config changes are
13
+ required in consuming projects.
14
+
15
+ ---
16
+
17
+ ## Quick Migration
18
+
19
+ No code changes required.
20
+
21
+ ```bash
22
+ # Update package
23
+ pnpm add @lenne.tech/nest-server@11.27.3
24
+
25
+ # Verify build
26
+ pnpm run build
27
+
28
+ # Run tests
29
+ pnpm test
30
+ ```
31
+
32
+ ---
33
+
34
+ ## What's Fixed in 11.27.3
35
+
36
+ ### Process crash on failed verification-email send
37
+
38
+ **The bug:** BetterAuth's `emailVerification.sendVerificationEmail` callback
39
+ invoked the email send fire-and-forget (intentionally not awaited, per
40
+ Better-Auth's recommendation, so response timing cannot leak whether an
41
+ account exists). But the detached promise had **no rejection handler**. When
42
+ the send failed — SMTP not configured, provider outage, delivery rejection —
43
+ the rejection became an *unhandled promise rejection*, which terminates the
44
+ Node process under Node's default `--unhandled-rejections=throw` (Node ≥ 15).
45
+
46
+ **Observed trigger:** signing up an unverified user and then signing in
47
+ (`sendOnSignUp` / `sendOnSignIn` are enabled by default) while no working
48
+ SMTP transport was configured took the whole API down.
49
+
50
+ **The fix:** sends are now routed through a small hardened wrapper:
51
+
52
+ ```typescript
53
+ // src/core/modules/better-auth/better-auth.config.ts
54
+ export function sendAuthEmailSafely(send: () => unknown, onError: (error: unknown) => void): void {
55
+ void Promise.resolve()
56
+ .then(send)
57
+ .catch((error) => {
58
+ try {
59
+ onError(error);
60
+ } catch {
61
+ // A throwing error handler must not crash the process either.
62
+ }
63
+ });
64
+ }
65
+ ```
66
+
67
+ Behavior after the fix:
68
+
69
+ - The send remains **non-blocking** — response timing is unchanged
70
+ (timing-attack mitigation preserved).
71
+ - Sync throws and async rejections are both caught and logged via the NestJS
72
+ logger (context `BetterAuthConfig`), including the **stack trace**:
73
+ `Failed to send verification email: <message>`.
74
+ - Even a throwing error handler (e.g. a custom logger transport failing)
75
+ cannot re-introduce the crash.
76
+ - The API response to the client is unchanged (it never reflected send
77
+ failures — this matches Better-Auth's own default behavior).
78
+
79
+ **Covered by regression tests:** `tests/unit/better-auth-email-safe.spec.ts`
80
+ (async rejection, sync throw, success path, non-blocking guarantee, no
81
+ unhandled rejection, throwing error handler).
82
+
83
+ ---
84
+
85
+ ## Breaking Changes
86
+
87
+ None.
88
+
89
+ ---
90
+
91
+ ## Compatibility Notes
92
+
93
+ - **`IServerOptions` / `CoreModule.forRoot()`:** unchanged.
94
+ - **`SendVerificationEmailCallback` contract:** unchanged (`{ token, url, user }`
95
+ → `Promise<void>`). Projects supplying their own callback need no changes —
96
+ their callback is now additionally protected by the wrapper.
97
+ - **Monitoring / operations:** if you relied on the process crash (supervisor
98
+ restart loops) as an implicit signal for a broken mail setup, switch your
99
+ alerting to the error log line `Failed to send verification email: …`
100
+ (logger context `BetterAuthConfig`). Note that the email-verification
101
+ service additionally logs delivery failures with a masked recipient address
102
+ before rethrowing — a real outage produces both lines.
103
+ - **Workarounds can be removed:** a custom `process.on('unhandledRejection')`
104
+ handler added to a project's `main.ts` solely to survive this crash is no
105
+ longer needed for this path.
106
+ - **Better-Auth standard mechanism:** the framework deliberately detaches only
107
+ the sends it owns instead of enabling Better-Auth's global
108
+ `advanced.backgroundTasks` option (which would detach ALL background tasks —
109
+ OTP, invites, password reset — and route failures to Better-Auth's internal
110
+ logger instead of the NestJS logger). Projects that want the native
111
+ mechanism can still enable it via the `betterAuth.options.advanced`
112
+ passthrough.
113
+ - **Vendor-mode consumers:** the fix lives in
114
+ `src/core/modules/better-auth/better-auth.config.ts` and is picked up by the
115
+ next core sync (`/lt-dev:backend:update-nest-server-core`). The regression
116
+ test file (`tests/unit/`) is framework-repo-only and is not part of the
117
+ vendored file set.
118
+ - **Custom controllers / resolvers extending Core\* classes:** no impact — no
119
+ Core method signatures changed.
120
+
121
+ ---
122
+
123
+ ## Troubleshooting
124
+
125
+ ### Verification emails silently don't arrive after updating
126
+
127
+ Nothing regressed — before this fix the same situation crashed the API
128
+ instead. Check the logs for `Failed to send verification email: …`
129
+ (context `BetterAuthConfig`) and fix the underlying SMTP/Brevo configuration
130
+ (`email.smtp` / provider settings in `config.env.ts`).
131
+
132
+ ### I see two error log lines for one failed send
133
+
134
+ Expected. The email-verification service logs the delivery failure with a
135
+ masked recipient address and rethrows; the wrapper logs the same failure once
136
+ more (with stack trace) as the final safety net. The wrapper line additionally
137
+ covers failures thrown outside the service's own try/catch.
138
+
139
+ ---
140
+
141
+ ## Repo-Internal Tooling Changes (no consumer action)
142
+
143
+ These affect only this repository's development workflow — listed here because
144
+ some projects copied `scripts/check.mjs` from 11.27.1+:
145
+
146
+ - **`scripts/check.mjs` idle watchdog:** a step whose child produces no output
147
+ for 300s (configurable via `--idle-timeout=<seconds>` / `CHECK_IDLE_TIMEOUT`,
148
+ `0` disables) is now killed — including its whole process tree — and the
149
+ check fails with a clear `[watchdog]` reason. Previously a deadlocked test
150
+ run (workers idle at 0% CPU) spun the live view forever. If you copied
151
+ `check.mjs` into your project, update your copy to get the watchdog.
152
+ - **Per-run test databases:** `tests/global-setup.ts` now gives every vitest
153
+ run a unique database (`<base>-run-<timestamp>-p<pid>`) instead of dropping
154
+ a shared fixed-name DB — concurrent runs (second terminal, IDE runner) can
155
+ no longer wipe each other's users/sessions mid-flight. A new reporter
156
+ (`tests/db-lifecycle.reporter.ts`) drops the DB after a successful run and
157
+ collects stale leftovers; after a failed run the DB is kept for debugging
158
+ until the next successful run. An externally set `MONGODB_URI` (CI) keeps
159
+ the previous behavior.
160
+
161
+ ---
162
+
163
+ ## Module Documentation
164
+
165
+ ### BetterAuth
166
+
167
+ - **README:** [src/core/modules/better-auth/README.md](../src/core/modules/better-auth/README.md)
168
+ - **Integration Checklist:** [src/core/modules/better-auth/INTEGRATION-CHECKLIST.md](../src/core/modules/better-auth/INTEGRATION-CHECKLIST.md)
169
+ - **Key File:** `src/core/modules/better-auth/better-auth.config.ts` —
170
+ `sendAuthEmailSafely()` helper + `buildEmailVerificationConfig()` wiring
171
+
172
+ ---
173
+
174
+ ## References
175
+
176
+ - [Migration Guide 11.27.1 → 11.27.2](./11.27.1-to-11.27.2.md) — Previous release (chain-faithful audit step + security overrides)
177
+ - [nest-server-starter](https://github.com/lenneTech/nest-server-starter) — reference implementation
@@ -0,0 +1,236 @@
1
+ # Migration Guide: 11.27.3 → 11.27.4
2
+
3
+ ## Overview
4
+
5
+ | Category | Details |
6
+ |----------|---------|
7
+ | **Breaking Changes** | None |
8
+ | **New Features** | Opt-in `CHECK_LOW_RESOURCE` e2e mode (keeps parallel test runs stable under load); `check.mjs` now labels signal-killed steps (`SIGTERM`/`SIGKILL`) instead of showing a bare exit code. |
9
+ | **Bugfixes** | Idle watchdog no longer false-kills output-buffering steps (build / typecheck / audit); a stray `SIGKILL` after PID reuse is prevented; an invalid `--idle-timeout` value now falls back to the default instead of silently disabling the watchdog. |
10
+ | **Migration Effort** | 0 minutes for npm consumers (`pnpm update` is enough). Optional: projects that copied `scripts/check.mjs` / `vitest-e2e.config.ts` from the starter can adopt the updated files. |
11
+
12
+ This is a **repo-internal tooling & test-infrastructure release**. It touches
13
+ **no framework source** (`src/` is unchanged) — only `scripts/check.mjs` and
14
+ `vitest-e2e.config.ts`. The npm package (`dist/`) is functionally identical to
15
+ 11.27.3; consuming projects need no code or config changes.
16
+
17
+ ---
18
+
19
+ ## Quick Migration
20
+
21
+ No code changes required.
22
+
23
+ ```bash
24
+ # Update package
25
+ pnpm add @lenne.tech/nest-server@11.27.4
26
+
27
+ # Verify build
28
+ pnpm run build
29
+
30
+ # Run tests
31
+ pnpm test
32
+ ```
33
+
34
+ > These files ship in the git repository / starter, **not** in the npm package.
35
+ > An npm-mode consumer is unaffected. A project that copied `scripts/check.mjs`
36
+ > and/or `vitest-e2e.config.ts` from the starter (11.27.1+) can copy the updated
37
+ > versions to get the improvements below — see **Compatibility Notes**.
38
+
39
+ ---
40
+
41
+ ## What's New in 11.27.4
42
+
43
+ ### 1. Opt-in low-resource e2e mode (`CHECK_LOW_RESOURCE`)
44
+
45
+ Running several full e2e suites at the same time on one machine (e.g. multiple
46
+ parallel `lt dev` / `lt ticket` environments, or a second terminal) can saturate
47
+ CPU and the shared MongoDB. Under that load individual requests exceed the 30s
48
+ `testTimeout` and auth queries fail — surfacing as intermittent `401`s or
49
+ per-file timeouts. Data isolation was never the cause (every run already gets a
50
+ unique database); the cause is **physical resource contention**.
51
+
52
+ `vitest-e2e.config.ts` now supports an **opt-in** throttle. It changes nothing
53
+ by default — solo and moderately parallel runs stay at full speed:
54
+
55
+ ```bash
56
+ # Full speed (default) — nothing changes
57
+ pnpm test
58
+
59
+ # Opt in when you deliberately run MANY suites in parallel
60
+ CHECK_LOW_RESOURCE=1 pnpm test
61
+
62
+ # Pin the fork cap explicitly (otherwise ~1/3 of CPU cores)
63
+ CHECK_LOW_RESOURCE=1 CHECK_LOW_RESOURCE_FORKS=2 pnpm test
64
+ ```
65
+
66
+ | `CHECK_LOW_RESOURCE` | Effect |
67
+ |----------------------|--------|
68
+ | unset / `0` / `false` (default) | No cap, no timeout change — full speed |
69
+ | `1` / `true` | `poolOptions.forks.maxForks` capped (`~cores/3`, min 2), `testTimeout` 30s → 60s, `hookTimeout` 120s → 240s |
70
+ | any value + `CHECK_LOW_RESOURCE_FORKS=<n>` | Fork cap pinned to `<n>` |
71
+
72
+ When active it prints a one-line notice at startup:
73
+ `[e2e] low-resource mode active: maxForks=…, timeouts raised`.
74
+
75
+ ### 2. Signal-exit labelling in `scripts/check.mjs`
76
+
77
+ When a step's process is killed by a signal, the package manager surfaces it as
78
+ a numeric `Command failed with exit code 143` (SIGTERM) / `137` (SIGKILL) line,
79
+ while the outer shell reports only a generic exit `1`. The `check` report now
80
+ detects that and appends a clear reason so it isn't mistaken for a test-assertion
81
+ failure:
82
+
83
+ ```
84
+ [check] step ended via SIGTERM (exit 143) — the process was killed, not an
85
+ assertion failure. Usual cause: resource pressure (parallel checks/builds
86
+ swapping) or an external kill. Re-run this project's check alone to confirm.
87
+ ```
88
+
89
+ The hint is suppressed when the step was killed by the check's own idle
90
+ watchdog (that path already carries its own `[watchdog]` note), and it only
91
+ matches the package manager's own failure line — never an `exit code 143` a test
92
+ happens to log — so real assertion failures are never mislabeled.
93
+
94
+ ---
95
+
96
+ ## What's Fixed in 11.27.4
97
+
98
+ All fixes are in `scripts/check.mjs`, refining the idle watchdog introduced in
99
+ 11.27.x.
100
+
101
+ ### Watchdog now applies to test steps only
102
+
103
+ The idle watchdog kills a step whose child produces no output for the timeout
104
+ window (default 300s). It previously watched **every** step. But `build`,
105
+ `typecheck` and `audit` legitimately buffer all their output to the end (and go
106
+ silent under a non-TTY pipe) — watching them risked false-killing a slow but
107
+ progressing run. The watchdog is now armed **only for test steps**, whose
108
+ runners stream progress continuously, so prolonged silence genuinely means
109
+ deadlocked workers. The run header reflects this: `watchdog: 5m 0s (tests)`.
110
+
111
+ ### No stray `SIGKILL` after PID reuse
112
+
113
+ When the watchdog fires it sends `SIGTERM`, then escalates to `SIGKILL` after a
114
+ 5s grace window. That escalation timer is now tracked and cancelled once the
115
+ child exits within the grace window — previously the stray timer could fire
116
+ later and `SIGKILL` an **unrelated** process that had reused the freed PID.
117
+
118
+ ### Invalid `--idle-timeout` falls back to the default
119
+
120
+ An unparseable, negative, or unit-suffixed value (e.g. `--idle-timeout=30s`)
121
+ now keeps the watchdog at its default (300s) and prints a notice, instead of
122
+ silently evaluating to `0` and disabling the protection. Only an explicit `0`
123
+ disables the watchdog.
124
+
125
+ ```bash
126
+ --idle-timeout=120 # 120s
127
+ --idle-timeout=0 # explicitly disabled
128
+ --idle-timeout=30s # invalid → default 300s + "[check] ignoring invalid idle-timeout" notice
129
+ CHECK_IDLE_TIMEOUT=90 # env equivalent
130
+ ```
131
+
132
+ ### Clearer watchdog reason
133
+
134
+ The `[watchdog]` note now suggests re-running the **actual** failing command
135
+ (`Re-run the step directly to debug: <cmd>`) instead of a hard-coded
136
+ `pnpm run vitest`.
137
+
138
+ ---
139
+
140
+ ## Breaking Changes
141
+
142
+ None.
143
+
144
+ ---
145
+
146
+ ## Compatibility Notes
147
+
148
+ - **npm-mode consumers:** unaffected. `scripts/check.mjs` and
149
+ `vitest-e2e.config.ts` are not part of the npm package (`dist/`); no runtime
150
+ or API behavior changed. `pnpm update` is sufficient.
151
+ - **`IServerOptions` / `CoreModule.forRoot()` / all Core classes:** unchanged.
152
+ No source, export, or method signature changed in this release.
153
+ - **Starter-derived projects** (copied `scripts/check.mjs` / `vitest-e2e.config.ts`
154
+ from nest-server-starter 11.27.1+): copy the updated files to adopt the
155
+ test-only watchdog, the PID-reuse fix, the signal-exit hint, and the opt-in
156
+ `CHECK_LOW_RESOURCE` mode. All changes are backward compatible — the default
157
+ behavior (no env vars set) is unchanged, so an updated `check.mjs` /
158
+ `vitest-e2e.config.ts` is a drop-in replacement.
159
+ - **Vendor-mode consumers:** not applicable — `scripts/` and
160
+ `vitest-e2e.config.ts` are repo/starter tooling, not part of the vendored
161
+ `src/core/` file set. Nothing to sync.
162
+ - **`lt dev` / `lt ticket` parallel workflows:** the throttle is **manual**
163
+ (`CHECK_LOW_RESOURCE=1`). To have it apply automatically inside isolated
164
+ environments, the lt CLI can export it into the test env (the same way it
165
+ exports `LT_DEV_TEST_SHARDS` for the Playwright config). That is a CLI change,
166
+ not a framework change.
167
+ - **Nuxt / Playwright side:** no equivalent needed. The Playwright config
168
+ already uses `workers: 1` plus a `LT_DEV_TEST_SHARDS`-driven relaxed mode for
169
+ load, so the same class of contention is already handled there.
170
+
171
+ ---
172
+
173
+ ## Troubleshooting
174
+
175
+ ### My `build` / `typecheck` / `audit` step is no longer killed after 300s
176
+
177
+ Intended. Only **test** steps are watched now (they stream progress). Buffering
178
+ steps that legitimately go silent are no longer at risk of a false watchdog
179
+ kill. If such a step genuinely hangs, it still fails via its own tool timeout or
180
+ the overall run.
181
+
182
+ ### The check aborted with "step ended via SIGTERM (exit 143)"
183
+
184
+ That step's process was killed by a signal, not by a failed assertion. The usual
185
+ cause is resource pressure (several heavy checks/builds/test suites running at
186
+ once and swapping the machine) or an external kill. Re-run that project's check
187
+ alone to confirm — it will typically pass.
188
+
189
+ ### e2e tests flake (401 / timeouts) only when I run many suites in parallel
190
+
191
+ This is physical CPU/MongoDB contention, not a data-isolation bug (each run has
192
+ its own database). Opt into the throttle for those sessions:
193
+ `CHECK_LOW_RESOURCE=1 pnpm test`. It caps parallel forks and raises timeouts so
194
+ the suites share the machine without starving each other.
195
+
196
+ ### `CHECK_LOW_RESOURCE` seems to have no effect
197
+
198
+ Confirm the value is truthy (`1` / `true`) and not `0` / `false` / empty. When
199
+ active, the run prints `[e2e] low-resource mode active: maxForks=…` at startup.
200
+
201
+ ---
202
+
203
+ ## Repo-Internal Tooling Changes (no consumer action)
204
+
205
+ Summary of everything in this release, for projects that track the tooling
206
+ (all listed above in detail):
207
+
208
+ - `scripts/check.mjs`: idle watchdog restricted to test steps; stray-`SIGKILL`
209
+ (PID-reuse) fix; invalid `--idle-timeout` falls back to default instead of
210
+ disabling; watchdog reason shows the actual command; new `signalExitHint`
211
+ labelling `SIGTERM`/`SIGKILL` step exits.
212
+ - `vitest-e2e.config.ts`: opt-in `CHECK_LOW_RESOURCE` mode (fork cap + raised
213
+ timeouts) for stable parallel e2e runs; default behavior unchanged.
214
+
215
+ These carry forward the tooling introduced in
216
+ [11.27.1 → 11.27.2](./11.27.1-to-11.27.2.md) (chain-faithful audit) and
217
+ [11.27.2 → 11.27.3](./11.27.2-to-11.27.3.md) (idle watchdog + per-run test
218
+ databases).
219
+
220
+ ---
221
+
222
+ ## Module Documentation
223
+
224
+ No core module changed in this release. Relevant testing documentation:
225
+
226
+ - **Testing rules:** [.claude/rules/testing.md](../.claude/rules/testing.md) —
227
+ test framework, per-run database lifecycle, parallel execution
228
+ - **Reference implementation:** [nest-server-starter](https://github.com/lenneTech/nest-server-starter)
229
+ — carries the same `scripts/check.mjs` + `vitest-e2e.config.ts`
230
+
231
+ ---
232
+
233
+ ## References
234
+
235
+ - [Migration Guide 11.27.2 → 11.27.3](./11.27.2-to-11.27.3.md) — Previous release (verification-email crash fix + idle watchdog + per-run test DBs)
236
+ - [nest-server-starter](https://github.com/lenneTech/nest-server-starter) — reference implementation
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "11.27.2",
3
+ "version": "11.27.4",
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",
@@ -15,6 +15,11 @@ import { detectCookiePrefixDrift, resolveBetterAuthCookiePrefix } from './better
15
15
  */
16
16
  export type BetterAuthInstance = ReturnType<typeof betterAuth>;
17
17
 
18
+ /**
19
+ * Shared logger for all functions in this config module
20
+ */
21
+ const logger = new Logger('BetterAuthConfig');
22
+
18
23
  // ---------------------------------------------------------------------------
19
24
  // Performance-optimized password hashing using Node.js native crypto.scrypt
20
25
  //
@@ -174,6 +179,40 @@ export type SendVerificationEmailCallback = (options: {
174
179
  user: { email: string; id: string; name?: null | string };
175
180
  }) => Promise<void>;
176
181
 
182
+ /**
183
+ * Invoke an auth-email send (verification / password-reset) fire-and-forget.
184
+ *
185
+ * Better-Auth recommends NOT awaiting these sends so the response time does not
186
+ * leak whether an account exists (timing attack). The catch here is essential:
187
+ * a rejected send (e.g. SMTP not configured / delivery failure) must never
188
+ * become an unhandled promise rejection — that crashes the Node process (an
189
+ * unverified sign-up + sign-in took the whole API down in dev). This wrapper
190
+ * keeps the send non-blocking while routing every failure (sync throw or async
191
+ * rejection) to `onError` instead. `onError` itself is guarded too: if the
192
+ * handler throws, the error is swallowed rather than crashing the process.
193
+ *
194
+ * Deliberate divergence from Better-Auth's own mechanism: Better-Auth awaits
195
+ * these callbacks via `runInBackgroundOrAwait` and offers
196
+ * `advanced.backgroundTasks` as its native non-blocking path. That option is
197
+ * global (it detaches ALL background tasks — OTP, invites, password reset, …)
198
+ * and routes failures to Better-Auth's internal logger instead of the NestJS
199
+ * logger, so this wrapper detaches only the sends the framework owns. Projects
200
+ * can still opt into `advanced.backgroundTasks` via the `options` passthrough.
201
+ */
202
+ export function sendAuthEmailSafely(send: () => unknown, onError: (error: unknown) => void): void {
203
+ void Promise.resolve()
204
+ .then(send)
205
+ .catch((error) => {
206
+ try {
207
+ onError(error);
208
+ } catch {
209
+ // Last resort: the error handler itself failed. Swallowing here keeps
210
+ // the no-unhandled-rejection guarantee — a throwing handler must not
211
+ // crash the process this helper exists to protect.
212
+ }
213
+ });
214
+ }
215
+
177
216
  /**
178
217
  * Better-Auth field type definition
179
218
  * Matches the DBFieldType from better-auth
@@ -264,7 +303,6 @@ export interface CreateBetterAuthResult {
264
303
  }
265
304
 
266
305
  export function createBetterAuthInstance(options: CreateBetterAuthOptions): CreateBetterAuthResult | null {
267
- const logger = new Logger('BetterAuthConfig');
268
306
  const { config, db, fallbackSecrets, onEmailVerified, sendVerificationEmail, serverEnv } = options;
269
307
 
270
308
  // Return null only if better-auth is explicitly disabled
@@ -495,9 +533,19 @@ function buildEmailVerificationConfig(
495
533
  data: { token: string; url: string; user: { email: string; id: string; name?: null | string } },
496
534
  _request?: Request,
497
535
  ) => {
498
- // Don't await to prevent timing attacks (as recommended by Better-Auth docs)
499
-
500
- sendVerificationEmail(data);
536
+ // Fire-and-forget (timing-attack mitigation, per Better-Auth docs) — but a
537
+ // failed send must be logged, never crash the process (see sendAuthEmailSafely).
538
+ // Note: delivery failures are also logged (with masked recipient) by the
539
+ // email-verification service before rethrowing; this line additionally
540
+ // covers failures thrown outside the service's own try/catch.
541
+ sendAuthEmailSafely(
542
+ () => sendVerificationEmail(data),
543
+ (error) =>
544
+ logger.error(
545
+ `Failed to send verification email: ${error instanceof Error ? error.message : 'Unknown error'}`,
546
+ error instanceof Error ? error.stack : undefined,
547
+ ),
548
+ );
501
549
  };
502
550
  }
503
551