@memberjunction/server 5.48.0 → 5.49.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 (117) hide show
  1. package/README.md +9 -0
  2. package/dist/auth/magicLink/MagicLinkKeys.d.ts.map +1 -1
  3. package/dist/auth/magicLink/MagicLinkKeys.js +11 -4
  4. package/dist/auth/magicLink/MagicLinkKeys.js.map +1 -1
  5. package/dist/auth/magicLink/MagicLinkRouter.js +2 -2
  6. package/dist/auth/magicLink/MagicLinkRouter.js.map +1 -1
  7. package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
  8. package/dist/auth/magicLink/MagicLinkService.js +4 -0
  9. package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
  10. package/dist/config.d.ts +40 -0
  11. package/dist/config.d.ts.map +1 -1
  12. package/dist/config.js +12 -0
  13. package/dist/config.js.map +1 -1
  14. package/dist/context.d.ts.map +1 -1
  15. package/dist/context.js +28 -2
  16. package/dist/context.js.map +1 -1
  17. package/dist/generated/generated.d.ts +122 -0
  18. package/dist/generated/generated.d.ts.map +1 -1
  19. package/dist/generated/generated.js +629 -0
  20. package/dist/generated/generated.js.map +1 -1
  21. package/dist/generic/ResolverBase.d.ts.map +1 -1
  22. package/dist/generic/ResolverBase.js +4 -2
  23. package/dist/generic/ResolverBase.js.map +1 -1
  24. package/dist/generic/RunViewResolver.d.ts +1 -0
  25. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  26. package/dist/generic/RunViewResolver.js +12 -0
  27. package/dist/generic/RunViewResolver.js.map +1 -1
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +73 -11
  30. package/dist/index.js.map +1 -1
  31. package/dist/integration/CustomColumnPromoter.d.ts +15 -2
  32. package/dist/integration/CustomColumnPromoter.d.ts.map +1 -1
  33. package/dist/integration/CustomColumnPromoter.js +113 -11
  34. package/dist/integration/CustomColumnPromoter.js.map +1 -1
  35. package/dist/integration/EntityMapLifecycle.d.ts +66 -0
  36. package/dist/integration/EntityMapLifecycle.d.ts.map +1 -0
  37. package/dist/integration/EntityMapLifecycle.js +189 -0
  38. package/dist/integration/EntityMapLifecycle.js.map +1 -0
  39. package/dist/realtimeProxy/RealtimeProxyServer.d.ts +10 -0
  40. package/dist/realtimeProxy/RealtimeProxyServer.d.ts.map +1 -1
  41. package/dist/realtimeProxy/RealtimeProxyServer.js +67 -1
  42. package/dist/realtimeProxy/RealtimeProxyServer.js.map +1 -1
  43. package/dist/realtimeWidget/WidgetRouter.d.ts.map +1 -1
  44. package/dist/realtimeWidget/WidgetRouter.js +6 -2
  45. package/dist/realtimeWidget/WidgetRouter.js.map +1 -1
  46. package/dist/resolvers/AdhocQueryResolver.d.ts +17 -0
  47. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  48. package/dist/resolvers/AdhocQueryResolver.js +81 -25
  49. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  50. package/dist/resolvers/GetDataResolver.d.ts +16 -0
  51. package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
  52. package/dist/resolvers/GetDataResolver.js +26 -1
  53. package/dist/resolvers/GetDataResolver.js.map +1 -1
  54. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +53 -1
  55. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  56. package/dist/resolvers/IntegrationDiscoveryResolver.js +522 -60
  57. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  58. package/dist/resolvers/QueryResolver.d.ts.map +1 -1
  59. package/dist/resolvers/QueryResolver.js +0 -6
  60. package/dist/resolvers/QueryResolver.js.map +1 -1
  61. package/dist/resolvers/RSUResolver.d.ts +10 -0
  62. package/dist/resolvers/RSUResolver.d.ts.map +1 -1
  63. package/dist/resolvers/RSUResolver.js +28 -0
  64. package/dist/resolvers/RSUResolver.js.map +1 -1
  65. package/dist/resolvers/RunAIAgentResolver.d.ts +9 -6
  66. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  67. package/dist/resolvers/RunAIAgentResolver.js +135 -132
  68. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  69. package/dist/resolvers/TransactionGroupResolver.d.ts +8 -1
  70. package/dist/resolvers/TransactionGroupResolver.d.ts.map +1 -1
  71. package/dist/resolvers/TransactionGroupResolver.js +29 -1
  72. package/dist/resolvers/TransactionGroupResolver.js.map +1 -1
  73. package/dist/resolvers/adhoc-query-helpers.d.ts +27 -0
  74. package/dist/resolvers/adhoc-query-helpers.d.ts.map +1 -0
  75. package/dist/resolvers/adhoc-query-helpers.js +42 -0
  76. package/dist/resolvers/adhoc-query-helpers.js.map +1 -0
  77. package/dist/rest/OAuthCallbackHandler.d.ts +9 -0
  78. package/dist/rest/OAuthCallbackHandler.d.ts.map +1 -1
  79. package/dist/rest/OAuthCallbackHandler.js +21 -4
  80. package/dist/rest/OAuthCallbackHandler.js.map +1 -1
  81. package/dist/telephony/media-upgrade-router.d.ts +14 -0
  82. package/dist/telephony/media-upgrade-router.d.ts.map +1 -1
  83. package/dist/telephony/media-upgrade-router.js +17 -1
  84. package/dist/telephony/media-upgrade-router.js.map +1 -1
  85. package/package.json +88 -88
  86. package/src/__tests__/AdhocQueryResolver.pagination.test.ts +114 -0
  87. package/src/__tests__/EntityMapLifecycle.dagWarnings.test.ts +91 -0
  88. package/src/__tests__/OAuthCallbackHandler.xss.test.ts +83 -0
  89. package/src/__tests__/RealtimeProxyServer.test.ts +53 -1
  90. package/src/__tests__/adhoc-query-helpers.test.ts +50 -0
  91. package/src/__tests__/magicLinkService.provisionLog.test.ts +106 -0
  92. package/src/__tests__/media-upgrade-router.test.ts +43 -0
  93. package/src/__tests__/unifiedAuth.test.ts +18 -3
  94. package/src/auth/magicLink/MagicLinkKeys.ts +12 -6
  95. package/src/auth/magicLink/MagicLinkRouter.ts +2 -2
  96. package/src/auth/magicLink/MagicLinkService.ts +4 -0
  97. package/src/config.ts +14 -0
  98. package/src/context.ts +28 -2
  99. package/src/generated/generated.ts +446 -1
  100. package/src/generic/ResolverBase.ts +5 -2
  101. package/src/generic/RunViewResolver.ts +11 -0
  102. package/src/index.ts +75 -11
  103. package/src/integration/CustomColumnPromoter.ts +123 -6
  104. package/src/integration/EntityMapLifecycle.ts +221 -0
  105. package/src/realtimeProxy/RealtimeProxyServer.ts +70 -1
  106. package/src/realtimeWidget/WidgetRouter.ts +6 -2
  107. package/src/resolvers/AdhocQueryResolver.ts +97 -30
  108. package/src/resolvers/GetDataResolver.ts +30 -1
  109. package/src/resolvers/IntegrationDiscoveryResolver.ts +514 -60
  110. package/src/resolvers/QueryResolver.ts +1 -7
  111. package/src/resolvers/RSUResolver.ts +28 -0
  112. package/src/resolvers/RunAIAgentResolver.ts +74 -70
  113. package/src/resolvers/TransactionGroupResolver.ts +36 -2
  114. package/src/resolvers/__tests__/GetDataResolver.tokens.test.ts +138 -0
  115. package/src/resolvers/adhoc-query-helpers.ts +50 -0
  116. package/src/rest/OAuthCallbackHandler.ts +22 -4
  117. package/src/telephony/media-upgrade-router.ts +18 -1
