@lenne.tech/nest-server 11.26.0 → 11.26.2

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.
Files changed (30) hide show
  1. package/FRAMEWORK-API.md +1 -1
  2. package/dist/core/modules/better-auth/better-auth-cookie-prefix.helper.d.ts +3 -0
  3. package/dist/core/modules/better-auth/better-auth-cookie-prefix.helper.js +27 -0
  4. package/dist/core/modules/better-auth/better-auth-cookie-prefix.helper.js.map +1 -0
  5. package/dist/core/modules/better-auth/better-auth.config.d.ts +1 -1
  6. package/dist/core/modules/better-auth/better-auth.config.js +13 -1
  7. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  8. package/dist/core/modules/better-auth/core-better-auth-cookie.helper.js +2 -1
  9. package/dist/core/modules/better-auth/core-better-auth-cookie.helper.js.map +1 -1
  10. package/dist/core/modules/better-auth/core-better-auth-web.helper.js +3 -4
  11. package/dist/core/modules/better-auth/core-better-auth-web.helper.js.map +1 -1
  12. package/dist/core/modules/better-auth/core-better-auth.controller.d.ts +2 -1
  13. package/dist/core/modules/better-auth/core-better-auth.controller.js +13 -3
  14. package/dist/core/modules/better-auth/core-better-auth.controller.js.map +1 -1
  15. package/dist/core/modules/better-auth/core-better-auth.service.d.ts +2 -0
  16. package/dist/core/modules/better-auth/core-better-auth.service.js +10 -4
  17. package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
  18. package/dist/test/test.helper.js +5 -3
  19. package/dist/test/test.helper.js.map +1 -1
  20. package/dist/tsconfig.build.tsbuildinfo +1 -1
  21. package/migration-guides/11.26.0-to-11.26.1.md +193 -0
  22. package/migration-guides/11.26.1-to-11.26.2.md +234 -0
  23. package/package.json +1 -1
  24. package/src/core/modules/better-auth/better-auth-cookie-prefix.helper.ts +84 -0
  25. package/src/core/modules/better-auth/better-auth.config.ts +34 -3
  26. package/src/core/modules/better-auth/core-better-auth-cookie.helper.ts +12 -2
  27. package/src/core/modules/better-auth/core-better-auth-web.helper.ts +6 -7
  28. package/src/core/modules/better-auth/core-better-auth.controller.ts +21 -5
  29. package/src/core/modules/better-auth/core-better-auth.service.ts +29 -4
  30. package/src/test/test.helper.ts +13 -3
