@memberjunction/server 6.1.5 → 6.1.6

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 (189) hide show
  1. package/dist/.tsbuildinfo +1 -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 +129 -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/exampleNewUserSubClass.js +2 -1
  19. package/dist/auth/exampleNewUserSubClass.js.map +1 -1
  20. package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
  21. package/dist/auth/magicLink/MagicLinkService.js +6 -3
  22. package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
  23. package/dist/auth/newUsers.d.ts.map +1 -1
  24. package/dist/auth/newUsers.js +15 -11
  25. package/dist/auth/newUsers.js.map +1 -1
  26. package/dist/context.d.ts +6 -0
  27. package/dist/context.d.ts.map +1 -1
  28. package/dist/context.js +13 -11
  29. package/dist/context.js.map +1 -1
  30. package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
  31. package/dist/generated/graphql-schemas/__mj.js +3417 -3428
  32. package/dist/generated/graphql-schemas/__mj.js.map +1 -1
  33. package/dist/generic/CacheInvalidationResolver.d.ts +44 -8
  34. package/dist/generic/CacheInvalidationResolver.d.ts.map +1 -1
  35. package/dist/generic/CacheInvalidationResolver.js +59 -19
  36. package/dist/generic/CacheInvalidationResolver.js.map +1 -1
  37. package/dist/generic/ResolverBase.d.ts +95 -11
  38. package/dist/generic/ResolverBase.d.ts.map +1 -1
  39. package/dist/generic/ResolverBase.js +186 -76
  40. package/dist/generic/ResolverBase.js.map +1 -1
  41. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  42. package/dist/generic/RunViewResolver.js +12 -0
  43. package/dist/generic/RunViewResolver.js.map +1 -1
  44. package/dist/index.d.ts +1 -0
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +8 -1
  47. package/dist/index.js.map +1 -1
  48. package/dist/isolatedProvider.d.ts +20 -0
  49. package/dist/isolatedProvider.d.ts.map +1 -0
  50. package/dist/isolatedProvider.js +22 -0
  51. package/dist/isolatedProvider.js.map +1 -0
  52. package/dist/multiTenancy/index.d.ts.map +1 -1
  53. package/dist/multiTenancy/index.js +22 -2
  54. package/dist/multiTenancy/index.js.map +1 -1
  55. package/dist/realtimeWidget/widgetGuestElevation.d.ts +4 -2
  56. package/dist/realtimeWidget/widgetGuestElevation.d.ts.map +1 -1
  57. package/dist/realtimeWidget/widgetGuestElevation.js +4 -2
  58. package/dist/realtimeWidget/widgetGuestElevation.js.map +1 -1
  59. package/dist/resolvers/ActionResolver.d.ts +31 -5
  60. package/dist/resolvers/ActionResolver.d.ts.map +1 -1
  61. package/dist/resolvers/ActionResolver.js +75 -14
  62. package/dist/resolvers/ActionResolver.js.map +1 -1
  63. package/dist/resolvers/AdhocQueryResolver.d.ts +28 -0
  64. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  65. package/dist/resolvers/AdhocQueryResolver.js +46 -0
  66. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  67. package/dist/resolvers/DatasetResolver.d.ts +24 -3
  68. package/dist/resolvers/DatasetResolver.d.ts.map +1 -1
  69. package/dist/resolvers/DatasetResolver.js +57 -8
  70. package/dist/resolvers/DatasetResolver.js.map +1 -1
  71. package/dist/resolvers/EntityRecordNameResolver.d.ts +8 -23
  72. package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
  73. package/dist/resolvers/EntityRecordNameResolver.js +11 -48
  74. package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
  75. package/dist/resolvers/FetchEntityVectorsResolver.js +2 -2
  76. package/dist/resolvers/FetchEntityVectorsResolver.js.map +1 -1
  77. package/dist/resolvers/GetDataContextDataResolver.d.ts +33 -7
  78. package/dist/resolvers/GetDataContextDataResolver.d.ts.map +1 -1
  79. package/dist/resolvers/GetDataContextDataResolver.js +117 -38
  80. package/dist/resolvers/GetDataContextDataResolver.js.map +1 -1
  81. package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
  82. package/dist/resolvers/GetDataResolver.js +13 -1
  83. package/dist/resolvers/GetDataResolver.js.map +1 -1
  84. package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
  85. package/dist/resolvers/QuerySystemUserResolver.js +2 -1
  86. package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
  87. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +39 -16
  88. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  89. package/dist/resolvers/RealtimeClientSessionResolver.js +84 -38
  90. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  91. package/dist/resolvers/RemoteBrowserActionResolver.d.ts +2 -0
  92. package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
  93. package/dist/resolvers/RemoteBrowserActionResolver.js +5 -1
  94. package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
  95. package/dist/resolvers/RunTemplateResolver.d.ts.map +1 -1
  96. package/dist/resolvers/RunTemplateResolver.js +12 -11
  97. package/dist/resolvers/RunTemplateResolver.js.map +1 -1
  98. package/dist/resolvers/SearchKnowledgeResolver.d.ts +1 -0
  99. package/dist/resolvers/SearchKnowledgeResolver.d.ts.map +1 -1
  100. package/dist/resolvers/SearchKnowledgeResolver.js +5 -0
  101. package/dist/resolvers/SearchKnowledgeResolver.js.map +1 -1
  102. package/dist/resolvers/SearchKnowledgeSystemUserResolver.d.ts.map +1 -1
  103. package/dist/resolvers/SearchKnowledgeSystemUserResolver.js +1 -0
  104. package/dist/resolvers/SearchKnowledgeSystemUserResolver.js.map +1 -1
  105. package/dist/resolvers/SyncDataResolver.d.ts.map +1 -1
  106. package/dist/resolvers/SyncDataResolver.js +6 -2
  107. package/dist/resolvers/SyncDataResolver.js.map +1 -1
  108. package/dist/resolvers/VectorizeEntityResolver.d.ts +1 -1
  109. package/dist/resolvers/VectorizeEntityResolver.d.ts.map +1 -1
  110. package/dist/resolvers/VectorizeEntityResolver.js +14 -10
  111. package/dist/resolvers/VectorizeEntityResolver.js.map +1 -1
  112. package/dist/rest/ViewOperationsHandler.d.ts +3 -1
  113. package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
  114. package/dist/rest/ViewOperationsHandler.js +29 -17
  115. package/dist/rest/ViewOperationsHandler.js.map +1 -1
  116. package/dist/services/TaskGraphActionRunner.d.ts +8 -0
  117. package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
  118. package/dist/services/TaskGraphActionRunner.js +27 -1
  119. package/dist/services/TaskGraphActionRunner.js.map +1 -1
  120. package/package.json +92 -92
  121. package/src/__tests__/ActionResolver.authorization.test.ts +353 -0
  122. package/src/__tests__/AdhocQueryResolver.bugs.test.ts +4 -2
  123. package/src/__tests__/AdhocQueryResolver.sqlAuthorizer.test.ts +104 -0
  124. package/src/__tests__/DatasetResolver.itemFilters.test.ts +201 -0
  125. package/src/__tests__/EntityRecordNameResolver.fieldSecurity.test.ts +27 -72
  126. package/src/__tests__/EntityRecordNameResolver.rowSecurity.test.ts +152 -0
  127. package/src/__tests__/GetDataContextDataResolver.sqlItems.test.ts +416 -0
  128. package/src/__tests__/ListOperationsResolver.adhocScreen.test.ts +137 -0
  129. package/src/__tests__/RealtimeClientSessionResolver.configTamper.test.ts +383 -0
  130. package/src/__tests__/RealtimeClientSessionResolver.test.ts +106 -41
  131. package/src/__tests__/RemoteBrowserActionResolver.goalRunLink.test.ts +129 -0
  132. package/src/__tests__/ResolverBase.adhocReadScope.test.ts +227 -0
  133. package/src/__tests__/ResolverBase.aggregateScreen.test.ts +174 -0
  134. package/src/__tests__/ResolverBase.balancedClause.test.ts +155 -0
  135. package/src/__tests__/ResolverBase.clauseUnscopedRead.test.ts +127 -0
  136. package/src/__tests__/ResolverBase.filterEscaping.test.ts +24 -2
  137. package/src/__tests__/ResolverBase.isaPromotionCreate.test.ts +412 -0
  138. package/src/__tests__/RunTemplateResolver.test.ts +132 -0
  139. package/src/__tests__/RunViewResolver.cacheCheckScreen.test.ts +165 -0
  140. package/src/__tests__/SessionManager.test.ts +94 -17
  141. package/src/__tests__/SessionRunIDVerifier.test.ts +251 -0
  142. package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +2 -0
  143. package/src/__tests__/TaskGraphActionRunner.storableOutputs.test.ts +76 -0
  144. package/src/__tests__/UpdateMJDataContextItem.oldValues.test.ts +220 -0
  145. package/src/__tests__/VectorizeEntityResolver.test.ts +106 -0
  146. package/src/__tests__/ViewOperationsHandler.clauseScreen.test.ts +157 -0
  147. package/src/__tests__/actionAuthorization.test.ts +130 -0
  148. package/src/__tests__/cacheInvalidationRecordData.test.ts +64 -0
  149. package/src/__tests__/isolatedProviderTransactions.test.ts +169 -0
  150. package/src/__tests__/magicLinkService.consume.test.ts +6 -0
  151. package/src/__tests__/magicLinkService.provisionLog.test.ts +5 -0
  152. package/src/__tests__/multiTenancy.postgresRewrite.test.ts +181 -0
  153. package/src/__tests__/multiTenancy.security.test.ts +51 -0
  154. package/src/__tests__/multiTenancy.test.ts +1 -1
  155. package/src/__tests__/newUsers.test.ts +62 -5
  156. package/src/agentSessions/SessionManager.ts +23 -25
  157. package/src/agentSessions/SessionRunIDVerifier.ts +171 -0
  158. package/src/agentSessions/index.ts +1 -0
  159. package/src/auth/actionAuthorization.ts +119 -0
  160. package/src/auth/exampleNewUserSubClass.ts +2 -2
  161. package/src/auth/magicLink/MagicLinkService.ts +6 -3
  162. package/src/auth/newUsers.ts +15 -11
  163. package/src/context.ts +14 -10
  164. package/src/generated/graphql-schemas/__mj.ts +3417 -3428
  165. package/src/generic/CacheInvalidationResolver.ts +74 -19
  166. package/src/generic/ResolverBase.ts +207 -80
  167. package/src/generic/RunViewResolver.ts +17 -0
  168. package/src/index.ts +9 -1
  169. package/src/isolatedProvider.ts +22 -0
  170. package/src/multiTenancy/index.ts +24 -2
  171. package/src/realtimeWidget/widgetGuestElevation.ts +4 -2
  172. package/src/resolvers/ActionResolver.ts +84 -21
  173. package/src/resolvers/AdhocQueryResolver.ts +49 -1
  174. package/src/resolvers/DatasetResolver.ts +70 -10
  175. package/src/resolvers/EntityRecordNameResolver.ts +11 -52
  176. package/src/resolvers/FetchEntityVectorsResolver.ts +2 -2
  177. package/src/resolvers/GetDataContextDataResolver.ts +139 -41
  178. package/src/resolvers/GetDataResolver.ts +14 -1
  179. package/src/resolvers/QuerySystemUserResolver.ts +2 -1
  180. package/src/resolvers/RealtimeClientSessionResolver.ts +103 -42
  181. package/src/resolvers/RemoteBrowserActionResolver.ts +11 -1
  182. package/src/resolvers/RunTemplateResolver.ts +13 -12
  183. package/src/resolvers/SearchKnowledgeResolver.ts +4 -0
  184. package/src/resolvers/SearchKnowledgeSystemUserResolver.ts +1 -0
  185. package/src/resolvers/SyncDataResolver.ts +6 -2
  186. package/src/resolvers/VectorizeEntityResolver.ts +16 -11
  187. package/src/resolvers/__tests__/GetDataResolver.validation.test.ts +95 -0
  188. package/src/rest/ViewOperationsHandler.ts +37 -18
  189. package/src/services/TaskGraphActionRunner.ts +29 -1
