@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.
- package/.claude/rules/configurable-features.md +24 -1
- package/.claude/rules/module-inheritance.md +2 -0
- package/.claude/rules/package-management.md +117 -2
- package/.claude/rules/testing.md +238 -5
- package/CLAUDE.md +13 -1
- package/FRAMEWORK-API.md +2 -2
- package/dist/config.env.js +4 -2
- package/dist/config.env.js.map +1 -1
- package/dist/core/common/helpers/config.helper.d.ts +2 -0
- package/dist/core/common/helpers/config.helper.js +18 -0
- package/dist/core/common/helpers/config.helper.js.map +1 -1
- package/dist/core/common/helpers/cookies.helper.d.ts +3 -0
- package/dist/core/common/helpers/cookies.helper.js +9 -0
- package/dist/core/common/helpers/cookies.helper.js.map +1 -1
- package/dist/core/common/helpers/input.helper.d.ts +1 -0
- package/dist/core/common/helpers/input.helper.js +4 -0
- package/dist/core/common/helpers/input.helper.js.map +1 -1
- package/dist/core/common/helpers/service.helper.js +6 -1
- package/dist/core/common/helpers/service.helper.js.map +1 -1
- package/dist/core/common/interceptors/check-security.interceptor.js +1 -0
- package/dist/core/common/interceptors/check-security.interceptor.js.map +1 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +2 -1
- package/dist/core/common/services/email.service.d.ts +4 -1
- package/dist/core/common/services/email.service.js +25 -2
- package/dist/core/common/services/email.service.js.map +1 -1
- package/dist/core/common/services/module.service.js +1 -0
- package/dist/core/common/services/module.service.js.map +1 -1
- package/dist/core/modules/ai/providers/openai-compatible.provider.d.ts +2 -0
- package/dist/core/modules/ai/providers/openai-compatible.provider.js +28 -3
- package/dist/core/modules/ai/providers/openai-compatible.provider.js.map +1 -1
- package/dist/core/modules/better-auth/better-auth.config.js +7 -0
- package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-api.middleware.js +3 -1
- package/dist/core/modules/better-auth/core-better-auth-api.middleware.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.d.ts +3 -1
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +30 -5
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-error-codes.helper.d.ts +2 -0
- package/dist/core/modules/better-auth/core-better-auth-error-codes.helper.js +54 -0
- package/dist/core/modules/better-auth/core-better-auth-error-codes.helper.js.map +1 -0
- package/dist/core/modules/better-auth/index.d.ts +1 -0
- package/dist/core/modules/better-auth/index.js +1 -0
- package/dist/core/modules/better-auth/index.js.map +1 -1
- package/dist/core/modules/error-code/error-codes.d.ts +27 -0
- package/dist/core/modules/error-code/error-codes.js +24 -0
- package/dist/core/modules/error-code/error-codes.js.map +1 -1
- package/dist/core/modules/hub/core-hub.service.js +1 -0
- package/dist/core/modules/hub/core-hub.service.js.map +1 -1
- package/dist/core/modules/user/core-user.model.d.ts +1 -0
- package/dist/core/modules/user/core-user.model.js +11 -1
- package/dist/core/modules/user/core-user.model.js.map +1 -1
- package/dist/core/modules/user/core-user.service.d.ts +9 -0
- package/dist/core/modules/user/core-user.service.js +96 -4
- package/dist/core/modules/user/core-user.service.js.map +1 -1
- package/dist/server/modules/error-code/error-codes.d.ts +3 -0
- package/dist/server/modules/user/user.model.d.ts +5 -0
- package/dist/server/modules/user/user.service.js +12 -6
- package/dist/server/modules/user/user.service.js.map +1 -1
- package/dist/templates/password-reset-de.ejs +12 -0
- package/dist/templates/password-reset-en.ejs +12 -0
- package/dist/templates/password-reset.ejs +1 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +14 -0
- package/migration-guides/11.37.x-to-11.38.x.md +18 -1
- package/migration-guides/11.38.x-to-11.39.x.md +456 -0
- package/migration-guides/11.39.0-to-11.40.0.md +186 -0
- package/package.json +5 -4
- package/src/config.env.ts +19 -3
- package/src/core/common/helpers/config.helper.ts +79 -0
- package/src/core/common/helpers/cookies.helper.ts +38 -0
- package/src/core/common/helpers/input.helper.ts +37 -0
- package/src/core/common/helpers/service.helper.ts +9 -1
- package/src/core/common/interceptors/check-security.interceptor.ts +1 -0
- package/src/core/common/interfaces/server-options.interface.ts +120 -4
- package/src/core/common/services/email.service.ts +46 -1
- package/src/core/common/services/module.service.ts +1 -0
- package/src/core/modules/ai/README.md +33 -0
- package/src/core/modules/ai/providers/openai-compatible.provider.ts +76 -3
- package/src/core/modules/better-auth/better-auth.config.ts +25 -0
- package/src/core/modules/better-auth/core-better-auth-api.middleware.ts +8 -1
- package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +101 -6
- package/src/core/modules/better-auth/core-better-auth-error-codes.helper.ts +146 -0
- package/src/core/modules/better-auth/index.ts +1 -0
- package/src/core/modules/error-code/error-codes.ts +55 -0
- package/src/core/modules/hub/core-hub.service.ts +1 -0
- package/src/core/modules/user/core-user.model.ts +28 -1
- package/src/core/modules/user/core-user.service.ts +267 -5
- package/src/server/modules/user/user.service.ts +26 -7
- package/src/templates/password-reset-de.ejs +12 -0
- package/src/templates/password-reset-en.ejs +12 -0
- 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.
|
|
@@ -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 =
|
|
138
|
-
if (!
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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';
|