@memberjunction/server 5.33.0 → 5.34.0

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 (56) hide show
  1. package/README.md +2 -1
  2. package/dist/agents/skip-agent.d.ts +3 -1
  3. package/dist/agents/skip-agent.d.ts.map +1 -1
  4. package/dist/agents/skip-agent.js +15 -3
  5. package/dist/agents/skip-agent.js.map +1 -1
  6. package/dist/generated/generated.d.ts +551 -5
  7. package/dist/generated/generated.d.ts.map +1 -1
  8. package/dist/generated/generated.js +3277 -120
  9. package/dist/generated/generated.js.map +1 -1
  10. package/dist/generic/ResolverBase.d.ts +1 -1
  11. package/dist/generic/ResolverBase.d.ts.map +1 -1
  12. package/dist/generic/ResolverBase.js +9 -2
  13. package/dist/generic/ResolverBase.js.map +1 -1
  14. package/dist/generic/RunViewResolver.d.ts +5 -1
  15. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  16. package/dist/generic/RunViewResolver.js +33 -2
  17. package/dist/generic/RunViewResolver.js.map +1 -1
  18. package/dist/index.d.ts +2 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +17 -2
  21. package/dist/index.js.map +1 -1
  22. package/dist/resolvers/AvailableSearchProvidersResolver.d.ts +26 -0
  23. package/dist/resolvers/AvailableSearchProvidersResolver.d.ts.map +1 -0
  24. package/dist/resolvers/AvailableSearchProvidersResolver.js +65 -0
  25. package/dist/resolvers/AvailableSearchProvidersResolver.js.map +1 -0
  26. package/dist/resolvers/ComponentRegistryResolver.d.ts +11 -25
  27. package/dist/resolvers/ComponentRegistryResolver.d.ts.map +1 -1
  28. package/dist/resolvers/ComponentRegistryResolver.js +51 -93
  29. package/dist/resolvers/ComponentRegistryResolver.js.map +1 -1
  30. package/dist/resolvers/QuerySystemUserResolver.d.ts +6 -0
  31. package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
  32. package/dist/resolvers/QuerySystemUserResolver.js +31 -1
  33. package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
  34. package/dist/resolvers/SearchKnowledgeResolver.d.ts +44 -1
  35. package/dist/resolvers/SearchKnowledgeResolver.d.ts.map +1 -1
  36. package/dist/resolvers/SearchKnowledgeResolver.js +217 -7
  37. package/dist/resolvers/SearchKnowledgeResolver.js.map +1 -1
  38. package/dist/resolvers/SearchKnowledgeStreamResolver.d.ts +79 -0
  39. package/dist/resolvers/SearchKnowledgeStreamResolver.d.ts.map +1 -0
  40. package/dist/resolvers/SearchKnowledgeStreamResolver.js +342 -0
  41. package/dist/resolvers/SearchKnowledgeStreamResolver.js.map +1 -0
  42. package/dist/types.d.ts +6 -1
  43. package/dist/types.d.ts.map +1 -1
  44. package/dist/types.js.map +1 -1
  45. package/package.json +68 -68
  46. package/src/agents/skip-agent.ts +18 -4
  47. package/src/generated/generated.ts +2229 -80
  48. package/src/generic/ResolverBase.ts +11 -2
  49. package/src/generic/RunViewResolver.ts +29 -2
  50. package/src/index.ts +17 -2
  51. package/src/resolvers/AvailableSearchProvidersResolver.ts +43 -0
  52. package/src/resolvers/ComponentRegistryResolver.ts +71 -123
  53. package/src/resolvers/QuerySystemUserResolver.ts +27 -1
  54. package/src/resolvers/SearchKnowledgeResolver.ts +205 -4
  55. package/src/resolvers/SearchKnowledgeStreamResolver.ts +338 -0
  56. package/src/types.ts +6 -1
@@ -1,8 +1,11 @@
1
- import { Resolver, Mutation, Arg, Ctx, ObjectType, Field, Float, InputType } from 'type-graphql';
1
+ import { Resolver, Mutation, Query, Arg, Ctx, ObjectType, Field, Float, InputType, ID } from 'type-graphql';
2
+ import { GraphQLJSON } from 'graphql-type-json';
2
3
  import { AppContext } from '../types.js';
