@lenne.tech/nest-server 11.38.0 → 11.40.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.
Files changed (91) hide show
  1. package/.claude/rules/configurable-features.md +24 -1
  2. package/.claude/rules/module-inheritance.md +2 -0
  3. package/.claude/rules/package-management.md +117 -2
  4. package/.claude/rules/testing.md +238 -5
  5. package/CLAUDE.md +13 -1
  6. package/FRAMEWORK-API.md +2 -2
  7. package/dist/config.env.js +4 -2
  8. package/dist/config.env.js.map +1 -1
  9. package/dist/core/common/helpers/config.helper.d.ts +2 -0
  10. package/dist/core/common/helpers/config.helper.js +18 -0
  11. package/dist/core/common/helpers/config.helper.js.map +1 -1
  12. package/dist/core/common/helpers/cookies.helper.d.ts +3 -0
  13. package/dist/core/common/helpers/cookies.helper.js +9 -0
  14. package/dist/core/common/helpers/cookies.helper.js.map +1 -1
  15. package/dist/core/common/helpers/input.helper.d.ts +1 -0
  16. package/dist/core/common/helpers/input.helper.js +4 -0
  17. package/dist/core/common/helpers/input.helper.js.map +1 -1
  18. package/dist/core/common/helpers/service.helper.js +6 -1
  19. package/dist/core/common/helpers/service.helper.js.map +1 -1
  20. package/dist/core/common/interceptors/check-security.interceptor.js +1 -0
  21. package/dist/core/common/interceptors/check-security.interceptor.js.map +1 -1
  22. package/dist/core/common/interfaces/server-options.interface.d.ts +2 -1
  23. package/dist/core/common/services/email.service.d.ts +4 -1
  24. package/dist/core/common/services/email.service.js +25 -2
  25. package/dist/core/common/services/email.service.js.map +1 -1
  26. package/dist/core/common/services/module.service.js +1 -0
  27. package/dist/core/common/services/module.service.js.map +1 -1
  28. package/dist/core/modules/ai/providers/openai-compatible.provider.d.ts +2 -0
  29. package/dist/core/modules/ai/providers/openai-compatible.provider.js +28 -3
  30. package/dist/core/modules/ai/providers/openai-compatible.provider.js.map +1 -1
  31. package/dist/core/modules/better-auth/better-auth.config.js +7 -0
  32. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  33. package/dist/core/modules/better-auth/core-better-auth-api.middleware.js +3 -1
  34. package/dist/core/modules/better-auth/core-better-auth-api.middleware.js.map +1 -1
  35. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.d.ts +3 -1
  36. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +30 -5
  37. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
  38. package/dist/core/modules/better-auth/core-better-auth-error-codes.helper.d.ts +2 -0
  39. package/dist/core/modules/better-auth/core-better-auth-error-codes.helper.js +54 -0
  40. package/dist/core/modules/better-auth/core-better-auth-error-codes.helper.js.map +1 -0
  41. package/dist/core/modules/better-auth/index.d.ts +1 -0
  42. package/dist/core/modules/better-auth/index.js +1 -0
  43. package/dist/core/modules/better-auth/index.js.map +1 -1
  44. package/dist/core/modules/error-code/error-codes.d.ts +27 -0
  45. package/dist/core/modules/error-code/error-codes.js +24 -0
  46. package/dist/core/modules/error-code/error-codes.js.map +1 -1
  47. package/dist/core/modules/hub/core-hub.service.js +1 -0
  48. package/dist/core/modules/hub/core-hub.service.js.map +1 -1
  49. package/dist/core/modules/user/core-user.model.d.ts +1 -0
  50. package/dist/core/modules/user/core-user.model.js +11 -1
  51. package/dist/core/modules/user/core-user.model.js.map +1 -1
  52. package/dist/core/modules/user/core-user.service.d.ts +9 -0
  53. package/dist/core/modules/user/core-user.service.js +96 -4
  54. package/dist/core/modules/user/core-user.service.js.map +1 -1
  55. package/dist/server/modules/error-code/error-codes.d.ts +3 -0
  56. package/dist/server/modules/user/user.model.d.ts +5 -0
  57. package/dist/server/modules/user/user.service.js +12 -6
  58. package/dist/server/modules/user/user.service.js.map +1 -1
  59. package/dist/templates/password-reset-de.ejs +12 -0
  60. package/dist/templates/password-reset-en.ejs +12 -0
  61. package/dist/templates/password-reset.ejs +1 -0
  62. package/dist/tsconfig.build.tsbuildinfo +1 -1
  63. package/docs/REQUEST-LIFECYCLE.md +14 -0
  64. package/migration-guides/11.37.x-to-11.38.x.md +18 -1
  65. package/migration-guides/11.38.x-to-11.39.x.md +456 -0
  66. package/migration-guides/11.39.0-to-11.40.0.md +186 -0
  67. package/package.json +5 -4
  68. package/src/config.env.ts +19 -3
  69. package/src/core/common/helpers/config.helper.ts +79 -0
  70. package/src/core/common/helpers/cookies.helper.ts +38 -0
  71. package/src/core/common/helpers/input.helper.ts +37 -0
  72. package/src/core/common/helpers/service.helper.ts +9 -1
  73. package/src/core/common/interceptors/check-security.interceptor.ts +1 -0
  74. package/src/core/common/interfaces/server-options.interface.ts +120 -4
  75. package/src/core/common/services/email.service.ts +46 -1
  76. package/src/core/common/services/module.service.ts +1 -0
  77. package/src/core/modules/ai/README.md +33 -0
  78. package/src/core/modules/ai/providers/openai-compatible.provider.ts +76 -3
  79. package/src/core/modules/better-auth/better-auth.config.ts +25 -0
  80. package/src/core/modules/better-auth/core-better-auth-api.middleware.ts +8 -1
  81. package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +101 -6
  82. package/src/core/modules/better-auth/core-better-auth-error-codes.helper.ts +146 -0
  83. package/src/core/modules/better-auth/index.ts +1 -0
  84. package/src/core/modules/error-code/error-codes.ts +55 -0
  85. package/src/core/modules/hub/core-hub.service.ts +1 -0
  86. package/src/core/modules/user/core-user.model.ts +28 -1
  87. package/src/core/modules/user/core-user.service.ts +267 -5
  88. package/src/server/modules/user/user.service.ts +26 -7
  89. package/src/templates/password-reset-de.ejs +12 -0
  90. package/src/templates/password-reset-en.ejs +12 -0
  91. package/src/templates/password-reset.ejs +1 -0
