@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/.claude/rules/configurable-features.md +2 -2
- package/.claude/rules/testing.md +27 -9
- package/CLAUDE.md +4 -2
- package/FRAMEWORK-API.md +2 -1
- package/bin/migrate.js +84 -25
- package/dist/config.env.js +1 -0
- package/dist/config.env.js.map +1 -1
- package/dist/core/common/helpers/cookies.helper.d.ts +19 -0
- package/dist/core/common/helpers/cookies.helper.js +98 -4
- package/dist/core/common/helpers/cookies.helper.js.map +1 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +1 -0
- package/dist/core/modules/better-auth/better-auth.config.js +20 -41
- package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
- package/dist/core/modules/migrate/migration-runner.d.ts +1 -0
- package/dist/core/modules/migrate/migration-runner.js +3 -2
- package/dist/core/modules/migrate/migration-runner.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +22 -2
- package/migration-guides/11.27.4-to-11.27.5.md +241 -0
- package/migration-guides/11.27.5-to-11.27.6.md +359 -0
- package/package.json +15 -8
- package/src/config.env.ts +4 -0
- package/src/core/common/helpers/cookies.helper.ts +293 -12
- package/src/core/common/interfaces/server-options.interface.ts +57 -6
- package/src/core/modules/better-auth/README.md +10 -4
- package/src/core/modules/better-auth/better-auth.config.ts +93 -84
- package/src/core/modules/migrate/migration-runner.ts +16 -2
|
@@ -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, strippedApiHostname } 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
|
|
|
@@ -379,15 +380,47 @@ export function createBetterAuthInstance(options: CreateBetterAuthOptions): Crea
|
|
|
379
380
|
);
|
|
380
381
|
}
|
|
381
382
|
|
|
383
|
+
// Better-Auth base URL (resolved localhost defaults → explicit config → fallback).
|
|
384
|
+
const betterAuthBaseUrl = resolvedUrls.baseUrl || config.baseUrl || 'http://localhost:3000';
|
|
385
|
+
// The `Secure` attribute Better-Auth WOULD derive for its cookies if `useSecureCookies`
|
|
386
|
+
// were not pinned below: an `https://` baseURL is Better-Auth's own signal
|
|
387
|
+
// (createCookieGetter → `baseURLString.startsWith('https://')`). We keep exactly this value so
|
|
388
|
+
// the pinned `useSecureCookies: false` (needed for name alignment, see below) does not silently
|
|
389
|
+
// strip `Secure` from the cookies Better-Auth's native handlers forward.
|
|
390
|
+
const secureCookies = betterAuthBaseUrl.startsWith('https://');
|
|
391
|
+
|
|
382
392
|
const betterAuthConfig: Record<string, unknown> = {
|
|
383
393
|
advanced: {
|
|
384
394
|
cookiePrefix,
|
|
385
395
|
...(crossSubDomain.enabled && {
|
|
386
396
|
crossSubDomainCookies: { domain: crossSubDomain.domain, enabled: true },
|
|
387
397
|
}),
|
|
398
|
+
// Keep Better-Auth's NATIVE handlers on the SAME cookie name the
|
|
399
|
+
// nest-server cookie helpers actually write. Those helpers always emit
|
|
400
|
+
// the UNPREFIXED `<cookiePrefix>.session_token`. Better-Auth, left to its
|
|
401
|
+
// own devices on an `https://` baseURL, auto-enables secure cookies AND
|
|
402
|
+
// prefixes the name with `__Secure-`, so its native handlers (2FA
|
|
403
|
+
// enable/disable, passkey register/list, backup codes, `/token`) look for
|
|
404
|
+
// `__Secure-<cookiePrefix>.session_token` — a cookie that is never written
|
|
405
|
+
// — and return `401 UNAUTHORIZED`, while the project's own session
|
|
406
|
+
// resolver (which reads the unprefixed cookie) still returns a session: a
|
|
407
|
+
// split-brain where `GET /iam/get-session` is 200 but every sensitive
|
|
408
|
+
// Better-Auth endpoint is 401. Pinning `useSecureCookies: false` keeps the
|
|
409
|
+
// native read path aligned with the cookie helper.
|
|
410
|
+
useSecureCookies: false,
|
|
411
|
+
// …BUT `useSecureCookies: false` also forces `secure: false` on EVERY cookie
|
|
412
|
+
// Better-Auth sets (createCookieGetter → `secure: !!secureCookiePrefix`), and the
|
|
413
|
+
// native handlers forward their `Set-Cookie` VERBATIM via `sendWebResponse`
|
|
414
|
+
// (2FA verify, social callback, magic link, passkey) WITHOUT passing through the
|
|
415
|
+
// cookie helper — so those session cookies would ship without `Secure` in
|
|
416
|
+
// production. Restore the exact Secure flag Better-Auth itself would have derived
|
|
417
|
+
// (an `https://` baseURL), keeping the unprefixed NAME while preserving the
|
|
418
|
+
// `Secure` TRANSPORT flag. Only injected on https so http/local and a consumer's
|
|
419
|
+
// own `options.advanced.useSecureCookies` override are left untouched.
|
|
420
|
+
...(secureCookies && { defaultCookieAttributes: { secure: true } }),
|
|
388
421
|
},
|
|
389
422
|
basePath,
|
|
390
|
-
baseURL:
|
|
423
|
+
baseURL: betterAuthBaseUrl,
|
|
391
424
|
database: mongodbAdapter(db),
|
|
392
425
|
// Enable email/password authentication by default (required by Better-Auth 1.x)
|
|
393
426
|
// Can be disabled by setting config.emailAndPassword.enabled = false
|
|
@@ -680,12 +713,20 @@ function buildSocialProviders(config: IBetterAuth): Record<string, SocialProvide
|
|
|
680
713
|
*
|
|
681
714
|
* Behavior (in priority order):
|
|
682
715
|
* 1. Explicit betterAuth.trustedOrigins → use those (always takes precedence)
|
|
683
|
-
* 2. Server CORS disabled → empty array (
|
|
684
|
-
*
|
|
716
|
+
* 2. Server CORS disabled → empty array (adds no extra trusted origins beyond BetterAuth's own
|
|
717
|
+
* baseURL — see NOTE; it does NOT switch BetterAuth's origin check off)
|
|
718
|
+
* 3. Server CORS allowAll → fall through to rules 4-7 (see below)
|
|
685
719
|
* 4. Server CORS allowedOrigins → merge with appUrl/baseUrl
|
|
686
720
|
* 5. Passkey trustedOrigins → use from normalizePasskeyConfig()
|
|
687
721
|
* 6. Fallback to resolved appUrl
|
|
688
|
-
* 7. Otherwise → undefined (
|
|
722
|
+
* 7. Otherwise → undefined (Better-Auth then trusts only its own baseURL)
|
|
723
|
+
*
|
|
724
|
+
* NOTE on rules 2 and 7: neither an empty array (rule 2) nor `undefined` (rule 7) means "any
|
|
725
|
+
* origin is allowed", and neither disables BetterAuth's origin check. In both cases BetterAuth
|
|
726
|
+
* still derives and trusts its own `baseURL` origin (see `getTrustedOrigins()` in
|
|
727
|
+
* `better-auth/context`, which unconditionally pushes `new URL(baseURL).origin` before appending
|
|
728
|
+
* `options.trustedOrigins`), so `[]` and `undefined` are behaviorally identical here: a
|
|
729
|
+
* separately hosted frontend is still rejected by every origin-checked endpoint.
|
|
689
730
|
*
|
|
690
731
|
* @param config - Better-auth configuration
|
|
691
732
|
* @param options - Passkey normalization, resolved URLs, and server CORS context
|
|
@@ -705,15 +746,25 @@ export function buildTrustedOrigins(
|
|
|
705
746
|
return config.trustedOrigins;
|
|
706
747
|
}
|
|
707
748
|
|
|
708
|
-
// 2. Server CORS disabled → BetterAuth
|
|
749
|
+
// 2. Server CORS disabled → add no extra trusted origins. BetterAuth still trusts its own
|
|
750
|
+
// baseURL (see the NOTE above), so this does NOT turn its origin check off — it only means
|
|
751
|
+
// "no separately hosted frontend is trusted". Behaviorally identical to rule 7's undefined.
|
|
709
752
|
if (serverCorsConfig === false || (typeof serverCorsConfig === 'object' && serverCorsConfig?.enabled === false)) {
|
|
710
753
|
return [];
|
|
711
754
|
}
|
|
712
755
|
|
|
713
|
-
// 3. Server CORS allowAll →
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
756
|
+
// 3. Server CORS allowAll → fall through to the appUrl / passkey rules below.
|
|
757
|
+
//
|
|
758
|
+
// This used to `return undefined` in the belief that BetterAuth then "allows all
|
|
759
|
+
// origins". It does not: without trustedOrigins BetterAuth trusts only its own
|
|
760
|
+
// baseURL, so the *app* origin is rejected and every origin-checked endpoint
|
|
761
|
+
// (two-factor/enable, passkey) answers 403 INVALID_ORIGIN. That silently broke
|
|
762
|
+
// 2FA and passkeys in exactly the setups that use allowAll — local dev and CI.
|
|
763
|
+
//
|
|
764
|
+
// An origin check has no meaningful "allow everything" mode — that is the very
|
|
765
|
+
// thing it defends against — so allowAll now yields the known-good origins
|
|
766
|
+
// (appUrl, passkey) instead of none. Projects that really do want to accept
|
|
767
|
+
// arbitrary origins can still set `betterAuth.trustedOrigins` explicitly (rule 1).
|
|
717
768
|
|
|
718
769
|
// 4. Server allowedOrigins → merge with appUrl/baseUrl.
|
|
719
770
|
// Order (appUrl, baseUrl, allowedOrigins) mirrors buildCorsConfig() in cookies.helper.ts
|
|
@@ -914,21 +965,6 @@ function readProjectNameFromPackageJson(): string {
|
|
|
914
965
|
return fallback;
|
|
915
966
|
}
|
|
916
967
|
|
|
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
968
|
/**
|
|
933
969
|
* Resolves the effective URLs for BetterAuth configuration.
|
|
934
970
|
*
|
|
@@ -948,37 +984,6 @@ interface ResolvedUrls {
|
|
|
948
984
|
warnings: string[];
|
|
949
985
|
}
|
|
950
986
|
|
|
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
987
|
/**
|
|
983
988
|
* Extracts the root domain from a URL for use as Passkey rpId.
|
|
984
989
|
*
|
|
@@ -1228,6 +1233,17 @@ export function resolveCrossSubDomainCookies(
|
|
|
1228
1233
|
* 3. Use baseUrl hostname as-is
|
|
1229
1234
|
*
|
|
1230
1235
|
* Returns undefined for localhost (cross-subdomain not meaningful there).
|
|
1236
|
+
*
|
|
1237
|
+
* The `api.` strip reuses {@link strippedApiHostname} so it shares that helper's guard against
|
|
1238
|
+
* bogus results: `api.dev` → `dev` would set a public-suffix cookie domain that browsers reject
|
|
1239
|
+
* outright (dropping every session cookie), so `api.dev` is kept as-is instead.
|
|
1240
|
+
*
|
|
1241
|
+
* KNOWN LIMITATION (fails closed, no leak): the single-label guard cannot recognise a MULTI-label
|
|
1242
|
+
* public suffix — `api.co.uk` still strips to `co.uk`, a Public-Suffix-List entry browsers also
|
|
1243
|
+
* reject, so cross-subdomain auth would break (cookie dropped) rather than over-scope. A full PSL
|
|
1244
|
+
* check would need a dependency, which this security-critical module deliberately avoids; for such
|
|
1245
|
+
* apex domains set `betterAuth.crossSubDomainCookies.domain` explicitly instead of relying on
|
|
1246
|
+
* derivation. See migration guide 11.27.5 → 11.27.6.
|
|
1231
1247
|
*/
|
|
1232
1248
|
function deriveCookieDomainFromUrls(appUrl?: string, baseUrl?: string): string | undefined {
|
|
1233
1249
|
// Priority 1: appUrl hostname (this IS the parent domain in typical setups)
|
|
@@ -1249,11 +1265,9 @@ function deriveCookieDomainFromUrls(appUrl?: string, baseUrl?: string): string |
|
|
|
1249
1265
|
if (hostname === 'localhost' || hostname === '127.0.0.1') {
|
|
1250
1266
|
return undefined;
|
|
1251
1267
|
}
|
|
1252
|
-
// Strip api. prefix to get parent domain
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
}
|
|
1256
|
-
return hostname;
|
|
1268
|
+
// Strip api. prefix to get parent domain — but keep the host unchanged when stripping
|
|
1269
|
+
// would leave a bare TLD (`api.dev`) or empty host (`api.`).
|
|
1270
|
+
return strippedApiHostname(hostname) ?? hostname;
|
|
1257
1271
|
} catch {
|
|
1258
1272
|
return undefined;
|
|
1259
1273
|
}
|
|
@@ -1263,36 +1277,31 @@ function deriveCookieDomainFromUrls(appUrl?: string, baseUrl?: string): string |
|
|
|
1263
1277
|
}
|
|
1264
1278
|
|
|
1265
1279
|
function resolveUrls(options: CreateBetterAuthOptions): ResolvedUrls {
|
|
1266
|
-
const { config, serverAppUrl, serverBaseUrl, serverEnv } = options;
|
|
1280
|
+
const { config, serverAppUrl, serverBaseUrl, serverCorsConfig, serverEnv } = options;
|
|
1267
1281
|
const warnings: string[] = [];
|
|
1268
|
-
const usesLocalhostDefaults = LOCALHOST_ENVS.includes(serverEnv || '');
|
|
1269
1282
|
|
|
1270
|
-
//
|
|
1271
|
-
//
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1283
|
+
// Steps 1+2 (baseUrl, appUrl) are delegated to the shared resolver in cookies.helper so that
|
|
1284
|
+
// BetterAuth's trustedOrigins and the REST/GraphQL CORS allowlist can never disagree about
|
|
1285
|
+
// which app origin this server is reachable under. `cors.deriveAppUrl: false` therefore
|
|
1286
|
+
// suppresses the `api.`-strip derivation on this layer too.
|
|
1287
|
+
const corsObj = typeof serverCorsConfig === 'object' && serverCorsConfig !== null ? serverCorsConfig : undefined;
|
|
1288
|
+
const resolved = resolveServerUrls({
|
|
1289
|
+
appUrl: serverAppUrl,
|
|
1290
|
+
// Priority: betterAuth.baseUrl > serverBaseUrl (> localhost default, applied by the resolver)
|
|
1291
|
+
baseUrl: config.baseUrl || serverBaseUrl,
|
|
1292
|
+
deriveAppUrl: corsObj?.deriveAppUrl,
|
|
1293
|
+
env: serverEnv,
|
|
1294
|
+
});
|
|
1295
|
+
const { appUrl, baseUrl } = resolved;
|
|
1296
|
+
|
|
1297
|
+
if (resolved.baseUrlSource === 'localhost-default') {
|
|
1275
1298
|
warnings.push(`URL: Using localhost default baseUrl="${baseUrl}" (env: '${serverEnv}')`);
|
|
1276
1299
|
}
|
|
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;
|
|
1300
|
+
if (resolved.appUrlSource === 'localhost-default') {
|
|
1287
1301
|
warnings.push(`URL: Using localhost default appUrl="${appUrl}" (env: '${serverEnv}')`);
|
|
1288
1302
|
}
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
if (!appUrl && baseUrl) {
|
|
1292
|
-
appUrl = deriveAppUrlFromBaseUrl(baseUrl);
|
|
1293
|
-
if (appUrl !== baseUrl) {
|
|
1294
|
-
warnings.push(`URL: Auto-derived appUrl="${appUrl}" from baseUrl="${baseUrl}"`);
|
|
1295
|
-
}
|
|
1303
|
+
if (resolved.appUrlSource === 'derived' && appUrl !== baseUrl) {
|
|
1304
|
+
warnings.push(`URL: Auto-derived appUrl="${appUrl}" from baseUrl="${baseUrl}"`);
|
|
1296
1305
|
}
|
|
1297
1306
|
|
|
1298
1307
|
// Step 3: Resolve rpId from appUrl (not baseUrl!)
|
|
@@ -19,13 +19,27 @@ export interface MigrationFile {
|
|
|
19
19
|
up: () => Promise<void>;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Default file pattern for migration files: `*.ts` and `*.js`, but never `*.d.ts`.
|
|
24
|
+
*
|
|
25
|
+
* A compiled migration ships `foo.js` next to `foo.d.ts` (whenever the project
|
|
26
|
+
* builds with `declaration: true`). A plain `/\.(ts|js)$/` matches the
|
|
27
|
+
* declaration file too, so the runner would load it as a *second* migration and
|
|
28
|
+
* throw — `export declare …` is not valid CommonJS.
|
|
29
|
+
*
|
|
30
|
+
* The lookbehind guards the `.ts` branch only. Writing it as `(?<!\.d)\.(ts|js)$`
|
|
31
|
+
* would also reject a perfectly valid `foo.d.js`, since `.d` precedes `.js` there
|
|
32
|
+
* as well.
|
|
33
|
+
*/
|
|
34
|
+
export const DEFAULT_MIGRATION_FILE_PATTERN = /(?:(?<!\.d)\.ts|\.js)$/;
|
|
35
|
+
|
|
22
36
|
/**
|
|
23
37
|
* Migration runner configuration
|
|
24
38
|
*/
|
|
25
39
|
export interface MigrationRunnerOptions {
|
|
26
40
|
/** Directory containing migration files */
|
|
27
41
|
migrationsDirectory: string;
|
|
28
|
-
/** Pattern to match migration files (default:
|
|
42
|
+
/** Pattern to match migration files (default: {@link DEFAULT_MIGRATION_FILE_PATTERN}) */
|
|
29
43
|
pattern?: RegExp;
|
|
30
44
|
/** State store for tracking migrations */
|
|
31
45
|
stateStore: MongoStateStore;
|
|
@@ -59,7 +73,7 @@ export class MigrationRunner {
|
|
|
59
73
|
|
|
60
74
|
constructor(options: MigrationRunnerOptions) {
|
|
61
75
|
this.options = options;
|
|
62
|
-
this.pattern = options.pattern ||
|
|
76
|
+
this.pattern = options.pattern || DEFAULT_MIGRATION_FILE_PATTERN;
|
|
63
77
|
}
|
|
64
78
|
|
|
65
79
|
/**
|