3
- import { LogError, LogStatus } from '@memberjunction/core';
4
+ import { LogError, LogStatus, Metadata, RunView, UserInfo } from '@memberjunction/core';
4
5
  import { ResolverBase } from '../generic/ResolverBase.js';
5
- import { SearchEngine, SearchResult as SearchEngineResult, SearchResultItem as SearchEngineResultItem, SearchProviderInfo } from '@memberjunction/search-engine';
6
+ import { SearchEngine, SearchResult as SearchEngineResult, SearchResultItem as SearchEngineResultItem, SearchProviderInfo, SearchContext, SearchScopePermissionResolver } from '@memberjunction/search-engine';
7
+ import { SearchEngineBase, MJAIAgentEntity } from '@memberjunction/core-entities';
8
+ import { UUIDsEqual } from '@memberjunction/global';
6
9
 
7
10
  /* ───── GraphQL types ───── */
8
11
 
@@ -152,6 +155,46 @@ export class SearchFiltersInput {
152
155
  Tags?: string[];
153
156
  }
154
157
 
158
+ /** Runtime multi-tenant context passed through to `SearchEngine.Search()`. */
159
+ @InputType()
160
+ export class SearchContextInput {
161
+ @Field({ nullable: true })
162
+ PrimaryScopeEntityID?: string;
163
+
164
+ @Field({ nullable: true })
165
+ PrimaryScopeRecordID?: string;
166
+
167
+ /** JSON-encoded `Record<string, SecondaryScopeValue>`. */
168
+ @Field(() => GraphQLJSON, { nullable: true })
169
+ SecondaryScopes?: unknown;
170
+ }
171
+
172
+ /** Lightweight metadata shape for the scope selector UI. */
173
+ @ObjectType()
174
+ export class SearchScopeInfo {
175
+ @Field(() => ID)
176
+ ID: string;
177
+
178
+ @Field()
179
+ Name: string;
180
+
181
+ @Field({ nullable: true })
182
+ Description?: string;
183
+
184
+ @Field({ nullable: true })
185
+ Icon?: string;
186
+
187
+ @Field()
188
+ IsGlobal: boolean;
189
+
190
+ @Field()
191
+ IsDefault: boolean;
192
+
193
+ /** True when the scope has an OwnerUserID — rendered as a personal scope in the UI. */
194
+ @Field()
195
+ IsPersonal: boolean;
196
+ }
197
+
155
198
  /* ───── Resolver (thin wrapper around SearchEngine) ───── */
156
199
 
157
200
  @Resolver()