@@ -202,7 +202,6 @@ export class RunQueryResolver extends ResolverBase {
202
202
  const provider = GetReadOnlyProvider(context.providers, {allowFallbackToReadWrite: true});
203
203
  const md = provider as unknown as IMetadataProvider;
204
204
  const rq = new RunQuery(provider as unknown as IRunQueryProvider);
205
- console.log('GetQueryData called with:', { QueryID, Parameters, MaxRows, StartRow, ForceAuditLog, AuditLogDescription });
206
205
  const result = await rq.RunQuery(
207
206
  {
208
207
  QueryID: QueryID,
@@ -216,12 +215,7 @@ export class RunQueryResolver extends ResolverBase {
216
215
  Enrichment: Enrichment
217
216
  },
218
217
  context.userPayload.userRecord);
219
- console.log('RunQuery result:', {
220
- Success: result.Success,
221
- ErrorMessage: result.ErrorMessage,
222
- AppliedParameters: result.AppliedParameters
223
- });
224
-
218
+
225
219
  // If QueryName is not populated by the provider, use efficient lookup
226
220
  let queryName = result.QueryName;
227
221
  if (!queryName) {
@@ -77,6 +77,14 @@ export class RSUPipelineStepGQL {
77
77
 
78
78
  @Field(() => String)
79
79
  Message: string;
80
+
81
+ /** U11 — 1-based position within the pipeline's expected step sequence (determinate stepper). */
82
+ @Field(() => Int, { nullable: true })
83
+ StepIndex?: number;
84
+
85
+ /** U11 — expected total steps for the run. */
86
+ @Field(() => Int, { nullable: true })
87
+ StepTotal?: number;
80
88
  }
81
89
 
82
90
  @ObjectType()
@@ -158,6 +166,18 @@ export class RSUStatusGQL {
158
166
 
159
167
  @Field(() => String, { nullable: true })
160
168
  LastRunResult?: string | null;
169
+
170
+ /** U11 — name of the currently-executing pipeline step (null when idle). */
171
+ @Field(() => String, { nullable: true })
172
+ CurrentStepName?: string | null;
173
+
174
+ /** U11 — 1-based index of the current step (null when idle). Powers a determinate stepper. */
175
+ @Field(() => Int, { nullable: true })
176
+ CurrentStepIndex?: number | null;
177
+
178
+ /** U11 — expected total steps for the in-flight run (null when idle). */
179
+ @Field(() => Int, { nullable: true })
180
+ StepTotal?: number | null;
161
181
  }
162
182
 
163
183
  @ObjectType()
@@ -188,6 +208,10 @@ export class RSUResolver {
188
208
  OutOfSyncSince: status.OutOfSyncSince,
189
209
  LastRunAt: status.LastRunAt,
190
210
  LastRunResult: status.LastRunResult,
211
+ // U11 — live determinate step progress
212
+ CurrentStepName: status.CurrentStepName ?? null,
213
+ CurrentStepIndex: status.CurrentStepIndex ?? null,
214
+ StepTotal: status.StepTotal ?? null,
191
215
  };
192
216
  }
193
217
 
@@ -261,6 +285,8 @@ export class RSUResolver {
261
285
  Status: s.Status,
262
286
  DurationMs: s.DurationMs,
263
287
  Message: s.Message,
288
+ StepIndex: s.StepIndex,
289
+ StepTotal: s.StepTotal,
264
290
  })),
