@lenne.tech/nest-server 11.31.1 → 11.31.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.
@@ -0,0 +1,127 @@
1
+ # Migration Guide: 11.31.1 → 11.31.2
2
+
3
+ ## Overview
4
+
5
+ | Category | Details |
6
+ |----------|---------|
7
+ | **Breaking Changes** | None (one behavior change for admins — see below) |
8
+ | **New Features** | Admin cross-user conversation listing (`?all=true`), creator attribution (`createdByUser`) |
9
+ | **Bugfixes** | Non-admins can list their own AI conversations again (was HTTP 403 / GraphQL "Access denied") |
10
+ | **Migration Effort** | None for most projects — no code changes required |
11
+
12
+ ---
13
+
14
+ ## Quick Migration
15
+
16
+ ```bash
17
+ # Update package
18
+ pnpm update @lenne.tech/nest-server@11.31.2
19
+
20
+ # Verify
21
+ pnpm run build && pnpm test
22
+ ```
23
+
24
+ No configuration changes are required. Everything below is automatic (the
25
+ bugfix) or opt-in (the two new features).
26
+
27
+ ---
28
+
29
+ ## What's New in 11.31.2
30
+
31
+ ### 1. Non-admins can list their own AI conversations again (bugfix)
32
+
33
+ `GET /ai/conversations` and the GraphQL `findAiConversations` query returned
34
+ `403` / "Access denied" for every non-admin user, so their own conversations
35
+ never loaded. The list endpoints passed the per-document roles
36
+ `[ADMIN, S_CREATOR, S_SELF]`, which a LIST operation can never satisfy (there is
37
+ no single object to check ownership against), so non-admins were rejected before
38
+ ownership scoping ran.
39
+
40
+ Both endpoints now authorize the list with `S_USER` and scope the result to the
41
+ caller via a server-side `createdBy` filter plus the model's `securityCheck`.
42
+ Cross-user isolation is unchanged — a non-admin still only ever sees their own
43
+ conversations. **No action required**; it simply works after the update.
44
+
45
+ ### 2. Admins can opt in to a cross-user view (`?all=true`)
46
+
47
+ By default every user — **admins included** — now receives only their own
48
+ conversations. An admin can list every user's conversations on demand:
49
+
50
+ ```bash
51
+ # REST
52
+ GET /ai/conversations?all=true
53
+ ```
54
+
55
+ ```graphql
56
+ # GraphQL
57
+ query { findAiConversations(all: true) { id title createdBy createdByUser } }
58
+ ```
59
+
60
+ The flag is ignored for non-admins, so it can never widen a regular user's
61
+ scope. Results are ordered newest-first.
62
+
63
+ ### 3. Creator attribution in the admin cross-user view (`createdByUser`)
64
+
65
+ In the admin cross-user view (`all: true`) each conversation additionally
66
+ carries a resolved `createdByUser` object so the list is attributable to a named
67
+ user:
68
+
69
+ ```jsonc
70
+ {
71
+ "id": "…",
72
+ "title": "…",
73
+ "createdBy": "665f…", // owner user id (always present)
74
+ "createdByUser": { // only in the admin all-view
75
+ "id": "665f…",
76
+ "email": "user@example.com",
77
+ "firstName": "…",
78
+ "lastName": "…",
79
+ "username": "…"
80
+ }
81
+ }
82
+ ```
83
+
84
+ On a normal own-only fetch `createdByUser` is left unset (the owner is the
85
+ caller, so no lookup is needed).
86
+
87
+ ---
88
+
89
+ ## Behavior Change (admins only)
90
+
91
+ Before 11.31.2, `GET /ai/conversations` returned **all** users' conversations
92
+ for an admin by default. It now returns the **admin's own** conversations by
93
+ default, matching every other role. If your admin UI relied on the unscoped
94
+ list, request the cross-user view explicitly with `?all=true` (REST) or
95
+ `all: true` (GraphQL). Non-admin behavior is unchanged.
96
+
97
+ ---
98
+
99
+ ## Compatibility Notes
100
+
101
+ - `getConversation` / `deleteConversation` (single-object operations) are
102
+ unchanged — they still use `[ADMIN, S_CREATOR, S_SELF]`, which is correct
103
+ because a single loaded object exists to check ownership against.
104
+ - Projects that extend `CoreAiConversationService` and override its constructor
105
+ must forward the new `@InjectConnection()` parameter to `super(...)` (used to
106
+ resolve creators for the admin view). Projects that do not subclass the
107
+ service are unaffected.
108
+
109
+ ---
110
+
111
+ ## Module Documentation
112
+
113
+ ### AI Module
114
+
115
+ - **README:** [src/core/modules/ai/README.md](../src/core/modules/ai/README.md)
116
+ - **Integration Checklist:** [src/core/modules/ai/INTEGRATION-CHECKLIST.md](../src/core/modules/ai/INTEGRATION-CHECKLIST.md)
117
+ - **Reference Implementation:** `src/server/modules/ai/`
118
+ - **Key Files:**
119
+ - `core-ai.controller.ts` / `core-ai.resolver.ts` — list endpoints delegate to the shared service method
120
+ - `services/core-ai-conversation.service.ts` — `findForCurrentUser()` (shared owner-scoped list + admin all-scope + creator attribution)
121
+ - `models/core-ai-conversation.model.ts` — `createdByUser` field, ownership `securityCheck`
122
+
123
+ ---
124
+
125
+ ## References
126
+
127
+ - [nest-server-starter](https://github.com/lenneTech/nest-server-starter) (reference implementation)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "11.31.1",
3
+ "version": "11.31.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",
@@ -1,4 +1,4 @@
1
- import { Body, Controller, Delete, Get, Param, Post, Put, Res } from '@nestjs/common';
1
+ import { Body, Controller, Delete, Get, Param, Post, Put, Query, Res } from '@nestjs/common';
2
2
  import { Response } from 'express';
3
3
 
4
4
  import { RESTServiceOptions } from '../../common/decorators/rest-service-options.decorator';
@@ -266,7 +266,9 @@ export class CoreAiController {
266
266
  }
267
267
 
268
268
  /**
269
- * Find the current user's AI conversations (admins see all).
269
+ * Find the current user's AI conversations (own only by default). An admin may
270
+ * pass `?all=true` to list every user's conversations; each result carries its
271
+ * `createdBy` owner id for attribution. The flag is ignored for non-admins.
270
272
  *
271
273
  * The `messages` subdocument array is excluded from the list result — clients
272
274
  * fetching the conversation detail via `getConversation` get the full message
@@ -274,13 +276,14 @@ export class CoreAiController {
274
276
  */
275
277
  @Get('conversations')
276
278
  @Roles(RoleEnum.S_USER)
277
- async findConversations(@RESTServiceOptions() serviceOptions: ServiceOptions): Promise<CoreAiConversation[]> {
278
- const currentUser = serviceOptions?.currentUser;
279
- const filterQuery = currentUser?.roles?.includes(RoleEnum.ADMIN) ? {} : { createdBy: currentUser?.id };
280
- return this.conversationService.find(
281
- { filterQuery },
282
- { ...serviceOptions, roles: [RoleEnum.ADMIN, RoleEnum.S_CREATOR, RoleEnum.S_SELF], select: '-messages' },
283
- );
279
+ async findConversations(
280
+ @RESTServiceOptions() serviceOptions: ServiceOptions,
281
+ @Query('all') all?: string,
282
+ ): Promise<CoreAiConversation[]> {
283
+ // Owner-scoped list shared with the GraphQL resolver — see
284
+ // CoreAiConversationService.findForCurrentUser for the role/ownership rationale.
285
+ // Admins default to their own conversations and opt in to the cross-user view via ?all=true.
286
+ return this.conversationService.findForCurrentUser(serviceOptions, { all: all === 'true' });
284
287
  }
285
288
 
286
289
  /**
@@ -270,21 +270,26 @@ export class CoreAiResolver {
270
270
  }
271
271
 
272
272
  /**
273
- * Find the current user's AI conversations (admins see all).
273
+ * Find the current user's AI conversations (own only by default). An admin may
274
+ * pass `all: true` to list every user's conversations; each result carries its
275
+ * `createdBy` owner id for attribution. The argument is ignored for non-admins.
274
276
  *
275
277
  * The `messages` subdocument array is excluded from the list result — clients
276
278
  * fetching the conversation detail via `getAiConversation` get the full message
277
279
  * history. List payloads stay small even for users with many long conversations.
278
280
  */
279
- @Query(() => [CoreAiConversation], { description: 'Find AI conversations of the current user' })
281
+ @Query(() => [CoreAiConversation], {
282
+ description: "Find AI conversations of the current user (admins may pass all: true to see every user's)",
283
+ })
280
284
  @Roles(RoleEnum.S_USER)
281
- async findAiConversations(@GraphQLServiceOptions() serviceOptions: ServiceOptions): Promise<CoreAiConversation[]> {
282
- const currentUser = serviceOptions?.currentUser;
283
- const filterQuery = currentUser?.roles?.includes(RoleEnum.ADMIN) ? {} : { createdBy: currentUser?.id };
284
- return this.conversationService.find(
285
- { filterQuery },
286
- { ...serviceOptions, roles: [RoleEnum.ADMIN, RoleEnum.S_CREATOR, RoleEnum.S_SELF], select: '-messages' },
287
- );
285
+ async findAiConversations(
286
+ @GraphQLServiceOptions() serviceOptions: ServiceOptions,
287
+ @Args('all', { nullable: true, type: () => Boolean }) all?: boolean,
288
+ ): Promise<CoreAiConversation[]> {
289
+ // Owner-scoped list shared with the REST controller — see
290
+ // CoreAiConversationService.findForCurrentUser for the role/ownership rationale.
291
+ // Admins default to their own conversations and opt in to the cross-user view via all: true.
292
+ return this.conversationService.findForCurrentUser(serviceOptions, { all: all === true });
288
293
  }
289
294
 
290
295
  /**
@@ -6,6 +6,7 @@ import { Restricted } from '../../../common/decorators/restricted.decorator';
6
6
  import { UnifiedField } from '../../../common/decorators/unified-field.decorator';
7
7
  import { RoleEnum } from '../../../common/enums/role.enum';
8
8
  import { CorePersistenceModel } from '../../../common/models/core-persistence.model';
9
+ import { JSON } from '../../../common/scalars/json.scalar';
9
10
  import { CoreAiMessage } from './core-ai-message.model';
10
11
 
11
12
  export type AiConversationDocument = CoreAiConversation & Document;
@@ -45,6 +46,22 @@ export class CoreAiConversation extends CorePersistenceModel {
45
46
  })
46
47
  createdBy?: string = undefined;
47
48
 
49
+ /**
50
+ * Resolved creator identity (`{ id, email, firstName, lastName, username }`),
51
+ * attached at read time ONLY for an admin's cross-user list view (`all: true`)
52
+ * so each conversation is attributable to a named user. Not persisted and left
53
+ * unset on a normal own-only fetch, where the owner is the caller.
54
+ */
55
+ @UnifiedField({
56
+ description:
57
+ 'Resolved creator identity (id, email, name) — set only in the admin cross-user list view, not persisted',
58
+ gqlType: JSON,
59
+ isOptional: true,
60
+ roles: RoleEnum.S_USER,
61
+ type: () => Object,
62
+ })
63
+ createdByUser?: Record<string, any> = undefined;
64
+
48
65
  /**
49
66
  * Conversation messages (appended via $push).
50
67
  */
@@ -76,7 +93,7 @@ export class CoreAiConversation extends CorePersistenceModel {
76
93
  if (force) {
77
94
  return this;
78
95
  }
79
- if (user && (user.hasRole?.(RoleEnum.ADMIN) || (this.createdBy && String(this.createdBy) === user.id))) {
96
+ if (user && (user.hasRole?.(RoleEnum.ADMIN) || (this.createdBy && String(this.createdBy) === String(user.id)))) {
80
97
  return this;
81
98
  }
82
99
  // Hide conversations owned by other users (filtered out of list responses).
@@ -1,7 +1,9 @@
1
1
  import { Inject, Injectable } from '@nestjs/common';
2
- import { InjectModel } from '@nestjs/mongoose';
3
- import { Model, Types } from 'mongoose';
2
+ import { InjectConnection, InjectModel } from '@nestjs/mongoose';
3
+ import { Connection, Model, Types } from 'mongoose';
4
4
 
5
+ import { RoleEnum } from '../../../common/enums/role.enum';
6
+ import { ServiceOptions } from '../../../common/interfaces/service-options.interface';
5
7
  import { CrudService } from '../../../common/services/crud.service';
6
8
  import { CoreModelConstructor } from '../../../common/types/core-model-constructor.type';
7
9
  import { CoreAiConversationCreateInput } from '../inputs/core-ai-conversation-create.input';
@@ -21,8 +23,15 @@ export { AI_CONVERSATION_CLASS, AI_CONVERSATION_MODEL } from '../core-ai.constan
21
23
  * CRUD service for multi-turn {@link CoreAiConversation}s.
22
24
  *
23
25
  * `appendMessage()` adds a turn via `$push` (never round-trips the subdocument
24
- * array through `update()`). Ownership is enforced by the model's `securityCheck`
25
- * and by the resolver/controller passing `S_CREATOR`/`S_SELF` roles.
26
+ * array through `update()`). `findForCurrentUser()` is the shared owner-scoped
27
+ * list used by both the REST controller and the GraphQL resolver.
28
+ *
29
+ * Ownership is enforced differently per operation shape:
30
+ * - single-object `get`/`delete`: the controller/resolver pass
31
+ * `S_CREATOR`/`S_SELF`, evaluated against the loaded `dbObject`.
32
+ * - list (`findForCurrentUser`): a list has no single `dbObject`, so the gate
33
+ * uses `S_USER` and ownership is scoped by the server-computed `createdBy`
34
+ * filterQuery plus the model's `securityCheck`.
26
35
  */
27
36
  @Injectable()
28
37
  export class CoreAiConversationService extends CrudService<
@@ -37,10 +46,107 @@ export class CoreAiConversationService extends CrudService<
37
46
  @InjectModel(AI_CONVERSATION_MODEL) protected override readonly mainDbModel: Model<AiConversationDocument>,
38
47
  @Inject(AI_CONVERSATION_CLASS)
39
48
  protected override readonly mainModelConstructor: CoreModelConstructor<CoreAiConversation>,
49
+ @InjectConnection() protected readonly connection: Connection,
40
50
  ) {
41
51
  super();
42
52
  }
43
53
 
54
+ /**
55
+ * List the AI conversations visible to the current user. By default — for
56
+ * every user, admins included — this returns only the caller's own
57
+ * conversations, newest first. An admin may opt in to the cross-user view
58
+ * (every user's conversations) by passing `{ all: true }`; the flag is
59
+ * ignored for non-admins, so it can never widen a regular user's scope.
60
+ *
61
+ * Each result carries its `createdBy` owner id. In the admin cross-user view
62
+ * (`all: true`) each result additionally carries a resolved `createdByUser`
63
+ * (`{ id, email, firstName, lastName, username }`) so the list is attributable
64
+ * to named users; on a normal own-only fetch that lookup is skipped, since the
65
+ * owner is the caller. The heavy `messages` subdocument array is excluded from
66
+ * the list payload — fetch a single conversation via `get()` for the full
67
+ * message history.
68
+ *
69
+ * Shared by the REST controller (`GET /ai/conversations`) and the GraphQL
70
+ * resolver (`findAiConversations`) so both API surfaces enforce ownership
71
+ * identically and cannot drift apart.
72
+ *
73
+ * A LIST operation has no single `dbObject`, so the per-document roles
74
+ * `S_CREATOR` / `S_SELF` can never be satisfied at the operation level —
75
+ * passing them rejected every non-admin with a 403 before ownership scoping
76
+ * ran. The operation gate therefore uses `[S_USER]`; the server-computed
77
+ * `createdBy` filterQuery and the model `securityCheck` scope the result to
78
+ * the owner.
79
+ */
80
+ async findForCurrentUser(
81
+ serviceOptions?: ServiceOptions,
82
+ options?: { all?: boolean },
83
+ ): Promise<CoreAiConversation[]> {
84
+ const currentUser = serviceOptions?.currentUser;
85
+ const isAdmin = !!currentUser?.roles?.includes(RoleEnum.ADMIN);
86
+ // Default is own-only for everyone; only an admin may opt in to all users' conversations.
87
+ const seeAll = isAdmin && options?.all === true;
88
+ const filterQuery = seeAll ? {} : { createdBy: currentUser?.id };
89
+ const conversations = await this.find(
90
+ { filterQuery, queryOptions: { sort: { createdAt: -1 } } },
91
+ { ...serviceOptions, roles: [RoleEnum.S_USER], select: '-messages' },
92
+ );
93
+ // Resolve creators to named users ONLY for the admin cross-user view — a normal
94
+ // fetch returns just the caller's own conversations, so the owner is already known.
95
+ if (seeAll && conversations.length) {
96
+ await this.attachCreators(conversations);
97
+ }
98
+ return conversations;
99
+ }
100
+
101
+ /**
102
+ * Attach a lightweight resolved creator (`{ id, email, firstName, lastName,
103
+ * username }`) as `createdByUser` to each conversation, so an admin's
104
+ * cross-user list is attributable to named users. Read-only lookup of
105
+ * non-sensitive identity fields via the shared `User` model on the same
106
+ * connection; fails soft (leaves `createdByUser` unset) when no `User` model is
107
+ * registered. Never called on a normal own-only fetch.
108
+ */
109
+ protected async attachCreators(conversations: CoreAiConversation[]): Promise<void> {
110
+ const ids = [
111
+ ...new Set(
112
+ conversations
113
+ .map((conversation) => conversation.createdBy)
114
+ .filter(Boolean)
115
+ .map(String),
116
+ ),
117
+ ];
118
+ if (!ids.length) {
119
+ return;
120
+ }
121
+ let userModel: Model<any>;
122
+ try {
123
+ userModel = this.connection.model('User');
124
+ } catch {
125
+ // No `User` model registered on this connection (exotic setup) — skip attribution.
126
+ return;
127
+ }
128
+ const users = await userModel
129
+ .find({ _id: { $in: ids } })
130
+ .select('email firstName lastName username')
131
+ .lean()
132
+ .exec();
133
+ const byId = new Map(
134
+ (users as any[]).map((user) => [
135
+ String(user._id),
136
+ {
137
+ email: user.email,
138
+ firstName: user.firstName,
139
+ id: String(user._id),
140
+ lastName: user.lastName,
141
+ username: user.username,
142
+ },
143
+ ]),
144
+ );
145
+ for (const conversation of conversations) {
146
+ conversation.createdByUser = byId.get(String(conversation.createdBy));
147
+ }
148
+ }
149
+
44
150
  /**
45
151
  * Append a message to a conversation via `$push` (system-internal; the user
46
152
  * already authorized the prompt that produced it). The array is capped at