Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions .claude/rules/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,17 @@ pnpm run test:cov
pnpm run test:ci

# Debug open handles
pnpm run test:e2e-doh
npx vitest run --config vitest-e2e.config.ts --reporter=hanging-process

# Clean up leftover test artifacts (.txt, .bin files from failed file upload tests)
pnpm run test:cleanup
```

## Test Environment

- Environment: `NODE_ENV=local`
- Database: Local MongoDB instance (`mongodb://127.0.0.1/nest-server-local`)
- Environment: `NODE_ENV=e2e` (via `pnpm test` → `vitest-e2e.config.ts`)
- Database: **one unique database per run** (`nest-server-e2e-run-<ts>-p<pid>`), created by `tests/global-setup.ts` so concurrent runs cannot interfere with each other
- DB lifecycle (`tests/db-lifecycle.reporter.ts`): run passes → DB dropped immediately + stale run DBs from crashed/failed runs collected; run fails → DB kept for debugging, removed by the next successful run. An externally set `MONGODB_URI` (CI) opts out of the scheme.
- Test helper: `src/test/test.helper.ts`
- Coverage: Collected from `src/**/*.{ts,js}`

Expand Down Expand Up @@ -115,6 +116,6 @@ Full documentation for TestHelper (REST, GraphQL, Cookie support):
## Common Test Issues

- **Tests timeout**: Ensure MongoDB is running
- **Open handles**: Use `pnpm run test:e2e-doh` to debug
- **Open handles**: Run vitest with `--reporter=hanging-process` to debug
- **Data conflicts**: Use unique identifiers per test
- **"NestApplication successfully started" log**: Use `httpServer.listen()` instead of `app.listen()`
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ pnpm run start:dev # Development mode with watch
# Testing (ALWAYS run before completing changes)
pnpm test # Run E2E tests (Vitest)
pnpm run test:cov # With coverage
pnpm run test:e2e-doh # Debug open handles
npx vitest run --config vitest-e2e.config.ts --reporter=hanging-process # Debug open handles
pnpm run test:cleanup # Remove leftover test artifacts (.txt, .bin)

# Linting & Formatting
Expand Down Expand Up @@ -218,7 +218,7 @@ The migration store uses `NSC__MONGOOSE__URI` env var (not `config.env.ts`) for
| Tests timeout | Ensure MongoDB running on localhost:27017 |
| GraphQL introspection fails | Check `config.env.ts` introspection setting |
| Module not found after adding | Verify export in `src/index.ts`, run `pnpm run build` |
| Open handles in tests | Run `pnpm run test:e2e-doh` |
| Open handles in tests | Run vitest with `--reporter=hanging-process` |

## Best Practices

Expand Down
2 changes: 1 addition & 1 deletion FRAMEWORK-API.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# @lenne.tech/nest-server — Framework API Reference

> Auto-generated from source code on 2026-06-22 (v11.27.2)
> Auto-generated from source code on 2026-07-03 (v11.27.3)
> File: `FRAMEWORK-API.md` — compact, machine-readable API surface for Claude Code

## CoreModule.forRoot()
Expand Down
177 changes: 177 additions & 0 deletions migration-guides/11.27.2-to-11.27.3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
# Migration Guide: 11.27.2 → 11.27.3

## Overview

| Category | Details |
|----------|---------|
| **Breaking Changes** | None |
| **New Features** | None (one new internal helper export: `sendAuthEmailSafely`) |
| **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. |
| **Migration Effort** | 0 minutes (automatic) — `pnpm update` is enough. |

This is a **stability bugfix release**. No source-code or config changes are
required in consuming projects.

---

## Quick Migration

No code changes required.

```bash
# Update package
pnpm add @lenne.tech/nest-server@11.27.3

# Verify build
pnpm run build

# Run tests
pnpm test
```

---

## What's Fixed in 11.27.3

### Process crash on failed verification-email send

**The bug:** BetterAuth's `emailVerification.sendVerificationEmail` callback
invoked the email send fire-and-forget (intentionally not awaited, per
Better-Auth's recommendation, so response timing cannot leak whether an
account exists). But the detached promise had **no rejection handler**. When
the send failed — SMTP not configured, provider outage, delivery rejection —
the rejection became an *unhandled promise rejection*, which terminates the
Node process under Node's default `--unhandled-rejections=throw` (Node ≥ 15).

**Observed trigger:** signing up an unverified user and then signing in
(`sendOnSignUp` / `sendOnSignIn` are enabled by default) while no working
SMTP transport was configured took the whole API down.

**The fix:** sends are now routed through a small hardened wrapper:

```typescript
// src/core/modules/better-auth/better-auth.config.ts
export function sendAuthEmailSafely(send: () => unknown, onError: (error: unknown) => void): void {
void Promise.resolve()
.then(send)
.catch((error) => {
try {
onError(error);
} catch {
// A throwing error handler must not crash the process either.
}
});
}
```

Behavior after the fix:

