@lenne.tech/nest-server 11.27.4 → 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.
@@ -460,8 +460,15 @@ if (!isCorsDisabled(envConfig.cors)) {
460
460
  | `cors: { allowAll: true }` | `origin: true` | `origin: true` | `trustedOrigins: undefined` |
461
461
  | `cors: { allowedOrigins: [...] }` | `origin: [merged list]` | `origin: [merged list]` | `trustedOrigins: [merged list]` |
462
462
  | `cors: { enabled: false }` | No CORS headers | No CORS headers | `trustedOrigins: []` |
463
+ | `cors: { deriveAppUrl: false }` | `appUrl` not derived from `baseUrl` | same | same |
463
464
 
464
- > **Note:** GraphQL CORS is configured in `CoreModule.buildCorsConfig()` using raw config values. BetterAuth derives `trustedOrigins` using resolved URLs (auto-derived from `baseUrl`). In edge cases (no explicit `appUrl`), these may differ slightly. Set `appUrl` explicitly for consistent behavior across all layers.
465
+ **URL resolution (since v11.27.5):** all three layers resolve `appUrl`/`baseUrl` through the single `resolveServerUrls()` helper in `cookies.helper.ts`, so they can no longer drift:
466
+
467
+ 1. `appUrl` set explicitly → used as-is
468
+ 2. `env: 'local' | 'ci' | 'e2e'` with a localhost `baseUrl` → `appUrl` defaults to `http://localhost:3001`
469
+ 3. otherwise derived from `baseUrl` by stripping a leading `api.` label (`https://api.example.com` → `https://example.com`), unless `cors.deriveAppUrl: false`
470
+
471
+ > **Security:** step 3 grants the derived origin credentialed CORS. If the apex domain is not trusted (e.g. a third-party-hosted marketing site whose XSS surface you do not control), set `cors.deriveAppUrl: false` and list the frontend origin explicitly via `appUrl` or `cors.allowedOrigins`. The derivation never yields a bare TLD (`https://api.dev` stays unchanged) and never emits the opaque `null` origin.
465
472
 
466
473
  ### NestJS Middleware Chain (CoreModule)
467
474
 
@@ -0,0 +1,241 @@
1
+ # Migration Guide: 11.27.4 → 11.27.5
2
+
3
+ ## Overview
4
+
5
+ | Category | Details |
6
+ |----------|---------|
7
+ | **Breaking Changes** | None (API-compatible). One **behavior change**: a config that sets only `baseUrl` now also trusts the derived app origin in the REST/GraphQL CORS allowlist — see [Behavior Change](#behavior-change-cors-allowlist-derives-appurl). |
8
+ | **New Features** | `cors.deriveAppUrl` opt-out; exported `resolveServerUrls()` and `deriveAppUrlFromBaseUrl()` helpers. |
9
+ | **Bugfixes** | REST/GraphQL CORS blocked the frontend origin on `api.<host>` deployments and in `local`/`ci`/`e2e`; the `api.`-strip could produce a bare TLD or the opaque `null` origin; trailing-slash `baseUrl` produced a never-matching origin entry. |
10
+ | **Security Updates** | `better-auth` + `@better-auth/passkey` `1.6.11` → `1.6.23` (GHSA-86j7-9j95-vpqj, High); `@xhmikosr/decompress` pinned to `11.1.3` via override (GHSA-mp2f-45pm-3cg9, Critical). |
11
+ | **Migration Effort** | 0 minutes for most projects (`pnpm update`). ~2 minutes if your apex domain is untrusted — see below. |
12
+
13
+ The three CORS layers (GraphQL/Apollo, REST/Express, BetterAuth `trustedOrigins`)
14
+ previously each resolved `appUrl`/`baseUrl` on their own and had drifted. They now
15
+ share a single `resolveServerUrls()` helper, so they cannot disagree.
16
+
17
+ ---
18
+
19
+ ## Quick Migration
20
+
21
+ No code changes required for the common case.
22
+
23
+ ```bash
24
+ # Update package
25
+ pnpm add @lenne.tech/nest-server@11.27.5
26
+
27
+ # Verify build
28
+ pnpm run build
29
+
30
+ # Run tests
31
+ pnpm test
32
+ ```
33
+
34
+ ---
35
+
36
+ ## What's Fixed in 11.27.5
37
+
38
+ ### 1. `api.<host>` deployments: the frontend origin was blocked
39
+
40
+ A deployment that sets only `baseUrl` (the typical `NSC__BASE_URL` /
41
+ `BASE_URL` setup) got a CORS allowlist containing just the API's own origin.
42
+ BetterAuth already derived `https://example.com` from
43
+ `https://api.example.com` for its `trustedOrigins`, but the REST and GraphQL
44
+ layers did not — so IAM endpoints worked cross-origin while every other
45
+ endpoint failed preflight.
46
+
47
+ Both layers now derive `appUrl` identically:
48
+
49
+ ```typescript
50
+ // config.env.ts — no appUrl needed
51
+ baseUrl: 'https://api.example.com',
52
+ // → CORS allowlist: ['https://example.com', 'https://api.example.com']
53
+ ```
54
+
55
+ ### 2. `local` / `ci` / `e2e`: localhost defaults were not applied to CORS
56
+
57
+ `resolveUrls()` in BetterAuth mapped these environments to
58
+ `appUrl = http://localhost:3001` (API on `:3000`, app on `:3001`).
59
+ `buildCorsConfig()` had no such branch, so the frontend was blocked. The
60
+ shared resolver now applies the same defaults on every layer.
61
+
62
+ > The framework's own `development` environment is **not** one of the
63
+ > localhost-default environments. `src/config.env.ts` now sets
64
+ > `appUrl: process.env.APP_URL || 'http://localhost:3001'` there explicitly.
65
+ > Projects that copied the `development` block from the starter and run their
66
+ > frontend on `:3001` should do the same.
67
+
68
+ ### 3. Hardened `api.`-label stripping
69
+
70
+ | `baseUrl` | Before | After |
71
+ |-----------|--------|-------|
72
+ | `https://api.example.com` | `https://example.com` | unchanged |
73
+ | `https://api.dev.example.com` | `https://dev.example.com` | unchanged |
74
+ | `https://api.dev` | `https://dev` (bare TLD!) | `https://api.dev` (no strip) |
75
+ | `https://api.` | `https://api.` (silent no-op) | `https://api.` (explicit no-op) |
76
+ | `custom://api.example.com` | `"null"` (opaque origin!) | `custom://api.example.com` |
77
+ | `https://api.example.com/` | `https://example.com` + dead `…/` entry | both entries origin-normalized |
78
+
79
+ The `"null"` case was the most serious: `URL.origin` serializes opaque origins
80
+ to the literal string `"null"`, which is exactly the `Origin` header a
81
+ sandboxed iframe sends. In a `credentials: true` allowlist that would have
82
+ granted credentialed access to any site able to frame a sandboxed document.
83
+ Only `http:`/`https:` URLs are normalized to origins now.
84
+
85
+ ### 4. Security updates in dependencies
86
+
87
+ | Package | Change | Advisory |
88
+ |---------|--------|----------|
89
+ | `better-auth` | `1.6.11` → `1.6.23` (direct) | [GHSA-86j7-9j95-vpqj](https://github.com/advisories/GHSA-86j7-9j95-vpqj) — High: stored XSS in the auth-server origin via a `javascript:` `redirect_uri` in `oidc-provider` and `mcp` |
90
+ | `@better-auth/passkey` | `1.6.11` → `1.6.23` (direct) | Kept in lockstep — peers on `better-auth@^1.6.23` |
91
+ | `@xhmikosr/decompress` | pinned to `11.1.3` via `pnpm.overrides` | [GHSA-mp2f-45pm-3cg9](https://github.com/advisories/GHSA-mp2f-45pm-3cg9) — Critical: archive extraction can create files/links outside the target directory |
92
+
93
+ `@xhmikosr/decompress` is transitive via `@swc/cli > @xhmikosr/bin-wrapper > @xhmikosr/downloader`.
94
+ `@swc/cli@0.8.1` is the latest release and still resolves the vulnerable range, so an override
95
+ with a fixed target is the only available fix.
96
+
97
+ Both `better-auth` bumps stay inside `1.6.x` — no BetterAuth API change. The full suite
98
+ (2019 tests) passes unchanged.
99
+
100
+ npm-mode consumers get all three fixes with a plain `pnpm update`. Vendor-mode consumers must
101
+ bump `better-auth`/`@better-auth/passkey` in their own `package.json` and copy the
102
+ `@xhmikosr/decompress` override — the vendored file set does not carry `package.json`.
103
+
104
+ ---
105
+
106
+ ## Behavior Change: CORS allowlist derives `appUrl`
107
+
108
+ **Who is affected:** projects that configure `baseUrl` but neither `appUrl`
109
+ nor `cors.allowedOrigins`, and whose apex domain is not under their control.
110
+
111
+ Deriving `appUrl` means the apex origin (`https://example.com`) receives
112
+ **credentialed** cross-origin access (`Access-Control-Allow-Credentials: true`).
113
+ That is the intended, documented `appUrl` auto-detection and matches what
114
+ BetterAuth's `trustedOrigins` has always done — but it now also applies to the
115
+ REST and GraphQL layers.
116
+
117
+ If your apex is a third-party-hosted marketing site (WordPress, Webflow,
118
+ HubSpot) whose XSS surface you do not control, opt out:
119
+
120
+ ```typescript
121
+ // config.env.ts
122
+ {
123
+ baseUrl: 'https://api.example.com',
124
+ cors: {
125
+ // Do not trust https://example.com implicitly
126
+ deriveAppUrl: false,
127
+ // List the real frontend origin instead
128
+ allowedOrigins: ['https://app.example.com'],
129
+ },
130
+ }
131
+ ```
132
+
133
+ `deriveAppUrl: false` suppresses the derivation on **all three** layers
134
+ (GraphQL, REST, BetterAuth `trustedOrigins`). It has no effect when `appUrl`
135
+ is set explicitly, and it does not disable the `local`/`ci`/`e2e` localhost
136
+ defaults.
137
+
138
+ ---
139
+
140
+ ## What's New in 11.27.5
141
+
142
+ ### `cors.deriveAppUrl`
143
+
144
+ | Value | Effect |
145
+ |-------|--------|
146
+ | `true` (default) | `appUrl` is derived from `baseUrl` by stripping a leading `api.` label |
147
+ | `false` | No derivation; configure `appUrl` / `cors.allowedOrigins` explicitly |
148
+
149
+ ### Exported URL helpers
150
+
151
+ Both are exported from the package root (`cookies.helper.ts`):
152
+
153
+ ```typescript
154
+ import { deriveAppUrlFromBaseUrl, resolveServerUrls } from '@lenne.tech/nest-server';
155
+
156
+ deriveAppUrlFromBaseUrl('https://api.example.com'); // 'https://example.com'
157
+
158
+ resolveServerUrls({ baseUrl: 'https://api.example.com' });
159
+ // { appUrl: 'https://example.com', appUrlSource: 'derived',
160
+ // baseUrl: 'https://api.example.com', baseUrlSource: 'explicit' }
161
+ ```
162
+
163
+ `appUrlSource` / `baseUrlSource` (`'explicit' | 'localhost-default' | 'derived' | 'none'`)
164
+ let callers emit accurate startup diagnostics without re-deriving the resolution logic.
165
+
166
+ ---
167
+
168
+ ## Breaking Changes
169
+
170
+ None. `ICorsConfig` gained an optional field; no signature, export, or Core
171
+ class changed.
172
+
173
+ ---
174
+
175
+ ## Compatibility Notes
176
+
177
+ - **npm-mode consumers:** `pnpm update` is sufficient. Review the
178
+ [behavior change](#behavior-change-cors-allowlist-derives-appurl) if your
179
+ apex domain is untrusted.
180
+ - **Vendor-mode consumers:** the change touches `src/core/common/helpers/cookies.helper.ts`,
181
+ `src/core/common/interfaces/server-options.interface.ts` and
182
+ `src/core/modules/better-auth/better-auth.config.ts` — all inside the vendored
183
+ `src/core/` file set. Sync via `/lt-dev:backend:update-nest-server-core`.
184
+ `better-auth.config.ts` now imports `resolveServerUrls` from
185
+ `../../common/helpers/cookies.helper` (still inside `src/core/`, so the
186
+ self-containment rule holds).
187
+ - **Projects that set `appUrl` explicitly:** unaffected. Explicit values are
188
+ never overridden by the derivation.
189
+ - **Projects that set `cors.allowAll: true`:** unaffected. `allowAll` short-circuits
190
+ before URL resolution.
191
+ - **Passkey / WebAuthn in `development`:** if you adopt the new
192
+ `appUrl: 'http://localhost:3001'` in the `development` block, the Passkey
193
+ `origin` becomes `http://localhost:3001` (where the browser actually runs)
194
+ instead of the API's `:3000`. This is a fix — WebAuthn ceremonies from the
195
+ frontend previously used the wrong origin.
196
+ - **A duplicate private `deriveAppUrlFromBaseUrl()` was removed** from
197
+ `better-auth.config.ts`. It was never exported; nothing to migrate.
198
+
199
+ ---
200
+
201
+ ## Troubleshooting
202
+
203
+ ### My frontend is still blocked by CORS
204
+
205
+ Check, in order:
206
+
207
+ 1. `cors.enabled` is not `false`.
208
+ 2. `cookies` is not disabled — `buildCorsConfig()` returns `{}` when cookies
209
+ are off (REST then falls back to permissive `enableCors()` without credentials).
210
+ 3. Your frontend origin is actually derivable. `baseUrl: 'https://my-api.example.com'`
211
+ has no `api.` label to strip, so `appUrl` resolves to the API's own origin.
212
+ Set `appUrl` explicitly.
213
+ 4. `env` is one of `local`/`ci`/`e2e` if you rely on the `localhost:3001` default.
214
+ `development` is not — set `appUrl` there.
215
+
216
+ ### My apex domain unexpectedly appears in the allowlist
217
+
218
+ That is the `appUrl` derivation. Set `cors.deriveAppUrl: false` and list the
219
+ frontend origin explicitly. See
220
+ [Behavior Change](#behavior-change-cors-allowlist-derives-appurl).
221
+
222
+ ### `https://api.dev` no longer derives an app origin
223
+
224
+ Intended. Stripping would leave the bare TLD `dev`, which is not a deployable
225
+ host. Set `appUrl` explicitly for such domains.
226
+
227
+ ---
228
+
229
+ ## Module Documentation
230
+
231
+ - **Request lifecycle & CORS:** [docs/REQUEST-LIFECYCLE.md](../docs/REQUEST-LIFECYCLE.md) — section `0b. CORS`
232
+ - **Configurable features:** [.claude/rules/configurable-features.md](../.claude/rules/configurable-features.md) — `CORS` row
233
+ - **BetterAuth:** [src/core/modules/better-auth/README.md](../src/core/modules/better-auth/README.md)
234
+
235
+ ---
236
+
237
+ ## References
238
+
239
+ - [Migration Guide 11.27.3 → 11.27.4](./11.27.3-to-11.27.4.md) — Previous release (test tooling)
240
+ - [Migration Guide 11.24.x → 11.25.0](./11.24.x-to-11.25.0.md) — Introduced the unified `cors` config
241
+ - [nest-server-starter](https://github.com/lenneTech/nest-server-starter) — reference implementation
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.5",
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",
@@ -77,7 +77,7 @@
77
77
  "dependencies": {
78
78
  "@apollo/server": "5.5.1",
79
79
  "@as-integrations/express5": "1.1.2",
80
- "@better-auth/passkey": "1.6.11",
80
+ "@better-auth/passkey": "1.6.23",
81
81
  "@getbrevo/brevo": "3.0.1",
82
82
  "@modelcontextprotocol/sdk": "1.29.0",
83
83
  "@nestjs/apollo": "13.4.2",
@@ -96,7 +96,7 @@
96
96
  "@tus/server": "2.4.1",
97
97
  "@types/supertest": "7.2.0",
98
98
  "bcrypt": "6.0.0",
99
- "better-auth": "1.6.11",
99
+ "better-auth": "1.6.23",
100
100
  "class-transformer": "0.5.1",
101
101
  "class-validator": "0.15.1",
102
102
  "compression": "1.8.1",
@@ -216,7 +216,8 @@
216
216
  "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
217
  "nodemailer@<9.0.1": "Security: email/header injection CVEs <9.0.1 - direct dependency",
218
218
  "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"
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",
220
+ "@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"
220
221
  },
221
222
  "overrides": {
222
223
  "axios@<1.16.0": "1.16.0",
@@ -252,7 +253,8 @@
252
253
  "hono@<4.12.25": "4.12.25",
253
254
  "nodemailer@<9.0.1": "9.0.1",
254
255
  "multer@<2.2.0": "2.2.0",
255
- "js-yaml@<4.2.0": "4.2.0"
256
+ "js-yaml@<4.2.0": "4.2.0",
257
+ "@xhmikosr/decompress@<11.1.3": "11.1.3"
256
258
  },
257
259
  "//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
260
  "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,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
  *