@@ -0,0 +1,193 @@
1
+ # Migration Guide: 11.26.0 → 11.26.1
2
+
3
+ ## Overview
4
+
5
+ | Category | Details |
6
+ |----------|---------|
7
+ | **Breaking Changes** | None |
8
+ | **Bugfixes** | BetterAuth REST sign-in/sign-up/get-session responses now include the user's `roles` from the synced legacy user (previously silently dropped) |
9
+ | **New Features** | New optional field `roles?: string[]` on `CoreBetterAuthUserResponse` (additive, Swagger-documented) |
10
+ | **Migration Effort** | 0 minutes (automatic) — optional cleanup if your project shipped a `mapUser()` override as a workaround |
11
+
12
+ ---
13
+
14
+ ## Quick Migration
15
+
16
+ No code changes required. The fix activates automatically.
17
+
18
+ ```bash
19
+ # Update package
20
+ pnpm add @lenne.tech/nest-server@11.26.1
21
+
22
+ # Verify build
23
+ pnpm run build
24
+
25
+ # Run tests
26
+ pnpm test
27
+ ```
28
+
29
+ ---
30
+
31
+ ## What's Fixed in 11.26.1
32
+
33
+ ### `roles` is now exposed on BetterAuth REST auth responses
34
+
35
+ **Affects:** Every project that uses BetterAuth (IAM) and reads `response.user` from the
36
+ REST endpoints `POST /iam/sign-in/email`, `POST /iam/sign-up/email`, or `GET /iam/session`.
37
+
38
+ **Symptom (before 11.26.1):**
39
+ The sign-in response body looked like this regardless of the user's role assignment:
40
+
41
+ ```json
42
+ {
43
+ "success": true,
44
+ "user": {
45
+ "id": "ba-user-1",
46
+ "email": "admin@example.com",
47
+ "emailVerified": true,
48
+ "name": "Admin"
49
+ },
50
+ "token": "..."
51
+ }
52
+ ```
53
+
54
+ Frontend consumers (e.g. `useLtAuth().setUser()` in `@lenne.tech/nuxt-extensions`,
55
+ the `lt-auth-state` cookie cache) persisted a roles-less user. Client-side admin gating
56
+ (e.g. routing the `nest-server-starter` setup flow into `/admin/*` based on
57
+ `roles: ['admin']`) silently failed for every freshly authenticated user.
58
+
59
+ **Fix (in 11.26.1):**
60
+ `CoreBetterAuthController.mapUser()` now forwards `roles` from the DB-synced legacy user:
61
+
62
+ ```json
63
+ {
64
+ "success": true,
65
+ "user": {
66
+ "id": "ba-user-1",
67
+ "email": "admin@example.com",
68
+ "emailVerified": true,
69
+ "name": "Admin",
70
+ "roles": ["admin"]
71
+ },
72
+ "token": "..."
73
+ }
74
+ ```
75
+
76
+ **Where the roles come from:**
77
+
78
+ `CoreBetterAuthController.mapUser()` reads `roles` from the result of
79
+ `CoreBetterAuthUserMapper.mapSessionUser()`, which looks the user up in the
80
+ `users` collection (`findOne({ $or: [{ email }, { iamId }] })`) and returns
81
+ `Array.isArray(dbUser.roles) ? dbUser.roles : []`. The client cannot influence
82
+ the value — roles remain server-authoritative.
83
+
84
+ **Defensive defaults:**
85
+
86
+ | `mappedUser` value | Response `roles` |
87
+ |-----------------------------------|------------------|
88
+ | `{ roles: ['admin'] }` | `['admin']` |
89
+ | `{ roles: [] }` | `[]` |
90
+ | `null` / `undefined` | `[]` |
91
+ | `{ roles: 'admin' }` (not array) | `[]` |
92
+ | `{}` (no `roles` field) | `[]` |
93
+
94
+ ---
95
+
96
+ ## Compatibility Notes
97
+
98
+ - **Frontend consumers (additive change):** Frontends that previously ignored
99
+ `response.user.roles` because it was always missing continue to work. Frontends
100
+ that now want to use it (admin gating, role-based UI) can read it directly —
101
+ no extra `/api/users/me` round-trip required after login.
102
+ - **Swagger / OpenAPI clients:** The generated schema gains `roles?: string[]`
103
+ on `CoreBetterAuthUserResponse`. Regenerate any SDK that consumes the OpenAPI
104
+ spec (e.g. `nuxt-base-starter` runs `pnpm run generate:types` against the API
105
+ schema).
106
+ - **Existing project-level `mapUser()` overrides (optional cleanup):**
107
+ If your project shipped a `mapUser()` override solely to add `roles` to the
108
+ response as a workaround, you can now delete it. See **Cleanup** below.
109
+ - **Subclasses that override `mapUser()`:** The second positional parameter is
110
+ unchanged in shape but was renamed `_mappedUser` → `mappedUser` (the leading
111
+ underscore previously signalled "unused"). Existing overrides keep working —
112
+ parameter names are local to each function signature.
113
+ - **Projects without BetterAuth:** Not affected. Legacy `signIn` GraphQL/REST
114
+ responses already included `user.roles` and are unchanged.
115
+
116
+ ---
117
+
118
+ ## Cleanup (Optional)
119
+
120
+ If your project previously added a `mapUser()` override in its
121
+ `BetterAuthController` subclass solely to surface `roles`, the override is now
122
+ redundant and can be removed. Example pattern that can be deleted:
123
+
124
+ ```typescript
125
+ // Project: src/server/modules/better-auth/better-auth.controller.ts
126
+ //
127
+ // REMOVE this override — the core now does the same thing.
128
+ protected override mapUser(sessionUser: BetterAuthSessionUser, mappedUser: any) {
129
+ return {
130
+ ...super.mapUser(sessionUser, mappedUser),
131
+ roles: Array.isArray(mappedUser?.roles) ? mappedUser.roles : [],
132
+ };
133
+ }
134
+ ```
135
+
136
+ Keep the override if it adds project-specific fields beyond `roles` (e.g.
137
+ `status`, `type`, `avatar`). In that case, drop only the `roles` line — `roles`
138
+ is now part of the base response.
139
+
140
+ ---
141
+
142
+ ## Troubleshooting
143
+
144
+ ### `roles` is still missing in the response
145
+
146
+ Check, in this order:
147
+
148
+ 1. **You actually pulled the new version.** Run `pnpm list @lenne.tech/nest-server`
149
+ and confirm 11.26.1 (or via the upstream-core-updater agent for vendored projects).
150
+ 2. **The user has been synced to the `users` collection.** `mapSessionUser()` looks
151
+ up the user via `$or: [{ email }, { iamId }]`. A user signed up exclusively via
152
+ BetterAuth but never created in the legacy `users` collection will fall through
153
+ to the "no DB user" branch which returns `roles: []` — assign roles in the
154
+ `users` collection (e.g. via `CoreUserService.setRoles()`), not in the
155
+ `iam_user` collection.
156
+ 3. **The role is a `S_`-prefixed system role.** Per project rule, `S_USER`,
157
+ `S_VERIFIED`, `S_CREATOR`, `S_SELF` etc. are runtime checks and must never be
158
+ stored in `user.roles`. They will not appear in the response.
159
+ 4. **A custom `mapUser()` override strips `roles`.** Search your project for
160
+ `mapUser` and confirm the override either calls `super.mapUser(...)` or
161
+ forwards `roles` explicitly.
162
+ 5. **The user-mapper cache is stale.** In production the mapper caches DB lookups
163
+ for 15s per `iamId`. `CoreUserService.setRoles()` / `update()` already invalidate
164
+ the cache; manual DB writes do not. Force a re-read by calling
165
+ `userMapper.invalidateUserCache(iamId)` after a direct write, or wait 15s.
166
+
167
+ ### Swagger schema shows `roles` as `required`
168
+
169
+ It is not — the property is declared as `required: false`. If your generated
170
+ client marks it required, regenerate after pulling 11.26.1.
171
+
172
+ ---
173
+
174
+ ## Module Documentation
175
+
176
+ ### BetterAuth Module
177
+
178
+ - **README:** [src/core/modules/better-auth/README.md](../src/core/modules/better-auth/README.md)
179
+ - **Integration Checklist:** [src/core/modules/better-auth/INTEGRATION-CHECKLIST.md](../src/core/modules/better-auth/INTEGRATION-CHECKLIST.md)
180
+ - **Customization Guide:** [src/core/modules/better-auth/CUSTOMIZATION.md](../src/core/modules/better-auth/CUSTOMIZATION.md) — patterns for overriding controller / resolver
181
+ - **Reference Implementation:** `src/server/modules/better-auth/`
182
+ - **Key Files:**
183
+ - `src/core/modules/better-auth/core-better-auth.controller.ts` — `mapUser()` (the fix), `CoreBetterAuthUserResponse` (the response DTO with the new `roles` field)
184
+ - `src/core/modules/better-auth/core-better-auth-user.mapper.ts` — `mapSessionUser()` (source of `roles`, with 15s TTL cache and `invalidateUserCache()`)
185
+
186
+ ---
187
+
188
+ ## References
189
+
190
+ - [Role System](../.claude/rules/role-system.md) — Real roles vs. `S_`-prefixed runtime checks
191
+ - [Module Inheritance Pattern](../.claude/rules/module-inheritance.md) — How to extend `CoreBetterAuthController.mapUser()` cleanly
192
+ - [Migration Guide 11.25.x → 11.26.0](./11.25.x-to-11.26.0.md) — Previous release
193
+ - [nest-server-starter](https://github.com/lenneTech/nest-server-starter) (reference implementation)
@@ -0,0 +1,234 @@
1
+ # Migration Guide: 11.26.1 → 11.26.2
2
+
3
+ ## Overview
4
+
5
+ | Category | Details |
6
+ |----------|---------|
7
+ | **Breaking Changes** | None |
8
+ | **Bugfixes** | Latent BetterAuth session-cookie drift fixed — six call sites that independently derived the cookie name from `basePath` now share a single resolver, so a `cookiePrefix` override no longer breaks sign-in / authenticated requests / sign-out |
9
+ | **New Features** | New `COOKIE_PREFIX` env variable — isolates the BetterAuth session cookie name on shared hosts (e.g. several lenne.tech apps on `*.localhost`). New helper module `better-auth-cookie-prefix.helper.ts` exporting `resolveBetterAuthCookiePrefix()`, `resolveBetterAuthSessionCookieName()`, `detectCookiePrefixDrift()`. New `CoreBetterAuthService.getCookiePrefix()`. Drift warning when `options.advanced.cookiePrefix` is used programmatically |
10
+ | **Migration Effort** | 0 minutes (automatic) — opt-in `COOKIE_PREFIX` for multi-app shared-host deployments only |
11
+
12
+ ---
13
+
14
+ ## Quick Migration
15
+
16
+ No code changes required. The fix and the new resolver activate automatically; `COOKIE_PREFIX` is opt-in.
17
+
18
+ ```bash
19
+ # Update package
20
+ pnpm add @lenne.tech/nest-server@11.26.2
21
+
22
+ # Verify build
23
+ pnpm run build
24
+
25
+ # Run tests
26
+ pnpm test
27
+ ```
28
+
29
+ ---
30
+
31
+ ## What's New in 11.26.2
32
+
33
+ ### 1. `COOKIE_PREFIX` env variable — isolate auth cookies per app
34
+
35
+ **Use case:** Several lenne.tech apps deployed on the same host (e.g. via `lt dev` — `nest-server.localhost`, `offers.localhost`, `showroom.localhost` all served by Caddy on the same machine). Browser cookies are scoped by host, not port — without isolation a session cookie from one app collides with another and authentication breaks. The BetterAuth REST surface in 11.26.1 derives the cookie name from `basePath` (default: `iam.session_token`), which is identical across apps.
36
+
37
+ **Fix:** Set `COOKIE_PREFIX` per app — the BetterAuth session cookie becomes `<prefix>.session_token`:
38
+
39
+ ```bash
40
+ # App A
41
+ COOKIE_PREFIX=offers # → cookie name: offers.session_token
42
+
43
+ # App B
44
+ COOKIE_PREFIX=showroom # → cookie name: showroom.session_token
45
+ ```
46
+
47
+ **Precedence (resolved by `resolveBetterAuthCookiePrefix`):**
48
+
49
+ 1. **`COOKIE_PREFIX` env** (dedicated, always wins) — sanitised to the safe cookie-name subset `[A-Za-z0-9._-]`; if it sanitises to empty it is ignored
50
+ 2. otherwise the **basePath-derived** prefix (`/iam` → `iam`, `/api/iam` → `api.iam`) — the previous default, fully backward compatible when `COOKIE_PREFIX` is unset
51
+
52
+ **Frontend pairing (required when set):** The frontend `@lenne.tech/nuxt-extensions` exposes `NUXT_PUBLIC_COOKIE_PREFIX` (same precedence, same sanitisation). The two sides MUST agree, otherwise the browser sends one name and the server reads another. The backend logs a `COOKIE_PREFIX override active → …` line on bootstrap to make the override visible in operator logs.
53
+
54
+ ### 2. Single source of truth helper module
55
+
56
+ New file: `src/core/modules/better-auth/better-auth-cookie-prefix.helper.ts`. Dependency-free on purpose so it can be imported from the config builder, the service, controllers and helpers without any import cycle.
57
+
58
+ ```typescript
59
+ // All public API:
60
+ import {
61
+ resolveBetterAuthCookiePrefix, // → 'iam' (default) or 'acme' (under COOKIE_PREFIX=acme)
62
+ resolveBetterAuthSessionCookieName, // → 'iam.session_token' or 'acme.session_token'
63
+ detectCookiePrefixDrift, // → null | warning string
64
+ } from '@lenne.tech/nest-server';
65
+ ```
66
+
67
+ A `@deprecated` re-export from `./better-auth.config` keeps old imports working — new code should import from the helper directly.
68
+
69
+ ### 3. `CoreBetterAuthService.getCookiePrefix()` (new public method)
70
+
71
+ ```typescript
72
+ class CoreBetterAuthService {
73
+ /** Cached on first read so it cannot drift away from the Better-Auth instance,
74
+ * which captures the prefix once at bootstrap. */
75
+ getCookiePrefix(): string;
76
+ getSessionCookieName(): string; // unchanged signature, now backed by getCookiePrefix()
77
+ }
78
+ ```
79
+
80
+ ### 4. Drift warning when `options.advanced.cookiePrefix` is set programmatically
81
+
82
+ ```typescript
83
+ betterAuth: {
84
+ options: {
85
+ advanced: { cookiePrefix: 'custom' }, // ← BetterAuth would honour this, NestJS layer would not
86
+ },
87
+ }
88
+ ```
89
+
90
+ This pattern silently broke sign-in / read / sign-out in 11.26.1 and earlier. 11.26.2 detects the drift via `detectCookiePrefixDrift()` and emits a loud `logger.warn` at bootstrap:
91
+
92
+ ```
93
+ options.advanced.cookiePrefix="custom" overrides Better-Auth but the NestJS layer
94
+ still uses "iam". This will break sign-in / read / sign-out — use the COOKIE_PREFIX
95
+ env variable instead (honoured by every call site).
96
+ ```
97
+
98
+ ---
99
+
100
+ ## What's Fixed in 11.26.2
101
+
102
+ ### Cross-layer cookie-prefix drift in the BetterAuth pipeline
103
+
104
+ **Affects:** Any project that set a non-default `cookiePrefix` via `options.advanced.cookiePrefix` or via the proposed `COOKIE_PREFIX` env (introduced here). Default deployments (no override) are not affected.
105
+
106
+ **Symptom (before 11.26.2):**
107
+ With a non-default cookie prefix, Better-Auth set `Set-Cookie: <prefix>.session_token=…` but the NestJS layer kept reading `iam.session_token`:
108
+
109
+ | Stage | Cookie name in 11.26.1 | Result |
110
+ |---|---|---|
111
+ | Sign-up / Sign-in | `<prefix>.session_token` (Better-Auth) | Cookie correctly set by Better-Auth |
112
+ | Authenticated request | `iam.session_token` ([core-better-auth.controller.ts:778-781](../src/core/modules/better-auth/core-better-auth.controller.ts)) | User reads as anonymous (`success: false`) |
113
+ | `getSession()` cookie-signing | `iam.session_token` ([core-better-auth.service.ts:437-438](../src/core/modules/better-auth/core-better-auth.service.ts)) | Session never resolved |
114
+ | `extractSessionToken` (middleware) | `iam.session_token` ([core-better-auth-web.helper.ts:98-103](../src/core/modules/better-auth/core-better-auth-web.helper.ts)) | 401 on protected routes |
115
+ | `BetterAuthCookieHelper` (set + clear) | `iam.session_token` ([core-better-auth-cookie.helper.ts:127-128](../src/core/modules/better-auth/core-better-auth-cookie.helper.ts)) | Sign-out leaves real cookie behind |
116
+
117
+ **Fix (in 11.26.2):**
118
+ All six call sites now resolve the cookie name through `resolveBetterAuthSessionCookieName(basePath)` — a single function that honours `COOKIE_PREFIX` and falls back to the basePath default. Cached on the service so a late `process.env` mutation (typical in tests) cannot drift the value away from the Better-Auth instance, which captures the prefix at bootstrap.
119
+
120
+ ---
121
+
122
+ ## Compatibility Notes
123
+
124
+ - **Default deployments (no `COOKIE_PREFIX`, no programmatic override):** Cookie name remains `iam.session_token`, behaviour is bit-for-bit identical to 11.26.1. No change is observable.
125
+ - **Projects that read `iam.session_token` directly in tests (hardcoded strings):** Continue to work as long as `COOKIE_PREFIX` is unset. Under override the hardcoded name is wrong — prefer `resolveBetterAuthSessionCookieName('/iam')` or the `TestHelper.extractSessionToken()` default (which now resolves through the same helper).
126
+ - **`TestHelper.extractSessionToken(response, cookieName?)`:** The second parameter is now optional; when omitted it derives the cookie name through the shared resolver (so tests under `COOKIE_PREFIX=acme` automatically look at `acme.session_token`). Existing call sites that pass an explicit name are unaffected.
127
+ - **`TestHelper.buildBetterAuthCookies(token, basePath = 'iam')`:** Already honoured `COOKIE_PREFIX` since the prior release via the same resolver — no caller change required.
128
+ - **Programmatic `options.advanced.cookiePrefix`:** Still applied to the Better-Auth instance (no behaviour change in Better-Auth), but now emits a `logger.warn` drift notice on bootstrap. Switch to `COOKIE_PREFIX` (env) to remove the warning and the latent breakage.
129
+ - **`BetterAuthCookieHelper.getNormalizedBasePath()`:** Unchanged — still returns the basePath-derived string (e.g. `'iam'`). It is **NOT** a cookie prefix; the JSDoc now carries a loud warning so future code never reconstructs the cookie name as `` `${getNormalizedBasePath()}.session_token` `` (which would re-introduce the drift).
130
+ - **Vendor-mode consumers:** Same as npm consumers; the new file ships under `src/core/modules/better-auth/`.
131
+
132
+ ---
133
+
134
+ ## Adopting `COOKIE_PREFIX` (Optional)
135
+
136
+ ### Step 1: Decide the prefix per app
137
+
138
+ Pick a short, unique slug per app — typically the project slug (`offers`, `showroom`) or a kebab/dot subset that matches your environment hygiene.
139
+
140
+ | Pick | Valid? | Result |
141
+ |---|---|---|
142
+ | `offers` | ✓ | `offers.session_token` |
143
+ | `kit-test_01` | ✓ | `kit-test_01.session_token` |
144
+ | `a;c=me x\r\n` | sanitised | `acmex.session_token` (semicolons, equals, whitespace, CR/LF stripped) |
145
+ | `;;==` | sanitises to empty → falls back to basePath | `iam.session_token` (the env is ignored) |
146
+
147
+ ### Step 2: Set both sides in lockstep
148
+
149
+ ```bash
150
+ # Backend (this package)
151
+ COOKIE_PREFIX=offers
152
+
153
+ # Frontend (@lenne.tech/nuxt-extensions)
154
+ NUXT_PUBLIC_COOKIE_PREFIX=offers
155
+ ```
156
+
157
+ ### Step 3: Verify
158
+
159
+ ```bash
160
+ # Bootstrap should log:
161
+ # COOKIE_PREFIX override active → auth cookies use prefix "offers" (e.g. "offers.session_token").
162
+ # The frontend NUXT_PUBLIC_COOKIE_PREFIX MUST match.
163
+
164
+ # Sign in and inspect the Set-Cookie header — it must use the overridden name:
165
+ curl -i -X POST http://localhost:3000/iam/sign-in/email \
166
+ -H 'Content-Type: application/json' \
167
+ -d '{"email":"…","password":"…"}' \
168
+ | grep -i set-cookie
169
+ # Set-Cookie: offers.session_token=…; Path=/; HttpOnly; …
170
+ ```
171
+
172
+ ---
173
+
174
+ ## Troubleshooting
175
+
176
+ ### After setting `COOKIE_PREFIX`, sign-in succeeds but every follow-up request is unauthenticated
177
+
178
+ Verify the frontend `NUXT_PUBLIC_COOKIE_PREFIX` matches the backend value. The most common cause is one side set in `.env` and the other still inheriting the default. The backend log line `COOKIE_PREFIX override active → …` confirms the backend value; check the browser's DevTools → Application → Cookies for the actual cookie name the browser holds.
179
+
180
+ ### Bootstrap log says `options.advanced.cookiePrefix="…" overrides Better-Auth but the NestJS layer still uses "…"`
181
+
182
+ You are setting the cookie prefix programmatically via `betterAuth.options.advanced.cookiePrefix`. Move the override to the `COOKIE_PREFIX` env variable — that is the only path that is honoured by every call site (sign-up, sign-in, get-session, sign-out, middleware). The programmatic override is left untouched but the NestJS layer cannot follow it, so sign-in writes one cookie and authenticated requests look for another.
183
+
184
+ ### Existing browser cookies under the old name keep the user "logged in" after switching `COOKIE_PREFIX`
185
+
186
+ Expected. Browser cookies are persistent — changing the prefix does not retroactively rename them. Users will be silently signed out (the server no longer reads the old name) and sign in again, after which the new cookie is set. Old cookies fall out of the browser at their max-age. To force a clean state, instruct users to clear cookies for the affected host, or set a one-off `Clear-Site-Data: "cookies"` header during the rollout.
187
+
188
+ ### Tests under `COOKIE_PREFIX=acme` return `{ success: false }` from `/iam/session` although sign-in succeeded
189
+
190
+ Most likely you are passing the JWT-converted token (sign-in response body's `token` field) instead of the raw session token. With `betterAuth.jwt` enabled the response token is a JWT (`eyJ…`), which goes in the `Authorization: Bearer` header — not in the session cookie. For cookie-based authenticated tests, read the raw token from the `session` collection:
191
+
192
+ ```typescript
193
+ const sessionRow = await db.collection('session').findOne(
194
+ { $or: [{ userId: iamUser._id }, { userId: iamUser._id.toString() }] },
195
+ { sort: { createdAt: -1 } },
196
+ );
197
+ const rawSessionToken = sessionRow.token;
198
+ ```
199
+
200
+ See `tests/stories/better-auth-cookie-prefix.story.test.ts` for a complete e2e reference.
201
+
202
+ ### `TestHelper.extractSessionToken(response)` returns `null` although the cookie is in the Set-Cookie header
203
+
204
+ You are likely on a `COOKIE_PREFIX=acme` setup but pass an explicit `cookieName: 'iam.session_token'` as the second argument. Either omit the argument (the new default goes through the resolver) or pass the resolved name explicitly.
205
+
206
+ ---
207
+
208
+ ## Module Documentation
209
+
210
+ ### BetterAuth Module
211
+
212
+ - **README:** [src/core/modules/better-auth/README.md](../src/core/modules/better-auth/README.md)
213
+ - **Integration Checklist:** [src/core/modules/better-auth/INTEGRATION-CHECKLIST.md](../src/core/modules/better-auth/INTEGRATION-CHECKLIST.md)
214
+ - **Customization Guide:** [src/core/modules/better-auth/CUSTOMIZATION.md](../src/core/modules/better-auth/CUSTOMIZATION.md)
215
+ - **Reference Implementation:** `src/server/modules/better-auth/`
216
+ - **Key Files:**
217
+ - `src/core/modules/better-auth/better-auth-cookie-prefix.helper.ts` — single source of truth for the prefix (`resolveBetterAuthCookiePrefix`, `resolveBetterAuthSessionCookieName`, `detectCookiePrefixDrift`)
218
+ - `src/core/modules/better-auth/better-auth.config.ts` — bootstrap-time resolution + drift warning + `@deprecated` re-export
219
+ - `src/core/modules/better-auth/core-better-auth.service.ts` — `getCookiePrefix()` (new, cached), `getSessionCookieName()` (now via cache)
220
+ - `src/core/modules/better-auth/core-better-auth.controller.ts`, `core-better-auth-cookie.helper.ts`, `core-better-auth-web.helper.ts` — all converted to the shared resolver
221
+ - **Reference tests:**
222
+ - `tests/unit/better-auth-cookie-prefix.spec.ts` — pure resolver + drift detector
223
+ - `tests/unit/better-auth-cookie-helper.spec.ts` — cross-layer set/read lockstep + service cache + `TestHelper` default
224
+ - `tests/stories/better-auth-cookie-prefix.story.test.ts` — e2e sign-up / authenticated request / sign-out against a real Mongo + Better-Auth boot under `COOKIE_PREFIX=acme`
225
+
226
+ ---
227
+
228
+ ## References
229
+
230
+ - [Migration Guide 11.26.0 → 11.26.1](./11.26.0-to-11.26.1.md) — Previous release (roles in REST auth response)
231
+ - [Configurable Features](../.claude/rules/configurable-features.md) — Pattern notes for env-driven overrides
232
+ - [BetterAuth module rules](../.claude/rules/better-auth.md) — Standard-compliance & security baselines
233
+ - [nest-server-starter](https://github.com/lenneTech/nest-server-starter) (reference implementation)
234
+ - [@lenne.tech/nuxt-extensions `resolveLtCookiePrefix`](https://github.com/lenneTech/nuxt-extensions) — frontend pendant (`NUXT_PUBLIC_COOKIE_PREFIX`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "11.26.0",
3
+ "version": "11.26.2",
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",
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Single source of truth for the Better-Auth session cookie prefix.
3
+ *
4
+ * The session cookie is named `<prefix>.session_token` (e.g. `iam.session_token`).
5
+ * The prefix is resolved here — and ONLY here — so every place that sets, reads,
6
+ * signs, extracts or clears the session cookie agrees on the exact name. (A
7
+ * previous bug derived the name from `basePath` independently in 6 places, so a
8
+ * `COOKIE_PREFIX` override broke the auth pipeline: Better-Auth set
9
+ * `acme.session_token` while the NestJS layer still looked for `iam.session_token`.)
10
+ *
11
+ * Dependency-free on purpose so it can be imported from the config builder, the
12
+ * service, controllers and helpers without any import cycle.
13
+ */
14
+
15
+ /**
16
+ * Characters allowed in a cookie-name token. A safe subset of RFC 6265 that is
17
+ * IDENTICAL to the frontend resolver (`@lenne.tech/nuxt-extensions`
18
+ * `resolveLtCookiePrefix`), so a shared `COOKIE_PREFIX` produces the SAME prefix
19
+ * on both sides. Everything else (spaces, `;`, `=`, `\r`, `\n`, …) is stripped so
20
+ * a typo can never corrupt the `Set-Cookie` header.
21
+ */
22
+ function sanitizeCookiePrefix(raw: string): string {
23
+ return raw.trim().replace(/[^A-Za-z0-9._-]/g, '');
24
+ }
25
+
26
+ /**
27
+ * Resolve the Better-Auth cookie prefix (the `iam` in `iam.session_token`).
28
+ *
29
+ * Precedence:
30
+ * 1. **`COOKIE_PREFIX` env** (dedicated, always wins) — for fully autonomous
31
+ * cookie isolation on a shared host (several lenne.tech apps on the same
32
+ * host, where cookies collide by host, not port). Sanitised to valid
33
+ * cookie-name characters; if it sanitises to empty it is ignored.
34
+ * 2. otherwise the **basePath-derived** prefix (`/iam` → `iam`,
35
+ * `/api/iam` → `api.iam`) — the previous behaviour, fully backward
36
+ * compatible when `COOKIE_PREFIX` is unset.
37
+ *
38
+ * IMPORTANT: when `COOKIE_PREFIX` is set it MUST match the frontend
39
+ * `NUXT_PUBLIC_COOKIE_PREFIX` (see `@lenne.tech/nuxt-extensions`
40
+ * `resolveLtCookiePrefix`) — otherwise the two sides use different cookie names
41
+ * and authentication breaks.
42
+ *
43
+ * @param basePath - Better-Auth base path (e.g. `/iam`)
44
+ * @param env - environment to read `COOKIE_PREFIX` from (defaults to `process.env`)
45
+ */
46
+ export function resolveBetterAuthCookiePrefix(basePath: string, env: NodeJS.ProcessEnv = process.env): string {
47
+ const basePathPrefix = (basePath || '/iam').replace(/^\//, '').replace(/\//g, '.');
48
+ const explicit = sanitizeCookiePrefix(typeof env.COOKIE_PREFIX === 'string' ? env.COOKIE_PREFIX : '');
49
+ return explicit || basePathPrefix;
50
+ }
51
+
52
+ /**
53
+ * Convenience: the full session-cookie name (`<prefix>.session_token`) for the
54
+ * given basePath, honouring `COOKIE_PREFIX`. Use this wherever the session
55
+ * cookie name is needed so all call sites stay in lockstep.
56
+ */
57
+ export function resolveBetterAuthSessionCookieName(basePath: string, env: NodeJS.ProcessEnv = process.env): string {
58
+ return `${resolveBetterAuthCookiePrefix(basePath, env)}.session_token`;
59
+ }
60
+
61
+ /**
62
+ * Detect a Better-Auth cookie-prefix drift between the value the NestJS layer
63
+ * resolves (via {@link resolveBetterAuthCookiePrefix}) and a programmatic
64
+ * `options.advanced.cookiePrefix` passed straight to Better-Auth. Such a
65
+ * mismatch silently breaks the auth pipeline because Better-Auth sets the
66
+ * cookie under the programmatic prefix while the NestJS read/clear path still
67
+ * uses the resolved prefix.
68
+ *
69
+ * Returns `null` when there is no drift (no programmatic prefix, or it
70
+ * matches), otherwise a human-readable warning sentence ready for `logger.warn`.
71
+ *
72
+ * Extracted so the drift-detection logic is testable without booting the full
73
+ * Better-Auth instance (which requires Mongo).
74
+ */
75
+ export function detectCookiePrefixDrift(resolvedPrefix: string, programmaticOptionsAdvanced: unknown): null | string {
76
+ if (!programmaticOptionsAdvanced || typeof programmaticOptionsAdvanced !== 'object') return null;
77
+ const programmaticPrefix = (programmaticOptionsAdvanced as Record<string, unknown>).cookiePrefix;
78
+ if (typeof programmaticPrefix !== 'string' || programmaticPrefix === resolvedPrefix) return null;
79
+ return (
80
+ `options.advanced.cookiePrefix="${programmaticPrefix}" overrides Better-Auth but the ` +
81
+ `NestJS layer still uses "${resolvedPrefix}". This will break sign-in / read / sign-out — ` +
82
+ `use the COOKIE_PREFIX env variable instead (honoured by every call site).`
83
+ );
84
+ }
@@ -8,6 +8,7 @@ import * as fs from 'fs';
8
8
  import * as path from 'path';
9
9
 
10
10
  import { IBetterAuth, ICorsConfig } from '../../common/interfaces/server-options.interface';
11
+ import { detectCookiePrefixDrift, resolveBetterAuthCookiePrefix } from './better-auth-cookie-prefix.helper';
11
12
 
12
13
  /**
13
14
  * Type for better-auth instance with plugins
@@ -325,9 +326,20 @@ export function createBetterAuthInstance(options: CreateBetterAuthOptions): Crea
325
326
  // Build the base Better-Auth configuration
326
327
  // Use resolved baseUrl (with local defaults) or fallback
327
328
  const basePath = config.basePath || '/iam';
328
- // Cookie prefix derived from basePath (e.g., '/iam' → 'iam')
329
- // This ensures Better-Auth looks for cookies like 'iam.session_token' instead of 'better-auth.session_token'
330
- const cookiePrefix = basePath.replace(/^\//, '').replace(/\//g, '.');
329
+ // Cookie prefix for Better-Auth's session cookies (e.g. 'iam.session_token').
330
+ // COOKIE_PREFIX env (always wins) lets a project fully isolate its auth
331
+ // cookies on a shared host; otherwise derived from basePath. The SAME pure
332
+ // resolver is used by every session-cookie call site (service, controller,
333
+ // web/cookie helpers) so the name stays in lockstep.
334
+ const cookiePrefix = resolveBetterAuthCookiePrefix(basePath);
335
+ // Operator visibility: make a non-default COOKIE_PREFIX override obvious in the
336
+ // logs, and remind that the frontend must mirror it or auth will break.
337
+ if ((process.env.COOKIE_PREFIX || '').trim()) {
338
+ logger.log(
339
+ `COOKIE_PREFIX override active → auth cookies use prefix "${cookiePrefix}" ` +
340
+ `(e.g. "${cookiePrefix}.session_token"). The frontend NUXT_PUBLIC_COOKIE_PREFIX MUST match.`,
341
+ );
342
+ }
331
343
 
332
344
  const betterAuthConfig: Record<string, unknown> = {
333
345
  advanced: {
@@ -382,6 +394,14 @@ export function createBetterAuthInstance(options: CreateBetterAuthOptions): Crea
382
394
  const { advanced: optionsAdvanced, ...restOptions } = config.options as Record<string, unknown>;
383
395
  finalConfig = { ...betterAuthConfig, ...restOptions };
384
396
  if (optionsAdvanced && typeof optionsAdvanced === 'object') {
397
+ // Drift guard: a programmatic `options.advanced.cookiePrefix` would only
398
+ // change what Better-Auth itself sets — the NestJS layer keeps resolving
399
+ // through `resolveBetterAuthCookiePrefix(basePath, env)`, so sign-in /
400
+ // read / sign-out would silently fall out of lockstep. Loud warning so
401
+ // operators see the misconfiguration immediately. Use `COOKIE_PREFIX`
402
+ // (env) instead — it is honoured by every call site.
403
+ const driftWarning = detectCookiePrefixDrift(cookiePrefix, optionsAdvanced);
404
+ if (driftWarning) logger.warn(driftWarning);
385
405
  finalConfig.advanced = {
386
406
  ...(betterAuthConfig.advanced as Record<string, unknown>),
387
407
  ...(optionsAdvanced as Record<string, unknown>),
@@ -1081,6 +1101,17 @@ export interface ResolvedCrossSubDomainCookies {
1081
1101
  enabled: boolean;
1082
1102
  }
1083
1103
 
1104
+ /**
1105
+ * Re-exported for backward compatibility.
1106
+ *
1107
+ * @deprecated Import from `./better-auth-cookie-prefix.helper` directly. The
1108
+ * re-export exists only so callers that landed on `./better-auth.config`
1109
+ * before the helper was extracted keep working; new code MUST import from the
1110
+ * dedicated helper module to avoid pulling the heavy config-builder into the
1111
+ * dependency graph (which forces import cycles in some build setups).
1112
+ */
1113
+ export { resolveBetterAuthCookiePrefix, resolveBetterAuthSessionCookieName } from './better-auth-cookie-prefix.helper';
1114
+
1084
1115
  /**
1085
1116
  * Resolves the cross-subdomain cookies configuration.
1086
1117
  *
@@ -2,6 +2,7 @@ import { Logger } from '@nestjs/common';
2
2
  import { Response } from 'express';
3
3
 
4
4
  import { isProductionLikeEnv } from '../../common/helpers/cookies.helper';
5
+ import { resolveBetterAuthSessionCookieName } from './better-auth-cookie-prefix.helper';
5
6
  import { signCookieValue } from './core-better-auth-web.helper';
6
7
 
7
8
  /**
@@ -124,8 +125,10 @@ export class BetterAuthCookieHelper {
124
125
  constructor(private readonly config: BetterAuthCookieHelperConfig) {
125
126
  // Normalize basePath: remove leading slash, replace slashes with dots
126
127
  this.normalizedBasePath = config.basePath.replace(/^\//, '').replace(/\//g, '.');
127
- // Default cookie name based on basePath (e.g., 'iam.session_token')
128
- this.cookieName = `${this.normalizedBasePath}.session_token`;
128
+ // Session cookie name via the single shared resolver: honours COOKIE_PREFIX
129
+ // (e.g. 'iam.session_token', or 'acme.session_token' when COOKIE_PREFIX=acme),
130
+ // so set/clear/read here always match the name Better-Auth itself uses.
131
+ this.cookieName = resolveBetterAuthSessionCookieName(config.basePath);
129
132
  }
130
133
 
131
134
  /**
@@ -153,6 +156,13 @@ export class BetterAuthCookieHelper {
153
156
 
154
157
  /**
155
158
  * Gets the normalized base path (e.g., 'iam' from '/iam').
159
+ *
160
+ * WARNING: This is the basePath-derived string, NOT the resolved cookie
161
+ * prefix — it intentionally ignores `COOKIE_PREFIX`. Do NOT use it to build
162
+ * a session cookie name (use {@link getCookieName} instead). Composing
163
+ * `${getNormalizedBasePath()}.session_token` would re-introduce the
164
+ * COOKIE_PREFIX drift bug where Better-Auth and the NestJS layer disagree
165
+ * on the cookie name (see better-auth-cookie-prefix.helper.ts).
156
166
  */
157
167
  getNormalizedBasePath(): string {
158
168
  return this.normalizedBasePath;
@@ -2,6 +2,7 @@ import { Logger } from '@nestjs/common';
2
2
  import * as crypto from 'crypto';
3
3
  import { Request, Response } from 'express';
4
4
 
5
+ import { resolveBetterAuthSessionCookieName } from './better-auth-cookie-prefix.helper';
5
6
  import { isSessionToken } from './core-better-auth-token.helper';
6
7
 
7
8
  /**
@@ -94,13 +95,12 @@ export function extractSessionToken(
94
95
  }
95
96
  }
96
97
 
97
- // Normalize basePath (remove leading slash, replace slashes with dots)
98
- const normalizedBasePath = basePath.replace(/^\//, '').replace(/\//g, '.');
99
-
100
98
  // Cookie names to check (in order of priority)
101
- // v11.12+: Only native Better-Auth cookie and legacy token
99
+ // v11.12+: Only native Better-Auth cookie and legacy token. The session cookie
100
+ // name is resolved through the single shared resolver (honours COOKIE_PREFIX),
101
+ // so it always matches what Better-Auth actually sets.
102
102
  const cookieNames = [
103
- `${normalizedBasePath}.session_token`, // Better-Auth native (PRIMARY)
103
+ resolveBetterAuthSessionCookieName(basePath), // Better-Auth native (PRIMARY)
104
104
  BETTER_AUTH_COOKIE_NAMES.TOKEN, // Legacy nest-server cookie
105
105
  ];
106
106
 
@@ -320,8 +320,7 @@ export async function toWebRequest(req: Request, options: ToWebRequestOptions):
320
320
  if (sessionToken) {
321
321
  headers.set('authorization', `Bearer ${sessionToken}`);
322
322
 
323
- const normalizedBasePath = basePath?.replace(/^\//, '').replace(/\//g, '.') || 'iam';
324
- const primaryCookieName = `${normalizedBasePath}.session_token`;
323
+ const primaryCookieName = resolveBetterAuthSessionCookieName(basePath || '/iam');
325
324
  const existingCookieString = headers.get('cookie') || '';
326
325
 
327
326
  // Check if the request already has a signed session cookie.