@@ -0,0 +1,186 @@
1
+ # Migration Guide: 11.39.0 → 11.40.0
2
+
3
+ > **Why a MINOR for what looks like a patch.** In this package the MAJOR digit tracks the NestJS
4
+ > major (11.x = NestJS 11), so it is not ours to spend — and every breaking change of our own ships
5
+ > as a MINOR instead. This release contains one: a security control that was silently inert starts
6
+ > being enforced, and a deployment that relied on the inert behaviour can stop working. The number
7
+ > of affected projects is small; the rule does not ask how many, it asks whether a working
8
+ > deployment can break.
9
+
10
+ ## Overview
11
+
12
+ | Category | Effort | Applies to |
13
+ |----------|--------|-----------|
14
+ | **Breaking (behaviour)** | 5 minutes | Projects that set `ai.allowedBaseUrlHosts` **as a string** (i.e. via `NSC__AI__ALLOWED_BASE_URL_HOSTS`) |
15
+ | Bugfix | none | Everyone using the AI module |
16
+ | Internal tooling | none | Nobody — `scripts/` does not ship |
17
+
18
+ Almost every project can update with `pnpm update @lenne.tech/nest-server` and read no further.
19
+ **One group cannot**, and for them the change is the uncomfortable kind: a security control that was
20
+ silently inert starts working, and a working deployment can stop working as a result.
21
+
22
+ ## Quick Migration
23
+
24
+ ```bash
25
+ # Does this affect you? If both come back empty, you are done.
26
+ grep -r "NSC__AI__ALLOWED_BASE_URL_HOSTS" . --include="*.env*" --include="*.yml" --include="*.yaml" 2>/dev/null
27
+ grep -rn "allowedBaseUrlHosts" src/ 2>/dev/null
28
+ ```
29
+
30
+ ## Breaking Change: `ai.allowedBaseUrlHosts` set as a string was never enforced
31
+
32
+ ### What was wrong
33
+
34
+ `ai.allowedBaseUrlHosts` is the SSRF egress allowlist for AI connection base URLs. It is reachable
35
+ through the framework's own environment mapping:
36
+
37
+ ```bash
38
+ NSC__AI__ALLOWED_BASE_URL_HOSTS=llm.example.com,api.openai.com
39
+ ```
40
+
41
+ `getEnvironmentObject()` turns that into `{ ai: { allowedBaseUrlHosts: '<string>' } }`, and lodash
42
+ `merge` assigns the scalar straight over the configured array.
43
+
44
+ **And there is no way to avoid that from the environment.** The `NSC__*` reader coerces exactly
45
+ three things — `'true'`, `'false'`, and anything `Number()` accepts — and leaves everything else a
46
+ string. No CSV, JSON array literal or other notation produces an array:
47
+
48
+ ```bash
49
+ NSC__AI__ALLOWED_BASE_URL_HOSTS='["a.example.com"]' # -> the string '["a.example.com"]'
50
+ NSC__AI__MAX_ITERATIONS=7 # -> the number 7
51
+ ```
52
+
53
+ So this is not an edge case for people who happened to pick the wrong notation. **If you configured
54
+ the allowlist through the environment at all, it was off — completely, on every deployment, for its
55
+ whole life.** Verified empirically against the built `config.helper.js`, not inferred from the code.
56
+ Reported by the nest-server-starter session, which went looking for why a string arrives in the
57
+ first place.
58
+
59
+ That gives you a clean dividing line, and it is the only thing you need to check:
60
+
61
+ | How you set `ai.allowedBaseUrlHosts` | Were you affected? |
62
+ |---|---|
63
+ | `NSC__AI__ALLOWED_BASE_URL_HOSTS` (or any env route) | **Yes — the control was inert** |
64
+ | A real array in `config.env.ts` | No — it worked as documented |
65
+ | Not set at all | No — no restriction was intended |
66
+
67
+ The guard then did:
68
+
69
+ ```typescript
70
+ if (!Array.isArray(allowedHosts) || !allowedHosts.length) {
71
+ return; // read as "no allowlist configured"
72
+ }
73
+ ```
74
+
75
+ So a string was read as **"not configured"** and the check was skipped entirely — no log line, no
76
+ error. An operator who used the canonical `NSC__` spelling (the documented form for every other
77
+ setting) had **no egress restriction at all** while believing the control was on.
78
+
79
+ ### What changes in 11.40.0
80
+
81
+ A string is now parsed as a comma-separated list, so the setting does what it says.
82
+
83
+ **This is a behaviour change in the restrictive direction.** If you set it as a string, your
84
+ deployment went from *no restriction* to *enforced*. Any AI connection whose host is not in that
85
+ list now fails with `ServiceUnavailableException` and a WARN naming the host. There is no
86
+ deprecation window, because leaving an SSRF control off for a release cycle is worse than the
87
+ breakage.
88
+
89
+ ### Before upgrading
90
+
91
+ 1. Find the configured value:
92
+ ```bash
93
+ grep -r "NSC__AI__ALLOWED_BASE_URL_HOSTS" . --include="*.env*" --include="*.yml" 2>/dev/null
94
+ ```
95
+ 2. List every `baseUrl` your connections actually use — including any seeded via
96
+ `ai.defaultConnection`, and any added at runtime through `aiConnections`:
97
+ ```
98
+ query { aiConnections { name baseUrl enabled } }
99
+ ```
100
+ 3. Confirm every one of those hosts appears in the list. Add the missing ones, or clear the setting
101
+ entirely if you did not mean to restrict egress:
102
+ ```bash
103
+ NSC__AI__ALLOWED_BASE_URL_HOSTS=llm.example.com,api.openai.com,localhost:11434
104
+ ```
105
+
106
+ **Unset is still permissive**, unchanged — a local Ollama works out of the box.
107
+
108
+ ### Matching rules
109
+
110
+ Worth reading once, because they are the likely cause of a surprising refusal:
111
+
112
+ | Entry | Matches | Does not match |
113
+ |-------|---------|----------------|
114
+ | `llm.example.com` | any port on that host | `llm.example.com.evil.test` |
115
+ | `llm.internal:8080` | exactly that port | `llm.internal:9200` |
116
+ | `example.com:443` | `https://example.com/` (default port) | `http://example.com/` |
117
+ | `LLM.Example.com` | `https://llm.example.com/` | — |
118
+
119
+ Entries and URLs are both trimmed, lowercased and stripped of a fully-qualifying trailing dot, so
120
+ neither side can win by spelling one DNS name differently.
121
+
122
+ ## Bugfix: the allowlist now covers every outbound path
123
+
124
+ `probeContextWindow()` (the Ollama `/api/show` probe behind `detectContextWindow()`) reached the
125
+ network **without consulting the allowlist**. It is not an admin-only path: `CoreAiService` calls
126
+ `detectAndPersistCapabilities()` on an ordinary user prompt whenever `contextWindow` is undefined,
127
+ and it runs *before* the AI rate limit.
128
+
129
+ All three outbound paths — chat completions, the capability probe, and the context-window probe —
130
+ now go through the same check. A refusal there degrades to "context window unknown" and falls back
131
+ to the built-in model table, so nothing breaks for an allowed host.
132
+
133
+ **No action required**, unless you were relying on the context-window probe reaching a host your
134
+ allowlist excludes — in which case add that host.
135
+
136
+ ## Bugfix: a malformed value is now reported
137
+
138
+ A value that is neither a list nor a string (a number, a boolean, an object — all reachable through
139
+ `NEST_SERVER_CONFIG`) carries no hostnames, so the allowlist cannot be applied. That was silent.
140
+ It is now logged as an error:
141
+
142
+ ```
143
+ ai.allowedBaseUrlHosts is a number and carries no hostnames — the SSRF egress allowlist is
144
+ NOT active. Use an array or a comma-separated string.
145
+ ```
146
+
147
+ The behaviour is unchanged (permissive); only the silence is gone. From the outside, "misconfigured"
148
+ and "deliberately unset" looked identical, which is what let the original defect survive.
149
+
150
+ ## Not consumer-facing
151
+
152
+ The rest of this release is repository tooling and does not ship: a new `check:overrides` guard for
153
+ stale pnpm overrides and audit suppressions, a `pnpm peers check` step, audit-count accounting in
154
+ `scripts/check.mjs`, and test-evidence work. `package.json` `files` ships `dist` plus docs, and the
155
+ CLI's vendor transformation copies `src/core/` — neither includes `scripts/`.
156
+
157
+ Four further tooling fixes landed late in the release, all in the same area and all of the same
158
+ kind — a gate that reported safety it had not established:
159
+
160
+ | Fix | What it was |
161
+ |-----|-------------|
162
+ | Audit hang guard (`CHECK_AUDIT_TIMEOUT`, default 600s) | `pnpm audit` emits no intermediate output, so the existing idle watchdog structurally could not tell a hang from a healthy slow run. A second, absolute cap now kills it with its own cause |
163
+ | `'unreadable'` degradation | An audit exiting **0** with no parseable tally printed a GREEN tick and a literal `0`, having assessed nothing. Reachable in practice: a pnpm version collision writes its error to stderr and exits 0 |
164
+ | 5xx read from the code field only | The signature matched `\b5\d\d\b` against the whole envelope, so pnpm's own `audited 503 packages` degraded a run that had to block |
165
+ | Reachability probe asked the wrong registry | Both check scripts hardcoded `registry.npmjs.org` while pnpm audits against the **configured** registry. Behind a private registry or proxy that reproduces the very false-green the probe removes: the real registry is unreachable, npmjs.org answers, the run reports "clean" |
166
+ | Steps list contradicted the warning | A degraded audit printed a yellow warning and "NOT CHECKED" — and a **green tick** in the Steps list, which hard-coded one per step |
167
+ | JSONC comment stripping | A regex stripper ate glob patterns out of `tsconfig.tests.json` and `.oxlintrc.json` (2783 bytes, and one whole `overrides` entry). Both files still parsed, so the assertions above them ran green against a mutilated config |
168
+
169
+ None of them changes shipped behaviour; they are recorded because each one made a **check** claim
170
+ something it had not verified, and that is the class of defect a consumer inherits indirectly — via
171
+ a release that passed a gate which was not looking.
172
+
173
+ ## Troubleshooting
174
+
175
+ | Symptom | Cause | Fix |
176
+ |---------|-------|-----|
177
+ | AI stopped working after the update; log shows `host "…" is not in ai.allowedBaseUrlHosts` | The allowlist is now enforced where it previously was not | Add the host, or clear the setting |
178
+ | `ServiceUnavailableException` on one connection only | That connection's `baseUrl` host is missing from the list | Add it — check `aiConnections`, not just `defaultConnection` |
179
+ | Error log says the allowlist is `NOT active` | The value is not a list or a string | Use an array, or a comma-separated string |
180
+ | An entry with `:443` stopped matching an `http://` URL | `:443` is the https default, not http's | Write the host without a port, or with the right one |
181
+
182
+ ## Module Documentation
183
+
184
+ - `src/core/modules/ai/README.md` → "Egress allowlist (`ai.allowedBaseUrlHosts`)"
185
+ - `.claude/rules/configurable-features.md` → "AI Egress Allowlist (SSRF)"
186
+ - `src/core/common/interfaces/server-options.interface.ts` → `IAi.allowedBaseUrlHosts`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "11.38.0",
3
+ "version": "11.40.0",
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",
@@ -25,11 +25,12 @@
25
25
  "cf": "pnpm run check:fix",
