@memberjunction/server 5.50.0 → 6.1.0-edge.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 (116) hide show
  1. package/LICENSE +7 -0
  2. package/dist/agentSessions/SessionManager.d.ts +22 -1
  3. package/dist/agentSessions/SessionManager.d.ts.map +1 -1
  4. package/dist/agentSessions/SessionManager.js +18 -3
  5. package/dist/agentSessions/SessionManager.js.map +1 -1
  6. package/dist/auth/actingContextResolver.d.ts +44 -0
  7. package/dist/auth/actingContextResolver.d.ts.map +1 -0
  8. package/dist/auth/actingContextResolver.js +18 -0
  9. package/dist/auth/actingContextResolver.js.map +1 -0
  10. package/dist/auth/index.d.ts +1 -0
  11. package/dist/auth/index.d.ts.map +1 -1
  12. package/dist/auth/index.js +1 -0
  13. package/dist/auth/index.js.map +1 -1
  14. package/dist/auth/sessionUserClone.d.ts +32 -0
  15. package/dist/auth/sessionUserClone.d.ts.map +1 -0
  16. package/dist/auth/sessionUserClone.js +50 -0
  17. package/dist/auth/sessionUserClone.js.map +1 -0
  18. package/dist/context.d.ts +1 -1
  19. package/dist/context.d.ts.map +1 -1
  20. package/dist/context.js +43 -6
  21. package/dist/context.js.map +1 -1
  22. package/dist/generated/generated.d.ts +71 -0
  23. package/dist/generated/generated.d.ts.map +1 -1
  24. package/dist/generated/generated.js +383 -3
  25. package/dist/generated/generated.js.map +1 -1
  26. package/dist/generic/FireAndForgetHeartbeat.d.ts +5 -0
  27. package/dist/generic/FireAndForgetHeartbeat.d.ts.map +1 -1
  28. package/dist/generic/FireAndForgetHeartbeat.js +5 -4
  29. package/dist/generic/FireAndForgetHeartbeat.js.map +1 -1
  30. package/dist/generic/PushStatusResolver.d.ts +58 -0
  31. package/dist/generic/PushStatusResolver.d.ts.map +1 -1
  32. package/dist/generic/PushStatusResolver.js +42 -3
  33. package/dist/generic/PushStatusResolver.js.map +1 -1
  34. package/dist/generic/ResolverBase.d.ts +9 -0
  35. package/dist/generic/ResolverBase.d.ts.map +1 -1
  36. package/dist/generic/ResolverBase.js +31 -13
  37. package/dist/generic/ResolverBase.js.map +1 -1
  38. package/dist/index.d.ts +2 -0
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +4 -2
  41. package/dist/index.js.map +1 -1
  42. package/dist/multiTenancy/index.d.ts +19 -0
  43. package/dist/multiTenancy/index.d.ts.map +1 -1
  44. package/dist/multiTenancy/index.js +82 -5
  45. package/dist/multiTenancy/index.js.map +1 -1
  46. package/dist/realtimeWidget/widgetGuestElevation.d.ts +22 -0
  47. package/dist/realtimeWidget/widgetGuestElevation.d.ts.map +1 -1
  48. package/dist/realtimeWidget/widgetGuestElevation.js +36 -0
  49. package/dist/realtimeWidget/widgetGuestElevation.js.map +1 -1
  50. package/dist/resolvers/ActionResolver.d.ts.map +1 -1
  51. package/dist/resolvers/ActionResolver.js +12 -5
  52. package/dist/resolvers/ActionResolver.js.map +1 -1
  53. package/dist/resolvers/MCPResolver.d.ts.map +1 -1
  54. package/dist/resolvers/MCPResolver.js +13 -16
  55. package/dist/resolvers/MCPResolver.js.map +1 -1
  56. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +22 -1
  57. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  58. package/dist/resolvers/RealtimeClientSessionResolver.js +90 -30
  59. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  60. package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
  61. package/dist/resolvers/RemoteBrowserActionResolver.js +19 -26
  62. package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
  63. package/dist/resolvers/ReportResolver.d.ts.map +1 -1
  64. package/dist/resolvers/ReportResolver.js +11 -3
  65. package/dist/resolvers/ReportResolver.js.map +1 -1
  66. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  67. package/dist/resolvers/RunAIAgentResolver.js +30 -40
  68. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  69. package/dist/resolvers/RunTestResolver.d.ts.map +1 -1
  70. package/dist/resolvers/RunTestResolver.js +90 -112
  71. package/dist/resolvers/RunTestResolver.js.map +1 -1
  72. package/dist/resolvers/SearchKnowledgeResolver.js +3 -3
  73. package/dist/resolvers/SearchKnowledgeResolver.js.map +1 -1
  74. package/dist/resolvers/SearchKnowledgeStreamResolver.js +2 -2
  75. package/dist/resolvers/SearchKnowledgeStreamResolver.js.map +1 -1
  76. package/dist/resolvers/VersionHistoryResolver.d.ts.map +1 -1
  77. package/dist/resolvers/VersionHistoryResolver.js +8 -12
  78. package/dist/resolvers/VersionHistoryResolver.js.map +1 -1
  79. package/dist/services/TaskOrchestrator.d.ts.map +1 -1
  80. package/dist/services/TaskOrchestrator.js +17 -15
  81. package/dist/services/TaskOrchestrator.js.map +1 -1
  82. package/package.json +100 -100
  83. package/src/__tests__/RealtimeBridgeResolver.test.ts +6 -0
  84. package/src/__tests__/RealtimeClientSessionResolver.test.ts +420 -0
  85. package/src/__tests__/SessionManager.test.ts +106 -0
  86. package/src/__tests__/multiTenancy.security.test.ts +254 -22
  87. package/src/__tests__/multiTenancy.test.ts +22 -0
  88. package/src/__tests__/pushStatusResolver.filter.test.ts +56 -0
  89. package/src/__tests__/resolverBase.rls.test.ts +4 -0
  90. package/src/__tests__/search-knowledge-tags.test.ts +74 -6
  91. package/src/__tests__/sessionUserClone.test.ts +161 -0
  92. package/src/__tests__/widgetGuestElevation.test.ts +70 -2
  93. package/src/agentSessions/SessionManager.ts +32 -3
  94. package/src/auth/actingContextResolver.ts +56 -0
  95. package/src/auth/index.ts +1 -0
  96. package/src/auth/sessionUserClone.ts +50 -0
  97. package/src/context.ts +48 -6
  98. package/src/generated/generated.ts +276 -3
  99. package/src/generic/FireAndForgetHeartbeat.ts +10 -4
  100. package/src/generic/PushStatusResolver.ts +79 -4
  101. package/src/generic/ResolverBase.ts +33 -13
  102. package/src/index.ts +4 -2
  103. package/src/multiTenancy/index.ts +92 -5
  104. package/src/realtimeWidget/widgetGuestElevation.ts +41 -0
  105. package/src/resolvers/ActionResolver.ts +15 -7
  106. package/src/resolvers/MCPResolver.ts +13 -15
  107. package/src/resolvers/RealtimeClientSessionResolver.ts +99 -29
  108. package/src/resolvers/RemoteBrowserActionResolver.ts +19 -25
  109. package/src/resolvers/ReportResolver.ts +11 -3
  110. package/src/resolvers/RunAIAgentResolver.ts +30 -40
  111. package/src/resolvers/RunTestResolver.ts +90 -112
  112. package/src/resolvers/SearchKnowledgeResolver.ts +3 -3
  113. package/src/resolvers/SearchKnowledgeStreamResolver.ts +2 -2
  114. package/src/resolvers/VersionHistoryResolver.ts +10 -13
  115. package/src/resolvers/__tests__/ReportResolver.test.ts +279 -0
  116. package/src/services/TaskOrchestrator.ts +17 -17
