@lenne.tech/nest-server 11.37.0 → 11.39.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 +51 -0
- package/.claude/rules/module-deprecation.md +25 -1
- package/.claude/rules/package-management.md +66 -0
- package/.claude/rules/testing.md +81 -5
- package/FRAMEWORK-API.md +4 -2
- package/dist/config.env.js +5 -3
- 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 +21 -0
- package/dist/core/common/middlewares/security-headers.middleware.d.ts +18 -0
- package/dist/core/common/middlewares/security-headers.middleware.js +90 -0
- package/dist/core/common/middlewares/security-headers.middleware.js.map +1 -0
- 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/auth/core-auth.controller.js +2 -5
- package/dist/core/modules/auth/core-auth.controller.js.map +1 -1
- package/dist/core/modules/auth/core-auth.module.js +2 -0
- package/dist/core/modules/auth/core-auth.module.js.map +1 -1
- package/dist/core/modules/auth/core-auth.resolver.js +2 -5
- package/dist/core/modules/auth/core-auth.resolver.js.map +1 -1
- package/dist/core/modules/auth/core-legacy-auth-deprecation.initializer.d.ts +12 -0
- package/dist/core/modules/auth/core-legacy-auth-deprecation.initializer.js +83 -0
- package/dist/core/modules/auth/core-legacy-auth-deprecation.initializer.js.map +1 -0
- package/dist/core/modules/auth/helpers/legacy-endpoints.helper.d.ts +3 -0
- package/dist/core/modules/auth/helpers/legacy-endpoints.helper.js +14 -0
- package/dist/core/modules/auth/helpers/legacy-endpoints.helper.js.map +1 -0
- package/dist/core/modules/better-auth/better-auth.config.d.ts +12 -0
- package/dist/core/modules/better-auth/better-auth.config.js +40 -1
- package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-api.middleware.d.ts +9 -1
- package/dist/core/modules/better-auth/core-better-auth-api.middleware.js +53 -4
- 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 +5 -2
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +57 -4
- 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/core-better-auth-password-reset.registry.d.ts +2 -0
- package/dist/core/modules/better-auth/core-better-auth-password-reset.registry.js +19 -0
- package/dist/core/modules/better-auth/core-better-auth-password-reset.registry.js.map +1 -0
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.d.ts +4 -1
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +13 -10
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.module.d.ts +8 -2
- package/dist/core/modules/better-auth/core-better-auth.module.js +34 -4
- package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
- 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 +10 -1
- package/dist/core/modules/user/core-user.service.js +124 -15
- package/dist/core/modules/user/core-user.service.js.map +1 -1
- package/dist/core/modules/user/inputs/core-user.input.js +1 -1
- package/dist/core/modules/user/inputs/core-user.input.js.map +1 -1
- package/dist/core.module.js +2 -0
- package/dist/core.module.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/server/modules/error-code/error-codes.d.ts +3 -0
- package/dist/server/modules/user/user.controller.js +2 -1
- package/dist/server/modules/user/user.controller.js.map +1 -1
- package/dist/server/modules/user/user.model.d.ts +5 -0
- package/dist/server/modules/user/user.resolver.js +2 -1
- package/dist/server/modules/user/user.resolver.js.map +1 -1
- package/dist/server/modules/user/user.service.d.ts +1 -1
- package/dist/server/modules/user/user.service.js +18 -5
- 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 +49 -1
- package/migration-guides/11.37.x-to-11.38.x.md +528 -0
- package/migration-guides/11.38.x-to-11.39.x.md +456 -0
- package/package.json +1 -1
- package/src/config.env.ts +28 -4
- 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 +326 -10
- package/src/core/common/middlewares/security-headers.middleware.ts +155 -0
- package/src/core/common/services/email.service.ts +46 -1
- package/src/core/common/services/module.service.ts +1 -0
- package/src/core/modules/auth/README.md +104 -0
- package/src/core/modules/auth/core-auth.controller.ts +4 -11
- package/src/core/modules/auth/core-auth.module.ts +5 -0
- package/src/core/modules/auth/core-auth.resolver.ts +4 -11
- package/src/core/modules/auth/core-legacy-auth-deprecation.initializer.ts +128 -0
- package/src/core/modules/auth/helpers/legacy-endpoints.helper.ts +69 -0
- package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +22 -0
- package/src/core/modules/better-auth/README.md +32 -59
- package/src/core/modules/better-auth/better-auth.config.ts +131 -4
- package/src/core/modules/better-auth/core-better-auth-api.middleware.ts +126 -3
- package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +166 -7
- package/src/core/modules/better-auth/core-better-auth-error-codes.helper.ts +146 -0
- package/src/core/modules/better-auth/core-better-auth-password-reset.registry.ts +92 -0
- package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +42 -10
- package/src/core/modules/better-auth/core-better-auth.module.ts +67 -4
- 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 +389 -22
- package/src/core/modules/user/inputs/core-user.input.ts +16 -1
- package/src/core.module.ts +8 -0
- package/src/index.ts +3 -0
- package/src/server/modules/user/user.controller.ts +7 -1
- package/src/server/modules/user/user.resolver.ts +7 -1
- package/src/server/modules/user/user.service.ts +58 -13
- 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
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
# Migration Guide: 11.38.x → 11.39.x
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
| Category | Change | Effort |
|
|
6
|
+
|----------|--------|--------|
|
|
7
|
+
| **Security (Critical)** | A NoSQL operator sent where a token string belongs could take over an account | None here — **but audit your own controllers** (§1) |
|
|
8
|
+
| **Security** | The legacy password-reset token now expires (default 60 min); it never did | Low — unredeemed old links stop working (§2) |
|
|
9
|
+
| **Bugfix (critical path)** | The reset mail can finally contain a token, and a validated link | **Required** if you send that mail yourself (§3) |
|
|
10
|
+
| **Changed** | `redirectTo` from the caller outranks the framework default | None — unless you relied on the default (§4) |
|
|
11
|
+
| **Changed** | Both reset flows resolve the app URL identically | None — the IAM flow gains behaviours it lacked (§4) |
|
|
12
|
+
| **Changed** | IAM error messages carry nest-server's `#LTNS_XXXX:` code | None — unless you match on the English text (§5) |
|
|
13
|
+
| **Bugfix** | The production SMTP default could not send mail at all | **Required** — the fix lives in *your* `config.env.ts` (§6) |
|
|
14
|
+
| **Security** | SMTP now requires STARTTLS instead of accepting a downgrade | Low — one env var if your relay cannot do TLS (§6) |
|
|
15
|
+
| **Changed** | The verification link carries `&email=` so a resend button can render | None (§7) |
|
|
16
|
+
| **Bugfix** | A `Set-Cookie` clear on an IAM failure path was being dropped | None (§8) |
|
|
17
|
+
| **New** | Partial index on `passwordResetToken` | None — created at boot (§9) |
|
|
18
|
+
|
|
19
|
+
Nothing here changes the API surface of `CoreModule.forRoot()`.
|
|
20
|
+
|
|
21
|
+
**This is a MINOR, and it contains breaking changes.** In this package the MAJOR digit states which
|
|
22
|
+
NestJS major is targeted and moves only when NestJS does, so a breaking change ships as a minor. Two
|
|
23
|
+
things can stop working for an existing deployment: unredeemed password-reset links (§2) and SMTP
|
|
24
|
+
against a relay that offers no STARTTLS (§6).
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Quick Migration
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pnpm update @lenne.tech/nest-server
|
|
32
|
+
pnpm run build
|
|
33
|
+
pnpm test
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Then do three things, in this order of consequence:
|
|
37
|
+
|
|
38
|
+
1. **§6 — edit your own `config.env.ts`.** The SMTP fix does not arrive with the package. Without
|
|
39
|
+
this edit a production deployment on the default port sends **no mail at all**, silently.
|
|
40
|
+
2. **§3 — switch your `sendPasswordResetMail()` to the two new methods**, if you send that mail
|
|
41
|
+
yourself. Anything still reading `user.passwordResetToken` off a `process()` result is mailing
|
|
42
|
+
the word `undefined` today.
|
|
43
|
+
3. **§1 — grep your controllers** for `@Body('x') x: string` / `@Query('x') x: string` whose value
|
|
44
|
+
reaches a database query.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## 1. Security: an operator where a string belongs
|
|
49
|
+
|
|
50
|
+
`POST /users/password/reset` accepted a JSON **object** in place of the token string:
|
|
51
|
+
|
|
52
|
+
```jsonc
|
|
53
|
+
{ "token": { "$ne": null }, "password": "…" }
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`findOne({ passwordResetToken: { $ne: null } })` then selected the first user holding *any* live
|
|
57
|
+
reset token and set that account's password. The attacker never saw the mail. Confirmed against the
|
|
58
|
+
live route, not inferred: the victim's token was consumed and their password hash changed.
|
|
59
|
+
|
|
60
|
+
**Why nothing caught it.** A parameter declared `@Body('token') token: string` has
|
|
61
|
+
`metatype === String`, and `MapAndValidatePipe` returns basic-type values verbatim — the declared
|
|
62
|
+
type is erased at runtime, so nothing validates it. Express parses query strings with `qs` in
|
|
63
|
+
extended mode, so `?token[$ne]=` reaches a `@Query('token') token: string` identically.
|
|
64
|
+
|
|
65
|
+
This is **pre-existing**, not introduced by 11.38.0. The framework's five affected sinks are now
|
|
66
|
+
guarded.
|
|
67
|
+
|
|
68
|
+
### What you must do
|
|
69
|
+
|
|
70
|
+
**The structural answer is not the guard — it is the DTO.** A parameter typed as a class is
|
|
71
|
+
validated by the pipe; a basic-type parameter is not:
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
// Exposed: metatype is String, the pipe returns whatever arrived
|
|
75
|
+
async resetPassword(@Body('token') token: string) { … }
|
|
76
|
+
|
|
77
|
+
// Safe: metatype is a class, the pipe validates, an object is rejected before your code runs
|
|
78
|
+
async resetPassword(@Body() input: ResetPasswordInput) { … } // token: @IsString()
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
For an existing signature you do not want to change, `isQueryableString` is exported:
|
|
82
|
+
|
|
83
|
+
```typescript
|
|
84
|
+
import { isQueryableString } from '@lenne.tech/nest-server';
|
|
85
|
+
|
|
86
|
+
if (!isQueryableString(token)) {
|
|
87
|
+
throw new NotFoundException(ErrorCode.LINK_INVALID_OR_EXPIRED);
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Guard in the **service**, not the controller, if anything other than HTTP reaches it — an MCP tool
|
|
92
|
+
or a cron job calling the service directly bypasses a controller-level check.
|
|
93
|
+
|
|
94
|
+
**Deliberately NOT done:** `mongoose.set('sanitizeFilter', true)` globally. It wraps every
|
|
95
|
+
object-valued filter in `$eq`, which breaks the framework's own operator-bearing queries unless each
|
|
96
|
+
is wrapped in `mongoose.trusted()`. That is a fleet-wide audit, not a fix.
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## 2. Security: the legacy password-reset token now expires
|
|
101
|
+
|
|
102
|
+
It never did. `resetPassword()` looked the token up by value and compared no time at all — while the
|
|
103
|
+
exception it threw read `Invalid or expired password reset token`, claiming a check that did not
|
|
104
|
+
exist.
|
|
105
|
+
|
|
106
|
+
A reset link takes over an **existing** account, which makes it the most powerful token this
|
|
107
|
+
framework mints. Unbounded, it means a mail in an archive, a forwarded message or a restored backup
|
|
108
|
+
opens that account years later. The IAM half already expired after an hour; only the legacy half had
|
|
109
|
+
nothing.
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
auth: {
|
|
113
|
+
passwordReset: { tokenExpiresInMinutes: 60 }, // the default; 0 disables expiry entirely
|
|
114
|
+
},
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
| Value | Meaning |
|
|
118
|
+
|-------|---------|
|
|
119
|
+
| unset | 60 minutes |
|
|
120
|
+
| a positive number | that many minutes |
|
|
121
|
+
| `0` | no expiry — restores the previous behaviour |
|
|
122
|
+
| negative, `NaN`, empty, non-numeric | **60 minutes**, not "unbounded" |
|
|
123
|
+
|
|
124
|
+
That last row is deliberate. Switching off the expiry of an account-takeover credential is a decision
|
|
125
|
+
somebody has to state; a typo must never be the thing that states it. (`Number('')` is `0`, so an
|
|
126
|
+
empty variable would otherwise read as a deliberate opt-out.)
|
|
127
|
+
|
|
128
|
+
An expired token is answered exactly like an unknown one — **404**, same message — and deleted on
|
|
129
|
+
sight. Distinguishing them would confirm to somebody holding a stale token that it was once real.
|
|
130
|
+
|
|
131
|
+
### Are you affected?
|
|
132
|
+
|
|
133
|
+
| Situation | Effect |
|
|
134
|
+
|-----------|--------|
|
|
135
|
+
| A user holds an unredeemed reset mail from before the upgrade | It stops working. They request a new one — which, for most projects, is the first link that actually works (§3) |
|
|
136
|
+
| Your tests write a `passwordResetToken` directly into the database | **Set `passwordResetTokenExpiresAt` too**, or they now get 404 |
|
|
137
|
+
| You need the old behaviour | `tokenExpiresInMinutes: 0` |
|
|
138
|
+
|
|
139
|
+
`CoreUserModel` gains `passwordResetTokenExpiresAt`, `@Restricted(S_NO_ONE)` like the token itself.
|
|
140
|
+
|
|
141
|
+
### The mail now states the deadline
|
|
142
|
+
|
|
143
|
+
An expiry the recipient is not told about is no better than a broken link — both arrive, look right,
|
|
144
|
+
and fail on click. All three shipped templates state it, from a new optional `templateData` field:
|
|
145
|
+
|
|
146
|
+
```typescript
|
|
147
|
+
templateData: { link, linkExpiresInMinutes: 60, name } // omit it, or pass 0, and no sentence renders
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Take the value from the flow you are in: `auth.passwordReset.tokenExpiresInMinutes` for legacy,
|
|
151
|
+
Better-Auth's `resetPasswordTokenExpiresIn` (seconds) for IAM. They expire independently, and
|
|
152
|
+
announcing the other one's deadline is worse than announcing none.
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## 3. Bugfix: the reset mail can finally carry a token and a working link
|
|
157
|
+
|
|
158
|
+
Two production incidents, one after the other, both reaching real recipients.
|
|
159
|
+
|
|
160
|
+
**The token was never in the link.** `setPasswordResetTokenForEmail()` returns the user through
|
|
161
|
+
`process()`, and the security interceptor strips `passwordResetToken` there — correctly: a reset
|
|
162
|
+
token in a response body is one in a log, a proxy cache and a browser history. So the method named
|
|
163
|
+
after setting the token could not hand it over, and callers mailed `undefined`.
|
|
164
|
+
|
|
165
|
+
**The base was never validated.** `email.passwordResetLink` had no default and callers concatenated
|
|
166
|
+
it anyway, so a project that never set it mailed `undefined/<token>`.
|
|
167
|
+
|
|
168
|
+
Every test stayed green both times, because each checked a piece: the config spec asserted the base,
|
|
169
|
+
the story test read the token from the *database*, the enumeration spec asserted status and timing.
|
|
170
|
+
Nothing asserted the one value the recipient actually receives.
|
|
171
|
+
|
|
172
|
+
### What you must do
|
|
173
|
+
|
|
174
|
+
```typescript
|
|
175
|
+
async sendPasswordResetMail(email: string, serviceOptions?: ServiceOptions): Promise<null | User> {
|
|
176
|
+
// NOT setPasswordResetTokenForEmail — that one cannot return the token, by design.
|
|
177
|
+
const created = await this.createPasswordResetToken(email, serviceOptions);
|
|
178
|
+
if (!created) {
|
|
179
|
+
return null; // unknown address — answer exactly as for a known one
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const link = this.buildPasswordResetLink(created.token);
|
|
183
|
+
if (!link) {
|
|
184
|
+
// Sending a mail whose link cannot work is worse than sending none.
|
|
185
|
+
this.userServiceLogger.error('Password reset mail not sent: set `email.passwordResetLink` or `appUrl`.');
|
|
186
|
+
return created.user;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// NOT awaited: awaiting it makes the known path visibly slower whatever the status code says.
|
|
190
|
+
void this.emailService.sendMail(created.user.email, 'Password reset', {
|
|
191
|
+
htmlTemplate: 'password-reset',
|
|
192
|
+
templateData: { link, linkExpiresInMinutes: this.passwordResetTokenExpiryMinutes(), name: created.user.username },
|
|
193
|
+
}).catch((error: unknown) => this.userServiceLogger.error(`Failed to send: ${String(error)}`));
|
|
194
|
+
|
|
195
|
+
return created.user;
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
The reference implementation is `src/server/modules/user/user.service.ts`; copy that shape.
|
|
200
|
+
|
|
201
|
+
### Two link conventions, and which applies depends on whether you set the option at all
|
|
202
|
+
|
|
203
|
+
| `email.passwordResetLink` | Resulting link |
|
|
204
|
+
|---------------------------|----------------|
|
|
205
|
+
| not set | `<appUrl>/auth/reset-password?token=<token>` |
|
|
206
|
+
| set, containing `{token}` | your value, placeholder substituted |
|
|
207
|
+
| set, **without** `{token}` | your value + `/<token>` as a **path segment** |
|
|
208
|
+
|
|
209
|
+
Writing the default out by hand therefore gives a **different** link than leaving the line out. That
|
|
210
|
+
looks like a trap and is kept deliberately: a project that configures such a value has a page built
|
|
211
|
+
for a path segment, and moving it silently would break the very flow this release repairs. The unset
|
|
212
|
+
default instead matches the page `nuxt-base-starter` ships, which reads `route.query.token`.
|
|
213
|
+
|
|
214
|
+
Because the rule is not guessable, **the framework warns once at boot** when the option is set
|
|
215
|
+
without `{token}`. The warning names a precondition: `{token}` is substituted by
|
|
216
|
+
`buildPasswordResetLink()` and nothing else, so a caller still concatenating by hand must switch
|
|
217
|
+
first or it mails the placeholder verbatim.
|
|
218
|
+
|
|
219
|
+
---
|
|
220
|
+
|
|
221
|
+
## 4. Changed: `redirectTo` wins, and both flows resolve the app URL identically
|
|
222
|
+
|
|
223
|
+
**Your own `redirectTo` now outranks the framework default.** If the client passed one to
|
|
224
|
+
`requestPasswordReset` — `nuxt-base-starter` does on every request — the mail links there.
|
|
225
|
+
Resolution order: explicit config → the caller's `redirectTo` → `<appUrl>/auth/reset-password` →
|
|
226
|
+
Better-Auth's own link. `passwordResetLink: false` still wins over a `redirectTo`.
|
|
227
|
+
|
|
228
|
+
This matters for a project that renamed its reset route: before, the mail carried the framework's
|
|
229
|
+
guess and the recipient landed on a route that does not exist. It also restores exact equivalence
|
|
230
|
+
with Better-Auth's redirect hop, which sends the user to `redirectTo?token=`.
|
|
231
|
+
|
|
232
|
+
**On safety:** `redirectTo` arrives in the body of an unauthenticated endpoint, so putting it into a
|
|
233
|
+
mail would be a token-exfiltration vector — were it not validated first. Better-Auth runs
|
|
234
|
+
`originCheck` on it at request time, so a value outside your `trustedOrigins` is rejected before any
|
|
235
|
+
mail is built. That makes `trustedOrigins` load-bearing for this path as well as for CORS; keep
|
|
236
|
+
`cors.allowedOrigins` as narrow as your frontends need.
|
|
237
|
+
|
|
238
|
+
**Both flows now resolve the app URL through one function.** The IAM flow previously read `appUrl`
|
|
239
|
+
straight off the configuration while the legacy flow had been moved to the shared resolver — two
|
|
240
|
+
hand-maintained copies of one decision, and they drifted. The IAM flow gains three behaviours it
|
|
241
|
+
lacked: the localhost default that `local`/`ci`/`e2e` depend on (none of them set `appUrl`),
|
|
242
|
+
derivation from a host-split `baseUrl` such as `https://api.crm.localhost`, and the
|
|
243
|
+
`cors.deriveAppUrl` opt-out that keeps a reset token out of an untrusted apex domain.
|
|
244
|
+
|
|
245
|
+
In practice this changes the IAM link only where `appUrl` is unset — local and CI. Production, where
|
|
246
|
+
it is set, is unaffected.
|
|
247
|
+
|
|
248
|
+
---
|
|
249
|
+
|
|
250
|
+
## 5. Changed: IAM error messages carry nest-server's error code
|
|
251
|
+
|
|
252
|
+
Better-Auth answers with its own vocabulary — `{ code: 'INVALID_TOKEN', message: 'Invalid token' }` —
|
|
253
|
+
while the frontends in this stack translate by parsing nest-server's marker. The parser found no
|
|
254
|
+
code and passed the raw string through, so **every** IAM error reached end users as English
|
|
255
|
+
developer text. It was felt hardest where it could least be afforded: an expired reset link answers
|
|
256
|
+
`INVALID_TOKEN`, so the person who already cannot sign in was the one reading it.
|
|
257
|
+
|
|
258
|
+
```jsonc
|
|
259
|
+
// before
|
|
260
|
+
{ "code": "INVALID_TOKEN", "message": "Invalid token" }
|
|
261
|
+
// after
|
|
262
|
+
{ "code": "INVALID_TOKEN", "message": "#LTNS_0027: Link is invalid or expired" }
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
**The `code` field is unchanged**, so anything branching on it keeps working.
|
|
266
|
+
|
|
267
|
+
New codes: `LTNS_0027 LINK_INVALID_OR_EXPIRED`, `LTNS_0028 PASSWORD_TOO_LONG`,
|
|
268
|
+
`LTNS_0029 PASSWORD_TOO_SHORT`.
|
|
269
|
+
|
|
270
|
+
Three deliberate absences, each for a reason worth knowing:
|
|
271
|
+
|
|
272
|
+
- **`INVALID_TOKEN` and `TOKEN_EXPIRED` map to the same message.** Better-Auth throws
|
|
273
|
+
`TOKEN_EXPIRED` in exactly one place — an expired verification *link*, one line above the
|
|
274
|
+
`INVALID_TOKEN` it throws for a broken one. Same user action, so the same instruction. The
|
|
275
|
+
session-oriented "sign in again" would be a dead end there: at that moment the user is usually not
|
|
276
|
+
signed in at all.
|
|
277
|
+
- **`USER_NOT_FOUND` is left untranslated.** It is an account-enumeration signal. Better-Auth already
|
|
278
|
+
exposes it as a `code`, but translating it would make the oracle friendlier and more legible — the
|
|
279
|
+
opposite direction from the rest of this release.
|
|
280
|
+
- **`SESSION_EXPIRED` is left untranslated.** It means "re-authenticate for this action", which is
|
|
281
|
+
not what nest-server's `TOKEN_EXPIRED` tells the user to do.
|
|
282
|
+
|
|
283
|
+
Reachable programmatically via `mapBetterAuthErrorCode(code)`.
|
|
284
|
+
|
|
285
|
+
### Two boundaries
|
|
286
|
+
|
|
287
|
+
- **A redirect is not rewritten.** Better-Auth reports some failures by redirecting to your
|
|
288
|
+
`callbackURL` with `?error=<CODE>` rather than answering with a body — nothing to translate, and
|
|
289
|
+
rewriting that query string would invent a second contract over its documented one. Read `?error=`
|
|
290
|
+
in the frontend. Narrower than it sounds: with `betterAuth.emailVerification.callbackURL` set —
|
|
291
|
+
which the starter does — the mail links straight at the frontend and the redirect route is never
|
|
292
|
+
walked.
|
|
293
|
+
- **`PASSWORD_TOO_SHORT` / `PASSWORD_TOO_LONG` are rarely reachable through the lt frontend**, which
|
|
294
|
+
hashes with `ltSha256` before sending, so every password arrives as 64 hex characters. They remain
|
|
295
|
+
reachable for a client that does not hash, and for a project configuring `minPasswordLength` above
|
|
296
|
+
64.
|
|
297
|
+
|
|
298
|
+
### Are you affected?
|
|
299
|
+
|
|
300
|
+
| Situation | Effect |
|
|
301
|
+
|-----------|--------|
|
|
302
|
+
| You use `@lenne.tech/nuxt-extensions` | Errors become translatable; no change needed |
|
|
303
|
+
| You branch on `error.code` | Unaffected |
|
|
304
|
+
| You match on the exact English `message` string | **Update it** — prefer `code`, which is stable |
|
|
305
|
+
|
|
306
|
+
---
|
|
307
|
+
|
|
308
|
+
## 6. Bugfix: the production SMTP default could not send mail
|
|
309
|
+
|
|
310
|
+
The `production` profile paired the default port **587** with `secure` resolving to **true**. Those
|
|
311
|
+
two cannot go together: `secure` means implicit TLS, which only port **465** speaks, while 587 opens
|
|
312
|
+
in plaintext and upgrades through STARTTLS. Nodemailer sent a TLS ClientHello to a server answering
|
|
313
|
+
with an SMTP greeting, and OpenSSL reported `wrong version number`.
|
|
314
|
+
|
|
315
|
+
A deployment configuring only `SMTP_HOST` and credentials could therefore send **no mail at all** —
|
|
316
|
+
invisibly, because authentication mail is deliberately not awaited, so the failure never reached a
|
|
317
|
+
response. The API answered 200 while every password-reset message died in transport.
|
|
318
|
+
|
|
319
|
+
### This does NOT arrive with `pnpm update`
|
|
320
|
+
|
|
321
|
+
The fix landed in the framework's own `src/config.env.ts`; your project has its own copy, seeded from
|
|
322
|
+
the starter at init and never re-synced. Upgrading gives you a boot warning — the repair is an edit
|
|
323
|
+
you make:
|
|
324
|
+
|
|
325
|
+
```typescript
|
|
326
|
+
import { resolveSmtpSecure } from '@lenne.tech/nest-server';
|
|
327
|
+
|
|
328
|
+
const smtpPort = parseInt(process.env.SMTP_PORT || '587', 10);
|
|
329
|
+
// …
|
|
330
|
+
smtp: {
|
|
331
|
+
host: process.env.SMTP_HOST,
|
|
332
|
+
port: smtpPort,
|
|
333
|
+
- secure: process.env.SMTP_SECURE !== 'false',
|
|
334
|
+
+ secure: resolveSmtpSecure(process.env.SMTP_SECURE, smtpPort),
|
|
335
|
+
+ requireTLS: process.env.SMTP_REQUIRE_TLS !== 'false',
|
|
336
|
+
},
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
| `SMTP_SECURE` | Result |
|
|
340
|
+
|---------------|--------|
|
|
341
|
+
| `true` / `false` | honoured, even where it cannot work — and warned about |
|
|
342
|
+
| unset, or anything else (`1`, `yes`, a typo) | derived: `true` on port 465, `false` otherwise |
|
|
343
|
+
|
|
344
|
+
Only the two canonical values override, because `secure` is not an independent setting but a
|
|
345
|
+
consequence of the port. Both obvious string rules have a silent wrong side — `!== 'false'` makes an
|
|
346
|
+
unknown value `true` on 587 (this outage), `=== 'true'` makes `1` resolve to `false` on 465 (the same
|
|
347
|
+
outage mirrored). Deferring to the port is the only rule under which no input produces a pair that
|
|
348
|
+
cannot connect.
|
|
349
|
+
|
|
350
|
+
### `requireTLS` is the second half, and it is new
|
|
351
|
+
|
|
352
|
+
`secure: false` alone means **opportunistic** STARTTLS: nodemailer upgrades only when the server
|
|
353
|
+
advertises it. An on-path attacker who strips that capability line gets a plaintext session carrying
|
|
354
|
+
your SMTP credentials and a working password-reset link. Before the repair nothing was sent at all,
|
|
355
|
+
so this exposure is created by the fix — which is why the fix has to include the floor.
|
|
356
|
+
|
|
357
|
+
Set `SMTP_REQUIRE_TLS=false` only for an internal relay that genuinely offers no STARTTLS.
|
|
358
|
+
`EmailService` warns once when the resolved pair cannot connect — reported, never overruled.
|
|
359
|
+
|
|
360
|
+
---
|
|
361
|
+
|
|
362
|
+
## 7. Changed: the verification link carries the address
|
|
363
|
+
|
|
364
|
+
`{callbackURL}?token=<token>&email=<address>`. The verification page needs it to offer "send a new
|
|
365
|
+
email" once the token has expired, and it cannot recover the address itself: that value lives inside
|
|
366
|
+
the token's JWT payload, and reading it there would mean rendering data from an unverified
|
|
367
|
+
signature. Without it, a user with an expired link is told correctly what happened and given no way
|
|
368
|
+
to fix it.
|
|
369
|
+
|
|
370
|
+
---
|
|
371
|
+
|
|
372
|
+
## 8. Bugfix: a `Set-Cookie` clear was being dropped
|
|
373
|
+
|
|
374
|
+
The header rebuild in the new error wrapper used `Headers.forEach` + `set()`. `forEach` yields each
|
|
375
|
+
`set-cookie` entry separately and `set()` overwrites, so all but the last were discarded. Better-Auth
|
|
376
|
+
**clears** session and 2FA cookies on several failure paths — precisely the responses being rewritten
|
|
377
|
+
— so the clear was lost and a stale credential stayed in the browser.
|
|
378
|
+
|
|
379
|
+
No action required. Noted because a failed 2FA attempt against an 11.39.0 pre-release could have left
|
|
380
|
+
a session cookie behind.
|
|
381
|
+
|
|
382
|
+
---
|
|
383
|
+
|
|
384
|
+
## 9. New: partial index on `passwordResetToken`
|
|
385
|
+
|
|
386
|
+
`findOne({ passwordResetToken: token })` runs on an unauthenticated, unrated endpoint and had no
|
|
387
|
+
index — a full collection scan of `users` per call. The index is partial
|
|
388
|
+
(`partialFilterExpression: { passwordResetToken: { $type: 'string' } }`) and the reset path now
|
|
389
|
+
`$unset`s the field rather than writing `null`, so it stays small.
|
|
390
|
+
|
|
391
|
+
**Never give `passwordResetTokenExpiresAt` a TTL index.** It is a `Date`, and a TTL index there would
|
|
392
|
+
delete the **user document**, not the token.
|
|
393
|
+
|
|
394
|
+
---
|
|
395
|
+
|
|
396
|
+
## 10. Operations: two boot warnings you may now see
|
|
397
|
+
|
|
398
|
+
**`jwks` under a throwaway or fallback secret.** The JWT plugin persists a `jwks` document encrypted
|
|
399
|
+
with whatever secret is active. Booting once without a permanent `betterAuth.secret` — or on the
|
|
400
|
+
`jwt.secret` fallback — leaves a row that a later, different secret cannot decrypt. Nothing fails at
|
|
401
|
+
the time; `/iam/token` starts answering `Failed to decrypt private key` whenever somebody next asks
|
|
402
|
+
for a JWT, which on a low-traffic deployment can be months later. Observed in production with five
|
|
403
|
+
weeks between cause and symptom.
|
|
404
|
+
|
|
405
|
+
Remedy: reuse the same value, or drop the `jwks` collection after setting a permanent secret.
|
|
406
|
+
Better-Auth creates a new key; previously issued JWTs were already unusable.
|
|
407
|
+
|
|
408
|
+
---
|
|
409
|
+
|
|
410
|
+
## 11. Vendor mode: one new file
|
|
411
|
+
|
|
412
|
+
| New file | Existing core files that now import it |
|
|
413
|
+
|---|---|
|
|
414
|
+
| `better-auth/core-better-auth-error-codes.helper.ts` | `better-auth/core-better-auth-api.middleware.ts`, `better-auth/index.ts` |
|
|
415
|
+
|
|
416
|
+
Taking the middleware without the helper leaves an unresolvable import.
|
|
417
|
+
|
|
418
|
+
---
|
|
419
|
+
|
|
420
|
+
## 12. Dependencies: two security overrides raised
|
|
421
|
+
|
|
422
|
+
`fast-uri` and `qs` were already overridden, and both entries had **aged past their own patch line**.
|
|
423
|
+
A stale override is not inert — it pins matching paths *below* the current fix, and adding a second,
|
|
424
|
+
higher entry does not help because the narrower key claims its paths first and the tree ends up
|
|
425
|
+
carrying both versions.
|
|
426
|
+
|
|
427
|
+
Raised in place: `'fast-uri@<3.1.6': '3.1.6'` and `'qs@<6.16.0': '6.16.0'`. `pnpm audit` is clean.
|
|
428
|
+
|
|
429
|
+
If you maintain your own overrides, the tell is: **`pnpm audit` reporting a package you already
|
|
430
|
+
override is never "transitive, pre-existing, not our problem"** — it means the entry is the cause.
|
|
431
|
+
See `.claude/rules/package-management.md` for both aging modes.
|
|
432
|
+
|
|
433
|
+
---
|
|
434
|
+
|
|
435
|
+
## Troubleshooting
|
|
436
|
+
|
|
437
|
+
| Symptom | Cause | Fix |
|
|
438
|
+
|---------|-------|-----|
|
|
439
|
+
| The reset mail link ends in `/undefined` | §3 — the token was read off a `process()` result, where the interceptor strips it | `createPasswordResetToken()`, read `created.token` |
|
|
440
|
+
| The reset mail link STARTS with `undefined/` | §3 — `email.passwordResetLink` unset and the caller concatenated anyway | `buildPasswordResetLink()`, which returns `null` instead of guessing |
|
|
441
|
+
| The reset page reports an invalid token, but the URL looks fine | §3 — the link carries a path segment and the page reads `?token=`, or the reverse | Configure `email.passwordResetLink` with `{token}` where your page expects it |
|
|
442
|
+
| A reset link that worked yesterday now 404s | §2 — tokens expire after 60 minutes | Request a new one, or `tokenExpiresInMinutes: 0` |
|
|
443
|
+
| `POST /users/password/reset` answers 404 for a token that exists | §1 — the value was not a plain string, or §2 — it expired | Both answer identically on purpose |
|
|
444
|
+
| Production sends no mail and the log shows `wrong version number` | §6 — port 587 with `secure: true` | Apply the `config.env.ts` edit |
|
|
445
|
+
| Production sends no mail and the log mentions STARTTLS | §6 — `requireTLS` and a relay that offers none | `SMTP_REQUIRE_TLS=false`, after confirming the relay really cannot |
|
|
446
|
+
| `/iam/token` fails with `Failed to decrypt private key` | §10 — a `jwks` key encrypted under a different secret | Drop the `jwks` collection after setting a permanent secret |
|
|
447
|
+
| Your tests get 404 on a reset token they wrote directly | §2 — the row has no `passwordResetTokenExpiresAt` and counts as expired | Set it in the fixture |
|
|
448
|
+
|
|
449
|
+
---
|
|
450
|
+
|
|
451
|
+
## Module Documentation
|
|
452
|
+
|
|
453
|
+
- [BetterAuth README](../src/core/modules/better-auth/README.md)
|
|
454
|
+
- [BetterAuth Integration Checklist](../src/core/modules/better-auth/INTEGRATION-CHECKLIST.md)
|
|
455
|
+
- [Request Lifecycle](../docs/REQUEST-LIFECYCLE.md)
|
|
456
|
+
- [Configurable Features](../.claude/rules/configurable-features.md)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lenne.tech/nest-server",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.39.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",
|
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
|
|
@@ -520,6 +530,10 @@ const config: { [env: string]: IServerOptions } = {
|
|
|
520
530
|
maxIterations: 5,
|
|
521
531
|
rateLimit: { max: 20, windowSeconds: 60 },
|
|
522
532
|
},
|
|
533
|
+
// Legacy auth is OFF by default since 11.38.0 (see isLegacyEndpointEnabled). This
|
|
534
|
+
// repository's own `src/server` registers the legacy module and its suites exercise
|
|
535
|
+
// BOTH auth systems, so every non-production environment here opts in explicitly —
|
|
536
|
+
// which is exactly what a project still running legacy now has to do.
|
|
523
537
|
auth: {
|
|
524
538
|
legacyEndpoints: { enabled: true },
|
|
525
539
|
},
|
|
@@ -653,7 +667,11 @@ const config: { [env: string]: IServerOptions } = {
|
|
|
653
667
|
// ===========================================================================
|
|
654
668
|
production: {
|
|
655
669
|
auth: {
|
|
656
|
-
|
|
670
|
+
// Opt-IN since 11.38.0: an unset LEGACY_AUTH_ENABLED now means "no legacy auth",
|
|
671
|
+
// not "legacy auth, because nobody said otherwise". Deployments that still need
|
|
672
|
+
// the legacy endpoints set LEGACY_AUTH_ENABLED=true until their users are migrated
|
|
673
|
+
// (`betterAuthMigrationStatus.canDisableLegacyAuth` says when they are).
|
|
674
|
+
legacyEndpoints: { enabled: process.env.LEGACY_AUTH_ENABLED === 'true' },
|
|
657
675
|
},
|
|
658
676
|
automaticObjectIdFiltering: true,
|
|
659
677
|
baseUrl: process.env.BASE_URL,
|
|
@@ -704,8 +722,14 @@ const config: { [env: string]: IServerOptions } = {
|
|
|
704
722
|
user: process.env.SMTP_USER,
|
|
705
723
|
},
|
|
706
724
|
host: process.env.SMTP_HOST,
|
|
707
|
-
port:
|
|
708
|
-
secure:
|
|
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),
|
|
709
733
|
},
|
|
710
734
|
},
|
|
711
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
|
+
}
|