@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.
Files changed (139) hide show
  1. package/.claude/rules/configurable-features.md +51 -0
  2. package/.claude/rules/module-deprecation.md +25 -1
  3. package/.claude/rules/package-management.md +66 -0
  4. package/.claude/rules/testing.md +81 -5
  5. package/FRAMEWORK-API.md +4 -2
  6. package/dist/config.env.js +5 -3
  7. package/dist/config.env.js.map +1 -1
  8. package/dist/core/common/helpers/config.helper.d.ts +2 -0
  9. package/dist/core/common/helpers/config.helper.js +18 -0
  10. package/dist/core/common/helpers/config.helper.js.map +1 -1
  11. package/dist/core/common/helpers/cookies.helper.d.ts +3 -0
  12. package/dist/core/common/helpers/cookies.helper.js +9 -0
  13. package/dist/core/common/helpers/cookies.helper.js.map +1 -1
  14. package/dist/core/common/helpers/input.helper.d.ts +1 -0
  15. package/dist/core/common/helpers/input.helper.js +4 -0
  16. package/dist/core/common/helpers/input.helper.js.map +1 -1
  17. package/dist/core/common/helpers/service.helper.js +6 -1
  18. package/dist/core/common/helpers/service.helper.js.map +1 -1
  19. package/dist/core/common/interceptors/check-security.interceptor.js +1 -0
  20. package/dist/core/common/interceptors/check-security.interceptor.js.map +1 -1
  21. package/dist/core/common/interfaces/server-options.interface.d.ts +21 -0
  22. package/dist/core/common/middlewares/security-headers.middleware.d.ts +18 -0
  23. package/dist/core/common/middlewares/security-headers.middleware.js +90 -0
  24. package/dist/core/common/middlewares/security-headers.middleware.js.map +1 -0
  25. package/dist/core/common/services/email.service.d.ts +4 -1
  26. package/dist/core/common/services/email.service.js +25 -2
  27. package/dist/core/common/services/email.service.js.map +1 -1
  28. package/dist/core/common/services/module.service.js +1 -0
  29. package/dist/core/common/services/module.service.js.map +1 -1
  30. package/dist/core/modules/auth/core-auth.controller.js +2 -5
  31. package/dist/core/modules/auth/core-auth.controller.js.map +1 -1
  32. package/dist/core/modules/auth/core-auth.module.js +2 -0
  33. package/dist/core/modules/auth/core-auth.module.js.map +1 -1
  34. package/dist/core/modules/auth/core-auth.resolver.js +2 -5
  35. package/dist/core/modules/auth/core-auth.resolver.js.map +1 -1
  36. package/dist/core/modules/auth/core-legacy-auth-deprecation.initializer.d.ts +12 -0
  37. package/dist/core/modules/auth/core-legacy-auth-deprecation.initializer.js +83 -0
  38. package/dist/core/modules/auth/core-legacy-auth-deprecation.initializer.js.map +1 -0
  39. package/dist/core/modules/auth/helpers/legacy-endpoints.helper.d.ts +3 -0
  40. package/dist/core/modules/auth/helpers/legacy-endpoints.helper.js +14 -0
  41. package/dist/core/modules/auth/helpers/legacy-endpoints.helper.js.map +1 -0
  42. package/dist/core/modules/better-auth/better-auth.config.d.ts +12 -0
  43. package/dist/core/modules/better-auth/better-auth.config.js +40 -1
  44. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  45. package/dist/core/modules/better-auth/core-better-auth-api.middleware.d.ts +9 -1
  46. package/dist/core/modules/better-auth/core-better-auth-api.middleware.js +53 -4
  47. package/dist/core/modules/better-auth/core-better-auth-api.middleware.js.map +1 -1
  48. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.d.ts +5 -2
  49. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +57 -4
  50. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
  51. package/dist/core/modules/better-auth/core-better-auth-error-codes.helper.d.ts +2 -0
  52. package/dist/core/modules/better-auth/core-better-auth-error-codes.helper.js +54 -0
  53. package/dist/core/modules/better-auth/core-better-auth-error-codes.helper.js.map +1 -0
  54. package/dist/core/modules/better-auth/core-better-auth-password-reset.registry.d.ts +2 -0
  55. package/dist/core/modules/better-auth/core-better-auth-password-reset.registry.js +19 -0
  56. package/dist/core/modules/better-auth/core-better-auth-password-reset.registry.js.map +1 -0
  57. package/dist/core/modules/better-auth/core-better-auth-user.mapper.d.ts +4 -1
  58. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +13 -10
  59. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
  60. package/dist/core/modules/better-auth/core-better-auth.module.d.ts +8 -2
  61. package/dist/core/modules/better-auth/core-better-auth.module.js +34 -4
  62. package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
  63. package/dist/core/modules/better-auth/index.d.ts +1 -0
  64. package/dist/core/modules/better-auth/index.js +1 -0
  65. package/dist/core/modules/better-auth/index.js.map +1 -1
  66. package/dist/core/modules/error-code/error-codes.d.ts +27 -0
  67. package/dist/core/modules/error-code/error-codes.js +24 -0
  68. package/dist/core/modules/error-code/error-codes.js.map +1 -1
  69. package/dist/core/modules/hub/core-hub.service.js +1 -0
  70. package/dist/core/modules/hub/core-hub.service.js.map +1 -1
  71. package/dist/core/modules/user/core-user.model.d.ts +1 -0
  72. package/dist/core/modules/user/core-user.model.js +11 -1
  73. package/dist/core/modules/user/core-user.model.js.map +1 -1
  74. package/dist/core/modules/user/core-user.service.d.ts +10 -1
  75. package/dist/core/modules/user/core-user.service.js +124 -15
  76. package/dist/core/modules/user/core-user.service.js.map +1 -1
  77. package/dist/core/modules/user/inputs/core-user.input.js +1 -1
  78. package/dist/core/modules/user/inputs/core-user.input.js.map +1 -1
  79. package/dist/core.module.js +2 -0
  80. package/dist/core.module.js.map +1 -1
  81. package/dist/index.d.ts +3 -0
  82. package/dist/index.js +3 -0
  83. package/dist/index.js.map +1 -1
  84. package/dist/server/modules/error-code/error-codes.d.ts +3 -0
  85. package/dist/server/modules/user/user.controller.js +2 -1
  86. package/dist/server/modules/user/user.controller.js.map +1 -1
  87. package/dist/server/modules/user/user.model.d.ts +5 -0
  88. package/dist/server/modules/user/user.resolver.js +2 -1
  89. package/dist/server/modules/user/user.resolver.js.map +1 -1
  90. package/dist/server/modules/user/user.service.d.ts +1 -1
  91. package/dist/server/modules/user/user.service.js +18 -5
  92. package/dist/server/modules/user/user.service.js.map +1 -1
  93. package/dist/templates/password-reset-de.ejs +12 -0
  94. package/dist/templates/password-reset-en.ejs +12 -0
  95. package/dist/templates/password-reset.ejs +1 -0
  96. package/dist/tsconfig.build.tsbuildinfo +1 -1
  97. package/docs/REQUEST-LIFECYCLE.md +49 -1
  98. package/migration-guides/11.37.x-to-11.38.x.md +528 -0
  99. package/migration-guides/11.38.x-to-11.39.x.md +456 -0
  100. package/package.json +1 -1
  101. package/src/config.env.ts +28 -4
  102. package/src/core/common/helpers/config.helper.ts +79 -0
  103. package/src/core/common/helpers/cookies.helper.ts +38 -0
  104. package/src/core/common/helpers/input.helper.ts +37 -0
  105. package/src/core/common/helpers/service.helper.ts +9 -1
  106. package/src/core/common/interceptors/check-security.interceptor.ts +1 -0
  107. package/src/core/common/interfaces/server-options.interface.ts +326 -10
  108. package/src/core/common/middlewares/security-headers.middleware.ts +155 -0
  109. package/src/core/common/services/email.service.ts +46 -1
  110. package/src/core/common/services/module.service.ts +1 -0
  111. package/src/core/modules/auth/README.md +104 -0
  112. package/src/core/modules/auth/core-auth.controller.ts +4 -11
  113. package/src/core/modules/auth/core-auth.module.ts +5 -0
  114. package/src/core/modules/auth/core-auth.resolver.ts +4 -11
  115. package/src/core/modules/auth/core-legacy-auth-deprecation.initializer.ts +128 -0
  116. package/src/core/modules/auth/helpers/legacy-endpoints.helper.ts +69 -0
  117. package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +22 -0
  118. package/src/core/modules/better-auth/README.md +32 -59
  119. package/src/core/modules/better-auth/better-auth.config.ts +131 -4
  120. package/src/core/modules/better-auth/core-better-auth-api.middleware.ts +126 -3
  121. package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +166 -7
  122. package/src/core/modules/better-auth/core-better-auth-error-codes.helper.ts +146 -0
  123. package/src/core/modules/better-auth/core-better-auth-password-reset.registry.ts +92 -0
  124. package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +42 -10
  125. package/src/core/modules/better-auth/core-better-auth.module.ts +67 -4
  126. package/src/core/modules/better-auth/index.ts +1 -0
  127. package/src/core/modules/error-code/error-codes.ts +55 -0
  128. package/src/core/modules/hub/core-hub.service.ts +1 -0
  129. package/src/core/modules/user/core-user.model.ts +28 -1
  130. package/src/core/modules/user/core-user.service.ts +389 -22
  131. package/src/core/modules/user/inputs/core-user.input.ts +16 -1
  132. package/src/core.module.ts +8 -0
  133. package/src/index.ts +3 -0
  134. package/src/server/modules/user/user.controller.ts +7 -1
  135. package/src/server/modules/user/user.resolver.ts +7 -1
  136. package/src/server/modules/user/user.service.ts +58 -13
  137. package/src/templates/password-reset-de.ejs +12 -0
  138. package/src/templates/password-reset-en.ejs +12 -0
  139. package/src/templates/password-reset.ejs +1 -0