26
26
  "check": "node scripts/check.mjs",
27
27
  "check:consumer": "node scripts/check-consumer.mjs",
28
- "check:fix": "pnpm install && pnpm run spectaql:sync && pnpm audit --fix && pnpm run format && pnpm run lint:fix && pnpm run typecheck:tests && pnpm run check:swc-tdz && pnpm test && pnpm run build && pnpm run check:manifest && bash scripts/check-server-start.sh",
28
+ "check:fix": "pnpm install && pnpm run spectaql:sync && pnpm audit --fix && pnpm run check:overrides && pnpm peers check && pnpm run format && pnpm run lint:fix && pnpm run typecheck:tests && pnpm run check:swc-tdz && pnpm test && pnpm run build && pnpm run check:manifest && bash scripts/check-server-start.sh",
29
29
  "check:manifest": "node scripts/check-package-manifest.mjs",
30
30
  "check:mutations": "node scripts/check-mutations.mjs",
31
- "check:naf": "pnpm install && pnpm run spectaql:sync && pnpm run format && pnpm run lint:fix && pnpm run typecheck:tests && pnpm run check:swc-tdz && pnpm test && pnpm run build && pnpm run check:manifest && bash scripts/check-server-start.sh",
32
- "check:raw": "pnpm install --frozen-lockfile && pnpm run spectaql:sync && pnpm audit && pnpm run format:check && pnpm run lint && pnpm run typecheck:tests && pnpm run check:swc-tdz && pnpm test && pnpm run build && pnpm run check:manifest && bash scripts/check-server-start.sh",
31
+ "check:naf": "pnpm install && pnpm run spectaql:sync && pnpm run check:overrides && pnpm peers check && pnpm run format && pnpm run lint:fix && pnpm run typecheck:tests && pnpm run check:swc-tdz && pnpm test && pnpm run build && pnpm run check:manifest && bash scripts/check-server-start.sh",
32
+ "check:overrides": "node scripts/check-overrides.mjs",
33
+ "check:raw": "pnpm install --frozen-lockfile && pnpm run spectaql:sync && pnpm audit && pnpm run check:overrides && pnpm peers check && pnpm run format:check && pnpm run lint && pnpm run typecheck:tests && pnpm run check:swc-tdz && pnpm test && pnpm run build && pnpm run check:manifest && bash scripts/check-server-start.sh",
33
34
  "check:swc-tdz": "nest build -b swc -p tsconfig.swc-tdz.json && node scripts/check-swc-tdz.mjs",
