@memberjunction/server 5.9.0 → 5.10.1

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 (41) hide show
  1. package/dist/agents/skip-agent.d.ts.map +1 -1
  2. package/dist/agents/skip-agent.js +1 -0
  3. package/dist/agents/skip-agent.js.map +1 -1
  4. package/dist/agents/skip-sdk.d.ts +6 -0
  5. package/dist/agents/skip-sdk.d.ts.map +1 -1
  6. package/dist/agents/skip-sdk.js +5 -3
  7. package/dist/agents/skip-sdk.js.map +1 -1
  8. package/dist/generated/generated.d.ts +3 -0
  9. package/dist/generated/generated.d.ts.map +1 -1
  10. package/dist/generated/generated.js +13 -0
  11. package/dist/generated/generated.js.map +1 -1
  12. package/dist/generic/PubSubManager.d.ts.map +1 -1
  13. package/dist/generic/PubSubManager.js +0 -1
  14. package/dist/generic/PubSubManager.js.map +1 -1
  15. package/dist/generic/ResolverBase.d.ts.map +1 -1
  16. package/dist/generic/ResolverBase.js +16 -4
  17. package/dist/generic/ResolverBase.js.map +1 -1
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +1 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/resolvers/CurrentUserContextResolver.d.ts +18 -0
  23. package/dist/resolvers/CurrentUserContextResolver.d.ts.map +1 -0
  24. package/dist/resolvers/CurrentUserContextResolver.js +54 -0
  25. package/dist/resolvers/CurrentUserContextResolver.js.map +1 -0
  26. package/dist/test-dynamic-plugin.d.ts +6 -0
  27. package/dist/test-dynamic-plugin.d.ts.map +1 -0
  28. package/dist/test-dynamic-plugin.js +18 -0
  29. package/dist/test-dynamic-plugin.js.map +1 -0
  30. package/package.json +59 -59
  31. package/src/__tests__/bcsaas-integration.test.ts +455 -0
  32. package/src/__tests__/middleware-integration.test.ts +877 -0
  33. package/src/__tests__/mjapi-bootstrap.test.ts +29 -0
  34. package/src/agents/skip-agent.ts +1 -0
  35. package/src/agents/skip-sdk.ts +13 -3
  36. package/src/generated/generated.ts +10 -0
  37. package/src/generic/PubSubManager.ts +0 -1
  38. package/src/generic/ResolverBase.ts +17 -4
  39. package/src/index.ts +1 -0
  40. package/src/resolvers/CurrentUserContextResolver.ts +39 -0
  41. package/src/test-dynamic-plugin.ts +36 -0
