@memberjunction/server 5.51.2 → 5.51.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (211) hide show
  1. package/README.md +34 -1
  2. package/dist/agentSessions/SessionManager.d.ts +5 -2
  3. package/dist/agentSessions/SessionManager.d.ts.map +1 -1
  4. package/dist/agentSessions/SessionManager.js +17 -8
  5. package/dist/agentSessions/SessionManager.js.map +1 -1
  6. package/dist/agentSessions/SessionRunIDVerifier.d.ts +63 -0
  7. package/dist/agentSessions/SessionRunIDVerifier.d.ts.map +1 -0
  8. package/dist/agentSessions/SessionRunIDVerifier.js +136 -0
  9. package/dist/agentSessions/SessionRunIDVerifier.js.map +1 -0
  10. package/dist/agentSessions/index.d.ts +1 -0
  11. package/dist/agentSessions/index.d.ts.map +1 -1
  12. package/dist/agentSessions/index.js +1 -0
  13. package/dist/agentSessions/index.js.map +1 -1
  14. package/dist/auth/actionAuthorization.d.ts +40 -0
  15. package/dist/auth/actionAuthorization.d.ts.map +1 -0
  16. package/dist/auth/actionAuthorization.js +94 -0
  17. package/dist/auth/actionAuthorization.js.map +1 -0
  18. package/dist/auth/index.d.ts +1 -0
  19. package/dist/auth/index.d.ts.map +1 -1
  20. package/dist/auth/index.js +1 -0
  21. package/dist/auth/index.js.map +1 -1
  22. package/dist/auth/scopeLimitedPrincipal.d.ts +39 -0
  23. package/dist/auth/scopeLimitedPrincipal.d.ts.map +1 -0
  24. package/dist/auth/scopeLimitedPrincipal.js +49 -0
  25. package/dist/auth/scopeLimitedPrincipal.js.map +1 -0
  26. package/dist/config.d.ts +53 -0
  27. package/dist/config.d.ts.map +1 -1
  28. package/dist/config.js +34 -1
  29. package/dist/config.js.map +1 -1
  30. package/dist/context.d.ts +6 -0
  31. package/dist/context.d.ts.map +1 -1
  32. package/dist/context.js +24 -34
  33. package/dist/context.js.map +1 -1
  34. package/dist/generated/generated.d.ts.map +1 -1
  35. package/dist/generated/generated.js +3262 -3273
  36. package/dist/generated/generated.js.map +1 -1
  37. package/dist/generic/CacheInvalidationResolver.d.ts +124 -4
  38. package/dist/generic/CacheInvalidationResolver.d.ts.map +1 -1
  39. package/dist/generic/CacheInvalidationResolver.js +170 -15
  40. package/dist/generic/CacheInvalidationResolver.js.map +1 -1
  41. package/dist/generic/FireAndForgetHeartbeat.d.ts +5 -0
  42. package/dist/generic/FireAndForgetHeartbeat.d.ts.map +1 -1
  43. package/dist/generic/FireAndForgetHeartbeat.js +5 -4
  44. package/dist/generic/FireAndForgetHeartbeat.js.map +1 -1
  45. package/dist/generic/PushStatusResolver.d.ts +58 -0
  46. package/dist/generic/PushStatusResolver.d.ts.map +1 -1
  47. package/dist/generic/PushStatusResolver.js +42 -3
  48. package/dist/generic/PushStatusResolver.js.map +1 -1
  49. package/dist/generic/ResolverBase.d.ts +148 -3
  50. package/dist/generic/ResolverBase.d.ts.map +1 -1
  51. package/dist/generic/ResolverBase.js +287 -30
  52. package/dist/generic/ResolverBase.js.map +1 -1
  53. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  54. package/dist/generic/RunViewResolver.js +12 -0
  55. package/dist/generic/RunViewResolver.js.map +1 -1
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +58 -50
  58. package/dist/index.js.map +1 -1
  59. package/dist/multiTenancy/index.d.ts.map +1 -1
  60. package/dist/multiTenancy/index.js +22 -2
  61. package/dist/multiTenancy/index.js.map +1 -1
  62. package/dist/postgresqlCompat.d.ts +8 -0
  63. package/dist/postgresqlCompat.d.ts.map +1 -0
  64. package/dist/postgresqlCompat.js +19 -0
  65. package/dist/postgresqlCompat.js.map +1 -0
  66. package/dist/postgresqlPoolSettings.d.ts +108 -0
  67. package/dist/postgresqlPoolSettings.d.ts.map +1 -0
  68. package/dist/postgresqlPoolSettings.js +134 -0
  69. package/dist/postgresqlPoolSettings.js.map +1 -0
  70. package/dist/realtimeWidget/widgetGuestElevation.d.ts +2 -2
  71. package/dist/realtimeWidget/widgetGuestElevation.js +2 -2
  72. package/dist/resolvers/ActionResolver.d.ts +31 -5
  73. package/dist/resolvers/ActionResolver.d.ts.map +1 -1
  74. package/dist/resolvers/ActionResolver.js +75 -14
  75. package/dist/resolvers/ActionResolver.js.map +1 -1
  76. package/dist/resolvers/AdhocQueryResolver.d.ts +39 -17
  77. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  78. package/dist/resolvers/AdhocQueryResolver.js +97 -157
  79. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  80. package/dist/resolvers/DatasetResolver.d.ts +24 -3
  81. package/dist/resolvers/DatasetResolver.d.ts.map +1 -1
  82. package/dist/resolvers/DatasetResolver.js +57 -8
  83. package/dist/resolvers/DatasetResolver.js.map +1 -1
  84. package/dist/resolvers/EntityRecordNameResolver.d.ts +14 -4
  85. package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
  86. package/dist/resolvers/EntityRecordNameResolver.js +18 -6
  87. package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
  88. package/dist/resolvers/GetDataContextDataResolver.d.ts +33 -7
  89. package/dist/resolvers/GetDataContextDataResolver.d.ts.map +1 -1
  90. package/dist/resolvers/GetDataContextDataResolver.js +117 -38
  91. package/dist/resolvers/GetDataContextDataResolver.js.map +1 -1
  92. package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
  93. package/dist/resolvers/GetDataResolver.js +13 -1
  94. package/dist/resolvers/GetDataResolver.js.map +1 -1
  95. package/dist/resolvers/MCPResolver.d.ts.map +1 -1
  96. package/dist/resolvers/MCPResolver.js +13 -16
  97. package/dist/resolvers/MCPResolver.js.map +1 -1
  98. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +26 -14
  99. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  100. package/dist/resolvers/RealtimeClientSessionResolver.js +63 -46
  101. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  102. package/dist/resolvers/RemoteBrowserActionResolver.d.ts +2 -0
  103. package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
  104. package/dist/resolvers/RemoteBrowserActionResolver.js +24 -27
  105. package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
  106. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  107. package/dist/resolvers/RunAIAgentResolver.js +30 -40
  108. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  109. package/dist/resolvers/RunTemplateResolver.d.ts.map +1 -1
  110. package/dist/resolvers/RunTemplateResolver.js +12 -11
  111. package/dist/resolvers/RunTemplateResolver.js.map +1 -1
  112. package/dist/resolvers/RunTestResolver.d.ts.map +1 -1
  113. package/dist/resolvers/RunTestResolver.js +90 -112
  114. package/dist/resolvers/RunTestResolver.js.map +1 -1
  115. package/dist/resolvers/SyncRolesUsersResolver.d.ts.map +1 -1
  116. package/dist/resolvers/SyncRolesUsersResolver.js +11 -2
  117. package/dist/resolvers/SyncRolesUsersResolver.js.map +1 -1
  118. package/dist/resolvers/VersionHistoryResolver.d.ts.map +1 -1
  119. package/dist/resolvers/VersionHistoryResolver.js +8 -12
  120. package/dist/resolvers/VersionHistoryResolver.js.map +1 -1
  121. package/dist/resolvers/adhocTimeout.d.ts +7 -0
  122. package/dist/resolvers/adhocTimeout.d.ts.map +1 -0
  123. package/dist/resolvers/adhocTimeout.js +12 -0
  124. package/dist/resolvers/adhocTimeout.js.map +1 -0
  125. package/dist/rest/ViewOperationsHandler.d.ts +3 -1
  126. package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
  127. package/dist/rest/ViewOperationsHandler.js +29 -17
  128. package/dist/rest/ViewOperationsHandler.js.map +1 -1
  129. package/dist/services/TaskOrchestrator.d.ts.map +1 -1
  130. package/dist/services/TaskOrchestrator.js +17 -15
  131. package/dist/services/TaskOrchestrator.js.map +1 -1
  132. package/package.json +90 -89
  133. package/src/__tests__/ActionResolver.authorization.test.ts +353 -0
  134. package/src/__tests__/AdhocQueryResolver.bugs.test.ts +14 -35
  135. package/src/__tests__/AdhocQueryResolver.pagination.test.ts +6 -2
  136. package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +84 -0
  137. package/src/__tests__/AdhocQueryResolver.sqlAuthorizer.test.ts +104 -0
  138. package/src/__tests__/DatasetResolver.itemFilters.test.ts +201 -0
  139. package/src/__tests__/EntityRecordNameResolver.rowSecurity.test.ts +152 -0
  140. package/src/__tests__/GetDataContextDataResolver.sqlItems.test.ts +416 -0
  141. package/src/__tests__/ListOperationsResolver.adhocScreen.test.ts +136 -0
  142. package/src/__tests__/RealtimeClientSessionResolver.configTamper.test.ts +318 -0
  143. package/src/__tests__/RealtimeClientSessionResolver.test.ts +66 -27
  144. package/src/__tests__/RemoteBrowserActionResolver.goalRunLink.test.ts +134 -0
  145. package/src/__tests__/ResolverBase.adhocReadScope.test.ts +221 -0
  146. package/src/__tests__/ResolverBase.aggregateScreen.test.ts +173 -0
  147. package/src/__tests__/ResolverBase.balancedClause.test.ts +155 -0
  148. package/src/__tests__/ResolverBase.clauseUnscopedRead.test.ts +116 -0
  149. package/src/__tests__/ResolverBase.filterEscaping.test.ts +351 -0
  150. package/src/__tests__/RunTemplateResolver.test.ts +132 -0
  151. package/src/__tests__/RunViewResolver.cacheCheckScreen.test.ts +164 -0
  152. package/src/__tests__/SessionManager.test.ts +88 -13
  153. package/src/__tests__/SessionRunIDVerifier.test.ts +255 -0
  154. package/src/__tests__/UpdateMJDataContextItem.oldValues.test.ts +220 -0
  155. package/src/__tests__/ViewOperationsHandler.clauseScreen.test.ts +155 -0
  156. package/src/__tests__/actionAuthorization.test.ts +130 -0
  157. package/src/__tests__/adhocTimeoutClamp.test.ts +26 -0
  158. package/src/__tests__/cacheInvalidationRecordData.test.ts +241 -0
  159. package/src/__tests__/multiTenancy.postgresRewrite.test.ts +180 -0
  160. package/src/__tests__/multiTenancy.security.test.ts +51 -0
  161. package/src/__tests__/multiTenancy.test.ts +1 -1
  162. package/src/__tests__/newUserRolesDefault.test.ts +81 -0
  163. package/src/__tests__/postgresqlPoolSettings.test.ts +131 -0
  164. package/src/__tests__/pushStatusResolver.filter.test.ts +56 -0
  165. package/src/__tests__/resolverBase.refusalMessage.test.ts +88 -0
  166. package/src/__tests__/resolverBase.rls.test.ts +2 -0
  167. package/src/__tests__/translateBracketsToPG.test.ts +20 -0
  168. package/src/agentSessions/SessionManager.ts +24 -25
  169. package/src/agentSessions/SessionRunIDVerifier.ts +179 -0
  170. package/src/agentSessions/index.ts +1 -0
  171. package/src/auth/actionAuthorization.ts +119 -0
  172. package/src/auth/index.ts +1 -0
  173. package/src/auth/scopeLimitedPrincipal.ts +50 -0
  174. package/src/config.ts +34 -1
  175. package/src/context.ts +25 -34
  176. package/src/generated/generated.ts +3262 -3273
  177. package/src/generic/CacheInvalidationResolver.ts +206 -15
  178. package/src/generic/FireAndForgetHeartbeat.ts +10 -4
  179. package/src/generic/PushStatusResolver.ts +79 -4
  180. package/src/generic/ResolverBase.ts +339 -31
  181. package/src/generic/RunViewResolver.ts +17 -0
  182. package/src/index.ts +65 -52
  183. package/src/multiTenancy/index.ts +24 -2
  184. package/src/postgresqlCompat.ts +21 -0
  185. package/src/postgresqlPoolSettings.ts +213 -0
  186. package/src/realtimeWidget/widgetGuestElevation.ts +2 -2
  187. package/src/resolvers/ActionResolver.ts +85 -22
  188. package/src/resolvers/AdhocQueryResolver.ts +110 -177
  189. package/src/resolvers/DatasetResolver.ts +70 -10
  190. package/src/resolvers/EntityRecordNameResolver.ts +24 -7
  191. package/src/resolvers/GetDataContextDataResolver.ts +139 -41
  192. package/src/resolvers/GetDataResolver.ts +14 -1
  193. package/src/resolvers/MCPResolver.ts +13 -15
  194. package/src/resolvers/RealtimeClientSessionResolver.ts +82 -50
  195. package/src/resolvers/RemoteBrowserActionResolver.ts +30 -26
  196. package/src/resolvers/RunAIAgentResolver.ts +30 -40
  197. package/src/resolvers/RunTemplateResolver.ts +13 -12
  198. package/src/resolvers/RunTestResolver.ts +90 -112
  199. package/src/resolvers/SyncRolesUsersResolver.ts +11 -2
  200. package/src/resolvers/VersionHistoryResolver.ts +10 -13
  201. package/src/resolvers/__tests__/GetDataResolver.validation.test.ts +95 -0
  202. package/src/resolvers/__tests__/ReportResolver.test.ts +2 -0
  203. package/src/resolvers/adhocTimeout.ts +12 -0
  204. package/src/rest/ViewOperationsHandler.ts +37 -18
  205. package/src/services/TaskOrchestrator.ts +17 -17
  206. package/dist/resolvers/adhoc-query-helpers.d.ts +0 -27
  207. package/dist/resolvers/adhoc-query-helpers.d.ts.map +0 -1
  208. package/dist/resolvers/adhoc-query-helpers.js +0 -42
  209. package/dist/resolvers/adhoc-query-helpers.js.map +0 -1
  210. package/src/__tests__/adhoc-query-helpers.test.ts +0 -50
  211. package/src/resolvers/adhoc-query-helpers.ts +0 -50