34
35
  "cnaf": "pnpm run check:naf",
35
36
  "docs": "pnpm run docs:ci && open http://127.0.0.1:8080/ && open ./public/index.html && compodoc -p tsconfig.json -s ",
package/src/config.env.ts CHANGED
@@ -3,7 +3,7 @@ import * as dotenv from 'dotenv';
3
3
  import { join } from 'path';
4
4
 
5
5
  import { RoleEnum } from './core/common/enums/role.enum';
6
- import { getEnvironmentConfig } from './core/common/helpers/config.helper';
6
+ import { getEnvironmentConfig, resolveSmtpSecure } from './core/common/helpers/config.helper';
7
7
  import { IServerOptions } from './core/common/interfaces/server-options.interface';
8
8
 
9
9
  /**
@@ -22,6 +22,16 @@ import { IServerOptions } from './core/common/interfaces/server-options.interfac
22
22
  // misconfiguration still surface.
23
23
  dotenv.config({ quiet: true });
24
24
 
25
+ /**
26
+ * Resolved once so the TLS flag can be derived from the very port that will be used.
27
+ *
28
+ * `secure` means implicit TLS, which only port 465 speaks; 587 upgrades via STARTTLS and needs
29
+ * `secure: false`. This profile used to hard-default `secure` to true alongside port 587 — a pair
30
+ * that cannot connect, and which killed every outgoing mail in a default production deployment
31
+ * while the API still answered 200. See `resolveSmtpSecure()` for the full account.
32
+ */
33
+ const productionSmtpPort = parseInt(process.env.SMTP_PORT || '587', 10);
34
+
25
35
  const config: { [env: string]: IServerOptions } = {
26
36
  // ===========================================================================
27
37
  // CI environment
@@ -712,8 +722,14 @@ const config: { [env: string]: IServerOptions } = {
712
722
  user: process.env.SMTP_USER,
713
723
  },
714
724
  host: process.env.SMTP_HOST,
715
- port: parseInt(process.env.SMTP_PORT || '587', 10),
716
- secure: process.env.SMTP_SECURE !== 'false',
725
+ port: productionSmtpPort,
726
+ // Refuse to send rather than send in the clear. `secure: false` means STARTTLS, and
727
+ // nodemailer upgrades only when the server ADVERTISES it — an on-path attacker who strips
728
+ // that capability line gets the SMTP credentials and a working password-reset link in
729
+ // plaintext, silently. Repairing the transport without pinning it would have shipped that.
730
+ // Inert on 465, where the connection is already TLS from the first byte.
731
+ requireTLS: process.env.SMTP_REQUIRE_TLS !== 'false',
732
+ secure: resolveSmtpSecure(process.env.SMTP_SECURE, productionSmtpPort),
717
733
  },