@@ -856,3 +856,40 @@ export function typeofArray(arr: any[], strict = false): string {
856
856
  }
857
857
  return type;
858
858
  }
859
+
860
+ /**
861
+ * Whether a value is safe to use as the right-hand side of a Mongoose equality filter.
862
+ *
863
+ * ── The hole this closes ───────────────────────────────────────────────────────
864
+ * A controller parameter declared as `@Body('token') token: string` has
865
+ * `metatype === String`, and `MapAndValidatePipe` short-circuits on exactly that shape:
866
+ * `if (!value || typeof value !== 'object' || !metatype || isBasicType(metatype)) return value`.
867
+ * The declared type is erased at runtime, so nothing checks it — a JSON object reaches the
868
+ * service verbatim.
869
+ *
870
+ * `findOne({ passwordResetToken: token })` with `token = { $ne: null }` therefore selects the
871
+ * first user holding ANY live reset token, and the caller sets that person's password without
872
+ * ever seeing their mail. Confirmed by probe against the real route, not by reading.
873
+ *
874
+ * Express parses query strings with `qs` in extended mode, so `?token[$ne]=` produces the same
875
+ * object without a JSON body at all — `@Query('token') token: string` is exposed identically.
876
+ *
877
+ * `{ $ne: null }` is the takeover primitive; `null` is the quieter one, because MongoDB matches
878
+ * MISSING fields against `null` and selects a user who never requested anything.
879
+ *
880
+ * ── Why a guard at the sink rather than `mongoose.set('sanitizeFilter', true)` ──
881
+ * The global switch wraps every object-valued filter in `$eq`, which would break the framework's
882
+ * own operator-bearing queries (`$in`, `$ne`, `$gt` in the filter helpers) unless each is wrapped
883
+ * in `mongoose.trusted()`. That is a fleet-wide audit, not a fix. This guard is exact: it says
884
+ * "this particular value came from a client and must be a plain string".
885
+ *
886
+ * Rejects an empty string too — `findOne({ token: '' })` is never a legitimate credential lookup,
887
+ * and `undefined` would be stripped from the filter entirely by Mongoose, turning the query into
888
+ * `findOne({})` and matching the first document in the collection.
889
+ *
890
+ * @param value - the raw value as it arrived from the transport
891
+ * @returns whether it may be used as a filter value
892
+ */
893
+ export function isQueryableString(value: unknown): value is string {
894
+ return typeof value === 'string' && value.length > 0;
895
+ }
@@ -18,7 +18,15 @@ import { clone, plainToInstanceClean, processDeep } from './input.helper';
18
18
  // Fields like refreshTokens/tempTokens are kept — they are needed for token validation
