@memberjunction/server 5.51.3 → 5.51.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (211) hide show
  1. package/README.md +34 -1
  2. package/dist/agentSessions/SessionManager.d.ts +5 -2
  3. package/dist/agentSessions/SessionManager.d.ts.map +1 -1
  4. package/dist/agentSessions/SessionManager.js +17 -8
  5. package/dist/agentSessions/SessionManager.js.map +1 -1
  6. package/dist/agentSessions/SessionRunIDVerifier.d.ts +63 -0
  7. package/dist/agentSessions/SessionRunIDVerifier.d.ts.map +1 -0
  8. package/dist/agentSessions/SessionRunIDVerifier.js +136 -0
  9. package/dist/agentSessions/SessionRunIDVerifier.js.map +1 -0
  10. package/dist/agentSessions/index.d.ts +1 -0
  11. package/dist/agentSessions/index.d.ts.map +1 -1
  12. package/dist/agentSessions/index.js +1 -0
  13. package/dist/agentSessions/index.js.map +1 -1
  14. package/dist/auth/actionAuthorization.d.ts +40 -0
  15. package/dist/auth/actionAuthorization.d.ts.map +1 -0
  16. package/dist/auth/actionAuthorization.js +94 -0
  17. package/dist/auth/actionAuthorization.js.map +1 -0
  18. package/dist/auth/index.d.ts +1 -0
  19. package/dist/auth/index.d.ts.map +1 -1
  20. package/dist/auth/index.js +1 -0
  21. package/dist/auth/index.js.map +1 -1
  22. package/dist/auth/scopeLimitedPrincipal.d.ts +39 -0
  23. package/dist/auth/scopeLimitedPrincipal.d.ts.map +1 -0
  24. package/dist/auth/scopeLimitedPrincipal.js +49 -0
  25. package/dist/auth/scopeLimitedPrincipal.js.map +1 -0
  26. package/dist/config.d.ts +53 -0
  27. package/dist/config.d.ts.map +1 -1
  28. package/dist/config.js +34 -1
  29. package/dist/config.js.map +1 -1
  30. package/dist/context.d.ts +6 -0
  31. package/dist/context.d.ts.map +1 -1
  32. package/dist/context.js +24 -34
  33. package/dist/context.js.map +1 -1
  34. package/dist/generated/generated.d.ts.map +1 -1
  35. package/dist/generated/generated.js +3262 -3273
  36. package/dist/generated/generated.js.map +1 -1
  37. package/dist/generic/CacheInvalidationResolver.d.ts +124 -4
  38. package/dist/generic/CacheInvalidationResolver.d.ts.map +1 -1
  39. package/dist/generic/CacheInvalidationResolver.js +170 -15
  40. package/dist/generic/CacheInvalidationResolver.js.map +1 -1
  41. package/dist/generic/FireAndForgetHeartbeat.d.ts +5 -0
  42. package/dist/generic/FireAndForgetHeartbeat.d.ts.map +1 -1
  43. package/dist/generic/FireAndForgetHeartbeat.js +5 -4
  44. package/dist/generic/FireAndForgetHeartbeat.js.map +1 -1
  45. package/dist/generic/PushStatusResolver.d.ts +58 -0
  46. package/dist/generic/PushStatusResolver.d.ts.map +1 -1
  47. package/dist/generic/PushStatusResolver.js +42 -3
  48. package/dist/generic/PushStatusResolver.js.map +1 -1
  49. package/dist/generic/ResolverBase.d.ts +148 -3
  50. package/dist/generic/ResolverBase.d.ts.map +1 -1
  51. package/dist/generic/ResolverBase.js +287 -30
  52. package/dist/generic/ResolverBase.js.map +1 -1
  53. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  54. package/dist/generic/RunViewResolver.js +12 -0
  55. package/dist/generic/RunViewResolver.js.map +1 -1
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +56 -48
  58. package/dist/index.js.map +1 -1
  59. package/dist/multiTenancy/index.d.ts.map +1 -1
  60. package/dist/multiTenancy/index.js +22 -2
  61. package/dist/multiTenancy/index.js.map +1 -1
  62. package/dist/postgresqlCompat.d.ts +8 -0
  63. package/dist/postgresqlCompat.d.ts.map +1 -0
  64. package/dist/postgresqlCompat.js +19 -0
  65. package/dist/postgresqlCompat.js.map +1 -0
  66. package/dist/postgresqlPoolSettings.d.ts +108 -0
  67. package/dist/postgresqlPoolSettings.d.ts.map +1 -0
  68. package/dist/postgresqlPoolSettings.js +134 -0
  69. package/dist/postgresqlPoolSettings.js.map +1 -0
  70. package/dist/realtimeWidget/widgetGuestElevation.d.ts +2 -2
  71. package/dist/realtimeWidget/widgetGuestElevation.js +2 -2
  72. package/dist/resolvers/ActionResolver.d.ts +31 -5
  73. package/dist/resolvers/ActionResolver.d.ts.map +1 -1
  74. package/dist/resolvers/ActionResolver.js +75 -14
  75. package/dist/resolvers/ActionResolver.js.map +1 -1
  76. package/dist/resolvers/AdhocQueryResolver.d.ts +39 -17
  77. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  78. package/dist/resolvers/AdhocQueryResolver.js +97 -157
  79. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  80. package/dist/resolvers/DatasetResolver.d.ts +24 -3
  81. package/dist/resolvers/DatasetResolver.d.ts.map +1 -1
  82. package/dist/resolvers/DatasetResolver.js +57 -8
  83. package/dist/resolvers/DatasetResolver.js.map +1 -1
  84. package/dist/resolvers/EntityRecordNameResolver.d.ts +14 -4
  85. package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
  86. package/dist/resolvers/EntityRecordNameResolver.js +18 -6
  87. package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
  88. package/dist/resolvers/GetDataContextDataResolver.d.ts +33 -7
  89. package/dist/resolvers/GetDataContextDataResolver.d.ts.map +1 -1
  90. package/dist/resolvers/GetDataContextDataResolver.js +117 -38
  91. package/dist/resolvers/GetDataContextDataResolver.js.map +1 -1
  92. package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
  93. package/dist/resolvers/GetDataResolver.js +13 -1
  94. package/dist/resolvers/GetDataResolver.js.map +1 -1
  95. package/dist/resolvers/MCPResolver.d.ts.map +1 -1
  96. package/dist/resolvers/MCPResolver.js +13 -16
  97. package/dist/resolvers/MCPResolver.js.map +1 -1
  98. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +26 -14
  99. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  100. package/dist/resolvers/RealtimeClientSessionResolver.js +63 -46
  101. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  102. package/dist/resolvers/RemoteBrowserActionResolver.d.ts +2 -0
  103. package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
  104. package/dist/resolvers/RemoteBrowserActionResolver.js +24 -27
  105. package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
  106. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  107. package/dist/resolvers/RunAIAgentResolver.js +30 -40
  108. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  109. package/dist/resolvers/RunTemplateResolver.d.ts.map +1 -1
  110. package/dist/resolvers/RunTemplateResolver.js +12 -11
  111. package/dist/resolvers/RunTemplateResolver.js.map +1 -1
  112. package/dist/resolvers/RunTestResolver.d.ts.map +1 -1
  113. package/dist/resolvers/RunTestResolver.js +90 -112
  114. package/dist/resolvers/RunTestResolver.js.map +1 -1
  115. package/dist/resolvers/SyncRolesUsersResolver.d.ts.map +1 -1
  116. package/dist/resolvers/SyncRolesUsersResolver.js +11 -2
  117. package/dist/resolvers/SyncRolesUsersResolver.js.map +1 -1
  118. package/dist/resolvers/VersionHistoryResolver.d.ts.map +1 -1
  119. package/dist/resolvers/VersionHistoryResolver.js +8 -12
  120. package/dist/resolvers/VersionHistoryResolver.js.map +1 -1
  121. package/dist/resolvers/adhocTimeout.d.ts +7 -0
  122. package/dist/resolvers/adhocTimeout.d.ts.map +1 -0
  123. package/dist/resolvers/adhocTimeout.js +12 -0
  124. package/dist/resolvers/adhocTimeout.js.map +1 -0
  125. package/dist/rest/ViewOperationsHandler.d.ts +3 -1
  126. package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
  127. package/dist/rest/ViewOperationsHandler.js +29 -17
  128. package/dist/rest/ViewOperationsHandler.js.map +1 -1
  129. package/dist/services/TaskOrchestrator.d.ts.map +1 -1
  130. package/dist/services/TaskOrchestrator.js +17 -15
  131. package/dist/services/TaskOrchestrator.js.map +1 -1
  132. package/package.json +90 -89
  133. package/src/__tests__/ActionResolver.authorization.test.ts +353 -0
  134. package/src/__tests__/AdhocQueryResolver.bugs.test.ts +14 -35
  135. package/src/__tests__/AdhocQueryResolver.pagination.test.ts +6 -2
  136. package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +84 -0
  137. package/src/__tests__/AdhocQueryResolver.sqlAuthorizer.test.ts +104 -0
  138. package/src/__tests__/DatasetResolver.itemFilters.test.ts +201 -0
  139. package/src/__tests__/EntityRecordNameResolver.rowSecurity.test.ts +152 -0
  140. package/src/__tests__/GetDataContextDataResolver.sqlItems.test.ts +416 -0
  141. package/src/__tests__/ListOperationsResolver.adhocScreen.test.ts +136 -0
  142. package/src/__tests__/RealtimeClientSessionResolver.configTamper.test.ts +318 -0
  143. package/src/__tests__/RealtimeClientSessionResolver.test.ts +66 -27
  144. package/src/__tests__/RemoteBrowserActionResolver.goalRunLink.test.ts +134 -0
  145. package/src/__tests__/ResolverBase.adhocReadScope.test.ts +221 -0
  146. package/src/__tests__/ResolverBase.aggregateScreen.test.ts +173 -0
  147. package/src/__tests__/ResolverBase.balancedClause.test.ts +155 -0
  148. package/src/__tests__/ResolverBase.clauseUnscopedRead.test.ts +116 -0
  149. package/src/__tests__/ResolverBase.filterEscaping.test.ts +351 -0
  150. package/src/__tests__/RunTemplateResolver.test.ts +132 -0
  151. package/src/__tests__/RunViewResolver.cacheCheckScreen.test.ts +164 -0
  152. package/src/__tests__/SessionManager.test.ts +88 -13
  153. package/src/__tests__/SessionRunIDVerifier.test.ts +255 -0
  154. package/src/__tests__/UpdateMJDataContextItem.oldValues.test.ts +220 -0
  155. package/src/__tests__/ViewOperationsHandler.clauseScreen.test.ts +155 -0
  156. package/src/__tests__/actionAuthorization.test.ts +130 -0
  157. package/src/__tests__/adhocTimeoutClamp.test.ts +26 -0
  158. package/src/__tests__/cacheInvalidationRecordData.test.ts +241 -0
  159. package/src/__tests__/multiTenancy.postgresRewrite.test.ts +180 -0
  160. package/src/__tests__/multiTenancy.security.test.ts +51 -0
  161. package/src/__tests__/multiTenancy.test.ts +1 -1
  162. package/src/__tests__/newUserRolesDefault.test.ts +81 -0
  163. package/src/__tests__/postgresqlPoolSettings.test.ts +131 -0
  164. package/src/__tests__/pushStatusResolver.filter.test.ts +56 -0
  165. package/src/__tests__/resolverBase.refusalMessage.test.ts +88 -0
  166. package/src/__tests__/resolverBase.rls.test.ts +2 -0
  167. package/src/__tests__/translateBracketsToPG.test.ts +20 -0
  168. package/src/agentSessions/SessionManager.ts +24 -25
  169. package/src/agentSessions/SessionRunIDVerifier.ts +179 -0
  170. package/src/agentSessions/index.ts +1 -0
  171. package/src/auth/actionAuthorization.ts +119 -0
  172. package/src/auth/index.ts +1 -0
  173. package/src/auth/scopeLimitedPrincipal.ts +50 -0
  174. package/src/config.ts +34 -1
  175. package/src/context.ts +25 -34
  176. package/src/generated/generated.ts +3262 -3273
  177. package/src/generic/CacheInvalidationResolver.ts +206 -15
  178. package/src/generic/FireAndForgetHeartbeat.ts +10 -4
  179. package/src/generic/PushStatusResolver.ts +79 -4
  180. package/src/generic/ResolverBase.ts +339 -31
  181. package/src/generic/RunViewResolver.ts +17 -0
  182. package/src/index.ts +63 -50
  183. package/src/multiTenancy/index.ts +24 -2
  184. package/src/postgresqlCompat.ts +21 -0
  185. package/src/postgresqlPoolSettings.ts +213 -0
  186. package/src/realtimeWidget/widgetGuestElevation.ts +2 -2
  187. package/src/resolvers/ActionResolver.ts +85 -22
  188. package/src/resolvers/AdhocQueryResolver.ts +110 -177
  189. package/src/resolvers/DatasetResolver.ts +70 -10
  190. package/src/resolvers/EntityRecordNameResolver.ts +24 -7
  191. package/src/resolvers/GetDataContextDataResolver.ts +139 -41
  192. package/src/resolvers/GetDataResolver.ts +14 -1
  193. package/src/resolvers/MCPResolver.ts +13 -15
  194. package/src/resolvers/RealtimeClientSessionResolver.ts +82 -50
  195. package/src/resolvers/RemoteBrowserActionResolver.ts +30 -26
  196. package/src/resolvers/RunAIAgentResolver.ts +30 -40
  197. package/src/resolvers/RunTemplateResolver.ts +13 -12
  198. package/src/resolvers/RunTestResolver.ts +90 -112
  199. package/src/resolvers/SyncRolesUsersResolver.ts +11 -2
  200. package/src/resolvers/VersionHistoryResolver.ts +10 -13
  201. package/src/resolvers/__tests__/GetDataResolver.validation.test.ts +95 -0
  202. package/src/resolvers/__tests__/ReportResolver.test.ts +2 -0
  203. package/src/resolvers/adhocTimeout.ts +12 -0
  204. package/src/rest/ViewOperationsHandler.ts +37 -18
  205. package/src/services/TaskOrchestrator.ts +17 -17
  206. package/dist/resolvers/adhoc-query-helpers.d.ts +0 -27
  207. package/dist/resolvers/adhoc-query-helpers.d.ts.map +0 -1
  208. package/dist/resolvers/adhoc-query-helpers.js +0 -42
  209. package/dist/resolvers/adhoc-query-helpers.js.map +0 -1
  210. package/src/__tests__/adhoc-query-helpers.test.ts +0 -50
  211. package/src/resolvers/adhoc-query-helpers.ts +0 -50
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Config-contract regression test for the shipped `userHandling.newUserRoles` default (issue #4260).
3
+ *
4
+ * THE DEFECT. `DEFAULT_SERVER_CONFIG` shipped `newUserRoles: ['UI', 'Developer']`. On the baseline
5
+ * seed the `Developer` role holds `CanUpdate` on 439 of the database's 446 entities with no row-level filter —
6
+ * `MJ: Users` among them, where `AllowUpdateAPI` is true and both `Type` and `Name` are
7
+ * `AllowUpdateAPI` fields. Every gate in the chain passes:
8
+ *
9
+ * config default -> newUsers.ts assigns the role -> EntityPermission grants Update
10
+ * -> Entity/Field AllowUpdateAPI allow it -> BaseEntity.CheckPermissions passes
11
+ * -> GRANT EXECUTE ON __mj.spUpdateUser TO cdp_Developer
12
+ *
13
+ * So anyone who obtained an account through JWT auto-provisioning or a magic-link redeem could
14
+ * write any row of `MJ: Users` — including setting their own `Type` to `'Owner'`, the column MJ's
15
+ * superuser checks read (`SqlLoggingConfigResolver`, `canIssueInvites`, the backup-system-user
16
+ * fallback). That is privilege escalation from "can obtain a token" to "platform Owner".
17
+ *
18
+ * WHY THE DEFAULT IS THE FIX. The value is not merely the fallback for a host with no config file.
19
+ * `loadConfig()` deep-merges through `mergeConfigs`, so a host writing a PARTIAL `userHandling`
20
+ * block (`{ autoCreateNewUsers: true }`, say) inherits this array — the Zod `.default([])` on the
21
+ * schema never fires, because the key is never absent after the merge. The shipped default is
22
+ * therefore the effective posture of every deployment that does not name `newUserRoles` explicitly.
23
+ *
24
+ * These assertions pin the CONTRACT, not just the literal: the shipped default must grant an
25
+ * auto-provisioned user no more than the end-user surface.
26
+ */
27
+ import { describe, it, expect, vi } from 'vitest';
28
+
29
+ // config.ts evaluates `configInfo = loadConfig()` at module load, and `loadConfig` rejects a
30
+ // configuration carrying no database credentials. Stubbing the cosmiconfig search — and ONLY that —
31
+ // supplies them so the module imports, while leaving `DEFAULT_SERVER_CONFIG` itself completely
32
+ // untouched. These assertions therefore run against the constant the server actually ships rather
33
+ // than against a copy of it, which is the whole point of a shipped-default contract test.
34
+ vi.mock('cosmiconfig', () => ({
35
+ cosmiconfigSync: () => ({
36
+ search: () => ({
37
+ filepath: '/test/mj.config.cjs',
38
+ isEmpty: false,
39
+ config: { dbUsername: 'test-user', dbPassword: 'test-password', dbDatabase: 'test-db' },
40
+ }),
41
+ }),
42
+ }));
43
+
44
+ import { DEFAULT_SERVER_CONFIG } from '../config.js';
45
+
46
+ /**
47
+ * Seeded roles that carry platform-wide write on the baseline database, and so must never appear
48
+ * in a default handed to a party the host has not vetted. `Integration`'s own seed Description
49
+ * says it "shouldn't be applied to end-users"; `Developer` is the role this issue was filed about.
50
+ */
51
+ const PLATFORM_WRITE_ROLES = ['Developer', 'Integration'];
52
+
53
+ describe('DEFAULT_SERVER_CONFIG.userHandling.newUserRoles (issue #4260)', () => {
54
+ const newUserRoles = DEFAULT_SERVER_CONFIG.userHandling?.newUserRoles;
55
+
56
+ it('grants auto-provisioned users the end-user role only', () => {
57
+ expect(newUserRoles).toEqual(['UI']);
58
+ });
59
+
60
+ it.each(PLATFORM_WRITE_ROLES)('does not include the platform-write role %j', role => {
61
+ // The regression itself. Before the fix, 'Developer' was present here and every
62
+ // auto-provisioned user could UPDATE MJ: Users, Type and Name included.
63
+ expect(newUserRoles).not.toContain(role);
64
+ });
65
+
66
+ it('is not empty — an auto-provisioned user must still be able to use the product', () => {
67
+ // The opposite failure mode: locking the default down to [] would provision users who
68
+ // cannot read their own conversations, views or dashboards. 'UI' is the seeded role that
69
+ // carries exactly that end-user surface (48 update grants, none of them on MJ: Users).
70
+ expect(newUserRoles?.length).toBeGreaterThan(0);
71
+ });
72
+
73
+ it('stays minimal because auto-provisioning is ON by default with no domain restriction', () => {
74
+ // This is the linkage that makes the role list security-relevant rather than a convenience
75
+ // setting. A stock server auto-creates an account for ANY identity its configured IdP will
76
+ // issue a token for. If either of these defaults is ever tightened, the role list may be
77
+ // reconsidered — but not before, and never in the other order.
78
+ expect(DEFAULT_SERVER_CONFIG.userHandling?.autoCreateNewUsers).toBe(true);
79
+ expect(DEFAULT_SERVER_CONFIG.userHandling?.newUserLimitedToAuthorizedDomains).toBe(false);
80
+ });
81
+ });
@@ -0,0 +1,131 @@
1
+ /**
2
+ * PostgreSQL pools read the same databaseSettings values as SQL Server, with the same defaults.
3
+ */
4
+ import { describe, it, expect } from 'vitest';
5
+ import {
6
+ BuildPostgreSQLConnectionConfig,
7
+ CODEGEN_STATEMENT_TIMEOUT_MS,
8
+ ResolvePostgreSQLEndpoint,
9
+ ResolvePostgreSQLReadOnlyCredentials,
10
+ ToPGPoolConfig
11
+ } from '../postgresqlPoolSettings.js';
12
+ import type { DatabaseSettingsInfo } from '../config.js';
13
+ import { DescribeReadOnlyLoginOverreach } from '../postgresqlPoolSettings.js';
14
+ import type { ReadOnlyLoginCheckClient, ReadOnlyLoginGrants } from '../postgresqlPoolSettings.js';
15
+
16
+ const defaults: DatabaseSettingsInfo = {
17
+ connectionTimeout: 45000,
18
+ requestTimeout: 30000,
19
+ metadataCacheRefreshInterval: 180000,
20
+ connectionPool: { max: 50, min: 5, idleTimeoutMillis: 30000, acquireTimeoutMillis: 30000 }
21
+ };
22
+ const endpoint = { Host: 'db', Port: 5432, Database: 'mj', User: 'api', Password: 'secret' };
23
+
24
+ describe('BuildPostgreSQLConnectionConfig', () => {
25
+ it('gives API pools requestTimeout as the statement timeout and the pool settings SQL Server uses', () => {
26
+ const config = BuildPostgreSQLConnectionConfig(endpoint, defaults, 'api');
27
+ expect(config).toMatchObject({
28
+ ...endpoint,
29
+ MaxConnections: 50,
30
+ MinConnections: 5,
31
+ IdleTimeoutMillis: 30000,
32
+ ConnectionTimeoutMillis: 30000,
33
+ StatementTimeoutMs: 30000,
34
+ IdleInTransactionSessionTimeoutMs: 60000
35
+ });
36
+ });
37
+
38
+ it('follows a configured requestTimeout and pool', () => {
39
+ const config = BuildPostgreSQLConnectionConfig(endpoint, {
40
+ ...defaults,
41
+ requestTimeout: 5000,
42
+ connectionPool: { max: 12, min: 1, idleTimeoutMillis: 7000, acquireTimeoutMillis: 9000 }
43
+ }, 'api');
44
+ expect(config).toMatchObject({ MaxConnections: 12, MinConnections: 1, IdleTimeoutMillis: 7000, ConnectionTimeoutMillis: 9000, StatementTimeoutMs: 5000, IdleInTransactionSessionTimeoutMs: 10000 });
45
+ });
46
+
47
+ it('sets no timeouts when requestTimeout is 0', () => {
48
+ const config = BuildPostgreSQLConnectionConfig(endpoint, { ...defaults, requestTimeout: 0 }, 'api');
49
+ expect(config.StatementTimeoutMs).toBe(0);
50
+ expect(config.IdleInTransactionSessionTimeoutMs).toBe(0);
51
+ });
52
+
53
+ it('keeps the read-only pool small but gives it the API timeouts', () => {
54
+ const config = BuildPostgreSQLConnectionConfig(endpoint, defaults, 'read-only');
55
+ expect(config).toMatchObject({ MaxConnections: 10, MinConnections: 0, StatementTimeoutMs: 30000, IdleInTransactionSessionTimeoutMs: 60000 });
56
+ expect(BuildPostgreSQLConnectionConfig(endpoint, { ...defaults, connectionPool: { max: 4, min: 1, idleTimeoutMillis: 1, acquireTimeoutMillis: 1 } }, 'read-only').MaxConnections).toBe(4);
57
+ });
58
+
59
+ it('gives the CodeGen pool the long timeout the SQL Server CodeGen pool has, and no idle-in-transaction limit', () => {
60
+ const config = BuildPostgreSQLConnectionConfig(endpoint, defaults, 'codegen');
61
+ expect(config.StatementTimeoutMs).toBe(CODEGEN_STATEMENT_TIMEOUT_MS);
62
+ expect(CODEGEN_STATEMENT_TIMEOUT_MS).toBe(600000);
63
+ expect(config.IdleInTransactionSessionTimeoutMs).toBe(0);
64
+ expect(config.MaxConnections).toBe(10);
65
+ });
66
+ });
67
+
68
+ describe('ToPGPoolConfig', () => {
69
+ it('maps the connection config onto pg.Pool option names', () => {
70
+ const pool = ToPGPoolConfig(BuildPostgreSQLConnectionConfig(endpoint, defaults, 'api'));
71
+ expect(pool).toEqual({
72
+ host: 'db', port: 5432, database: 'mj', user: 'api', password: 'secret',
73
+ max: 50, min: 5, idleTimeoutMillis: 30000, connectionTimeoutMillis: 30000,
74
+ statement_timeout: 30000, idle_in_transaction_session_timeout: 60000
75
+ });
76
+ });
77
+
78
+ it('leaves the timeouts out when they are 0', () => {
79
+ const pool = ToPGPoolConfig(BuildPostgreSQLConnectionConfig(endpoint, { ...defaults, requestTimeout: 0 }, 'api'));
80
+ expect(pool).not.toHaveProperty('statement_timeout');
81
+ expect(pool).not.toHaveProperty('idle_in_transaction_session_timeout');
82
+ });
83
+ });
84
+
85
+ describe('ResolvePostgreSQLEndpoint', () => {
86
+ it('prefers PG_* variables over DB_* ones', () => {
87
+ expect(ResolvePostgreSQLEndpoint({ PG_HOST: 'pg', DB_HOST: 'db', DB_PORT: '6000', DB_DATABASE: 'd', DB_USERNAME: 'u', DB_PASSWORD: 'p' }))
88
+ .toEqual({ Host: 'pg', Port: 6000, Database: 'd', User: 'u', Password: 'p' });
89
+ });
90
+
91
+ it('falls back to localhost:5432 and the postgres user', () => {
92
+ expect(ResolvePostgreSQLEndpoint({})).toEqual({ Host: 'localhost', Port: 5432, Database: '', User: 'postgres', Password: '' });
93
+ });
94
+ });
95
+
96
+ describe('ResolvePostgreSQLReadOnlyCredentials', () => {
97
+ it('uses the dbReadOnlyUsername / dbReadOnlyPassword settings SQL Server uses', () => {
98
+ expect(ResolvePostgreSQLReadOnlyCredentials({ dbReadOnlyUsername: 'ro', dbReadOnlyPassword: 'pw' }, {}))
99
+ .toEqual({ User: 'ro', Password: 'pw' });
100
+ });
101
+
102
+ it('falls back to PG_READ_ONLY_* when the settings are absent', () => {
103
+ expect(ResolvePostgreSQLReadOnlyCredentials({}, { PG_READ_ONLY_USERNAME: 'pgro', PG_READ_ONLY_PASSWORD: 'x' }))
104
+ .toEqual({ User: 'pgro', Password: 'x' });
105
+ });
106
+
107
+ it('returns null when no complete pair is configured', () => {
108
+ expect(ResolvePostgreSQLReadOnlyCredentials({ dbReadOnlyUsername: 'ro' }, { PG_READ_ONLY_USERNAME: 'pgro' })).toBeNull();
109
+ });
110
+ });
111
+
112
+ describe('DescribeReadOnlyLoginOverreach', () => {
113
+ const clientAnswering = (row: ReadOnlyLoginGrants): ReadOnlyLoginCheckClient => ({
114
+ query: async () => ({ rows: [row] }),
115
+ });
116
+
117
+ it('says nothing for a login that reads only views', async () => {
118
+ expect(await DescribeReadOnlyLoginOverreach(clientAnswering({ ReadsServerFiles: false, ReadableBaseTables: 0 }), '__mj')).toEqual([]);
119
+ });
120
+
121
+ it('warns when the login can read base tables', async () => {
122
+ const warnings = await DescribeReadOnlyLoginOverreach(clientAnswering({ ReadsServerFiles: false, ReadableBaseTables: 412 }), '__mj');
123
+ expect(warnings).toHaveLength(1);
124
+ expect(warnings[0]).toMatch(/can read 412 base table\(s\) in schema "__mj"/);
125
+ });
126
+
127
+ it('warns when the login can read server files', async () => {
128
+ const warnings = await DescribeReadOnlyLoginOverreach(clientAnswering({ ReadsServerFiles: true, ReadableBaseTables: 0 }), '__mj');
129
+ expect(warnings[0]).toMatch(/pg_read_server_files/);
130
+ });
131
+ });
@@ -0,0 +1,56 @@
1
+ /**
2
+ * pushStatusResolver.filter.test.ts — locks the security-critical subscription filter for B49.
3
+ *
4
+ * `statusUpdatesFilter` is the gate that fixes the `statusUpdates` session-hijack: a push is
5
+ * delivered only when the push's `sessionId` matches the subscriber's requested session AND the
6
+ * push's `ownerUserId` matches the subscriber CONNECTION's server-authenticated identity. Knowing
7
+ * a victim's client-chosen `sessionId` must no longer be sufficient to receive their pushes.
8
+ *
9
+ * The filter is a pure function (no DB / config / transport), so these are plain unit tests.
10
+ */
11
+ import 'reflect-metadata';
12
+ import { describe, it, expect } from 'vitest';
13
+ import { statusUpdatesFilter, type PushStatusNotificationPayload, type StatusUpdatesFilterContext } from '../generic/PushStatusResolver';
14
+ import type { UserPayload } from '../types';
15
+
16
+ const OWNER = 'AA11BB22-0000-4000-8000-000000000001';
17
+ const ATTACKER = 'CC33DD44-0000-4000-8000-000000000002';
18
+ const SESSION = 'session-abc-123';
19
+
20
+ function ctxFor(userId: string | undefined): StatusUpdatesFilterContext {
21
+ // userRecord is `any` in UserPayload; a minimal shape with ID is all the filter reads.
22
+ return { userPayload: (userId ? { userRecord: { ID: userId } } : {}) as unknown as UserPayload };
23
+ }
24
+
25
+ function push(overrides: Partial<PushStatusNotificationPayload> = {}): PushStatusNotificationPayload {
26
+ return { sessionId: SESSION, ownerUserId: OWNER, message: 'progress', ...overrides };
27
+ }
28
+
29
+ describe('statusUpdatesFilter (B49 session-hijack gate)', () => {
30
+ it('delivers when sessionId matches AND the connection identity owns the push', () => {
31
+ expect(statusUpdatesFilter({ payload: push(), args: { sessionId: SESSION }, context: ctxFor(OWNER) })).toBe(true);
32
+ });
33
+
34
+ it('THE FIX: rejects a matching sessionId when the connection identity is a DIFFERENT user (hijack)', () => {
35
+ // Attacker lifted the victim's sessionId and subscribes with it — but their authenticated
36
+ // connection identity is their own, so delivery must be denied.
37
+ expect(statusUpdatesFilter({ payload: push({ ownerUserId: OWNER }), args: { sessionId: SESSION }, context: ctxFor(ATTACKER) })).toBe(false);
38
+ });
39
+
40
+ it('rejects when the requested sessionId does not match the push', () => {
41
+ expect(statusUpdatesFilter({ payload: push(), args: { sessionId: 'some-other-session' }, context: ctxFor(OWNER) })).toBe(false);
42
+ });
43
+
44
+ it('fails closed when the push carries no ownerUserId', () => {
45
+ expect(statusUpdatesFilter({ payload: push({ ownerUserId: '' }), args: { sessionId: SESSION }, context: ctxFor(OWNER) })).toBe(false);
46
+ });
47
+
48
+ it('fails closed when the connection has no authenticated identity', () => {
49
+ expect(statusUpdatesFilter({ payload: push(), args: { sessionId: SESSION }, context: ctxFor(undefined) })).toBe(false);
50
+ expect(statusUpdatesFilter({ payload: push(), args: { sessionId: SESSION }, context: undefined })).toBe(false);
51
+ });
52
+
53
+ it('matches identity case-insensitively (SQL Server upper vs PostgreSQL lower UUIDs)', () => {
54
+ expect(statusUpdatesFilter({ payload: push({ ownerUserId: OWNER.toUpperCase() }), args: { sessionId: SESSION }, context: ctxFor(OWNER.toLowerCase()) })).toBe(true);
55
+ });
56
+ });
@@ -0,0 +1,88 @@
1
+ /**
2
+ * What the API says when a save or a delete is REFUSED.
3
+ *
4
+ * The three write resolvers all end the same way — `throw new GraphQLError(<a string off
5
+ * LatestResult> ?? 'Unknown error')` — and they did not agree on which string. `CreateRecord` read
6
+ * `CompleteMessage`; `UpdateRecord` and `DeleteRecord` read `Message`. A validation refusal puts its
7
+ * reasons in `Errors` and leaves `Message` null, so the same rule on the same entity explained itself
8
+ * on a create and produced the literal text "Unknown error" on an update.
9
+ *
10
+ * Nothing could catch it: both properties exist, both are strings, and the `??` fallback renders the
11
+ * failure as a plausible-looking error rather than a crash. It is visible only by refusing an update
12
+ * and reading what comes back.
13
+ *
14
+ * The first two cases below guard the direction of RISK in the change — that swapping the property
15
+ * cannot lose a plain `Message`, and cannot blank out the `'Unknown error'` fallback. The last case
16
+ * is the only one that fails if a resolver is switched back, and it has to read the source to do it:
17
+ * any test that restates what a resolver reads stays green through the regression, which is exactly
18
+ * how this survived (a create-path test proved nothing about update).
19
+ */
20
+ import { readFileSync } from 'node:fs';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { describe, expect, it } from 'vitest';
23
+ import { BaseEntityResult } from '@memberjunction/core';
24
+
25
+ /** The shape a validation refusal leaves: `Message` untouched, reasons in `Errors`. */
26
+ function refusedByValidation(...messages: string[]): BaseEntityResult {
27
+ const result = new BaseEntityResult();
28
+ result.Success = false;
29
+ result.Errors = messages.map((Message) => ({
30
+ Source: 'SomeField',
31
+ Message,
32
+ Value: null,
33
+ Type: 'Failure',
34
+ })) as unknown as BaseEntityResult['Errors'];
35
+ return result;
36
+ }
37
+
38
+ /** Exactly what the update/delete resolvers hand to `GraphQLError`, fallback included. */
39
+ const asReported = (r: BaseEntityResult) => r.CompleteMessage ?? 'Unknown error';
40
+
41
+ describe('switching the update/delete throws to CompleteMessage', () => {
42
+ it('does NOT lose a plain Message — it is a superset, not a replacement', () => {
43
+ // The risk in the change: a non-validation failure (a provider/SQL error) sets `Message` and
44
+ // no `Errors`. The new property must still report it.
45
+ const result = new BaseEntityResult();
46
+ result.Success = false;
47
+ result.Message = 'Timeout expired while saving.';
48
+
49
+ expect(asReported(result)).toBe('Timeout expired while saving.');
50
+ });
51
+
52
+ it('still falls back to "Unknown error" when there is genuinely nothing to say', () => {
53
+ // The other direction: if CompleteMessage returned '' rather than undefined for an empty
54
+ // result, `??` would not fire and the user would get a blank error.
55
+ expect(asReported(new BaseEntityResult())).toBe('Unknown error');
56
+ });
57
+
58
+ it('carries the validation prose that "Unknown error" used to replace', () => {
59
+ const prose = 'Record what this contract says instead of the standard clause.';
60
+ const result = refusedByValidation(prose);
61
+
62
+ expect(result.Message, 'a refusal leaves Message unset — this is why reading it failed').toBeFalsy();
63
+ expect(asReported(result)).toBe(prose);
64
+ });
65
+ });
66
+
67
+ describe('ResolverBase selects CompleteMessage on every write-refusal throw', () => {
68
+ const source = readFileSync(fileURLToPath(new URL('../generic/ResolverBase.ts', import.meta.url)), 'utf8');
69
+
70
+ it('no refusal throw reads the bare LatestResult.Message', () => {
71
+ // `\b` so the longer `CompleteMessage` does not match.
72
+ const offenders = source
73
+ .split('\n')
74
+ .map((line, i) => ({ line: line.trim(), n: i + 1 }))
75
+ .filter(({ line }) => /LatestResult\??\.Message\b/.test(line));
76
+
77
+ expect(
78
+ offenders,
79
+ `these lines still read the bare Message:\n${offenders.map((o) => ` ${o.n}: ${o.line}`).join('\n')}`,
80
+ ).toEqual([]);
81
+ });
82
+
83
+ it('keeps the three error codes distinct — the fix must not have collapsed the paths', () => {
84
+ for (const code of ['CREATE_ENTITY_ERROR', 'SAVE_ENTITY_ERROR', 'DELETE_ENTITY_ERROR']) {
85
+ expect(source, code).toContain(code);
86
+ }
87
+ });
88
+ });
@@ -103,6 +103,8 @@ vi.mock('@memberjunction/core', async () => {
103
103
  });
104
104
 
105
105
  vi.mock('@memberjunction/core-entities', () => ({}));
106
+ // ResolverBase imports the client clause screen from generic-database-provider; these tests do not reach it.
107
+ vi.mock('@memberjunction/generic-database-provider', () => ({}));
106
108
 
107
109
  // ─── Import after mocks ──────────────────────────────────────────────────
108
110
  import { ResolverBase } from '../generic/ResolverBase';
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Generated resolvers emit SQL Server bracket-quoted identifiers; on PostgreSQL they become
3
+ * double-quoted. Only identifiers change: brackets inside a string literal are data.
4
+ */
5
+ import { describe, it, expect } from 'vitest';
6
+ import { TranslateBracketsToPG } from '../postgresqlCompat.js';
7
+
8
+ describe('TranslateBracketsToPG', () => {
9
+ it('turns bracket-quoted identifiers into double-quoted ones', () => {
10
+ expect(TranslateBracketsToPG('SELECT [ID], [Name] FROM [__mj].[vwUsers]')).toBe('SELECT "ID", "Name" FROM "__mj"."vwUsers"');
11
+ });
12
+
13
+ it('leaves brackets inside a string literal alone', () => {
14
+ expect(TranslateBracketsToPG("SELECT [ID] FROM [t] WHERE [Note] = '[draft] copy'")).toBe(`SELECT "ID" FROM "t" WHERE "Note" = '[draft] copy'`);
15
+ });
16
+
17
+ it('unescapes ]] and escapes a double quote in the name', () => {
18
+ expect(TranslateBracketsToPG('SELECT [a]]b], [say "hi"] FROM t')).toBe('SELECT "a]b", "say ""hi""" FROM t');
19
+ });
20
+ });
@@ -8,6 +8,7 @@ import { AIAgentPermissionHelper } from '@memberjunction/ai-engine-base';
8
8
  import { RealtimeClientSessionService, RealtimeChannelServerHost } from '@memberjunction/ai-agents';
9
9
  import { GetHostInstanceID } from './HostInstance.js';
10
10
  import { writeReturningVisitorRecap } from './ReturningVisitorRecap.js';
11
+ import { SessionRunIDVerifier, SessionRunIDs } from './SessionRunIDVerifier.js';
11
12
  import { ResolveScopedAnonymousRunUser } from '../realtimeWidget/widgetGuestElevation.js';
12
13
 
13
14
  /** Entity names — centralised so the `MJ:`-prefix convention is applied in exactly one place. */
@@ -15,18 +16,6 @@ const SESSION_ENTITY = 'MJ: AI Agent Sessions';
15
16
  const SESSION_CHANNEL_ENTITY = 'MJ: AI Agent Session Channels';
16
17
  const CONVERSATION_ENTITY = 'MJ: Conversations';
17
18
 
18
- /**
19
- * Shape of the observability run ids a realtime voice session persists in `AIAgentSession.Config_`.
20
- * Read on {@link SessionManager.CloseSession} to finalize the co-agent runs. All fields optional —
21
- * a non-voice session (or one whose run creation was skipped) carries none of them.
22
- */
23
- interface SessionRunConfig {
24
- coAgentRunID?: string;
25
- promptRunID?: string;
26
- /** The co-agent run's single system-prompt `MJ: AI Agent Run Steps` row id (its Timeline entry). */
27
- coAgentRunStepID?: string;
28
- }
29
-
30
19
  /**
31
20
  * Inputs for {@link SessionManager.CreateSession}. `conversationID`/`lastSessionID`/`config`
32
21
  * are optional — when no `conversationID` is supplied a fresh Conversation is created and linked.
@@ -96,6 +85,9 @@ export class SessionManager {
96
85
  /** session ID (lowercased) → epoch ms of its last persisted `LastActiveAt` write. */
97
86
  private heartbeatLastWrite = new Map<string, number>();
98
87
 
88
+ /** Checks the run ids read from a session's owner-editable `Config_` before they are used. */
89
+ private readonly runIDVerifier = new SessionRunIDVerifier();
90
+
99
91
  /**
100
92
  * Authorize and create a new session. Flow:
101
93
  * 1. `CanRun` check on the agent — denied → {@link SessionAuthorizationError}, no row written.
@@ -218,7 +210,8 @@ export class SessionManager {
218
210
 
219
211
  /**
220
212
  * Finalizes the co-agent observability runs a realtime voice session stored in its `Config_`
221
- * (see `RealtimeClientSessionResolver`). No-op when the config carries no run ids. Tolerant: a
213
+ * (see `RealtimeClientSessionResolver`). Only run ids that are records of the session are
214
+ * finalized, because the owner can edit `Config_`. No-op when none remain. Tolerant: a
222
215
  * malformed config or a finalize failure is swallowed so it can never break session close.
223
216
  */
224
217
  private async finalizeObservabilityRuns(
@@ -226,8 +219,8 @@ export class SessionManager {
226
219
  contextUser: UserInfo,
227
220
  provider: IMetadataProvider,
228
221
  ): Promise<void> {
229
- const config = this.parseSessionRunConfig(session.Config_);
230
- if (!config.coAgentRunID && !config.promptRunID && !config.coAgentRunStepID) {
222
+ const claimed = this.parseSessionRunConfig(session.Config_);
223
+ if (!claimed.CoAgentRunID && !claimed.PromptRunID && !claimed.CoAgentRunStepID) {
231
224
  return;
232
225
  }
233
226
  try {
@@ -235,36 +228,42 @@ export class SessionManager {
235
228
  // for a scoped anonymous session, so an owner-initiated (or error) close must finalize
236
229
  // under it too — the caller's role holds no grants on the AI run entities. Janitor and
237
230
  // shutdown sweeps already close as the system user and pass through unchanged.
231
+ const runUser = ResolveScopedAnonymousRunUser(contextUser);
232
+ const runIDs = await this.runIDVerifier.Verify(session.ID, claimed, runUser, provider);
233
+ if (!runIDs.CoAgentRunID && !runIDs.PromptRunID && !runIDs.CoAgentRunStepID) {
234
+ return;
235
+ }
238
236
  await new RealtimeClientSessionService().FinalizeCoAgentRun(
239
- config.coAgentRunID ?? null,
240
- config.promptRunID ?? null,
241
- ResolveScopedAnonymousRunUser(contextUser),
237
+ runIDs.CoAgentRunID ?? null,
238
+ runIDs.PromptRunID ?? null,
239
+ runUser,
242
240
  provider,
243
241
  true,
244
- config.coAgentRunStepID ?? null,
242
+ runIDs.CoAgentRunStepID ?? null,
245
243
  );
246
244
  } catch (e) {
247
245
  LogError(`SessionManager.finalizeObservabilityRuns failed: ${e instanceof Error ? e.message : String(e)}`);
248
246
  }
249
247
  }
250
248
 
251
- /** Parses the session's `Config_` for observability run ids; returns an empty config on any error. */
252
- private parseSessionRunConfig(raw: string | null): SessionRunConfig {
249
+ /** Parses the session's `Config_` for observability run ids; returns none on any error. */
250
+ private parseSessionRunConfig(raw: string | null): SessionRunIDs {
253
251
  if (!raw) {
254
252
  return {};
255
253
  }
256
254
  try {
257
- const parsed = JSON.parse(raw) as Partial<SessionRunConfig>;
255
+ const parsed = JSON.parse(raw) as { coAgentRunID?: unknown; promptRunID?: unknown; coAgentRunStepID?: unknown };
258
256
  return {
259
- coAgentRunID: typeof parsed.coAgentRunID === 'string' ? parsed.coAgentRunID : undefined,
260
- promptRunID: typeof parsed.promptRunID === 'string' ? parsed.promptRunID : undefined,
261
- coAgentRunStepID: typeof parsed.coAgentRunStepID === 'string' ? parsed.coAgentRunStepID : undefined,
257
+ CoAgentRunID: typeof parsed.coAgentRunID === 'string' ? parsed.coAgentRunID : undefined,
258
+ PromptRunID: typeof parsed.promptRunID === 'string' ? parsed.promptRunID : undefined,
259
+ CoAgentRunStepID: typeof parsed.coAgentRunStepID === 'string' ? parsed.coAgentRunStepID : undefined,
262
260
  };
263
261
  } catch {
264
262
  return {};
265
263
  }
266
264
  }
267
265
 
266
+
268
267
  /**
269
268
  * Record activity on a session. Coalesces persisted writes so at most one `LastActiveAt` update
270
269
  * lands per session per {@link HEARTBEAT_MIN_WRITE_INTERVAL_MS}; intervening calls return `true`