- The send remains **non-blocking** — response timing is unchanged
(timing-attack mitigation preserved).
- Sync throws and async rejections are both caught and logged via the NestJS
logger (context `BetterAuthConfig`), including the **stack trace**:
`Failed to send verification email: <message>`.
- Even a throwing error handler (e.g. a custom logger transport failing)
cannot re-introduce the crash.
- The API response to the client is unchanged (it never reflected send
failures — this matches Better-Auth's own default behavior).

**Covered by regression tests:** `tests/unit/better-auth-email-safe.spec.ts`
(async rejection, sync throw, success path, non-blocking guarantee, no
unhandled rejection, throwing error handler).

---

## Breaking Changes

None.

---

## Compatibility Notes

- **`IServerOptions` / `CoreModule.forRoot()`:** unchanged.
- **`SendVerificationEmailCallback` contract:** unchanged (`{ token, url, user }`
→ `Promise<void>`). Projects supplying their own callback need no changes —
their callback is now additionally protected by the wrapper.
- **Monitoring / operations:** if you relied on the process crash (supervisor
restart loops) as an implicit signal for a broken mail setup, switch your
alerting to the error log line `Failed to send verification email: …`
(logger context `BetterAuthConfig`). Note that the email-verification
service additionally logs delivery failures with a masked recipient address
before rethrowing — a real outage produces both lines.
- **Workarounds can be removed:** a custom `process.on('unhandledRejection')`
handler added to a project's `main.ts` solely to survive this crash is no
longer needed for this path.
- **Better-Auth standard mechanism:** the framework deliberately detaches only
the sends it owns instead of enabling Better-Auth's global
`advanced.backgroundTasks` option (which would detach ALL background tasks —
OTP, invites, password reset — and route failures to Better-Auth's internal
logger instead of the NestJS logger). Projects that want the native
mechanism can still enable it via the `betterAuth.options.advanced`
passthrough.
- **Vendor-mode consumers:** the fix lives in
`src/core/modules/better-auth/better-auth.config.ts` and is picked up by the
next core sync (`/lt-dev:backend:update-nest-server-core`). The regression
test file (`tests/unit/`) is framework-repo-only and is not part of the
vendored file set.
- **Custom controllers / resolvers extending Core\* classes:** no impact — no
Core method signatures changed.

---

## Troubleshooting

### Verification emails silently don't arrive after updating

Nothing regressed — before this fix the same situation crashed the API
instead. Check the logs for `Failed to send verification email: …`
(context `BetterAuthConfig`) and fix the underlying SMTP/Brevo configuration
(`email.smtp` / provider settings in `config.env.ts`).

### I see two error log lines for one failed send

Expected. The email-verification service logs the delivery failure with a
masked recipient address and rethrows; the wrapper logs the same failure once
more (with stack trace) as the final safety net. The wrapper line additionally
covers failures thrown outside the service's own try/catch.

---

## Repo-Internal Tooling Changes (no consumer action)

These affect only this repository's development workflow — listed here because
some projects copied `scripts/check.mjs` from 11.27.1+:

- **`scripts/check.mjs` idle watchdog:** a step whose child produces no output
for 300s (configurable via `--idle-timeout=<seconds>` / `CHECK_IDLE_TIMEOUT`,
`0` disables) is now killed — including its whole process tree — and the
check fails with a clear `[watchdog]` reason. Previously a deadlocked test
run (workers idle at 0% CPU) spun the live view forever. If you copied
`check.mjs` into your project, update your copy to get the watchdog.
- **Per-run test databases:** `tests/global-setup.ts` now gives every vitest
run a unique database (`<base>-run-<timestamp>-p<pid>`) instead of dropping
a shared fixed-name DB — concurrent runs (second terminal, IDE runner) can
no longer wipe each other's users/sessions mid-flight. A new reporter
(`tests/db-lifecycle.reporter.ts`) drops the DB after a successful run and
collects stale leftovers; after a failed run the DB is kept for debugging
until the next successful run. An externally set `MONGODB_URI` (CI) keeps
the previous behavior.

---

## Module Documentation

### BetterAuth

- **README:** [src/core/modules/better-auth/README.md](../src/core/modules/better-auth/README.md)
- **Integration Checklist:** [src/core/modules/better-auth/INTEGRATION-CHECKLIST.md](../src/core/modules/better-auth/INTEGRATION-CHECKLIST.md)
- **Key File:** `src/core/modules/better-auth/better-auth.config.ts` —
`sendAuthEmailSafely()` helper + `buildEmailVerificationConfig()` wiring

---

## References

- [Migration Guide 11.27.1 → 11.27.2](./11.27.1-to-11.27.2.md) — Previous release (chain-faithful audit step + security overrides)
- [nest-server-starter](https://github.com/lenneTech/nest-server-starter) — reference implementation
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@lenne.tech/nest-server",
"version": "11.27.2",
"version": "11.27.3",
"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).",
"keywords": [
"node",
Expand Down
72 changes: 69 additions & 3 deletions scripts/check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
* Exit code: 0 when every step passed, 1 otherwise (preserves the contract the
* lt-dev `running-check-script` skill relies on: non-zero === failed).
*/
import { spawn } from "node:child_process";
import { execSync, spawn } from "node:child_process";
import { readdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
Expand All @@ -37,6 +37,17 @@ const NO_FIX = process.argv.includes("--no-fix");
const PROJECT_FILTERS = process.argv
.filter((a) => a.startsWith("--project="))
.map((a) => a.slice("--project=".length));
// Watchdog: kill a step whose child produces NO output for this long. A wedged
// test run (workers deadlocked at 0% CPU) otherwise spins the live view
// forever — the spinner only proves the child process exists, not that it
// progresses. Override with --idle-timeout=<seconds> or CHECK_IDLE_TIMEOUT
// (seconds); 0 disables the watchdog.
const IDLE_TIMEOUT_MS = (() => {
const flag = process.argv.find((a) => a.startsWith("--idle-timeout="));
const raw = flag ? flag.slice("--idle-timeout=".length) : process.env.CHECK_IDLE_TIMEOUT;
const seconds = raw === undefined || raw === "" ? 300 : Number(raw);
return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : 0;
})();
// Verbose streams raw output, so the in-place live view is disabled there.
const TTY = Boolean(process.stdout.isTTY) && !VERBOSE;

Expand Down Expand Up @@ -144,19 +155,72 @@ async function runAudit(auditCmd) {

// ── command runner ─────────────────────────────────────────────────────────
const RUNNING = new Set();

// Best-effort kill of a child's whole process tree (sh → pnpm → vitest →
// fork workers). Killing only the direct child orphans the tree — exactly the
// zombie workers a deadlock leaves behind. Children are collected via pgrep
// and killed leaves-first.
function killTree(child, signal = "SIGTERM") {
const pids = [];
const collect = (pid) => {
pids.push(pid);
let out = "";
try {
out = execSync(`pgrep -P ${pid}`, { stdio: ["ignore", "pipe", "ignore"] })
.toString()
.trim();
} catch {
/* no children */
}
if (out) for (const p of out.split("\n")) collect(Number(p));
};
collect(child.pid);
for (const pid of pids.reverse()) {
try {
process.kill(pid, signal);
} catch {
/* already gone */
}
}
}

function capture(cmd, cwd) {
return new Promise((resolve) => {
const child = spawn(cmd, { cwd, shell: true });
RUNNING.add(child);
let out = "";
let idleTimer = null;
let watchdogHit = false;
// Any output resets the watchdog — only complete silence for the full
// window counts as wedged. Escalate to SIGKILL for processes that ignore
// SIGTERM (deadlocked event loops usually still honor TERM, but be sure).
const armWatchdog = () => {
if (!IDLE_TIMEOUT_MS) return;
clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
watchdogHit = true;
killTree(child);
setTimeout(() => killTree(child, "SIGKILL"), 5000).unref();
}, IDLE_TIMEOUT_MS);
};
const onData = (d) => {
out += d;
armWatchdog();
if (VERBOSE) process.stdout.write(d);
};
armWatchdog();
child.stdout.on("data", onData);
child.stderr.on("data", onData);
const done = (code, extra) => {
clearTimeout(idleTimer);
RUNNING.delete(child);
if (watchdogHit) {
const note =
`[watchdog] step produced no output for ${Math.round(IDLE_TIMEOUT_MS / 1000)}s — ` +
"process tree killed as deadlocked. This is a hang (workers idle at 0% CPU), " +
"not a slow run. Re-run the step directly to debug, e.g. `pnpm run vitest`.";
return resolve({ code: 1, out: `${out}\n${note}` });
}
resolve({ code, out: extra ? `${out}\n${extra}` : out });
};
child.on("close", (code) => done(code ?? 1));
Expand All @@ -166,7 +230,7 @@ function capture(cmd, cwd) {
function killAll() {
for (const child of RUNNING) {
try {
child.kill("SIGTERM");
killTree(child);
} catch {
/* already gone */
}
Expand Down Expand Up @@ -361,7 +425,9 @@ async function main() {
console.log(
C.dim(
`${projects.length} project(s) · ${stepCount} steps · ${mode} · audit: ${auditCmd ?? "none"}` +
`${NO_FIX ? "" : " · auto-fix format+lint"}${VERBOSE ? " · verbose" : ""}\n`,
`${NO_FIX ? "" : " · auto-fix format+lint"}` +
` · watchdog: ${IDLE_TIMEOUT_MS ? fmtDuration(IDLE_TIMEOUT_MS) : "off"}` +
`${VERBOSE ? " · verbose" : ""}\n`,
),
);

Expand Down
2 changes: 1 addition & 1 deletion spectaql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ servers:
info:
title: lT Nest Server
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).
version: 11.27.2
version: 11.27.3
contact:
name: lenne.Tech GmbH
url: https://lenne.tech
Expand Down
Loading
Loading