@@ -163,6 +206,9 @@ export class SearchKnowledgeResolver extends ResolverBase {
163
206
  @Arg('maxResults', () => Float, { nullable: true }) maxResults: number | undefined,
164
207
  @Arg('filters', () => SearchFiltersInput, { nullable: true }) filters: SearchFiltersInput | undefined,
165
208
  @Arg('minScore', () => Float, { nullable: true }) minScore: number | undefined,
209
+ @Arg('scopeIDs', () => [ID], { nullable: true }) scopeIDs: string[] | undefined,
210
+ @Arg('searchContext', () => SearchContextInput, { nullable: true }) searchContext: SearchContextInput | undefined,
211
+ @Arg('agentID', () => ID, { nullable: true }) agentID: string | undefined,
166
212
  @Ctx() { userPayload }: AppContext = {} as AppContext
167
213
  ): Promise<SearchKnowledgeResult> {
168
214
  const startTime = Date.now();
@@ -172,15 +218,49 @@ export class SearchKnowledgeResolver extends ResolverBase {
172
218
  return this.errorResult('Unable to determine current user', startTime);
173
219
  }
174
220
 
221
+ // Phase 2A enforcement: every scope the caller wants to use must
222
+ // resolve to an Allowed permission for this user (and optionally
223
+ // this agent). Reject the whole call on the first denial — we
224
+ // don't silently drop forbidden scopes because that masks bugs
225
+ // in scope authoring and surprises agents.
226
+ if (scopeIDs && scopeIDs.length) {
227
+ const denied = await this.rejectForbiddenScopes(scopeIDs, currentUser, agentID);
228
+ if (denied) {
229
+ // Emit a Status='Forbidden' SearchExecutionLog row so the
230
+ // analytics dashboard surfaces denied attempts. Best-effort
231
+ // — failures are swallowed by the helper.
232
+ await SearchEngine.Instance.LogForbiddenSearch({
233
+ Query: query,
234
+ ScopeIDs: scopeIDs,
235
+ FailureReason: denied,
236
+ StartTime: startTime,
237
+ ContextUser: currentUser,
238
+ AIAgentID: agentID ?? null,
239
+ });
240
+ return this.errorResult(denied, startTime);
241
+ }
242
+ }
243
+
244
+ const mappedContext: SearchContext | undefined = searchContext
245
+ ? {
246
+ PrimaryScopeEntityID: searchContext.PrimaryScopeEntityID,
247
+ PrimaryScopeRecordID: searchContext.PrimaryScopeRecordID,
248
+ SecondaryScopes: searchContext.SecondaryScopes as SearchContext['SecondaryScopes']
249
+ }
250
+ : undefined;
251
+
175
252
  const result = await SearchEngine.Instance.Search({
176
253
  Query: query,
177
254
  MaxResults: maxResults,
178
255
  MinScore: minScore,
256
+ ScopeIDs: scopeIDs && scopeIDs.length ? scopeIDs : undefined,
257
+ SearchContext: mappedContext,
179
258
  Filters: filters ? {
180
259
  EntityNames: filters.EntityNames,
181
260
  SourceTypes: filters.SourceTypes,
182
261
  Tags: filters.Tags
183
- } : undefined
262
+ } : undefined,
263
+ AIAgentID: agentID ?? null,
184
264
  }, currentUser);
185
265
 
186
266
  return this.mapSearchResult(result);
@@ -191,6 +271,127 @@ export class SearchKnowledgeResolver extends ResolverBase {
191
271
  }
192
272
  }
193
273
 
