@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.
- package/README.md +9 -0
- package/dist/auth/magicLink/MagicLinkKeys.d.ts.map +1 -1
- package/dist/auth/magicLink/MagicLinkKeys.js +11 -4
- package/dist/auth/magicLink/MagicLinkKeys.js.map +1 -1
- package/dist/auth/magicLink/MagicLinkRouter.js +2 -2
- package/dist/auth/magicLink/MagicLinkRouter.js.map +1 -1
- package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
- package/dist/auth/magicLink/MagicLinkService.js +4 -0
- package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
- package/dist/config.d.ts +40 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +12 -0
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +28 -2
- package/dist/context.js.map +1 -1
- package/dist/generated/generated.d.ts +122 -0
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +629 -0
- package/dist/generated/generated.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +4 -2
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/generic/RunViewResolver.d.ts +1 -0
- package/dist/generic/RunViewResolver.d.ts.map +1 -1
- package/dist/generic/RunViewResolver.js +12 -0
- package/dist/generic/RunViewResolver.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +73 -11
- package/dist/index.js.map +1 -1
- package/dist/integration/CustomColumnPromoter.d.ts +15 -2
- package/dist/integration/CustomColumnPromoter.d.ts.map +1 -1
- package/dist/integration/CustomColumnPromoter.js +113 -11
- package/dist/integration/CustomColumnPromoter.js.map +1 -1
- package/dist/integration/EntityMapLifecycle.d.ts +66 -0
- package/dist/integration/EntityMapLifecycle.d.ts.map +1 -0
- package/dist/integration/EntityMapLifecycle.js +189 -0
- package/dist/integration/EntityMapLifecycle.js.map +1 -0
- package/dist/realtimeProxy/RealtimeProxyServer.d.ts +10 -0
- package/dist/realtimeProxy/RealtimeProxyServer.d.ts.map +1 -1
- package/dist/realtimeProxy/RealtimeProxyServer.js +67 -1
- package/dist/realtimeProxy/RealtimeProxyServer.js.map +1 -1
- package/dist/realtimeWidget/WidgetRouter.d.ts.map +1 -1
- package/dist/realtimeWidget/WidgetRouter.js +6 -2
- package/dist/realtimeWidget/WidgetRouter.js.map +1 -1
- package/dist/resolvers/AdhocQueryResolver.d.ts +17 -0
- package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
- package/dist/resolvers/AdhocQueryResolver.js +81 -25
- package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
- package/dist/resolvers/GetDataResolver.d.ts +16 -0
- package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
- package/dist/resolvers/GetDataResolver.js +26 -1
- package/dist/resolvers/GetDataResolver.js.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +53 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.js +522 -60
- package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
- package/dist/resolvers/QueryResolver.d.ts.map +1 -1
- package/dist/resolvers/QueryResolver.js +0 -6
- package/dist/resolvers/QueryResolver.js.map +1 -1
- package/dist/resolvers/RSUResolver.d.ts +10 -0
- package/dist/resolvers/RSUResolver.d.ts.map +1 -1
- package/dist/resolvers/RSUResolver.js +28 -0
- package/dist/resolvers/RSUResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts +9 -6
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +135 -132
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/TransactionGroupResolver.d.ts +8 -1
- package/dist/resolvers/TransactionGroupResolver.d.ts.map +1 -1
- package/dist/resolvers/TransactionGroupResolver.js +29 -1
- package/dist/resolvers/TransactionGroupResolver.js.map +1 -1
- package/dist/resolvers/adhoc-query-helpers.d.ts +27 -0
- package/dist/resolvers/adhoc-query-helpers.d.ts.map +1 -0
- package/dist/resolvers/adhoc-query-helpers.js +42 -0
- package/dist/resolvers/adhoc-query-helpers.js.map +1 -0
- package/dist/rest/OAuthCallbackHandler.d.ts +9 -0
- package/dist/rest/OAuthCallbackHandler.d.ts.map +1 -1
- package/dist/rest/OAuthCallbackHandler.js +21 -4
- package/dist/rest/OAuthCallbackHandler.js.map +1 -1
- package/dist/telephony/media-upgrade-router.d.ts +14 -0
- package/dist/telephony/media-upgrade-router.d.ts.map +1 -1
- package/dist/telephony/media-upgrade-router.js +17 -1
- package/dist/telephony/media-upgrade-router.js.map +1 -1
- package/package.json +88 -88
- package/src/__tests__/AdhocQueryResolver.pagination.test.ts +114 -0
- package/src/__tests__/EntityMapLifecycle.dagWarnings.test.ts +91 -0
- package/src/__tests__/OAuthCallbackHandler.xss.test.ts +83 -0
- package/src/__tests__/RealtimeProxyServer.test.ts +53 -1
- package/src/__tests__/adhoc-query-helpers.test.ts +50 -0
- package/src/__tests__/magicLinkService.provisionLog.test.ts +106 -0
- package/src/__tests__/media-upgrade-router.test.ts +43 -0
- package/src/__tests__/unifiedAuth.test.ts +18 -3
- package/src/auth/magicLink/MagicLinkKeys.ts +12 -6
- package/src/auth/magicLink/MagicLinkRouter.ts +2 -2
- package/src/auth/magicLink/MagicLinkService.ts +4 -0
- package/src/config.ts +14 -0
- package/src/context.ts +28 -2
- package/src/generated/generated.ts +446 -1
- package/src/generic/ResolverBase.ts +5 -2
- package/src/generic/RunViewResolver.ts +11 -0
- package/src/index.ts +75 -11
- package/src/integration/CustomColumnPromoter.ts +123 -6
- package/src/integration/EntityMapLifecycle.ts +221 -0
- package/src/realtimeProxy/RealtimeProxyServer.ts +70 -1
- package/src/realtimeWidget/WidgetRouter.ts +6 -2
- package/src/resolvers/AdhocQueryResolver.ts +97 -30
- package/src/resolvers/GetDataResolver.ts +30 -1
- package/src/resolvers/IntegrationDiscoveryResolver.ts +514 -60
- package/src/resolvers/QueryResolver.ts +1 -7
- package/src/resolvers/RSUResolver.ts +28 -0
- package/src/resolvers/RunAIAgentResolver.ts +74 -70
- package/src/resolvers/TransactionGroupResolver.ts +36 -2
- package/src/resolvers/__tests__/GetDataResolver.tokens.test.ts +138 -0
- package/src/resolvers/adhoc-query-helpers.ts +50 -0
- package/src/rest/OAuthCallbackHandler.ts +22 -4
- 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
|
-
|
|
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
|
|
1341
|
-
*
|
|
1342
|
-
*
|
|
1343
|
-
*
|
|
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
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
//
|
|
1355
|
-
//
|
|
1356
|
-
//
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
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
|
|
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
|
|
1386
|
-
const
|
|
1387
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
${
|
|
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, '&')
|
|
557
|
+
.replace(/</g, '<')
|
|
558
|
+
.replace(/>/g, '>')
|
|
559
|
+
.replace(/"/g, '"')
|
|
560
|
+
.replace(/'/g, ''');
|
|
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
|
|
68
|
+
if (IsGraphQLWsPath(pathname, graphqlPath)) {
|
|
52
69
|
dispatch(graphqlWss, req, socket, head);
|
|
53
70
|
return;
|
|
54
71
|
}
|