@@ -1,9 +1,13 @@
1
1
  import { Arg, Ctx, Field, ObjectType, Query, Resolver } from "type-graphql";
2
+ import type { DatabaseProviderBase, UserInfo } from "@memberjunction/core";
3
+ import { IsValidUUID, UUIDsEqual } from "@memberjunction/global";
4
+ import { DataContext, DataContextItem } from "@memberjunction/data-context";
5
+ import { MJDataContextEntity, MJDataContextItemEntity } from "@memberjunction/core-entities";
6
+ import { RenderPipeline } from "@memberjunction/generic-database-provider";
2
7
  import { AppContext } from "../types.js";
3
- import { DataContext } from "@memberjunction/data-context";
4
- import { GetReadOnlyDataSource, GetReadOnlyProvider } from "../util.js";
5
- import { MJDataContextItemEntity } from "@memberjunction/core-entities";
8
+ import { GetReadOnlyProvider } from "../util.js";
6
9
  import { ResolverBase } from "../generic/ResolverBase.js";
10
+ import { IsScopeLimitedPrincipal } from "../auth/scopeLimitedPrincipal.js";
7
11
 
8
12
  @ObjectType()
9
13
  export class GetDataContextItemDataOutputType {
@@ -39,6 +43,28 @@ export class GetDataContextDataOutputType {
39
43
  }
40
44
 
41
45
 
46
+ /** The outcome of loading one data context item: its data as JSON, or why it has none. */
47
+ interface ItemOutcome {
48
+ Result: string | null;
49
+ Error: string | null;
50
+ }
51
+
52
+ /** Which `sql` items of a data context may run: the read-only provider, and each refused item's reason. */
53
+ interface SQLItemGate {
54
+ Provider: DatabaseProviderBase | null;
55
+ Refusals: Map<DataContextItem, string>;
56
+ }
57
+
58
+ /**
59
+ * Loads the data of data contexts and their items.
60
+ *
61
+ * Items of type `sql` hold raw SQL, which is held to the rules ExecuteAdhocQuery applies to
62
+ * caller-supplied SQL. It runs only for the data context's owner or an administrator (Owner-type
63
+ * user), never for a scope-limited session, only on the read-only provider, and only as a single read
64
+ * statement over entity base views the caller may read in full. A SQL item that may not run is
65
+ * refused on its own, and the other items still load. Items of the other types load through entity
66
+ * permissions for the calling user.
67
+ */
42
68
  @Resolver()
43
69
  export class GetDataContextDataResolver extends ResolverBase {
44
70
  /**
@@ -52,30 +78,25 @@ export class GetDataContextDataResolver extends ResolverBase {
52
78
  ) {
53
79
  // Check API key scope authorization for data context read
54
80
  await this.CheckAPIKeyScopeAuthorization('datacontext:read', DataContextItemID, appCtx.userPayload);
81
+ if (!IsValidUUID(DataContextItemID)) {
82
+ return { Success: false, ErrorMessage: 'DataContextItemID must be a UUID', Result: null };
83
+ }
55
84
 
56
85
  try {
57
- const ds = GetReadOnlyDataSource(appCtx.dataSources, {
58
- allowFallbackToReadWrite: true,
59
- })
86
+ const user: UserInfo = appCtx.userPayload.userRecord;
60
87
  const md = GetReadOnlyProvider(appCtx.providers, {allowFallbackToReadWrite: true});
61
- const dciData = await md.GetEntityObject<MJDataContextItemEntity>("MJ: Data Context Items", appCtx.userPayload.userRecord);
88
+ const dciData = await md.GetEntityObject<MJDataContextItemEntity>("MJ: Data Context Items", user);
62
89
  if (await dciData.Load(DataContextItemID)) {
63
90
  const dci = DataContext.CreateDataContextItem(); // use class factory to get whatever lowest level sub-class is registered
64
- await dci.LoadMetadataFromEntityRecord(dciData, md, appCtx.userPayload.userRecord);
91
+ await dci.LoadMetadataFromEntityRecord(dciData, md, user);
92
+ const ownerID = dci.Type === 'sql' ? await this.loadDataContextOwnerID(dciData.DataContextID, md, user) : null;
93
+ const gate = this.authorizeSQLItems([dci], ownerID, appCtx);
65
94
  // now the metadata is loaded so we can call the regular load function
66
- if (await dci.LoadData(ds)) {
67
- return {
68
- Success: true,
69
- ErrorMessage: null,
70
- Result: JSON.stringify(dci.Data),
71
- }
72
- }
73
- else {
74
- return {
75
- Success: false,
76
- ErrorMessage: 'Error loading data context item data',
77
- Result: null,
78
- }
95
+ const outcome = await this.loadItem(dci, gate, user, false);
96
+ return {
97
+ Success: outcome.Error === null,
98
+ ErrorMessage: outcome.Error,
99
+ Result: outcome.Result,
79
100
  }
80
101
  }
81
102
  else {
@@ -89,14 +110,15 @@ export class GetDataContextDataResolver extends ResolverBase {
89
110
  catch (e) {
90
111
  return {
91
112
  Success: false,
92
- ErrorMessage: e,
113
+ ErrorMessage: e instanceof Error ? e.message : String(e),
93
114
  Result: null,
94
115
  }
95
116
  }
96
117
  }
97
118
 
98
119
  /**
99
- * Returns data for a given data context.
120
+ * Returns data for a given data context. `Results` and `ErrorMessages` hold one entry per item, in
121
+ * item order. `Success` is true, and `ErrorMessages` null, only when every item loaded.
100
122
  * @param DataContextID
101
123
  */
102
124
  @Query(() => GetDataContextDataOutputType)
@@ -106,38 +128,114 @@ export class GetDataContextDataResolver extends ResolverBase {
106
128
  ) {
107
129
  // Check API key scope authorization for data context read
108
130
  await this.CheckAPIKeyScopeAuthorization('datacontext:read', DataContextID, appCtx.userPayload);
131
+ // DataContext.LoadMetadata places this ID in the item filter, so it must be a plain UUID.
132
+ if (!IsValidUUID(DataContextID)) {
133
+ return { Success: false, ErrorMessages: ['DataContextID must be a UUID'], Results: null };
134
+ }
109
135
 
110
136
  try {
111
137
  // our job here is to load the entire data context, so we do that with the Data Context object
138
+ const user: UserInfo = appCtx.userPayload.userRecord;
139
+ const md = GetReadOnlyProvider(appCtx.providers, { allowFallbackToReadWrite: true });
112
140
  const dc = new DataContext();
113
- const ds = GetReadOnlyDataSource(appCtx.dataSources, {
114
- allowFallbackToReadWrite: true,
115
- })
116
- const success = await dc.Load(DataContextID, ds, true, false, 0, appCtx.userPayload.userRecord);
117
- if (success) {
118
- const retVal = {
119
- Success: true,
120
- ErrorMessages: null,
121
- Results: dc.Items.map((item) => {
122
- return JSON.stringify(item.Data);
123
- }),
124
- }
125
- return retVal;
126
- }
127
- else {
141
+ if (!(await dc.LoadMetadata(DataContextID, user, md))) {
128
142
  return {
129
143
  Success: false,
130
144
  ErrorMessages: ['Error loading data context'],
131
- Results: null,
145
+ Results: null,
132
146
  }
133
147
  }
148
+ const gate = this.authorizeSQLItems(dc.Items, dc.MJDataContextEntity.UserID, appCtx);
149
+ const outcomes = await Promise.all(dc.Items.map((item) => this.loadItem(item, gate, user, true)));
150
+ const allLoaded = outcomes.every((outcome) => outcome.Error === null);
151
+ return {
152
+ Success: allLoaded,
153
+ ErrorMessages: allLoaded ? null : outcomes.map((outcome) => outcome.Error),
154
+ Results: outcomes.map((outcome) => outcome.Result),
155
+ }
134
156
  }
135
157
  catch (e) {
136
158
  return {
137
159
  Success: false,
138
- ErrorMessages: [e],
160
+ ErrorMessages: [e instanceof Error ? e.message : String(e)],
139
161
  Results: null,
140
162
  }
141
163
  }
142
164
  }
143
- }
165
+
166
+ /**
167
+ * Decides which `sql` items among `items` may run. Returns the read-only provider they run on, and
168
+ * the reason for each SQL item that may not run.
169
+ */
170
+ private authorizeSQLItems(items: DataContextItem[], ownerID: string | null, appCtx: AppContext): SQLItemGate {
171
+ const refusals = new Map<DataContextItem, string>();
172
+ const sqlItems = items.filter((item) => item.Type === 'sql');
173
+ if (sqlItems.length === 0) {
174
+ return { Provider: null, Refusals: refusals };
175
+ }
176
+ const user: UserInfo | undefined = appCtx.userPayload?.userRecord;
177
+ const provider = GetReadOnlyProvider(appCtx.providers, { allowFallbackToReadWrite: false });
178
+ for (const item of sqlItems) {
179
+ const refusal = this.sqlItemRefusal(item, ownerID, user, provider);
180
+ if (refusal !== null) {
181
+ refusals.set(item, refusal);
182
+ }
183
+ }
184
+ return { Provider: provider, Refusals: refusals };
185
+ }
186
+
187
+ /** Returns why `item` may not run for `user`, or null when it may. */
188
+ private sqlItemRefusal(item: DataContextItem, ownerID: string | null, user: UserInfo | undefined, provider: DatabaseProviderBase | null): string | null {
189
+ if (!user || IsScopeLimitedPrincipal(user)) {
190
+ return 'SQL data context items cannot be run by a scope-limited or unidentified session.';
191
+ }
192
+ if (!this.mayRunDataContextSQL(ownerID, user)) {
193
+ return 'Only the owner of this data context, or an administrator, may run its SQL items.';
194
+ }
195
+ if (!provider) {
196
+ return 'No read-only data source is configured, so SQL data context items cannot run.';
197
+ }
198
+ return this.screenItemSQL(item.SQL ?? '', provider, user);
199
+ }
200
+
201
+ /** True when `user` owns the data context or is an administrator (Owner-type user). */
202
+ private mayRunDataContextSQL(ownerID: string | null, user: UserInfo): boolean {
203
+ const isOwner = !!ownerID && UUIDsEqual(ownerID, user.ID);
204
+ return isOwner || user.Type?.trim().toLowerCase() === 'owner';
205
+ }
206
+
207
+ /**
208
+ * Returns why `sqlText` may not run, or null when it is a single read statement whose every table
209
+ * reference is an entity base view `user` may read without row-level or field-level narrowing. It is
210
+ * the check ExecuteAdhocQuery makes on the rendered SQL.
211
+ */
212
+ private screenItemSQL(sqlText: string, provider: DatabaseProviderBase, user: UserInfo): string | null {
213
+ try {
214
+ const rendered = RenderPipeline.Run(sqlText, { Platform: provider.PlatformKey, ContextUser: user, RequireReadStatement: true });
215
+ this.assertFullQueryUsesReadableEntityViews(rendered.Trace.AfterTemplates, provider, user, 'data context SQL');
216
+ return null;
217
+ }
218
+ catch (e) {
219
+ const reason = e instanceof Error ? e.message : String(e);
220
+ return reason || 'The SQL of this item did not pass the read-only check.';
221
+ }
222
+ }
223
+
224
+ /** Loads one item's data unless `gate` refused it. Returns the data as JSON, or why there is none. */
225
+ private async loadItem(item: DataContextItem, gate: SQLItemGate, user: UserInfo, forceRefresh: boolean): Promise<ItemOutcome> {
226
+ const refusal = gate.Refusals.get(item);
227
+ if (refusal !== undefined) {
228
+ return { Result: null, Error: refusal };
229
+ }
230
+ if (await item.LoadData(gate.Provider, forceRefresh, false, 0, user)) {
231
+ return { Result: JSON.stringify(item.Data), Error: null };
232
+ }
233
+ return { Result: null, Error: item.DataLoadingError || 'Error loading data context item data' };
234
+ }
235
+
236
+ /** Returns the owner (`UserID`) of a data context, or null when the caller cannot load it. */
237
+ private async loadDataContextOwnerID(dataContextID: string, provider: DatabaseProviderBase, user: UserInfo): Promise<string | null> {
238
+ const dataContext = await provider.GetEntityObject<MJDataContextEntity>('MJ: Data Contexts', user);
239
+ return (await dataContext.Load(dataContextID)) ? dataContext.UserID : null;
240
+ }
241
+ }
@@ -1,4 +1,5 @@
1
1
  import { Arg, Ctx, Field, InputType, ObjectType, Query } from 'type-graphql';
2
+ import { SQLExpressionValidator } from '@memberjunction/global';
2
3
  import { AppContext } from '../types.js';
3
4
  import { LogError, LogStatus, LogStatusEx, Metadata } from '@memberjunction/core';
4
5
  import { QueryCompositionEngine } from '@memberjunction/generic-database-provider';
@@ -154,8 +155,20 @@ export class GetDataResolver {
154
155
  resolvedSQL = compositionResult.ResolvedSQL;
155
156
  }
156
157
 
158
+ // Defense-in-depth: even system-token holders get SELECT-only SQL here.
159
+ // The read-only pool is the primary control, but if it is ever
160
+ // misconfigured to a read-write login this validator still refuses DML/DDL
161
+ // (mirrors AdhocQueryResolver). The validator reads exactly the text that
162
+ // runs: it is trimmed here as validate() trims it, and literal \n sequences
163
+ // are not rewritten as validateFullQuery would.
164
+ const sqlToRun = resolvedSQL.trim();
165
+ const validation = SQLExpressionValidator.Instance.validate(sqlToRun, { context: 'full_query', Dialect: platform });
166
+ if (!validation.valid) {
167
+ return { result: null, error: validation.error || 'SQL validation failed' };
168
+ }
169
+
157
170
  const request = new sql.Request(readOnlyDataSource);
158
- const result = await request.query(resolvedSQL);
171
+ const result = await request.query(sqlToRun);
159
172
  return { result: result.recordset, error: null };
160
173
  } catch (err) {
161
174
  // Extract clean SQL error message
@@ -6,6 +6,7 @@ import { MJQueryResolver, MJQuery_, MJQueryField_, MJQueryParameter_, MJQueryEnt
6
6
  import { GetReadWriteProvider } from '../util.js';
7
7
  import { DeleteOptionsInput } from '../generic/DeleteOptionsInput.js';
8
8
  import { MJQueryEntityServer } from '@memberjunction/core-entities-server';
9
+ import { EscapeSQLString } from '@memberjunction/global';
9
10
 
10
11
  /**
11
12
  * Minimal shape of a query row returned by RunView lookups (plain object, not entity instance).
@@ -493,7 +494,7 @@ export class MJQueryResolverExtended extends MJQueryResolver {
493
494
  // now make sure there is NO existing query by the same name in the specified category
494
495
  const existingQueryResult = await provider.RunView({
495
496
  EntityName: 'MJ: Queries',
496
- ExtraFilter: `Name='${input.Name}' AND CategoryID='${finalCategoryID}'`
497
+ ExtraFilter: `Name='${EscapeSQLString(input.Name)}' AND CategoryID='${EscapeSQLString(finalCategoryID)}'`
497
498
  }, context.userPayload.userRecord);
498
499
  if (existingQueryResult.Success && existingQueryResult.Results?.length > 0) {
499
500
  // we have a match! Let's return an error
@@ -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,13 @@
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, direct actions never come from it, and its
26
+ * run ids are used only when {@link SessionRunIDVerifier} finds them to be records of the session.
27
+ * - **Scope-limited callers** (anonymous and resource-scoped magic-link sessions, including public
28
+ * web-widget guests): no direct actions. Their scope exists only as row-level filters, which an
29
+ * action can read around, and a scoped anonymous caller's relay runs as the system user. They
30
+ * reach the target agent only through `invoke-target-agent`.
24
31
  *
25
32
  * @module @memberjunction/server
26
33
  */
@@ -48,6 +55,7 @@ import {
48
55
  FindIgnoredRealtimeConfigKeys,
49
56
  ParseRealtimeTypeConfiguration,
50
57
  ResolveEffectiveRealtimeConfig,
58
+ INVOKE_TARGET_AGENT_TOOL_NAME,
51
59
  RealtimeAllowedAgent,
52
60
  RealtimeDirectActionsConfig,
53
61
  REALTIME_ADVANCED_SESSION_CONTROLS_AUTHORIZATION,
@@ -62,6 +70,8 @@ import { ResolverBase } from '../generic/ResolverBase.js';
62
70
  import { PUSH_STATUS_UPDATES_TOPIC } from '../generic/PushStatusResolver.js';
63
71
  import { GetReadWriteProvider } from '../util.js';
64
72
  import { SessionManager } from '../agentSessions/index.js';
73
+ import { SessionRunIDVerifier } from '../agentSessions/SessionRunIDVerifier.js';
74
+ import { IsScopeLimitedPrincipal } from '../auth/scopeLimitedPrincipal.js';
65
75
  import { resolveWidgetGuestRunContext, ResolveScopedAnonymousRunUser } from '../realtimeWidget/widgetGuestElevation.js';
66
76
 
67
77
  /**
@@ -137,9 +147,9 @@ const MAX_PRIOR_TRANSCRIPT_CHARS = 8_000;
137
147
  const MAX_PRIOR_TRANSCRIPT_LEGS = 5;
138
148
 
139
149
  /**
140
- * Authoritative shape persisted in `AIAgentSession.Config_` for a client-direct voice session.
141
- * The target agent id is stored here at start and read back on every relay — the browser never
142
- * re-supplies it, so it cannot swap targets mid-session.
150
+ * Shape persisted in `AIAgentSession.Config_` for a client-direct voice session. The server writes
151
+ * it, but the session owner can also edit it through the generic entity API, so relays re-check
152
+ * what they read from it (see the authorization model above).
143
153
  */
144
154
  interface RealtimeSessionConfig {
145
155
  /** The top-level target agent the co-agent voices on behalf of. */
@@ -375,6 +385,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
375
385
  // state on — otherwise that cleanup silently no-ops on a freshly-constructed instance.
376
386
  private readonly clientSessionService = new RealtimeClientSessionService();
377
387
  private readonly sessionManager = new SessionManager(this.clientSessionService);
388
+ private readonly runIDVerifier = new SessionRunIDVerifier();
378
389
 
379
390
  /**
380
391
  * The reason a `Save()`/`Delete()` returned false, for a log line. `CompleteMessage` renders
@@ -411,7 +422,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
411
422
  * agent's `DefaultCoAgentID` → type-level `AIAgentCoAgent` default row → global Realtime Co-Agent) —
412
423
  * see {@link RealtimeClientSessionResolver.resolveCoAgentID} for the full contract.
413
424
  * 3. Create the durable `AIAgentSession` (run by the co-agent), storing `targetAgentID` in its
414
- * config server-side — this is the authoritative target for all later relays.
425
+ * config — the target later relays use, re-authorized for the caller on each relay.
415
426
  * 4. Mint the {@link import('@memberjunction/ai').ClientRealtimeSessionConfig} via the service.
416
427
  * On failure the just-created session is closed so no half-open session leaks.
417
428
  *
@@ -526,8 +537,10 @@ export class RealtimeClientSessionResolver extends ResolverBase {
526
537
  * Execute a single tool call the browser relayed from its provider socket and return the
527
538
  * serialized result for the browser to relay back to the model.
528
539
  *
529
- * Ownership-gated; the target agent id is read from the **session config** (authoritative), never
530
- * from the client. Heartbeats the session on success.
540
+ * Ownership-gated. The target agent id is read from the session config and `CanRun` on it is
541
+ * checked for the caller on every call, because the owner can edit the config. A direct-action
542
+ * call from a scope-limited caller is refused before any elevation. Heartbeats the session on
543
+ * success.
531
544
  *
532
545
  * @returns The serialized tool result JSON.
533
546
  */
@@ -543,13 +556,23 @@ export class RealtimeClientSessionResolver extends ResolverBase {
543
556
  const { contextUser, provider } = this.requireUserAndProvider(userPayload, providers);
544
557
  const session = await this.loadOwnedActiveSession(agentSessionId, contextUser, provider);
545
558
  const config = this.readSessionConfig(session);
559
+ await this.assertCanRunTarget(config.targetAgentID, contextUser, provider);
560
+ if (toolName !== INVOKE_TARGET_AGENT_TOOL_NAME && IsScopeLimitedPrincipal(contextUser)) {
561
+ return this.refuseDirectAction(agentSessionId, toolName);
562
+ }
546
563
 
547
564
  // SCOPED-ANONYMOUS ELEVATION (issue #3371): once ownership is proven above, the delegated
548
565
  // run + its AI-run-entity writes execute as the system user for a scoped anonymous caller
549
566
  // (the caller's role deliberately holds no grants on the run entities). The lead
550
- // targetAgentID comes from the session config and was CanRun-gated at start; the colleague
551
- // union is gated just below, against the CALLER, so elevation never widens agent authority.
567
+ // targetAgentID is CanRun-gated against the CALLER just above, and the colleague union
568
+ // just below, so elevation never widens agent authority.
552
569
  const runUser = ResolveScopedAnonymousRunUser(contextUser);
570
+ const runIDs = await this.runIDVerifier.Verify(
571
+ session.ID,
572
+ { CoAgentRunID: config.coAgentRunID, PendingFeedbackRunID: config.pendingFeedbackRunID },
573
+ runUser,
574
+ provider,
575
+ );
553
576
  if (runUser !== contextUser) {
554
577
  LogStatus(
555
578
  `ExecuteRealtimeSessionTool: dispatching relayed tool '${toolName}' for session ${agentSessionId} ` +
@@ -567,16 +590,17 @@ export class RealtimeClientSessionResolver extends ResolverBase {
567
590
  // Multi-target (Move 4): the session's persisted allowed-agent union — a model-named
568
591
  // colleague in the call is validated against this; absent ⇒ single-target behavior.
569
592
  AllowedAgents: await this.filterAllowedAgentsByCanRun(config.allowedAgents, contextUser),
570
- DirectActions: config.directActions,
593
+ // No DirectActions: the service takes them from the session's start-time projection
594
+ // or the target agent's metadata, never from the owner-editable config.
571
595
  // Attribution follows the VISITOR even when `runUser` is elevated: the delegated run
572
596
  // row and its context-memory scope must stay the person's, not the system user's.
573
597
  AttributionUserID: contextUser.ID,
574
598
  // Nest the delegated target-agent run under the co-agent observability run (when present).
575
- ParentRunID: config.coAgentRunID,
599
+ ParentRunID: runIDs.CoAgentRunID,
576
600
  Call: { CallID: callId, ToolName: toolName, Arguments: argsJson },
577
601
  OnProgress: this.buildDelegationProgressCallback(pubSub, userPayload, agentSessionId, callId),
578
602
  // Resume a previously-paused delegated run (if any) with the user's answer.
579
- ResumeRunID: config.pendingFeedbackRunID,
603
+ ResumeRunID: runIDs.PendingFeedbackRunID,
580
604
  },
581
605
  runUser,
582
606
  provider,
@@ -586,7 +610,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
586
610
  `ExecuteRealtimeSessionTool: completed relayed tool '${toolName}' (callId: ${callId}) in ${durationMs}ms`,
587
611
  );
588
612
 
589
- if (toolName !== 'invoke-target-agent') {
613
+ if (toolName !== INVOKE_TARGET_AGENT_TOOL_NAME) {
590
614
  await this.persistDirectActionTurn(
591
615
  session,
592
616
  callId,
@@ -643,6 +667,19 @@ export class RealtimeClientSessionResolver extends ResolverBase {
643
667
  };
644
668
  }
645
669
 
670
+ /**
671
+ * The tool result for a direct-action call from a scope-limited caller (see the authorization
672
+ * model above). It has the broker's result shape, so the model hears that the tool is not
673
+ * available and can ask the target agent through `invoke-target-agent` instead.
674
+ */
675
+ private refuseDirectAction(agentSessionId: string, toolName: string): string {
676
+ LogStatus(`ExecuteRealtimeSessionTool: refused direct action '${toolName}' for a scope-limited caller on session ${agentSessionId}.`);
677
+ return JSON.stringify({
678
+ success: false,
679
+ output: `The tool '${toolName}' is not available in this session. Ask the agent to do it with ${INVOKE_TARGET_AGENT_TOOL_NAME}.`,
680
+ });
681
+ }
682
+
646
683
  /**
647
684
  * Rolls the session's `pendingFeedbackRunID` forward after a relayed tool call. The id we just
648
685
  * consumed (resumed or none) is dropped; a new paused run id is stored only when the run paused
@@ -713,14 +750,15 @@ export class RealtimeClientSessionResolver extends ResolverBase {
713
750
  // conversation (run-viewer observability parity). Best-effort — never fails the transcript relay.
714
751
  // The prompt-run write runs as the scoped-anonymous elevated user (issue #3371) — the visible
715
752
  // Conversation Detail above deliberately stays on the caller.
716
- const promptRunID = this.readPromptRunID(session);
753
+ const runUser = ResolveScopedAnonymousRunUser(contextUser);
754
+ const promptRunID = await this.readPromptRunID(session, runUser, provider);
717
755
  if (promptRunID) {
718
756
  await this.clientSessionService.AppendPromptRunMessage(
719
757
  promptRunID,
720
758
  this.mapTranscriptRoleToChatRole(role),
721
759
  text,
722
760
  replacesPrevious ?? false,
723
- ResolveScopedAnonymousRunUser(contextUser),
761
+ runUser,
724
762
  provider,
725
763
  );
726
764
  }
@@ -918,7 +956,8 @@ export class RealtimeClientSessionResolver extends ResolverBase {
918
956
  provider,
919
957
  );
920
958
 
921
- const promptRunID = this.readPromptRunID(session);
959
+ const runUser = ResolveScopedAnonymousRunUser(contextUser);
960
+ const promptRunID = await this.readPromptRunID(session, runUser, provider);
922
961
  if (!promptRunID) {
923
962
  return false;
924
963
  }
@@ -927,7 +966,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
927
966
  'assistant',
928
967
  this.formatToolTurn(toolName, argsJson, resultJson),
929
968
  false,
930
- ResolveScopedAnonymousRunUser(contextUser),
969
+ runUser,
931
970
  provider,
932
971
  );
933
972
  }
@@ -1019,16 +1058,15 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1019
1058
  return true; // nothing to add — a no-op flush is a success, not a failure
1020
1059
  }
1021
1060
 
1022
- const promptRunID = this.readPromptRunID(session);
1061
+ // Runs as the scoped-anonymous elevated user (issue #3371) — the caller's role holds no prompt-run grants.
1062
+ const runUser = ResolveScopedAnonymousRunUser(contextUser);
1063
+ const promptRunID = await this.readPromptRunID(session, runUser, provider);
1023
1064
  if (!promptRunID) {
1024
1065
  return false; // no observability prompt run for this session — logged in the helper
1025
1066
  }
1026
1067
  // Delegate to the service so usage writes share the per-run serialization with transcript-message
1027
1068
  // appends — otherwise the frequent usage save clobbers freshly-appended Messages (and vice-versa).
1028
- // Runs as the scoped-anonymous elevated user (issue #3371) — the caller's role holds no prompt-run grants.
1029
- return this.clientSessionService.AccumulatePromptRunUsage(
1030
- promptRunID, inputDelta, outputDelta, ResolveScopedAnonymousRunUser(contextUser), provider,
1031
- );
1069
+ return this.clientSessionService.AccumulatePromptRunUsage(promptRunID, inputDelta, outputDelta, runUser, provider);
1032
1070
  }
1033
1071
 
1034
1072
  /** Clamps a relayed token delta: negative / non-finite values become 0. */
@@ -1055,24 +1093,40 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1055
1093
 
1056
1094
  /**
1057
1095
  * Reads the co-agent `AIPromptRun` id from the session config WITHOUT the relay path's
1058
- * throw-on-missing-target semantics — usage relay is best-effort, so a missing/malformed
1059
- * config or absent id just logs and returns `null`.
1096
+ * throw-on-missing-target semantics, and keeps it only when it is the prompt run of the
1097
+ * session's co-agent run (the owner can edit the config). Usage relay is best-effort, so a
1098
+ * missing/malformed config, an absent id, or an id of another session just logs and returns `null`.
1099
+ *
1100
+ * @param runUser The user the prompt-run write runs as; the check runs as the same user.
1060
1101
  */
1061
- private readPromptRunID(session: MJAIAgentSessionEntity): string | null {
1062
- try {
1063
- const config = this.readSessionConfig(session);
1064
- if (config.promptRunID) {
1065
- return config.promptRunID;
1066
- }
1102
+ private async readPromptRunID(
1103
+ session: MJAIAgentSessionEntity,
1104
+ runUser: UserInfo,
1105
+ provider: IMetadataProvider,
1106
+ ): Promise<string | null> {
1107
+ const config = this.tryReadSessionConfig(session);
1108
+ if (!config) {
1109
+ LogError(`RelayRealtimeUsage: session ${session.ID} has no parseable config — usage delta dropped.`);
1110
+ return null;
1111
+ }
1112
+ if (!config.promptRunID) {
1067
1113
  LogStatus(
1068
1114
  `RelayRealtimeUsage: session ${session.ID} has no co-agent promptRunID (observability run ` +
1069
1115
  'creation was skipped or failed) — usage delta dropped.',
1070
1116
  );
1071
1117
  return null;
1072
- } catch {
1073
- LogError(`RelayRealtimeUsage: session ${session.ID} has no parseable config — usage delta dropped.`);
1118
+ }
1119
+ const verified = await this.runIDVerifier.Verify(
1120
+ session.ID,
1121
+ { CoAgentRunID: config.coAgentRunID, PromptRunID: config.promptRunID },
1122
+ runUser,
1123
+ provider,
1124
+ );
1125
+ if (!verified.PromptRunID) {
1126
+ LogError(`RelayRealtimeUsage: session ${session.ID} names a promptRunID that is not its co-agent prompt run — ignored.`);
1074
1127
  return null;
1075
1128
  }
1129
+ return verified.PromptRunID;
1076
1130
  }
1077
1131
 
1078
1132
  /**
@@ -1222,8 +1276,10 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1222
1276
  }
1223
1277
 
1224
1278
  /**
1225
- * Authorization gate for {@link RealtimeClientSessionResolver.StartRealtimeClientSession}: the
1226
- * caller must be able to run the **target** agent. Denial throws (no session is created).
1279
+ * Authorization gate for the **target** agent: the caller must be able to run it. Denial throws.
1280
+ * {@link RealtimeClientSessionResolver.StartRealtimeClientSession} runs it before any session is
1281
+ * created, and {@link RealtimeClientSessionResolver.ExecuteRealtimeSessionTool} runs it on every
1282
+ * relayed call, because the owner can edit the target in the session config.
1227
1283
  */
1228
1284
  private async assertCanRunTarget(
1229
1285
  targetAgentId: string,
@@ -1752,6 +1808,9 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1752
1808
  // (allowed-agent union); the snapshot is injected into the system prompt at mint.
1753
1809
  ApplicationID: applicationId,
1754
1810
  AppContext: appContext,
1811
+ // Scope-limited callers get no direct actions (see the authorization model above), so
1812
+ // the model is not given tools that the relay refuses.
1813
+ ExcludeDirectActions: IsScopeLimitedPrincipal(contextUser),
1755
1814
  },
1756
1815
  // SCOPED-ANONYMOUS ELEVATION (issue #3371): the prepare creates the co-agent
1757
1816
  // observability AIAgentRun/AIPromptRun/run-step, which a scoped anonymous caller's role
@@ -1793,9 +1852,10 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1793
1852
 
1794
1853
  /**
1795
1854
  * Writes the co-agent observability run ids into the session's `Config_` alongside the
1796
- * authoritative `targetAgentID`, then saves the session. These ids are read back on close to
1797
- * finalize the runs (and on relay to nest delegated runs). Best-effort: a save failure is logged,
1798
- * not thrown — the voice session still proceeds, it just won't carry the run ids.
1855
+ * `targetAgentID`, then saves the session. These ids are read back on close to
1856
+ * finalize the runs (and on relay to nest delegated runs). Relays never read the direct actions
1857
+ * from it. Best-effort: a save failure is logged, not thrown — the voice session still proceeds,
1858
+ * it just won't carry the run ids.
1799
1859
  */
1800
1860
  private async persistObservabilityRunIDs(
1801
1861
  session: MJAIAgentSessionEntity,
@@ -2537,9 +2597,10 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2537
2597
  }
2538
2598
 
2539
2599
  /**
2540
- * Parses the session's persisted config, returning the authoritative `targetAgentID` plus any
2541
- * observability run ids (`coAgentRunID`/`promptRunID`). Throws when the config is missing/malformed
2542
- * or carries no target — a relay cannot proceed without a target.
2600
+ * Parses the session's persisted config, returning its `targetAgentID` plus any observability run
2601
+ * ids (`coAgentRunID`/`promptRunID`). Throws when the config is missing/malformed or carries no
2602
+ * target — a relay cannot proceed without a target. The owner can edit the config, so callers
2603
+ * re-check what they act on.
2543
2604
  */
2544
2605
  private readSessionConfig(session: MJAIAgentSessionEntity): RealtimeSessionConfig {
2545
2606
  const raw = session.Config_;
@@ -24,6 +24,7 @@ import { MJAIAgentSessionEntity, MJAIAgentEntity } from '@memberjunction/core-en
24
24
  import { NormalizeInstanceKey, 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,
@@ -261,6 +262,9 @@ export class RemoteBrowserActionResolver extends ResolverBase {
261
262
  */
262
263
  private startedScreencasts = new Map<string, number>();
263
264
 
265
+ /** Checks the co-agent run id read from a session's owner-editable `Config_` before a step is attached to it. */
266
+ private readonly runIDVerifier = new SessionRunIDVerifier();
267
+
264
268
  /**
265
269
  * Surfaces whose live tab-audio stream this resolver has already started, keyed by
266
270
  * {@link RemoteBrowserActionResolver.streamKey} for the same reason the screencast map is. Entries
@@ -400,7 +404,13 @@ export class RemoteBrowserActionResolver extends ResolverBase {
400
404
  const providerName = await this.resolveProviderName(session, contextUser, provider);
401
405
  // Observability: nest this goal's many prompt runs under ONE "Browser goal" step on the realtime
402
406
  // co-agent run (when the session has one). Best-effort — a null step just means the goal runs unlinked.
403
- const coAgentRunID = extractCoAgentRunID(session.Config_);
407
+ // The owner can edit Config_, so its run id is used only when it is a run of this session.
408
+ const { CoAgentRunID: coAgentRunID } = await this.runIDVerifier.Verify(
409
+ session.ID,
410
+ { CoAgentRunID: extractCoAgentRunID(session.Config_) },
411
+ contextUser,
412
+ provider,
413
+ );
404
414
  const goalStep = await beginBrowserGoalStep(provider, contextUser, coAgentRunID, goal);
405
415
 
406
416
  // ASYNC START: a goal loop can run for minutes; do NOT hold this request open for it (browser