@memberjunction/server 5.51.3 → 5.51.4

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 (211) hide show
  1. package/README.md +34 -1
  2. package/dist/agentSessions/SessionManager.d.ts +5 -2
  3. package/dist/agentSessions/SessionManager.d.ts.map +1 -1
  4. package/dist/agentSessions/SessionManager.js +17 -8
  5. package/dist/agentSessions/SessionManager.js.map +1 -1
  6. package/dist/agentSessions/SessionRunIDVerifier.d.ts +63 -0
  7. package/dist/agentSessions/SessionRunIDVerifier.d.ts.map +1 -0
  8. package/dist/agentSessions/SessionRunIDVerifier.js +136 -0
  9. package/dist/agentSessions/SessionRunIDVerifier.js.map +1 -0
  10. package/dist/agentSessions/index.d.ts +1 -0
  11. package/dist/agentSessions/index.d.ts.map +1 -1
  12. package/dist/agentSessions/index.js +1 -0
  13. package/dist/agentSessions/index.js.map +1 -1
  14. package/dist/auth/actionAuthorization.d.ts +40 -0
  15. package/dist/auth/actionAuthorization.d.ts.map +1 -0
  16. package/dist/auth/actionAuthorization.js +94 -0
  17. package/dist/auth/actionAuthorization.js.map +1 -0
  18. package/dist/auth/index.d.ts +1 -0
  19. package/dist/auth/index.d.ts.map +1 -1
  20. package/dist/auth/index.js +1 -0
  21. package/dist/auth/index.js.map +1 -1
  22. package/dist/auth/scopeLimitedPrincipal.d.ts +39 -0
  23. package/dist/auth/scopeLimitedPrincipal.d.ts.map +1 -0
  24. package/dist/auth/scopeLimitedPrincipal.js +49 -0
  25. package/dist/auth/scopeLimitedPrincipal.js.map +1 -0
  26. package/dist/config.d.ts +53 -0
  27. package/dist/config.d.ts.map +1 -1
  28. package/dist/config.js +34 -1
  29. package/dist/config.js.map +1 -1
  30. package/dist/context.d.ts +6 -0
  31. package/dist/context.d.ts.map +1 -1
  32. package/dist/context.js +24 -34
  33. package/dist/context.js.map +1 -1
  34. package/dist/generated/generated.d.ts.map +1 -1
  35. package/dist/generated/generated.js +3262 -3273
  36. package/dist/generated/generated.js.map +1 -1
  37. package/dist/generic/CacheInvalidationResolver.d.ts +124 -4
  38. package/dist/generic/CacheInvalidationResolver.d.ts.map +1 -1
  39. package/dist/generic/CacheInvalidationResolver.js +170 -15
  40. package/dist/generic/CacheInvalidationResolver.js.map +1 -1
  41. package/dist/generic/FireAndForgetHeartbeat.d.ts +5 -0
  42. package/dist/generic/FireAndForgetHeartbeat.d.ts.map +1 -1
  43. package/dist/generic/FireAndForgetHeartbeat.js +5 -4
  44. package/dist/generic/FireAndForgetHeartbeat.js.map +1 -1
  45. package/dist/generic/PushStatusResolver.d.ts +58 -0
  46. package/dist/generic/PushStatusResolver.d.ts.map +1 -1
  47. package/dist/generic/PushStatusResolver.js +42 -3
  48. package/dist/generic/PushStatusResolver.js.map +1 -1
  49. package/dist/generic/ResolverBase.d.ts +148 -3
  50. package/dist/generic/ResolverBase.d.ts.map +1 -1
  51. package/dist/generic/ResolverBase.js +287 -30
  52. package/dist/generic/ResolverBase.js.map +1 -1
  53. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  54. package/dist/generic/RunViewResolver.js +12 -0
  55. package/dist/generic/RunViewResolver.js.map +1 -1
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +56 -48
  58. package/dist/index.js.map +1 -1
  59. package/dist/multiTenancy/index.d.ts.map +1 -1
  60. package/dist/multiTenancy/index.js +22 -2
  61. package/dist/multiTenancy/index.js.map +1 -1
  62. package/dist/postgresqlCompat.d.ts +8 -0
  63. package/dist/postgresqlCompat.d.ts.map +1 -0
  64. package/dist/postgresqlCompat.js +19 -0
  65. package/dist/postgresqlCompat.js.map +1 -0
  66. package/dist/postgresqlPoolSettings.d.ts +108 -0
  67. package/dist/postgresqlPoolSettings.d.ts.map +1 -0
  68. package/dist/postgresqlPoolSettings.js +134 -0
  69. package/dist/postgresqlPoolSettings.js.map +1 -0
  70. package/dist/realtimeWidget/widgetGuestElevation.d.ts +2 -2
  71. package/dist/realtimeWidget/widgetGuestElevation.js +2 -2
  72. package/dist/resolvers/ActionResolver.d.ts +31 -5
  73. package/dist/resolvers/ActionResolver.d.ts.map +1 -1
  74. package/dist/resolvers/ActionResolver.js +75 -14
  75. package/dist/resolvers/ActionResolver.js.map +1 -1
  76. package/dist/resolvers/AdhocQueryResolver.d.ts +39 -17
  77. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  78. package/dist/resolvers/AdhocQueryResolver.js +97 -157
  79. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  80. package/dist/resolvers/DatasetResolver.d.ts +24 -3
  81. package/dist/resolvers/DatasetResolver.d.ts.map +1 -1
  82. package/dist/resolvers/DatasetResolver.js +57 -8
  83. package/dist/resolvers/DatasetResolver.js.map +1 -1
  84. package/dist/resolvers/EntityRecordNameResolver.d.ts +14 -4
  85. package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
  86. package/dist/resolvers/EntityRecordNameResolver.js +18 -6
  87. package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
  88. package/dist/resolvers/GetDataContextDataResolver.d.ts +33 -7
  89. package/dist/resolvers/GetDataContextDataResolver.d.ts.map +1 -1
  90. package/dist/resolvers/GetDataContextDataResolver.js +117 -38
  91. package/dist/resolvers/GetDataContextDataResolver.js.map +1 -1
  92. package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
  93. package/dist/resolvers/GetDataResolver.js +13 -1
  94. package/dist/resolvers/GetDataResolver.js.map +1 -1
  95. package/dist/resolvers/MCPResolver.d.ts.map +1 -1
  96. package/dist/resolvers/MCPResolver.js +13 -16
  97. package/dist/resolvers/MCPResolver.js.map +1 -1
  98. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +26 -14
  99. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  100. package/dist/resolvers/RealtimeClientSessionResolver.js +63 -46
  101. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  102. package/dist/resolvers/RemoteBrowserActionResolver.d.ts +2 -0
  103. package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
  104. package/dist/resolvers/RemoteBrowserActionResolver.js +24 -27
  105. package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
  106. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  107. package/dist/resolvers/RunAIAgentResolver.js +30 -40
  108. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  109. package/dist/resolvers/RunTemplateResolver.d.ts.map +1 -1
  110. package/dist/resolvers/RunTemplateResolver.js +12 -11
  111. package/dist/resolvers/RunTemplateResolver.js.map +1 -1
  112. package/dist/resolvers/RunTestResolver.d.ts.map +1 -1
  113. package/dist/resolvers/RunTestResolver.js +90 -112
  114. package/dist/resolvers/RunTestResolver.js.map +1 -1
  115. package/dist/resolvers/SyncRolesUsersResolver.d.ts.map +1 -1
  116. package/dist/resolvers/SyncRolesUsersResolver.js +11 -2
  117. package/dist/resolvers/SyncRolesUsersResolver.js.map +1 -1
  118. package/dist/resolvers/VersionHistoryResolver.d.ts.map +1 -1
  119. package/dist/resolvers/VersionHistoryResolver.js +8 -12
  120. package/dist/resolvers/VersionHistoryResolver.js.map +1 -1
  121. package/dist/resolvers/adhocTimeout.d.ts +7 -0
  122. package/dist/resolvers/adhocTimeout.d.ts.map +1 -0
  123. package/dist/resolvers/adhocTimeout.js +12 -0
  124. package/dist/resolvers/adhocTimeout.js.map +1 -0
  125. package/dist/rest/ViewOperationsHandler.d.ts +3 -1
  126. package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
  127. package/dist/rest/ViewOperationsHandler.js +29 -17
  128. package/dist/rest/ViewOperationsHandler.js.map +1 -1
  129. package/dist/services/TaskOrchestrator.d.ts.map +1 -1
  130. package/dist/services/TaskOrchestrator.js +17 -15
  131. package/dist/services/TaskOrchestrator.js.map +1 -1
  132. package/package.json +90 -89
  133. package/src/__tests__/ActionResolver.authorization.test.ts +353 -0
  134. package/src/__tests__/AdhocQueryResolver.bugs.test.ts +14 -35
  135. package/src/__tests__/AdhocQueryResolver.pagination.test.ts +6 -2
  136. package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +84 -0
  137. package/src/__tests__/AdhocQueryResolver.sqlAuthorizer.test.ts +104 -0
  138. package/src/__tests__/DatasetResolver.itemFilters.test.ts +201 -0
  139. package/src/__tests__/EntityRecordNameResolver.rowSecurity.test.ts +152 -0
  140. package/src/__tests__/GetDataContextDataResolver.sqlItems.test.ts +416 -0
  141. package/src/__tests__/ListOperationsResolver.adhocScreen.test.ts +136 -0
  142. package/src/__tests__/RealtimeClientSessionResolver.configTamper.test.ts +318 -0
  143. package/src/__tests__/RealtimeClientSessionResolver.test.ts +66 -27
  144. package/src/__tests__/RemoteBrowserActionResolver.goalRunLink.test.ts +134 -0
  145. package/src/__tests__/ResolverBase.adhocReadScope.test.ts +221 -0
  146. package/src/__tests__/ResolverBase.aggregateScreen.test.ts +173 -0
  147. package/src/__tests__/ResolverBase.balancedClause.test.ts +155 -0
  148. package/src/__tests__/ResolverBase.clauseUnscopedRead.test.ts +116 -0
  149. package/src/__tests__/ResolverBase.filterEscaping.test.ts +351 -0
  150. package/src/__tests__/RunTemplateResolver.test.ts +132 -0
  151. package/src/__tests__/RunViewResolver.cacheCheckScreen.test.ts +164 -0
  152. package/src/__tests__/SessionManager.test.ts +88 -13
  153. package/src/__tests__/SessionRunIDVerifier.test.ts +255 -0
  154. package/src/__tests__/UpdateMJDataContextItem.oldValues.test.ts +220 -0
  155. package/src/__tests__/ViewOperationsHandler.clauseScreen.test.ts +155 -0
  156. package/src/__tests__/actionAuthorization.test.ts +130 -0
  157. package/src/__tests__/adhocTimeoutClamp.test.ts +26 -0
  158. package/src/__tests__/cacheInvalidationRecordData.test.ts +241 -0
  159. package/src/__tests__/multiTenancy.postgresRewrite.test.ts +180 -0
  160. package/src/__tests__/multiTenancy.security.test.ts +51 -0
  161. package/src/__tests__/multiTenancy.test.ts +1 -1
  162. package/src/__tests__/newUserRolesDefault.test.ts +81 -0
  163. package/src/__tests__/postgresqlPoolSettings.test.ts +131 -0
  164. package/src/__tests__/pushStatusResolver.filter.test.ts +56 -0
  165. package/src/__tests__/resolverBase.refusalMessage.test.ts +88 -0
  166. package/src/__tests__/resolverBase.rls.test.ts +2 -0
  167. package/src/__tests__/translateBracketsToPG.test.ts +20 -0
  168. package/src/agentSessions/SessionManager.ts +24 -25
  169. package/src/agentSessions/SessionRunIDVerifier.ts +179 -0
  170. package/src/agentSessions/index.ts +1 -0
  171. package/src/auth/actionAuthorization.ts +119 -0
  172. package/src/auth/index.ts +1 -0
  173. package/src/auth/scopeLimitedPrincipal.ts +50 -0
  174. package/src/config.ts +34 -1
  175. package/src/context.ts +25 -34
  176. package/src/generated/generated.ts +3262 -3273
  177. package/src/generic/CacheInvalidationResolver.ts +206 -15
  178. package/src/generic/FireAndForgetHeartbeat.ts +10 -4
  179. package/src/generic/PushStatusResolver.ts +79 -4
  180. package/src/generic/ResolverBase.ts +339 -31
  181. package/src/generic/RunViewResolver.ts +17 -0
  182. package/src/index.ts +63 -50
  183. package/src/multiTenancy/index.ts +24 -2
  184. package/src/postgresqlCompat.ts +21 -0
  185. package/src/postgresqlPoolSettings.ts +213 -0
  186. package/src/realtimeWidget/widgetGuestElevation.ts +2 -2
  187. package/src/resolvers/ActionResolver.ts +85 -22
  188. package/src/resolvers/AdhocQueryResolver.ts +110 -177
  189. package/src/resolvers/DatasetResolver.ts +70 -10
  190. package/src/resolvers/EntityRecordNameResolver.ts +24 -7
  191. package/src/resolvers/GetDataContextDataResolver.ts +139 -41
  192. package/src/resolvers/GetDataResolver.ts +14 -1
  193. package/src/resolvers/MCPResolver.ts +13 -15
  194. package/src/resolvers/RealtimeClientSessionResolver.ts +82 -50
  195. package/src/resolvers/RemoteBrowserActionResolver.ts +30 -26
  196. package/src/resolvers/RunAIAgentResolver.ts +30 -40
  197. package/src/resolvers/RunTemplateResolver.ts +13 -12
  198. package/src/resolvers/RunTestResolver.ts +90 -112
  199. package/src/resolvers/SyncRolesUsersResolver.ts +11 -2
  200. package/src/resolvers/VersionHistoryResolver.ts +10 -13
  201. package/src/resolvers/__tests__/GetDataResolver.validation.test.ts +95 -0
  202. package/src/resolvers/__tests__/ReportResolver.test.ts +2 -0
  203. package/src/resolvers/adhocTimeout.ts +12 -0
  204. package/src/rest/ViewOperationsHandler.ts +37 -18
  205. package/src/services/TaskOrchestrator.ts +17 -17
  206. package/dist/resolvers/adhoc-query-helpers.d.ts +0 -27
  207. package/dist/resolvers/adhoc-query-helpers.d.ts.map +0 -1
  208. package/dist/resolvers/adhoc-query-helpers.js +0 -42
  209. package/dist/resolvers/adhoc-query-helpers.js.map +0 -1
  210. package/src/__tests__/adhoc-query-helpers.test.ts +0 -50
  211. package/src/resolvers/adhoc-query-helpers.ts +0 -50
