@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lenne.tech/nest-server",
|
|
3
|
-
"version": "11.27.
|
|
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": ">=
|
|
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.
|
|
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.
|
|
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
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
* explicitly (for development),
|
|
260
|
-
*
|
|
261
|
-
*
|
|
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
|
|
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
|
-
|
|
285
|
-
|
|
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`.
|
|
1016
|
-
*
|
|
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'
|
|
1324
|
-
*
|
|
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
|
|
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
|
},
|