@@ -0,0 +1,132 @@
1
+ // type-graphql decorators call `Reflect.getMetadata`, which only exists once this polyfill is
2
+ // loaded. MUST precede any import that pulls in the resolver file.
3
+ import 'reflect-metadata';
4
+
5
+ /**
6
+ * `RunTemplate` renders a stored template on demand. Its API-key scope check is a no-op for JWT,
7
+ * magic-link and widget sessions, so the resolver applies its own rule. These tests pin that it
8
+ * refuses scope-limited sessions and callers who cannot read templates before it loads anything.
9
+ */
10
+ import { describe, it, expect, vi } from 'vitest';
11
+
12
+ // As in RunDecisionResolver.test.ts: vitest's esbuild transform does not apply
13
+ // `emitDecoratorMetadata`, so the decorators are no-ops and the resolver runs as a plain class.
14
+ vi.mock('type-graphql', () => {
15
+ const noopDecorator = () => () => undefined;
16
+ return {
17
+ Resolver: noopDecorator, Mutation: noopDecorator, Query: noopDecorator, Subscription: noopDecorator,
18
+ ObjectType: noopDecorator, InputType: noopDecorator, Field: noopDecorator, Arg: noopDecorator,
19
+ Args: noopDecorator, Ctx: noopDecorator, PubSub: noopDecorator, Root: noopDecorator,
20
+ Directive: noopDecorator, Authorized: noopDecorator, UseMiddleware: noopDecorator,
21
+ createUnionType: () => class {},
22
+ AuthorizationError: class AuthorizationError extends Error {
23
+ constructor(message?: string) {
24
+ super(message);
25
+ this.name = 'AuthorizationError';
26
+ }
27
+ },
28
+ Float: class {}, Int: class {}, ID: class {},
29
+ };
30
+ });
31
+
32
+ import { EntityInfo, UserInfo, UserRoleInfo } from '@memberjunction/core';
33
+ import { RunTemplateResolver } from '../resolvers/RunTemplateResolver.js';
34
+ import type { AppContext, UserPayload } from '../types.js';
35
+
36
+ const UI_ROLE_ID = '0b9b7b55-0000-4000-8000-0000000000a1';
37
+ const TEMPLATE_ID = '0b9b7b55-0000-4000-8000-0000000000c1';
38
+
39
+ /** An entity whose read permission is granted to the given roles. */
40
+ function entityReadableBy(name: string, roleIds: string[]): EntityInfo {
41
+ const entityId = `entity-${name}`;
42
+ return new EntityInfo({
43
+ ID: entityId, Name: name, SchemaName: '__mj', BaseTable: name, BaseView: `vw${name}`, IncludeInAPI: true,
44
+ EntityPermissions: roleIds.map((roleId, index) => ({
45
+ ID: `${entityId}-permission-${index}`, EntityID: entityId, RoleID: roleId,
46
+ CanCreate: false, CanRead: true, CanUpdate: false, CanDelete: false, Type: 'Allow',
47
+ })),
48
+ });
49
+ }
50
+
51
+ /**
52
+ * Metadata plus the one data call the resolver makes first. The data call fails on purpose, so a
53
+ * test can tell whether the resolver got past authorization without needing a database.
54
+ */
55
+ class FakeProvider {
56
+ public readonly EntityObjectRequests: string[] = [];
57
+
58
+ constructor(private readonly entities: EntityInfo[]) {}
59
+
60
+ public EntityByName(name: string): EntityInfo | undefined {
61
+ return this.entities.find((e) => e.Name.trim().toLowerCase() === name.trim().toLowerCase());
62
+ }
63
+
64
+ public async GetEntityObject(entityName: string): Promise<never> {
65
+ this.EntityObjectRequests.push(entityName);
66
+ throw new Error(`reached data access for ${entityName}`);
67
+ }
68
+ }
69
+
70
+ function makeUser(roleIds: string[]): UserInfo {
71
+ const userId = '0b9b7b55-0000-4000-8000-0000000000b1';
72
+ return new UserInfo(null, {
73
+ ID: userId, Name: 'Template Runner', Email: 'runner@example.com',
74
+ UserRoles: roleIds.map((roleId) => new UserRoleInfo({ UserID: userId, RoleID: roleId })),
75
+ });
76
+ }
77
+
78
+ const readableEverywhere = () => [
79
+ entityReadableBy('MJ: Templates', [UI_ROLE_ID]),
80
+ entityReadableBy('MJ: Template Contents', [UI_ROLE_ID]),
81
+ ];
82
+
83
+ /** A JWT-style session (no API key), so the API-key scope check is a no-op. */
84
+ function contextFor(user: UserInfo, provider: FakeProvider): AppContext {
85
+ const userPayload: UserPayload = { email: user.Email, userRecord: user, sessionId: 'session-1' };
86
+ return { userPayload, providers: [{ type: 'Read-Write', provider }] } as unknown as AppContext;
87
+ }
88
+
89
+ const runTemplate = (user: UserInfo, provider: FakeProvider) =>
90
+ new RunTemplateResolver().RunTemplate(TEMPLATE_ID, contextFor(user, provider));
91
+
92
+ describe('RunTemplate authorization', () => {
93
+ it('refuses an anonymous magic-link guest before loading the template', async () => {
94
+ const provider = new FakeProvider(readableEverywhere());
95
+ const guest = makeUser([UI_ROLE_ID]);
96
+ guest.IsMagicLinkAnonymous = true;
97
+
98
+ const result = await runTemplate(guest, provider);
99
+
100
+ expect(result).toMatchObject({ success: false, error: 'Running templates is not permitted for scope-limited sessions' });
101
+ expect(provider.EntityObjectRequests).toEqual([]);
102
+ });
103
+
104
+ it('refuses a resource-scoped magic-link session before loading the template', async () => {
105
+ const provider = new FakeProvider(readableEverywhere());
106
+ const scoped = makeUser([UI_ROLE_ID]);
107
+ scoped.MagicLinkScope = { ResourceID: '0b9b7b55-0000-4000-8000-0000000000d1', ResourceType: 'Dashboards' };
108
+
109
+ const result = await runTemplate(scoped, provider);
110
+
111
+ expect(result).toMatchObject({ success: false, error: 'Running templates is not permitted for scope-limited sessions' });
112
+ expect(provider.EntityObjectRequests).toEqual([]);
113
+ });
114
+
115
+ it('refuses a user without read permission on MJ: Templates before loading the template', async () => {
116
+ const provider = new FakeProvider(readableEverywhere());
117
+
118
+ const result = await runTemplate(makeUser([]), provider);
119
+
120
+ expect(result).toMatchObject({ success: false, error: 'You do not have permission to read MJ: Templates' });
121
+ expect(provider.EntityObjectRequests).toEqual([]);
122
+ });
123
+
124
+ it('lets a user who can read templates and their content through to the template load', async () => {
125
+ const provider = new FakeProvider(readableEverywhere());
126
+
127
+ const result = await runTemplate(makeUser([UI_ROLE_ID]), provider);
128
+
129
+ expect(result).toMatchObject({ success: false, error: 'reached data access for MJ: Templates' });
130
+ expect(provider.EntityObjectRequests).toEqual(['MJ: Templates']);
131
+ });
132
+ });
@@ -0,0 +1,164 @@
1
+ // ResolverBase pulls in type-graphql decorators, which need the Reflect.metadata polyfill first.
2
+ import 'reflect-metadata';
3
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
4
+
5
+ /**
6
+ * `RunViewsWithCacheCheck` is the transport `RunViews` switches to when any view in the batch is
7
+ * `CacheLocal`, so it carries the same client-supplied clauses as `RunViews`. These tests pin that
8
+ * it applies the same GraphQL-boundary protections before the provider is called: the base-view
9
+ * AST screen on ExtraFilter / OrderBy / OverrideExcludeFilter, and the API-key scope check.
10
+ *
11
+ * Every payload here is harmless (stacked `SELECT`s); the point is only whether it reaches the
12
+ * provider. The provider's own keyword denylist accepts the bracket-identifier payload, because a
13
+ * quote inside `[a']` hides the rest of the text from its literal stripper.
14
+ */
15
+
16
+ const { authorize } = vi.hoisted(() => ({ authorize: vi.fn() }));
17
+
18
+ vi.mock('@memberjunction/api-keys', async (importOriginal) => {
19
+ const actual = await importOriginal<typeof import('@memberjunction/api-keys')>();
20
+ return { ...actual, GetAPIKeyEngine: () => ({ Authorize: authorize }) };
21
+ });
22
+
23
+ import type { DatabaseProviderBase, EntityInfo, RunViewWithCacheCheckParams, UserInfo } from '@memberjunction/core';
24
+ import { UserCache } from '@memberjunction/sqlserver-dataprovider';
25
+ import { RunDynamicViewInput, RunViewResolver, RunViewWithCacheCheckInput } from '../generic/RunViewResolver.js';
26
+ import type { AppContext, UserPayload } from '../types.js';
27
+
28
+ /** A stacked statement hidden from the keyword denylist by a quote inside a bracket identifier. */
29
+ const BRACKET_STACKED =
30
+ "1 = (SELECT 1 AS [a'])) ; SELECT 1 AS [x] ; SELECT * FROM __mj.vwUserViews WHERE (1 = (SELECT 1 AS [b'])";
31
+
32
+ const USER = { ID: 'user-1', Email: 'reader@example.com', Name: 'Reader' } as UserInfo;
33
+
34
+ function entity(name: string, schema: string, baseView: string): EntityInfo {
35
+ return {
36
+ Name: name,
37
+ SchemaName: schema,
38
+ BaseView: baseView,
39
+ PrimaryKeys: [{ Name: 'ID' }],
40
+ GetUserPermisions: () => ({ CanRead: true }),
41
+ GetUserRowLevelSecurityWhereClause: () => '',
42
+ } as unknown as EntityInfo;
43
+ }
44
+
45
+ /** Provider double: the metadata the screen reads, and a cache-check method that records calls. */
46
+ function fakeProvider() {
47
+ const calls: RunViewWithCacheCheckParams[][] = [];
48
+ const entities = [
49
+ entity('MJ: User Views', '__mj', 'vwUserViews'),
50
+ entity('MJ: Users', '__mj', 'vwUsers'),
51
+ ];
52
+ const provider = {
53
+ Entities: entities,
54
+ EntityByName: (name: string) => entities.find((e) => e.Name === name),
55
+ RunViewsWithCacheCheck: async (params: RunViewWithCacheCheckParams[]) => {
56
+ calls.push(params);
57
+ return { success: true, results: [] };
58
+ },
59
+ };
60
+ return { provider: provider as unknown as DatabaseProviderBase, calls };
61
+ }
62
+
63
+ function context(provider: DatabaseProviderBase, payload: Partial<UserPayload> = {}): AppContext {
64
+ const userPayload = { email: USER.Email, userRecord: USER, ...payload } as UserPayload;
65
+ return { providers: [{ type: 'Read-Only', provider }], userPayload } as unknown as AppContext;
66
+ }
67
+
68
+ function cacheCheckInput(params: Partial<RunDynamicViewInput>): RunViewWithCacheCheckInput[] {
69
+ const dynamic = Object.assign(new RunDynamicViewInput(), { EntityName: 'MJ: User Views', ...params });
70
+ return [Object.assign(new RunViewWithCacheCheckInput(), { params: dynamic })];
71
+ }
72
+
73
+ describe('RunViewsWithCacheCheck — GraphQL-boundary clause screen', () => {
74
+ it('refuses a stacked statement hidden behind a bracket identifier, before the provider runs', async () => {
75
+ const { provider, calls } = fakeProvider();
76
+
77
+ const result = await new RunViewResolver().RunViewsWithCacheCheck(
78
+ cacheCheckInput({ ExtraFilter: BRACKET_STACKED }),
79
+ context(provider),
80
+ );
81
+
82
+ expect(calls).toHaveLength(0);
83
+ expect(result.success).toBe(false);
84
+ expect(result.errorMessage).toMatch(/Invalid ExtraFilter: multiple statements/);
85
+ });
86
+
87
+ it('refuses a subquery against a base table', async () => {
88
+ const { provider, calls } = fakeProvider();
89
+
90
+ const result = await new RunViewResolver().RunViewsWithCacheCheck(
91
+ cacheCheckInput({ ExtraFilter: `EXISTS (SELECT 1 FROM __mj.[User] WHERE [Type] = 'Owner')` }),
92
+ context(provider),
93
+ );
94
+
95
+ expect(calls).toHaveLength(0);
96
+ expect(result.success).toBe(false);
97
+ expect(result.errorMessage).toMatch(/entity base view/);
98
+ });
99
+
100
+ it('screens OrderBy and OverrideExcludeFilter too', async () => {
101
+ for (const params of [
102
+ { OrderBy: "[a'] ; SELECT 1 AS [x] ; SELECT 1 AS [b']" },
103
+ { OverrideExcludeFilter: BRACKET_STACKED },
104
+ ]) {
105
+ const { provider, calls } = fakeProvider();
106
+
107
+ const result = await new RunViewResolver().RunViewsWithCacheCheck(cacheCheckInput(params), context(provider));
108
+
109
+ expect(calls).toHaveLength(0);
110
+ expect(result.success).toBe(false);
111
+ }
112
+ });
113
+
114
+ it('passes a filter that only reads entity base views through to the provider', async () => {
115
+ const { provider, calls } = fakeProvider();
116
+ const filter = `UserID IN (SELECT ID FROM [__mj].[vwUsers] WHERE Email = 'o''brien@example.com')`;
117
+
118
+ const result = await new RunViewResolver().RunViewsWithCacheCheck(
119
+ cacheCheckInput({ ExtraFilter: filter, OrderBy: '[Name] DESC' }),
120
+ context(provider),
121
+ );
122
+
123
+ expect(result.success).toBe(true);
124
+ expect(calls).toHaveLength(1);
125
+ expect(calls[0][0].params.ExtraFilter).toBe(filter);
126
+ });
127
+ });
128
+
129
+ describe('RunViewsWithCacheCheck — API-key scope', () => {
130
+ beforeEach(() => {
131
+ authorize.mockReset();
132
+ vi.spyOn(UserCache.Instance, 'GetSystemUser').mockReturnValue({ ID: 'system', Email: 'system@example.com' } as UserInfo);
133
+ });
134
+
135
+ it('refuses an API key without the batch view scope, before the provider runs', async () => {
136
+ authorize.mockResolvedValue({ Allowed: false, Reason: 'scope not granted' });
137
+ const { provider, calls } = fakeProvider();
138
+
139
+ const result = await new RunViewResolver().RunViewsWithCacheCheck(
140
+ cacheCheckInput({ ExtraFilter: `Name = 'x'` }),
141
+ context(provider, { apiKeyHash: 'hash-of-a-narrow-key' }),
142
+ );
143
+
144
+ expect(calls).toHaveLength(0);
145
+ expect(result.success).toBe(false);
146
+ expect(result.errorMessage).toMatch(/'view:batch' scope/);
147
+ });
148
+
149
+ it('lets an API key holding the scope through', async () => {
150
+ authorize.mockImplementation(async (_hash: string, _app: string, scope: string) => ({
151
+ Allowed: scope === 'view:batch',
152
+ Reason: scope === 'view:batch' ? 'granted' : 'not full access',
153
+ }));
154
+ const { provider, calls } = fakeProvider();
155
+
156
+ const result = await new RunViewResolver().RunViewsWithCacheCheck(
157
+ cacheCheckInput({ ExtraFilter: `Name = 'x'` }),
158
+ context(provider, { apiKeyHash: 'hash-of-a-batch-key' }),
159
+ );
160
+
161
+ expect(result.success).toBe(true);
162
+ expect(calls).toHaveLength(1);
163
+ });
164
+ });
@@ -27,15 +27,18 @@ vi.mock('@memberjunction/ai-agents', () => ({
27
27
  },
28
28
  }));
