@lenne.tech/nest-server 11.27.3 → 11.27.5

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.
@@ -237,6 +237,190 @@ 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
+ * Normalizes a URL string to its http(s) origin, or `undefined` when it is not a usable
277
+ * http(s) URL.
278
+ *
279
+ * The `protocol` guard is security-relevant, not cosmetic: `URL.origin` serializes to the
280
+ * literal string `'null'` for opaque origins (any non-special scheme, e.g. `custom://host`).
281
+ * That string is exactly the `Origin` header a sandboxed iframe sends, so letting it into a
282
+ * `credentials: true` allowlist would grant credentialed access to any site able to frame a
283
+ * sandboxed document.
284
+ */
285
+ function toHttpOrigin(value: string): string | undefined {
286
+ let url: URL;
287
+ try {
288
+ url = new URL(value);
289
+ } catch {
290
+ return undefined;
291
+ }
292
+
293
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
294
+ return undefined;
295
+ }
296
+
297
+ return url.origin;
298
+ }
299
+
300
+ /**
301
+ * Whether the URL points at the local machine (localhost, `*.localhost`, loopback IP).
302
+ */
303
+ function isLocalhostUrl(value: string | undefined): boolean {
304
+ if (!value) {
305
+ return false;
306
+ }
307
+
308
+ try {
309
+ const { hostname } = new URL(value);
310
+ return (
311
+ hostname === 'localhost' || hostname.endsWith('.localhost') || hostname === '127.0.0.1' || hostname === '[::1]'
312
+ );
313
+ } catch {
314
+ return false;
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Whether stripping the leading `api.` label from the hostname leaves a deployable host.
320
+ *
321
+ * Guards two cases where a naive strip produces a bogus origin:
322
+ * - `api.dev` → `dev` — a bare TLD. `api.dev`/`api.io`/`api.co` are registrable domains, so
323
+ * this is reachable configuration, and the result would be an unreachable host in a
324
+ * credentialed allowlist.
325
+ * - `api.` → `` — not a host at all. Assigning an empty hostname is silently ignored by the
326
+ * `URL` setter for special schemes, so the strip would appear to succeed but do nothing.
327
+ *
328
+ * `localhost` is the one legitimate single-label host (`api.localhost` → `localhost`).
329
+ */
330
+ function canStripApiLabel(hostname: string): boolean {
331
+ if (!hostname.startsWith(API_HOST_LABEL)) {
332
+ return false;
333
+ }
334
+
335
+ const remainder = hostname.slice(API_HOST_LABEL.length);
336
+ return remainder === 'localhost' || remainder.includes('.');
337
+ }
338
+
339
+ /**
340
+ * Derives the frontend app URL from the API base URL by stripping a leading `api.`
341
+ * label from the hostname (e.g. `https://api.example.com` → `https://example.com`,
342
+ * `https://api.dev.example.com` → `https://dev.example.com`).
343
+ *
344
+ * Returns the origin unchanged when there is no strippable `api.` prefix, and returns the
345
+ * input unchanged when it is not an http(s) URL — callers decide what to do with a value
346
+ * they cannot normalize.
347
+ *
348
+ * @since 11.27.5
349
+ */
350
+ export function deriveAppUrlFromBaseUrl(baseUrl: string): string {
351
+ const origin = toHttpOrigin(baseUrl);
352
+ if (!origin) {
353
+ return baseUrl;
354
+ }
355
+
356
+ const url = new URL(origin);
357
+ if (canStripApiLabel(url.hostname)) {
358
+ url.hostname = url.hostname.slice(API_HOST_LABEL.length);
359
+ }
360
+
361
+ return url.origin;
362
+ }
363
+
364
+ /**
365
+ * Where a resolved URL came from. Callers use this to emit accurate startup diagnostics
366
+ * without re-deriving the resolution logic.
367
+ *
368
+ * @since 11.27.5
369
+ */
370
+ export interface IResolvedServerUrls {
371
+ appUrl: string | undefined;
372
+ appUrlSource: 'derived' | 'explicit' | 'localhost-default' | 'none';
373
+ baseUrl: string | undefined;
374
+ baseUrlSource: 'explicit' | 'localhost-default' | 'none';
375
+ }
376
+
377
+ /**
378
+ * Resolves the effective app/API URLs for a server configuration.
379
+ *
380
+ * `baseUrl`: explicit → localhost default (local/ci/e2e only) → none.
381
+ * `appUrl`: explicit → localhost default (local/ci/e2e with a localhost `baseUrl`) →
382
+ * derived from `baseUrl` → none.
383
+ *
384
+ * `baseUrl` is returned verbatim (not origin-normalized) because BetterAuth passes it
385
+ * straight through as its `baseURL`; normalization for origin matching is the caller's job.
386
+ *
387
+ * @param input.deriveAppUrl - Set to `false` to disable the `api.`-strip derivation. The
388
+ * localhost defaults are unaffected — they are an explicit,
389
+ * documented behavior of the `local`/`ci`/`e2e` environments.
390
+ *
391
+ * @since 11.27.5
392
+ */
393
+ export function resolveServerUrls(input: {
394
+ appUrl?: string;
395
+ baseUrl?: string;
396
+ deriveAppUrl?: boolean;
397
+ env?: string;
398
+ }): IResolvedServerUrls {
399
+ const usesLocalhostDefaults = LOCALHOST_URL_ENVS.includes(input.env ?? '');
400
+
401
+ let baseUrl = input.baseUrl;
402
+ let baseUrlSource: IResolvedServerUrls['baseUrlSource'] = baseUrl ? 'explicit' : 'none';
403
+ if (!baseUrl && usesLocalhostDefaults) {
404
+ baseUrl = LOCALHOST_URL_DEFAULTS.apiUrl;
405
+ baseUrlSource = 'localhost-default';
406
+ }
407
+
408
+ if (input.appUrl) {
409
+ return { appUrl: input.appUrl, appUrlSource: 'explicit', baseUrl, baseUrlSource };
410
+ }
411
+
412
+ // API on :3000 and app on :3001 — deriving from baseUrl would yield the API's own origin.
413
+ if (usesLocalhostDefaults && isLocalhostUrl(baseUrl)) {
414
+ return { appUrl: LOCALHOST_URL_DEFAULTS.appUrl, appUrlSource: 'localhost-default', baseUrl, baseUrlSource };
415
+ }
416
+
417
+ if (baseUrl && input.deriveAppUrl !== false) {
418
+ return { appUrl: deriveAppUrlFromBaseUrl(baseUrl), appUrlSource: 'derived', baseUrl, baseUrlSource };
419
+ }
420
+
421
+ return { appUrl: undefined, appUrlSource: 'none', baseUrl, baseUrlSource };
422
+ }
423
+
240
424
  /**
241
425
  * Builds a CORS configuration object from server options.
242
426
  *
@@ -244,21 +428,30 @@ export function isCorsDisabled(cors: boolean | ICorsConfig | undefined): boolean
244
428
  * 1. CORS disabled → empty object (no CORS)
245
429
  * 2. Cookies disabled → empty object (no credentials needed, handled by simple enableCors())
246
430
  * 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
431
+ * 4. `cors.allowedOrigins` + resolved `appUrl`/`baseUrl` → deduplicated origin list
432
+ * 5. Only resolved `appUrl`/`baseUrl` → those origins
249
433
  * 6. Nothing configured → `{}` (no credentialed CORS — caller decides fallback)
250
434
  *
435
+ * `appUrl`/`baseUrl` are resolved via {@link resolveServerUrls}, the same function BetterAuth
436
+ * uses, so all three CORS layers (GraphQL, REST, BetterAuth `trustedOrigins`) agree.
437
+ *
251
438
  * Used by both:
252
439
  * - `CoreModule.buildCorsConfig()` for GraphQL (Apollo) CORS
253
440
  * - `main.ts` reference implementation for REST (Express) CORS
254
441
  *
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`
442
+ * Security notes:
443
+ * - When no origins are resolvable AND cookies are enabled, the function returns `{}` rather
444
+ * than `{ credentials: true, origin: true }`. Returning open CORS with credentials would
445
+ * allow any website to make credentialed requests. Callers should either configure
446
+ * `appUrl`/`baseUrl`/`allowedOrigins`, enable `cors.allowAll` explicitly (for development),
447
+ * or accept no credentialed CORS.
448
+ * - Configuring only `baseUrl` grants credentialed CORS to the derived app origin as well
449
+ * (`https://api.example.com` → also `https://example.com`). This is the documented
450
+ * `appUrl` auto-detection and matches BetterAuth's `trustedOrigins`. Deployments whose
451
+ * apex domain is not trusted (e.g. a third-party-hosted marketing site) must opt out with
452
+ * `cors.deriveAppUrl: false` and list the real frontend origin explicitly.
453
+ *
454
+ * @param options - Server options containing `cors`, `cookies`, `appUrl`, `baseUrl`, `env`
262
455
  * @returns CORS config object for Apollo/Express, or empty object if disabled/unconfigured
263
456
  *
264
457
  * @since 11.25.0
@@ -279,10 +472,23 @@ export function buildCorsConfig(options: Partial<IServerOptions>): Record<string
279
472
  return { credentials: true, origin: true };
280
473
  }
281
474
 
282
- // Build origin list from appUrl, baseUrl, and allowedOrigins
475
+ // Build origin list from the shared URL resolution (appUrl auto-derived from baseUrl,
476
+ // localhost defaults for local/ci/e2e), then allowedOrigins.
477
+ const { appUrl, baseUrl } = resolveServerUrls({
478
+ appUrl: options?.appUrl,
479
+ baseUrl: options?.baseUrl,
480
+ deriveAppUrl: corsObj.deriveAppUrl,
481
+ env: options?.env,
482
+ });
483
+
484
+ // Normalize to origins before deduplicating: a browser's `Origin` header is always a bare
485
+ // scheme://host[:port] triple, so a configured `https://api.example.com/` (trailing slash —
486
+ // common in env-var-sourced URLs) could never match, and would defeat the Set below.
487
+ // Values we cannot normalize are passed through verbatim rather than dropped.
283
488
  const origins: string[] = [];
284
- if (options?.appUrl) origins.push(options.appUrl);
285
- if (options?.baseUrl) origins.push(options.baseUrl);
489
+ for (const url of [appUrl, baseUrl]) {
490
+ if (url) origins.push(toHttpOrigin(url) ?? url);
491
+ }
286
492
  if (corsObj.allowedOrigins?.length) {
287
493
  origins.push(...corsObj.allowedOrigins);
288
494
  }
@@ -1032,6 +1032,28 @@ export interface ICorsConfig {
1032
1032
  */
1033
1033
  allowedOrigins?: string[];
1034
1034
 
1035
+ /**
1036
+ * Whether `appUrl` may be auto-derived from `baseUrl` when it is not set explicitly.
1037
+ *
1038
+ * By default the leading `api.` label is stripped from `baseUrl`'s hostname
1039
+ * (`https://api.example.com` → `https://example.com`), and the result is trusted by all
1040
+ * three CORS layers (GraphQL, REST, BetterAuth `trustedOrigins`). This is what makes the
1041
+ * common `api.<host>` / `<host>` deployment work without extra configuration.
1042
+ *
1043
+ * Set to `false` when the derived apex domain must NOT receive credentialed cross-origin
1044
+ * access — for example when `example.com` is a third-party-hosted marketing site whose
1045
+ * XSS surface you do not control. With `false`, configure the frontend origin explicitly
1046
+ * via `appUrl` or `allowedOrigins`.
1047
+ *
1048
+ * Has no effect on the localhost defaults applied for `env: 'local' | 'ci' | 'e2e'`, and
1049
+ * no effect when `appUrl` is set explicitly.
1050
+ *
1051
+ * @default true
1052
+ *
1053
+ * @since 11.27.5
1054
+ */
1055
+ deriveAppUrl?: boolean;
1056
+
1035
1057
  /**
1036
1058
  * Whether CORS is enabled.
1037
1059
  *
@@ -7,6 +7,7 @@ import * as crypto from 'crypto';
7
7
  import * as fs from 'fs';
8
8
  import * as path from 'path';
9
9
 
10
+ import { resolveServerUrls } from '../../common/helpers/cookies.helper';
10
11
  import { IBetterAuth, ICorsConfig } from '../../common/interfaces/server-options.interface';
11
12
  import { detectCookiePrefixDrift, resolveBetterAuthCookiePrefix } from './better-auth-cookie-prefix.helper';
12
13
 
@@ -914,21 +915,6 @@ function readProjectNameFromPackageJson(): string {
914
915
  return fallback;
915
916
  }
916
917
 
917
- /**
918
- * Default URLs for local/test environments (local, ci, e2e)
919
- * These environments typically run on localhost and don't have a deployed domain.
920
- */
921
- const LOCALHOST_DEFAULTS = {
922
- API_URL: 'http://localhost:3000',
923
- APP_URL: 'http://localhost:3001',
924
- };
925
-
926
- /**
927
- * Environments that use localhost defaults for URLs.
928
- * These are typically development/test environments without deployed domains.
929
- */
930
- const LOCALHOST_ENVS = ['local', 'ci', 'e2e'];
931
-
932
918
  /**
933
919
  * Resolves the effective URLs for BetterAuth configuration.
934
920
  *
@@ -948,37 +934,6 @@ interface ResolvedUrls {
948
934
  warnings: string[];
949
935
  }
950
936
 
951
- /**
952
- * Derives appUrl from baseUrl by removing 'api.' prefix from subdomain.
953
- *
954
- * Examples:
955
- * - 'https://api.example.com' → 'https://example.com'
956
- * - 'https://api.dev.example.com' → 'https://dev.example.com'
957
- * - 'https://example.com' → 'https://example.com' (unchanged)
958
- * - 'http://localhost:3000' → 'http://localhost:3000' (unchanged)
959
- *
960
- * @param baseUrl - The API base URL
961
- * @returns Derived app URL or the original URL if no 'api.' prefix
962
- */
963
- function deriveAppUrlFromBaseUrl(baseUrl: string): string {
964
- try {
965
- const url = new URL(baseUrl);
966
- const hostname = url.hostname;
967
-
968
- // Check if hostname starts with 'api.'
969
- if (hostname.startsWith('api.')) {
970
- // Remove 'api.' prefix
971
- url.hostname = hostname.substring(4);
972
- return url.origin;
973
- }
974
-
975
- // Return original URL if no 'api.' prefix
976
- return url.origin;
977
- } catch {
978
- return baseUrl;
979
- }
980
- }
981
-
982
937
  /**
983
938
  * Extracts the root domain from a URL for use as Passkey rpId.
984
939
  *
@@ -1263,36 +1218,31 @@ function deriveCookieDomainFromUrls(appUrl?: string, baseUrl?: string): string |
1263
1218
  }
1264
1219
 
1265
1220
  function resolveUrls(options: CreateBetterAuthOptions): ResolvedUrls {
1266
- const { config, serverAppUrl, serverBaseUrl, serverEnv } = options;
1221
+ const { config, serverAppUrl, serverBaseUrl, serverCorsConfig, serverEnv } = options;
1267
1222
  const warnings: string[] = [];
1268
- const usesLocalhostDefaults = LOCALHOST_ENVS.includes(serverEnv || '');
1269
1223
 
1270
- // Step 1: Resolve baseUrl
1271
- // Priority: betterAuth.baseUrl > serverBaseUrl > localhost default (for local/ci/e2e)
1272
- let baseUrl = config.baseUrl || serverBaseUrl;
1273
- if (!baseUrl && usesLocalhostDefaults) {
1274
- baseUrl = LOCALHOST_DEFAULTS.API_URL;
1224
+ // Steps 1+2 (baseUrl, appUrl) are delegated to the shared resolver in cookies.helper so that
1225
+ // BetterAuth's trustedOrigins and the REST/GraphQL CORS allowlist can never disagree about
1226
+ // which app origin this server is reachable under. `cors.deriveAppUrl: false` therefore
1227
+ // suppresses the `api.`-strip derivation on this layer too.
1228
+ const corsObj = typeof serverCorsConfig === 'object' && serverCorsConfig !== null ? serverCorsConfig : undefined;
1229
+ const resolved = resolveServerUrls({
1230
+ appUrl: serverAppUrl,
1231
+ // Priority: betterAuth.baseUrl > serverBaseUrl (> localhost default, applied by the resolver)
1232
+ baseUrl: config.baseUrl || serverBaseUrl,
1233
+ deriveAppUrl: corsObj?.deriveAppUrl,
1234
+ env: serverEnv,
1235
+ });
1236
+ const { appUrl, baseUrl } = resolved;
1237
+
1238
+ if (resolved.baseUrlSource === 'localhost-default') {
1275
1239
  warnings.push(`URL: Using localhost default baseUrl="${baseUrl}" (env: '${serverEnv}')`);
1276
1240
  }
1277
-
1278
- // Step 2: Resolve appUrl
1279
- // Priority: serverAppUrl > localhost default (when baseUrl is localhost) > derived from baseUrl
1280
- let appUrl = serverAppUrl;
1281
-
1282
- // For localhost environments with localhost baseUrl, use localhost app default
1283
- // This handles the common case where API runs on :3000 and App on :3001
1284
- const isBaseUrlLocalhost = baseUrl && (baseUrl.includes('localhost') || baseUrl.includes('127.0.0.1'));
1285
- if (!appUrl && usesLocalhostDefaults && isBaseUrlLocalhost) {
1286
- appUrl = LOCALHOST_DEFAULTS.APP_URL;
1241
+ if (resolved.appUrlSource === 'localhost-default') {
1287
1242
  warnings.push(`URL: Using localhost default appUrl="${appUrl}" (env: '${serverEnv}')`);
1288
1243
  }
1289
-
1290
- // For non-localhost environments, try to derive appUrl from baseUrl
1291
- if (!appUrl && baseUrl) {
1292
- appUrl = deriveAppUrlFromBaseUrl(baseUrl);
1293
- if (appUrl !== baseUrl) {
1294
- warnings.push(`URL: Auto-derived appUrl="${appUrl}" from baseUrl="${baseUrl}"`);
1295
- }
1244
+ if (resolved.appUrlSource === 'derived' && appUrl !== baseUrl) {
1245
+ warnings.push(`URL: Auto-derived appUrl="${appUrl}" from baseUrl="${baseUrl}"`);
1296
1246
  }
1297
1247
 
1298
1248
  // Step 3: Resolve rpId from appUrl (not baseUrl!)