19
19
  // and process flows (password reset, email verification). The CheckSecurityInterceptor
20
20
  // removes those from HTTP responses as a separate layer.
21
- const SECRET_FIELD_NAMES = Object.freeze(['password', 'verificationToken', 'passwordResetToken']);
21
+ const SECRET_FIELD_NAMES = Object.freeze([
22
+ 'password',
23
+ 'verificationToken',
24
+ 'passwordResetToken',
25
+ // `S_NO_ONE` covers Model instances; this list is what runs on a plain-object path (`.lean()`,
26
+ // `aggregate`, a spread). Its sibling token has always been here — the timestamp says "a reset
27
+ // is pending for this account", which is exactly what the field's own JSDoc calls attacker-useful.
28
+ 'passwordResetTokenExpiresAt',
29
+ ]);
22
30
 
23
31
  /**
24
32
  * Helper class for services
@@ -21,6 +21,7 @@ export class CheckSecurityInterceptor implements NestInterceptor {
21
21
  'password',
22
22
  'verificationToken',
23
23
  'passwordResetToken',
24
+ 'passwordResetTokenExpiresAt',
24
25
  'refreshTokens',
25
26
  'tempTokens',
26
27
  'apiKeyEncrypted',
@@ -118,6 +118,13 @@ export interface IAuth {
118
118
  */