@@ -60,7 +60,7 @@ import { ResolverBase } from '../generic/ResolverBase.js';
60
60
  import { PUSH_STATUS_UPDATES_TOPIC } from '../generic/PushStatusResolver.js';
61
61
  import { GetReadWriteProvider } from '../util.js';
62
62
  import { SessionManager } from '../agentSessions/index.js';
63
- import { resolveWidgetGuestRunContext } from '../realtimeWidget/widgetGuestElevation.js';
63
+ import { resolveWidgetGuestRunContext, ResolveScopedAnonymousRunUser } from '../realtimeWidget/widgetGuestElevation.js';
64
64
 
65
65
  /**
66
66
  * Progress steps worth narrating to the realtime model — mirrors the normal agent-run path's filter
@@ -349,8 +349,13 @@ export class UploadRealtimeRecordingResult {
349
349
  */
350
350
  @Resolver()
351
351
  export class RealtimeClientSessionResolver extends ResolverBase {
352
- private readonly sessionManager = new SessionManager();
352
+ // Declaration order matters: `clientSessionService` must be initialized before `sessionManager`
353
+ // reads it, so `SessionManager.CloseSession` finalizes co-agent observability runs (see
354
+ // `finalizeObservabilityRuns`) through the SAME `RealtimeClientSessionService` instance that
355
+ // `AppendPromptRunMessage`/`AccumulatePromptRunUsage` (below) accumulated per-run write-chain
356
+ // state on — otherwise that cleanup silently no-ops on a freshly-constructed instance.
353
357
  private readonly clientSessionService = new RealtimeClientSessionService();
358
+ private readonly sessionManager = new SessionManager(this.clientSessionService);
354
359
 
355
360
  /**
356
361
  * Start a client-direct realtime voice session targeting `targetAgentId`.
@@ -495,13 +500,28 @@ export class RealtimeClientSessionResolver extends ResolverBase {
495
500
  const session = await this.loadOwnedActiveSession(agentSessionId, contextUser, provider);
496
501
  const config = this.readSessionConfig(session);
497
502
 
503
+ // SCOPED-ANONYMOUS ELEVATION (issue #3371): once ownership is proven above, the delegated
504
+ // run + its AI-run-entity writes execute as the system user for a scoped anonymous caller
505
+ // (the caller's role deliberately holds no grants on the run entities). The lead
506
+ // targetAgentID comes from the session config and was CanRun-gated at start; the colleague
507
+ // union is gated just below, against the CALLER, so elevation never widens agent authority.
508
+ const runUser = ResolveScopedAnonymousRunUser(contextUser);
509
+ if (runUser !== contextUser) {
510
+ LogStatus(
511
+ `ExecuteRealtimeSessionTool: dispatching relayed tool '${toolName}' for session ${agentSessionId} ` +
512
+ 'under the system user (scoped-anonymous caller).',
513
+ );
514
+ }
498
515
  const { ResultJson, PausedRunID, Artifacts } = await this.clientSessionService.ExecuteRelayedTool(
499
516
  {
500
517
  AgentSessionID: agentSessionId,
501
518
  TargetAgentID: config.targetAgentID,
502
519
  // Multi-target (Move 4): the session's persisted allowed-agent union — a model-named
503
520
  // colleague in the call is validated against this; absent ⇒ single-target behavior.
504
- AllowedAgents: config.allowedAgents,
521
+ AllowedAgents: await this.filterAllowedAgentsByCanRun(config.allowedAgents, contextUser),
522
+ // Attribution follows the VISITOR even when `runUser` is elevated: the delegated run
523
+ // row and its context-memory scope must stay the person's, not the system user's.
524
+ AttributionUserID: contextUser.ID,
505
525
  // Nest the delegated target-agent run under the co-agent observability run (when present).
506
526
  ParentRunID: config.coAgentRunID,
507
527
  Call: { CallID: callId, ToolName: toolName, Arguments: argsJson },
@@ -509,7 +529,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
509
529
  // Resume a previously-paused delegated run (if any) with the user's answer.
510
530
  ResumeRunID: config.pendingFeedbackRunID,
511
531
  },
512
- contextUser,
532
+ runUser,
513
533
  provider,
514
534
  );
515
535
 
@@ -519,7 +539,8 @@ export class RealtimeClientSessionResolver extends ResolverBase {
519
539
 
520
540
  // Junction-link any artifacts the delegated run produced into the session's conversation
521
541
  // history (best-effort) — so chat, session review, and resume carryover can all see them.
522
- await this.linkDelegatedArtifactsToConversation(session, Artifacts, contextUser, provider);
542
+ // Runs as `runUser`: the junction entity is not among an anonymous caller's relay grants.
543
+ await this.linkDelegatedArtifactsToConversation(session, Artifacts, runUser, provider);
523
544
 
524
545
  await this.sessionManager.Heartbeat(agentSessionId, contextUser, provider);
525
546
  return ResultJson;
@@ -542,18 +563,15 @@ export class RealtimeClientSessionResolver extends ResolverBase {
542
563
  if (!SIGNIFICANT_PROGRESS_STEPS.includes(progress.step)) {
543
564
  return;
544
565
  }
545
- pubSub.publish(PUSH_STATUS_UPDATES_TOPIC, {
546
- message: JSON.stringify({
547
- resolver: 'RealtimeClientSessionResolver',
548
- type: 'RealtimeDelegationProgress',
549
- agentSessionID,
550
- callID,
551
- step: progress.step,
552
- message: progress.message,
553
- percentage: progress.percentage,
554
- }),
555
- sessionId: userPayload.sessionId,
556
- });
566
+ this.PublishStatusUpdate(pubSub, userPayload.sessionId, JSON.stringify({
567
+ resolver: 'RealtimeClientSessionResolver',
568
+ type: 'RealtimeDelegationProgress',
569
+ agentSessionID,
570
+ callID,
571
+ step: progress.step,
572
+ message: progress.message,
573
+ percentage: progress.percentage,
574
+ }), userPayload);
557
575
  };
558
576
  }
559
577
 
@@ -625,6 +643,8 @@ export class RealtimeClientSessionResolver extends ResolverBase {
625
643
  }
626
644
  // Mirror the turn onto the co-agent's long-lived prompt run so its Messages capture the full
627
645
  // conversation (run-viewer observability parity). Best-effort — never fails the transcript relay.
646
+ // The prompt-run write runs as the scoped-anonymous elevated user (issue #3371) — the visible
647
+ // Conversation Detail above deliberately stays on the caller.
628
648
  const promptRunID = this.readPromptRunID(session);
629
649
  if (promptRunID) {
630
650
  await this.clientSessionService.AppendPromptRunMessage(
@@ -632,7 +652,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
632
652
  this.mapTranscriptRoleToChatRole(role),
633
653
  text,
634
654
  replacesPrevious ?? false,
635
- contextUser,
655
+ ResolveScopedAnonymousRunUser(contextUser),
636
656
  provider,
637
657
  );
638
658
  }
@@ -691,13 +711,18 @@ export class RealtimeClientSessionResolver extends ResolverBase {
691
711
  return { Success: false, ErrorMessage: 'Recording consent was not granted.' };
692
712
  }
693
713
 
714
+ // SCOPED-ANONYMOUS ELEVATION (issue #3371): past the ownership + consent gates, the store is
715
+ // server-side plumbing over entities (MJ: AI Agents read, MJ: Files, the file-session link)
716
+ // the caller's narrow relay role deliberately does not hold. Attribution flows through the
717
+ // session link, so nothing here depends on the caller's identity.
718
+ const runUser = ResolveScopedAnonymousRunUser(contextUser);
694
719
  try {
695
- const agent = await provider.GetEntityObject<MJAIAgentEntity>('MJ: AI Agents', contextUser);
720
+ const agent = await provider.GetEntityObject<MJAIAgentEntity>('MJ: AI Agents', runUser);
696
721
  if (!(await agent.Load(session.AgentID))) {
697
722
  return { Success: false, ErrorMessage: `Co-agent ${session.AgentID} for the session could not be loaded.` };
698
723
  }
699
724
 
700
- const accountID = await resolveRecordingStorageAccountID(agent, contextUser, provider);
725
+ const accountID = await resolveRecordingStorageAccountID(agent, runUser, provider);
701
726
  if (!accountID) {
702
727
  return { Success: false, ErrorMessage: 'No recording storage account is configured for this agent.' };
703
728
  }
@@ -714,7 +739,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
714
739
  StartedAt: session.RecordingStartedAt ?? new Date(),
715
740
  StorageAccountID: accountID,
716
741
  SessionID: agentSessionId,
717
- ContextUser: contextUser,
742
+ ContextUser: runUser,
718
743
  Provider: provider,
719
744
  // Sanitized capture-time waveform peaks → persisted as a peaks.json sidecar.
720
745
  Peaks: this.sanitizePeaks(peaks),
@@ -722,7 +747,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
722
747
 
723
748
  // Canonical consolidated file written — drop the crash-recovery shards (best-effort).
724
749
  if (fileID) {
725
- await deleteRealtimeRecordingSegments(agentSessionId, accountID, contextUser);
750
+ await deleteRealtimeRecordingSegments(agentSessionId, accountID, runUser);
726
751
  }
727
752
 
728
753
  return {
@@ -762,11 +787,13 @@ export class RealtimeClientSessionResolver extends ResolverBase {
762
787
  try {
763
788
  const { contextUser, provider } = this.requireUserAndProvider(ctx.userPayload, ctx.providers);
764
789
  const session = await this.loadOwnedSession(agentSessionId, contextUser, provider);
765
- const agent = await provider.GetEntityObject<MJAIAgentEntity>('MJ: AI Agents', contextUser);
790
+ // Scoped-anonymous elevation (issue #3371) same rationale as UploadRealtimeRecording.
791
+ const runUser = ResolveScopedAnonymousRunUser(contextUser);
792
+ const agent = await provider.GetEntityObject<MJAIAgentEntity>('MJ: AI Agents', runUser);
766
793
  if (!(await agent.Load(session.AgentID))) {
767
794
  return false;
768
795
  }
769
- const accountID = await resolveRecordingStorageAccountID(agent, contextUser, provider);
796
+ const accountID = await resolveRecordingStorageAccountID(agent, runUser, provider);
770
797
  if (!accountID) {
771
798
  return false;
772
799
  }
@@ -780,7 +807,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
780
807
  Audio: buffer,
781
808
  MimeType: mimeType,
782
809
  StorageAccountID: accountID,
783
- ContextUser: contextUser,
810
+ ContextUser: runUser,
784
811
  });
785
812
  } catch (error) {
786
813
  LogError(`RealtimeClientSessionResolver.UploadRealtimeRecordingSegment failed for session ${agentSessionId}: ${error instanceof Error ? error.message : String(error)}`);
@@ -815,7 +842,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
815
842
  'assistant',
816
843
  this.formatToolTurn(toolName, argsJson, resultJson),
817
844
  false,
818
- contextUser,
845
+ ResolveScopedAnonymousRunUser(contextUser),
819
846
  provider,
820
847
  );
821
848
  }
@@ -913,7 +940,10 @@ export class RealtimeClientSessionResolver extends ResolverBase {
913
940
  }
914
941
  // Delegate to the service so usage writes share the per-run serialization with transcript-message
915
942
  // appends — otherwise the frequent usage save clobbers freshly-appended Messages (and vice-versa).
916
- return this.clientSessionService.AccumulatePromptRunUsage(promptRunID, inputDelta, outputDelta, contextUser, provider);
943
+ // Runs as the scoped-anonymous elevated user (issue #3371) the caller's role holds no prompt-run grants.
944
+ return this.clientSessionService.AccumulatePromptRunUsage(
945
+ promptRunID, inputDelta, outputDelta, ResolveScopedAnonymousRunUser(contextUser), provider,
946
+ );
917
947
  }
918
948
 
919
949
  /** Clamps a relayed token delta: negative / non-finite values become 0. */
@@ -1123,6 +1153,39 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1123
1153
  }
