@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.
@@ -457,11 +457,31 @@ if (!isCorsDisabled(envConfig.cors)) {
457
457
 
458
458
  | Config | REST (Express) | GraphQL (Apollo) | BetterAuth |
459
459
  |--------|----------------|-------------------|------------|
460
- | `cors: { allowAll: true }` | `origin: true` | `origin: true` | `trustedOrigins: undefined` |
460
+ | `cors: { allowAll: true }` | `origin: true` | `origin: true` | `trustedOrigins: [appUrl] (+ passkey origins)` |
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
+ > **BetterAuth has no "allow all origins" mode (since v11.27.6).** `cors.allowAll` mirrors the request origin for REST/GraphQL, but BetterAuth's origin check is a security control with no meaningful "allow everything" setting. So `allowAll` yields the known-good origins (`appUrl` + any passkey origins), NOT `undefined` returning nothing there would leave BetterAuth trusting only its own `baseURL` and silently answer `403 INVALID_ORIGIN` for a separately hosted frontend on `two-factor/enable`, passkey registration, etc. For the same reason `trustedOrigins: []` (the `enabled: false` row) does **not** switch the origin check off: BetterAuth always trusts its own `baseURL`, so `[]` and `undefined` behave identically. To accept arbitrary origins for auth, set `betterAuth.trustedOrigins` explicitly.
466
+
467
+ **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:
468
+
469
+ 1. `appUrl` set explicitly → used as-is
470
+ 2. `env: 'local' | 'ci' | 'e2e'` with a localhost `baseUrl` that splits API and app by **host** → derived from `baseUrl` (see below)
471
+ 3. `env: 'local' | 'ci' | 'e2e'` with any other localhost `baseUrl` → `appUrl` defaults to `http://localhost:3001`
472
+ 4. otherwise derived from `baseUrl` by stripping a leading `api.` label (`https://api.example.com` → `https://example.com`), unless `cors.deriveAppUrl: false`
473
+
474
+ **Port split vs. host split (step 2 vs. 3, since v11.27.6).** The localhost defaults encode a *port split*: one host, API on `:3000`, app on `:3001`. `lt dev up` instead serves a *host split* behind Caddy — API on `https://api.<slug>.localhost`, app on `https://<slug>.localhost`. The two are told apart by what the `api.` label strips to, never by the port:
475
+
476
+ | `baseUrl` (`env: 'local'`) | Split | Resolved `appUrl` |
477
+ |---------------------------|-------|-------------------|
478
+ | `https://api.crm.localhost` | host | `https://crm.localhost` |
479
+ | `https://api.crm.localhost:8443` | host | `https://crm.localhost:8443` |
480
+ | `https://api.localhost` | port (strips to the bare host the API answers on) | `http://localhost:3001` |
481
+ | `http://api.localhost:3000` | port | `http://localhost:3001` |
482
+ | `http://localhost:3000` | port (no `api.` label) | `http://localhost:3001` |
483
+
484
+ > **Security:** steps 2 and 4 grant 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. With `cors.deriveAppUrl: false`, a host-split localhost `baseUrl` falls back to the `http://localhost:3001` default.
465
485
 
466
486
  ### NestJS Middleware Chain (CoreModule)
467
487
 
@@ -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
@@ -0,0 +1,359 @@
1
+ # Migration Guide: 11.27.5 → 11.27.6
2
+
3
+ ## Overview
4
+
5
+ | Category | Details |
6
+ |----------|---------|
7
+ | **Breaking Changes** | None. No existing signature, export, or Core class was removed or changed shape. |
8
+ | **New Features** | Two additive exports: `DEFAULT_MIGRATION_FILE_PATTERN` (the migration runner's default file matcher) and `strippedApiHostname` (the `api.`-label strip helper). Purely additive — nothing existing changed. |
9
+ | **Bugfixes** | (1) `resolveServerUrls()` ignored a host-split localhost `baseUrl` in `local`/`ci`/`e2e`: `https://api.crm.localhost` resolved `appUrl` to the flat `http://localhost:3001` default instead of deriving `https://crm.localhost`, blocking the frontend origin in the CORS allowlist, BetterAuth's `trustedOrigins`, and the Passkey `rpId`/`origin` for every `lt dev up` project. (2) `buildTrustedOrigins()` returned `undefined` for `cors.allowAll`, silently breaking 2FA/passkey. (3) `MigrationRunner`'s default pattern loaded `*.d.ts` declaration files as migrations and threw. (4) `bin/migrate.js` failed to find the CLI from vendored layouts. (5) BetterAuth's native handlers (2FA enable/verify, passkey register/list, backup codes, `/token`) returned `401 UNAUTHORIZED` on an `https://` baseURL because BetterAuth auto-prefixed its session cookie with `__Secure-` while the nest-server cookie helper writes the unprefixed name — a split-brain where `GET /iam/get-session` was `200` but every sensitive native endpoint was `401`. (6) `deriveCookieDomainFromUrls()` collapsed `api.<bare-TLD>` (e.g. `api.dev`) to the browser-rejected public suffix `dev`, dropping every cross-subdomain session cookie. |
10
+ | **Behavior Changes** | Five consumer-visible shifts — see [Behavior changes in framework source](#behavior-changes-in-framework-source): the host-split `appUrl` derivation above (an `api.*.localhost` `baseUrl` with no explicit `appUrl` now derives the sibling host instead of `localhost:3001`); `cors.allowAll` now yields `[appUrl]` for BetterAuth `trustedOrigins`; the migration runner's default pattern now skips `*.d.ts`; BetterAuth's native session cookie loses its `__Secure-` prefix (the `Secure` attribute is preserved), opt-out via `betterAuth.options.advanced.useSecureCookies`; and the cross-subdomain cookie domain keeps `api.<bare-TLD>` as-is instead of collapsing to a public suffix. |
11
+ | **Security Updates** | None. The `allowAll` fix removes an availability bug (the origin check was never loosened), and the BetterAuth cookie-**name** change preserves the `Secure` attribute — `createBetterAuthInstance()` restores it via `advanced.defaultCookieAttributes` on an `https://` baseURL, so session-cookie confidentiality is unchanged on every path, including the native handlers that forward `Set-Cookie` verbatim. |
12
+ | **Migration Effort** | 0 minutes (`pnpm update`) for the vast majority. A few edge cases need a one-line config: see [Behavior changes in framework source](#behavior-changes-in-framework-source). Optional: adopt the updated `scripts/check.mjs` / vitest configs / `test` scripts — see [Repo-internal tooling](#repo-internal-tooling). |
13
+
14
+ This is a **follow-up bugfix to the URL unification shipped in 11.27.5**. The
15
+ `api.`-strip derivation was already implemented and unit-tested for
16
+ `api.<slug>.localhost` hosts, but `resolveServerUrls()` never reached it in the
17
+ environments where those hosts actually occur. It also folds in three smaller
18
+ framework-source fixes (BetterAuth `allowAll`, the migration file pattern, and
19
+ the `bin/migrate.js` layout resolution) that shipped in the same release.
20
+
21
+ ---
22
+
23
+ ## Quick Migration
24
+
25
+ No code changes required.
26
+
27
+ ```bash
28
+ # Update package
29
+ pnpm add @lenne.tech/nest-server@11.27.6
30
+
31
+ # Verify build
32
+ pnpm run build
33
+
34
+ # Run tests
35
+ pnpm test
36
+ ```
37
+
38
+ ---
39
+
40
+ ## What's Fixed in 11.27.6
41
+
42
+ ### `lt dev up` projects: the app origin was resolved to `localhost:3001`
43
+
44
+ `resolveServerUrls()` checked `usesLocalhostDefaults && isLocalhostUrl(baseUrl)`
45
+ **before** the derivation branch, and `isLocalhostUrl()` matches `*.localhost`
46
+ subdomains. Any localhost `baseUrl` therefore short-circuited to the flat
47
+ localhost default, and the derivation branch became unreachable in exactly the
48
+ `local`/`ci`/`e2e` environments where `.localhost` hosts are used.
49
+
50
+ That contradicted `strippedApiHostname()` (formerly `canStripApiLabel()`), which
51
+ explicitly supports `api.localhost → localhost` ("`localhost` is the one
52
+ legitimate single-label host"), and the existing unit test `should strip api. in
53
+ front of localhost (lt dev: api.<slug>.localhost)`. The helper did the right
54
+ thing; nothing called it on that path.
55
+
56
+ | `env` | `baseUrl` | `appUrl` before | `appUrl` after |
57
+ |-------|-----------|-----------------|----------------|
58
+ | `local` | `https://api.crm.localhost` | `http://localhost:3001` | `https://crm.localhost` |
59
+ | `local` | `https://api.crm.localhost:8443` | `http://localhost:3001` | `https://crm.localhost:8443` |
60
+ | `local` | `https://api.nest-server.localhost` | `http://localhost:3001` | `https://nest-server.localhost` |
61
+ | `local` | `https://api.localhost` | `http://localhost:3001` | unchanged |
62
+ | `local` | `http://api.localhost:3000` | `http://localhost:3001` | unchanged |
63
+ | `local` | `http://localhost:3000` | `http://localhost:3001` | unchanged |
64
+ | `local` | `http://127.0.0.1:3000` | `http://localhost:3001` | unchanged |
65
+ | `develop` | `https://api.example.com` | `https://example.com` | unchanged |
66
+
67
+ ### Port split vs. host split
68
+
69
+ The localhost defaults encode a **port split**: one host, API on `:3000`, app on
70
+ `:3001`. `lt dev up` instead serves a **host split** behind Caddy — API on
71
+ `https://api.<slug>.localhost`, app on `https://<slug>.localhost`. Only the
72
+ second shape can name a distinct app origin, so only it takes precedence over
73
+ the localhost default.
74
+
75
+ The two are told apart by **what the `api.` label strips to**, never by the port:
76
+
77
+ - `api.crm.localhost` → `crm.localhost` — a sibling host the API never answers
78
+ on. **Host split**, derive it. This holds on any port, so
79
+ `https://api.crm.localhost:8443` derives `https://crm.localhost:8443`.
80
+ - `api.localhost` → `localhost` — the bare host the API already answers on. Only
81
+ the port tells app and API apart. **Port split**, keep `http://localhost:3001`.
82
+ This holds whether or not a port is written out, so both
83
+ `http://api.localhost:3000` and `https://api.localhost` keep the default.
84
+
85
+ > A port-based rule ("no explicit port ⇒ host split") looks equivalent on the two
86
+ > most common inputs and is wrong on both edges: it sends `api.crm.localhost:8443`
87
+ > back to `localhost:3001`, and it derives the unreachable `https://localhost`
88
+ > (port 443) from `https://api.localhost`.
89
+
90
+ ### Impact per layer
91
+
92
+ All three CORS layers share `resolveServerUrls()` since 11.27.5, so all three
93
+ were affected identically:
94
+
95
+ - **REST / GraphQL CORS:** the allowlist contained `http://localhost:3001`
96
+ instead of `https://<slug>.localhost`, so the frontend was blocked by
97
+ preflight. Projects whose local config sets `cors.allowAll: true` did **not**
98
+ see this — `allowAll` short-circuits before URL resolution.
99
+ - **BetterAuth `trustedOrigins`:** taken straight from the resolved `appUrl`, so
100
+ the real frontend origin was not trusted.
101
+ - **Passkey `rpId` / `origin`:** both auto-detected from `appUrl`, so `rpId`
102
+ resolved to `localhost` and `origin` to `http://localhost:3001` instead of the
103
+ `lt dev` host. Projects that set `betterAuth.passkey.rpId`/`origin` explicitly
104
+ were unaffected.
105
+
106
+ **Not affected by this fix:** the `crossSubDomainCookies` domain. For the host-split
107
+ `appUrl` derivation, `deriveCookieDomainFromUrls()` skips an `appUrl` whose hostname is
108
+ exactly `localhost` and falls back to the `baseUrl` with its `api.` label stripped — so it
109
+ already resolved to `crm.localhost` before this fix. (The function itself **did** change this
110
+ release, but for a separate reason — the `api.<bare-TLD>` guard, [behavior change #5](#5-cross-subdomain-cookie-domain-keeps-apibare-tld-as-is) below.)
111
+
112
+ Projects that set `APP_URL` explicitly were never affected — `lt dev up` exports
113
+ both `BASE_URL` and `APP_URL`, and explicit values always win
114
+ (`appUrlSource: 'explicit'`). The bug surfaced only when `baseUrl` was set
115
+ without `appUrl`.
116
+
117
+ ---
118
+
119
+ ## Behavior changes in framework source
120
+
121
+ All three ship inside the published `dist/` and reach every consumer via
122
+ `pnpm update`. None changes an existing signature — they change what existing
123
+ code *does* — so review this section even though the [Overview](#overview) lists
124
+ no breaking changes.
125
+
126
+ ### 1. Host-split `appUrl` derivation (the primary fix)
127
+
128
+ For `env: 'local' | 'ci' | 'e2e'`, a `baseUrl` whose `api.` label strips to a
129
+ **sibling host** now derives that host instead of falling back to the flat
130
+ `http://localhost:3001` default:
131
+
132
+ | `env` | `baseUrl` (no `appUrl` set) | `appUrl` before | `appUrl` after |
133
+ |-------|----------------------------|-----------------|----------------|
134
+ | `local` | `https://api.crm.localhost` | `http://localhost:3001` | **`https://crm.localhost`** |
135
+ | `local` | `https://api.localhost` | `http://localhost:3001` | `http://localhost:3001` (unchanged) |
136
+
137
+ This is the intended fix for `lt dev up`, but it **is** a behavior change for any
138
+ project that previously relied on the `localhost:3001` default while setting an
139
+ `api.*.localhost` `baseUrl`. If your frontend really does run on
140
+ `http://localhost:3001` (e.g. a plain `pnpm dev`, not behind Caddy), set
141
+ `appUrl: 'http://localhost:3001'` or `cors.deriveAppUrl: false` — both suppress
142
+ the derivation. `lt dev up` projects need no action (they export `APP_URL`).
143
+
144
+ ### 2. `cors.allowAll` no longer disables BetterAuth's origin check
145
+
146
+ `buildTrustedOrigins()` used to `return undefined` for `cors.allowAll`, in the
147
+ belief that BetterAuth then allowed all origins. It does not: without
148
+ `trustedOrigins`, BetterAuth trusts **only its own `baseURL`** (its
149
+ `getTrustedOrigins()` unconditionally pushes `new URL(baseURL).origin`), so the
150
+ separately hosted app origin was rejected and every origin-checked endpoint
151
+ (`two-factor/enable`, passkey registration) answered `403 INVALID_ORIGIN`. That
152
+ silently broke 2FA and passkeys in exactly the setups that run with `allowAll` —
153
+ local dev and CI.
154
+
155
+ `allowAll` now falls through to the known-good origins (`appUrl` + passkey), so
156
+ BetterAuth trusts the real app origin. An origin check has no meaningful "allow
157
+ everything" mode; projects that genuinely need to accept arbitrary origins for
158
+ auth must set `betterAuth.trustedOrigins` explicitly. **No action needed** unless
159
+ you depended on the (broken) old behavior.
160
+
161
+ ### 3. Migration runner skips `*.d.ts`
162
+
163
+ `MigrationRunner`'s default file pattern changed from `/\.(ts|js)$/` to
164
+ `DEFAULT_MIGRATION_FILE_PATTERN` (`/(?:(?<!\.d)\.ts|\.js)$/`), which matches
165
+ `foo.ts` / `foo.js` but not `foo.d.ts`. A compiled migration directory ships a
166
+ declaration file next to each `.js`; the old pattern loaded it as a *second*
167
+ migration and threw (`export declare …` is not valid CommonJS). Projects that
168
+ passed an explicit `pattern` to `MigrationRunner` are unaffected. Projects on the
169
+ default now correctly ignore declaration files. The pattern is exported as
170
+ `DEFAULT_MIGRATION_FILE_PATTERN` if you need to extend rather than replace it.
171
+
172
+ > `bin/migrate.js` also gained multi-layout CLI resolution (npm package, vendored
173
+ > repo, vendored production image). This is transparent — it only widens where the
174
+ > shim looks for the compiled CLI — and needs no action.
175
+
176
+ ### 4. BetterAuth native session cookie loses its `__Secure-` prefix (`Secure` preserved)
177
+
178
+ `createBetterAuthInstance()` now pins `advanced.useSecureCookies: false`. On an `https://`
179
+ baseURL Better-Auth otherwise auto-enables secure cookies **and** prefixes its session cookie
180
+ with `__Secure-`, so its native handlers (2FA enable/verify, passkey register/list, backup
181
+ codes, `/token`) look for `__Secure-<cookiePrefix>.session_token` — a cookie the nest-server
182
+ cookie helper never writes (it emits the unprefixed name) — and answer `401 UNAUTHORIZED`,
183
+ while `GET /iam/get-session` (reading the unprefixed cookie) still returns `200`. Pinning the
184
+ name realigns the native read path with the helper.
185
+
186
+ **The `Secure` attribute is NOT dropped.** `useSecureCookies: false` would by itself also strip
187
+ `Secure` from every cookie Better-Auth sets — including the ones its native handlers forward
188
+ **verbatim** (2FA verify, social callback, magic link, passkey) without passing through the
189
+ cookie helper. To avoid a transport downgrade, `createBetterAuthInstance()` restores the exact
190
+ `Secure` flag Better-Auth would have derived (an `https://` baseURL) via
191
+ `advanced.defaultCookieAttributes`, so on production HTTPS the session cookie still ships with
192
+ `Secure`.
193
+
194
+ **Opt-out.** Consumers who manage cookies entirely through Better-Auth (not the nest-server
195
+ helper) can re-enable the `__Secure-` prefix with
196
+ `betterAuth.options.advanced.useSecureCookies: true` (deep-merged). Most projects need no
197
+ action — `pnpm update` is sufficient.
198
+
199
+ ### 5. Cross-subdomain cookie domain keeps `api.<bare-TLD>` as-is
200
+
201
+ `deriveCookieDomainFromUrls()` now strips the `api.` label through the shared
202
+ `strippedApiHostname()` guard. Previously a `baseUrl` like `https://api.dev` (with
203
+ `crossSubDomainCookies` enabled) derived the cookie domain `dev` — a public suffix browsers
204
+ reject outright, dropping every session cookie. It now keeps `api.dev`. Only projects with
205
+ `crossSubDomainCookies` on an `api.<single-label>` host are affected, and only for the better.
206
+
207
+ > Known limitation (fails closed): a **multi-label** public suffix (`api.co.uk` → `co.uk`) is
208
+ > still not recognised without a Public-Suffix-List check, which this security-critical module
209
+ > avoids adding as a dependency. For such apex domains set
210
+ > `betterAuth.crossSubDomainCookies.domain` explicitly.
211
+
212
+ ---
213
+
214
+ ## Repo-internal tooling
215
+
216
+ These changes touch **no framework source** and ship no behavior change to
217
+ consuming projects. They are listed because `nest-server-starter` mirrors these
218
+ files, so projects that copied them can adopt the same fixes.
219
+
220
+ ### Unit tests ran in the e2e runner
221
+
222
+ `vitest-e2e.config.ts` used `include: ['tests/**/*.ts']`, which matched
223
+ `tests/unit/**` as well. Every unit test therefore ran inside the e2e runner —
224
+ under `NODE_ENV=e2e`, the mongod `globalSetup` and `retry: 5`, none of which a
225
+ unit test needs. The include now names the suites that actually need a database:
226
+
227
+ ```typescript
228
+ include: ['tests/**/*.e2e-spec.ts', 'tests/stories/**/*.story.test.ts'],
229
+ ```
230
+
231
+ Story tests are matched explicitly — they are e2e-grade and must keep the
232
+ globalSetup. The pattern also stops matching helpers, `global-setup.ts`,
233
+ `setup.ts` and the DB reporter, so `exclude` could be dropped entirely (which
234
+ restores vitest's own defaults for `node_modules`, `dist`, … instead of
235
+ replacing them).
236
+
237
+ Because the unit tests no longer come along for the ride, `test` now runs both
238
+ runners:
239
+
240
+ ```jsonc
241
+ "test": "pnpm run vitest:unit && pnpm run vitest",
242
+ "test:ci": "pnpm run vitest:unit && pnpm run vitest:ci",
243
+ ```
244
+
245
+ Three follow-on fixes were needed to make that split safe:
246
+
247
+ 1. **`vitest.config.ts` gained `setupFiles: ['tests/setup.ts']`.** The e2e config
248
+ had it; without it the unit run loses `Logger.overrideLogger(['error','fatal'])`
249
+ and the `@UnifiedField` deprecation-warning filter, and drowns in expected
250
+ DEBUG/WARN output.
251
+ 2. **`tests/unit/test-file-routing.spec.ts` guards the include patterns.** Narrow
252
+ patterns mean a file matching *neither* runner runs nowhere and still reports
253
+ green — `tests/stories/foo.test.ts` (missing `.story.`),
254
+ `tests/integration/bar.spec.ts`, `tests/unit/baz.test.ts`. The spec reads the
255
+ `include` patterns out of both configs and asserts every `*.spec.ts` /
256
+ `*.test.ts` in the repo is claimed by exactly one runner.
257
+ 3. **Coverage covers both suites again.** `vitest:cov` only ever ran the e2e
258
+ config, which no longer sees the unit tests. New `vitest:unit:cov`, and
259
+ `test:cov` runs both. The two runners are separate vitest processes, so each
260
+ writes its own report (`coverage/unit`, `coverage/e2e`) rather than
261
+ overwriting the other.
262
+
263
+ ### `check.mjs` under-reported multi-run test steps
264
+
265
+ `parseVitest()` used `String.match()` without the `g` flag and read only the
266
+ **first** vitest summary block in a step's output. With `test` now invoking
267
+ vitest twice, the report would have shown the unit run's `passed` count and
268
+ silently dropped the e2e run — and the test count is the only visible evidence in
269
+ the report that a suite ran at all. It now sums every summary block, is exported,
270
+ and is covered by `tests/unit/check-script-metrics.spec.ts`. The script only
271
+ executes its pipeline when invoked directly (`process.argv[1]` check), so
272
+ importing it for tests does not spawn a check run.
273
+
274
+ ---
275
+
276
+ ## Behavior Change
277
+
278
+ Five consumer-visible behavior changes ship in this release — the host-split
279
+ `appUrl` derivation, the `cors.allowAll` origin handling, the migration file
280
+ pattern, the BetterAuth native-cookie name change (`Secure` preserved), and the
281
+ cross-subdomain `api.<bare-TLD>` cookie-domain guard. Each is described, with its
282
+ opt-out where one exists, under
283
+ [Behavior changes in framework source](#behavior-changes-in-framework-source).
284
+
285
+ Unchanged: `deriveAppUrl: false` still suppresses the derivation on all three
286
+ layers, and still does **not** disable the `local`/`ci`/`e2e` localhost defaults —
287
+ a host-split `baseUrl` with `deriveAppUrl: false` falls back to
288
+ `http://localhost:3001`, exactly as before.
289
+
290
+ ---
291
+
292
+ ## Breaking Changes
293
+
294
+ None — every change is additive or a bugfix; no existing signature was removed or
295
+ altered. `separatesApiAndAppByHost()` and `toHttpUrl()` remain module-private.
296
+ `deriveAppUrlFromBaseUrl()` and `resolveServerUrls()` keep their signatures. Two
297
+ new named exports are added (both additive): `DEFAULT_MIGRATION_FILE_PATTERN` and
298
+ `strippedApiHostname()` (the latter promoted from module-private so BetterAuth's
299
+ cookie-domain derivation can share its bare-TLD guard). Both are free
300
+ helpers/constants, so they do not appear in `FRAMEWORK-API.md` (which tracks
301
+ interfaces, `CoreModule.forRoot()` overloads, and `CrudService` methods). The
302
+ tooling changes live in `scripts/` and the vitest configs, outside the published
303
+ `dist/`.
304
+
305
+ ---
306
+
307
+ ## Compatibility Notes
308
+
309
+ - **npm-mode consumers:** `pnpm update` is sufficient.
310
+ - **Projects that copied `scripts/check.mjs` / `vitest-e2e.config.ts` from the
311
+ starter:** optionally adopt the updated files. Check first whether your
312
+ `vitest-e2e.config.ts` matches `tests/unit/**`; if it does, your unit tests are
313
+ running inside the e2e runner against a database they do not use.
314
+ - **Vendor-mode consumers:** the change touches
315
+ `src/core/common/helpers/cookies.helper.ts` and the `appUrl` JSDoc in
316
+ `src/core/common/interfaces/server-options.interface.ts` — both inside the
317
+ vendored `src/core/` file set. Sync via
318
+ `/lt-dev:backend:update-nest-server-core`.
319
+ - **Projects that set `appUrl` / `APP_URL` explicitly:** unaffected.
320
+ - **Projects that set `cors.allowAll: true` in local envs:** unaffected on the
321
+ CORS layer; the `trustedOrigins` and Passkey fixes still apply.
322
+ - **Deployed envs (`develop` / `test` / `production`):** unaffected. They are not
323
+ localhost-default environments, so the derivation branch was always reachable
324
+ there.
325
+
326
+ ---
327
+
328
+ ## Troubleshooting
329
+
330
+ ### My `lt dev` frontend is still blocked by CORS
331
+
332
+ Check, in order:
333
+
334
+ 1. You are on 11.27.6 or later.
335
+ 2. `baseUrl` carries an `api.` label. `https://crm.localhost` has nothing to
336
+ strip — set `appUrl` explicitly.
337
+ 3. The label strips to a **sibling host**, not to the bare `localhost`.
338
+ `https://api.localhost` is a port split and resolves to `http://localhost:3001`
339
+ by design.
340
+ 4. `cors.deriveAppUrl` is not `false`.
341
+
342
+ ### I rely on `http://localhost:3001` for an `api.<slug>.localhost` baseUrl
343
+
344
+ Set `appUrl: 'http://localhost:3001'` explicitly, or `cors.deriveAppUrl: false`.
345
+ Both suppress the derivation.
346
+
347
+ ---
348
+
349
+ ## Module Documentation
350
+
351
+ - **Request lifecycle & CORS:** [docs/REQUEST-LIFECYCLE.md](../docs/REQUEST-LIFECYCLE.md) — section `0b. CORS`
352
+ - **BetterAuth:** [src/core/modules/better-auth/README.md](../src/core/modules/better-auth/README.md)
353
+
354
+ ---
355
+
356
+ ## References
357
+
358
+ - [Migration Guide 11.27.4 → 11.27.5](./11.27.4-to-11.27.5.md) — introduced `resolveServerUrls()`
359
+ - [nest-server-starter](https://github.com/lenneTech/nest-server-starter) — reference implementation