@@ -11,9 +11,9 @@
11
11
  * and **executes** every tool call the browser relays back. The three mutations here cover the MVP:
12
12
  *
13
13
  * 1. {@link RealtimeClientSessionResolver.StartRealtimeClientSession} — authorize, create the
14
- * session (storing the target agent id server-side, authoritatively), mint the ephemeral config.
15
- * 2. {@link RealtimeClientSessionResolver.ExecuteRealtimeSessionTool} — execute a relayed tool call,
16
- * reading the target agent id from the session (NOT the client) so the browser can't swap targets.
14
+ * session (storing the target agent id on it), mint the ephemeral config.
15
+ * 2. {@link RealtimeClientSessionResolver.ExecuteRealtimeSessionTool} — execute a relayed tool call
16
+ * for the target agent stored on the session, re-authorized for the caller on every call.
17
17
  * 3. {@link RealtimeClientSessionResolver.RelayRealtimeTranscript} — persist a transcript turn as a
18
18
  * `Conversation Detail`.
19
19
  *
@@ -21,6 +21,9 @@
21
21
  * - **Start**: the caller must have `CanRun` on the *target* agent (the Realtime Co-Agent is an internal
22
22
  * orchestration agent, so the meaningful gate is the agent doing the real work).
23
23
  * - **Execute / Relay**: inbound ownership — the session's `UserID` must equal `contextUser.ID`.