265
291
  ErrorMessage: result.ErrorMessage,
266
292
  ErrorStep: result.ErrorStep,
@@ -307,6 +333,8 @@ export class RSUResolver {
307
333
  Status: s.Status,
308
334
  DurationMs: s.DurationMs,
309
335
  Message: s.Message,
336
+ StepIndex: s.StepIndex,
337
+ StepTotal: s.StepTotal,
310
338
  })),
311
339
  ErrorMessage: result.ErrorMessage,
312
340
  ErrorStep: result.ErrorStep,
@@ -1,7 +1,7 @@
1
1
  import { Resolver, Mutation, Query, Arg, Ctx, ObjectType, Field, PubSub, PubSubEngine, Subscription, Root, ResolverFilterData, ID, Int } from 'type-graphql';
2
2
  import { AppContext, UserPayload } from '../types.js';
3
3
  import { DatabaseProviderBase, LogError, LogStatus, Metadata, RunView, UserInfo, IMetadataProvider } from '@memberjunction/core';
4
- import { MJConversationDetailEntity, MJConversationDetailAttachmentEntity, MJConversationDetailArtifactEntity, MJArtifactVersionEntity, MJAIAgentRequestEntity, ArtifactMetadataEngine } from '@memberjunction/core-entities';
4
+ import { MJConversationDetailEntity, MJConversationDetailAttachmentEntity, MJConversationDetailArtifactEntity, MJArtifactVersionEntity, MJAIAgentRequestEntity, ArtifactMetadataEngine, ConversationEngine } from '@memberjunction/core-entities';
5
5
  import { RouteArtifact } from './artifact-routing.js';
6
6
  import { AgentRunner, ArtifactToolManager } from '@memberjunction/ai-agents';
7
7
  import { MJAIAgentEntityExtended, MJAIAgentRunEntityExtended, ExecuteAgentResult, ConversationUtility, AttachmentData, AgentExecutionStreamingCallback } from '@memberjunction/ai-core-plus';
@@ -1018,7 +1018,10 @@ export class RunAIAgentResolver extends ResolverBase {
1018
1018
  conversationId,
1019
1019
  currentUser,
1020
1020
  maxHistoryMessages || 20,
1021
- p
1021
+ p,
1022
+ // The UI creates the agent-response placeholder row ('⏳ ...') before invoking
1023
+ // this mutation — exclude it so the model never sees an empty assistant turn.
1024
+ [conversationDetailId]
1022
1025
  );
1023
1026
 
1024
1027
  // Convert to JSON string for the existing executeAIAgent method