718
734
  },
719
735
  env: 'production',
@@ -194,3 +194,82 @@ export function merge(obj: Record<string, any>, ...sources: any[]): any {
194
194
  }
195
195
  });
196
196
  }
197
+
198
+ /**
199
+ * Resolve nodemailer's `smtp.secure` flag, deriving it from the port when it was not set.
200
+ *
201
+ * ── The defect this exists for ─────────────────────────────────────────────────
202
+ * `secure` does not mean "use encryption". It means "start the TLS handshake IMMEDIATELY, before
203
+ * any SMTP conversation" — implicit TLS, which only port **465** speaks. Port 587 is the
204
+ * submission port: the session opens in plaintext and is upgraded via STARTTLS, which nodemailer
205
+ * does on its own with `secure: false`.
206
+ *
207
+ * Pairing the common default port 587 with `secure: true` therefore cannot work. Nodemailer sends
208
+ * a TLS ClientHello, the server answers with an SMTP greeting, and OpenSSL reports
209
+ * `wrong version number`. This framework's `production` profile shipped exactly that pairing —
210
+ * port 587 by default, `secure` true unless explicitly disabled — so a deployment that configured
211
+ * nothing beyond host and credentials could not send ANY mail.
212
+ *
213
+ * It stayed invisible because authentication mail is deliberately fire-and-forget: the send is not
214
+ * awaited (it would leak whether an address exists), so the failure never reached a response. The
215
+ * API answered 200, the operator saw success, and every password-reset mail died in transport with
216
+ * only a log line. Found in production, not by a test.
217
+ *
218
+ * ── Why ONLY the two canonical values override ─────────────────────────────────
219
+ * `secure` is not an independent setting. It is a CONSEQUENCE of the port: 465 negotiates TLS
220
+ * immediately, everything else upgrades through STARTTLS. Letting the two be configured
221
+ * independently is precisely what allowed the broken pairing to exist.
222
+ *
223
+ * Both obvious string rules have a silent wrong side:
224
+ *
225
+ * `value !== 'false'` — an unset or unknown value yields TRUE, paired with 587. The reported
226
+ * outage.
227
+ * `value === 'true'` — `1` or `yes` yields FALSE. On port 465 that is a plaintext handshake
228
+ * against a TLS-only port: the same outage, mirrored.
229
+ *
230
+ * So only `'true'` and `'false'` override. Anything else — `1`, `yes`, a typo — falls back to the
231
+ * port, which is the fact rather than a guess. No input value can produce a pair that cannot
232
+ * connect, which is a stronger property than either rule had.
233
+ *
234
+ * The one behaviour change for a value that IS set: a non-canonical value on a non-465 port now
235
+ * resolves to `false` where it used to be `true`. Every such combination was broken, so this fixes
236
+ * rather than regresses — and `SMTP_SECURE=1` on 465, the case worth protecting, still resolves to
237
+ * `true` via the port.
238
+ *
239
+ * An explicit `'true'` on 587 (or `'false'` on 465) is still honoured and still impossible; that is
240
+ * deliberate, and `warnOnImpossibleSmtpTlsCombination()` reports it rather than overruling it.
241
+ *
242
+ * @param value - the raw `SMTP_SECURE` environment value, if any
243
+ * @param port - the resolved SMTP port
244
+ * @returns whether nodemailer should open the connection with implicit TLS
245
+ */
246
+ export function resolveSmtpSecure(value: string | undefined, port: number): boolean {
247
+ const normalized = typeof value === 'string' ? value.trim().toLowerCase() : undefined;
248
+
249
+ if (normalized === 'true') {
250
+ return true;
251
+ }
252
+ if (normalized === 'false') {
253
+ return false;
254
+ }
255
+
256
+ // Unset or non-canonical: 465 is the only port that speaks implicit TLS.
257
+ return port === 465;
258
+ }
259
+
260
+ /**
261
+ * Whether an SMTP port/TLS pair describes a connection that cannot succeed.
262
+ *
263
+ * `secure: true` off port 465 is the fatal one — a TLS handshake against a plaintext greeting.
264
+ * `secure: false` ON 465 is its mirror and equally broken, just rarer.
265
+ *
266
+ * Reported rather than corrected: a deployment may legitimately run submission on a non-standard
267
+ * port, and silently overriding an explicit setting is how the original defect became invisible in
268
+ * the first place.
269
+ */
270
+ export function isImpossibleSmtpTlsCombination(port: number, secure: boolean): boolean {
271
+ if (!Number.isFinite(port)) {
272
+ return false;
273
+ }
274
+ return secure ? port !== 465 : port === 465;
275
+ }
@@ -577,3 +577,41 @@ export function buildCorsConfig(options: Partial<IServerOptions>): Record<string
577
577
  // No origins resolvable → return empty (secure default — no open CORS with credentials)
