@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
@@ -1,8 +1,9 @@
1
1
  import { createHash } from 'crypto';
2
- import { Inject, Injectable, OnModuleDestroy, Optional } from '@nestjs/common';
2
+ import { Inject, Injectable, Logger, OnModuleDestroy, Optional } from '@nestjs/common';
3
3
  import nodemailer = require('nodemailer');
4
4
  import { Attachment } from 'nodemailer/lib/mailer';
5
5
 
6
+ import { isImpossibleSmtpTlsCombination } from '../helpers/config.helper';
6
7
  import { isNonEmptyString, isTrue, returnFalse } from '../helpers/input.helper';
7
8
  import { MailTransportOptions } from '../interfaces/server-options.interface';
8
9
  import { HUB_EMAIL_CAPTURE } from '../../modules/hub/hub.constants';
@@ -22,6 +23,11 @@ export class EmailService implements OnModuleDestroy {
22
23
  private cachedTransporter: nodemailer.Transporter | null = null;
23
24
  private cachedSmtpConfig: string | null = null;
24
25
 
26
+ protected readonly emailServiceLogger = new Logger(EmailService.name);
27
+
28
+ /** Once-per-process latch for the SMTP TLS warning — a per-send warning would be noise. */
29
+ private smtpTlsWarningEmitted = false;
30
+
25
31
  /**
26
32
  * Inject services
27
33
  */
@@ -33,6 +39,43 @@ export class EmailService implements OnModuleDestroy {
33
39
  @Optional() @Inject(HUB_EMAIL_CAPTURE) protected readonly emailCapture?: IHubEmailCapture,
34
40
  ) {}
35
41
 
42
+ /**
43
+ * Warn once when the SMTP port and TLS mode describe a connection that cannot be established.
44
+ *
45
+ * `secure: true` means implicit TLS, which only port 465 speaks. On 587 — the submission port,
46
+ * which upgrades through STARTTLS — nodemailer sends a TLS ClientHello, the server answers with
47
+ * an SMTP greeting, and OpenSSL reports `wrong version number`. This package's own `production`
48
+ * profile shipped that pair, and because authentication mail is deliberately not awaited, the
49
+ * API answered 200 while every message died in transport. It was found in production.
50
+ *
51
+ * Reported rather than corrected: a deployment may legitimately run submission on a non-standard
52
+ * port, and silently overriding an explicit setting is how the original defect stayed invisible.
53
+ * A warning names the problem without deciding it.
54
+ */
55
+ protected warnOnImpossibleSmtpTlsCombination(smtp: unknown): void {
56
+ if (this.smtpTlsWarningEmitted || typeof smtp !== 'object' || smtp === null) {
57
+ return;
58
+ }
59
+
60
+ const { port, secure } = smtp as { port?: unknown; secure?: unknown };
61
+ if (typeof port !== 'number' || typeof secure !== 'boolean') {
62
+ return;
63
+ }
64
+
65
+ if (!isImpossibleSmtpTlsCombination(port, secure)) {
66
+ return;
67
+ }
68
+
69
+ this.smtpTlsWarningEmitted = true;
70
+ this.emailServiceLogger.warn(
71
+ `SMTP is configured with port ${port} and secure: ${secure}, a combination that cannot connect. ` +
72
+ '`secure: true` starts TLS immediately, which only port 465 supports; port 587 and friends open in ' +
73
+ 'plaintext and upgrade via STARTTLS, which needs `secure: false`. Expect ' +
74
+ '"wrong version number" from OpenSSL and NO outgoing mail — silently, because authentication mail ' +
75
+ 'is not awaited. Set SMTP_SECURE=false for port 587, or SMTP_PORT=465 to keep implicit TLS.',
76
+ );
77
+ }
78
+
36
79
  onModuleDestroy(): void {
37
80
  if (this.cachedTransporter) {
38
81
  this.cachedTransporter.close();
@@ -109,6 +152,8 @@ export class EmailService implements OnModuleDestroy {
109
152
  }
110
153
  }
111
154
 
155
+ this.warnOnImpossibleSmtpTlsCombination(smtp);
156
+
112
157
  // Hub mailbox capture (Mailpit-style). Runs after templates are rendered, before the transport.
113
158
  // In capture mode it records the mail and suppresses the send (returns a jsonTransport-like ack).
114
159
  // Fully guarded: a broken mailbox hook must never break (or crash) the mail path.
@@ -177,6 +177,7 @@ export abstract class ModuleService<T extends CoreModel = any> {
177
177
  'password',
178
178
  'verificationToken',
179
179
  'passwordResetToken',
180
+ 'passwordResetTokenExpiresAt',
180
181
  'refreshTokens',
181
182
  'tempTokens',
182
183
  ]);
@@ -159,6 +159,39 @@ calling and executes tools itself through `CrudService` with the caller's permis
159
159
  the child runs in a temp dir so no `CLAUDE.md`/settings leak into the context. See
160
160
  `ClaudeCliProvider` for the full security model and the optional `ai.claudeCli` config.
161
161
 
162
+ ## Egress allowlist (`ai.allowedBaseUrlHosts`)
163
+
164
+ A connection's `baseUrl` decides where the server sends outbound HTTP. It is admin-set, so the
165
+ threat model is a compromised or mistyped admin rather than end-user input — but the request still
166
+ leaves from inside your network, which is what makes it an SSRF surface.
167
+
168
+ **Unset (the default) means no restriction**, so a local provider works out of the box. When set,
169
+ only the listed hosts are reachable and everything else is refused with
170
+ `ServiceUnavailableException` plus a WARN naming the host:
171
+
172
+ ```typescript
173
+ ai: {
174
+ allowedBaseUrlHosts: ['llm.example.com', 'localhost:11434'],
175
+ }
176
+ ```
177
+
178
+ ```bash
179
+ # Same setting via the canonical env spelling — a comma-separated string is understood
180
+ NSC__AI__ALLOWED_BASE_URL_HOSTS=llm.example.com,localhost:11434
181
+ ```
182
+
183
+ Matching details worth knowing before you debug a refusal:
184
+
185
+ - A bare hostname entry matches **any port** on that host. An entry that names the scheme's default
186
+ port (`example.com:443` for https) also matches the portless URL.
187
+ - Entries and URLs are both trimmed, lowercased, and stripped of a fully-qualifying trailing dot, so
188
+ `LLM.Example.com`, `llm.example.com` and `llm.example.com.` are one host.
189
+ - The check covers **all three** outbound paths: chat completions, the capability probe, and the
190
+ Ollama context-window probe.
191
+ - A value that is neither a list nor a string carries no hostnames. The allowlist is then inactive
192
+ and the framework logs an error — from the outside that state is indistinguishable from
193
+ "correctly unset", which is exactly why it is not silent.
194
+
162
195
  ## Connections (DB configuration)
163
196
 
164
197
  Connections live in the `aiConnections` collection and are managed by admins via
@@ -134,8 +134,8 @@ export class OpenAiCompatibleProvider implements ILlmProvider {
134
134
  * admin, not an end-user input.
135
135
  */
136
136
  protected assertBaseUrlAllowed(url: string): void {
137
- const allowedHosts = ConfigService.get<string[]>('ai.allowedBaseUrlHosts');
138
- if (!Array.isArray(allowedHosts) || !allowedHosts.length) {
137
+ const allowedHosts = this.resolveAllowedBaseUrlHosts();
138
+ if (!allowedHosts.length) {
139
139
  return;
140
140
  }
141
141
  let parsed: URL;
@@ -144,7 +144,17 @@ export class OpenAiCompatibleProvider implements ILlmProvider {
144
144
  } catch {
145
145
  throw new ServiceUnavailableException(ErrorCode.AI_CONNECTION_INVALID_URL);
146
146
  }
147
- if (!allowedHosts.includes(parsed.host) && !allowedHosts.includes(parsed.hostname)) {
147
+ // A hostname entry stays host-wide (any port); the extra candidates only make an
148
+ // operator who was MORE explicit than necessary succeed rather than fail. `URL.host`
149
+ // omits the default port, so a conscientious `llm.example.com:443` entry would
150
+ // otherwise never match `https://llm.example.com/` — a lockout whose only symptom is
151
+ // a WARN and "the AI stopped working". Nothing here widens the set of reachable hosts.
152
+ const defaultPort = parsed.protocol === 'https:' ? '443' : parsed.protocol === 'http:' ? '80' : '';
153
+ const candidates = [parsed.host, parsed.hostname];
154
+ if (defaultPort && parsed.host === parsed.hostname) {
155
+ candidates.push(`${parsed.hostname}:${defaultPort}`);
156
+ }
157
+ if (!candidates.map((candidate) => this.normaliseHostEntry(candidate)).some((c) => allowedHosts.includes(c))) {
148
158
  this.logger.warn(
149
159
  `AI connection "${this.connection.name}" host "${parsed.host}" is not in ai.allowedBaseUrlHosts`,
150
160
  );
@@ -152,6 +162,62 @@ export class OpenAiCompatibleProvider implements ILlmProvider {
152
162
  }
153
163
  }
154
164
 
165
+ /**
166
+ * The configured egress allowlist as a lowercase array, whatever shape it has.
167
+ *
168
+ * A STRING is split as CSV rather than rejected, because `ai.allowedBaseUrlHosts`
169
+ * is reachable through the framework's own `NSC__AI__ALLOWED_BASE_URL_HOSTS`
170
+ * environment mapping: `getEnvironmentObject()` turns that variable into
171
+ * `{ ai: { allowedBaseUrlHosts: '<string>' } }` and lodash `merge` assigns the
172
+ * scalar straight over the configured array. A bare `!Array.isArray(...) -> return`
173
+ * then reads it as "no allowlist configured" and skips the check entirely — so an
174
+ * operator using the canonical `NSC__` spelling silently disables SSRF egress
175
+ * control, with no log line and no error. A malformed security setting must be
176
+ * interpreted or fail CLOSED, never fail open.
177
+ *
178
+ * Entries are lowercased because `URL.host` / `URL.hostname` always are; a
179
+ * differently-cased entry would otherwise fail closed for no stated reason, and
180
+ * the only symptom would be a WARN log plus "the AI stopped working".
181
+ *
182
+ * A value that is NEITHER an array nor a string carries no hostnames and cannot be
183
+ * interpreted — `NSC__AI__ALLOWED_BASE_URL_HOSTS=0` coerces to a number, and
184
+ * `NEST_SERVER_CONFIG` can deliver an object. Returning an empty list there is the
185
+ * only honest answer, but it reopens egress while the operator believes the control
186
+ * is on, so it is LOGGED every time rather than passed over in silence. That is the
187
+ * difference between this and the documented unset-is-permissive default: unset is a
188
+ * decision, a malformed value is an accident nobody is told about.
189
+ */
190
+ protected resolveAllowedBaseUrlHosts(): string[] {
191
+ const configured = ConfigService.get<unknown>('ai.allowedBaseUrlHosts');
192
+ if (configured === undefined || configured === null) {
193
+ return [];
194
+ }
195
+ const entries = typeof configured === 'string' ? configured.split(',') : configured;
196
+ if (!Array.isArray(entries)) {
197
+ this.logger.error(
198
+ `ai.allowedBaseUrlHosts is a ${typeof configured} and carries no hostnames — the SSRF egress ` +
199
+ 'allowlist is NOT active. Use an array or a comma-separated string.',
200
+ );
201
+ return [];
202
+ }
203
+ return entries.map((host) => this.normaliseHostEntry(String(host))).filter(Boolean);
204
+ }
205
+
206
+ /**
207
+ * Lowercase, trim, and drop a fully-qualifying trailing dot.
208
+ *
209
+ * Applied to BOTH the allowlist entry and the URL being checked, so neither side can
210
+ * win by spelling the same DNS name differently. `llm.example.com.` and
211
+ * `llm.example.com` resolve identically, so treating them as different hosts only ever
212
+ * produced a confusing refusal — never protection.
213
+ */
214
+ protected normaliseHostEntry(value: string): string {
215
+ return value
216
+ .trim()
217
+ .toLowerCase()
218
+ .replace(/\.(?=$|:)/, '');
219
+ }
220
+
155
221
  /**
156
222
  * Probe the backend to auto-detect capabilities for flags the connection left
157
223
  * undefined. Explicit flags are authoritative and are NOT probed. Best effort:
@@ -263,6 +329,13 @@ export class OpenAiCompatibleProvider implements ILlmProvider {
263
329
  if (!base.startsWith('http')) {
264
330
  return undefined;
265
331
  }
332
+ // The third outbound path from the same admin-controlled baseUrl, and the one the
333
+ // allowlist used to miss. It is NOT admin-only in practice: CoreAiService calls
334
+ // detectAndPersistCapabilities() on an ordinary user prompt whenever contextWindow is
335
+ // undefined, and it runs BEFORE checkRateLimit(). A guard applied to two of three
336
+ // egress paths is not a guard. Throwing is right here — detectContextWindow() already
337
+ // wraps this call, so a refusal degrades to "context window unknown".
338
+ this.assertBaseUrlAllowed(`${base}/api/show`);
266
339
  const response = await fetch(`${base}/api/show`, {
267
340
  body: JSON.stringify({ name: this.connection.model }),
268
341
  headers: { 'Content-Type': 'application/json' },
@@ -1691,6 +1691,18 @@ function validateConfig(
1691
1691
  case 'auto-generated':
1692
1692
  warnings.push('BETTER_AUTH: No secret configured - using auto-generated secret.');
1693
1693
  warnings.push('CONSEQUENCE: All user sessions will be invalidated on server restart!');
1694
+ // The session consequence above is transient — a restart clears it. This one is not, and it
1695
+ // surfaces long after the cause: the JWT plugin PERSISTS a `jwks` document encrypted with
1696
+ // whatever secret was active when it was created. Booting once without a secret is therefore
1697
+ // enough to leave a row that no later, real secret can decrypt. Nothing fails at the time;
1698
+ // `/iam/token` starts answering "Failed to decrypt private key" whenever somebody next asks
1699
+ // for a JWT, which on a deployment with few sign-ins can be months later. Observed in
1700
+ // production on a key written at setup time.
1701
+ warnings.push(
1702
+ 'CONSEQUENCE: a `jwks` key persisted now is encrypted with THIS throwaway secret and stays ' +
1703
+ 'unreadable once a real one is configured — /iam/token then fails with "Failed to decrypt ' +
1704
+ 'private key". Drop the `jwks` collection after setting a permanent secret.',
1705
+ );
1694
1706
  warnings.push(
1695
1707
  'FOR PRODUCTION: Set betterAuth.secret in config or provide a valid fallback secret (min 32 chars).',
1696
1708
  );
@@ -1700,6 +1712,19 @@ function validateConfig(
1700
1712
  warnings.push(
1701
1713
  'BETTER_AUTH: Using fallback secret (backwards compatible). Consider setting betterAuth.secret explicitly.',
1702
1714
  );
1715
+ // The same persistence trap as the auto-generated branch, and MORE likely to be walked into,
1716
+ // because this transition is a planned upgrade step rather than an accident: a deployment
1717
+ // runs on `jwt.secret`, the JWT plugin persists a `jwks` document encrypted with it, and
1718
+ // months later somebody "configures betterAuth properly" with a NEW value. The key is then
1719
+ // unreadable, nothing fails at the time, and `/iam/token` starts answering "Failed to
1720
+ // decrypt private key" whenever the next JWT is requested. Observed in production, where
1721
+ // cause and symptom were five weeks apart.
1722
+ warnings.push(
1723
+ 'BEFORE setting betterAuth.secret later: any `jwks` key persisted now is encrypted with THIS ' +
1724
+ 'fallback secret. Setting a DIFFERENT explicit secret makes it unreadable and /iam/token ' +
1725
+ 'then fails with "Failed to decrypt private key" — reuse the same value, or drop the `jwks` ' +
1726
+ 'collection when you change it.',
1727
+ );
1703
1728
  break;
1704
1729
  // 'explicit' - no warning needed, explicitly configured
1705
1730
  }
@@ -5,6 +5,7 @@ import { isProduction } from '../../common/helpers/logging.helper';
5
5
  import { ConfigService } from '../../common/services/config.service';
6
6
  import { CoreBetterAuthChallengeService } from './core-better-auth-challenge.service';
7
7
  import { BetterAuthCookieHelper, createCookieHelper } from './core-better-auth-cookie.helper';
8
+ import { wrapBetterAuthErrorResponse } from './core-better-auth-error-codes.helper';
8
9
  import { runWithResetPassword } from './core-better-auth-password-reset.registry';
9
10
  import { CoreBetterAuthUserMapper } from './core-better-auth-user.mapper';
10
11
  import { extractSessionToken, sendWebResponse, signCookieValue, toWebRequest } from './core-better-auth-web.helper';
@@ -310,10 +311,16 @@ export class CoreBetterAuthApiMiddleware implements NestMiddleware {
310
311
  // Better-Auth invokes inside this call, and which is told WHICH user was reset but not
311
312
  // to WHAT — can read the new password and mirror it into the legacy store. The context
312
313
  // lives exactly as long as the handler call, so no other request can observe it.
313
- const response = resetPassword
314
+ const rawResponse = resetPassword
314
315
  ? await runWithResetPassword(resetPassword, () => authInstance.handler(webRequest))
315
316
  : await authInstance.handler(webRequest);
316
317
 
318
+ // The single choke point for every error exit below. Each later branch acts only on
319
+ // `response.ok`, so rewriting failures HERE reaches all of them without touching any — and
320
+ // without a second place that has to remember to do it. Successful responses are returned
321
+ // by identity, so nothing on the happy path changes shape.
322
+ const response = await wrapBetterAuthErrorResponse(rawResponse);
323
+
317
324
  this.logger.debug(`Better Auth handler response: ${response.status}`);
318
325
 
319
326
  // For passkey generate requests with DB storage, extract verificationToken and store mapping
@@ -4,7 +4,7 @@ import ejs = require('ejs');
4
4
  import * as fs from 'fs';
5
5
  import * as path from 'path';
6
6
 
7
- import { isProductionLikeEnv } from '../../common/helpers/cookies.helper';
7
+ import { isProductionLikeEnv, resolveAppUrlFromConfig } from '../../common/helpers/cookies.helper';
8
8
  import { maskEmail } from '../../common/helpers/logging.helper';
9
9
  import { IBetterAuthEmailVerificationConfig } from '../../common/interfaces/server-options.interface';
10
10
  import { BrevoService } from '../../common/services/brevo.service';
@@ -222,7 +222,7 @@ export class CoreBetterAuthEmailVerificationService {
222
222
  try {
223
223
  // Override URL if callbackURL is configured (frontend-based verification)
224
224
  if (this.config.callbackURL) {
225
- url = this.buildFrontendVerificationUrl(token);
225
+ url = this.buildFrontendVerificationUrl(token, user.email);
226
226
  }
227
227
 
228
228
  this.logAuthUrlForDevelopment('EMAIL VERIFICATION', user.email, url);
@@ -358,6 +358,10 @@ export class CoreBetterAuthEmailVerificationService {
358
358
  const templateData = {
359
359
  appName,
360
360
  link: url,
361
+ // Better-Auth's own lifetime, NOT the legacy `auth.passwordReset.tokenExpiresInMinutes`.
362
+ // The two flows expire independently, and announcing the wrong one would be worse than
363
+ // announcing none.
364
+ linkExpiresInMinutes: this.passwordResetLinkExpiryMinutes(),
361
365
  name: user.name || user.email.split('@')[0],
362
366
  };
363
367
 
@@ -596,6 +600,27 @@ export class CoreBetterAuthEmailVerificationService {
596
600
  * `?token=`. That is what lets a page reading a PATH parameter configure
597
601
  * `https://example.com/auth/reset-password/{token}`.
598
602
  */
603
+ /**
604
+ * How long the IAM reset link stays valid, in whole minutes, for the mail to state.
605
+ *
606
+ * Read from Better-Auth's own `emailAndPassword.resetPasswordTokenExpiresIn` — expressed in
607
+ * SECONDS, defaulting to 3600 (`password.mjs`). Deliberately not the legacy
608
+ * `auth.passwordReset.tokenExpiresInMinutes`: the two flows expire independently, and a mail that
609
+ * announces the other flow's deadline is worse than one that announces none.
610
+ *
611
+ * Returns 0 for anything unusable, which the templates render as no sentence at all — silence is
612
+ * the safe failure for a deadline nobody can verify.
613
+ */
614
+ protected passwordResetLinkExpiryMinutes(): number {
615
+ const raw = this.configService.getFastButReadOnly<unknown>(
616
+ 'betterAuth.options.emailAndPassword.resetPasswordTokenExpiresIn',
617
+ );
618
+ const seconds = typeof raw === 'string' ? Number(raw) : raw;
619
+ const resolved = typeof seconds === 'number' && Number.isFinite(seconds) && seconds > 0 ? seconds : 3600;
620
+
621
+ return Math.max(1, Math.round(resolved / 60));
622
+ }
623
+
599
624
  protected buildPasswordResetUrl(options: SendPasswordResetEmailOptions): string {
600
625
  const configured = this.config.passwordResetLink;
601
626
 
@@ -606,8 +631,21 @@ export class CoreBetterAuthEmailVerificationService {
606
631
 
607
632
  let target = typeof configured === 'string' && configured.trim().length ? configured.trim() : undefined;
608
633
 
634
+ // The caller's own `redirectTo`, when it sent one, beats anything this server would guess.
635
+ // `nuxt-base-starter` sends it on every request (`forgot-password.vue` →
636
+ // `requestPasswordReset({ email, redirectTo: appUrl('/auth/reset-password', siteUrl) })`), and
637
+ // a client that names its page is a better authority on that page than a framework default.
638
+ if (!target) {
639
+ target = this.readCallbackUrlFromBetterAuthLink(options.url);
640
+ }
641
+
642
+ // The SAME resolver the legacy twin uses. Until 11.39.0 this read `appUrl` straight off the
643
+ // configuration, which meant no localhost default in local/ci/e2e, no host-split `baseUrl`
644
+ // derivation, and no `cors.deriveAppUrl` opt-out — three behaviours the other half of the same
645
+ // framework had. Two hand-maintained copies of one decision is how that happened.
646
+ const appUrl = resolveAppUrlFromConfig(this.configService);
647
+
609
648
  if (!target) {
610
- const appUrl = this.configService.getFastButReadOnly<string>('appUrl');
611
649
  if (!appUrl) {
612
650
  // Nothing to point at. Better-Auth's link at least works, which beats a guess.
613
651
  return options.url;
@@ -617,7 +655,6 @@ export class CoreBetterAuthEmailVerificationService {
617
655
 
618
656
  // Resolve a relative value against appUrl, like buildFrontendVerificationUrl does.
619
657
  if (target.startsWith('/')) {
620
- const appUrl = this.configService.getFastButReadOnly<string>('appUrl');
621
658
  if (!appUrl) {
622
659
  return options.url;
623
660
  }
@@ -633,6 +670,43 @@ export class CoreBetterAuthEmailVerificationService {
633
670
  return `${target}${separator}token=${encodeURIComponent(token)}`;
634
671
  }
635
672
 
673
+ /**
674
+ * Read the `callbackURL` out of the link Better-Auth built, i.e. the caller's own `redirectTo`.
675
+ *
676
+ * ── Why reading this back is SAFE, and why that is not obvious ──────────────────
677
+ * `redirectTo` arrives in the REQUEST BODY of `/request-password-reset`, which is unauthenticated.
678
+ * Putting a client-supplied URL into a password-reset mail would otherwise be a token-exfiltration
679
+ * vector in its purest form: an attacker requests a reset for a victim's address with
680
+ * `redirectTo: https://evil.example`, and the victim receives a genuine mail, from the real
681
+ * sender, carrying a valid token to the attacker's site.
682
+ *
683
+ * It is safe here only because Better-Auth validates the value BEFORE this runs. The endpoint
684
+ * declares `use: [originCheck((ctx) => ctx.body.redirectTo)]`, so a `redirectTo` outside
685
+ * `trustedOrigins` is rejected at request time and `sendResetPassword` is never reached. By the
686
+ * time we see it, the origin is one this deployment already trusts.
687
+ *
688
+ * That makes `trustedOrigins` load-bearing for THIS path too, not only for CORS — which is worth
689
+ * knowing before anyone widens it. `cors.allowAll` does not widen it (an origin check has no
690
+ * "allow everything" mode), but an over-broad `allowedOrigins` would.
691
+ *
692
+ * Returns `undefined` for anything unparseable or absent, so the caller falls through to its own
693
+ * resolution rather than to a half-formed URL.
694
+ */
695
+ protected readCallbackUrlFromBetterAuthLink(betterAuthUrl: string): string | undefined {
696
+ if (typeof betterAuthUrl !== 'string' || !betterAuthUrl.length) {
697
+ return undefined;
698
+ }
699
+
700
+ try {
701
+ const callbackUrl = new URL(betterAuthUrl).searchParams.get('callbackURL');
702
+ return callbackUrl?.trim().length ? callbackUrl.trim() : undefined;
703
+ } catch {
704
+ // Not a parseable absolute URL. Nothing to read, and nothing worth logging — the caller has
705
+ // two further fallbacks.
706
+ return undefined;
707
+ }
708
+ }
709
+
636
710
  /**
637
711
  * Build the frontend verification URL from the configured callbackURL and token.
638
712
  *
@@ -641,7 +715,7 @@ export class CoreBetterAuthEmailVerificationService {
641
715
  * @param token - The verification token from Better-Auth
642
716
  * @returns The full frontend URL with token query parameter
643
717
  */
644
- protected buildFrontendVerificationUrl(token: string): string {
718
+ protected buildFrontendVerificationUrl(token: string, email?: string): string {
645
719
  let baseUrl = this.config.callbackURL!;
646
720
 
647
721
  // Resolve relative paths against appUrl
@@ -652,7 +726,28 @@ export class CoreBetterAuthEmailVerificationService {
652
726
 
653
727
  // Append token as query parameter
654
728
  const separator = baseUrl.includes('?') ? '&' : '?';
655
- return `${baseUrl}${separator}token=${token}`;
729
+ let url = `${baseUrl}${separator}token=${encodeURIComponent(token)}`;
730
+
731
+ // The address travels alongside the token so the page can offer "send a new email" when the
732
+ // token turns out to be expired — which is the ONLY moment that button matters, and precisely
733
+ // the moment the page has nothing else to work with. The verification page shipped by
734
+ // nuxt-base-starter gates that button on `route.query.email`, so without this the user is told
735
+ // correctly what went wrong and left with no way to fix it.
736
+ //
737
+ // The frontend cannot recover the address itself. It is inside the token's JWT payload, and
738
+ // reading it there would mean trusting an unverified signature for display — not a pattern to
739
+ // put in a starter.
740
+ //
741
+ // The cost, stated plainly: the address appears in the URL, so it reaches browser history and
742
+ // any access log the app keeps. It is the recipient's OWN address, arriving in their own
743
+ // mailbox next to a token that is far more sensitive, so this widens nothing that the link did
744
+ // not already carry. Encoded, because a `+` in an address (Gmail tags, and therefore most test
745
+ // addresses) would otherwise arrive as a space.
746
+ if (email) {
747
+ url += `&email=${encodeURIComponent(email)}`;
748
+ }
749
+
750
+ return url;
656
751
  }
657
752
 
658
753
  /**
@@ -0,0 +1,146 @@
1
+ import { ErrorCode } from '../error-code/error-codes';
2
+
3
+ /**
4
+ * Translate Better-Auth's own error codes into nest-server's `#LTNS_XXXX:` message format.
5
+ *
6
+ * ── Why this exists ────────────────────────────────────────────────────────────
7
+ * Frontends in this stack translate errors by parsing the message for nest-server's marker —
8
+ * `useLtErrorTranslation` matches `/^#([A-Z_]+_\d+):\s*(.+)$/` and nothing else. Better-Auth
9
+ * answers with `{ code: 'INVALID_TOKEN', message: 'Invalid token' }`, which carries no marker, so
10
+ * the parser finds no code and hands the raw string through. The end user is shown English
11
+ * developer text — not as an edge case, but as the NORMAL outcome of every IAM error.
12
+ *
13
+ * That is felt most on the password-reset page: a link that has expired produces `INVALID_TOKEN`,
14
+ * and the person who already cannot sign in is told "Invalid token" in a language the rest of the
15
+ * product does not use.
16
+ *
17
+ * Wrapping the message here fixes it for every consumer at once — including projects that do not
18
+ * use `@lenne.tech/nuxt-extensions` and would otherwise each need their own code table.
19
+ *
20
+ * ── Two rules that keep this safe ──────────────────────────────────────────────
21
+ * 1. The original `code` field is left ALONE. Anything keying on `code` — Better-Auth's own client,
22
+ * a project's error branch — keeps working. Only the human-facing `message` is rewritten.
23
+ * 2. An unknown code is passed through UNCHANGED. A guessed mapping would show a confident,
24
+ * wrong sentence, which is worse than an untranslated true one. New codes are added here
25
+ * deliberately, not inferred.
26
+ *
27
+ * Mapping only where the meaning is unambiguous. Three deliberate absences:
28
+ *
29
+ * - `SESSION_EXPIRED` means "re-authenticate for this sensitive action", which is not what
30
+ * nest-server's `TOKEN_EXPIRED` ("please sign in again") tells the user to do.
31
+ * - `USER_NOT_FOUND` is an account-enumeration signal. Better-Auth already exposes it as a `code`,
32
+ * but translating it into the user's language would make the oracle friendlier and more legible
33
+ * — the opposite direction from the rest of this release, which spent real effort closing the
34
+ * legacy reset endpoint's equivalent. Left untranslated on purpose.
35
+ * - Anything else Better-Auth may add later. An unmapped code passes through unchanged.
36
+ *
37
+ * `PASSWORD_TOO_SHORT` / `PASSWORD_TOO_LONG` ARE mapped, and their reachability is worth stating
38
+ * because it is narrower than it looks: the lt frontend hashes with `ltSha256` before sending, so
39
+ * every password arrives as 64 hex characters and neither bound is crossed. They remain reachable
40
+ * for a client that does not hash (this is a framework, not only the lt stack) and for a project
41
+ * that configures `minPasswordLength` above 64 — which the middleware reads as a passthrough
42
+ * option, so it is a supported configuration rather than a hypothetical.
43
+ */
44
+ const BETTER_AUTH_ERROR_CODE_MAP: Readonly<Record<string, string>> = Object.freeze({
45
+ EMAIL_ALREADY_VERIFIED: ErrorCode.EMAIL_ALREADY_VERIFIED,
46
+ EMAIL_NOT_VERIFIED: ErrorCode.EMAIL_VERIFICATION_REQUIRED,
47
+ INVALID_EMAIL_OR_PASSWORD: ErrorCode.INVALID_CREDENTIALS,
48
+ INVALID_PASSWORD: ErrorCode.INVALID_PASSWORD,
49
+ // Distinct codes, not LINK_INVALID_OR_EXPIRED: Better-Auth answers BOTH with HTTP 400 on
50
+ // `/reset-password`, so a page branching on status alone cannot tell them apart and shows "your
51
+ // link is dead". The user then requests a new link, pastes the same over-long passphrase from
52
+ // their password manager, and fails again — a closed loop that never names the cause.
53
+ PASSWORD_TOO_LONG: ErrorCode.PASSWORD_TOO_LONG,
54
+ PASSWORD_TOO_SHORT: ErrorCode.PASSWORD_TOO_SHORT,
55
+ // NOT ErrorCode.INVALID_TOKEN: that one is the legacy auth service's refresh/session token and
56
+ // reads "sign in again". Every Better-Auth INVALID_TOKEN reaches the user through a LINK in a
57
+ // mail — reset, verification, magic link — and telling somebody who cannot sign in to sign in is
58
+ // the one instruction that helps least.
59
+ INVALID_TOKEN: ErrorCode.LINK_INVALID_OR_EXPIRED,
60
+ // Also NOT ErrorCode.TOKEN_EXPIRED ("please sign in again"), for the same reason as above and
61
+ // with a sharper edge: Better-Auth throws TOKEN_EXPIRED in exactly ONE place — an expired
62
+ // verification LINK (email-verification.mjs:178) — one line above the INVALID_TOKEN it throws
63
+ // for a broken one. Splitting that single user action ("I clicked an old link in my mail") into
64
+ // two opposite instructions would be wrong in the MORE common half, since links expire far more
65
+ // often than they get mangled. Worse, at that point the user is typically not signed in at all,
66
+ // so "sign in again" is a dead end. `LINK_INVALID_OR_EXPIRED` covers expiry in its wording.
67
+ TOKEN_EXPIRED: ErrorCode.LINK_INVALID_OR_EXPIRED,
68
+ USER_ALREADY_EXISTS: ErrorCode.EMAIL_ALREADY_EXISTS,
69
+ USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL: ErrorCode.EMAIL_ALREADY_EXISTS,
70
+ });
71
+
72
+ /**
73
+ * The nest-server message for a Better-Auth error code, or `undefined` when it has no mapping.
74
+ *
75
+ * Exported so a project can reuse the same table rather than build a second one that drifts.
76
+ */
77
+ export function mapBetterAuthErrorCode(code: unknown): string | undefined {
78
+ return typeof code === 'string' ? BETTER_AUTH_ERROR_CODE_MAP[code] : undefined;
79
+ }
80
+
81
+ /**
82
+ * Rewrite a failed Better-Auth response so its `message` carries nest-server's error marker.
83
+ *
84
+ * Returns the ORIGINAL response object whenever nothing should change — a successful response, a
85
+ * body that is not JSON, or a code with no mapping. That matters: the response body is a stream
86
+ * that can be read once, so handing back an untouched original rather than a rebuilt copy keeps
87
+ * every other consumer of it working.
88
+ *
89
+ * Never throws. An error surfacing from the error-formatting path would replace a useful message
90
+ * with a 500, which is the one outcome worse than an untranslated string.
91
+ */
92
+ export async function wrapBetterAuthErrorResponse(response: Response): Promise<Response> {
93
+ if (response.ok) {
94
+ return response;
95
+ }
96
+
97
+ // A redirect is not ours to rewrite. Better-Auth reports some failures by REDIRECTING to the
98
+ // caller's `callbackURL` with `?error=<CODE>` (see `redirectOnError` in its email-verification
99
+ // route) — a 3xx with no JSON body, so there is no message to translate. Rewriting the code in
100
+ // that query string instead would invent a second contract on top of Better-Auth's documented
101
+ // one, which the frontend reads. Those cases stay untranslated on purpose, and the frontend maps
102
+ // `?error=` itself.
103
+ if (response.status >= 300 && response.status < 400) {
104
+ return response;
105
+ }
106
+
107
+ try {
108
+ const body = await response.clone().json();
109
+ const mapped = mapBetterAuthErrorCode(body?.code);
110
+
111
+ // No mapping, or a message that already carries the marker (a nest-server exception that
112
+ // travelled through Better-Auth): leave it exactly as it is.
113
+ if (!mapped || typeof body?.message !== 'string' || body.message.startsWith('#')) {
114
+ return response;
115
+ }
116
+
117
+ const headers = new Headers();
118
+
119
+ // `set-cookie` FIRST, and via getSetCookie/append rather than the forEach below. `forEach`
120
+ // yields each cookie separately and `set()` overwrites, so a response carrying two of them
121
+ // would keep only the last. Better-Auth CLEARS session and 2FA cookies on several failure
122
+ // paths — exactly the responses this function rewrites — so collapsing them leaves a stale
123
+ // credential in the browser. `sendWebResponse` uses getSetCookie() for the same reason.
124
+ for (const cookie of response.headers.getSetCookie?.() ?? []) {
125
+ headers.append('set-cookie', cookie);
126
+ }
127
+
128
+ response.headers.forEach((value, key) => {
129
+ const lower = key.toLowerCase();
130
+ // content-length: the rewritten body has a different length, and a stale one truncates the
131
+ // response. set-cookie: already appended above, and `set()` here would undo that.
132
+ if (lower !== 'content-length' && lower !== 'set-cookie') {
133
+ headers.set(key, value);
134
+ }
135
+ });
136
+
137
+ return new Response(JSON.stringify({ ...body, message: mapped }), {
138
+ headers,
139
+ status: response.status,
140
+ statusText: response.statusText,
141
+ });
142
+ } catch {
143
+ // Not JSON, or an unreadable body. The original is still the best answer available.
144
+ return response;
145
+ }
146
+ }
@@ -31,6 +31,7 @@ export * from './core-better-auth-api.middleware';
31
31
  export * from './core-better-auth-auth.model';
32
32
  export * from './core-better-auth-cookie.helper';
33
33
  export * from './core-better-auth-email-verification.service';
34
+ export * from './core-better-auth-error-codes.helper';
34
35
  export * from './core-better-auth-migration-status.model';
35
36
  export * from './core-better-auth-models';
36
37
  export * from './core-better-auth-rate-limit.middleware';