@memberjunction/server 6.2.0-edge.4 → 6.2.0-edge.5

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 (287) hide show
  1. package/README.md +20 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/agentSessions/SessionJanitor.d.ts +91 -30
  4. package/dist/agentSessions/SessionJanitor.d.ts.map +1 -1
  5. package/dist/agentSessions/SessionJanitor.js +311 -81
  6. package/dist/agentSessions/SessionJanitor.js.map +1 -1
  7. package/dist/agentSessions/SessionManager.d.ts +5 -1
  8. package/dist/agentSessions/SessionManager.d.ts.map +1 -1
  9. package/dist/agentSessions/SessionManager.js +13 -1
  10. package/dist/agentSessions/SessionManager.js.map +1 -1
  11. package/dist/apiRoot.d.ts +32 -0
  12. package/dist/apiRoot.d.ts.map +1 -0
  13. package/dist/apiRoot.js +67 -0
  14. package/dist/apiRoot.js.map +1 -0
  15. package/dist/auth/AuthProviderCatalogRouter.d.ts.map +1 -1
  16. package/dist/auth/AuthProviderCatalogRouter.js +25 -0
  17. package/dist/auth/AuthProviderCatalogRouter.js.map +1 -1
  18. package/dist/auth/LoginScreenEngine.d.ts +22 -0
  19. package/dist/auth/LoginScreenEngine.d.ts.map +1 -0
  20. package/dist/auth/LoginScreenEngine.js +57 -0
  21. package/dist/auth/LoginScreenEngine.js.map +1 -0
  22. package/dist/auth/index.d.ts +1 -0
  23. package/dist/auth/index.d.ts.map +1 -1
  24. package/dist/auth/index.js +1 -0
  25. package/dist/auth/index.js.map +1 -1
  26. package/dist/auth/magicLink/MagicLinkRouter.d.ts.map +1 -1
  27. package/dist/auth/magicLink/MagicLinkRouter.js +3 -2
  28. package/dist/auth/magicLink/MagicLinkRouter.js.map +1 -1
  29. package/dist/config.d.ts +136 -22
  30. package/dist/config.d.ts.map +1 -1
  31. package/dist/config.js +43 -4
  32. package/dist/config.js.map +1 -1
  33. package/dist/coreRoutes.d.ts +29 -0
  34. package/dist/coreRoutes.d.ts.map +1 -0
  35. package/dist/coreRoutes.js +142 -0
  36. package/dist/coreRoutes.js.map +1 -0
  37. package/dist/generated/graphql-schemas/__mj.d.ts +369 -3
  38. package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
  39. package/dist/generated/graphql-schemas/__mj.js +1750 -7
  40. package/dist/generated/graphql-schemas/__mj.js.map +1 -1
  41. package/dist/generic/CacheInvalidationResolver.d.ts +43 -8
  42. package/dist/generic/CacheInvalidationResolver.d.ts.map +1 -1
  43. package/dist/generic/CacheInvalidationResolver.js +59 -19
  44. package/dist/generic/CacheInvalidationResolver.js.map +1 -1
  45. package/dist/generic/PushStatusResolver.js +1 -1
  46. package/dist/generic/PushStatusResolver.js.map +1 -1
  47. package/dist/generic/ResolverBase.d.ts +103 -44
  48. package/dist/generic/ResolverBase.d.ts.map +1 -1
  49. package/dist/generic/ResolverBase.js +264 -305
  50. package/dist/generic/ResolverBase.js.map +1 -1
  51. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  52. package/dist/generic/RunViewResolver.js +12 -0
  53. package/dist/generic/RunViewResolver.js.map +1 -1
  54. package/dist/globalCors.d.ts +33 -0
  55. package/dist/globalCors.d.ts.map +1 -0
  56. package/dist/globalCors.js +58 -0
  57. package/dist/globalCors.js.map +1 -0
  58. package/dist/index.d.ts +11 -0
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +112 -160
  61. package/dist/index.js.map +1 -1
  62. package/dist/multiTenancy/index.d.ts.map +1 -1
  63. package/dist/multiTenancy/index.js +22 -2
  64. package/dist/multiTenancy/index.js.map +1 -1
  65. package/dist/realtimeSessions/RealtimeSessionVerificationService.d.ts.map +1 -1
  66. package/dist/realtimeSessions/RealtimeSessionVerificationService.js +2 -1
  67. package/dist/realtimeSessions/RealtimeSessionVerificationService.js.map +1 -1
  68. package/dist/realtimeSessions/RealtimeVerifyRouter.d.ts.map +1 -1
  69. package/dist/realtimeSessions/RealtimeVerifyRouter.js +2 -1
  70. package/dist/realtimeSessions/RealtimeVerifyRouter.js.map +1 -1
  71. package/dist/realtimeSessions/RecordingAssemblyScheduler.d.ts +100 -0
  72. package/dist/realtimeSessions/RecordingAssemblyScheduler.d.ts.map +1 -0
  73. package/dist/realtimeSessions/RecordingAssemblyScheduler.js +173 -0
  74. package/dist/realtimeSessions/RecordingAssemblyScheduler.js.map +1 -0
  75. package/dist/realtimeSessions/index.d.ts +2 -0
  76. package/dist/realtimeSessions/index.d.ts.map +1 -1
  77. package/dist/realtimeSessions/index.js +2 -0
  78. package/dist/realtimeSessions/index.js.map +1 -1
  79. package/dist/realtimeSessions/ownedRealtimeSession.d.ts +30 -0
  80. package/dist/realtimeSessions/ownedRealtimeSession.d.ts.map +1 -0
  81. package/dist/realtimeSessions/ownedRealtimeSession.js +47 -0
  82. package/dist/realtimeSessions/ownedRealtimeSession.js.map +1 -0
  83. package/dist/realtimeSessions/sessionAccess.d.ts +6 -0
  84. package/dist/realtimeSessions/sessionAccess.d.ts.map +1 -1
  85. package/dist/realtimeSessions/sessionAccess.js +7 -3
  86. package/dist/realtimeSessions/sessionAccess.js.map +1 -1
  87. package/dist/resolvers/ArtifactFileResolver.d.ts +5 -1
  88. package/dist/resolvers/ArtifactFileResolver.d.ts.map +1 -1
  89. package/dist/resolvers/ArtifactFileResolver.js +9 -7
  90. package/dist/resolvers/ArtifactFileResolver.js.map +1 -1
  91. package/dist/resolvers/ConversationRunEventsResolver.d.ts +8 -4
  92. package/dist/resolvers/ConversationRunEventsResolver.d.ts.map +1 -1
  93. package/dist/resolvers/ConversationRunEventsResolver.js +6 -6
  94. package/dist/resolvers/ConversationRunEventsResolver.js.map +1 -1
  95. package/dist/resolvers/DatasetResolver.d.ts +24 -3
  96. package/dist/resolvers/DatasetResolver.d.ts.map +1 -1
  97. package/dist/resolvers/DatasetResolver.js +57 -8
  98. package/dist/resolvers/DatasetResolver.js.map +1 -1
  99. package/dist/resolvers/EntityRecordNameResolver.d.ts +2 -1
  100. package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
  101. package/dist/resolvers/EntityRecordNameResolver.js +3 -2
  102. package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
  103. package/dist/resolvers/FetchEntityVectorsResolver.d.ts +18 -3
  104. package/dist/resolvers/FetchEntityVectorsResolver.d.ts.map +1 -1
  105. package/dist/resolvers/FetchEntityVectorsResolver.js +100 -35
  106. package/dist/resolvers/FetchEntityVectorsResolver.js.map +1 -1
  107. package/dist/resolvers/FileResolver.d.ts +82 -11
  108. package/dist/resolvers/FileResolver.d.ts.map +1 -1
  109. package/dist/resolvers/FileResolver.js +279 -101
  110. package/dist/resolvers/FileResolver.js.map +1 -1
  111. package/dist/resolvers/GetDataContextDataResolver.d.ts +33 -7
  112. package/dist/resolvers/GetDataContextDataResolver.d.ts.map +1 -1
  113. package/dist/resolvers/GetDataContextDataResolver.js +117 -38
  114. package/dist/resolvers/GetDataContextDataResolver.js.map +1 -1
  115. package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
  116. package/dist/resolvers/GetDataResolver.js +6 -3
  117. package/dist/resolvers/GetDataResolver.js.map +1 -1
  118. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  119. package/dist/resolvers/IntegrationDiscoveryResolver.js +4 -2
  120. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  121. package/dist/resolvers/QueryResolver.d.ts +2 -0
  122. package/dist/resolvers/QueryResolver.d.ts.map +1 -1
  123. package/dist/resolvers/QueryResolver.js +13 -0
  124. package/dist/resolvers/QueryResolver.js.map +1 -1
  125. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +2 -8
  126. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  127. package/dist/resolvers/RealtimeClientSessionResolver.js +10 -26
  128. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  129. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  130. package/dist/resolvers/RunAIAgentResolver.js +35 -6
  131. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  132. package/dist/resolvers/SearchKnowledgeStreamResolver.d.ts +12 -1
  133. package/dist/resolvers/SearchKnowledgeStreamResolver.d.ts.map +1 -1
  134. package/dist/resolvers/SearchKnowledgeStreamResolver.js +15 -2
  135. package/dist/resolvers/SearchKnowledgeStreamResolver.js.map +1 -1
  136. package/dist/resolvers/SqlLoggingConfigResolver.d.ts.map +1 -1
  137. package/dist/resolvers/SqlLoggingConfigResolver.js +2 -3
  138. package/dist/resolvers/SqlLoggingConfigResolver.js.map +1 -1
  139. package/dist/resolvers/TaskGraphFrameResolver.d.ts +2 -1
  140. package/dist/resolvers/TaskGraphFrameResolver.d.ts.map +1 -1
  141. package/dist/resolvers/TaskGraphFrameResolver.js +28 -1
  142. package/dist/resolvers/TaskGraphFrameResolver.js.map +1 -1
  143. package/dist/resolvers/TestQuerySQLResolver.d.ts +24 -0
  144. package/dist/resolvers/TestQuerySQLResolver.d.ts.map +1 -1
  145. package/dist/resolvers/TestQuerySQLResolver.js +65 -13
  146. package/dist/resolvers/TestQuerySQLResolver.js.map +1 -1
  147. package/dist/resolvers/TransactionGroupResolver.d.ts +19 -2
  148. package/dist/resolvers/TransactionGroupResolver.d.ts.map +1 -1
  149. package/dist/resolvers/TransactionGroupResolver.js +102 -24
  150. package/dist/resolvers/TransactionGroupResolver.js.map +1 -1
  151. package/dist/resolvers/agent-run-data-guard.d.ts +31 -0
  152. package/dist/resolvers/agent-run-data-guard.d.ts.map +1 -0
  153. package/dist/resolvers/agent-run-data-guard.js +72 -0
  154. package/dist/resolvers/agent-run-data-guard.js.map +1 -0
  155. package/dist/rest/RealtimeRecordingHandler.d.ts +49 -0
  156. package/dist/rest/RealtimeRecordingHandler.d.ts.map +1 -0
  157. package/dist/rest/RealtimeRecordingHandler.js +383 -0
  158. package/dist/rest/RealtimeRecordingHandler.js.map +1 -0
  159. package/dist/rest/ViewOperationsHandler.d.ts +3 -1
  160. package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
  161. package/dist/rest/ViewOperationsHandler.js +21 -12
  162. package/dist/rest/ViewOperationsHandler.js.map +1 -1
  163. package/dist/rest/setupRESTEndpoints.d.ts +6 -0
  164. package/dist/rest/setupRESTEndpoints.d.ts.map +1 -1
  165. package/dist/rest/setupRESTEndpoints.js +6 -0
  166. package/dist/rest/setupRESTEndpoints.js.map +1 -1
  167. package/dist/serverExtensionReservedRoots.d.ts +12 -10
  168. package/dist/serverExtensionReservedRoots.d.ts.map +1 -1
  169. package/dist/serverExtensionReservedRoots.js +14 -17
  170. package/dist/serverExtensionReservedRoots.js.map +1 -1
  171. package/dist/services/MJStorageBlobStore.d.ts +8 -1
  172. package/dist/services/MJStorageBlobStore.d.ts.map +1 -1
  173. package/dist/services/MJStorageBlobStore.js +25 -19
  174. package/dist/services/MJStorageBlobStore.js.map +1 -1
  175. package/dist/services/StartTaskGraphDispatcher.js +2 -2
  176. package/dist/services/StartTaskGraphDispatcher.js.map +1 -1
  177. package/dist/services/TaskGraphActionRunner.d.ts +5 -3
  178. package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
  179. package/dist/services/TaskGraphActionRunner.js +27 -5
  180. package/dist/services/TaskGraphActionRunner.js.map +1 -1
  181. package/dist/services/TaskGraphAgentRunner.d.ts +14 -2
  182. package/dist/services/TaskGraphAgentRunner.d.ts.map +1 -1
  183. package/dist/services/TaskGraphAgentRunner.js +157 -9
  184. package/dist/services/TaskGraphAgentRunner.js.map +1 -1
  185. package/dist/services/TaskGraphContinuationDeliverer.d.ts +54 -2
  186. package/dist/services/TaskGraphContinuationDeliverer.d.ts.map +1 -1
  187. package/dist/services/TaskGraphContinuationDeliverer.js +598 -15
  188. package/dist/services/TaskGraphContinuationDeliverer.js.map +1 -1
  189. package/dist/sharedCache.d.ts.map +1 -1
  190. package/dist/sharedCache.js +3 -0
  191. package/dist/sharedCache.js.map +1 -1
  192. package/package.json +99 -98
  193. package/src/__tests__/DatasetResolver.itemFilters.test.ts +201 -0
  194. package/src/__tests__/EntityRecordNameResolver.rowSecurity.test.ts +152 -0
  195. package/src/__tests__/FetchEntityVectorsResolver.test.ts +163 -15
  196. package/src/__tests__/FileResolver.storageAccess.test.ts +562 -0
  197. package/src/__tests__/GetDataContextDataResolver.sqlItems.test.ts +416 -0
  198. package/src/__tests__/ListOperationsResolver.adhocScreen.test.ts +137 -0
  199. package/src/__tests__/LoginScreenEngine.test.ts +86 -0
  200. package/src/__tests__/MJStorageBlobStore.storageGate.test.ts +63 -0
  201. package/src/__tests__/RecordingAssemblyScheduler.test.ts +178 -0
  202. package/src/__tests__/ResolverBase.adhocReadScope.test.ts +133 -2
  203. package/src/__tests__/ResolverBase.balancedClause.test.ts +155 -0
  204. package/src/__tests__/ResolverBase.clauseUnscopedRead.test.ts +127 -0
  205. package/src/__tests__/ResolverBase.clientOldValues.test.ts +209 -6
  206. package/src/__tests__/ResolverBase.filterEscaping.test.ts +5 -2
  207. package/src/__tests__/RunAIAgentResolver.branch.test.ts +1 -0
  208. package/src/__tests__/RunAIAgentResolver.historyFrom.test.ts +5 -3
  209. package/src/__tests__/RunAIAgentResolver.runDataGuard.test.ts +258 -0
  210. package/src/__tests__/RunViewResolver.cacheCheckScreen.test.ts +165 -0
  211. package/src/__tests__/SearchKnowledgeStreamResolver.test.ts +161 -0
  212. package/src/__tests__/SessionJanitor.test.ts +365 -10
  213. package/src/__tests__/SessionManager.test.ts +27 -0
  214. package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +25 -0
  215. package/src/__tests__/TaskGraphAgentRunner.test.ts +266 -0
  216. package/src/__tests__/TaskGraphContinuationDeliverer.test.ts +356 -22
  217. package/src/__tests__/TaskGraphFrameResolver.test.ts +22 -0
  218. package/src/__tests__/TestQuerySQLResolver.test.ts +274 -0
  219. package/src/__tests__/TransactionGroupResolver.refusals.test.ts +98 -1
  220. package/src/__tests__/UpdateMJDataContextItem.oldValues.test.ts +220 -0
  221. package/src/__tests__/ViewOperationsHandler.clauseScreen.test.ts +157 -0
  222. package/src/__tests__/agent-run-data-guard.test.ts +130 -0
  223. package/src/__tests__/apiRootBoot.test.ts +202 -0
  224. package/src/__tests__/cacheInvalidationRecordData.test.ts +64 -0
  225. package/src/__tests__/conversationRunEvents.test.ts +56 -14
  226. package/src/__tests__/globalCors.test.ts +217 -0
  227. package/src/__tests__/multiTenancy.postgresRewrite.test.ts +181 -0
  228. package/src/__tests__/multiTenancy.security.test.ts +51 -0
  229. package/src/__tests__/multiTenancy.test.ts +1 -1
  230. package/src/__tests__/ownedRealtimeSession.test.ts +39 -0
  231. package/src/__tests__/pushStatusResolver.filter.test.ts +7 -0
  232. package/src/__tests__/realtimeRecordingHandler.test.ts +652 -0
  233. package/src/__tests__/serverExtensionHostOverlay.test.ts +66 -0
  234. package/src/__tests__/serverExtensionReservedRoots.test.ts +11 -98
  235. package/src/__tests__/sharedCache.test.ts +5 -3
  236. package/src/agentSessions/SessionJanitor.ts +369 -93
  237. package/src/agentSessions/SessionManager.ts +13 -1
  238. package/src/apiRoot.ts +78 -0
  239. package/src/auth/AuthProviderCatalogRouter.ts +26 -0
  240. package/src/auth/LoginScreenEngine.ts +50 -0
  241. package/src/auth/index.ts +1 -0
  242. package/src/auth/magicLink/MagicLinkRouter.ts +3 -2
  243. package/src/config.ts +46 -5
  244. package/src/coreRoutes.ts +166 -0
  245. package/src/generated/graphql-schemas/__mj.ts +1273 -10
  246. package/src/generic/CacheInvalidationResolver.ts +73 -18
  247. package/src/generic/PushStatusResolver.ts +1 -1
  248. package/src/generic/ResolverBase.ts +304 -325
  249. package/src/generic/RunViewResolver.ts +17 -0
  250. package/src/globalCors.ts +71 -0
  251. package/src/index.ts +116 -174
  252. package/src/multiTenancy/index.ts +24 -2
  253. package/src/realtimeSessions/RealtimeSessionVerificationService.ts +2 -1
  254. package/src/realtimeSessions/RealtimeVerifyRouter.ts +2 -1
  255. package/src/realtimeSessions/RecordingAssemblyScheduler.ts +234 -0
  256. package/src/realtimeSessions/index.ts +2 -0
  257. package/src/realtimeSessions/ownedRealtimeSession.ts +55 -0
  258. package/src/realtimeSessions/sessionAccess.ts +7 -3
  259. package/src/resolvers/ArtifactFileResolver.ts +9 -8
  260. package/src/resolvers/ConversationRunEventsResolver.ts +15 -11
  261. package/src/resolvers/DatasetResolver.ts +70 -10
  262. package/src/resolvers/EntityRecordNameResolver.ts +3 -2
  263. package/src/resolvers/FetchEntityVectorsResolver.ts +121 -39
  264. package/src/resolvers/FileResolver.ts +321 -106
  265. package/src/resolvers/GetDataContextDataResolver.ts +139 -41
  266. package/src/resolvers/GetDataResolver.ts +6 -3
  267. package/src/resolvers/IntegrationDiscoveryResolver.ts +4 -2
  268. package/src/resolvers/QueryResolver.ts +11 -0
  269. package/src/resolvers/RealtimeClientSessionResolver.ts +10 -33
  270. package/src/resolvers/RunAIAgentResolver.ts +39 -4
  271. package/src/resolvers/SearchKnowledgeStreamResolver.ts +19 -3
  272. package/src/resolvers/SqlLoggingConfigResolver.ts +2 -4
  273. package/src/resolvers/TaskGraphFrameResolver.ts +33 -1
  274. package/src/resolvers/TestQuerySQLResolver.ts +68 -13
  275. package/src/resolvers/TransactionGroupResolver.ts +131 -27
  276. package/src/resolvers/__tests__/GetDataResolver.validation.test.ts +95 -0
  277. package/src/resolvers/agent-run-data-guard.ts +89 -0
  278. package/src/rest/RealtimeRecordingHandler.ts +464 -0
  279. package/src/rest/ViewOperationsHandler.ts +29 -13
  280. package/src/rest/setupRESTEndpoints.ts +6 -0
  281. package/src/serverExtensionReservedRoots.ts +14 -17
  282. package/src/services/MJStorageBlobStore.ts +31 -30
  283. package/src/services/StartTaskGraphDispatcher.ts +2 -2
  284. package/src/services/TaskGraphActionRunner.ts +25 -5
  285. package/src/services/TaskGraphAgentRunner.ts +188 -11
  286. package/src/services/TaskGraphContinuationDeliverer.ts +704 -14
  287. package/src/sharedCache.ts +3 -0