578
578
  return {};
579
579
  }
580
+
581
+ /**
582
+ * The app URL as every mail-link builder in this package must resolve it.
583
+ *
584
+ * ── Why this exists rather than four inline reads ──────────────────────────────
585
+ * Two classes build a password-reset link — `CoreUserService.buildPasswordResetLink()` for the
586
+ * legacy flow and `CoreBetterAuthEmailVerificationService.buildPasswordResetUrl()` for IAM — and a
587
+ * third builds the verification link. They are near-identical by construction and were maintained
588
+ * by hand, which is exactly how they drifted: one was fixed to resolve through `resolveServerUrls`
589
+ * while the others kept reading `appUrl` straight off the configuration.
590
+ *
591
+ * A raw read is wrong in three situations that all look fine locally:
592
+ *
593
+ * - `local` / `ci` / `e2e` do not set `appUrl` — their localhost default lives inside
594
+ * `resolveServerUrls`, so a raw read yields nothing and the builder falls back or returns null.
595
+ * - A host-split `baseUrl` such as `https://api.crm.localhost` (what `lt dev up` serves) carries
596
+ * the app origin one label away; a raw read cannot see it.
597
+ * - `cors.deriveAppUrl: false` is how a deployment states that the apex domain is NOT its own —
598
+ * the documented case being a third-party-hosted marketing site. Deriving anyway puts a
599
+ * password-reset token into that origin's access log.
600
+ *
601
+ * The third point is why this is a shared function rather than a convention: it is a security
602
+ * decision, and a security decision repeated by hand in three places is one that will eventually
603
+ * be made differently in one of them.
604
+ *
605
+ * @param configService - anything exposing the frozen-config reader
606
+ * @returns the resolved app URL, or `undefined` when nothing can be resolved — never a guess
607
+ */
608
+ export function resolveAppUrlFromConfig(configService: {
609
+ getFastButReadOnly<T = any>(key: string, defaultValue?: any): T;
610
+ }): string | undefined {
611
+ return resolveServerUrls({
612
+ appUrl: configService.getFastButReadOnly<string>('appUrl'),
613
+ baseUrl: configService.getFastButReadOnly<string>('baseUrl'),
614
+ deriveAppUrl: configService.getFastButReadOnly<boolean>('cors.deriveAppUrl'),
615
+ env: configService.getFastButReadOnly<string>('env'),
616
+ }).appUrl;
617
+ }
@@ -856,3 +856,40 @@ export function typeofArray(arr: any[], strict = false): string {
856
856
  }
857
857
  return type;
858
858
  }
859
+
860
+ /**
861
+ * Whether a value is safe to use as the right-hand side of a Mongoose equality filter.
862
+ *
863
+ * ── The hole this closes ───────────────────────────────────────────────────────
864
+ * A controller parameter declared as `@Body('token') token: string` has
865
+ * `metatype === String`, and `MapAndValidatePipe` short-circuits on exactly that shape:
866
+ * `if (!value || typeof value !== 'object' || !metatype || isBasicType(metatype)) return value`.
867
+ * The declared type is erased at runtime, so nothing checks it — a JSON object reaches the
868
+ * service verbatim.
869
+ *
870
+ * `findOne({ passwordResetToken: token })` with `token = { $ne: null }` therefore selects the
871
+ * first user holding ANY live reset token, and the caller sets that person's password without
872
+ * ever seeing their mail. Confirmed by probe against the real route, not by reading.
873
+ *
874
+ * Express parses query strings with `qs` in extended mode, so `?token[$ne]=` produces the same
875
+ * object without a JSON body at all — `@Query('token') token: string` is exposed identically.
876
+ *
877
+ * `{ $ne: null }` is the takeover primitive; `null` is the quieter one, because MongoDB matches
878
+ * MISSING fields against `null` and selects a user who never requested anything.
879
+ *
880
+ * ── Why a guard at the sink rather than `mongoose.set('sanitizeFilter', true)` ──
881
+ * The global switch wraps every object-valued filter in `$eq`, which would break the framework's
882
+ * own operator-bearing queries (`$in`, `$ne`, `$gt` in the filter helpers) unless each is wrapped
883
+ * in `mongoose.trusted()`. That is a fleet-wide audit, not a fix. This guard is exact: it says
884
+ * "this particular value came from a client and must be a plain string".
885
+ *
886
+ * Rejects an empty string too — `findOne({ token: '' })` is never a legitimate credential lookup,
887
+ * and `undefined` would be stripped from the filter entirely by Mongoose, turning the query into
888
+ * `findOne({})` and matching the first document in the collection.
889
+ *
890
+ * @param value - the raw value as it arrived from the transport
891
+ * @returns whether it may be used as a filter value
892
+ */
893
+ export function isQueryableString(value: unknown): value is string {
894
+ return typeof value === 'string' && value.length > 0;
895
+ }
@@ -18,7 +18,15 @@ import { clone, plainToInstanceClean, processDeep } from './input.helper';
18
18
  // Fields like refreshTokens/tempTokens are kept — they are needed for token validation