1124
1154
  }
1125
1155
 
1156
+ /**
1157
+ * Narrows a session's colleague union (`allowedAgents`) to the agents the CALLER may run.
1158
+ *
1159
+ * {@link assertCanRunTarget} gates the LEAD target at session start, but the union it travels
1160
+ * with was never gated at all — a model-named colleague resolves straight to a delegated run.
1161
+ * That was survivable while the run carried the caller's own identity, because base-agent
1162
+ * re-checks `CanRun` against `contextUser`. Once the run user is elevated for a scoped anonymous
1163
+ * caller (issue #3371) that check sees the SYSTEM user, so this is the only remaining place the
1164
+ * caller's own authority is applied to a colleague. It therefore runs for EVERY caller, elevated
1165
+ * or not — the authorization identity must never depend on the elevation decision.
1166
+ *
1167
+ * `HasPermission` reads AIEngineBase's in-memory caches (no DB round trip) and already fails
1168
+ * closed on error, so an unresolvable agent drops OUT of the union rather than becoming runnable.
1169
+ * A filtered-out colleague is not an error: the delegation layer reports it as "not available in
1170
+ * this session" and lists what remains, which is the same answer the model gets for a typo.
1171
+ *
1172
+ * @param allowedAgents The session's persisted colleague union (absent/empty ⇒ single-target).
1173
+ * @param contextUser The ORIGINAL caller — never the elevated run user.
1174
+ * @returns The subset the caller may run, preserving order.
1175
+ */
1176
+ private async filterAllowedAgentsByCanRun(
1177
+ allowedAgents: RealtimeAllowedAgent[] | undefined,
1178
+ contextUser: UserInfo,
1179
+ ): Promise<RealtimeAllowedAgent[] | undefined> {
1180
+ if (!allowedAgents || allowedAgents.length === 0) {
1181
+ return allowedAgents;
1182
+ }
1183
+ const verdicts = await Promise.all(
1184
+ allowedAgents.map((a) => AIAgentPermissionHelper.HasPermission(a.agentId, contextUser, 'run')),
1185
+ );
1186
+ return allowedAgents.filter((_, i) => verdicts[i]);
1187
+ }
1188
+
1126
1189
  /**
1127
1190
  * Resolves the AUTHORITATIVE target agent id under the co-agent's PAIRING CONSTRAINTS
1128
1191
  * (`MJ: AI Agent Co Agents`, ordered by `Sequence`):
@@ -1584,7 +1647,12 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1584
1647
  ApplicationID: applicationId,
1585
1648
  AppContext: appContext,
1586
1649
  },
1587
- contextUser,
1650
+ // SCOPED-ANONYMOUS ELEVATION (issue #3371): the prepare creates the co-agent
1651
+ // observability AIAgentRun/AIPromptRun/run-step, which a scoped anonymous caller's role
1652
+ // deliberately cannot write. `UserID` above stays the CALLER's id, so run attribution
1653
+ // and memory scope remain the visitor's. Authorization (CanRun, runtime overrides)
1654
+ // already ran on the caller in StartRealtimeClientSession.
1655
+ ResolveScopedAnonymousRunUser(contextUser),
1588
1656
  provider,
1589
1657
  );
1590
1658
 
@@ -2190,7 +2258,9 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2190
2258
  detail.HiddenToUser = true;
2191
2259
  detail.Message = 'Artifacts produced during a realtime session (system anchor).';
2192
2260
  detail.AgentSessionID = session.ID;
2193
- detail.UserID = contextUser.ID;
2261
+ // Attribute the anchor to the SESSION owner, not the (possibly elevated) writer — identical
2262
+ // for every non-elevated caller, whose ownership of the session is already proven.
2263
+ detail.UserID = session.UserID;
2194
2264
  if (await detail.Save()) {
2195
2265
  return detail.ID;
2196
2266
  }
@@ -750,18 +750,15 @@ export class RemoteBrowserActionResolver extends ResolverBase {
750
750
  agentSessionID: string,
751
751
  frame: { DataBase64: string; Width: number; Height: number; SequenceNumber: number },
752
752
  ): void {
753
- pubSub.publish(PUSH_STATUS_UPDATES_TOPIC, {
754
- message: JSON.stringify({
755
- resolver: 'RemoteBrowserActionResolver',
756
- type: 'RemoteBrowserScreencastFrame',
757
- agentSessionID,
758
- dataBase64: frame.DataBase64,
759
- width: frame.Width,
760
- height: frame.Height,
761
- seq: frame.SequenceNumber,
762
- }),
763
- sessionId: userPayload.sessionId,
764
- });
753
+ this.PublishStatusUpdate(pubSub, userPayload.sessionId, JSON.stringify({
754
+ resolver: 'RemoteBrowserActionResolver',
755
+ type: 'RemoteBrowserScreencastFrame',
756
+ agentSessionID,
757
+ dataBase64: frame.DataBase64,
758
+ width: frame.Width,
759
+ height: frame.Height,
760
+ seq: frame.SequenceNumber,
761
+ }), userPayload);
765
762
  }
766
763
 
767
764
  /**
@@ -775,19 +772,16 @@ export class RemoteBrowserActionResolver extends ResolverBase {
775
772
  * @param chunk The encoded audio chunk.
776
773
  */