@@ -128,7 +128,7 @@ describe('TailConversationEvents', () => {
128
128
 
129
129
  const runCall = mockRunView.mock.calls.find((c) => c[0].EntityName === 'MJ: AI Agent Runs');
130
130
  expect(runCall?.[0].ResultType).toBe('simple');
131
- expect([...(runCall?.[0].Fields ?? [])].sort()).toEqual(['ID', 'Result', 'Status']);
131
+ expect([...(runCall?.[0].Fields ?? [])].sort()).toEqual(['FinalPayload', 'ID', 'Result', 'Status']);
132
132
  });
133
133
 
134
134
  it('returns real dates when a plain row carries them as strings', async () => {
@@ -261,20 +261,20 @@ describe('TailConversationEvents', () => {
261
261
  it('marks a Running run in flight and withholds the payload', async () => {
262
262
  scriptRunView({
263
263
  detail: { ID: DETAIL_ID, Status: 'In-Progress' },
264
- run: { ID: RUN_ID, Status: 'Running', Result: '{"partial":true}' },
264
+ run: { ID: RUN_ID, Status: 'Running', FinalPayload: '{"partial":true}' },
265
265
  steps: [step(1)],
266
266
  });
267
267
  const out = await resolver.TailConversationEvents(DETAIL_ID, ctxFor(USER), 0);
268
268
 
269
269
  expect(out.IsInFlight).toBe(true);
270
- // A partial Result mid-run could be mistaken for the final answer.
270
+ // FinalPayload is filled during the run. A partial one could be mistaken for the final answer.
271
271
  expect(out.FinalPayload).toBeUndefined();
272
272
  });
273
273
 
274
274
  it('releases the payload once the run is terminal, so one call ends the wait', async () => {
275
275
  scriptRunView({
276
276
  detail: { ID: DETAIL_ID, Status: 'Complete' },
277
- run: { ID: RUN_ID, Status: 'Completed', Result: '{"answer":"done"}' },
277
+ run: { ID: RUN_ID, Status: 'Completed', FinalPayload: '{"answer":"done"}', Result: null },
278
278
  steps: [step(1)],
279
279
  });
280
280
  const out = await resolver.TailConversationEvents(DETAIL_ID, ctxFor(USER), 0);
@@ -302,7 +302,7 @@ describe('TailConversationEvents', () => {
302
302
  // Reporting it as terminal would tell a client to complete a message mid-workflow.
303
303
  scriptRunView({
304
304
  detail: { ID: DETAIL_ID, Status: 'In-Progress' },
305
- run: { ID: RUN_ID, Status: 'Paused', Result: '{"started":"workflow"}' },
305
+ run: { ID: RUN_ID, Status: 'Paused', FinalPayload: '{"started":"workflow"}' },
306
306
  steps: [step(1)],
307
307
  });
308
308
  const out = await resolver.TailConversationEvents(DETAIL_ID, ctxFor(USER), 0);
@@ -353,16 +353,19 @@ describe('TailConversationEvents', () => {
353
353
  });
354
354
 
355
355
  /**
356
- * REGRESSION, found in manual testing. `FinalPayload` read only `AIAgentRun.Result`, which is
357
- * agent-dependent — null on 16 of 40 completed runs in a real database, including every run of the
358
- * Loop-type agent used to reproduce #4222. A caller waiting on this field to decide a message was
359
- * finished would wait forever on those agents.
356
+ * `FinalPayload` comes from `AIAgentRun.FinalPayload`, then `Result` (some older rows have no
357
+ * `FinalPayload`), then the conversation detail's message.
358
+ *
359
+ * REGRESSION, found in manual testing: the run payload is agent-dependent — null on 16 of 40
360
+ * completed runs in a real database, including every run of the Loop-type agent used to reproduce
361
+ * #4222. Without the detail-message fallback, a caller waiting on this field to decide a message
362
+ * was finished would wait forever on those agents.
360
363
  */
361
364
  describe('TailConversationEvents FinalPayload sourcing', () => {
362
- it('falls back to the conversation detail message when the run has no Result', async () => {
365
+ it('falls back to the conversation detail message when the run has no payload', async () => {
363
366
  scriptRunView({
364
367
  detail: { ID: DETAIL_ID, Status: 'Complete', Message: 'the answer text' },
365
- run: { ID: RUN_ID, Status: 'Completed', Result: null },
368
+ run: { ID: RUN_ID, Status: 'Completed', FinalPayload: null, Result: null },
366
369
  steps: [],
367
370
  });
368
371
 
@@ -372,10 +375,34 @@ describe('TailConversationEvents FinalPayload sourcing', () => {
372
375
  expect(result.FinalPayload).toBe('the answer text');
373
376
  });
374
377
 
375
- it('prefers the run Result when the agent does write one', async () => {
378
+ it('prefers the run FinalPayload over the detail message', async () => {
379
+ scriptRunView({
380
+ detail: { ID: DETAIL_ID, Status: 'Complete', Message: 'detail message' },
381
+ run: { ID: RUN_ID, Status: 'Completed', FinalPayload: 'run payload', Result: null },
382
+ steps: [],
383
+ });
384
+
385
+ const result = await new ConversationRunEventsResolver().TailConversationEvents(DETAIL_ID, ctxFor(USER), 0);
386
+
387
+ expect(result.FinalPayload).toBe('run payload');
388
+ });
389
+
390
+ it('prefers FinalPayload over Result when a row has both', async () => {
391
+ scriptRunView({
392
+ detail: { ID: DETAIL_ID, Status: 'Complete', Message: 'detail message' },
393
+ run: { ID: RUN_ID, Status: 'Completed', FinalPayload: 'run payload', Result: 'run result' },
394
+ steps: [],
395
+ });
396
+
397
+ const result = await new ConversationRunEventsResolver().TailConversationEvents(DETAIL_ID, ctxFor(USER), 0);
398
+
399
+ expect(result.FinalPayload).toBe('run payload');
400
+ });
401
+
402
+ it('falls back to Result on an older row that has no FinalPayload', async () => {
376
403
  scriptRunView({
377
404
  detail: { ID: DETAIL_ID, Status: 'Complete', Message: 'detail message' },
378
- run: { ID: RUN_ID, Status: 'Completed', Result: 'run result' },
405
+ run: { ID: RUN_ID, Status: 'Completed', FinalPayload: null, Result: 'run result' },
379
406
  steps: [],
380
407
  });
381
408
 
@@ -384,10 +411,25 @@ describe('TailConversationEvents FinalPayload sourcing', () => {
384
411
  expect(result.FinalPayload).toBe('run result');
385
412
  });
386
413
 
414
+ it('returns the partial FinalPayload a Cancelled run saved, with RunStatus telling the caller', async () => {
415
+ // Cancelling saves the run with no Result, but it keeps the FinalPayload saved during the run.
416
+ scriptRunView({
417
+ detail: { ID: DETAIL_ID, Status: 'Error', Message: 'detail message' },
418
+ run: { ID: RUN_ID, Status: 'Cancelled', FinalPayload: '{"partial":true}', Result: null },
419
+ steps: [],
420
+ });
421
+
422
+ const result = await new ConversationRunEventsResolver().TailConversationEvents(DETAIL_ID, ctxFor(USER), 0);
423
+
424
+ expect(result.IsInFlight).toBe(false);
425
+ expect(result.RunStatus).toBe('Cancelled');
426
+ expect(result.FinalPayload).toBe('{"partial":true}');
427
+ });
428
+
387
429
  it('still withholds the payload while the run is in flight', async () => {
388
430
  scriptRunView({
389
431
  detail: { ID: DETAIL_ID, Status: 'In-Progress', Message: 'partial text' },
390
- run: { ID: RUN_ID, Status: 'Running', Result: null },
432
+ run: { ID: RUN_ID, Status: 'Running', FinalPayload: '{"partial":true}', Result: null },
391
433
  steps: [],
392
434
  });
393
435
 
@@ -0,0 +1,217 @@
1
+ /**
2
+ * MJServer's global CORS middleware vs. server extensions that own their CORS policy.
3
+ *
4
+ * Reproduces the BizApps Voting ballot-edge failure: the global middleware ran ahead of the
5
+ * extension, reflected any origin onto the extension's refusals, and answered every preflight
6
+ * with 204 so the extension's own OPTIONS handler never ran. Runs a real Express app on an
7
+ * ephemeral port so the preflight is Express's and `cors`'s own behavior, not a mock.
8
+ */
9
+ import { describe, it, expect, afterEach } from 'vitest';
10
+ import express, { type Application } from 'express';
11
+ import type { Server } from 'node:http';
12
+ import type { AddressInfo } from 'node:net';
13
+ import { readFileSync } from 'node:fs';
14
+ import { GetCorsOwningServerExtensionRoots, type ServerExtensionConfig } from '@memberjunction/server-extensions-core';
15
+ import cors from 'cors';
16
+ import { CreateExtensionOwnedPreflightFallback, CreateGlobalCorsMiddleware } from '../globalCors.js';
17
+
18
+ const ALLOWED_BALLOT_ORIGIN = 'https://vote.example.org';
19
+ const OTHER_ORIGIN = 'https://evil.example.com';
20
+
21
+ /** A ballot-style edge: its own per-resource origin policy and its own preflight answer. */
22
+ function mountBallotExtension(app: Application): void {
23
+ const router = express.Router();
24
+ router.use((req, res, next) => {
25
+ const origin = req.headers.origin;
26
+ if (origin && origin !== ALLOWED_BALLOT_ORIGIN) {
27
+ res.status(403).json({ error: 'origin not allowed for this ballot' });
28
+ return;
29
+ }
30
+ if (origin) {
31
+ res.setHeader('Access-Control-Allow-Origin', origin);
32
+ res.setHeader('Vary', 'Origin');
33
+ }
34
+ if (req.method === 'OPTIONS') {
35
+ res.setHeader('Access-Control-Allow-Methods', 'POST');
36
+ res.setHeader('X-Ballot-Preflight', 'extension');
37
+ res.status(200).end();
38
+ return;
39
+ }
40
+ next();
41
+ });
42
+ router.post('/cast', (_req, res) => { res.json({ ok: true }); });
43
+ app.use('/ballot', router);
44
+ }
45
+
46
+ let server: Server | undefined;
47
+
48
+ afterEach(async () => {
49
+ if (server) {
50
+ await new Promise<void>((resolve) => server!.close(() => resolve()));
51
+ server = undefined;
52
+ }
53
+ });
54
+
55
+ async function startApp(extensions: ServerExtensionConfig[]): Promise<string> {
56
+ const app = express();
57
+ app.disable('x-powered-by');
58
+ app.use(CreateGlobalCorsMiddleware({ allowedOrigins: ['*'] }, GetCorsOwningServerExtensionRoots(extensions)));
59
+ mountBallotExtension(app);
60
+ app.get('/core', (_req, res) => { res.json({ ok: true }); });
61
+ server = app.listen(0);
62
+ await new Promise<void>((resolve) => server!.once('listening', () => resolve()));
63
+ return `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
64
+ }
65
+
66
+ const ballotConfig = (ownsCors: boolean | undefined): ServerExtensionConfig => ({
67
+ Enabled: true,
68
+ DriverClass: 'BallotEdge',
69
+ RootPath: '/ballot',
70
+ Phase: 'pre-auth',
71
+ ...(ownsCors === undefined ? {} : { OwnsCORS: ownsCors }),
72
+ Settings: {},
73
+ });
74
+
75
+ describe('CreateGlobalCorsMiddleware', () => {
76
+ it('without OwnsCORS, global CORS answers the preflight and the extension never sees it (the bug)', async () => {
77
+ const base = await startApp([ballotConfig(undefined)]);
78
+ const res = await fetch(`${base}/ballot/cast`, {
79
+ method: 'OPTIONS',
80
+ headers: { Origin: OTHER_ORIGIN, 'Access-Control-Request-Method': 'POST' },
81
+ });
82
+ expect(res.status).toBe(204);
83
+ expect(res.headers.get('access-control-allow-origin')).toBe(OTHER_ORIGIN);
84
+ expect(res.headers.get('x-ballot-preflight')).toBeNull();
85
+ });
86
+
87
+ it('with OwnsCORS, the preflight reaches the extension, which refuses a foreign origin without CORS headers', async () => {
88
+ const base = await startApp([ballotConfig(true)]);
89
+ const res = await fetch(`${base}/ballot/cast`, {
90
+ method: 'OPTIONS',
91
+ headers: { Origin: OTHER_ORIGIN, 'Access-Control-Request-Method': 'POST' },
92
+ });
93
+ expect(res.status).toBe(403);
94
+ expect(res.headers.get('access-control-allow-origin')).toBeNull();
95
+ });
96
+
97
+ it('with OwnsCORS, the extension answers its own preflight for its allowed origin', async () => {
98
+ const base = await startApp([ballotConfig(true)]);
99
+ const res = await fetch(`${base}/ballot/cast`, {
100
+ method: 'OPTIONS',
101
+ headers: { Origin: ALLOWED_BALLOT_ORIGIN, 'Access-Control-Request-Method': 'POST' },
102
+ });
103
+ expect(res.status).toBe(200);
104
+ expect(res.headers.get('x-ballot-preflight')).toBe('extension');
105
+ expect(res.headers.get('access-control-allow-origin')).toBe(ALLOWED_BALLOT_ORIGIN);
106
+ // The global policy's max-age would only be there if global CORS had run.
107
+ expect(res.headers.get('access-control-max-age')).toBeNull();
108
+ });
109
+
110
+ it('with OwnsCORS, a foreign-origin refusal on a normal request carries no reflected origin', async () => {
111
+ const base = await startApp([ballotConfig(true)]);
112
+ const res = await fetch(`${base}/ballot/cast`, { method: 'POST', headers: { Origin: OTHER_ORIGIN } });
113
+ expect(res.status).toBe(403);
114
+ expect(res.headers.get('access-control-allow-origin')).toBeNull();
115
+ });
116
+
117
+ it('keeps global CORS on every path the extension does not own', async () => {
118
+ const base = await startApp([ballotConfig(true)]);
119
+ const res = await fetch(`${base}/core`, {
120
+ method: 'OPTIONS',
121
+ headers: { Origin: OTHER_ORIGIN, 'Access-Control-Request-Method': 'GET' },
122
+ });
123
+ expect(res.status).toBe(204);
124
+ expect(res.headers.get('access-control-allow-origin')).toBe(OTHER_ORIGIN);
125
+ expect(res.headers.get('access-control-max-age')).toBe('86400');
126
+ });
127
+
128
+ it('ignores OwnsCORS on a disabled extension', async () => {
129
+ const base = await startApp([{ ...ballotConfig(true), Enabled: false }]);
130
+ const res = await fetch(`${base}/ballot/cast`, {
131
+ method: 'OPTIONS',
132
+ headers: { Origin: OTHER_ORIGIN, 'Access-Control-Request-Method': 'POST' },
133
+ });
134
+ expect(res.status).toBe(204);
135
+ });
136
+
137
+ it('never sends credentials under the wildcard, even when allowCredentials is true', async () => {
138
+ const app = express();
139
+ app.use(CreateGlobalCorsMiddleware({ allowedOrigins: ['*'], allowCredentials: true }));
140
+ app.get('/core', (_req, res) => { res.json({ ok: true }); });
141
+ server = app.listen(0);
142
+ await new Promise<void>((resolve) => server!.once('listening', () => resolve()));
143
+ const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
144
+ const res = await fetch(`${base}/core`, { headers: { Origin: OTHER_ORIGIN } });
145
+ expect(res.headers.get('access-control-allow-credentials')).toBeNull();
146
+ });
147
+ });
148
+
149
+ /**
150
+ * The real mount order: global CORS → extensions → (fallback) → the GraphQL mount at `/`, whose own
151
+ * `cors()` approves every origin. The webhook extension owns its CORS but has no OPTIONS handler.
152
+ */
153
+ async function startAppWithGraphQLMount(withFallback: boolean): Promise<string> {
154
+ const extensions: ServerExtensionConfig[] = [
155
+ ballotConfig(true),
156
+ { Enabled: true, DriverClass: 'Webhook', RootPath: '/webhook', Phase: 'pre-auth', OwnsCORS: true, Settings: {} },
157
+ ];
158
+ const ownedRoots = GetCorsOwningServerExtensionRoots(extensions);
159
+ const app = express();
160
+ app.use(CreateGlobalCorsMiddleware({ allowedOrigins: ['*'] }, ownedRoots));
161
+ mountBallotExtension(app);
162
+ app.post('/webhook/events', (_req, res) => { res.json({ received: true }); });
163
+ if (withFallback) {
164
+ app.use(CreateExtensionOwnedPreflightFallback(ownedRoots));
165
+ }
166
+ app.use('/', cors(), (_req, res) => { res.json({ graphql: true }); });
167
+ server = app.listen(0);
168
+ await new Promise<void>((resolve) => server!.once('listening', () => resolve()));
169
+ return `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
170
+ }
171
+
172
+ async function preflight(url: string, origin: string): Promise<Response> {
173
+ return fetch(url, { method: 'OPTIONS', headers: { Origin: origin, 'Access-Control-Request-Method': 'POST' } });
174
+ }
175
+
176
+ describe('CreateExtensionOwnedPreflightFallback', () => {
177
+ it('without it, an unanswered preflight under an owned root falls through to the GraphQL cors() and is approved for any origin (the bug)', async () => {
178
+ const base = await startAppWithGraphQLMount(false);
179
+ const res = await preflight(`${base}/webhook/events`, OTHER_ORIGIN);
180
+ expect(res.status).toBe(204);
181
+ expect(res.headers.get('access-control-allow-origin')).toBe('*');
182
+ });
183
+
184
+ it('refuses an unanswered preflight under an owned root with a bare 404', async () => {
185
+ const base = await startAppWithGraphQLMount(true);
186
+ for (const path of ['/webhook/events', '/WEBHOOK/Events', '/webhook/', '/webhook/no-such-route']) {
187
+ const res = await preflight(`${base}${path}`, OTHER_ORIGIN);
188
+ expect(res.status, path).toBe(404);
189
+ expect(res.headers.get('access-control-allow-origin'), path).toBeNull();
190
+ }
191
+ });
192
+
193
+ it('leaves an extension that answers its own preflight in charge', async () => {
194
+ const base = await startAppWithGraphQLMount(true);
195
+ const res = await preflight(`${base}/ballot/cast`, ALLOWED_BALLOT_ORIGIN);
196
+ expect(res.status).toBe(200);
197
+ expect(res.headers.get('x-ballot-preflight')).toBe('extension');
198
+ });
199
+
200
+ it('touches neither non-preflight requests under an owned root nor preflights elsewhere', async () => {
201
+ const base = await startAppWithGraphQLMount(true);
202
+ const post = await fetch(`${base}/webhook/events`, { method: 'POST', headers: { Origin: OTHER_ORIGIN } });
203
+ expect(post.status).toBe(200);
204
+ const sibling = await preflight(`${base}/webhookx/events`, OTHER_ORIGIN);
205
+ expect(sibling.status).toBe(204);
206
+ expect(sibling.headers.get('access-control-allow-origin')).toBe(OTHER_ORIGIN);
207
+ });
208
+ });
209
+
210
+ describe('X-Powered-By', () => {
211
+ it('CreateApp and Serve() both disable X-Powered-By', () => {
212
+ // Read as text so this test never loads config.ts / the full server graph.
213
+ const src = readFileSync(new URL('../index.ts', import.meta.url), 'utf8');
214
+ // Both the default app factory and Serve() (for host-supplied apps) disable the header.
215
+ expect(src.match(/app\.disable\('x-powered-by'\)/g)?.length ?? 0).toBeGreaterThanOrEqual(2);
216
+ });
217
+ });
@@ -0,0 +1,181 @@
1
+ /**
2
+ * The tenant PreRunView hook checks the caller's ExtraFilter and builds
3
+ * `(<ExtraFilter>) AND (<tenant predicate>)`. On PostgreSQL the provider then rewrites the whole
4
+ * filter (identifier quoting, T-SQL date functions, boolean literals), and that rewrite can change
5
+ * the caller's part after the hook checked it. These tests run the real hook, the real PostgreSQL
6
+ * rewrite and the real view query builder, with only ExecuteSQL recorded, and pin that the tenant
7
+ * predicate stays a separate AND term whatever the rewrite does to the caller's term. The last
8
+ * block runs the same path with no hook and pins that the per-term rewrite keeps a SQL Server
9
+ * bracket name whole.
10
+ */
11
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
12
+ import {
13
+ ClearAllDataHooks,
14
+ Metadata,
15
+ RegisterDataHook,
16
+ UserInfo,
17
+ type EntityInfo,
18
+ type IMetadataProvider,
19
+ } from '@memberjunction/core';
20
+ import { PostgreSQLDataProvider } from '@memberjunction/postgresql-dataprovider';
21
+ import type { SQLParserDialect } from '@memberjunction/sql-dialect';
22
+ import { IsBalancedSQLFragment, SplitTopLevelAndTerms } from '@memberjunction/sql-parser';
23
+ import { CreateTenantPreRunViewHook } from '../multiTenancy/index.js';
24
+ import type { MultiTenancyConfig } from '../config.js';
25
+
26
+ const TENANT_TERM = `(("OrganizationID" = 't1'))`;
27
+
28
+ /** The real PostgreSQL provider over one entity fixture, with every executed statement recorded. */
29
+ class RecordingPostgreSQLProvider extends PostgreSQLDataProvider {
30
+ public readonly ExecutedSQL: string[] = [];
31
+
32
+ constructor(private readonly testEntities: EntityInfo[]) {
33
+ super();
34
+ }
35
+
36
+ public override get Entities(): EntityInfo[] {
37
+ return this.testEntities;
38
+ }
39
+
40
+ public override async ExecuteSQL<T>(sql: string): Promise<T[]> {
41
+ this.ExecutedSQL.push(sql);
42
+ return [];
43
+ }
44
+ }
45
+
46
+ /** A tenant-scoped entity with a boolean field, which the PostgreSQL rewrite coerces. */
47
+ function customersEntity(): EntityInfo {
48
+ const field = (name: string, tsType: string) => ({ Name: name, CodeName: name, TSType: tsType, IsVirtual: false });
49
+ const fields = [field('ID', 'string'), field('Name', 'string'), field('OrganizationID', 'string'), field('IsActive', 'boolean')];
50
+ return {
51
+ ID: 'ENT-CUSTOMERS',
52
+ Name: 'Customers',
53
+ SchemaName: 'app',
54
+ BaseView: 'vwCustomers',
55
+ Fields: fields,
56
+ PrimaryKeys: [fields[0]],
57
+ FirstPrimaryKey: fields[0],
58
+ FieldByName: (n: string) => fields.find((f) => f.Name.toLowerCase() === n.trim().toLowerCase()),
59
+ DatetimeFields: [],
60
+ ExternalDataSourceID: null,
61
+ UserViewMaxRows: 0,
62
+ EnableFieldLevelSecurity: false,
63
+ AuditViewRuns: false,
64
+ GetUserPermisions: () => ({ CanRead: true }),
65
+ GetEffectiveRowFilterWhereClause: () => '',
66
+ } as unknown as EntityInfo;
67
+ }
68
+
69
+ function tenantConfig(): MultiTenancyConfig {
70
+ return {
71
+ enabled: true,
72
+ contextSource: 'header',
73
+ tenantHeader: 'x-tenant-id',
74
+ scopingStrategy: 'allowlist',
75
+ scopedEntities: ['Customers'],
76
+ autoExcludeCoreEntities: true,
77
+ defaultTenantColumn: 'OrganizationID',
78
+ entityColumnMappings: {},
79
+ adminRoles: ['Admin'],
80
+ writeProtection: 'strict',
81
+ };
82
+ }
83
+
84
+ /** The WHERE clause of the one statement the provider ran. */
85
+ function whereOf(provider: RecordingPostgreSQLProvider): string {
86
+ expect(provider.ExecutedSQL).toHaveLength(1);
87
+ const sql = provider.ExecutedSQL[0];
88
+ return sql.substring(sql.indexOf(' WHERE ') + ' WHERE '.length);
89
+ }
90
+
91
+ /**
92
+ * Whether `term` is one of the top-level AND terms inside the one pair of parentheses around
93
+ * `where`. Only then does it narrow every row the query returns.
94
+ */
95
+ function isTopLevelAndTerm(where: string, term: string, dialect: SQLParserDialect): boolean {
96
+ const inner = where.slice(1, -1);
97
+ const wrapped = where.startsWith('(') && where.endsWith(')') && IsBalancedSQLFragment(inner, dialect).IsBalanced;
98
+ return wrapped && (SplitTopLevelAndTerms(inner, dialect) ?? []).some((t) => t.trim() === term);
99
+ }
100
+
101
+ describe('Multi-tenancy on PostgreSQL — the provider rewrite cannot reach the tenant predicate', () => {
102
+ let provider: RecordingPostgreSQLProvider;
103
+ let savedProvider: IMetadataProvider;
104
+ let user: UserInfo;
105
+
106
+ beforeEach(() => {
107
+ provider = new RecordingPostgreSQLProvider([customersEntity()]);
108
+ savedProvider = Metadata.Provider;
109
+ Metadata.Provider = provider;
110
+ ClearAllDataHooks();
111
+ RegisterDataHook('PreRunView', CreateTenantPreRunViewHook(tenantConfig()));
112
+ user = new UserInfo(provider, { ID: 'U1', Email: 'u1@example.com' });
113
+ user.TenantContext = { TenantID: 't1', Source: 'header' };
114
+ });
115
+
116
+ afterEach(() => {
117
+ ClearAllDataHooks();
118
+ Metadata.Provider = savedProvider;
119
+ });
120
+
121
+ it('keeps the tenant predicate a separate AND term when the rewrite changes the caller term', async () => {
122
+ // The rewrite turns the caller's quoted text into boolean literals and changes where its
123
+ // parentheses close. Each term is rewritten and wrapped on its own, so that change stays
124
+ // inside the caller's term.
125
+ const result = await provider.RunView({ EntityName: 'Customers', ExtraFilter: `"IsActive" = '1) AND ("IsActive" = 1'` }, user);
126
+
127
+ expect(result.Success).toBe(true);
128
+ expect(whereOf(provider)).toBe(`((("IsActive" = TRUE) AND ("IsActive" = TRUE)) AND ${TENANT_TERM})`);
129
+ });
130
+
131
+ it('keeps the tenant predicate its own AND term after a quote the boolean rewrite drops, followed by a top-level OR', async () => {
132
+ // The rewrite drops the quote, so text the hook read inside a string literal becomes code
133
+ // with a top-level OR. The tenant predicate must still narrow every row.
134
+ const result = await provider.RunView({ EntityName: 'Customers', ExtraFilter: `"IsActive" = '1) OR (1=1) OR ("IsActive" = 1'` }, user);
135
+
136
+ expect(result.Success).toBe(true);
137
+ const where = whereOf(provider);
138
+ expect(isTopLevelAndTerm(where, TENANT_TERM, provider.Dialect), `tenant predicate is not a top-level AND term of ${where}`).toBe(true);
139
+ expect(where).toBe(`((("IsActive" = TRUE) OR (1=1) OR ("IsActive" = TRUE)) AND ${TENANT_TERM})`);
140
+ });
141
+
142
+ it('refuses a caller term that the rewrite leaves unbalanced, and runs no SQL', async () => {
143
+ const result = await provider.RunView({ EntityName: 'Customers', ExtraFilter: `"IsActive" = '1) AND (1 = 1'` }, user);
144
+
145
+ expect(provider.ExecutedSQL).toEqual([]);
146
+ expect(result.Success).toBe(false);
147
+ expect(result.ErrorMessage).toMatch(/ExtraFilter/);
148
+ });
149
+
150
+ it('leaves an ordinary filter and the tenant predicate as written, each in its own term', async () => {
151
+ const result = await provider.RunView({ EntityName: 'Customers', ExtraFilter: `"IsActive" = 1 OR "Name" = 'a'` }, user);
152
+
153
+ expect(result.Success).toBe(true);
154
+ expect(whereOf(provider)).toBe(`((("IsActive" = TRUE OR "Name" = 'a')) AND ${TENANT_TERM})`);
155
+ });
156
+ });
157
+
158
+ describe('PostgreSQL ExtraFilter rewrite with no PreRunView hook', () => {
159
+ let provider: RecordingPostgreSQLProvider;
160
+ let savedProvider: IMetadataProvider;
161
+
162
+ beforeEach(() => {
163
+ provider = new RecordingPostgreSQLProvider([customersEntity()]);
164
+ savedProvider = Metadata.Provider;
165
+ Metadata.Provider = provider;
166
+ ClearAllDataHooks();
167
+ });
168
+
169
+ afterEach(() => {
170
+ Metadata.Provider = savedProvider;
171
+ });
172
+
173
+ it('keeps a SQL Server bracket name that contains AND whole when it rewrites each AND term', async () => {
174
+ const user = new UserInfo(provider, { ID: 'U1', Email: 'u1@example.com' });
175
+
176
+ const result = await provider.RunView({ EntityName: 'Customers', ExtraFilter: '[Profit and Loss] > 0 AND [IsActive] = 1' }, user);
177
+
178
+ expect(result.Success).toBe(true);
179
+ expect(whereOf(provider)).toBe('(("Profit and Loss" > 0) AND ("IsActive" = TRUE))');
180
+ });
181
+ });
@@ -518,6 +518,57 @@ describe('Multi-Tenancy Security Edge Cases', () => {
518
518
  });
519
519
  });
520
520
 
521
+ // ─── Client ExtraFilter containment ───────────────────────────────────
522
+
523
+ describe('client ExtraFilter must stay inside its parentheses', () => {
524
+ // The hook builds `(<ExtraFilter>) AND (<tenant>)`. A filter that closes a parenthesis it did
525
+ // not open turns that into `(1=1) OR (1=1) AND (<tenant>)`: AND binds tighter than OR, so the
526
+ // tenant predicate would narrow only one side of an OR the caller controls.
527
+ const escapes = [
528
+ '1=1) OR (1=1',
529
+ "Name = 'a') OR (Name = 'b'",
530
+ '1=1)) OR ((1=1',
531
+ ];
532
+
533
+ it.each(escapes)('refuses the ExtraFilter %j instead of ANDing the tenant predicate onto it', (extraFilter) => {
534
+ const hook = CreateTenantPreRunViewHook(makeConfig());
535
+ const params = { EntityName: 'Customers', ExtraFilter: extraFilter } as RunViewParams;
536
+
537
+ expect(() => hook(params, makeUser('tenant-abc'))).toThrow(/parenthesis/);
538
+ });
539
+
540
+ it('refuses an ExtraFilter that ends inside an unterminated string literal', () => {
541
+ const hook = CreateTenantPreRunViewHook(makeConfig());
542
+ const params = { EntityName: 'Customers', ExtraFilter: "Name = 'a" } as RunViewParams;
543
+
544
+ expect(() => hook(params, makeUser('tenant-abc'))).toThrow(/unterminated/);
545
+ });
546
+
547
+ it('refuses an ExtraFilter that contains a comment', () => {
548
+ const hook = CreateTenantPreRunViewHook(makeConfig());
549
+ const params = { EntityName: 'Customers', ExtraFilter: "Name = 'a' -- note\n" } as RunViewParams;
550
+
551
+ expect(() => hook(params, makeUser('tenant-abc'))).toThrow(/comment/);
552
+ });
553
+
554
+ it('applies the PostgreSQL rules too when the provider dialect is unknown', () => {
555
+ // The mocked Metadata.Provider here is not a database provider, so the hook reads the filter
556
+ // with every supported dialect.
557
+ const hook = CreateTenantPreRunViewHook(makeConfig());
558
+ const params = { EntityName: 'Customers', ExtraFilter: 'Name = $$x$$' } as RunViewParams;
559
+
560
+ expect(() => hook(params, makeUser('tenant-abc'))).toThrow(/dollar/);
561
+ });
562
+
563
+ it('keeps a balanced ExtraFilter and the tenant predicate as separate parenthesized terms', () => {
564
+ const hook = CreateTenantPreRunViewHook(makeConfig());
565
+ const params = { EntityName: 'Customers', ExtraFilter: "Name = 'a) OR (b' OR Name = 'c'" } as RunViewParams;
566
+
567
+ const result = hook(params, makeUser('tenant-abc')) as RunViewParams;
568
+ expect(result.ExtraFilter).toBe("(Name = 'a) OR (b' OR Name = 'c') AND ([OrganizationID] = 'tenant-abc')");
569
+ });
570
+ });
571
+
521
572
  // ─── PreSave security edge cases ──────────────────────────────────────
522
573
 
523
574
  describe('PreSave security', () => {
@@ -87,7 +87,7 @@ describe('Multi-Tenancy Hooks', () => {
87
87
 
88
88
  const result = hook(params, user);
89
89
  expect((result as RunViewParams).ExtraFilter).toBe(
90
- "(Status = 'Active') AND [OrganizationID] = 'tenant-abc'"
90
+ "(Status = 'Active') AND ([OrganizationID] = 'tenant-abc')"
91
91
  );
92
92
  });
93
93
 
@@ -0,0 +1,39 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import type { IMetadataProvider, UserInfo } from '@memberjunction/core';
3
+ import {
4
+ LoadOwnedRealtimeSession, RealtimeSessionNotFoundError, RealtimeSessionOwnershipError,
5
+ } from '../realtimeSessions/ownedRealtimeSession.js';
6
+
7
+ const SESSION = '11111111-1111-4111-8111-111111111111';
8
+ const OWNER = { ID: 'AAAAAAAA-0000-4000-8000-000000000001' } as unknown as UserInfo;
9
+ const STRANGER = { ID: 'BBBBBBBB-0000-4000-8000-000000000002' } as unknown as UserInfo;
10
+
11
+ function providerWith(row: { UserID: string } | null): IMetadataProvider {
12
+ return {
13
+ GetEntityObject: vi.fn(async () => ({
14
+ UserID: row?.UserID ?? '',
15
+ Load: vi.fn(async () => row !== null),
16
+ })),
17
+ } as unknown as IMetadataProvider;
18
+ }
19
+
20
+ describe('LoadOwnedRealtimeSession', () => {
21
+ it('returns the session when the caller owns it (UUIDs compared case-insensitively)', async () => {
22
+ const session = await LoadOwnedRealtimeSession(SESSION, OWNER, providerWith({ UserID: OWNER.ID.toLowerCase() }));
23
+ expect(session.UserID).toBe(OWNER.ID.toLowerCase());
24
+ });
25
+
26
+ it('throws RealtimeSessionNotFoundError when the row does not load', async () => {
27
+ await expect(LoadOwnedRealtimeSession(SESSION, OWNER, providerWith(null)))
28
+ .rejects.toBeInstanceOf(RealtimeSessionNotFoundError);
29
+ await expect(LoadOwnedRealtimeSession(SESSION, OWNER, providerWith(null)))
30
+ .rejects.toThrow(`Realtime session ${SESSION} not found`);
31
+ });
32
+
33
+ it('throws RealtimeSessionOwnershipError for another user', async () => {
34
+ await expect(LoadOwnedRealtimeSession(SESSION, STRANGER, providerWith({ UserID: OWNER.ID })))
35
+ .rejects.toBeInstanceOf(RealtimeSessionOwnershipError);
36
+ await expect(LoadOwnedRealtimeSession(SESSION, STRANGER, providerWith({ UserID: OWNER.ID })))
37
+ .rejects.toThrow('Not authorized: you do not own this realtime session');
38
+ });
39
+ });
@@ -53,4 +53,11 @@ describe('statusUpdatesFilter (B49 session-hijack gate)', () => {
53
53
  it('matches identity case-insensitively (SQL Server upper vs PostgreSQL lower UUIDs)', () => {
54
54
  expect(StatusUpdatesFilter({ payload: push({ ownerUserId: OWNER.toUpperCase() }), args: { sessionId: SESSION }, context: ctxFor(OWNER.toLowerCase()) })).toBe(true);
55
55
  });
56
+
57
+ it('delivers server-originated wildcard sessionId ("*") pushes ONLY when ownerUserId matches the connection user', () => {
58
+ expect(StatusUpdatesFilter({ payload: push({ sessionId: '*', ownerUserId: OWNER }), args: { sessionId: SESSION }, context: ctxFor(OWNER) })).toBe(true);
59
+ expect(StatusUpdatesFilter({ payload: push({ sessionId: '*', ownerUserId: OWNER }), args: { sessionId: SESSION }, context: ctxFor(ATTACKER) })).toBe(false);
60
+ expect(StatusUpdatesFilter({ payload: push({ sessionId: '*', ownerUserId: '' }), args: { sessionId: SESSION }, context: ctxFor(OWNER) })).toBe(false);
61
+ });
56
62
  });
63
+