19
19
  // and process flows (password reset, email verification). The CheckSecurityInterceptor
20
20
  // removes those from HTTP responses as a separate layer.
21
- const SECRET_FIELD_NAMES = Object.freeze(['password', 'verificationToken', 'passwordResetToken']);
21
+ const SECRET_FIELD_NAMES = Object.freeze([
22
+ 'password',
23
+ 'verificationToken',
24
+ 'passwordResetToken',
25
+ // `S_NO_ONE` covers Model instances; this list is what runs on a plain-object path (`.lean()`,
26
+ // `aggregate`, a spread). Its sibling token has always been here — the timestamp says "a reset
27
+ // is pending for this account", which is exactly what the field's own JSDoc calls attacker-useful.
28
+ 'passwordResetTokenExpiresAt',
29
+ ]);
22
30
 
23
31
  /**
24
32
  * Helper class for services
@@ -21,6 +21,7 @@ export class CheckSecurityInterceptor implements NestInterceptor {
21
21
  'password',
22
22
  'verificationToken',
23
23
  'passwordResetToken',
24
+ 'passwordResetTokenExpiresAt',
24
25
  'refreshTokens',
25
26
  'tempTokens',
26
27
  'apiKeyEncrypted',
@@ -297,6 +297,51 @@ export interface IAuthPasswordReset {
297
297
  * ```
298
298
  */
299
299
  preventUserEnumeration?: boolean;
300
+
301
+ /**
302
+ * How long a LEGACY password-reset token stays valid, in minutes.
303
+ *
304
+ * **Before 11.38.0 it never expired at all.** `resetPassword()` looked the token up by value and
305
+ * nothing else, while the exception it threw on a miss read "Invalid or expired password reset
306
+ * token" — a message describing a check that did not exist. A reset link is a bearer credential
307
+ * for full account takeover, so an unbounded one means a mail sitting in an archive, a forwarded
308
+ * message or a restored backup opens the account years later.
309
+ *
310
+ * That gap became more reachable in this very release, which is why it is closed here: until now
311
+ * a project relying on the default mailed a link containing the word `undefined`, so the eternal
312
+ * token was unusable by accident. Repairing the link without adding an expiry would have turned a
313
+ * dead credential into a live and permanent one.
314
+ *
315
+ * The IAM flow already expires its token after one hour (Better-Auth's
316
+ * `resetPasswordTokenExpiresIn`), so 60 matches the half of the framework that had it right.
317
+ *
318
+ * **Value semantics — the two ends mean different things, deliberately:**
319
+ *
320
+ * | Value | Meaning |
321
+ * |-------|---------|
322
+ * | unset | 60 minutes |
323
+ * | a positive number | that many minutes |
324
+ * | `0` | **no expiry** — restores the pre-11.38.0 behaviour |
325
+ * | negative, `NaN`, non-numeric | 60 minutes, i.e. the safe default rather than "unbounded" |
326
+ *
327
+ * `0` opting OUT while an invalid value falls back to the DEFAULT is intentional: switching the
328
+ * expiry off is a decision somebody has to state, and a typo in an environment variable must
329
+ * never be the thing that states it.
330
+ *
331
+ * **Upgrade note:** a token minted before this release carries no expiry timestamp and is treated
332
+ * as expired. Anyone holding an unredeemed reset mail must request a new one — which, for every
333
+ * project that relied on the default link, is the first one that will actually work.
334
+ *
335
+ * @default 60
336
+ *
337
+ * @example
338
+ * ```typescript
339
+ * auth: {
340
+ * passwordReset: { tokenExpiresInMinutes: 15 },
341
+ * }
342
+ * ```
343
+ */
344
+ tokenExpiresInMinutes?: number;
300
345
  }
301
346
 
