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