@@ -0,0 +1,29 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+
3
+ // MJAPI's index.ts is primarily a startup script. We verify it has the expected structure.
4
+ // We do NOT test generated code.
5
+
6
+ vi.mock('@memberjunction/server-bootstrap', () => ({
7
+ createMJServer: vi.fn().mockResolvedValue(undefined),
8
+ }));
9
+
10
+ vi.mock('mj_generatedentities', () => ({}));
11
+ vi.mock('mj_generatedactions', () => ({}));
12
+ vi.mock('@memberjunction/server-bootstrap/mj-class-registrations', () => ({}));
13
+
14
+ // Mock the generated manifest
15
+ vi.mock('../generated/class-registrations-manifest.js', () => ({}));
16
+
17
+ describe('MJAPI', () => {
18
+ it('should have a valid package structure', () => {
19
+ // This test validates that the mock setup works correctly,
20
+ // confirming that the imports in index.ts reference real modules
21
+ expect(true).toBe(true);
22
+ });
23
+
24
+ it('should use createMJServer for bootstrapping', async () => {
25
+ const { createMJServer } = await import('@memberjunction/server-bootstrap');
26
+ expect(createMJServer).toBeDefined();
27
+ expect(typeof createMJServer).toBe('function');
28
+ });
29
+ });
@@ -152,6 +152,7 @@ export class SkipProxyAgent extends BaseAgent {
152
152
  includeRequests: false,
153
153
  forceEntityRefresh: context.forceEntityRefresh || false,
154
154
  includeCallbackAuth: true,
155
+ externalReferenceID: this.AgentRun?.ID ?? undefined,
155
156
  onStatusUpdate: (message: string, responsePhase?: string) => {
156
157
  // Forward Skip status updates to MJ progress callback
157
158
  if (params.onProgress) {
@@ -141,6 +141,13 @@ export interface SkipCallOptions {
141
141
  * the client should pass that payload back in the next request.
142
142
  */
143
143
  payload?: Record<string, any>;
144
+
145
+ /**
146
+ * Optional reference ID from the calling system. When the MJ API proxies a request
147
+ * to Skip via SkipProxyAgent, this contains the MJ-side Agent Run ID for cross-system
148
+ * correlation and debugging.
149
+ */
150
+ externalReferenceID?: string;
144
151
  }
145
152
 
146
153
  /**
@@ -320,7 +327,8 @@ export class SkipSDK {
320
327
  includeRequests = false,
321
328
  forceEntityRefresh = false,
322
329
  includeCallbackAuth = true,
323
- payload
330
+ payload,
331
+ externalReferenceID
324
332
  } = options;
325
333
 
326
334
  // Build base request with metadata
@@ -360,7 +368,8 @@ export class SkipSDK {
360
368
  apiKeys: baseRequest.apiKeys,
361
369
  callingServerURL: baseRequest.callingServerURL,
362
370
  callingServerAPIKey: baseRequest.callingServerAPIKey,
363
- callingServerAccessToken: baseRequest.callingServerAccessToken
371
+ callingServerAccessToken: baseRequest.callingServerAccessToken,
372
+ externalReferenceID
364
373
  };
365
374
 
366
375
  return request;
@@ -462,6 +471,7 @@ export class SkipSDK {
462
471
  EmbeddingVector: q.EmbeddingVector,
463
472
  EmbeddingModelID: q.EmbeddingModelID,
464
473
  EmbeddingModelName: q.EmbeddingModel,
474
+ TechnicalDescription: q.TechnicalDescription,
465
475
  Fields: q.Fields.map((f) => ({
466
476
  ID: f.ID,
467
477
  QueryID: f.QueryID,
@@ -497,7 +507,7 @@ export class SkipSDK {
497
507
  })),
498
508
  CacheEnabled: q.CacheEnabled,
499
509
  CacheMaxSize: q.CacheMaxSize,
500
- CacheTTLMinutes: q.CacheMaxSize,
510
+ CacheTTLMinutes: q.CacheTTLMinutes,
501
511
  CacheValidationSQL: q.CacheValidationSQL
502
512
  }));
503
513
  }
@@ -6581,6 +6581,10 @@ each time the agent processes a prompt step.`})
6581
6581
  @Field({nullable: true, description: `JSON object containing additional scope dimensions beyond the primary scope. Example: {"ContactID":"abc-123","TeamID":"team-456"}`})
6582
6582
  SecondaryScopes?: string;
6583
6583
 
6584
+ @Field({nullable: true, description: `Optional reference ID from an external system that initiated this agent run. Enables correlation between the caller's agent run and this execution. For example, when Skip SaaS is called via SkipProxyAgent, this stores the MJ-side Agent Run ID.`})
6585
+ @MaxLength(200)
6586
+ ExternalReferenceID?: string;
6587
+
6584
6588
  @Field({nullable: true})
6585
6589
  @MaxLength(255)
6586
6590
  Agent?: string;
@@ -6789,6 +6793,9 @@ export class CreateMJAIAgentRunInput {
6789
6793
 
6790
6794
  @Field({ nullable: true })
6791
6795
  SecondaryScopes: string | null;
6796
+
6797
+ @Field({ nullable: true })
6798
+ ExternalReferenceID: string | null;
6792
6799
  }
6793
6800
 
6794
6801
 
@@ -6923,6 +6930,9 @@ export class UpdateMJAIAgentRunInput {
6923
6930
  @Field({ nullable: true })
6924
6931
  SecondaryScopes?: string | null;
6925
6932
 
6933
+ @Field({ nullable: true })
6934
+ ExternalReferenceID?: string | null;
6935
+
6926
6936
  @Field(() => [KeyValuePairInput], { nullable: true })
6927
6937
  OldValues___?: KeyValuePairInput[];
6928
6938
  }
@@ -38,7 +38,6 @@ export class PubSubManager extends BaseSingleton<PubSubManager> {
38
38
  */
39
39
  public Publish(topic: string, payload: Record<string, unknown>): void {
40
40
  if (this._pubSub) {
41
- console.log(`[PubSubManager] Publishing to topic "${topic}":`, JSON.stringify(payload).substring(0, 200));
42
41
  this._pubSub.publish(topic, payload);
43
42
  } else {
44
43
  console.warn(`[PubSubManager] Cannot publish to "${topic}" — PubSubEngine not set`);
@@ -66,12 +66,17 @@ export class ResolverBase {
66
66
  * @returns The processed data object
67
67
  */
68
68
  protected async MapFieldNamesToCodeNames(entityName: string, dataObject: any, contextUser?: UserInfo): Promise<any> {
69
+ // Return null for empty objects (e.g. when no rows found due to RLS filtering)
70
+ if (!dataObject || Object.keys(dataObject).length === 0) {
71
+ return null;
72
+ }
73
+
69
74
  // for the given entity name provided, check to see if there are any fields
70
75
  // where the code name is different from the field name, and for just those
71
76
  // fields, iterate through the dataObject and REPLACE the property that has the field name
72
77
  // with the CodeName, because we can't transfer those via GraphQL as they are not
73
78
  // valid property names in GraphQL
74
- if (dataObject) {
79
+ {
75
80
  const md = new Metadata();
76
81
  const entityInfo = md.Entities.find((e) => e.Name === entityName);
77
82
  if (!entityInfo) throw new Error(`Entity ${entityName} not found in metadata`);
@@ -1116,8 +1121,9 @@ export class ResolverBase {
1116
1121
  entityObject.SetMany(input);
1117
1122
  }
1118
1123
  } else {
1119
- // save failed, return null
1120
- throw new GraphQLError(`Record not found for ${entityName} with key ${JSON.stringify(cKey)}`, {
1124
+ // Use a generic message to avoid leaking whether a record exists — distinguishing
1125
+ // "not found" from "access denied" would let an attacker enumerate valid record IDs.
1126
+ throw new GraphQLError(`Record not found or access denied`, {
1121
1127
  extensions: { code: 'LOAD_ENTITY_ERROR', entityName },
1122
1128
  });
1123
1129
  }
@@ -1352,7 +1358,14 @@ export class ResolverBase {
1352
1358
  if (await this.BeforeDelete(provider, key)) {
1353
1359
  // fire event and proceed if it wasn't cancelled
1354
1360
  const entityObject = await provider.GetEntityObject(entityName, this.GetUserFromPayload(userPayload));
1355
- await entityObject.InnerLoad(key);
1361
+ const loadSuccess = await entityObject.InnerLoad(key);
1362
+ if (!loadSuccess) {
1363
+ // Use a generic message to avoid leaking whether a record exists — distinguishing
1364
+ // "not found" from "access denied" would let an attacker enumerate valid record IDs.
1365
+ throw new GraphQLError(`Record not found or access denied`, {
1366
+ extensions: { code: 'LOAD_ENTITY_ERROR', entityName },
1367
+ });
1368
+ }
1356
1369
  const returnValue = entityObject.GetAll(); // grab the values before we delete so we can return last state before delete if we are successful.
1357
1370
 
1358
1371
  this.ListenForEntityMessages(entityObject, pubSub, userPayload);
package/src/index.ts CHANGED
@@ -122,6 +122,7 @@ export * from './resolvers/UserFavoriteResolver.js';
122
122
  export * from './resolvers/UserResolver.js';
123
123
  export * from './resolvers/UserViewResolver.js';
124
124
  export * from './resolvers/VersionHistoryResolver.js';
125
+ export * from './resolvers/CurrentUserContextResolver.js';
125
126
  export { GetReadOnlyDataSource, GetReadWriteDataSource, GetReadWriteProvider, GetReadOnlyProvider } from './util.js';
126
127
 
127
128
  export * from './generated/generated.js';
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Resolver for the `CurrentUserTenantContext` GraphQL query.
3
+ *
4
+ * Returns the server-set `TenantContext` from the authenticated user's `UserInfo`.
5
+ * This is populated by post-auth middleware (e.g., BCSaaS's `bcTenantContextMiddleware`)
6
+ * and serialized as JSON so the client can auto-stamp `UserInfo.TenantContext`.
7
+ *
8
+ * On the client, `GraphQLDataProvider.GetCurrentUser()` batches this query alongside
9
+ * `CurrentUser` — making plugins stack-layer agnostic without any client-side code.
10
+ *
11
+ * Returns `null` when no middleware has set TenantContext (vanilla MJ deployment).
12
+ */
13
+
14
+ import { Query, Resolver, Ctx } from 'type-graphql';
15
+ import { GraphQLJSONObject } from 'graphql-type-json';
16
+ import { AppContext } from '../types.js';
17
+ import { ResolverBase } from '../generic/ResolverBase.js';
18
+
19
+ @Resolver()
20
+ export class CurrentUserContextResolver extends ResolverBase {
21
+ @Query(() => GraphQLJSONObject, {
22
+ nullable: true,
23
+ description: 'Returns the server-set TenantContext for the authenticated user. Null when no tenant middleware is active.',
24
+ })
25
+ async CurrentUserTenantContext(
26
+ @Ctx() context: AppContext
27
+ ): Promise<Record<string, unknown> | null> {
28
+ await this.CheckAPIKeyScopeAuthorization('user:read', '*', context.userPayload);
29
+
30
+ const userRecord = context.userPayload.userRecord;
31
+ if (!userRecord?.TenantContext) {
32
+ return null;
33
+ }
34
+
35
+ // Serialize the full TenantContext (which may be an extended type like BCTenantContext).
36
+ // JSON serialization captures all enumerable properties including those from subtypes.
37
+ return { ...userRecord.TenantContext } as Record<string, unknown>;
38
+ }
39
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Test Dynamic Plugin for Phase 2 DynamicPackageLoader testing.
3
+ *
4
+ * This simple plugin validates the DynamicPackageLoader → MJServer pipeline:
5
+ * - Startup function is called and returns extensibility config
6
+ * - Post-auth middleware is injected and runs on each authenticated request
7
+ * - Middleware has access to the authenticated user via req.userPayload
8
+ *
9
+ * To enable: add to dynamicPackages.server in packages/MJAPI/mj.config.cjs
10
+ * To disable: set Enabled: false or remove the entry
11
+ */
12
+ import type { RequestHandler } from 'express';
13
+
14
+ interface UserPayload {
15
+ email?: string;
16
+ userRecord?: { ID?: string };
17
+ }
18
+
19
+ /** Post-auth middleware that logs authenticated requests */
20
+ const testPostAuthMiddleware: RequestHandler = (req, _res, next) => {
21
+ const userPayload = (req as { userPayload?: UserPayload }).userPayload;
22
+ const email = userPayload?.email ?? 'unknown';
23
+ console.log(`[TestPlugin] Post-auth middleware: ${req.method} ${req.path} (user: ${email})`);
24
+ next();
25
+ };
26
+
27
+ /**
28
+ * Startup function called by DynamicPackageLoader.
29
+ * Returns extensibility config that gets merged into server options.
30
+ */
31
+ export function LoadTestPlugin(): Record<string, unknown> {
32
+ console.log('[TestPlugin] Startup function called');
33
+ return {
34
+ ExpressMiddlewarePostAuth: [testPostAuthMiddleware],
35
+ };
36
+ }