302
347
  export interface IAuthLegacyEndpoints {
@@ -493,6 +538,12 @@ export interface IBetterAuthEmailVerificationConfig {
493
538
  * When not set, the verification link points directly to the backend
494
539
  * endpoint which handles verification and redirects.
495
540
  *
541
+ * Since 11.38.0 the generated link carries the recipient's address as well —
542
+ * `{callbackURL}?token=<token>&email=<address>`. The verification page needs it to offer
543
+ * "send a new email" once the token has expired, and it cannot recover the address itself:
544
+ * that value lives inside the token's JWT payload, and reading it there would mean rendering
545
+ * data from an unverified signature.
546
+ *
496
547
  * @default undefined (backend-handled verification)
497
548
  * @since 11.13.0
498
549
  */
@@ -560,9 +611,15 @@ export interface IBetterAuthEmailVerificationConfig {
560
611
  *
561
612
  * Set to `false` to keep Better-Auth's own link.
562
613
  *
614
+ * **Resolution order**, first hit wins: this option → the caller's `redirectTo` → `<appUrl>/auth/
615
+ * reset-password` → Better-Auth's own URL. `false` is a hard opt-out that a `redirectTo` does not
616
+ * override.
617
+ *
563
618
  * Note this is separate from `email.passwordResetLink`, which serves the LEGACY
564
- * `/users/password/reset-request` flow and appends the token as a path segment. Two flows, two
565
- * conventions; a project using both should point them at the same page.
619
+ * `/users/password/reset-request` flow. Both DEFAULTS point at the same page with `?token=`; what
620
+ * still differs is the fallback for a configured value without a placeholder `?token=` here, a
621
+ * path segment there. A project using both should point them at the same page, and writing
622
+ * `{token}` in both makes them identical.
566
623
  *
567
624
  * @default `<appUrl>/auth/reset-password?token={token}`
568
625
  * @since 11.38.0
@@ -1655,9 +1712,25 @@ export interface IAi {
1655
1712
  * bare `hostname`); unset → permissive (so local providers like Ollama on localhost
1656
1713
  * work out of the box). `baseUrl` is admin-only, so this guards a compromised or
1657
1714
  * misconfigured admin, not end-user input.
1715
+ *
1716
+ * Accepts an array OR a comma-separated string. The string form is not a convenience:
1717
+ * it is the shape the framework's own env mapping produces. `NSC__AI__ALLOWED_BASE_URL_HOSTS`
1718
+ * becomes `{ ai: { allowedBaseUrlHosts: '<string>' } }`, and lodash `merge` assigns that
1719
+ * scalar straight over a configured array — so the canonical `NSC__` spelling MUST be
1720
+ * understood here or the control silently switches itself off.
1721
+ *
1722
+ * Entries are trimmed, lowercased and stripped of a fully-qualifying trailing dot, and the
1723
+ * same normalisation is applied to the URL being checked, so neither side can win by
1724
+ * spelling the same DNS name differently. A bare hostname entry matches any port on that
1725
+ * host; an entry naming the scheme's default port (`example.com:443` for https) also matches
1726
+ * the portless URL. A value that is neither an array nor a string carries no hostnames, so
1727
+ * the allowlist is inactive — that case is LOGGED as an error rather than passed over, since
1728
+ * it looks identical to "correctly unset" from the outside.
1729
+ *
1658
1730
  * @example ['llm.example.com', 'localhost:11434']
1731
+ * @example 'llm.example.com,localhost:11434' // NSC__AI__ALLOWED_BASE_URL_HOSTS
1659
1732
  */
1660
- allowedBaseUrlHosts?: string[];
1733
+ allowedBaseUrlHosts?: string | string[];
1661
1734
 
1662
1735
  /**
1663
1736
  * Persist an audit record (`aiInteractions`) for every prompt run (admin-readable).
@@ -2184,7 +2257,50 @@ export interface IServerOptions {
2184
2257
  mailjet?: MailjetOptions;
2185
2258
 
2186
2259
  /**
2187
- * Password reset link for email
2260
+ * Base of the link in the LEGACY password-reset mail (`/users/password/reset-request`).
2261
+ *
2262
+ * Three cases, and the difference between them is where the token lands:
2263
+ *
2264
+ * | Value | Link the recipient gets |
2265
+ * | --------------------------------------- | ---------------------------------------- |
2266
+ * | contains `{token}` | placeholder replaced, wherever it sits |
2267
+ * | set, but no `{token}` | token appended as a PATH segment |
2268
+ * | not set (the default below) | `?token=<token>`, per the default's shape |
2269
+ *
2270
+ * **Copying the default is safe; writing your own base is where it gets sharp.** The default
2271
+ * carries `{token}`, so pasting it lands in row one and behaves exactly as leaving the option
2272
+ * out. A base WITHOUT the placeholder lands in row two instead — same URL, different link. That
2273
+ * is deliberate: it is the convention the legacy flow has always used, and changing it would
2274
+ * silently break every project whose page reads a path parameter. It is also easy to walk into
2275
+ * by accident, so the framework logs a warning at boot for any configured value without a
2276
+ * placeholder. The way to silence it is to write `{token}` where you want the token, which
2277
+ * states the convention in the place it applies instead of leaving it implicit.
2278
+ *
2279
+ * Build the link with `CoreUserService.buildPasswordResetLink(token)` rather than by
2280
+ * concatenation. It returns `null` when it can resolve nothing — which is the difference
2281
+ * between sending no mail and sending one whose link reads `undefined/<token>`. The latter
2282
+ * happened in a downstream project and reached real recipients: the request succeeds, the mail
2283
+ * arrives, it looks right, and only the click reveals it, to somebody who by definition has no
2284
+ * second way in.
2285
+ *
2286
+ * Note this is separate from `betterAuth.emailVerification.passwordResetLink`, which serves the
2287
+ * IAM flow. Both DEFAULTS now point at the same page with `?token=`; what still differs is the
2288
+ * fallback for a configured value without a placeholder — path segment here, `?token=` there.
2289
+ * A project using both flows should point them at the same page, and writing `{token}` in both
2290
+ * makes them identical.
2291
+ *
2292
+ * @default `<appUrl>/auth/reset-password?token={token}`
2293
+ * @since 11.38.0
2294
+ *
2295
+ * @example
2296
+ * ```typescript
2297
+ * email: {
2298
+ * // A page that reads the token from the path. Spelling out `{token}` is what keeps the
2299
+ * // boot warning quiet — `'https://example.com/auth/reset-password'` produces the same
2300
+ * // link, but leaves the reader guessing which convention was meant.
2301
+ * passwordResetLink: 'https://example.com/auth/reset-password/{token}',
2302
+ * }
2303
+ * ```
2188
2304
  */
2189
2305
  passwordResetLink?: string;
2190
2306