274
+ /**
275
+ * Returns a rejection message if any of the requested scopes are not
276
+ * accessible to (user, agent), or undefined if all are permitted.
277
+ * Resolution goes through SearchScopePermissionResolver — see its docs
278
+ * for the rule order.
279
+ */
280
+ private async rejectForbiddenScopes(
281
+ scopeIDs: string[],
282
+ user: UserInfo,
283
+ agentID: string | undefined,
284
+ ): Promise<string | undefined> {
285
+ const agent = await this.loadAgent(agentID, user);
286
+ const resolver = new SearchScopePermissionResolver();
287
+ for (const scopeID of scopeIDs) {
288
+ const verdict = await resolver.ResolveEffectivePermission({
289
+ User: user,
290
+ SearchScopeID: scopeID,
291
+ Agent: agent,
292
+ ContextUser: user,
293
+ });
294
+ if (!verdict.Allowed) {
295
+ LogStatus(`SearchKnowledge denied: ${verdict.Reason} (scope=${scopeID}, source=${verdict.Source})`);
296
+ return `Forbidden: ${verdict.Reason}`;
297
+ }
298
+ // Read level grants metadata visibility but not the right to run a
299
+ // search. The SearchScopes query (scope-listing) accepts Read; the
300
+ // SearchKnowledge mutation (actual search) does not.
301
+ if (verdict.Level === 'Read') {
302
+ const reason = `User '${user.Name}' has Read-level access on this scope, which permits metadata visibility but not search execution. Search or Manage is required to run a query.`;
303
+ LogStatus(`SearchKnowledge denied: ${reason} (scope=${scopeID}, source=${verdict.Source})`);
304
+ return `Forbidden: ${reason}`;
305
+ }
306
+ }
307
+ return undefined;
308
+ }
309
+
310
+ /**
311
+ * Loads the AIAgent record by ID for the agent-fallback path. Returns
312
+ * null if no agentID was supplied, or if the lookup fails (which we
313
+ * treat as "no agent context" rather than as an error so a malformed
314
+ * input can't escalate privilege).
315
+ */
316
+ private async loadAgent(agentID: string | undefined, contextUser: UserInfo): Promise<MJAIAgentEntity | null> {
317
+ if (!agentID) return null;
318
+ try {
319
+ const md = new Metadata(); // global-provider-ok: ResolverBase has no bound IMetadataProvider; contextUser is the per-request scope
320
+ const agent = await md.GetEntityObject<MJAIAgentEntity>('MJ: AI Agents', contextUser);
321
+ const loaded = await agent.Load(agentID);
322
+ return loaded ? agent : null;
323
+ } catch (err) {
324
+ LogError(`SearchKnowledge: failed to load agent ${agentID}: ${err instanceof Error ? err.message : String(err)}`);
325
+ return null;
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Returns the list of search scopes the current user can see and use.
331
+ *
332
+ * Phase 2A: filters the active scope list through
333
+ * SearchScopePermissionResolver — only scopes that resolve to Allowed
334
+ * (Read or higher) for the caller appear in the response. Personal
335
+ * scopes owned by the caller bypass the resolver because ownership is
336
+ * itself an implicit Manage grant.
337
+ */
338
+ @Query(() => [SearchScopeInfo])
339
+ async SearchScopes(
340
+ @Arg('agentID', () => ID, { nullable: true }) agentID: string | undefined,
341
+ @Ctx() { userPayload }: AppContext = {} as AppContext
342
+ ): Promise<SearchScopeInfo[]> {
343
+ try {
344
+ const currentUser = this.GetUserFromPayload(userPayload);
345
+ if (!currentUser) return [];
346
+
347
+ await SearchEngineBase.Instance.Config(false, currentUser);
348
+ const scopes = SearchEngineBase.Instance.ActiveScopes;
349
+ const userID = currentUser.ID;
350
+
351
+ const ownedOrUnowned = scopes.filter(s => !s.OwnerUserID || UUIDsEqual(s.OwnerUserID, userID));
352
+ const agent = await this.loadAgent(agentID, currentUser);
353
+ const resolver = new SearchScopePermissionResolver();
354
+
355
+ const visible: SearchScopeInfo[] = [];
356
+ for (const s of ownedOrUnowned) {
357
+ // Personal scopes owned by the caller are implicitly visible.
358
+ if (s.OwnerUserID && UUIDsEqual(s.OwnerUserID, userID)) {
359
+ visible.push(this.toScopeInfo(s));
360
+ continue;
361
+ }
362
+ // Otherwise, defer to the permission resolver. A scope with
363
+ // no permission rows AND no agent fallback is invisible to
364
+ // non-owners — this is the intended Phase 2A default.
365
+ const verdict = await resolver.ResolveEffectivePermission({
366
+ User: currentUser,
367
+ SearchScopeID: s.ID,
368
+ Agent: agent,
369
+ ContextUser: currentUser,
370
+ });
371
+ if (verdict.Allowed) {
372
+ visible.push(this.toScopeInfo(s));
373
+ }
374
+ }
375
+ return visible;
376
+ } catch (error) {
377
+ const msg = error instanceof Error ? error.message : String(error);
378
+ LogError(`SearchScopes query failed: ${msg}`);
379
+ return [];
380
+ }
381
+ }
382
+
383
+ private toScopeInfo(s: { ID: string; Name: string; Description?: string | null; Icon?: string | null; IsGlobal?: boolean; IsDefault?: boolean; OwnerUserID?: string | null }): SearchScopeInfo {
384
+ return {
385
+ ID: s.ID,
386
+ Name: s.Name,
387
+ Description: s.Description ?? undefined,
388
+ Icon: s.Icon ?? undefined,
389
+ IsGlobal: !!s.IsGlobal,
390
+ IsDefault: !!s.IsDefault,
391
+ IsPersonal: !!s.OwnerUserID,
392
+ };
393
+ }
394
+
194
395
  @Mutation(() => SearchKnowledgeResult)
195
396
  async PreviewSearch(
196
397
  @Arg('query') query: string,
@@ -0,0 +1,338 @@
1
+ /**
2
+ * Streaming variant of the SearchKnowledge mutation. Uses GraphQL
3
+ * subscriptions over the existing Apollo subscription transport (see
4
+ * `plans/search-scopes-rag-plus/streaming-mechanism-decision.md` for the
5
+ * decision rationale).
6
+ *
7
+ * Two-step protocol:
8
+ *
9
+ * 1. Caller invokes the `StreamScopedSearch` mutation with the same
10
+ * arguments as `SearchKnowledge`. The mutation returns a streamID
11
+ * (uuid) immediately and starts the search in the background.
12
+ * 2. Caller subscribes to `SearchStreamEvents(streamID: $id)`. The
13
+ * server publishes events from `SearchEngine.streamSearch()` to the
14
+ * pubsub topic; the subscription's filter narrows to the requested
15
+ * streamID. The final event has `Phase = 'final'` (or 'error');
16
+ * consumers should unsubscribe after receiving it.
17
+ *
18
+ * Permission enforcement is identical to SearchKnowledge: each requested
19
+ * scopeID is run through SearchScopePermissionResolver before the engine
20
+ * is invoked. A denial publishes a single 'error' event and ends.
21
+ */
22
+ import { Resolver, Mutation, Arg, Ctx, ObjectType, Field, Float, ID, Subscription, Root, PubSub, PubSubEngine } from 'type-graphql';
23
+ import { v4 as uuidv4 } from 'uuid';
24
+ import { GraphQLJSON } from 'graphql-type-json';
25
+ import { AppContext } from '../types.js';
26
+ import { LogError, LogStatus, Metadata, UserInfo } from '@memberjunction/core';
27
+ import { ResolverBase } from '../generic/ResolverBase.js';
28
+ import {
29
+ SearchEngine,
30
+ SearchContext,
31
+ SearchScopePermissionResolver,
32
+ SearchStreamEvent,
33
+ } from '@memberjunction/search-engine';
34
+ import { MJAIAgentEntity } from '@memberjunction/core-entities';
35
+ import { UUIDsEqual } from '@memberjunction/global';
36
+ import { SearchKnowledgeResultItem, SearchScoreBreakdown, SearchSourceCounts } from './SearchKnowledgeResolver.js';
37
+
38
+ const SEARCH_STREAM_TOPIC = 'SEARCH_STREAM';
39
+
40
+ /**
41
+ * Wire format for search stream events. We do not use a discriminated
42
+ * union at the GraphQL level (type-graphql doesn't have first-class
43
+ * support for union outputs in a clean way) — instead we use a Phase
44
+ * discriminator and make the data fields nullable per phase.
45
+ */
46
+ @ObjectType()
47
+ export class SearchStreamNotification {
48
+ @Field(() => ID)
49
+ StreamID: string;
50
+
51
+ @Field()
52
+ Phase: string; // 'provider' | 'fused' | 'reranked' | 'final' | 'error'
53
+
54
+ /** Set on `provider` events. */
55
+ @Field({ nullable: true })
56
+ ProviderName?: string;
57
+
58
+ /** Set on `provider` events. */
59
+ @Field(() => Float, { nullable: true })
60
+ DurationMs?: number;
61
+
62
+ /** Set on `provider`, `fused`, `reranked`, `final` events. */
63
+ @Field(() => [SearchKnowledgeResultItem], { nullable: true })
64
+ Results?: SearchKnowledgeResultItem[];
65
+
66
+ /** Set on `final` events. */
67
+ @Field(() => SearchSourceCounts, { nullable: true })
68
+ SourceCounts?: SearchSourceCounts;
69
+
70
+ /** Set on `final` events. */
71
+ @Field(() => Float, { nullable: true })
72
+ ElapsedMs?: number;
73
+
74
+ /** Set on `error` events. */
75
+ @Field({ nullable: true })
76
+ ErrorMessage?: string;
77
+ }
78
+
79
+ @ObjectType()
80
+ export class SearchStreamStartResult {
81
+ @Field()
82
+ Success: boolean;
83
+
84
+ @Field(() => ID)
85
+ StreamID: string;
86
+
87
+ @Field({ nullable: true })
88
+ ErrorMessage?: string;
89
+ }
90
+
91
+ @Resolver()
92
+ export class SearchKnowledgeStreamResolver extends ResolverBase {
93
+ /**
94
+ * Subscribe to events from a running streamScopedSearch. Filter by
95
+ * streamID so multiple in-flight streams don't interleave on the same
96
+ * client.
97
+ */
98
+ @Subscription(() => SearchStreamNotification, {
99
+ topics: SEARCH_STREAM_TOPIC,
100
+ filter: ({ payload, args }: { payload: SearchStreamNotification; args: { streamID: string } }) =>
101
+ UUIDsEqual(payload.StreamID, args.streamID),
102
+ })
103
+ SearchStreamEvents(
104
+ @Root() notification: SearchStreamNotification,
105
+ @Arg('streamID', () => ID) _streamID: string,
106
+ ): SearchStreamNotification {
107
+ return notification;
108
+ }
109
+
110
+ /**
111
+ * Start a streaming search. Returns a streamID that the caller passes
112
+ * to the SearchStreamEvents subscription. Permission checks are
113
+ * performed before the stream begins; on rejection a single 'error'
114
+ * event is published and the stream ends.
115
+ */
116
+ @Mutation(() => SearchStreamStartResult)
117
+ async StreamScopedSearch(
118
+ @Arg('query') query: string,
119
+ @Arg('maxResults', () => Float, { nullable: true }) maxResults: number | undefined,
120
+ @Arg('minScore', () => Float, { nullable: true }) minScore: number | undefined,
121
+ @Arg('scopeIDs', () => [ID], { nullable: true }) scopeIDs: string[] | undefined,
122
+ @Arg('searchContext', () => GraphQLJSON, { nullable: true }) searchContext: unknown,
123
+ @Arg('agentID', () => ID, { nullable: true }) agentID: string | undefined,
124
+ @PubSub() pubSub: PubSubEngine,
125
+ @Ctx() { userPayload }: AppContext = {} as AppContext,
126
+ ): Promise<SearchStreamStartResult> {
127
+ const streamID = uuidv4();
128
+ const startTime = Date.now();
129
+ try {
130
+ const currentUser = this.GetUserFromPayload(userPayload);
131
+ if (!currentUser) {
132
+ return { Success: false, StreamID: streamID, ErrorMessage: 'Unable to determine current user' };
133
+ }
134
+
135
+ // Phase 2A enforcement: validate every requested scope before
136
+ // we kick the stream off. Same predicate / message as the
137
+ // synchronous resolver — single source of truth.
138
+ if (scopeIDs && scopeIDs.length) {
139
+ const denial = await this.rejectForbiddenScopes(scopeIDs, currentUser, agentID);
140
+ if (denial) {
141
+ // Mirror the synchronous resolver: surface denied attempts in
142
+ // the analytics dashboard so an admin tuning permissions can
143
+ // see them. Best-effort — failures are swallowed by the helper.
144
+ await SearchEngine.Instance.LogForbiddenSearch({
145
+ Query: query,
146
+ ScopeIDs: scopeIDs,
147
+ FailureReason: denial,
148
+ StartTime: startTime,
149
+ ContextUser: currentUser,
150
+ AIAgentID: agentID ?? null,
151
+ });
152
+ return { Success: false, StreamID: streamID, ErrorMessage: denial };
153
+ }
154
+ }
155
+
156
+ // Kick off the stream in the background and return immediately.
157
+ // The mutation resolves before any events are published — the
158
+ // client must subscribe first to avoid missing the early ones.
159
+ // (TODO Phase 2C v2: optionally buffer until first subscriber
160
+ // arrives; today's contract is "subscribe before mutation
161
+ // returns" which the SDK can enforce.)
162
+ void this.runStream(streamID, {
163
+ query,
164
+ maxResults,
165
+ minScore,
166
+ scopeIDs,
167
+ searchContext,
168
+ agentID,
169
+ }, currentUser, pubSub).catch(err => {
170
+ const msg = err instanceof Error ? err.message : String(err);
171
+ LogError(`StreamScopedSearch background failure: ${msg}`);
172
+ void pubSub.publish(SEARCH_STREAM_TOPIC, {
173
+ StreamID: streamID,
174
+ Phase: 'error',
175
+ ErrorMessage: msg,
176
+ } as SearchStreamNotification);
177
+ });
178
+
179
+ return { Success: true, StreamID: streamID };
180
+ } catch (err) {
181
+ const msg = err instanceof Error ? err.message : String(err);
182
+ LogError(`StreamScopedSearch start failed: ${msg}`);
183
+ return { Success: false, StreamID: streamID, ErrorMessage: msg };
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Runs the stream and publishes events. Always publishes a terminal
189
+ * 'final' or 'error' so clients can confidently unsubscribe.
190
+ */
191
+ private async runStream(
192
+ streamID: string,
193
+ params: {
194
+ query: string;
195
+ maxResults?: number;
196
+ minScore?: number;
197
+ scopeIDs?: string[];
198
+ searchContext: unknown;
199
+ agentID?: string;
200
+ },
201
+ currentUser: UserInfo,
202
+ pubSub: PubSubEngine,
203
+ ): Promise<void> {
204
+ const mappedContext: SearchContext | undefined = params.searchContext
205
+ ? params.searchContext as SearchContext
206
+ : undefined;
207
+ try {
208
+ for await (const ev of SearchEngine.Instance.streamSearch({
209
+ Query: params.query,
210
+ MaxResults: params.maxResults,
211
+ MinScore: params.minScore,
212
+ ScopeIDs: params.scopeIDs && params.scopeIDs.length ? params.scopeIDs : undefined,
213
+ SearchContext: mappedContext,
214
+ AIAgentID: params.agentID ?? null,
215
+ }, currentUser)) {
216
+ const notification = this.toNotification(streamID, ev);
217
+ await pubSub.publish(SEARCH_STREAM_TOPIC, notification);
218
+ }
219
+ LogStatus(`StreamScopedSearch ${streamID} completed`);
220
+ } catch (err) {
221
+ const msg = err instanceof Error ? err.message : String(err);
222
+ LogError(`StreamScopedSearch ${streamID} failed mid-stream: ${msg}`);
223
+ await pubSub.publish(SEARCH_STREAM_TOPIC, {
224
+ StreamID: streamID,
225
+ Phase: 'error',
226
+ ErrorMessage: msg,
227
+ } as SearchStreamNotification);
228
+ }
229
+ }
230
+
231
+ private toNotification(streamID: string, ev: SearchStreamEvent): SearchStreamNotification {
232
+ // Reuse the same item shape as the synchronous resolver.
233
+ const mapItem = (r: { ID: string; EntityName: string; RecordID: string; SourceType: string; ResultType: string; Title: string; Snippet: string; Score: number; ScoreBreakdown: SearchScoreBreakdown; Tags: string[]; EntityIcon?: string; RecordName?: string; MatchedAt: Date; RawMetadata?: string; ProviderId?: string; ProviderLabel?: string; ProviderIcon?: string }): SearchKnowledgeResultItem => ({
234
+ ID: r.ID,
235
+ EntityName: r.EntityName,
236
+ RecordID: r.RecordID,
237
+ SourceType: r.SourceType,
238
+ ResultType: r.ResultType,
239
+ Title: r.Title,
240
+ Snippet: r.Snippet,
241
+ Score: r.Score,
242
+ ScoreBreakdown: r.ScoreBreakdown,
243
+ Tags: r.Tags ?? [],
244
+ EntityIcon: r.EntityIcon,
245
+ RecordName: r.RecordName,
246
+ MatchedAt: r.MatchedAt,
247
+ RawMetadata: r.RawMetadata,
248
+ ProviderId: r.ProviderId,
249
+ ProviderLabel: r.ProviderLabel,
250
+ ProviderIcon: r.ProviderIcon,
251
+ });
252
+
253
+ switch (ev.phase) {
254
+ case 'provider':
255
+ return {
256
+ StreamID: streamID,
257
+ Phase: 'provider',
258
+ ProviderName: ev.providerName,
259
+ DurationMs: ev.durationMs,
260
+ Results: ev.results.map(mapItem),
261
+ };
262
+ case 'fused':
263
+ return {
264
+ StreamID: streamID,
265
+ Phase: 'fused',
266
+ Results: ev.results.map(mapItem),
267
+ };
268
+ case 'reranked':
269
+ return {
270
+ StreamID: streamID,
271
+ Phase: 'reranked',
272
+ ProviderName: ev.rerankerName,
273
+ Results: ev.results.map(mapItem),
274
+ };
275
+ case 'final':
276
+ return {
277
+ StreamID: streamID,
278
+ Phase: 'final',
279
+ Results: ev.results.map(mapItem),
280
+ SourceCounts: ev.sourceCounts,
281
+ ElapsedMs: ev.elapsedMs,
282
+ };
283
+ case 'error':
284
+ return {
285
+ StreamID: streamID,
286
+ Phase: 'error',
287
+ ProviderName: ev.providerName,
288
+ ErrorMessage: ev.error,
289
+ };
290
+ }
291
+ }
292
+
293
+ /** Mirrors the helper in SearchKnowledgeResolver — kept private to
294
+ * avoid an inter-resolver coupling. If a third resolver ever needs
295
+ * this, lift it into a shared util in `packages/SearchEngine`. */
296
+ private async rejectForbiddenScopes(
297
+ scopeIDs: string[],
298
+ user: UserInfo,
299
+ agentID: string | undefined,
300
+ ): Promise<string | undefined> {
301
+ const agent = await this.loadAgent(agentID, user);
302
+ const resolver = new SearchScopePermissionResolver();
303
+ for (const scopeID of scopeIDs) {
304
+ const verdict = await resolver.ResolveEffectivePermission({
305
+ User: user,
306
+ SearchScopeID: scopeID,
307
+ Agent: agent,
308
+ ContextUser: user,
309
+ });
310
+ if (!verdict.Allowed) {
311
+ LogStatus(`StreamScopedSearch denied: ${verdict.Reason} (scope=${scopeID}, source=${verdict.Source})`);
312
+ return `Forbidden: ${verdict.Reason}`;
313
+ }
314
+ // Read level grants metadata visibility but not the right to run a
315
+ // search. Mirror the SearchKnowledge resolver's gate so the streaming
316
+ // path enforces the same Read=metadata-only rule.
317
+ if (verdict.Level === 'Read') {
318
+ const reason = `User '${user.Name}' has Read-level access on this scope, which permits metadata visibility but not search execution. Search or Manage is required to run a query.`;
319
+ LogStatus(`StreamScopedSearch denied: ${reason} (scope=${scopeID}, source=${verdict.Source})`);
320
+ return `Forbidden: ${reason}`;
321
+ }
322
+ }
323
+ return undefined;
324
+ }
325
+
326
+ private async loadAgent(agentID: string | undefined, contextUser: UserInfo): Promise<MJAIAgentEntity | null> {
327
+ if (!agentID) return null;
328
+ try {
329
+ const md = new Metadata(); // global-provider-ok: ResolverBase has no bound IMetadataProvider; contextUser is the per-request scope
330
+ const agent = await md.GetEntityObject<MJAIAgentEntity>('MJ: AI Agents', contextUser);
331
+ const loaded = await agent.Load(agentID);
332
+ return loaded ? agent : null;
333
+ } catch (err) {
334
+ LogError(`StreamScopedSearch: failed to load agent ${agentID}: ${err instanceof Error ? err.message : String(err)}`);
335
+ return null;
336
+ }
337
+ }
338
+ }
package/src/types.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { AggregateExpression, DatabaseProviderBase, UserInfo } from '@memberjunction/core';
1
+ import { AggregateExpression, CompositeKey, DatabaseProviderBase, UserInfo } from '@memberjunction/core';
2
2
  import { MJUserViewEntityExtended } from '@memberjunction/core-entities';
3
3
  import { GraphQLSchema } from 'graphql';
4
4
  import sql from 'mssql';
@@ -90,6 +90,11 @@ export type RunViewGenericParams = {
90
90
  ignoreMaxRows?: boolean;
91
91
  maxRows?: number;
92
92
  startRow?: number;
93
+ /**
94
+ * Keyset (seek) pagination cursor — see {@link RunViewParams.AfterKey}.
95
+ * When set, the entity must have a single-column PK; throws AfterKeyNotSupportedError otherwise.
96
+ */
97
+ afterKey?: CompositeKey;
93
98
  excludeDataFromAllPriorViewRuns?: boolean;
94
99
  forceAuditLog?: boolean;
95
100
  auditLogDescription?: string;