@lenne.tech/nest-server 11.27.4 → 11.27.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "11.27.4",
3
+ "version": "11.27.6",
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",
@@ -48,8 +48,9 @@
48
48
  "start:dev:swc": "nest start -b swc -w --type-check",
49
49
  "start:local": "NODE_ENV=local nodemon",
50
50
  "start:local:swc": "NODE_ENV=local nest start -b swc -w --type-check",
51
- "test": "pnpm run vitest",
52
- "test:ci": "pnpm run vitest:ci",
51
+ "test": "pnpm run vitest:unit && pnpm run vitest",
52
+ "test:ci": "pnpm run vitest:unit && pnpm run vitest:ci",
53
+ "test:cov": "pnpm run vitest:unit:cov && pnpm run vitest:cov",
53
54
  "test:e2e": "pnpm run vitest",
54
55
  "prepack": "pnpm run prestart:prod",
55
56
  "prepublishOnly": "pnpm run lint && pnpm run test:ci",
@@ -59,6 +60,7 @@
59
60
  "vitest:cov": "NODE_ENV=e2e vitest run --coverage --config vitest-e2e.config.ts",
60
61
  "vitest:watch": "NODE_ENV=e2e vitest --config vitest-e2e.config.ts",
61
62
  "vitest:unit": "vitest run --config vitest.config.ts",
63
+ "vitest:unit:cov": "vitest run --coverage --config vitest.config.ts",
62
64
  "test:unit:watch": "vitest --config vitest.config.ts",
63
65
  "test:types": "tsc --noEmit --skipLibCheck -p tests/types/tsconfig.json",
64
66
  "test:cleanup": "find tests -type f \\( -name '*.txt' -o -name '*.bin' \\) -not -name '.gitkeep' -delete && echo 'Test artifacts cleaned up'",
@@ -72,12 +74,12 @@
72
74
  "url": "https://github.com/lenneTech/nest-server/issues"
73
75
  },
74
76
  "engines": {
75
- "node": ">= 20"
77
+ "node": ">= 22"
76
78
  },
77
79
  "dependencies": {
78
80
  "@apollo/server": "5.5.1",
79
81
  "@as-integrations/express5": "1.1.2",
80
- "@better-auth/passkey": "1.6.11",
82
+ "@better-auth/passkey": "1.6.23",
81
83
  "@getbrevo/brevo": "3.0.1",
82
84
  "@modelcontextprotocol/sdk": "1.29.0",
83
85
  "@nestjs/apollo": "13.4.2",
@@ -96,7 +98,7 @@
96
98
  "@tus/server": "2.4.1",
97
99
  "@types/supertest": "7.2.0",
98
100
  "bcrypt": "6.0.0",
99
- "better-auth": "1.6.11",
101
+ "better-auth": "1.6.23",
100
102
  "class-transformer": "0.5.1",
101
103
  "class-validator": "0.15.1",
102
104
  "compression": "1.8.1",
@@ -211,12 +213,15 @@
211
213
  "follow-redirects@<=1.15.11": "Security: Custom Authentication Headers leak on cross-domain redirect (GHSA-r4q5-vmmm-2653) - transitive via axios>@getbrevo/brevo and axios>node-mailjet",
212
214
  "uuid@<14.0.0": "Security: Missing buffer bounds check in v3/v5/v6 (GHSA-w5hq-g745-h8pq) - transitive via @compodoc/compodoc and @compodoc/compodoc>@compodoc/live-server>http-auth",
213
215
  "postcss@<8.5.10": "Security: XSS via Unescaped </style> in CSS Stringify Output (GHSA-qx2v-qp2m-jg93) - transitive via vite. Remove when vite ships with postcss>=8.5.10",
216
+ "esbuild@>=0.17.0 <0.28.1": "Security: esbuild dev-server responds to any request origin, letting any website the developer visits read files it serves (GHSA-67mh-4wv8-2f99, moderate); pinned to the latest patched release to keep esbuild aligned across the toolchain - dev-only, transitive via vite>vitest and @nestjs build tooling. Remove when all consumers resolve esbuild>=0.28.1",
214
217
  "form-data@<4.0.6": "Security: CRLF injection via unescaped multipart field names/filenames (GHSA-hmw2-7cc7-3qxx) - transitive via @getbrevo/brevo>axios and node-mailjet>axios",
215
218
  "vite@>=8.0.0 <8.0.16": "Security: fs.deny bypass on Windows alternate paths + file read CVEs - transitive via better-auth>vitest",
216
219
  "hono@<4.12.25": "Security: multiple CVEs <4.12.25 (prototype pollution, bodyLimit/Vary bypass, JWT NumericDate) - transitive via @nestjs/terminus>prisma>@prisma/dev",
217
220
  "nodemailer@<9.0.1": "Security: email/header injection CVEs <9.0.1 - direct dependency",
218
221
  "multer@<2.2.0": "Security: unhandled multipart errors / DoS <2.2.0 - transitive via @nestjs/platform-express",
219
- "js-yaml@<4.2.0": "Security: special-character handling / prototype pollution (patched in 4.2.0; 4.1.2 was never published) - transitive via @nestjs/swagger"
222
+ "js-yaml@<4.2.0": "Security: special-character handling / prototype pollution (patched in 4.2.0; 4.1.2 was never published) - transitive via @nestjs/swagger",
223
+ "@xhmikosr/decompress@<11.1.3": "Security: archive extraction can create files/links outside the target directory (GHSA-mp2f-45pm-3cg9, critical) - transitive via @swc/cli>@xhmikosr/bin-wrapper>@xhmikosr/downloader; @swc/cli 0.8.1 is already the latest release and still resolves the vulnerable range, so an override is the only fix",
224
+ "morgan@<1.11.0": "Security: Log Forging via unneutralized control characters in :remote-user (GHSA-4vj7-5mj6-jm8m, moderate, patched in 1.11.0) - dev-only, transitive via @compodoc/compodoc>@compodoc/live-server>morgan"
220
225
  },
