From e6467450e4d5bd296a9d326b273fd6dc4d0942ec Mon Sep 17 00:00:00 2001 From: Kai Haase Date: Sun, 31 May 2026 23:25:16 +0200 Subject: [PATCH] 11.26.1: expose in REST auth response so clients can gate admin areas without a second round-trip --- FRAMEWORK-API.md | 2 +- migration-guides/11.26.0-to-11.26.1.md | 193 ++++++++++++++ package.json | 2 +- spectaql.yml | 2 +- .../core-better-auth.controller.ts | 23 +- tests/unit/better-auth-map-user.spec.ts | 239 ++++++++++++++++++ 6 files changed, 455 insertions(+), 6 deletions(-) create mode 100644 migration-guides/11.26.0-to-11.26.1.md create mode 100644 tests/unit/better-auth-map-user.spec.ts diff --git a/FRAMEWORK-API.md b/FRAMEWORK-API.md index cd2c717..106b217 100644 --- a/FRAMEWORK-API.md +++ b/FRAMEWORK-API.md @@ -1,6 +1,6 @@ # @lenne.tech/nest-server — Framework API Reference -> Auto-generated from source code on 2026-05-31 (v11.26.0) +> Auto-generated from source code on 2026-05-31 (v11.26.1) > File: `FRAMEWORK-API.md` — compact, machine-readable API surface for Claude Code ## CoreModule.forRoot() diff --git a/migration-guides/11.26.0-to-11.26.1.md b/migration-guides/11.26.0-to-11.26.1.md new file mode 100644 index 0000000..896c772 --- /dev/null +++ b/migration-guides/11.26.0-to-11.26.1.md @@ -0,0 +1,193 @@ +# Migration Guide: 11.26.0 → 11.26.1 + +## Overview + +| Category | Details | +|----------|---------| +| **Breaking Changes** | None | +| **Bugfixes** | BetterAuth REST sign-in/sign-up/get-session responses now include the user's `roles` from the synced legacy user (previously silently dropped) | +| **New Features** | New optional field `roles?: string[]` on `CoreBetterAuthUserResponse` (additive, Swagger-documented) | +| **Migration Effort** | 0 minutes (automatic) — optional cleanup if your project shipped a `mapUser()` override as a workaround | + +--- + +## Quick Migration + +No code changes required. The fix activates automatically. + +```bash +# Update package +pnpm add @lenne.tech/nest-server@11.26.1 + +# Verify build +pnpm run build + +# Run tests +pnpm test +``` + +--- + +## What's Fixed in 11.26.1 + +### `roles` is now exposed on BetterAuth REST auth responses + +**Affects:** Every project that uses BetterAuth (IAM) and reads `response.user` from the +REST endpoints `POST /iam/sign-in/email`, `POST /iam/sign-up/email`, or `GET /iam/session`. + +**Symptom (before 11.26.1):** +The sign-in response body looked like this regardless of the user's role assignment: + +```json +{ + "success": true, + "user": { + "id": "ba-user-1", + "email": "admin@example.com", + "emailVerified": true, + "name": "Admin" + }, + "token": "..." +} +``` + +Frontend consumers (e.g. `useLtAuth().setUser()` in `@lenne.tech/nuxt-extensions`, +the `lt-auth-state` cookie cache) persisted a roles-less user. Client-side admin gating +(e.g. routing the `nest-server-starter` setup flow into `/admin/*` based on +`roles: ['admin']`) silently failed for every freshly authenticated user. + +**Fix (in 11.26.1):** +`CoreBetterAuthController.mapUser()` now forwards `roles` from the DB-synced legacy user: + +```json +{ + "success": true, + "user": { + "id": "ba-user-1", + "email": "admin@example.com", + "emailVerified": true, + "name": "Admin", + "roles": ["admin"] + }, + "token": "..." +} +``` + +**Where the roles come from:** + +`CoreBetterAuthController.mapUser()` reads `roles` from the result of +`CoreBetterAuthUserMapper.mapSessionUser()`, which looks the user up in the +`users` collection (`findOne({ $or: [{ email }, { iamId }] })`) and returns +`Array.isArray(dbUser.roles) ? dbUser.roles : []`. The client cannot influence +the value — roles remain server-authoritative. + +**Defensive defaults:** + +| `mappedUser` value | Response `roles` | +|-----------------------------------|------------------| +| `{ roles: ['admin'] }` | `['admin']` | +| `{ roles: [] }` | `[]` | +| `null` / `undefined` | `[]` | +| `{ roles: 'admin' }` (not array) | `[]` | +| `{}` (no `roles` field) | `[]` | + +--- + +## Compatibility Notes + +- **Frontend consumers (additive change):** Frontends that previously ignored + `response.user.roles` because it was always missing continue to work. Frontends + that now want to use it (admin gating, role-based UI) can read it directly — + no extra `/api/users/me` round-trip required after login. +- **Swagger / OpenAPI clients:** The generated schema gains `roles?: string[]` + on `CoreBetterAuthUserResponse`. Regenerate any SDK that consumes the OpenAPI + spec (e.g. `nuxt-base-starter` runs `pnpm run generate:types` against the API + schema). +- **Existing project-level `mapUser()` overrides (optional cleanup):** + If your project shipped a `mapUser()` override solely to add `roles` to the + response as a workaround, you can now delete it. See **Cleanup** below. +- **Subclasses that override `mapUser()`:** The second positional parameter is + unchanged in shape but was renamed `_mappedUser` → `mappedUser` (the leading + underscore previously signalled "unused"). Existing overrides keep working — + parameter names are local to each function signature. +- **Projects without BetterAuth:** Not affected. Legacy `signIn` GraphQL/REST + responses already included `user.roles` and are unchanged. + +--- + +## Cleanup (Optional) + +If your project previously added a `mapUser()` override in its +`BetterAuthController` subclass solely to surface `roles`, the override is now +redundant and can be removed. Example pattern that can be deleted: + +```typescript +// Project: src/server/modules/better-auth/better-auth.controller.ts +// +// REMOVE this override — the core now does the same thing. +protected override mapUser(sessionUser: BetterAuthSessionUser, mappedUser: any) { + return { + ...super.mapUser(sessionUser, mappedUser), + roles: Array.isArray(mappedUser?.roles) ? mappedUser.roles : [], + }; +} +``` + +Keep the override if it adds project-specific fields beyond `roles` (e.g. +`status`, `type`, `avatar`). In that case, drop only the `roles` line — `roles` +is now part of the base response. + +--- + +## Troubleshooting + +### `roles` is still missing in the response + +Check, in this order: + +1. **You actually pulled the new version.** Run `pnpm list @lenne.tech/nest-server` + and confirm 11.26.1 (or via the upstream-core-updater agent for vendored projects). +2. **The user has been synced to the `users` collection.** `mapSessionUser()` looks + up the user via `$or: [{ email }, { iamId }]`. A user signed up exclusively via + BetterAuth but never created in the legacy `users` collection will fall through + to the "no DB user" branch which returns `roles: []` — assign roles in the + `users` collection (e.g. via `CoreUserService.setRoles()`), not in the + `iam_user` collection. +3. **The role is a `S_`-prefixed system role.** Per project rule, `S_USER`, + `S_VERIFIED`, `S_CREATOR`, `S_SELF` etc. are runtime checks and must never be + stored in `user.roles`. They will not appear in the response. +4. **A custom `mapUser()` override strips `roles`.** Search your project for + `mapUser` and confirm the override either calls `super.mapUser(...)` or + forwards `roles` explicitly. +5. **The user-mapper cache is stale.** In production the mapper caches DB lookups + for 15s per `iamId`. `CoreUserService.setRoles()` / `update()` already invalidate + the cache; manual DB writes do not. Force a re-read by calling + `userMapper.invalidateUserCache(iamId)` after a direct write, or wait 15s. + +### Swagger schema shows `roles` as `required` + +It is not — the property is declared as `required: false`. If your generated +client marks it required, regenerate after pulling 11.26.1. + +--- + +## Module Documentation + +### BetterAuth Module + +- **README:** [src/core/modules/better-auth/README.md](../src/core/modules/better-auth/README.md) +- **Integration Checklist:** [src/core/modules/better-auth/INTEGRATION-CHECKLIST.md](../src/core/modules/better-auth/INTEGRATION-CHECKLIST.md) +- **Customization Guide:** [src/core/modules/better-auth/CUSTOMIZATION.md](../src/core/modules/better-auth/CUSTOMIZATION.md) — patterns for overriding controller / resolver +- **Reference Implementation:** `src/server/modules/better-auth/` +- **Key Files:** + - `src/core/modules/better-auth/core-better-auth.controller.ts` — `mapUser()` (the fix), `CoreBetterAuthUserResponse` (the response DTO with the new `roles` field) + - `src/core/modules/better-auth/core-better-auth-user.mapper.ts` — `mapSessionUser()` (source of `roles`, with 15s TTL cache and `invalidateUserCache()`) + +--- + +## References + +- [Role System](../.claude/rules/role-system.md) — Real roles vs. `S_`-prefixed runtime checks +- [Module Inheritance Pattern](../.claude/rules/module-inheritance.md) — How to extend `CoreBetterAuthController.mapUser()` cleanly +- [Migration Guide 11.25.x → 11.26.0](./11.25.x-to-11.26.0.md) — Previous release +- [nest-server-starter](https://github.com/lenneTech/nest-server-starter) (reference implementation) diff --git a/package.json b/package.json index 765e5b2..d76f913 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lenne.tech/nest-server", - "version": "11.26.0", + "version": "11.26.1", "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).", "keywords": [ "node", diff --git a/spectaql.yml b/spectaql.yml index 750726e..50b81f7 100644 --- a/spectaql.yml +++ b/spectaql.yml @@ -19,7 +19,7 @@ servers: info: title: lT Nest Server 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). - version: 11.26.0 + version: 11.26.1 contact: name: lenne.Tech GmbH url: https://lenne.tech diff --git a/src/core/modules/better-auth/core-better-auth.controller.ts b/src/core/modules/better-auth/core-better-auth.controller.ts index 119e727..d7da40c 100644 --- a/src/core/modules/better-auth/core-better-auth.controller.ts +++ b/src/core/modules/better-auth/core-better-auth.controller.ts @@ -75,6 +75,14 @@ export class CoreBetterAuthUserResponse { @ApiProperty({ description: 'User display name' }) name: string; + @ApiProperty({ + description: 'Roles of the user (e.g. ["admin"]) — populated from the synced legacy user.', + isArray: true, + required: false, + type: String, + }) + roles?: string[]; + @ApiProperty({ description: 'Whether 2FA is enabled', required: false }) twoFactorEnabled?: boolean; } @@ -799,16 +807,25 @@ export class CoreBetterAuthController { /** * Map user to response format + * + * The `mappedUser` parameter is the synced legacy user from `mapSessionUser()` — it + * carries DB-only fields like `roles` that are not part of the Better-Auth session + * payload but are required by consumers for admin gating / RBAC. The base + * implementation forwards `roles` so the client-side state (`useLtAuth().setUser()` + * cache, `lt-auth-state` cookie) can route admin areas without a second round-trip. + * + * Subclasses can override to add project-specific fields (e.g. `status`, `type`). + * * @param sessionUser - The user from Better-Auth session - * @param _mappedUser - The synced user from legacy system (available for override customization) + * @param mappedUser - The synced user from legacy system (includes `roles`) */ - - protected mapUser(sessionUser: BetterAuthSessionUser, _mappedUser: any): CoreBetterAuthUserResponse { + protected mapUser(sessionUser: BetterAuthSessionUser, mappedUser: any): CoreBetterAuthUserResponse { return { email: sessionUser.email, emailVerified: sessionUser.emailVerified || false, id: sessionUser.id, name: sessionUser.name || sessionUser.email.split('@')[0], + roles: Array.isArray(mappedUser?.roles) ? mappedUser.roles : [], }; } diff --git a/tests/unit/better-auth-map-user.spec.ts b/tests/unit/better-auth-map-user.spec.ts new file mode 100644 index 0000000..ffc8def --- /dev/null +++ b/tests/unit/better-auth-map-user.spec.ts @@ -0,0 +1,239 @@ +/** + * Regression guard for CoreBetterAuthController.mapUser(). + * + * History: pre-fix, mapUser() ignored the `_mappedUser` parameter (underscore = + * unused) and emitted `{ email, emailVerified, id, name }` only. The DB-fetched + * `roles` from mapSessionUser() were silently dropped, so the sign-in HTTP + * response carried no roles. Frontend consumers (`useLtAuth().setUser()`) + * persisted a roles-less user in `lt-auth-state`, breaking client-side admin + * gating for every project that relies on `roles: ['admin']` (e.g. + * nest-server-starter setup flow). At least one customer project (Volksbank IMO, + * Linear DEV-1789) shipped a project-level mapUser() override as a workaround. + * + * This spec calls mapUser() directly with a synthetic sessionUser + mappedUser + * pair — no Better-Auth runtime needed — and asserts the response contract. + */ +import { describe, expect, it } from 'vitest'; + +import type { BetterAuthSessionUser } from '../../src/core/modules/better-auth/core-better-auth-user.mapper'; + +import { + CoreBetterAuthController, + type CoreBetterAuthUserResponse, +} from '../../src/core/modules/better-auth/core-better-auth.controller'; +import { RoleEnum } from '../../src/core/common/enums/role.enum'; + +// `mapUser()` is `protected` and `this`-free — we lift it from the prototype +// without invoking the constructor (which would require all Nest DI deps). +const mapUser: (sessionUser: BetterAuthSessionUser, mappedUser: any) => CoreBetterAuthUserResponse = ( + CoreBetterAuthController as any +).prototype.mapUser.bind({}); + +const sessionUser = (overrides: Partial = {}): BetterAuthSessionUser => ({ + email: 'admin@test.com', + emailVerified: true, + id: 'ba-user-1', + name: 'Test Admin', + ...overrides, +}); + +describe('CoreBetterAuthController.mapUser', () => { + // =================================================================================================================== + // Roles forwarding (the actual fix) + // =================================================================================================================== + + describe('roles forwarding', () => { + it('forwards roles from mappedUser into the response', () => { + const result = mapUser(sessionUser(), { roles: ['admin'] }); + expect(result.roles).toEqual(['admin']); + }); + + it('returns an empty array when mappedUser is null (e.g. unsynced user)', () => { + const result = mapUser(sessionUser(), null); + expect(result.roles).toEqual([]); + }); + + it('returns an empty array when mappedUser is undefined', () => { + const result = mapUser(sessionUser(), undefined); + expect(result.roles).toEqual([]); + }); + + it('returns an empty array when mappedUser has no roles field', () => { + const result = mapUser(sessionUser(), { email: 'admin@test.com' }); + expect(result.roles).toEqual([]); + }); + + it('returns an empty array when mappedUser.roles is not an array (defensive)', () => { + const result = mapUser(sessionUser(), { roles: 'admin' as any }); + expect(result.roles).toEqual([]); + }); + + it('returns an empty array when mappedUser.roles is null', () => { + const result = mapUser(sessionUser(), { roles: null as any }); + expect(result.roles).toEqual([]); + }); + + it('forwards a multi-role array (admin + functional roles)', () => { + const result = mapUser(sessionUser(), { roles: ['admin', 'editor', 'orga'] }); + expect(result.roles).toEqual(['admin', 'editor', 'orga']); + }); + + it('preserves an empty array as empty (not converted to undefined)', () => { + const result = mapUser(sessionUser(), { roles: [] }); + expect(result.roles).toEqual([]); + expect(result.roles).not.toBeUndefined(); + }); + }); + + // =================================================================================================================== + // Base shape (regression guard for the original fields) + // =================================================================================================================== + + describe('base shape', () => { + it('preserves the base shape (email, emailVerified, id, name) untouched', () => { + const result = mapUser(sessionUser({ email: 'editor@test.com', name: 'Editor' }), { roles: ['editor1'] }); + expect(result).toEqual({ + email: 'editor@test.com', + emailVerified: true, + id: 'ba-user-1', + name: 'Editor', + roles: ['editor1'], + }); + }); + + it('falls back to the email-local-part when name is missing', () => { + const result = mapUser(sessionUser({ name: undefined }), { roles: [] }); + expect(result.name).toBe('admin'); + }); + + it('defaults emailVerified to false when missing on sessionUser', () => { + const result = mapUser(sessionUser({ emailVerified: undefined }), { roles: [] }); + expect(result.emailVerified).toBe(false); + }); + + it('forwards explicit emailVerified=false', () => { + const result = mapUser(sessionUser({ emailVerified: false }), { roles: [] }); + expect(result.emailVerified).toBe(false); + }); + }); + + // =================================================================================================================== + // Security: explicit allow-list — extra mappedUser fields must NEVER leak + // =================================================================================================================== + + describe('security: response is an explicit allow-list', () => { + it('does not leak password / hash fields when mappedUser carries them', () => { + // mapSessionUser() reads from the `users` collection. A future regression could + // shape mappedUser closer to the raw DB document — the response must still + // strip secrets even then. This test guards the contract independently of + // mapSessionUser()'s current return shape. + const result = mapUser(sessionUser(), { + roles: ['admin'], + password: '$2b$10$leaked.hash.value', + passwordHash: 'should-not-leak', + verificationToken: 'should-not-leak', + passwordResetToken: 'should-not-leak', + refreshTokens: ['rt1', 'rt2'], + tempTokens: ['tmp1'], + }); + + expect((result as any).password).toBeUndefined(); + expect((result as any).passwordHash).toBeUndefined(); + expect((result as any).verificationToken).toBeUndefined(); + expect((result as any).passwordResetToken).toBeUndefined(); + expect((result as any).refreshTokens).toBeUndefined(); + expect((result as any).tempTokens).toBeUndefined(); + }); + + it('does not leak internal IDs (_id, iamId) or arbitrary DB fields', () => { + const result = mapUser(sessionUser(), { + _id: '507f1f77bcf86cd799439011', + iamId: 'should-not-leak-via-mapUser', + firstName: 'should-not-leak', + lastName: 'should-not-leak', + avatar: 'should-not-leak', + createdAt: new Date(), + updatedAt: new Date(), + roles: [], + }); + + expect((result as any)._id).toBeUndefined(); + expect((result as any).iamId).toBeUndefined(); + expect((result as any).firstName).toBeUndefined(); + expect((result as any).lastName).toBeUndefined(); + expect((result as any).avatar).toBeUndefined(); + expect((result as any).createdAt).toBeUndefined(); + expect((result as any).updatedAt).toBeUndefined(); + }); + + it('returns exactly the documented response keys', () => { + const result = mapUser(sessionUser(), { roles: ['admin'] }); + // Keys not present in the response payload (e.g. twoFactorEnabled) are + // intentionally absent here — the base implementation only sets the + // five fields below. Subclasses may extend this set. + expect(Object.keys(result).sort()).toEqual(['email', 'emailVerified', 'id', 'name', 'roles']); + }); + + it('does not allow client-supplied sessionUser fields to overwrite the response shape', () => { + // BetterAuthSessionUser comes from Better-Auth which is server-controlled. + // Still: even if a future codepath populated additional fields, mapUser() + // must not echo them back — the response is an explicit allow-list. + const su = sessionUser() as any; + su.password = 'cannot-leak-from-session'; + su.roles = ['cannot-leak-from-session']; + su.isAdmin = true; + + const result = mapUser(su, { roles: ['admin'] }); + expect((result as any).password).toBeUndefined(); + expect((result as any).isAdmin).toBeUndefined(); + // roles MUST come from the DB-mapped user, not from the (potentially + // forged) sessionUser argument. + expect(result.roles).toEqual(['admin']); + }); + }); + + // =================================================================================================================== + // Security: role values + // =================================================================================================================== + + describe('security: role values', () => { + it('does not strip ADMIN role (real role, must propagate)', () => { + const result = mapUser(sessionUser(), { roles: [RoleEnum.ADMIN] }); + expect(result.roles).toContain(RoleEnum.ADMIN); + }); + + it('passes through S_-prefixed values verbatim (defense in depth — never expected in user.roles, but mapUser must not silently rewrite them)', () => { + // Per project rule (.claude/rules/role-system.md): S_ roles are runtime + // checks and MUST NEVER be stored in user.roles. If the DB is polluted + // (data bug elsewhere), mapUser() should not hide the pollution — the + // wrong data must surface so it can be detected and cleaned, not be + // silently filtered which would mask the real bug. + const result = mapUser(sessionUser(), { roles: [RoleEnum.S_USER, RoleEnum.S_VERIFIED] }); + expect(result.roles).toEqual([RoleEnum.S_USER, RoleEnum.S_VERIFIED]); + }); + }); + + // =================================================================================================================== + // Override contract — protected method must remain callable from a subclass + // =================================================================================================================== + + describe('override contract', () => { + it('subclasses can override and call super.mapUser to extend the response', () => { + // The base class is intentionally cast to `any` so the test does not require + // the full Nest DI constructor surface. Because of that, TS cannot resolve + // `super.mapUser` as a typed member and the `override` modifier would error + // with TS4113 — call the prototype directly instead. + class CustomController extends (CoreBetterAuthController as any) { + public mapUser(su: BetterAuthSessionUser, mu: any): CoreBetterAuthUserResponse & { status?: string } { + const base = (CoreBetterAuthController as any).prototype.mapUser.call(this, su, mu); + return { ...base, status: mu?.status ?? 'unknown' }; + } + } + const instance = Object.create(CustomController.prototype); + const result = instance.mapUser(sessionUser(), { roles: ['admin'], status: 'active' }); + expect(result.roles).toEqual(['admin']); + expect(result.status).toBe('active'); + expect(result.email).toBe('admin@test.com'); + }); + }); +});