119
119
  preventUserEnumeration?: boolean;
120
120
 
121
+ /**
122
+ * Password-reset request behaviour.
123
+ *
124
+ * @since 11.38.0
125
+ */
126
+ passwordReset?: IAuthPasswordReset;
127
+
121
128
  /**
122
129
  * Rate limiting configuration for Legacy Auth endpoints
123
130
  *
@@ -154,23 +161,211 @@ export interface IAuth {
154
161
  * @since 11.7.1
155
162
  * @see https://github.com/lenneTech/nest-server/blob/develop/.claude/rules/module-deprecation.md
156
163
  */
164
+ /**
165
+ * Interface for the legacy password-reset request endpoint
166
+ *
167
+ * @since 11.38.0
168
+ */
169
+ /**
170
+ * Browser security headers set on every response.
171
+ *
172
+ * @since 11.38.0
173
+ */
174
+ export interface ISecurityHeaders {
175
+ /**
176
+ * `Content-Security-Policy` value.
177
+ *
178
+ * **Opt-in on purpose — there is no safe default.** A CSP that does not match what the
179
+ * application loads breaks it rather than hardening it, and only the project knows that. This
180
+ * package additionally serves its own HTML from two surfaces (the Hub and the GraphQL
181
+ * playground), so any value shipped here would be wrong for somebody.
182
+ *
183
+ * @default undefined (no header)
184
+ */
185
+ contentSecurityPolicy?: string;
186
+
187
+ /**
188
+ * `X-Content-Type-Options: nosniff` — stops a browser from re-interpreting a response as a
189
+ * type the server did not declare, which is what turns an uploaded file into a script.
190
+ *
191
+ * @default true
192
+ */
193
+ contentTypeOptions?: boolean;
194
+
195
+ /**
196
+ * Whether the headers are set at all. `false` disables the middleware entirely.
197
+ *
198
+ * @default true
199
+ */
200
+ enabled?: boolean;
201
+
202
+ /**
203
+ * `X-Frame-Options` — clickjacking protection. `false` omits the header.
204
+ *
205
+ * @default 'DENY'
206
+ */
207
+ frameOptions?: 'DENY' | 'SAMEORIGIN' | false;
208
+
209
+ /**
210
+ * `Strict-Transport-Security`. `false` omits it.
211
+ *
212
+ * **Only ever sent on a request that actually arrived over HTTPS** — decided by
213
+ * `x-forwarded-proto`, falling back to the connection protocol, and never by configuration.
214
+ * A browser REMEMBERS this header: one sent from a dev server over `http://localhost` makes
215
+ * every project on that host unreachable over http for up to a year, with no way to undo it
216
+ * from the server. So it must not be possible to switch it on where it does not belong.
217
+ *
218
+ * Note this makes `trustProxy` load-bearing here too: behind a TLS-terminating proxy the
219
+ * connection reaching Node is plain http, so without a trusted forwarded header no production
220
+ * request looks secure and no HSTS is sent.
221
+ *
222
+ * @default `{ includeSubDomains: true, maxAge: 31536000, preload: false }`
223
+ */
224
+ hsts?: boolean | ISecurityHstsOptions;
225
+
226
+ /**
227
+ * `Referrer-Policy` — how much of the current URL travels to a third party. `false` omits it.
228
+ *
229
+ * @default 'strict-origin-when-cross-origin'
230
+ */
231
+ referrerPolicy?: false | string;
232
+
233
+ /**
234
+ * Whether to strip Express's `X-Powered-By`, which names the stack to an attacker and buys
235
+ * nothing.
236
+ *
237
+ * @default true
238
+ */
239
+ removePoweredBy?: boolean;
240
+ }
241
+
242
+ /**
243
+ * `Strict-Transport-Security` directives.
244
+ *
245
+ * @since 11.38.0
246
+ */
247
+ export interface ISecurityHstsOptions {
248
+ /** Apply to every subdomain. @default true */
249
+ includeSubDomains?: boolean;
250
+
251
+ /** Lifetime in seconds. @default 31536000 (one year) */
252
+ maxAge?: number;
253
+
254
+ /**
255
+ * Add the `preload` directive.
256
+ *
257
+ * **Off by default.** It is a submission to a browser-vendor list that is slow and awkward to
258
+ * reverse and commits every subdomain — a decision for whoever owns the domain, not a framework
259
+ * default.
260
+ *
261
+ * @default false
262
+ */
263
+ preload?: boolean;
264
+ }
265
+
266
+ export interface IAuthPasswordReset {
267
+ /**
268
+ * Whether `POST /users/password/reset-request` answers identically for a known and an unknown
269
+ * address.
270
+ *
271
+ * **Defaults to `true`** — the safe answer. Set `false` only to restore the previous behaviour,
272
+ * where an unknown address produced HTTP 404 and a known one HTTP 201: a working oracle for
273
+ * "does this person have an account here", which matters most in a multi-tenant product, where
274
+ * it also answers "who works at which customer".
275
+ *
276
+ * Deliberately NOT the same switch as {@link IAuth.preventUserEnumeration}, which governs the
277
+ * SIGN-IN messages and defaults to `false` for backward compatibility. Two settings because the
278
+ * trade-offs differ: at sign-in the distinction is genuine UX ("wrong password" vs "unknown
279
+ * address"), while a reset request carries no password at all — the only thing given up here is
280
+ * the form's ability to say "we do not know this address".
281
+ *
282
+ * **The status code is the smaller half.** The response TIME also distinguishes the two cases:
283
+ * the known path writes a token and sends mail, the unknown path returns immediately. This
284
+ * option equalises what the framework controls, but a project whose `sendPasswordResetMail()`
285
+ * AWAITS the mail send still leaks the difference — the send is a network round trip, orders of
286
+ * magnitude above anything else in the request. See `src/server/modules/user/user.service.ts`
287
+ * for the reference implementation, which does not await it, and the 11.38.x migration guide.
288
+ *
289
+ * @default true
290
+ *
291
+ * @example
292
+ * ```typescript
293
+ * auth: {
294
+ * // Restore the pre-11.38.0 behaviour — the reset form can then say "unknown address"
295
+ * passwordReset: { preventUserEnumeration: false },
296
+ * }
297
+ * ```
298
+ */
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;
345
+ }
346
+
157
347
  export interface IAuthLegacyEndpoints {
158
348
  /**
159
349
  * Whether legacy auth endpoints are enabled.
160
350
  *
161
- * Set to false to disable all legacy auth endpoints (GraphQL and REST).
162
- * Use this after all users have migrated to BetterAuth (IAM).
351
+ * **Since 11.38.0 this defaults to `false`.** Legacy Auth is superseded by IAM
352
+ * (Better-Auth) and slated for removal. A project that registers the legacy module
353
+ * but never made a decision here used to keep a second, fully functional
354
+ * password-authentication surface open indefinitely; it now has to be asked for.
355
+ * Disabled endpoints answer HTTP 410 Gone.
163
356
  *
164
- * Check migration status via the `betterAuthMigrationStatus` query.
357
+ * Set `true` while users are still migrating. `betterAuthMigrationStatus`
358
+ * `canDisableLegacyAuth` says when it can go back off; the boot log reports the same
359
+ * figure on every start.
165
360
  *
166
361
  * **Environment Variable:** `LEGACY_AUTH_ENABLED`
167
362
  *
168
- * @default true
363
+ * @default false (was `true` before 11.38.0)
169
364
  *
170
365
  * @example
171
366
  * ```typescript
172
- * // Via environment variable
173
- * enabled: process.env.LEGACY_AUTH_ENABLED !== 'false',
367
+ * // Via environment variable — note `=== 'true'`, not `!== 'false'`
368
+ * enabled: process.env.LEGACY_AUTH_ENABLED === 'true',
174
369
  * ```
175
370
  */
176
371
  enabled?: boolean;
@@ -179,15 +374,21 @@ export interface IAuthLegacyEndpoints {
179
374
  * Whether legacy GraphQL auth endpoints are enabled.
180
375
  * Affects: signIn, signUp, signOut, refreshToken mutations
181
376
  *
182
- * @default true (inherits from `enabled`)
377
+ * Overrides `enabled` for this transport — except that an explicit
378
+ * `enabled: false` stays a hard off switch and cannot be reopened here.
379
+ *
380
+ * @default false (inherits from `enabled`, which defaults to false since 11.38.0)
183
381
  */
184
382
  graphql?: boolean;
185
383
 
186
384
  /**
187
385
  * Whether legacy REST auth endpoints are enabled.
188
- * Affects: /auth/sign-in, /auth/sign-up, etc.
386
+ * Affects: /auth/signin, /auth/signup, etc.
387
+ *
388
+ * Overrides `enabled` for this transport — except that an explicit
389
+ * `enabled: false` stays a hard off switch and cannot be reopened here.
189
390
  *
190
- * @default true (inherits from `enabled`)
391
+ * @default false (inherits from `enabled`, which defaults to false since 11.38.0)
191
392
  */
192
393
  rest?: boolean;
193
394
  }
@@ -337,6 +538,12 @@ export interface IBetterAuthEmailVerificationConfig {
337
538
  * When not set, the verification link points directly to the backend
338
539
  * endpoint which handles verification and redirects.
339
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
+ *
340
547
  * @default undefined (backend-handled verification)
341
548
  * @since 11.13.0
342
549
  */
@@ -378,6 +585,57 @@ export interface IBetterAuthEmailVerificationConfig {
378
585
  */
379
586
  passwordResetBrevoTemplateId?: number;
380
587
 
588
+ /**
589
+ * Where the password-reset mail sends the recipient.
590
+ *
591
+ * **Defaults to the APP**, derived as `<appUrl>/auth/reset-password?token=<token>` — the path the
592
+ * starter's reset page lives at.
593
+ *
594
+ * Without this, the link is the one Better-Auth generates, and that points at the **API**
595
+ * (`https://api.example.com/iam/reset-password/<token>?callbackURL=…`), which then redirects to
596
+ * the app. That works, but it puts a domain the recipient does not recognise into a password
597
+ * mail — precisely what people are trained to be suspicious of. In this stack an app and an API
598
+ * host are the norm, so the app is the better default.
599
+ *
600
+ * **What the API hop did, and what you give up.** Better-Auth's redirect route validates the
601
+ * token and its expiry before forwarding, so an expired link produced an error page instead of a
602
+ * form that fails on submit. Linking straight to the app moves that error later, to the moment
603
+ * the new password is submitted. It is NOT a security difference — the token ends up in the app
604
+ * URL either way, and the `callbackURL` origin check only exists because of the hop. If you want
605
+ * the early error back, either leave this unset or have the reset page validate the token before
606
+ * rendering.
607
+ *
608
+ * `{token}` is substituted anywhere in the value. Without the placeholder, `?token=<token>` is
609
+ * appended — so a page that reads a PATH parameter configures
610
+ * `https://example.com/auth/reset-password/{token}`.
611
+ *
612
+ * Set to `false` to keep Better-Auth's own link.
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
+ *
618
+ * Note this is separate from `email.passwordResetLink`, which serves the LEGACY
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.
623
+ *
624
+ * @default `<appUrl>/auth/reset-password?token={token}`
625
+ * @since 11.38.0
626
+ *
627
+ * @example
628
+ * ```typescript
629
+ * betterAuth: {
630
+ * emailVerification: {
631
+ * // A page that reads the token from the path
632
+ * passwordResetLink: 'https://example.com/auth/reset-password/{token}',
633
+ * },
634
+ * }
635
+ * ```
636
+ */
637
+ passwordResetLink?: false | string;
638
+
381
639
  /**
382
640
  * Cooldown in seconds between resend requests for the same email address.
383
641
  * Prevents abuse by limiting how often verification emails can be resent.
@@ -1983,7 +2241,50 @@ export interface IServerOptions {
1983
2241
  mailjet?: MailjetOptions;
1984
2242
 
1985
2243
  /**
1986
- * Password reset link for email
2244
+ * Base of the link in the LEGACY password-reset mail (`/users/password/reset-request`).
2245
+ *
2246
+ * Three cases, and the difference between them is where the token lands:
2247
+ *
2248
+ * | Value | Link the recipient gets |
2249
+ * | --------------------------------------- | ---------------------------------------- |
2250
+ * | contains `{token}` | placeholder replaced, wherever it sits |
2251
+ * | set, but no `{token}` | token appended as a PATH segment |
2252
+ * | not set (the default below) | `?token=<token>`, per the default's shape |
2253
+ *
2254
+ * **Copying the default is safe; writing your own base is where it gets sharp.** The default
2255
+ * carries `{token}`, so pasting it lands in row one and behaves exactly as leaving the option
2256
+ * out. A base WITHOUT the placeholder lands in row two instead — same URL, different link. That
2257
+ * is deliberate: it is the convention the legacy flow has always used, and changing it would
2258
+ * silently break every project whose page reads a path parameter. It is also easy to walk into
2259
+ * by accident, so the framework logs a warning at boot for any configured value without a
2260
+ * placeholder. The way to silence it is to write `{token}` where you want the token, which
2261
+ * states the convention in the place it applies instead of leaving it implicit.
2262
+ *
2263
+ * Build the link with `CoreUserService.buildPasswordResetLink(token)` rather than by
2264
+ * concatenation. It returns `null` when it can resolve nothing — which is the difference
2265
+ * between sending no mail and sending one whose link reads `undefined/<token>`. The latter
2266
+ * happened in a downstream project and reached real recipients: the request succeeds, the mail
2267
+ * arrives, it looks right, and only the click reveals it, to somebody who by definition has no
2268
+ * second way in.
2269
+ *
2270
+ * Note this is separate from `betterAuth.emailVerification.passwordResetLink`, which serves the
2271
+ * IAM flow. Both DEFAULTS now point at the same page with `?token=`; what still differs is the
2272
+ * fallback for a configured value without a placeholder — path segment here, `?token=` there.
2273
+ * A project using both flows should point them at the same page, and writing `{token}` in both
2274
+ * makes them identical.
2275
+ *
2276
+ * @default `<appUrl>/auth/reset-password?token={token}`
2277
+ * @since 11.38.0
2278
+ *
2279
+ * @example
2280
+ * ```typescript
2281
+ * email: {
2282
+ * // A page that reads the token from the path. Spelling out `{token}` is what keeps the
2283
+ * // boot warning quiet — `'https://example.com/auth/reset-password'` produces the same
2284
+ * // link, but leaves the reader guessing which convention was meant.
2285
+ * passwordResetLink: 'https://example.com/auth/reset-password/{token}',
2286
+ * }
2287
+ * ```
1987
2288
  */
1988
2289
  passwordResetLink?: string;
1989
2290
 
@@ -2643,6 +2944,21 @@ export interface IServerOptions {
2643
2944
  *
2644
2945
  * @since 11.18.0
2645
2946
  */
2947
+ /**
2948
+ * Browser security headers (HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy).
2949
+ *
2950
+ * Set on EVERY response, including the ones a guard rejects — they are registered as
2951
+ * middleware, so a `401` carries them too.
2952
+ *
2953
+ * `true` / `{}` / omitted → enabled with the safe defaults. `false` → no headers at all.
2954
+ * A reverse proxy setting the same headers wins, because it writes last; this layer exists so
2955
+ * that a deployment without one, or a local instance, is not bare.
2956
+ *
2957
+ * @default true
2958
+ * @since 11.38.0
2959
+ */
2960
+ headers?: boolean | ISecurityHeaders;
2961
+
2646
2962
  responseModelInterceptor?:
2647
2963
  | boolean
2648
2964
  | {
@@ -0,0 +1,155 @@
1
+ import { Injectable, NestMiddleware } from '@nestjs/common';
2
+ import { Response as ExpressResponse, NextFunction, Request } from 'express';
3
+
4
+ import { ISecurityHeaders } from '../interfaces/server-options.interface';
5
+ import { ConfigService } from '../services/config.service';
6
+
7
+ /**
8
+ * Sets the standard browser security headers on every response.
9
+ *
10
+ * WHY THIS IS IN THE FRAMEWORK AND NOT IN EACH PROJECT
11
+ *
12
+ * The same header set was being rebuilt by hand across the stack — one project inline in
13
+ * `main.ts`, another as its own middleware, a third about to. A freshly generated project sent
14
+ * none at all. Anything rebuilt per project drifts per project, and a header that is missing
15
+ * produces no error anywhere: it shows up in a pentest report months later.
16
+ *
17
+ * Putting it here also reaches EXISTING projects on their next update, which a change in the
18
+ * starter would not.
19
+ *
20
+ * ORDER MATTERS: THIS IS MIDDLEWARE, NOT AN INTERCEPTOR
21
+ *
22
+ * Registered through `CoreModule.configure()`, so it runs before guards and before the handler.
23
+ * That is deliberate — a `401` from a roles guard is still a response a browser renders, and it
24
+ * has to carry the headers too. An interceptor would miss every request rejected before the
25
+ * handler, which is precisely the set an attacker is most likely to be generating.
26
+ *
27
+ * NOT A REPLACEMENT FOR THE EDGE
28
+ *
29
+ * A reverse proxy (Traefik, Caddy, nginx) can set the same headers, and where one does, its
30
+ * values win — the proxy writes last. That is fine and intended: this layer exists so that a
31
+ * deployment WITHOUT such a proxy, or a local instance, is not bare. Defense in depth, not a
32
+ * turf claim.
33
+ */
34
+ @Injectable()
35
+ export class SecurityHeadersMiddleware implements NestMiddleware {
36
+ constructor(protected readonly configService: ConfigService) {}
37
+
38
+ use(req: Request, res: ExpressResponse, next: NextFunction): void {
39
+ const config = this.resolveConfig();
40
+ if (!config) {
41
+ return next();
42
+ }
43
+
44
+ // Express advertises itself by default. It tells an attacker which stack to target and buys
45
+ // nothing; `app.disable('x-powered-by')` would work too, but doing it here keeps the whole
46
+ // header policy in one readable place.
47
+ if (config.removePoweredBy) {
48
+ res.removeHeader('X-Powered-By');
49
+ }
50
+
51
+ if (config.contentTypeOptions) {
52
+ res.setHeader('X-Content-Type-Options', 'nosniff');
53
+ }
54
+
55
+ if (config.frameOptions) {
56
+ res.setHeader('X-Frame-Options', config.frameOptions);
57
+ }
58
+
59
+ if (config.referrerPolicy) {
60
+ res.setHeader('Referrer-Policy', config.referrerPolicy);
61
+ }
62
+
63
+ if (config.contentSecurityPolicy) {
64
+ res.setHeader('Content-Security-Policy', config.contentSecurityPolicy);
65
+ }
66
+
67
+ if (config.hsts && this.isSecureRequest(req)) {
68
+ const directives = [`max-age=${config.hsts.maxAge}`];
69
+ if (config.hsts.includeSubDomains) {
70
+ directives.push('includeSubDomains');
71
+ }
72
+ if (config.hsts.preload) {
73
+ directives.push('preload');
74
+ }
75
+ res.setHeader('Strict-Transport-Security', directives.join('; '));
76
+ }
77
+
78
+ next();
79
+ }
80
+
81
+ /**
82
+ * Whether this request actually arrived over HTTPS.
83
+ *
84
+ * **HSTS is decided by the protocol, never by configuration or environment.** A browser
85
+ * remembers the header: one `Strict-Transport-Security` sent from a dev server over
86
+ * `http://localhost` makes EVERY project on that host unreachable over http for up to a year,
87
+ * and there is no way to take it back from the server side. So an env flag must not be able to
88
+ * turn it on where it does not belong — the request itself has to say so.
89
+ *
90
+ * `x-forwarded-proto` is read first because in every deployment that terminates TLS at a proxy
91
+ * the connection reaching Node is plain http, and `req.secure` alone would answer "no" for
92
+ * every production request. That header is only trustworthy behind a proxy, which is what
93
+ * `trustProxy` exists to declare — and Express only populates `req.secure` from it when
94
+ * `trust proxy` is set, so both halves agree on the same configuration.
95
+ */
96
+ protected isSecureRequest(req: Request): boolean {
97
+ const forwarded = req.headers['x-forwarded-proto'];
98
+ if (typeof forwarded === 'string' && forwarded.length) {
99
+ // A proxy chain may append: "https,http". The client-facing hop is the first entry.
100
+ return forwarded.split(',')[0].trim().toLowerCase() === 'https';
101
+ }
102
+
103
+ return req.secure === true || req.protocol === 'https';
104
+ }
105
+
106
+ /**
107
+ * Resolves the effective policy, or `null` when the feature is switched off.
108
+ *
109
+ * Safe by default: an unconfigured project gets the full set except CSP. CSP is opt-in because
110
+ * a wrong one breaks the application rather than hardening it, and a correct one depends on
111
+ * what the project loads — this package cannot know that, and two of its own surfaces (the Hub
112
+ * and the GraphQL playground) serve their own HTML.
113
+ */
114
+ protected resolveConfig():
115
+ | null
116
+ | (Required<Omit<ISecurityHeaders, 'contentSecurityPolicy' | 'enabled' | 'hsts'>> & {
117
+ contentSecurityPolicy?: string;
118
+ hsts: null | { includeSubDomains: boolean; maxAge: number; preload: boolean };
119
+ }) {
120
+ const raw = this.configService.getFastButReadOnly('security')?.headers;
121
+
122
+ if (raw === false) {
123
+ return null;
124
+ }
125
+
126
+ const options: ISecurityHeaders = raw === true || raw === undefined ? {} : raw;
127
+ if (options.enabled === false) {
128
+ return null;
129
+ }
130
+
131
+ const hsts =
132
+ options.hsts === false ? null : options.hsts === true || options.hsts === undefined ? {} : options.hsts;
133
+
134
+ return {
135
+ contentSecurityPolicy: options.contentSecurityPolicy,
136
+ contentTypeOptions: options.contentTypeOptions !== false,
137
+ frameOptions: options.frameOptions === false ? undefined : (options.frameOptions ?? 'DENY'),
138
+ hsts: hsts
139
+ ? {
140
+ includeSubDomains: hsts.includeSubDomains !== false,
141
+ // One year. The value the major preload lists require, and long enough that it is a
142
+ // decision rather than a formality.
143
+ maxAge: typeof hsts.maxAge === 'number' ? hsts.maxAge : 31536000,
144
+ // NOT on by default: `preload` is a submission to a browser-vendor list that is
145
+ // slow and awkward to reverse, and it commits every subdomain. That belongs to
146
+ // whoever owns the domain, not to a framework default.
147
+ preload: hsts.preload === true,
148
+ }
149
+ : null,
150
+ referrerPolicy:
151
+ options.referrerPolicy === false ? undefined : (options.referrerPolicy ?? 'strict-origin-when-cross-origin'),
152
+ removePoweredBy: options.removePoweredBy !== false,
153
+ } as any;
154
+ }
155
+ }