24
+ * - **Session `Config`**: the owner can edit it through the generic entity API, so nothing in it is
25
+ * trusted. Execute re-checks `CanRun` on its target, and its run ids are used only when
26
+ * {@link SessionRunIDVerifier} finds them to be records of the session.
24
27
  *
25
28
  * @module @memberjunction/server
26
29
  */
@@ -60,6 +63,7 @@ import { ResolverBase } from '../generic/ResolverBase.js';
60
63
  import { PUSH_STATUS_UPDATES_TOPIC } from '../generic/PushStatusResolver.js';
61
64
  import { GetReadWriteProvider } from '../util.js';
62
65
  import { SessionManager } from '../agentSessions/index.js';
66
+ import { SessionRunIDVerifier } from '../agentSessions/SessionRunIDVerifier.js';
63
67
  import { resolveWidgetGuestRunContext, ResolveScopedAnonymousRunUser } from '../realtimeWidget/widgetGuestElevation.js';
64
68
 
65
69
  /**
@@ -135,9 +139,9 @@ const MAX_PRIOR_TRANSCRIPT_CHARS = 8_000;
135
139
  const MAX_PRIOR_TRANSCRIPT_LEGS = 5;
136
140
 
137
141
  /**
138
- * Authoritative shape persisted in `AIAgentSession.Config_` for a client-direct voice session.
139
- * The target agent id is stored here at start and read back on every relay — the browser never
140
- * re-supplies it, so it cannot swap targets mid-session.
142
+ * Shape persisted in `AIAgentSession.Config_` for a client-direct voice session. The server writes
143
+ * it, but the session owner can also edit it through the generic entity API, so relays re-check
144
+ * what they read from it (see the authorization model above).
141
145
  */
