@memberjunction/server 5.32.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.
- package/README.md +2 -1
- package/dist/agents/skip-agent.d.ts +3 -1
- package/dist/agents/skip-agent.d.ts.map +1 -1
- package/dist/agents/skip-agent.js +15 -3
- package/dist/agents/skip-agent.js.map +1 -1
- package/dist/auth/newUsers.d.ts.map +1 -1
- package/dist/auth/newUsers.js +11 -3
- package/dist/auth/newUsers.js.map +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +23 -3
- package/dist/context.js.map +1 -1
- package/dist/generated/generated.d.ts +551 -5
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +3277 -120
- package/dist/generated/generated.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +1 -1
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +9 -2
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/generic/RunViewResolver.d.ts +5 -1
- package/dist/generic/RunViewResolver.d.ts.map +1 -1
- package/dist/generic/RunViewResolver.js +33 -2
- package/dist/generic/RunViewResolver.js.map +1 -1
- package/dist/index.d.ts +12 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +29 -9
- package/dist/index.js.map +1 -1
- package/dist/resolvers/AvailableSearchProvidersResolver.d.ts +26 -0
- package/dist/resolvers/AvailableSearchProvidersResolver.d.ts.map +1 -0
- package/dist/resolvers/AvailableSearchProvidersResolver.js +65 -0
- package/dist/resolvers/AvailableSearchProvidersResolver.js.map +1 -0
- package/dist/resolvers/ComponentRegistryResolver.d.ts +11 -25
- package/dist/resolvers/ComponentRegistryResolver.d.ts.map +1 -1
- package/dist/resolvers/ComponentRegistryResolver.js +51 -93
- package/dist/resolvers/ComponentRegistryResolver.js.map +1 -1
- package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
- package/dist/resolvers/GetDataResolver.js +5 -1
- package/dist/resolvers/GetDataResolver.js.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.js +9 -4
- package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.d.ts +6 -0
- package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.js +31 -1
- package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
- package/dist/resolvers/SearchKnowledgeResolver.d.ts +44 -1
- package/dist/resolvers/SearchKnowledgeResolver.d.ts.map +1 -1
- package/dist/resolvers/SearchKnowledgeResolver.js +217 -7
- package/dist/resolvers/SearchKnowledgeResolver.js.map +1 -1
- package/dist/resolvers/SearchKnowledgeStreamResolver.d.ts +79 -0
- package/dist/resolvers/SearchKnowledgeStreamResolver.d.ts.map +1 -0
- package/dist/resolvers/SearchKnowledgeStreamResolver.js +342 -0
- package/dist/resolvers/SearchKnowledgeStreamResolver.js.map +1 -0
- package/dist/types.d.ts +6 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +68 -68
- package/src/__tests__/databaseAbstraction.test.ts +123 -61
- package/src/agents/skip-agent.ts +18 -4
- package/src/auth/newUsers.ts +12 -3
- package/src/context.ts +21 -3
- package/src/generated/generated.ts +2229 -80
- package/src/generic/ResolverBase.ts +11 -2
- package/src/generic/RunViewResolver.ts +29 -2
- package/src/index.ts +29 -9
- package/src/resolvers/AvailableSearchProvidersResolver.ts +43 -0
- package/src/resolvers/ComponentRegistryResolver.ts +71 -123
- package/src/resolvers/GetDataResolver.ts +6 -2
- package/src/resolvers/IntegrationDiscoveryResolver.ts +10 -4
- package/src/resolvers/QuerySystemUserResolver.ts +27 -1
- package/src/resolvers/SearchKnowledgeResolver.ts +205 -4
- package/src/resolvers/SearchKnowledgeStreamResolver.ts +338 -0
- 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;
|