@@ -1337,58 +1340,89 @@ export class RunAIAgentResolver extends ResolverBase {
1337
1340
  * passes conversationId down.
1338
1341
  *
1339
1342
  * Opt #8: Switched from ResultType 'entity_object' to 'simple' with explicit Fields.
1340
- * The history query only needs ID, Role, and Message from each ConversationDetail record.
1341
- * Using 'entity_object' created full BaseEntity instances with getters/setters, dirty tracking,
1342
- * and validation none of which are needed for read-only history assembly. The 'simple' result
1343
- * type returns plain JS objects, reducing per-record overhead (~30ms total savings).
1343
+ * The history query only needs the window-assembly fields (ConversationWindowFields:
1344
+ * ID, Sequence, Role, Message, SummaryOfEarlierConversation) from each
1345
+ * ConversationDetail record. Using 'entity_object' created full BaseEntity instances
1346
+ * with getters/setters, dirty tracking, and validation none of which are needed for
1347
+ * read-only history assembly. The 'simple' result type returns plain JS objects,
1348
+ * reducing per-record overhead (~30ms total savings).
1344
1349
  */
1345
1350
  private async loadConversationHistoryWithAttachments(
1346
1351
  conversationId: string,
1347
1352
  contextUser: UserInfo,
1348
1353
  maxMessages: number,
1349
- provider: IMetadataProvider
1354
+ provider: IMetadataProvider,
1355
+ excludeDetailIds?: string[]
1350
1356
  ): Promise<ChatMessage[]> {
1351
- const rv = RunView.FromMetadataProvider(provider);
1352
- const attachmentService = GetAttachmentService();
1353
-
1354
- // Load recent conversation details (messages) for this conversation.
1355
- // Only fetch the three fields we actually use — ID for attachment lookups,
1356
- // Role for message routing, Message for content.
1357
- const detailsResult = await rv.RunView<{ ID: string; Role: string; Message: string }>({
1358
- EntityName: 'MJ: Conversation Details',
1359
- ExtraFilter: `ConversationID='${conversationId}'`,
1360
- OrderBy: '__mj_CreatedAt DESC',
1361
- MaxRows: maxMessages,
1362
- Fields: ['ID', 'Role', 'Message'],
1363
- ResultType: 'simple'
1364
- }, contextUser);
1365
-
1366
- if (!detailsResult.Success || !detailsResult.Results) {
1367
- throw new Error('Failed to load conversation history');
1368
- }
1369
-
1370
- // Reverse to get chronological order (oldest first)
1371
- const details = detailsResult.Results.reverse();
1372
-
1373
- // Get all message IDs for batch loading artifacts
1374
- const messageIds = details.map(d => d.ID);
1357
+ // Context windowing (summary boundary + raw tail, or legacy last-N when no
1358
+ // summary exists) shares ONE fold implementation with all other callers —
1359
+ // ConversationEngine.AssembleContextWindow — and the ROWS come from the
1360
+ // single-sourced fresh-per-request loader (same one the compaction pass uses).
1361
+ // Three deliberate properties vs. the engine's cached GetAgentContextWindow:
1362
+ // 1. Entity RLS applies (the engine path loads via a stored query with no
1363
+ // row-level security wrong for guest/widget-scoped users), and the
1364
+ // process-global per-conversation cache — keyed by conversation, not user —
1365
+ // can't leak the first toucher's row visibility to later callers.
1366
+ // 2. No server-side population of the engine's unbounded detail cache, and no
1367
+ // cross-server staleness (a warm cache misses rows written by other nodes).
1368
+ // 3. Load failure THROWS → the mutation fails, instead of silently running
1369
+ // the agent against zero history.
1370
+ const rows = await ConversationEngine.LoadWindowRowsFresh(conversationId, contextUser, provider);
1371
+ const window = ConversationEngine.AssembleContextWindow(rows, {
1372
+ maxTailMessages: maxMessages,
1373
+ excludeDetailIds
1374
+ });
1375
1375
 
1376
- // Batch load input artifacts for these messages. Since the backfill migration
1376
+ // Batch load input artifacts for the windowed messages. Since the backfill migration
1377
1377
  // (V202605271400__Backfill_Attachment_Artifacts) converted all legacy
1378
1378
  // ConversationDetailAttachment rows to artifact pairs, the artifact junction
1379
- // is the single source of truth — no separate attachment query needed.
1379
+ // is the single source of truth — no separate attachment query needed. The synthetic
1380
+ // summary message has no detail row and needs no enrichment.
1381
+ const messageIds = window
1382
+ .map(m => m.metadata?.conversationDetailId)
1383
+ .filter((id): id is string => !!id);
1380
1384
  const inputArtifactsByDetailId = await this.loadInputArtifactsBatch(messageIds, contextUser, provider);
1381
1385
 
1382
1386
  // Build ChatMessage array with attachments and input artifacts
1383
1387
  const messages: ChatMessage[] = [];
1384
1388
 
1385
- for (const detail of details) {
1386
- const role = this.mapDetailRoleToMessageRole(detail.Role);
1387
- const validAttachments: AttachmentData[] = [];
1389
+ for (const windowMessage of window) {
1390
+ const detailId = windowMessage.metadata?.conversationDetailId;
1391
+ const baseText = typeof windowMessage.content === 'string' ? windowMessage.content : '';
1392
+ const inputArtifacts = detailId ? (inputArtifactsByDetailId.get(detailId) || []) : [];
1393
+ const validAttachments = await this.buildAttachmentsForArtifacts(inputArtifacts, contextUser, provider);
1394
+
1395
+ // Build message content (with or without attachments)
1396
+ let content: ChatMessageContent;
1397
+ if (validAttachments.length > 0) {
1398
+ // Use ConversationUtility to build multimodal content blocks
1399
+ content = await ConversationUtility.BuildChatMessageContent(baseText, validAttachments);
1400
+ } else {
1401
+ content = baseText;
1402
+ }
1403
+
1404
+ messages.push({
1405
+ role: windowMessage.role,
1406
+ content,
1407
+ metadata: windowMessage.metadata
1408
+ });
1409
+ }
1410
+
1411
+ return messages;
1412
+ }
1413
+
1414
+ /**
1415
+ * Converts a message's input artifact versions into inline attachment payloads,
1416
+ * honoring each artifact's delivery routing (inline vs. tools-only) via RouteArtifact.
1417
+ */
1418
+ private async buildAttachmentsForArtifacts(
1419
+ inputArtifacts: MJArtifactVersionEntity[],
1420
+ contextUser: UserInfo,
1421
+ provider: IMetadataProvider
1422
+ ): Promise<AttachmentData[]> {
1423
+ const validAttachments: AttachmentData[] = [];
1388
1424
 
1389
- // Get input artifacts for this message routing via RouteArtifact.
1390
- const inputArtifacts = inputArtifactsByDetailId.get(detail.ID) || [];
1391
- for (const artifactVersion of inputArtifacts) {
1425
+ for (const artifactVersion of inputArtifacts) {
1392
1426
  const artifactMime = artifactVersion.MimeType || '';
1393
1427
  const fileName = artifactVersion.FileName ?? '';
1394
1428
  const ext = fileName.includes('.') ? fileName.split('.').pop() : undefined;
@@ -1477,37 +1511,7 @@ export class RunAIAgentResolver extends ResolverBase {
1477
1511
  }
1478
1512
  }
1479
1513
 
1480
- // Build message content (with or without attachments)
1481
- let content: ChatMessageContent;
1482
-
1483
- if (validAttachments.length > 0) {
1484
- // Use ConversationUtility to build multimodal content blocks
1485
- content = await ConversationUtility.BuildChatMessageContent(
1486
- detail.Message || '',
1487
- validAttachments
1488
- );
1489
- } else {
1490
- content = detail.Message || '';
1491
- }
1492
-
1493
- messages.push({
1494
- role,
1495
- content
1496
- });
1497
- }
1498
-
1499
- return messages;
1500
- }
1501
-
1502
- /**
1503
- * Map ConversationDetail Role to ChatMessage role
1504
- */
1505
- private mapDetailRoleToMessageRole(role: string): 'user' | 'assistant' | 'system' {
1506
- const roleLower = (role || '').toLowerCase();
1507
- if (roleLower === 'user') return 'user';
1508
- if (roleLower === 'assistant' || roleLower === 'agent' || roleLower === 'ai') return 'assistant';
1509
- if (roleLower === 'system') return 'system';
1510
- return 'user'; // Default to user
1514
+ return validAttachments;
1511
1515
  }
1512
1516
 
1513
1517
  /**
@@ -3,6 +3,7 @@ import { AppContext } from '../types.js';
3
3
  import { CompositeKey, IMetadataProvider, KeyValuePair, LogError, Metadata, TransactionVariable, BaseEntity, EntityDeleteOptions, EntitySaveOptions } from '@memberjunction/core';
4
4
  import { SafeJSONParse } from '@memberjunction/global';
5
5
  import { GetReadWriteProvider } from '../util.js';
6
+ import { ResolverBase } from '../generic/ResolverBase.js';
6
7
 
7
8
  export enum TransactionVariableType {
8
9
  Define = "Define",
@@ -77,13 +78,46 @@ export class TransactionOutputType {
77
78
 
78
79
 
79
80
 
80
- export class TransactionResolver {
81
+ export class TransactionResolver extends ResolverBase {
82
+ /**
83
+ * Maps a TransactionGroup item's operation type onto the SAME API-key scope path the singular
84
+ * CRUD mutations enforce (ResolverBase.CreateRecord/UpdateRecord/DeleteRecord), so the
85
+ * transaction path cannot be used to sidestep a key's scope ceiling.
86
+ */
87
+ protected GetScopePathForOperation(operationType: TransactionOperationType): string {
88
+ switch (operationType) {
89
+ case TransactionOperationType.Create:
90
+ return 'entity:create';
91
+ case TransactionOperationType.Update:
92
+ return 'entity:update';
93
+ case TransactionOperationType.Delete:
94
+ return 'entity:delete';
95
+ }
96
+ }
97
+
81
98
  @Mutation(() => TransactionOutputType)
82
99
  async ExecuteTransactionGroup(
83
100
  @Arg('group', () => TransactionInputType ) group: TransactionInputType,
84
101
  @Ctx() context: AppContext
85
102
  ) {
86
- try {
103
+ // SECURITY (bug-register B1 / catalog SEC1): route every transaction item through the same
104
+ // API-key scope gate the singular CRUD resolvers use, BEFORE any entity work happens.
105
+ // Without this pre-pass, a restricted API key (e.g. view:run-only) could Create/Update/
106
+ // Delete over the wire by wrapping the mutation in a TransactionGroup — the scope ceiling
107
+ // was simply never consulted on this path. No-op for OAuth/JWT sessions (no apiKeyHash),
108
+ // and full_access keys short-circuit inside CheckAPIKeyScopeAuthorization, so legitimate
109
+ // callers are unaffected; only keys that would already be denied on the singular CRUD
110
+ // mutations are now equally denied here. Deliberately OUTSIDE the try below so the
111
+ // AuthorizationError propagates to the client unwrapped.
112
+ for (const item of group.Items) {
113
+ await this.CheckAPIKeyScopeAuthorization(
114
+ this.GetScopePathForOperation(item.OperationType),
115
+ item.EntityName,
116
+ context.userPayload
117
+ );
118
+ }
119
+
120
+ try {
87
121
  // we have received the transaction group information via the network, now we need to reconstruct our TransactionGroup object and run it
88
122
  const md = (GetReadWriteProvider(context.providers, { allowFallbackToReadOnly: true }) as unknown as IMetadataProvider) ?? new Metadata();
89
123
  const tg = await md.CreateTransactionGroup();
@@ -0,0 +1,138 @@
1
+ // type-graphql decorators on the resolver class call Reflect.getMetadata, which only
2
+ // exists when this polyfill is loaded first (mirrors RealtimeBridgeResolver.test.ts).
3
+ import 'reflect-metadata';
4
+
5
+ import { describe, it, expect, vi } from 'vitest';
6
+
7
+ // This test exercises only the plain, exported token-lifecycle functions at the
8
+ // bottom of GetDataResolver.ts — NOT the `GetData`/`GetAllEntities` resolver
9
+ // methods. Those methods (and the module-level `import ... from '../index.js'`)
10
+ // pull in MJServer's entire bootstrap graph (SQL Server provider, Apollo server,
11
+ // env-driven config validation), which is both heavy and requires live DB env
12
+ // vars to even import. Mock those heavy imports out so this file stays a true,
13
+ // isolated unit test of the token-tracking logic (mirrors the mocking convention
14
+ // in RealtimeBridgeResolver.test.ts).
15
+ vi.mock('../../index.js', () => ({ getDbType: vi.fn() }));
16
+ vi.mock('@memberjunction/generic-database-provider', () => ({
17
+ QueryCompositionEngine: vi.fn(() => ({ HasCompositionTokens: vi.fn(() => false) })),
18
+ }));
19
+ vi.mock('../../util.js', () => ({ GetReadOnlyDataSource: vi.fn(), GetReadOnlyProvider: vi.fn() }));
20
+ vi.mock('../../auth/index.js', () => ({ getSystemUser: vi.fn() }));
21
+ vi.mock('mssql', () => ({ default: { Request: vi.fn() } }));
22
+
23
+ import {
24
+ registerAccessToken,
25
+ deleteAccessToken,
26
+ tokenExists,
27
+ isTokenValid,
28
+ recordTokenUse,
29
+ pruneExpiredTokens,
30
+ getAccessTokenCount,
31
+ } from '../GetDataResolver.js';
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Regression coverage for a memory leak (Memory Leak Audit Round 7, Critical):
35
+ // `__accessTokens` (module-level array backing GetDataResolver's short-lived
36
+ // access tokens) grew forever — nothing ever removed an expired token,
37
+ // `deleteAccessToken()` has no production caller, and `isTokenValid()` only
38
+ // checked expiry without pruning. `registerAccessToken()` now sweeps expired
39
+ // tokens (via `pruneExpiredTokens()`) on every call, so the array self-bounds to
40
+ // roughly "tokens registered within one lifespan window" instead of growing with
41
+ // total lifetime registrations.
42
+ //
43
+ // Each test uses a unique token (uuidv4-backed default, or an explicit unique
44
+ // string) so state from other tests sharing the same module-level array doesn't
45
+ // interfere with assertions.
46
+ // ---------------------------------------------------------------------------
47
+
48
+ let seq = 0;
49
+ const uniqueToken = () => `test-token-${Date.now()}-${++seq}`;
50
+
51
+ describe('GetDataResolver access-token lifecycle', () => {
52
+ it('registerAccessToken() creates a token that is valid and exists', () => {
53
+ const token = uniqueToken();
54
+ const created = registerAccessToken(token, 60_000);
55
+ expect(created.Token).toBe(token);
56
+ expect(tokenExists(token)).toBe(true);
57
+ expect(isTokenValid(token)).toBe(true);
58
+ });
59
+
60
+ it('registerAccessToken() throws when the same custom token is registered twice', () => {
61
+ const token = uniqueToken();
62
+ registerAccessToken(token, 60_000);
63
+ expect(() => registerAccessToken(token, 60_000)).toThrow(/already exists/);
64
+ });
65
+
66
+ it('isTokenValid() is false for an unknown token and for an expired one', () => {
67
+ expect(isTokenValid('never-registered-token')).toBe(false);
68
+
69
+ const token = uniqueToken();
70
+ registerAccessToken(token, -1); // already expired (ExpiresAt in the past)
71
+ expect(isTokenValid(token)).toBe(false);
72
+ });
73
+
74
+ it('deleteAccessToken() removes a token and throws for an unknown one', () => {
75
+ const token = uniqueToken();
76
+ registerAccessToken(token, 60_000);
77
+ expect(tokenExists(token)).toBe(true);
78
+
79
+ deleteAccessToken(token);
80
+ expect(tokenExists(token)).toBe(false);
81
+ expect(() => deleteAccessToken(token)).toThrow(/does not exist/);
82
+ });
83
+
84
+ it('recordTokenUse() appends to TokenUses and throws for an unknown token', () => {
85
+ const token = uniqueToken();
86
+ registerAccessToken(token, 60_000);
87
+
88
+ expect(() => recordTokenUse(token, { some: 'payload' })).not.toThrow();
89
+ expect(() => recordTokenUse('never-registered-token', {})).toThrow(/does not exist/);
90
+ });
91
+
92
+ it('pruneExpiredTokens() removes only tokens whose ExpiresAt has passed', () => {
93
+ const expiredToken = uniqueToken();
94
+ const liveToken = uniqueToken();
95
+ registerAccessToken(expiredToken, -1); // already expired
96
+ registerAccessToken(liveToken, 60_000); // still valid
97
+
98
+ pruneExpiredTokens();
99
+
100
+ expect(tokenExists(expiredToken)).toBe(false);
101
+ expect(tokenExists(liveToken)).toBe(true);
102
+ });
103
+
104
+ it('pruneExpiredTokens() accepts an injected "now" for deterministic testing', () => {
105
+ const token = uniqueToken();
106
+ const registeredAt = new Date('2026-01-01T00:00:00Z');
107
+ registerAccessToken(token, 5 * 60 * 1000); // 5-minute lifespan from real "now"
108
+
109
+ // Simulate a check far in the future — token should count as expired at that instant.
110
+ const farFuture = new Date(registeredAt.getTime() + 365 * 24 * 60 * 60 * 1000);
111
+ pruneExpiredTokens(farFuture);
112
+
113
+ expect(tokenExists(token)).toBe(false);
114
+ });
115
+
116
+ it('the core Round 7 regression: registering many expired tokens does not grow the array without bound', () => {
117
+ const before = getAccessTokenCount();
118
+
119
+ // Simulate 50 short-lived tokens that all expire immediately (e.g. a burst
120
+ // of external calls with a near-zero lifespan, or tokens whose consumer
121
+ // never came back before expiry — the exact shape of the leak this fix
122
+ // addresses).
123
+ for (let i = 0; i < 50; i++) {
124
+ registerAccessToken(`${uniqueToken()}-burst-${i}`, -1);
125
+ }
126
+
127
+ // Register one more, healthy token — its registerAccessToken() call sweeps
128
+ // all 50 expired ones out, so the count should NOT have grown by 51; it
129
+ // should only reflect whatever was live before plus this one new token.
130
+ const survivorToken = uniqueToken();
131
+ registerAccessToken(survivorToken, 60_000);
132
+
133
+ // The 50 expired burst tokens must be gone — the array self-bounded rather
134
+ // than accumulating every registration for the life of the process.
135
+ expect(getAccessTokenCount()).toBeLessThan(before + 51);
136
+ expect(tokenExists(survivorToken)).toBe(true);
137
+ });
138
+ });
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Pure, dependency-free helpers for {@link AdhocQueryResolver}. Kept in their own
3
+ * module (no type-graphql / mssql imports) so the total-row-count boundary logic is
4
+ * unit-testable without standing up the GraphQL resolver or a database.
5
+ */
6
+
7
+ /**
8
+ * Determines the exact total row count from the page alone, when it can be known
9
+ * without a separate `COUNT(*)`:
10
+ *
11
+ * - **Not paging** (`maxRows == null`): the query ran uncapped, so every row came
12
+ * back — the total is just the number returned.
13
+ * - **Short page** (`pageLength < maxRows`): a page shorter than the page size means
14
+ * there are no rows beyond it, so the total is `startRow + pageLength` exactly.
15
+ * - **Full page** (`pageLength >= maxRows`): more rows may exist — returns `null` to
16
+ * signal that a `COUNT(*)` is required to know the true total.
17
+ */
18
+ export function exactTotalFromPage(
19
+ startRow: number,
20
+ pageLength: number,
21
+ maxRows: number | null,
22
+ ): number | null {
23
+ if (maxRows == null) {
24
+ return pageLength;
25
+ }
26
+ if (pageLength < maxRows) {
27
+ return startRow + pageLength;
28
+ }
29
+ return null;
30
+ }
31
+
32
+ /**
33
+ * Reads the total row count from a `COUNT(*)` recordset (`[{ TotalRowCount }]`),
34
+ * falling back to `fallback` when the count is absent, non-numeric, or negative — so
35
+ * an unexpected count shape never yields a misleading total. `fallback` should be a
36
+ * safe lower bound (e.g. `startRow + rowsReturned`).
37
+ */
38
+ export function resolveAdhocTotalRowCount(
39
+ countRows: ReadonlyArray<{ TotalRowCount?: unknown }> | null | undefined,
40
+ fallback: number,
41
+ ): number {
42
+ const raw = countRows?.[0]?.TotalRowCount;
43
+ if (raw != null) {
44
+ const n = Number(raw);
45
+ if (Number.isFinite(n) && n >= 0) {
46
+ return Math.floor(n);
47
+ }
48
+ }
49
+ return fallback;
50
+ }
@@ -98,7 +98,8 @@ export class OAuthCallbackHandler {
98
98
  * Renders a success page after OAuth authorization completes.
99
99
  */
100
100
  private handleSuccessPage(req: express.Request, res: express.Response): void {
101
- const { state, connectionId } = req.query;
101
+ const { connectionId } = req.query;
102
+ const safeConnectionId = connectionId ? this.escapeHtml(String(connectionId)) : '';
102
103
  res.status(200).send(`
103
104
  <!DOCTYPE html>
104
105
  <html lang="en">
@@ -120,7 +121,7 @@ export class OAuthCallbackHandler {
120
121
  <div class="icon">✅</div>
121
122
  <h1>Authorization Successful</h1>
122
123
  <p>Your MCP server connection has been authorized. You can close this window and return to MemberJunction.</p>
123
- ${connectionId ? `<div class="details">Connection ID: ${connectionId}</div>` : ''}
124
+ ${safeConnectionId ? `<div class="details">Connection ID: ${safeConnectionId}</div>` : ''}
124
125
  </div>
125
126
  </body>
126
127
  </html>
@@ -132,8 +133,8 @@ export class OAuthCallbackHandler {
132
133
  */
133
134
  private handleErrorPage(req: express.Request, res: express.Response): void {
134
135
  const { error, error_description } = req.query;
135
- const errorCode = error ? String(error) : 'unknown_error';
136
- const errorMessage = error_description ? String(error_description) : 'An error occurred during authorization.';
136
+ const errorCode = this.escapeHtml(error ? String(error) : 'unknown_error');
137
+ const errorMessage = this.escapeHtml(error_description ? String(error_description) : 'An error occurred during authorization.');
137
138
 
138
139
  res.status(200).send(`
139
140
  <!DOCTYPE html>
@@ -542,6 +543,23 @@ export class OAuthCallbackHandler {
542
543
  // Helper Methods
543
544
  // ========================================
544
545
 
546
+ /**
547
+ * Escapes HTML-special characters in a string so untrusted (e.g. query-string-derived)
548
+ * values can be safely interpolated into an HTML response without introducing
549
+ * reflected-XSS. Escapes `&`, `<`, `>`, `"`, and `'`.
550
+ *
551
+ * @param value - The raw string to escape
552
+ * @returns The HTML-escaped string
553
+ */
554
+ private escapeHtml(value: string): string {
555
+ return value
556
+ .replace(/&/g, '&amp;')
557
+ .replace(/</g, '&lt;')
558
+ .replace(/>/g, '&gt;')
559
+ .replace(/"/g, '&quot;')
560
+ .replace(/'/g, '&#39;');
561
+ }
562
+
545
563
  /**
546
564
  * Gets the system user from cache.
547
565
  */
@@ -28,6 +28,23 @@ export function RegisterMediaUpgradeRoute(path: string, wss: WebSocketServer): v
28
28
  mediaRoutes.set(path, wss);
29
29
  }
30
30
 
31
+ /**
32
+ * Whether an upgrade request's pathname should be routed to the GraphQL subscriptions socket.
33
+ *
34
+ * `GRAPHQL_ROOT_PATH` defaults to `/` (see `config.ts` / the docker README). On the HTTP side that
35
+ * default is invisible — Express's `app.use(graphqlRootPath, ...)` mounts with PREFIX matching, so a
36
+ * request to `/graphql` lands on the GraphQL middleware even when the configured root is the bare `/`.
37
+ * A WebSocket upgrade has no such prefix-matching fallback: it needs an exact pathname match. With no
38
+ * override, a stock install's default root (`/`) never matches the shipped MJExplorer client's hardcoded
39
+ * `GRAPHQL_WS_URI` (`ws://<host>/graphql`), so every subscription upgrade gets silently rejected — surfaces
40
+ * client-side as "WebSocket handshake: Unexpected response code: 400". Accept the conventional `/graphql`
41
+ * suffix as an alias of the bare root so a stock install works out of the box; a non-default `graphqlPath`
42
+ * (e.g. `/api`) keeps requiring an exact match, unchanged.
43
+ */
44
+ export function IsGraphQLWsPath(pathname: string, graphqlPath: string): boolean {
45
+ return pathname === graphqlPath || (graphqlPath === '/' && pathname === '/graphql');
46
+ }
47
+
31
48
  /**
32
49
  * Installs the single path-routing `upgrade` dispatcher. No-op when no media routes are registered, so
33
50
  * the GraphQL server keeps its own listener untouched. Otherwise it removes the auto-attached listeners
@@ -48,7 +65,7 @@ export function InstallMediaUpgradeDispatcher(httpServer: HttpServer, graphqlWss
48
65
  };
49
66
  httpServer.on('upgrade', (req: IncomingMessage, socket: Duplex, head: Buffer) => {
50
67
  const pathname = (req.url ?? '').split('?')[0];
51
- if (pathname === graphqlPath) {
68
+ if (IsGraphQLWsPath(pathname, graphqlPath)) {
52
69
  dispatch(graphqlWss, req, socket, head);
53
70
  return;
54
71
  }