142
146
  interface RealtimeSessionConfig {
143
147
  /** The top-level target agent the co-agent voices on behalf of. */
@@ -351,6 +355,7 @@ export class UploadRealtimeRecordingResult {
351
355
  export class RealtimeClientSessionResolver extends ResolverBase {
352
356
  private readonly sessionManager = new SessionManager();
353
357
  private readonly clientSessionService = new RealtimeClientSessionService();
358
+ private readonly runIDVerifier = new SessionRunIDVerifier();
354
359
 
355
360
  /**
356
361
  * Start a client-direct realtime voice session targeting `targetAgentId`.
@@ -362,7 +367,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
362
367
  * agent's `DefaultCoAgentID` → type-level `AIAgentCoAgent` default row → global Realtime Co-Agent) —
363
368
  * see {@link RealtimeClientSessionResolver.resolveCoAgentID} for the full contract.
364
369
  * 3. Create the durable `AIAgentSession` (run by the co-agent), storing `targetAgentID` in its
365
- * config server-side — this is the authoritative target for all later relays.
370
+ * config — the target later relays use, re-authorized for the caller on each relay.
366
371
  * 4. Mint the {@link import('@memberjunction/ai').ClientRealtimeSessionConfig} via the service.
367
372
  * On failure the just-created session is closed so no half-open session leaks.
368
373
  *
@@ -477,8 +482,10 @@ export class RealtimeClientSessionResolver extends ResolverBase {
477
482
  * Execute a single tool call the browser relayed from its provider socket and return the
478
483
  * serialized result for the browser to relay back to the model.
479
484
  *
480
- * Ownership-gated; the target agent id is read from the **session config** (authoritative), never
481
- * from the client. Heartbeats the session on success.
485
+ * Ownership-gated. The target agent id is read from the session config and `CanRun` on it is
486
+ * checked for the caller on every call, because the owner can edit the config. A direct-action
487
+ * call from a scope-limited caller is refused before any elevation. Heartbeats the session on
488
+ * success.
482
489
  *
483
490
  * @returns The serialized tool result JSON.
484
491
  */
@@ -494,13 +501,20 @@ export class RealtimeClientSessionResolver extends ResolverBase {
494
501
  const { contextUser, provider } = this.requireUserAndProvider(userPayload, providers);
495
502
  const session = await this.loadOwnedActiveSession(agentSessionId, contextUser, provider);
496
503
  const config = this.readSessionConfig(session);
504
+ await this.assertCanRunTarget(config.targetAgentID, contextUser, provider);
497
505
 
498
506
  // SCOPED-ANONYMOUS ELEVATION (issue #3371): once ownership is proven above, the delegated
499
507
  // run + its AI-run-entity writes execute as the system user for a scoped anonymous caller
500
508
  // (the caller's role deliberately holds no grants on the run entities). The lead
501
- // targetAgentID comes from the session config and was CanRun-gated at start; the colleague
502
- // union is gated just below, against the CALLER, so elevation never widens agent authority.
509
+ // targetAgentID is CanRun-gated against the CALLER just above, and the colleague union
510
+ // just below, so elevation never widens agent authority.
503
511
  const runUser = ResolveScopedAnonymousRunUser(contextUser);
512
+ const runIDs = await this.runIDVerifier.Verify(
513
+ session.ID,
514
+ { CoAgentRunID: config.coAgentRunID, PendingFeedbackRunID: config.pendingFeedbackRunID },
515
+ runUser,
516
+ provider,
517
+ );
504
518
  if (runUser !== contextUser) {
505
519
  LogStatus(
506
520
  `ExecuteRealtimeSessionTool: dispatching relayed tool '${toolName}' for session ${agentSessionId} ` +
@@ -518,11 +532,11 @@ export class RealtimeClientSessionResolver extends ResolverBase {
518
532
  // row and its context-memory scope must stay the person's, not the system user's.
519
533
  AttributionUserID: contextUser.ID,
520
534
  // Nest the delegated target-agent run under the co-agent observability run (when present).
521
- ParentRunID: config.coAgentRunID,
535
+ ParentRunID: runIDs.CoAgentRunID,
522
536
  Call: { CallID: callId, ToolName: toolName, Arguments: argsJson },
523
537
  OnProgress: this.buildDelegationProgressCallback(pubSub, userPayload, agentSessionId, callId),
524
538
  // Resume a previously-paused delegated run (if any) with the user's answer.
525
- ResumeRunID: config.pendingFeedbackRunID,
539
+ ResumeRunID: runIDs.PendingFeedbackRunID,
526
540
  },
527
541
  runUser,
528
542
  provider,
@@ -558,21 +572,19 @@ export class RealtimeClientSessionResolver extends ResolverBase {
558
572
  if (!SIGNIFICANT_PROGRESS_STEPS.includes(progress.step)) {
559
573
  return;
560
574
  }
561
- pubSub.publish(PUSH_STATUS_UPDATES_TOPIC, {
562
- message: JSON.stringify({
563
- resolver: 'RealtimeClientSessionResolver',
564
- type: 'RealtimeDelegationProgress',
565
- agentSessionID,
566
- callID,
567
- step: progress.step,
568
- message: progress.message,
569
- percentage: progress.percentage,
570
- }),
571
- sessionId: userPayload.sessionId,
572
- });
575
+ this.PublishStatusUpdate(pubSub, userPayload.sessionId, JSON.stringify({
576
+ resolver: 'RealtimeClientSessionResolver',
577
+ type: 'RealtimeDelegationProgress',
578
+ agentSessionID,
579
+ callID,
580
+ step: progress.step,
581
+ message: progress.message,
582
+ percentage: progress.percentage,
583
+ }), userPayload);
573
584
  };
574
585
  }
575
586
 
587
+
576
588
  /**
577
589
  * Rolls the session's `pendingFeedbackRunID` forward after a relayed tool call. The id we just
578
590
  * consumed (resumed or none) is dropped; a new paused run id is stored only when the run paused
@@ -643,14 +655,15 @@ export class RealtimeClientSessionResolver extends ResolverBase {
643
655
  // conversation (run-viewer observability parity). Best-effort — never fails the transcript relay.
644
656
  // The prompt-run write runs as the scoped-anonymous elevated user (issue #3371) — the visible
645
657
  // Conversation Detail above deliberately stays on the caller.
646
- const promptRunID = this.readPromptRunID(session);
658
+ const runUser = ResolveScopedAnonymousRunUser(contextUser);
659
+ const promptRunID = await this.readPromptRunID(session, runUser, provider);
647
660
  if (promptRunID) {
648
661
  await this.clientSessionService.AppendPromptRunMessage(
649
662
  promptRunID,
650
663
  this.mapTranscriptRoleToChatRole(role),
651
664
  text,
652
665
  replacesPrevious ?? false,
653
- ResolveScopedAnonymousRunUser(contextUser),
666
+ runUser,
654
667
  provider,
655
668
  );
656
669
  }
@@ -831,7 +844,8 @@ export class RealtimeClientSessionResolver extends ResolverBase {
831
844
  ): Promise<boolean> {
832
845
  const { contextUser, provider } = this.requireUserAndProvider(userPayload, providers);
833
846
  const session = await this.loadOwnedActiveSession(agentSessionId, contextUser, provider);
834
- const promptRunID = this.readPromptRunID(session);
847
+ const runUser = ResolveScopedAnonymousRunUser(contextUser);
848
+ const promptRunID = await this.readPromptRunID(session, runUser, provider);
835
849
  if (!promptRunID) {
836
850
  return false;
837
851
  }
@@ -840,7 +854,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
840
854
  'assistant',
841
855
  this.formatToolTurn(toolName, argsJson, resultJson),
842
856
  false,
843
- ResolveScopedAnonymousRunUser(contextUser),
857
+ runUser,
844
858
  provider,
845
859
  );
846
860
  }
@@ -932,16 +946,15 @@ export class RealtimeClientSessionResolver extends ResolverBase {
932
946
  return true; // nothing to add — a no-op flush is a success, not a failure
933
947
  }
934
948
 
935
- const promptRunID = this.readPromptRunID(session);
949
+ // Runs as the scoped-anonymous elevated user (issue #3371) — the caller's role holds no prompt-run grants.
950
+ const runUser = ResolveScopedAnonymousRunUser(contextUser);
951
+ const promptRunID = await this.readPromptRunID(session, runUser, provider);
936
952
  if (!promptRunID) {
937
953
  return false; // no observability prompt run for this session — logged in the helper
938
954
  }
939
955
  // Delegate to the service so usage writes share the per-run serialization with transcript-message
940
956
  // appends — otherwise the frequent usage save clobbers freshly-appended Messages (and vice-versa).
941
- // Runs as the scoped-anonymous elevated user (issue #3371) — the caller's role holds no prompt-run grants.
942
- return this.clientSessionService.AccumulatePromptRunUsage(
943
- promptRunID, inputDelta, outputDelta, ResolveScopedAnonymousRunUser(contextUser), provider,
944
- );
957
+ return this.clientSessionService.AccumulatePromptRunUsage(promptRunID, inputDelta, outputDelta, runUser, provider);
945
958
  }
946
959
 
947
960
  /** Clamps a relayed token delta: negative / non-finite values become 0. */
@@ -968,24 +981,40 @@ export class RealtimeClientSessionResolver extends ResolverBase {
968
981
 
969
982
  /**
970
983
  * Reads the co-agent `AIPromptRun` id from the session config WITHOUT the relay path's
971
- * throw-on-missing-target semantics — usage relay is best-effort, so a missing/malformed
972
- * config or absent id just logs and returns `null`.
984
+ * throw-on-missing-target semantics, and keeps it only when it is the prompt run of the
985
+ * session's co-agent run (the owner can edit the config). Usage relay is best-effort, so a
986
+ * missing/malformed config, an absent id, or an id of another session just logs and returns `null`.
987
+ *
988
+ * @param runUser The user the prompt-run write runs as; the check runs as the same user.
973
989
  */
974
- private readPromptRunID(session: MJAIAgentSessionEntity): string | null {
975
- try {
976
- const config = this.readSessionConfig(session);
977
- if (config.promptRunID) {
978
- return config.promptRunID;
979
- }
990
+ private async readPromptRunID(
991
+ session: MJAIAgentSessionEntity,
992
+ runUser: UserInfo,
993
+ provider: IMetadataProvider,
994
+ ): Promise<string | null> {
995
+ const config = this.tryReadSessionConfig(session);
996
+ if (!config) {
997
+ LogError(`RelayRealtimeUsage: session ${session.ID} has no parseable config — usage delta dropped.`);
998
+ return null;
999
+ }
1000
+ if (!config.promptRunID) {
980
1001
  LogStatus(
981
1002
  `RelayRealtimeUsage: session ${session.ID} has no co-agent promptRunID (observability run ` +
982
1003
  'creation was skipped or failed) — usage delta dropped.',
983
1004
  );
984
1005
  return null;
985
- } catch {
986
- LogError(`RelayRealtimeUsage: session ${session.ID} has no parseable config — usage delta dropped.`);
1006
+ }
1007
+ const verified = await this.runIDVerifier.Verify(
1008
+ session.ID,
1009
+ { CoAgentRunID: config.coAgentRunID, PromptRunID: config.promptRunID },
1010
+ runUser,
1011
+ provider,
1012
+ );
1013
+ if (!verified.PromptRunID) {
1014
+ LogError(`RelayRealtimeUsage: session ${session.ID} names a promptRunID that is not its co-agent prompt run — ignored.`);
987
1015
  return null;
988
1016
  }
1017
+ return verified.PromptRunID;
989
1018
  }
990
1019
 
991
1020
  /**
@@ -1135,8 +1164,10 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1135
1164
  }
1136
1165
 
1137
1166
  /**
1138
- * Authorization gate for {@link RealtimeClientSessionResolver.StartRealtimeClientSession}: the
1139
- * caller must be able to run the **target** agent. Denial throws (no session is created).
1167
+ * Authorization gate for the **target** agent: the caller must be able to run it. Denial throws.
1168
+ * {@link RealtimeClientSessionResolver.StartRealtimeClientSession} runs it before any session is
1169
+ * created, and {@link RealtimeClientSessionResolver.ExecuteRealtimeSessionTool} runs it on every
1170
+ * relayed call, because the owner can edit the target in the session config.
1140
1171
  */
1141
1172
  private async assertCanRunTarget(
1142
1173
  targetAgentId: string,
@@ -1683,7 +1714,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1683
1714
 
1684
1715
  /**
1685
1716
  * Writes the co-agent observability run ids into the session's `Config_` alongside the
1686
- * authoritative `targetAgentID`, then saves the session. These ids are read back on close to
1717
+ * `targetAgentID`, then saves the session. These ids are read back on close to
1687
1718
  * finalize the runs (and on relay to nest delegated runs). Best-effort: a save failure is logged,
1688
1719
  * not thrown — the voice session still proceeds, it just won't carry the run ids.
1689
1720
  */
@@ -2416,9 +2447,10 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2416
2447
  }
2417
2448
 
2418
2449
  /**
2419
- * Parses the session's persisted config, returning the authoritative `targetAgentID` plus any
2420
- * observability run ids (`coAgentRunID`/`promptRunID`). Throws when the config is missing/malformed
2421
- * or carries no target — a relay cannot proceed without a target.
2450
+ * Parses the session's persisted config, returning its `targetAgentID` plus any observability run
2451
+ * ids (`coAgentRunID`/`promptRunID`). Throws when the config is missing/malformed or carries no
2452
+ * target — a relay cannot proceed without a target. The owner can edit the config, so callers
2453
+ * re-check what they act on.
2422
2454
  */
2423
2455
  private readSessionConfig(session: MJAIAgentSessionEntity): RealtimeSessionConfig {
2424
2456
  const raw = session.Config_;
@@ -24,6 +24,7 @@ import { MJAIAgentSessionEntity, MJAIAgentEntity } from '@memberjunction/core-en
24
24
  import { RemoteBrowserEngine } from '@memberjunction/remote-browser-server';
25
25
  import { beginBrowserGoalStep, finalizeBrowserGoalStep, extractCoAgentRunID } from '../agentSessions/remoteBrowserGoalEngine.js';
26
26
  import { RemoteBrowserGoalRegistry } from '../agentSessions/remoteBrowserGoalRegistry.js';
27
+ import { SessionRunIDVerifier } from '../agentSessions/SessionRunIDVerifier.js';
27
28
  import { randomUUID } from 'node:crypto';
28
29
  import {
29
30
  RemoteBrowserAction,
@@ -240,6 +241,9 @@ export class RemoteBrowserActionResolver extends ResolverBase {
240
241
  */
241
242
  private startedScreencasts = new Set<string>();
242
243
 
244
+ /** Checks the co-agent run id read from a session's owner-editable `Config_` before a step is attached to it. */
245
+ private readonly runIDVerifier = new SessionRunIDVerifier();
246
+
243
247
  /**
244
248
  * Agent-session ids whose live tab-audio stream this resolver has already started. Keyed by
245
249
  * `agentSessionID` so a re-issued {@link RemoteBrowserActionResolver.StartRemoteBrowserAudioStream}
@@ -327,7 +331,13 @@ export class RemoteBrowserActionResolver extends ResolverBase {
327
331
  const providerName = await this.resolveProviderName(session, contextUser, provider);
328
332
  // Observability: nest this goal's many prompt runs under ONE "Browser goal" step on the realtime
329
333
  // co-agent run (when the session has one). Best-effort — a null step just means the goal runs unlinked.
330
- const coAgentRunID = extractCoAgentRunID(session.Config_);
334
+ // The owner can edit Config_, so its run id is used only when it is a run of this session.
335
+ const { CoAgentRunID: coAgentRunID } = await this.runIDVerifier.Verify(
336
+ session.ID,
337
+ { CoAgentRunID: extractCoAgentRunID(session.Config_) },
338
+ contextUser,
339
+ provider,
340
+ );
331
341
  const goalStep = await beginBrowserGoalStep(provider, contextUser, coAgentRunID, goal);
332
342
 
333
343
  // ASYNC START: a goal loop can run for minutes; do NOT hold this request open for it (browser
@@ -750,18 +760,15 @@ export class RemoteBrowserActionResolver extends ResolverBase {
750
760
  agentSessionID: string,
751
761
  frame: { DataBase64: string; Width: number; Height: number; SequenceNumber: number },
752
762
  ): 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
- });
763
+ this.PublishStatusUpdate(pubSub, userPayload.sessionId, JSON.stringify({
764
+ resolver: 'RemoteBrowserActionResolver',
765
+ type: 'RemoteBrowserScreencastFrame',
766
+ agentSessionID,
767
+ dataBase64: frame.DataBase64,
768
+ width: frame.Width,
769
+ height: frame.Height,
770
+ seq: frame.SequenceNumber,
771
+ }), userPayload);
765
772
  }
766
773
 
767
774
  /**
@@ -775,19 +782,16 @@ export class RemoteBrowserActionResolver extends ResolverBase {
775
782
  * @param chunk The encoded audio chunk.
776
783
  */
777
784
  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
- });
785
+ this.PublishStatusUpdate(pubSub, userPayload.sessionId, JSON.stringify({
786
+ resolver: 'RemoteBrowserActionResolver',
787
+ type: 'RemoteBrowserAudioChunk',
788
+ agentSessionID,
789
+ dataBase64: chunk.DataBase64,
790
+ codec: chunk.Codec,
791
+ sampleRate: chunk.SampleRate,
792
+ channels: chunk.Channels,
793
+ seq: chunk.SequenceNumber,
794
+ }), userPayload);
791
795
  }
792
796
 
793
797
  /**
@@ -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 }
@@ -2,7 +2,7 @@ import { Resolver, Mutation, Arg, Ctx, ObjectType, Field } from 'type-graphql';
2
2
  import { AppContext, UserPayload } from '../types.js';
3
3
  import { LogError, LogStatus, Metadata, RunView } from '@memberjunction/core';
4
4
  import { MJTemplateContentEntity, MJTemplateEntityExtended } from '@memberjunction/core-entities';
5
- import { TemplateEngineServer } from '@memberjunction/templates';
5
+ import { GetTemplateRunRefusal, TemplateEngineServer } from '@memberjunction/templates';
6
6
  import { ResolverBase } from '../generic/ResolverBase.js';
7
7
  import { GetReadWriteProvider } from '../util.js';
8
8
 
@@ -45,6 +45,18 @@ export class RunTemplateResolver extends ResolverBase {
45
45
  try {
46
46
  LogStatus(`=== RUNNING TEMPLATE FOR ID: ${templateId} ===`);
47
47
 
48
+ // Same rule as the Template.Run remote operation, checked before anything is loaded
49
+ const currentUser = this.GetUserFromPayload(userPayload);
50
+ const p = GetReadWriteProvider(providers);
51
+ const refusal = GetTemplateRunRefusal(currentUser, p);
52
+ if (refusal) {
53
+ return {
54
+ success: false,
55
+ error: refusal,
56
+ executionTimeMs: Date.now() - startTime
57
+ };
58
+ }
59
+
48
60
  // Parse context data (JSON string)
49
61
  let data = {};
50
62
  if (contextData) {
@@ -59,17 +71,6 @@ export class RunTemplateResolver extends ResolverBase {
59
71
  }
60
72
  }
61
73
 
62
- // Get current user from payload
63
- const currentUser = this.GetUserFromPayload(userPayload);
64
- if (!currentUser) {
65
- return {
66
- success: false,
67
- error: 'Unable to determine current user',
68
- executionTimeMs: Date.now() - startTime
69
- };
70
- }
71
-
72
- const p = GetReadWriteProvider(providers);
73
74
  // Load the template entity
74
75
  const templateEntity = await p.GetEntityObject<MJTemplateEntityExtended>('MJ: Templates', currentUser);
75
76
  await templateEntity.Load(templateId);