29
29
 
30
- // --- Mock RunView.FromMetadataProvider so channel sweeps are controllable, while leaving the
31
- // rest of @memberjunction/core intact. ---
30
+ // --- Mock RunView.FromMetadataProvider so channel sweeps and run-id lookups are controllable,
31
+ // while leaving the rest of @memberjunction/core intact. RunViews runs each view through runViewMock. ---
32
32
  const runViewMock = vi.fn();
33
33
  vi.mock('@memberjunction/core', async (importOriginal) => {
34
34
  const actual = await importOriginal<typeof import('@memberjunction/core')>();
35
35
  return {
36
36
  ...actual,
37
37
  RunView: {
38
- FromMetadataProvider: () => ({ RunView: runViewMock }),
38
+ FromMetadataProvider: () => ({
39
+ RunView: runViewMock,
40
+ RunViews: (paramsList: unknown[]) => Promise.all(paramsList.map((params) => runViewMock(params))),
41
+ }),
39
42
  },
40
43
  };
41
44
  });
@@ -100,6 +103,27 @@ function makeSessionEntity(overrides: Partial<FakeEntity> = {}): FakeEntity {
100
103
  };
101
104
  }
102
105
 
106
+ /** Co-agent observability ids as a voice session's Config holds them. */
107
+ const CO_RUN_ID = 'a5a5a5a5-0000-4000-8000-0000000000c0';
108
+ const PROMPT_RUN_ID = 'a5a5a5a5-0000-4000-8000-0000000000b0';
109
+ const RUN_STEP_ID = 'a5a5a5a5-0000-4000-8000-0000000000a0';
110
+
111
+ /**
112
+ * Makes the given runs records of the session for the run-id lookups: `runIDs` carry the session's
113
+ * id, and `promptRunID` (when given) is the TargetLogID of the co-agent run's Prompt step.
114
+ */
115
+ function mockSessionRunRecords(runIDs: string[], promptRunID?: string): void {
116
+ runViewMock.mockImplementation(async (params: { EntityName?: string }) => {
117
+ if (params.EntityName === 'MJ: AI Agent Runs') {
118
+ return { Success: true, Results: runIDs.map((ID) => ({ ID })) };
119
+ }
120
+ if (params.EntityName === 'MJ: AI Agent Run Steps') {
121
+ return { Success: true, Results: promptRunID ? [{ ID: RUN_STEP_ID, StepType: 'Prompt', TargetLogID: promptRunID }] : [] };
122
+ }
123
+ return { Success: true, Results: [] };
124
+ });
125
+ }
126
+
103
127
  beforeEach(() => {
104
128
  hasPermissionMock.mockReset();
105
129
  finalizeCoAgentRunMock.mockClear();
@@ -228,8 +252,9 @@ describe('SessionManager.CloseSession', () => {
228
252
  const session = makeSessionEntity({
229
253
  ID: 'session-voice',
230
254
  Status: 'Active',
231
- Config_: JSON.stringify({ targetAgentID: 't1', coAgentRunID: 'co-run-5', promptRunID: 'prompt-run-5' }),
255
+ Config_: JSON.stringify({ targetAgentID: 't1', coAgentRunID: CO_RUN_ID, promptRunID: PROMPT_RUN_ID }),
232
256
  });
257
+ mockSessionRunRecords([CO_RUN_ID], PROMPT_RUN_ID);
233
258
  const { provider } = makeProvider(() => session);
234
259
  const mgr = new SessionManager();
235
260
  const user = makeUser();
@@ -238,7 +263,7 @@ describe('SessionManager.CloseSession', () => {
238
263
 
239
264
  expect(ok).toBe(true);
240
265
  expect(finalizeCoAgentRunMock).toHaveBeenCalledTimes(1);
241
- expect(finalizeCoAgentRunMock).toHaveBeenCalledWith('co-run-5', 'prompt-run-5', user, provider, true, null);
266
+ expect(finalizeCoAgentRunMock).toHaveBeenCalledWith(CO_RUN_ID, PROMPT_RUN_ID, user, provider, true, null);
242
267
  });
243
268
 
244
269
  it('threads the co-agent run-step id into finalize when the session config carries it', async () => {
@@ -247,11 +272,12 @@ describe('SessionManager.CloseSession', () => {
247
272
  Status: 'Active',
248
273
  Config_: JSON.stringify({
249
274
  targetAgentID: 't1',
250
- coAgentRunID: 'co-run-6',
251
- promptRunID: 'prompt-run-6',
252
- coAgentRunStepID: 'run-step-6',
275
+ coAgentRunID: CO_RUN_ID,
276
+ promptRunID: PROMPT_RUN_ID,
277
+ coAgentRunStepID: RUN_STEP_ID,
253
278
  }),
254
279
  });
280
+ mockSessionRunRecords([CO_RUN_ID], PROMPT_RUN_ID);
255
281
  const { provider } = makeProvider(() => session);
256
282
  const mgr = new SessionManager();
257
283
  const user = makeUser();
@@ -259,7 +285,7 @@ describe('SessionManager.CloseSession', () => {
259
285
  const ok = await mgr.CloseSession('session-voice-step', user, provider);
260
286
 
261
287
  expect(ok).toBe(true);
262
- expect(finalizeCoAgentRunMock).toHaveBeenCalledWith('co-run-6', 'prompt-run-6', user, provider, true, 'run-step-6');
288
+ expect(finalizeCoAgentRunMock).toHaveBeenCalledWith(CO_RUN_ID, PROMPT_RUN_ID, user, provider, true, RUN_STEP_ID);
263
289
  });
264
290
 
265
291
  it('finalizes as the SYSTEM user when a scoped anonymous owner closes their session (issue #3371)', async () => {
@@ -274,8 +300,9 @@ describe('SessionManager.CloseSession', () => {
274
300
  const session = makeSessionEntity({
275
301
  ID: 'session-anon',
276
302
  Status: 'Active',
277
- Config_: JSON.stringify({ targetAgentID: 't1', coAgentRunID: 'co-run-9', promptRunID: 'prompt-run-9' }),
303
+ Config_: JSON.stringify({ targetAgentID: 't1', coAgentRunID: CO_RUN_ID, promptRunID: PROMPT_RUN_ID }),
278
304
  });
305
+ mockSessionRunRecords([CO_RUN_ID], PROMPT_RUN_ID);
279
306
  const { provider } = makeProvider(() => session);
280
307
  const mgr = new SessionManager();
281
308
 
@@ -284,7 +311,7 @@ describe('SessionManager.CloseSession', () => {
284
311
  expect(ok).toBe(true);
285
312
  // The session-close writes themselves stay on the caller — only finalize elevates.
286
313
  expect(session.Status).toBe('Closed');
287
- expect(finalizeCoAgentRunMock).toHaveBeenCalledWith('co-run-9', 'prompt-run-9', systemUser, provider, true, null);
314
+ expect(finalizeCoAgentRunMock).toHaveBeenCalledWith(CO_RUN_ID, PROMPT_RUN_ID, systemUser, provider, true, null);
288
315
  });
289
316
 
290
317
  it('FAILS CLOSED — finalizes as the anonymous caller when no system user is available', async () => {
@@ -297,14 +324,15 @@ describe('SessionManager.CloseSession', () => {
297
324
  const session = makeSessionEntity({
298
325
  ID: 'session-anon-2',
299
326
  Status: 'Active',
300
- Config_: JSON.stringify({ targetAgentID: 't1', coAgentRunID: 'co-run-10', promptRunID: null }),
327
+ Config_: JSON.stringify({ targetAgentID: 't1', coAgentRunID: CO_RUN_ID, promptRunID: null }),
301
328
  });
329
+ mockSessionRunRecords([CO_RUN_ID]);
302
330
  const { provider } = makeProvider(() => session);
303
331
  const mgr = new SessionManager();
304
332
 
305
333
  await mgr.CloseSession('session-anon-2', anonUser, provider);
306
334
 
307
- expect(finalizeCoAgentRunMock).toHaveBeenCalledWith('co-run-10', null, anonUser, provider, true, null);
335
+ expect(finalizeCoAgentRunMock).toHaveBeenCalledWith(CO_RUN_ID, null, anonUser, provider, true, null);
308
336
  });
309
337
 
310
338
  it('does not finalize when the session config has no run ids (target only)', async () => {
@@ -527,3 +555,50 @@ describe('SessionManager — server-side channel plugin lifecycle notifications'
527
555
  expect(session.Status).toBe('Closed');
528
556
  });
529
557
  });
558
+
559
+ // A session's Config is writable by its owner (generic CRUD on MJ: AI Agent Sessions), so the run
560
+ // ids in it are acted on only when they are UUIDs of records that belong to the session.
561
+ describe('SessionManager — run ids in the owner-writable session Config', () => {
562
+ const SESSION_ID = 'c3c3c3c3-1000-4000-8000-000000000001';
563
+ const FOREIGN_RUN_ID = 'c3c3c3c3-1000-4000-8000-0000000000f1';
564
+ const FOREIGN_PROMPT_RUN_ID = 'c3c3c3c3-1000-4000-8000-0000000000f2';
565
+ const FOREIGN_STEP_ID = 'c3c3c3c3-1000-4000-8000-0000000000f3';
566
+ /** Harmless text that is not a UUID and would change the meaning of a quoted filter. */
567
+ const NOT_A_UUID = "x' OR '1'='1";
568
+
569
+ function issuedFilters(): string[] {
570
+ return runViewMock.mock.calls.map(([params]) => (params as { ExtraFilter?: string }).ExtraFilter ?? '');
571
+ }
572
+
573
+ it('CloseSession does not finalize run ids that are not records of the session', async () => {
574
+ const session = makeSessionEntity({
575
+ ID: SESSION_ID,
576
+ Status: 'Active',
577
+ Config_: JSON.stringify({
578
+ targetAgentID: 't1',
579
+ coAgentRunID: FOREIGN_RUN_ID,
580
+ promptRunID: FOREIGN_PROMPT_RUN_ID,
581
+ coAgentRunStepID: FOREIGN_STEP_ID,
582
+ }),
583
+ });
584
+ const { provider } = makeProvider(() => session);
585
+
586
+ await new SessionManager().CloseSession(SESSION_ID, makeUser(), provider);
587
+
588
+ expect(finalizeCoAgentRunMock).not.toHaveBeenCalled();
589
+ });
590
+
591
+ it('CloseSession does not finalize run ids that are not UUIDs, and never puts them in a filter', async () => {
592
+ const session = makeSessionEntity({
593
+ ID: SESSION_ID,
594
+ Status: 'Active',
595
+ Config_: JSON.stringify({ targetAgentID: 't1', coAgentRunID: NOT_A_UUID, promptRunID: NOT_A_UUID }),
596
+ });
597
+ const { provider } = makeProvider(() => session);
598
+
599
+ await new SessionManager().CloseSession(SESSION_ID, makeUser(), provider);
600
+
601
+ expect(finalizeCoAgentRunMock).not.toHaveBeenCalled();
602
+ expect(issuedFilters().filter((f) => f.includes(NOT_A_UUID))).toEqual([]);
603
+ });
604
+ });