777
774
  private publishAudioChunk(pubSub: PubSubEngine, userPayload: UserPayload, agentSessionID: string, chunk: RemoteBrowserAudioChunk): void {
778
- pubSub.publish(PUSH_STATUS_UPDATES_TOPIC, {
779
- message: JSON.stringify({
780
- resolver: 'RemoteBrowserActionResolver',
781
- type: 'RemoteBrowserAudioChunk',
782
- agentSessionID,
783
- dataBase64: chunk.DataBase64,
784
- codec: chunk.Codec,
785
- sampleRate: chunk.SampleRate,
786
- channels: chunk.Channels,
787
- seq: chunk.SequenceNumber,
788
- }),
789
- sessionId: userPayload.sessionId,
790
- });
775
+ this.PublishStatusUpdate(pubSub, userPayload.sessionId, JSON.stringify({
776
+ resolver: 'RemoteBrowserActionResolver',
777
+ type: 'RemoteBrowserAudioChunk',
778
+ agentSessionID,
779
+ dataBase64: chunk.DataBase64,
780
+ codec: chunk.Codec,
781
+ sampleRate: chunk.SampleRate,
782
+ channels: chunk.Channels,
783
+ seq: chunk.SequenceNumber,
784
+ }), userPayload);
791
785
  }
792
786
 