221
226
  "overrides": {
222
227
  "axios@<1.16.0": "1.16.0",
@@ -252,7 +257,9 @@
252
257
  "hono@<4.12.25": "4.12.25",
253
258
  "nodemailer@<9.0.1": "9.0.1",
254
259
  "multer@<2.2.0": "2.2.0",
255
- "js-yaml@<4.2.0": "4.2.0"
260
+ "js-yaml@<4.2.0": "4.2.0",
261
+ "@xhmikosr/decompress@<11.1.3": "11.1.3",
262
+ "morgan@<1.11.0": "1.11.0"
256
263
  },
257
264
  "//peerDependencyRules": "allowedVersions: deps lag behind our newer majors (graphql-upload wants @types/express@^4, the deprecated apollo playground plugin wants @apollo/server@^4) — both work with our v5. ignoreMissing: browser-only vis-network peers pulled in transitively via yuml-diagram (server-side UML generation never renders, so these are not needed).",
258
265
  "peerDependencyRules": {
package/src/config.env.ts CHANGED
@@ -170,6 +170,10 @@ const config: { [env: string]: IServerOptions } = {
170
170
  // Development environment
171
171
  // ===========================================================================
172
172
  development: {
173
+ // `development` is not one of the localhost-default environments (local/ci/e2e), so appUrl
174
+ // must be set explicitly: deriving it from baseUrl would yield the API's own :3000 origin
175
+ // and leave the frontend on :3001 outside the CORS allowlist.
176
+ appUrl: process.env.APP_URL || 'http://localhost:3001',
173
177
  auth: {
174
178
  legacyEndpoints: { enabled: true },
175
179
  },
@@ -237,6 +237,265 @@ export function isCorsDisabled(cors: boolean | ICorsConfig | undefined): boolean
237
237
  return false;
238
238
  }
239
239
 
240
+ // =================================================================================================
241
+ // Server URL resolution
242
+ //
243
+ // Single source of truth for "which app/API origin is this server reachable under".
244
+ // Consumed by `buildCorsConfig()` (REST + GraphQL CORS) and by BetterAuth's `resolveUrls()`
245
+ // (trustedOrigins, Passkey rpId/origin, cross-subdomain cookies). Keeping one implementation
246
+ // is what makes the three CORS layers agree — previously each layer derived URLs on its own
247
+ // and they drifted (BetterAuth applied localhost defaults, the CORS layer did not).
248
+ // =================================================================================================
249
+
250
+ /**
251
+ * Default URLs for local/test environments (`local`, `ci`, `e2e`).
252
+ *
253
+ * These environments run on localhost and have no deployed domain: the API listens on
254
+ * port 3000, the frontend app on port 3001.
255
+ *
256
+ * @since 11.27.5
257
+ */
258
+ export const LOCALHOST_URL_DEFAULTS = {
259
+ apiUrl: 'http://localhost:3000',
260
+ appUrl: 'http://localhost:3001',
261
+ } as const;
262
+
263
+ /**
264
+ * Environments that fall back to {@link LOCALHOST_URL_DEFAULTS} when no URLs are configured.
265
+ *
266
+ * @since 11.27.5
267
+ */
268
+ export const LOCALHOST_URL_ENVS: readonly string[] = ['ci', 'e2e', 'local'];
269
+
270
+ /**
271
+ * The hostname label stripped from `baseUrl` to derive `appUrl`.
272
+ */
273
+ const API_HOST_LABEL = 'api.';
274
+
275
+ /**
276
+ * Parses a URL string into a `URL`, or returns `undefined` when it is not a usable http(s) URL.
277
+ *
278
+ * The `protocol` guard is security-relevant, not cosmetic: `URL.origin` serializes to the
279
+ * literal string `'null'` for opaque origins (any non-special scheme, e.g. `custom://host`).
280
+ * That string is exactly the `Origin` header a sandboxed iframe sends, so letting it into a
281
+ * `credentials: true` allowlist would grant credentialed access to any site able to frame a
282
+ * sandboxed document.
283
+ */
284
+ function toHttpUrl(value: string): undefined | URL {
285
+ let url: URL;
286
+ try {
287
+ url = new URL(value);
288
+ } catch {
289
+ return undefined;
290
+ }
291
+
292
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
293
+ return undefined;
294
+ }
295
+
296
+ return url;
297
+ }
298
+
299
+ /**
300
+ * Normalizes a URL string to its http(s) origin, or `undefined` when it is not a usable
301
+ * http(s) URL. See {@link toHttpUrl} for why the protocol guard matters.
302
+ */
303
+ function toHttpOrigin(value: string): string | undefined {
304
+ return toHttpUrl(value)?.origin;
305
+ }
306
+
307
+ /**
308
+ * Whether the URL points at the local machine (localhost, `*.localhost`, loopback IP).
309
+ */
310
+ function isLocalhostUrl(value: string | undefined): boolean {
311
+ if (!value) {
312
+ return false;
313
+ }
314
+
315
+ try {
316
+ const { hostname } = new URL(value);
317
+ return (
318
+ hostname === 'localhost' || hostname.endsWith('.localhost') || hostname === '127.0.0.1' || hostname === '[::1]'
319
+ );
320
+ } catch {
321
+ return false;
322
+ }
323
+ }
324
+
325
+ /**
326
+ * The hostname with its leading `api.` label removed, or `undefined` when the label is absent
327
+ * or stripping it would not leave a deployable host.
328
+ *
329
+ * Guards two cases where a naive strip produces a bogus origin:
330
+ * - `api.dev` → `dev` — a bare TLD. `api.dev`/`api.io`/`api.co` are registrable domains, so
331
+ * this is reachable configuration, and the result would be an unreachable host in a
332
+ * credentialed allowlist.
333
+ * - `api.` → `` — not a host at all. Assigning an empty hostname is silently ignored by the
334
+ * `URL` setter for special schemes, so the strip would appear to succeed but do nothing.
335
+ *
336
+ * `localhost` is the one legitimate single-label host (`api.localhost` → `localhost`).
337
+ *
338
+ * Exported for reuse by BetterAuth's `deriveCookieDomainFromUrls()`, so the cookie-domain
339
+ * derivation shares this bare-TLD/empty guard instead of re-implementing a naive `api.`-strip.
340
+ */
341
+ export function strippedApiHostname(hostname: string): string | undefined {
342
+ if (!hostname.startsWith(API_HOST_LABEL)) {
343
+ return undefined;
344
+ }
345
+
346
+ const remainder = hostname.slice(API_HOST_LABEL.length);
347
+ if (remainder !== 'localhost' && !remainder.includes('.')) {
348
+ return undefined;
349
+ }
350
+
351
+ return remainder;
352
+ }
353
+
354
+ /**
355
+ * Derives the app origin from an already-parsed API base `URL` by stripping a leading `api.`
356
+ * label from its hostname. Mutates and re-serializes the passed `URL`, so callers must not
357
+ * reuse it afterwards. Shared by {@link deriveAppUrlFromBaseUrl} (string entry point) and
358
+ * {@link resolveServerUrls} (which parses `baseUrl` once and threads the result through).
359
+ */
360
+ function deriveAppOriginFromUrl(url: URL): string {
361
+ const stripped = strippedApiHostname(url.hostname);
362
+ if (stripped !== undefined) {
363
+ url.hostname = stripped;
364
+ }
365
+
366
+ return url.origin;
367
+ }
368
+
369
+ /**
370
+ * Whether stripping the `api.` label from `baseUrl` names a host the API itself is NOT served
371
+ * from — i.e. API and app are separated by HOST rather than by PORT.
372
+ *
373
+ * {@link LOCALHOST_URL_DEFAULTS} encode a port split: one host, API on `:3000`, app on `:3001`.
374
+ * `http://api.localhost:3000` and `https://api.localhost` are both that shape — the label
375
+ * strips to the bare `localhost` the API already answers on, so only the port tells app and API
376
+ * apart, and the flat `http://localhost:3001` default is the right answer.
377
+ *
378
+ * `https://api.crm.localhost` (as served by `lt dev up` behind Caddy) strips to the sibling host
379
+ * `crm.localhost`, which the API never answers on. There the derivation names the real app
380
+ * origin and must win over the flat default.
381
+ *
382
+ * Keyed on the stripped LABEL, never on the presence of a port: a host split stays a host split
383
+ * behind a non-default port (`https://api.crm.localhost:8443` → `https://crm.localhost:8443`),
384
+ * and a port split stays a port split on the default port (`https://api.localhost`).
385
+ *
386
+ * Takes an already-parsed `URL` (or `undefined` for an unparseable/absent `baseUrl`) so the
387
+ * caller parses `baseUrl` only once.
388
+ */
389
+ function separatesApiAndAppByHost(url: undefined | URL): boolean {
390
+ if (!url) {
391
+ return false;
392
+ }
393
+
394
+ const stripped = strippedApiHostname(url.hostname);
395
+ return stripped !== undefined && stripped !== 'localhost';
396
+ }
397
+
398
+ /**
399
+ * Derives the frontend app URL from the API base URL by stripping a leading `api.`
400
+ * label from the hostname (e.g. `https://api.example.com` → `https://example.com`,
401
+ * `https://api.dev.example.com` → `https://dev.example.com`).
402
+ *
403
+ * The port is preserved (`https://api.example.com:8443` → `https://example.com:8443`).
404
+ *
405
+ * Returns the origin unchanged when there is no strippable `api.` prefix, and returns the
406
+ * input unchanged when it is not an http(s) URL — callers decide what to do with a value
407
+ * they cannot normalize.
408
+ *
409
+ * @since 11.27.5
410
+ */
411
+ export function deriveAppUrlFromBaseUrl(baseUrl: string): string {
412
+ const url = toHttpUrl(baseUrl);
413
+ return url ? deriveAppOriginFromUrl(url) : baseUrl;
414
+ }
415
+
416
+ /**
417
+ * Where a resolved URL came from. Callers use this to emit accurate startup diagnostics
418
+ * without re-deriving the resolution logic.
419
+ *
420
+ * @since 11.27.5
421
+ */
422
+ export interface IResolvedServerUrls {
423
+ appUrl: string | undefined;
424
+ appUrlSource: 'derived' | 'explicit' | 'localhost-default' | 'none';
425
+ baseUrl: string | undefined;
426
+ baseUrlSource: 'explicit' | 'localhost-default' | 'none';
427
+ }
428
+
429
+ /**
430
+ * Resolves the effective app/API URLs for a server configuration.
431
+ *
432
+ * `baseUrl`: explicit → localhost default (local/ci/e2e only) → none.
433
+ * `appUrl`: explicit → derived from a host-split localhost `baseUrl` (local/ci/e2e) →
434
+ * localhost default (local/ci/e2e with a localhost `baseUrl`) → derived from `baseUrl` → none.
435
+ *
436
+ * The localhost defaults assume a PORT split (API `:3000`, app `:3001`, one host). A localhost
437
+ * `baseUrl` whose `api.` label strips to a SIBLING host — `https://api.crm.localhost` from
438
+ * `lt dev up` — derives that host on whatever port it carries, because the flat
439
+ * `http://localhost:3001` default would name a host the app never serves from. See
440
+ * {@link separatesApiAndAppByHost}.
441
+ *
442
+ * `baseUrl` is returned verbatim (not origin-normalized) because BetterAuth passes it
443
+ * straight through as its `baseURL`; normalization for origin matching is the caller's job.
444
+ *
445
+ * @param input.deriveAppUrl - Set to `false` to disable the `api.`-strip derivation. The
446
+ * localhost defaults are unaffected — they are an explicit,
447
+ * documented behavior of the `local`/`ci`/`e2e` environments, and
448
+ * a host-split localhost `baseUrl` falls back to them.
449
+ *
450
+ * @since 11.27.5
451
+ */
452
+ export function resolveServerUrls(input: {
453
+ appUrl?: string;
454
+ baseUrl?: string;
455
+ deriveAppUrl?: boolean;
456
+ env?: string;
457
+ }): IResolvedServerUrls {
458
+ const usesLocalhostDefaults = LOCALHOST_URL_ENVS.includes(input.env ?? '');
459
+
460
+ let baseUrl = input.baseUrl;
461
+ let baseUrlSource: IResolvedServerUrls['baseUrlSource'] = baseUrl ? 'explicit' : 'none';
462
+ if (!baseUrl && usesLocalhostDefaults) {
463
+ baseUrl = LOCALHOST_URL_DEFAULTS.apiUrl;
464
+ baseUrlSource = 'localhost-default';
465
+ }
466
+
467
+ if (input.appUrl) {
468
+ return { appUrl: input.appUrl, appUrlSource: 'explicit', baseUrl, baseUrlSource };
469
+ }
470
+
471
+ const mayDerive = input.deriveAppUrl !== false;
472
+
473
+ // Parse `baseUrl` once and reuse the result for both the host-split check and the derivation
474
+ // below (the localhost check keeps its own string parse — its loopback-IP matching differs
475
+ // from `toHttpUrl`'s http(s)-only guard). Gated on `mayDerive`: both consumers of
476
+ // `parsedBaseUrl` sit behind it (the host-split check via short-circuit, the derivation via its
477
+ // own guard), so with `deriveAppUrl: false` the parse is never needed and is skipped entirely.
478
+ const parsedBaseUrl = baseUrl && mayDerive ? toHttpUrl(baseUrl) : undefined;
479
+
480
+ // API on :3000 and app on :3001 — deriving from a port-split baseUrl would yield the API's
481
+ // own origin. A baseUrl that splits API and app by host instead (`https://api.crm.localhost`,
482
+ // from `lt dev up`) derives the real app origin, so prefer it over the flat default.
483
+ // `separatesApiAndAppByHost` is evaluated last, so every deployed (non-localhost) environment
484
+ // short-circuits before paying for the parse.
485
+ if (usesLocalhostDefaults && isLocalhostUrl(baseUrl) && !(mayDerive && separatesApiAndAppByHost(parsedBaseUrl))) {
486
+ return { appUrl: LOCALHOST_URL_DEFAULTS.appUrl, appUrlSource: 'localhost-default', baseUrl, baseUrlSource };
487
+ }
488
+
489
+ if (baseUrl && mayDerive) {
490
+ // `parsedBaseUrl` is mutated by `deriveAppOriginFromUrl`; a non-http(s) `baseUrl` (parse
491
+ // failed) is returned verbatim, matching `deriveAppUrlFromBaseUrl`.
492
+ const appUrl = parsedBaseUrl ? deriveAppOriginFromUrl(parsedBaseUrl) : baseUrl;
493
+ return { appUrl, appUrlSource: 'derived', baseUrl, baseUrlSource };
494
+ }
495
+
496
+ return { appUrl: undefined, appUrlSource: 'none', baseUrl, baseUrlSource };
497
+ }
498
+
240
499
  /**
241
500
  * Builds a CORS configuration object from server options.
242
501
  *
@@ -244,21 +503,30 @@ export function isCorsDisabled(cors: boolean | ICorsConfig | undefined): boolean
244
503
  * 1. CORS disabled → empty object (no CORS)
245
504
  * 2. Cookies disabled → empty object (no credentials needed, handled by simple enableCors())
246
505
  * 3. `cors.allowAll` → `{ credentials: true, origin: true }` (mirror request origin)
247
- * 4. `cors.allowedOrigins` + `appUrl`/`baseUrl` → deduplicated origin list
248
- * 5. Only `appUrl`/`baseUrl` → those origins
506
+ * 4. `cors.allowedOrigins` + resolved `appUrl`/`baseUrl` → deduplicated origin list
507
+ * 5. Only resolved `appUrl`/`baseUrl` → those origins
249
508
  * 6. Nothing configured → `{}` (no credentialed CORS — caller decides fallback)
250
509
  *
510
+ * `appUrl`/`baseUrl` are resolved via {@link resolveServerUrls}, the same function BetterAuth
511
+ * uses, so all three CORS layers (GraphQL, REST, BetterAuth `trustedOrigins`) agree.
512
+ *
251
513
  * Used by both:
252
514
  * - `CoreModule.buildCorsConfig()` for GraphQL (Apollo) CORS
253
515
  * - `main.ts` reference implementation for REST (Express) CORS
254
516
  *
255
- * Security note: when no origins are resolvable AND cookies are enabled, the function
256
- * returns `{}` rather than `{ credentials: true, origin: true }`. Returning open CORS
257
- * with credentials would allow any website to make credentialed requests. Callers
258
- * should either configure `appUrl`/`baseUrl`/`allowedOrigins`, enable `cors.allowAll`
259
- * explicitly (for development), or accept no credentialed CORS.
260
- *
261
- * @param options - Server options containing `cors`, `cookies`, `appUrl`, `baseUrl`
517
+ * Security notes:
518
+ * - When no origins are resolvable AND cookies are enabled, the function returns `{}` rather
519
+ * than `{ credentials: true, origin: true }`. Returning open CORS with credentials would
520
+ * allow any website to make credentialed requests. Callers should either configure
521
+ * `appUrl`/`baseUrl`/`allowedOrigins`, enable `cors.allowAll` explicitly (for development),
522
+ * or accept no credentialed CORS.
523
+ * - Configuring only `baseUrl` grants credentialed CORS to the derived app origin as well
524
+ * (`https://api.example.com` → also `https://example.com`). This is the documented
525
+ * `appUrl` auto-detection and matches BetterAuth's `trustedOrigins`. Deployments whose
526
+ * apex domain is not trusted (e.g. a third-party-hosted marketing site) must opt out with
527
+ * `cors.deriveAppUrl: false` and list the real frontend origin explicitly.
528
+ *
529
+ * @param options - Server options containing `cors`, `cookies`, `appUrl`, `baseUrl`, `env`
262
530
  * @returns CORS config object for Apollo/Express, or empty object if disabled/unconfigured
263
531
  *
264
532
  * @since 11.25.0
@@ -279,10 +547,23 @@ export function buildCorsConfig(options: Partial<IServerOptions>): Record<string
279
547
  return { credentials: true, origin: true };
280
548
  }
281
549
 
282
- // Build origin list from appUrl, baseUrl, and allowedOrigins
550
+ // Build origin list from the shared URL resolution (appUrl auto-derived from baseUrl,
551
+ // localhost defaults for local/ci/e2e), then allowedOrigins.
552
+ const { appUrl, baseUrl } = resolveServerUrls({
553
+ appUrl: options?.appUrl,
554
+ baseUrl: options?.baseUrl,
555
+ deriveAppUrl: corsObj.deriveAppUrl,
556
+ env: options?.env,
557
+ });
558
+
559
+ // Normalize to origins before deduplicating: a browser's `Origin` header is always a bare
560
+ // scheme://host[:port] triple, so a configured `https://api.example.com/` (trailing slash —
561
+ // common in env-var-sourced URLs) could never match, and would defeat the Set below.
562
+ // Values we cannot normalize are passed through verbatim rather than dropped.
283
563
  const origins: string[] = [];
284
- if (options?.appUrl) origins.push(options.appUrl);
285
- if (options?.baseUrl) origins.push(options.baseUrl);
564
+ for (const url of [appUrl, baseUrl]) {
565
+ if (url) origins.push(toHttpOrigin(url) ?? url);
566
+ }
286
567
  if (corsObj.allowedOrigins?.length) {
287
568
  origins.push(...corsObj.allowedOrigins);
288
569
  }
@@ -1012,8 +1012,17 @@ export interface ICorsConfig {
1012
1012
  * Convenient for development but NOT recommended for production as it enables
1013
1013
  * CSRF-like attacks from any domain.
1014
1014
  *
1015
- * When true, overrides `allowedOrigins`. Also propagates to BetterAuth
1016
- * (trustedOrigins is set to undefined, allowing all origins).
1015
+ * When true, overrides `allowedOrigins`.
1016
+ *
1017
+ * **This does NOT disable BetterAuth's origin check.** BetterAuth keeps
1018
+ * verifying the `Origin` header against its `trustedOrigins`, which are derived
1019
+ * from `appUrl` (and the passkey config). Leaving `trustedOrigins` empty would
1020
+ * not "allow everything" — BetterAuth then trusts only its own `baseURL`, and
1021
+ * origin-checked endpoints such as `two-factor/enable` or passkey registration
1022
+ * answer `403 INVALID_ORIGIN` for a separately hosted frontend.
1023
+ *
1024
+ * To accept arbitrary origins for auth as well, set `betterAuth.trustedOrigins`
1025
+ * explicitly — an origin check has no meaningful "allow everything" mode.
1017
1026
  *
1018
1027
  * @default false
1019
1028
  */
@@ -1032,6 +1041,28 @@ export interface ICorsConfig {
1032
1041
  */
1033
1042
  allowedOrigins?: string[];
1034
1043
 
1044
+ /**
1045
+ * Whether `appUrl` may be auto-derived from `baseUrl` when it is not set explicitly.
1046
+ *
1047
+ * By default the leading `api.` label is stripped from `baseUrl`'s hostname
1048
+ * (`https://api.example.com` → `https://example.com`), and the result is trusted by all
1049
+ * three CORS layers (GraphQL, REST, BetterAuth `trustedOrigins`). This is what makes the
1050
+ * common `api.<host>` / `<host>` deployment work without extra configuration.
1051
+ *
1052
+ * Set to `false` when the derived apex domain must NOT receive credentialed cross-origin
1053
+ * access — for example when `example.com` is a third-party-hosted marketing site whose
1054
+ * XSS surface you do not control. With `false`, configure the frontend origin explicitly
1055
+ * via `appUrl` or `allowedOrigins`.
1056
+ *
1057
+ * Has no effect on the localhost defaults applied for `env: 'local' | 'ci' | 'e2e'`, and
1058
+ * no effect when `appUrl` is set explicitly.
1059
+ *
1060
+ * @default true
1061
+ *
1062
+ * @since 11.27.5
1063
+ */
1064
+ deriveAppUrl?: boolean;
1065
+
1035
1066
  /**
1036
1067
  * Whether CORS is enabled.
1037
1068
  *
@@ -1315,13 +1346,19 @@ export interface IServerOptions {
1315
1346
  * - Frontend redirect URLs
1316
1347
  *
1317
1348
  * **Auto-Detection from `baseUrl`:**
1318
- * If not set, `appUrl` is derived from `baseUrl`:
1349
+ * If not set, `appUrl` is derived from `baseUrl` (the port is preserved):
1319
1350
  * - `https://api.example.com` → `https://example.com` (removes 'api.' prefix)
1320
1351
  * - `https://example.com` → `https://example.com` (unchanged)
1321
1352
  *
1322
1353
  * **Localhost Environment Defaults:**
1323
- * When `env` is 'local', 'ci', or 'e2e' and neither `baseUrl` nor `appUrl` is set:
1324
- * - `appUrl` defaults to `http://localhost:3001`
1354
+ * When `env` is 'local', 'ci', or 'e2e', `appUrl` is not set, and `baseUrl` is unset or points at
1355
+ * localhost, `appUrl` defaults to `http://localhost:3001`. These defaults encode a PORT split:
1356
+ * one host, API on `:3000`, app on `:3001`.
1357
+ *
1358
+ * A localhost `baseUrl` whose `api.` label strips to a SIBLING host is a HOST split and is
1359
+ * derived instead — `https://api.crm.localhost` → `https://crm.localhost`, as served by
1360
+ * `lt dev up` behind Caddy. `https://api.localhost` strips to the bare `localhost` the API
1361
+ * already answers on, so it keeps the `http://localhost:3001` default.
1325
1362
  *
1326
1363
  * **Environment Variable:** `APP_URL` (only needed if not auto-derivable from `BASE_URL`)
1327
1364
  *
@@ -1340,6 +1377,11 @@ export interface IServerOptions {
1340
1377
  * env: 'local', // or 'ci' or 'e2e'
1341
1378
  * // baseUrl defaults to 'http://localhost:3000'
1342
1379
  * // appUrl defaults to 'http://localhost:3001'
1380
+ *
1381
+ * // Local/CI/E2E behind `lt dev up` (host split — appUrl auto-derived)
1382
+ * env: 'local',
1383
+ * baseUrl: 'https://api.crm.localhost',
1384
+ * // → appUrl auto-derived: 'https://crm.localhost'
1343
1385
  * ```
1344
1386
  */
1345
1387
  appUrl?: string;
@@ -2762,7 +2804,8 @@ interface IBetterAuthBase {
2762
2804
  * },
2763
2805
  * advanced: {
2764
2806
  * cookiePrefix: 'my-app',
2765
- * useSecureCookies: true,
2807
+ * useSecureCookies: true, // re-enables the __Secure- prefix; safe ONLY when Better-Auth
2808
+ * // fully manages your cookies — see the note below.
2766
2809
  * crossSubDomainCookies: {
2767
2810
  * domain: 'example.com', // Cookies shared across *.example.com
2768
2811
  * },
@@ -2773,6 +2816,14 @@ interface IBetterAuthBase {
2773
2816
  * **Note on `advanced` options:** The `advanced` object is deep-merged with internal defaults
2774
2817
  * (e.g., `cookiePrefix` derived from `basePath`). You do not need to re-specify `cookiePrefix`
2775
2818
  * when adding other `advanced` options like `crossSubDomainCookies`.
2819
+ *
2820
+ * **`useSecureCookies` is pinned to `false` by default (since v11.27.6).** The framework keeps
2821
+ * Better-Auth's native handlers on the same UNPREFIXED cookie name the nest-server cookie helper
2822
+ * writes; the `Secure` attribute is still applied on an `https://` baseURL via
2823
+ * `advanced.defaultCookieAttributes`, so transport security is unchanged. Only set
2824
+ * `useSecureCookies: true` if Better-Auth manages your session cookies entirely (not the
2825
+ * nest-server helper) — otherwise its native handlers look for a `__Secure-`-prefixed cookie the
2826
+ * helper never writes and answer `401` on 2FA / passkey / `/token`.
2776
2827
  */
2777
2828
  options?: Record<string, unknown>;
2778
2829
 
@@ -262,10 +262,10 @@ Read the security section below for production deployments.
262
262
 
263
263
  **Global server-level settings that affect BetterAuth behavior (since v11.25.0):**
264
264
 
265
- | Setting (top-level `IServerOptions`) | Technical Purpose | Impact of Wrong Value |
266
- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
267
- | `cookies` (`boolean \| ICookiesConfig`, default: `true`) | Controls cookie-parser middleware and session cookie setting. `cookies.exposeTokenInBody` additionally returns the token in the response body (test-only; **forbidden in production**) | Tokens missing from response body surprises test clients; `exposeTokenInBody` in prod = XSS-risk, framework throws at startup |
268
- | `cors` (`boolean \| ICorsConfig`, default: enabled with auto-derived origins) | Unified CORS config — propagates to GraphQL (Apollo), REST (Express), and BetterAuth `trustedOrigins` from a single source | `cors.enabled: false` disables all three layers; `cors.allowAll` allows any origin (dev only) |
265
+ | Setting (top-level `IServerOptions`) | Technical Purpose | Impact of Wrong Value |
266
+ | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
267
+ | `cookies` (`boolean \| ICookiesConfig`, default: `true`) | Controls cookie-parser middleware and session cookie setting. `cookies.exposeTokenInBody` additionally returns the token in the response body (test-only; **forbidden in production**) | Tokens missing from response body surprises test clients; `exposeTokenInBody` in prod = XSS-risk, framework throws at startup |
268
+ | `cors` (`boolean \| ICorsConfig`, default: enabled with auto-derived origins) | Unified CORS config — propagates to GraphQL (Apollo), REST (Express), and BetterAuth `trustedOrigins` from a single source | `cors.enabled: false` disables the REST/GraphQL layers; `cors.allowAll` mirrors any request origin for REST/GraphQL (dev only) but BetterAuth keeps restricting to `appUrl` (its origin check has no "allow all" mode) |
269
269
 
270
270
  **For Development:** The defaults (`http://localhost:3000`, `/iam`) are correct.
271
271
 
@@ -725,6 +725,12 @@ const config = {
725
725
  },
726
726
  advanced: {
727
727
  cookiePrefix: 'my-app',
728
+ // Since v11.27.6 the framework pins `useSecureCookies: false` so Better-Auth's native
729
+ // handlers read the same UNPREFIXED cookie the nest-server helper writes (the `Secure`
730
+ // attribute is still applied on https via `advanced.defaultCookieAttributes`). Only set
731
+ // `true` if Better-Auth manages your cookies entirely — otherwise its native handlers
732
+ // look for a `__Secure-`-prefixed cookie that is never written and answer 401 on
733
+ // 2FA / passkey / `/token`.
728
734
  useSecureCookies: true,
729
735
  },
730
736
  },