793
787
  /**
@@ -3,7 +3,6 @@ import { Arg, Ctx, Field, Int, Mutation, ObjectType, Query, Resolver } from 'typ
3
3
  import { AppContext } from '../types.js';
4
4
  import { MJConversationDetailEntity, MJReportEntity } from '@memberjunction/core-entities';
5
5
  import { DataContext } from '@memberjunction/data-context';
6
- import { UserCache } from '@memberjunction/sqlserver-dataprovider';
7
6
  import { z } from 'zod';
8
7
  import mssql from 'mssql';
9
8
  import { GetReadOnlyProvider, GetReadWriteProvider } from '../util.js';
@@ -80,7 +79,15 @@ export class ReportResolverExtended extends ResolverBase {
80
79
  try {
81
80
  const md = GetReadWriteProvider(providers);
82
81
 
83
- const u = UserCache.Users.find((u) => u.Email?.trim().toLowerCase() === userPayload?.email?.trim().toLowerCase());
82
+ // Resolve via the stamped payload user (GetUserFromPayload), NOT a fresh UserCache
83
+ // lookup by email. UserCache.Users returns the shared, unstamped instance — re-fetching
84
+ // by email here would silently drop any per-request API-key row-filter binding
85
+ // (APIKeyRowFilters / APIKeyActingContext) already stamped onto userPayload.userRecord,
86
+ // so a filtered key's report-creation work would run unfiltered. GetUserFromPayload
87
+ // prefers userPayload.userRecord and only falls back to a UserCache email lookup when
88
+ // no payload user is present (e.g. non-request-scoped callers), matching every other
89
+ // resolver's contextUser resolution.
90
+ const u = this.GetUserFromPayload(userPayload);
84
91
  if (!u) throw new Error('Unable to find user');
85
92
 
86
93
  const cde = md.Entities.find((e) => e.Name === 'MJ: Conversation Details');
@@ -98,9 +105,10 @@ export class ReportResolverExtended extends ResolverBase {
98
105
  ON
99
106
  cd.ConversationID = c.ID
100
107
  WHERE
101
- cd.ID='${ConversationDetailID}'`;
108
+ cd.ID=@ConversationDetailID`;
102
109
 
103
110
  const request = new mssql.Request(dataSource);
111
+ request.input('ConversationDetailID', mssql.UniqueIdentifier, ConversationDetailID);
104
112
  const result = await request.query(sql);
105
113
  if (!result || !result.recordset || result.recordset.length === 0) throw new Error('Unable to retrieve converation details');
106
114
  const skipData: { title?: string; reportTitle?: string; userExplanation?: string; messages?: unknown[] } = JSON.parse(result.recordset[0].Message);
@@ -8,7 +8,6 @@ import { MJAIAgentEntityExtended, MJAIAgentRunEntityExtended, ExecuteAgentResult
8
8
  import { AIEngine } from '@memberjunction/aiengine';
9
9
  import { ChatMessage, ChatMessageContent } from '@memberjunction/ai';
10
10
  import { ResolverBase } from '../generic/ResolverBase.js';
11
- import { PUSH_STATUS_UPDATES_TOPIC } from '../generic/PushStatusResolver.js';
12
11
  import { startLivenessPulse } from '../generic/FireAndForgetHeartbeat.js';
13
12
  import { RequireSystemUser } from '../directives/RequireSystemUser.js';
14
13
  import { GetReadWriteProvider } from '../util.js';
@@ -306,28 +305,22 @@ export class RunAIAgentResolver extends ResolverBase {
306
305
  }
307
306
 
308
307
  private PublishProgressUpdate(pubSub: PubSubEngine, data: any, userPayload: UserPayload) {
309
- pubSub.publish(PUSH_STATUS_UPDATES_TOPIC, {
310
- message: JSON.stringify({
311
- resolver: 'RunAIAgentResolver',
312
- type: 'ExecutionProgress',
313
- status: 'ok',
314
- data,
315
- }),
316
- sessionId: userPayload.sessionId,
317
- });
308
+ this.PublishStatusUpdate(pubSub, userPayload.sessionId, JSON.stringify({
309
+ resolver: 'RunAIAgentResolver',
310
+ type: 'ExecutionProgress',
311
+ status: 'ok',
312
+ data,
313
+ }), userPayload);
318
314
  }
319
315
 
320
316
 
321
317
  private PublishStreamingUpdate(pubSub: PubSubEngine, data: any, userPayload: UserPayload) {
322
- pubSub.publish(PUSH_STATUS_UPDATES_TOPIC, {
323
- message: JSON.stringify({
324
- resolver: 'RunAIAgentResolver',
325
- type: 'StreamingContent',
326
- status: 'ok',
327
- data,
328
- }),
329
- sessionId: userPayload.sessionId,
330
- });
318
+ this.PublishStatusUpdate(pubSub, userPayload.sessionId, JSON.stringify({
319
+ resolver: 'RunAIAgentResolver',
320
+ type: 'StreamingContent',
321
+ status: 'ok',
322
+ data,
323
+ }), userPayload);
331
324
  }
332
325
 
333
326
  /**
@@ -814,17 +807,15 @@ export class RunAIAgentResolver extends ResolverBase {
814
807
  LogStatus(`📬 Notification sent via ${channelList} (ID: ${result.inAppNotificationId})`);
815
808
 
816
809
  // Publish real-time notification event so client updates immediately
817
- pubSub.publish(PUSH_STATUS_UPDATES_TOPIC, {
818
- userPayload: JSON.stringify(userPayload),
819
- message: JSON.stringify({
820
- type: 'notification',
821
- notificationId: result.inAppNotificationId,
822
- action: 'create',
823
- title: `${agentName} completed your request`,
824
- message: message,
825
- conversationId: conversationId
826
- })
827
- });
810
+ // NOTE (B49): normalized from a malformed no-sessionId payload
811
+ this.PublishStatusUpdate(pubSub, userPayload.sessionId, JSON.stringify({
812
+ type: 'notification',
813
+ notificationId: result.inAppNotificationId,
814
+ action: 'create',
815
+ title: `${agentName} completed your request`,
816
+ message: message,
817
+ conversationId: conversationId
818
+ }), userPayload);
828
819
 
829
820
  LogStatus(`📡 Published notification event to client`);
830
821
  } else if (!result.success) {
@@ -926,16 +917,14 @@ export class RunAIAgentResolver extends ResolverBase {
926
917
  LogStatus(`📬 Feedback request notification sent (ID: ${notifResult.inAppNotificationId})`);
927
918
 
928
919
  // Publish real-time notification event
929
- pubSub.publish(PUSH_STATUS_UPDATES_TOPIC, {
930
- userPayload: JSON.stringify(userPayload),
931
- message: JSON.stringify({
932
- type: 'notification',
933
- notificationId: notifResult.inAppNotificationId,
934
- action: 'create',
935
- title: `${agentName} needs your input`,
936
- message: truncatedMessage
937
- })
938
- });
920
+ // NOTE (B49): normalized from a malformed no-sessionId payload
921
+ this.PublishStatusUpdate(pubSub, userPayload.sessionId, JSON.stringify({
922
+ type: 'notification',
923
+ notificationId: notifResult.inAppNotificationId,
924
+ action: 'create',
925
+ title: `${agentName} needs your input`,
926
+ message: truncatedMessage
927
+ }), userPayload);
939
928
  } else if (!notifResult.success) {
940
929
  LogError(`Feedback request notification failed: ${notifResult.errors?.join(', ')}`);
941
930
  }
@@ -1294,6 +1283,7 @@ export class RunAIAgentResolver extends ResolverBase {
1294
1283
  const pulse = startLivenessPulse({
1295
1284
  pubSub,
1296
1285
  sessionId,
1286
+ ownerUserId: userPayload.userRecord.ID,
1297
1287
  resolver: 'RunAIAgentResolver',
1298
1288
  readStatus: () => runRef.current
1299
1289
  ? { runId: runRef.current.ID, status: runRef.current.Status }