@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,179 @@
1
+ /**
2
+ * @fileoverview Checks the run ids a realtime session keeps in its `Config` before server code
3
+ * acts on them.
4
+ *
5
+ * @module @memberjunction/server
6
+ */
7
+ import { IMetadataProvider, LogError, RunView, RunViewParams, UserInfo } from '@memberjunction/core';
8
+ import { IsValidUUID, UUIDsEqual } from '@memberjunction/global';
9
+ import { UserCache } from '@memberjunction/sqlserver-dataprovider';
10
+
11
+ const AGENT_RUN_ENTITY = 'MJ: AI Agent Runs';
12
+ const AGENT_RUN_STEP_ENTITY = 'MJ: AI Agent Run Steps';
13
+
14
+ /**
15
+ * Escapes a value for a single-quoted SQL literal: drops null bytes and doubles quotes. A copy of
16
+ * `EscapeSQLString` from `@memberjunction/global` on next, which lts/5 does not have.
17
+ */
18
+ function EscapeSQLString(value: string): string {
19
+ return value.replace(/\0/g, '').replace(/'/g, "''");
20
+ }
21
+
22
+ /**
23
+ * The run ids a realtime voice session keeps in its `Config` JSON. Server code reads and writes the
24
+ * records they point at, often as an elevated user.
25
+ */
26
+ export interface SessionRunIDs {
27
+ /** The co-agent observability run (`MJ: AI Agent Runs`). */
28
+ CoAgentRunID?: string;
29
+ /** The co-agent prompt run (`MJ: AI Prompt Runs`), linked through the co-agent run's prompt step. */
30
+ PromptRunID?: string;
31
+ /** The co-agent run's system-prompt step (`MJ: AI Agent Run Steps`). */
32
+ CoAgentRunStepID?: string;
33
+ /** A delegated run (`MJ: AI Agent Runs`) that paused for the user's answer. */
34
+ PendingFeedbackRunID?: string;
35
+ }
36
+
37
+ /** A row of the run-step lookup. */
38
+ interface RunStepRow {
39
+ ID: string;
40
+ StepType: string;
41
+ TargetLogID: string | null;
42
+ }
43
+
44
+ /** A row of either lookup; the run lookup returns only `ID`. */
45
+ type LookupRow = Partial<RunStepRow> & { ID: string };
46
+
47
+ /**
48
+ * Keeps the run ids from a session's `Config` that are records of that session.
49
+ *
50
+ * The session owner can edit `Config`, so its run ids are claims. A run id is kept when it is the
51
+ * UUID of an `MJ: AI Agent Runs` row whose `AgentSessionID` is the session. The step id and the
52
+ * prompt run id are kept when they belong to that co-agent run: the step's `AgentRunID` is the
53
+ * co-agent run, and the prompt run is the `TargetLogID` of one of its `Prompt` steps.
54
+ *
55
+ * The lookups run as the system user when one is cached, else as the acting user. They are pinned
56
+ * to the session's id and return nothing to the caller, and row-level security can hide a session's
57
+ * own runs from its owner (a web-widget guest's delegated runs carry no `ConversationID`, which the
58
+ * guest's run filter keys on).
59
+ *
60
+ * Each call reads the database again (`BypassCache`) and keeps no result, because the links can
61
+ * change after a lookup: a run's `AgentSessionID` and a step's `AgentRunID` and `TargetLogID` are
62
+ * updatable.
63
+ */
64
+ export class SessionRunIDVerifier {
65
+ /**
66
+ * Returns the subset of `claimed` that belongs to the session now. Never throws: when a lookup
67
+ * fails, nothing is kept.
68
+ *
69
+ * @param sessionID The `MJ: AI Agent Sessions` id the claims were read from. The caller must have
70
+ * checked that the session is the caller's own.
71
+ * @param claimed The run ids read from the session's `Config`.
72
+ * @param contextUser The acting user; the lookups run as it only when no system user is cached.
73
+ * @param provider The request-scoped metadata provider.
74
+ * @returns The claims that are records of the session.
75
+ */
76
+ public async Verify(
77
+ sessionID: string,
78
+ claimed: SessionRunIDs,
79
+ contextUser: UserInfo,
80
+ provider: IMetadataProvider,
81
+ ): Promise<SessionRunIDs> {
82
+ const candidates = this.keepUUIDs(claimed);
83
+ if (!candidates.CoAgentRunID && !candidates.PendingFeedbackRunID) {
84
+ return {};
85
+ }
86
+ return (await this.lookUpOwned(sessionID.trim(), candidates, contextUser, provider)) ?? {};
87
+ }
88
+
89
+ /** Drops every claim that is not a UUID. The prompt run and step need a co-agent run to hang off. */
90
+ private keepUUIDs(claimed: SessionRunIDs): SessionRunIDs {
91
+ const coAgentRunID = this.uuidOrUndefined(claimed.CoAgentRunID);
92
+ return {
93
+ CoAgentRunID: coAgentRunID,
94
+ PromptRunID: coAgentRunID ? this.uuidOrUndefined(claimed.PromptRunID) : undefined,
95
+ CoAgentRunStepID: coAgentRunID ? this.uuidOrUndefined(claimed.CoAgentRunStepID) : undefined,
96
+ PendingFeedbackRunID: this.uuidOrUndefined(claimed.PendingFeedbackRunID),
97
+ };
98
+ }
99
+
100
+ private uuidOrUndefined(value: string | undefined): string | undefined {
101
+ return value && IsValidUUID(value) ? value.trim() : undefined;
102
+ }
103
+
104
+ /** Runs the lookups; `null` when one of them fails. */
105
+ private async lookUpOwned(
106
+ sessionID: string,
107
+ candidates: SessionRunIDs,
108
+ contextUser: UserInfo,
109
+ provider: IMetadataProvider,
110
+ ): Promise<SessionRunIDs | null> {
111
+ const stepsQuery = this.stepsQuery(candidates);
112
+ const queries = stepsQuery ? [this.runsQuery(sessionID, candidates), stepsQuery] : [this.runsQuery(sessionID, candidates)];
113
+ try {
114
+ const lookupUser = UserCache.Instance.GetSystemUser() ?? contextUser;
115
+ const [runs, steps] = await RunView.FromMetadataProvider(provider).RunViews<LookupRow>(queries, lookupUser);
116
+ const failed = [runs, steps].find((result) => result && !result.Success);
117
+ if (!runs || failed) {
118
+ LogError(`SessionRunIDVerifier: run lookup failed for session ${sessionID}: ${failed?.ErrorMessage ?? 'no result'}`);
119
+ return null;
120
+ }
121
+ return this.pickOwned(candidates, runs.Results, steps?.Results ?? []);
122
+ } catch (error) {
123
+ LogError(`SessionRunIDVerifier: run lookup failed for session ${sessionID}: ${error instanceof Error ? error.message : String(error)}`);
124
+ return null;
125
+ }
126
+ }
127
+
128
+ /** The claimed runs that carry this session's id. */
129
+ private runsQuery(sessionID: string, candidates: SessionRunIDs): RunViewParams {
130
+ const ids = [candidates.CoAgentRunID, candidates.PendingFeedbackRunID]
131
+ .filter((id): id is string => !!id)
132
+ .map((id) => `'${EscapeSQLString(id)}'`);
133
+ return {
134
+ EntityName: AGENT_RUN_ENTITY,
135
+ ExtraFilter: `AgentSessionID='${EscapeSQLString(sessionID)}' AND ID IN (${ids.join(', ')})`,
136
+ Fields: ['ID'],
137
+ ResultType: 'simple',
138
+ BypassCache: true,
139
+ };
140
+ }
141
+
142
+ /** The claimed co-agent run's steps that match the claimed step or prompt run; `null` when neither is claimed. */
143
+ private stepsQuery(candidates: SessionRunIDs): RunViewParams | null {
144
+ const matches: string[] = [];
145
+ if (candidates.CoAgentRunStepID) {
146
+ matches.push(`ID='${EscapeSQLString(candidates.CoAgentRunStepID)}'`);
147
+ }
148
+ if (candidates.PromptRunID) {
149
+ matches.push(`TargetLogID='${EscapeSQLString(candidates.PromptRunID)}'`);
150
+ }
151
+ if (!candidates.CoAgentRunID || matches.length === 0) {
152
+ return null;
153
+ }
154
+ return {
155
+ EntityName: AGENT_RUN_STEP_ENTITY,
156
+ ExtraFilter: `AgentRunID='${EscapeSQLString(candidates.CoAgentRunID)}' AND (${matches.join(' OR ')})`,
157
+ Fields: ['ID', 'StepType', 'TargetLogID'],
158
+ ResultType: 'simple',
159
+ BypassCache: true,
160
+ };
161
+ }
162
+
163
+ private pickOwned(candidates: SessionRunIDs, runs: LookupRow[], steps: LookupRow[]): SessionRunIDs {
164
+ const isOwnedRun = (id: string | undefined): boolean => !!id && runs.some((row) => UUIDsEqual(row.ID, id));
165
+ const coAgentRunID = isOwnedRun(candidates.CoAgentRunID) ? candidates.CoAgentRunID : undefined;
166
+ const ownedSteps = coAgentRunID ? steps : [];
167
+ const stepID = candidates.CoAgentRunStepID;
168
+ const promptRunID = candidates.PromptRunID;
169
+ return {
170
+ CoAgentRunID: coAgentRunID,
171
+ CoAgentRunStepID: stepID && ownedSteps.some((row) => UUIDsEqual(row.ID, stepID)) ? stepID : undefined,
172
+ PromptRunID:
173
+ promptRunID && ownedSteps.some((row) => row.StepType === 'Prompt' && UUIDsEqual(row.TargetLogID, promptRunID))
174
+ ? promptRunID
175
+ : undefined,
176
+ PendingFeedbackRunID: isOwnedRun(candidates.PendingFeedbackRunID) ? candidates.PendingFeedbackRunID : undefined,
177
+ };
178
+ }
179
+ }
@@ -32,3 +32,4 @@ export * from './remoteBrowserGoalEngine.js';
32
32
  export * from './HostInstance.js';
33
33
  export * from './SessionManager.js';
34
34
  export * from './SessionJanitor.js';
35
+ export * from './SessionRunIDVerifier.js';
@@ -0,0 +1,119 @@
1
+ import { AuthorizationInfo, AuthorizationRoleInfo, AuthorizationRoleType, IMetadataProvider, UserInfo } from '@memberjunction/core';
2
+ import { NormalizeUUID, UUIDsEqual } from '@memberjunction/global';
3
+
4
+ /** Authorizations keyed by their normalized ID. */
5
+ type AuthorizationsByID = Map<string, AuthorizationInfo>;
6
+
7
+ /** The outcome of {@link EvaluateActionRunAuthorization}. */
8
+ export interface ActionRunAuthorization {
9
+ /** True when the user may run the action. */
10
+ Allowed: boolean;
11
+ /** Why the run is allowed or refused. */
12
+ Reason: string;
13
+ }
14
+
15
+ /** One provider's authorization tree and the role grants on it. */
16
+ export type AuthorizationMetadata = Pick<IMetadataProvider, 'Authorizations' | 'AuthorizationRoles'>;
17
+
18
+ /**
19
+ * True when the user is an Owner, MJ's superuser type. The column is nchar, so the stored value is
20
+ * padded.
21
+ */
22
+ export function IsOwner(user: UserInfo): boolean {
23
+ return user.Type?.trim().toLowerCase() === 'owner';
24
+ }
25
+
26
+ /**
27
+ * Decides whether a user may run an action through a public entry point such as the `RunAction`
28
+ * mutation. It fails closed:
29
+ *
30
+ * - An Owner may run any action.
31
+ * - An action with `MJ: Action Authorizations` rows runs for a user who holds at least one of the
32
+ * linked authorizations. A linked authorization counts only when it is active and none of the
33
+ * user's roles is denied it; the user may then hold it directly or through an ancestor.
34
+ * - An action with no such rows runs for any user when its class authorizes its caller itself
35
+ * (`BaseAction.AuthorizesCaller`), so the action's own check decides. Otherwise it runs for
36
+ * Owners only.
37
+ *
38
+ * The authorizations, their parents and their role grants are read only from `metadata`, never
39
+ * from the global provider. Pass the provider the `MJ: Action Authorizations` rows were read from.
40
+ *
41
+ * Refuse scope-limited sessions before calling this; see `IsScopeLimitedPrincipal`.
42
+ *
43
+ * @param user the caller
44
+ * @param actionAuthorizationIDs the AuthorizationID of each `MJ: Action Authorizations` row for the action
45
+ * @param metadata the provider whose authorizations and role grants decide
46
+ * @param actionAuthorizesCaller true when the action's class authorizes its caller itself
47
+ * @returns whether the run is allowed, and why
48
+ */
49
+ export function EvaluateActionRunAuthorization(
50
+ user: UserInfo,
51
+ actionAuthorizationIDs: readonly string[],
52
+ metadata: AuthorizationMetadata,
53
+ actionAuthorizesCaller: boolean
54
+ ): ActionRunAuthorization {
55
+ if (IsOwner(user)) {
56
+ return { Allowed: true, Reason: 'The user is an Owner.' };
57
+ }
58
+ if (actionAuthorizationIDs.length === 0) {
59
+ return actionAuthorizesCaller
60
+ ? { Allowed: true, Reason: 'The action authorizes its caller itself.' }
61
+ : { Allowed: false, Reason: 'No Action Authorization grants this action to any role, and the action does not authorize its caller itself; only an Owner may run it.' };
62
+ }
63
+ const authorizationsByID: AuthorizationsByID = new Map(metadata.Authorizations.map((auth) => [NormalizeUUID(auth.ID), auth]));
64
+ const userGrants = grantsToUserRoles(user, metadata.AuthorizationRoles);
65
+ const granted = actionAuthorizationIDs
66
+ .map((id) => authorizationsByID.get(NormalizeUUID(id)))
67
+ .filter((auth): auth is AuthorizationInfo => auth !== undefined)
68
+ .find((auth) => holdsLinkedAuthorization(auth, authorizationsByID, userGrants));
69
+ return granted
70
+ ? { Allowed: true, Reason: `The user holds the "${granted.Name}" authorization.` }
71
+ : { Allowed: false, Reason: 'None of your roles holds an authorization this action requires.' };
72
+ }
73
+
74
+ /** The role grants that name one of the user's roles. */
75
+ function grantsToUserRoles(user: UserInfo, grants: readonly AuthorizationRoleInfo[]): AuthorizationRoleInfo[] {
76
+ const userRoleIDs = new Set((user.UserRoles ?? []).map((userRole) => NormalizeUUID(userRole.RoleID)));
77
+ return grants.filter((grant) => userRoleIDs.has(NormalizeUUID(grant.RoleID)));
78
+ }
79
+
80
+ /**
81
+ * True when the user holds a linked authorization: it is active, none of the user's roles is denied
82
+ * it, and the user holds it directly or through an ancestor. The first two are checked on the linked
83
+ * authorization itself, because the ancestor walk would otherwise grant it through the parent.
84
+ */
85
+ function holdsLinkedAuthorization(auth: AuthorizationInfo, authorizationsByID: AuthorizationsByID, userGrants: readonly AuthorizationRoleInfo[]): boolean {
86
+ if (!auth.IsActive || grantTypes(auth, userGrants).includes(AuthorizationRoleType.Deny)) {
87
+ return false;
88
+ }
89
+ return holdsDirectlyOrThroughAncestor(auth, authorizationsByID, userGrants);
90
+ }
91
+
92
+ /**
93
+ * True when the user holds the authorization or one of its ancestors, following `ParentID` within
94
+ * `authorizationsByID`. This is the walk `AuthorizationEvaluator.UserCanExecuteWithAncestors` does,
95
+ * with the role grants taken from the same provider as the authorizations.
96
+ */
97
+ function holdsDirectlyOrThroughAncestor(auth: AuthorizationInfo, authorizationsByID: AuthorizationsByID, userGrants: readonly AuthorizationRoleInfo[]): boolean {
98
+ const visited = new Set<string>();
99
+ let node: AuthorizationInfo | undefined = auth;
100
+ while (node && !visited.has(NormalizeUUID(node.ID))) {
101
+ if (holdsDirectly(node, userGrants)) {
102
+ return true;
103
+ }
104
+ visited.add(NormalizeUUID(node.ID));
105
+ node = node.ParentID ? authorizationsByID.get(NormalizeUUID(node.ParentID)) : undefined;
106
+ }
107
+ return false;
108
+ }
109
+
110
+ /** True when the authorization is active and the user's roles have an Allow and no Deny on it. */
111
+ function holdsDirectly(auth: AuthorizationInfo, userGrants: readonly AuthorizationRoleInfo[]): boolean {
112
+ const types = grantTypes(auth, userGrants);
113
+ return auth.IsActive && types.includes(AuthorizationRoleType.Allow) && !types.includes(AuthorizationRoleType.Deny);
114
+ }
115
+
116
+ /** Allow or Deny, for each of the user's role grants on the authorization. */
117
+ function grantTypes(auth: AuthorizationInfo, userGrants: readonly AuthorizationRoleInfo[]): AuthorizationRoleType[] {
118
+ return userGrants.filter((grant) => UUIDsEqual(grant.AuthorizationID, auth.ID)).map((grant) => grant.AuthorizationType());
119
+ }
package/src/auth/index.ts CHANGED
@@ -10,6 +10,7 @@ import { AuthProviderFactory } from '@memberjunction/auth-providers';
10
10
  import { initializeAuthProviders } from './initializeProviders.js';
11
11
 
12
12
  export * from './APIKeyScopeAuth.js';
13
+ export * from './scopeLimitedPrincipal.js';
13
14
 
14
15
  // This is a hard-coded forever constant due to internal migrations
15
16
 
@@ -0,0 +1,50 @@
1
+ import { UserInfo } from '@memberjunction/core';
2
+
3
+ /**
4
+ * True when a session's read authority is deliberately narrower than the role grant it
5
+ * authenticates with — i.e. the principal is confined by per-session scope rather than by
6
+ * its roles alone.
7
+ *
8
+ * Two such principals exist today, both from the magic-link surface:
9
+ *
10
+ * - **Anonymous magic-link guests** (`IsMagicLinkAnonymous`) authenticate as the *shared*
11
+ * Anonymous principal, so their role grant is common to every guest. What separates one
12
+ * guest from another is not the role — it is the per-session scope.
13
+ * - **Resource-scoped magic-link sessions** (`MagicLinkScope`) hold a normal role, pinned to
14
+ * one shared resource (and its FK-reachable dependents) for the life of the session.
15
+ *
16
+ * In both cases the confinement is expressed as row-level-security filter tokens
17
+ * (`{{ScopeResourceID}}` / `{{ScopeResourceType}}`) that the entity-read path substitutes.
18
+ * That means the confinement only exists on code paths that go **through** entity
19
+ * permissions and RLS — `RunView`, `BaseEntity`, the generated resolvers. Any path that
20
+ * reaches the database *around* those layers (raw SQL on the read-only pool, for instance)
21
+ * sees no scope at all, so such a path must refuse these principals outright rather than
22
+ * assume a filter it never applies.
23
+ *
24
+ * Callers should treat a `true` result as "not permitted here", never as "apply a narrower
25
+ * filter" — a caller that could narrow correctly would not need this predicate.
26
+ *
27
+ * @param user the principal to classify. A missing principal returns `true`: an
28
+ * unidentifiable session is the one case where the scope certainly cannot be
29
+ * established, so it fails closed.
30
+ * @returns `true` when the principal is scope-limited (or unidentifiable), else `false`.
31
+ *
32
+ * @example
33
+ * ```typescript
34
+ * if (IsScopeLimitedPrincipal(context.userPayload?.userRecord)) {
35
+ * return this.buildErrorResult('Not permitted for scope-limited sessions.');
36
+ * }
37
+ * ```
38
+ */
39
+ export function IsScopeLimitedPrincipal(user: UserInfo | undefined | null): boolean {
40
+ if (!user) {
41
+ return true; // no identifiable principal — fail closed
42
+ }
43
+ if (user.IsMagicLinkAnonymous) {
44
+ return true;
45
+ }
46
+ const scope = user.MagicLinkScope;
47
+ // An empty scope object carries no confinement; only a populated one does. (Truthiness
48
+ // of the object alone would classify `{}` as scoped and lock out a session that is not.)
49
+ return !!(scope?.ResourceID || scope?.ResourceType);
50
+ }
package/src/config.ts CHANGED
@@ -198,6 +198,29 @@ const cacheSettingsSchema = z.object({
198
198
  evictionSweepIntervalSeconds: z.number().optional().default(300),
199
199
  /** Enable verbose cache logging (hits, misses, evictions). Default: false. */
200
200
  verboseLogging: z.boolean().optional().default(false),
201
+ /**
202
+ * Entity names whose FULL ROW may ride along with a cache-invalidation broadcast.
203
+ *
204
+ * The cache-invalidation subscription is delivered to EVERY connected client with no per-user
205
+ * filter (see CacheInvalidationResolver), so any row named here is disclosed to every signed-in
206
+ * session, whatever row-level security or tenant scoping would otherwise apply to reading it.
207
+ *
208
+ * Defaults to `[]`: invalidation still carries the entity name and primary key, which is all a
209
+ * client needs to evict, and the client re-fetches through the normal read path where access
210
+ * control applies. Listing an entity re-enables the apply-in-place optimisation for it — correct
211
+ * only for reference data every signed-in user is allowed to read.
212
+ *
213
+ * What the re-fetch costs depends on the consumer. `ConversationEngine` re-reads the ONE record
214
+ * by primary key. `BaseEngine` (every engine subclass with `AutoRefresh`, the default) applies a
215
+ * remote save in place only when the row is present, so without it a remote save falls through to
216
+ * a full reload of each matching config — a `RunView` of that entity, not a keyed read. Remote
217
+ * deletes still apply in place from the primary key. Engine-cached reference entities that every
218
+ * signed-in user may read are the ones worth listing here.
219
+ *
220
+ * `['*']` opts every entity in, restoring the previous behaviour. Only safe on a deployment where
221
+ * every signed-in user may read every row of every entity.
222
+ */
223
+ recordDataBroadcastEntities: z.array(z.string()).optional().default([]),
201
224
  });
202
225
 
203
226
  const loggingSettingsSchema = z.object({
@@ -623,7 +646,17 @@ export const DEFAULT_SERVER_CONFIG: Partial<ConfigInfo> = {
623
646
  autoCreateNewUsers: true,
624
647
  newUserLimitedToAuthorizedDomains: false,
625
648
  newUserAuthorizedDomains: [],
626
- newUserRoles: ['UI', 'Developer'],
649
+ // 'UI' ONLY, deliberately (issue #4260). Auto-provisioning is on by default above, with no
650
+ // domain restriction, so this list is the standing authority of anyone the configured IdP will
651
+ // issue a token for. On the baseline seed 'Developer' and 'Integration' hold unfiltered
652
+ // CanUpdate on ~439 of the database's ~446 entities, so defaulting every such identity into
653
+ // either grants broad data-plane access no host should hand out by default. (The MJ: Users
654
+ // escalation this list also used to guard against — writing your own Type to 'Owner' — is now
655
+ // closed at the entity layer regardless of role: see MJUserEntityServer in
656
+ // @memberjunction/core-entities-server.) 'UI' carries the end-user surface (conversations,
657
+ // views, dashboards, settings) and no write on MJ: Users. Hosts that need more grant it
658
+ // per-deployment.
659
+ newUserRoles: ['UI'],
627
660
  updateCacheWhenNotFound: true,
628
661
  updateCacheWhenNotFoundDelay: 5000,
629
662
  contextUserForNewUserCreation: 'not.set@nowhere.com',
package/src/context.ts CHANGED
@@ -8,7 +8,8 @@ import sql from 'mssql';
8
8
  import { getSigningKeys, getSystemUser, getValidationOptions, verifyUserRecord, extractUserInfoFromPayload } from './auth/index.js';
9
9
  import { TokenExpiredError, AuthProviderFactory } from '@memberjunction/auth-providers';
10
10
  import { authCache } from './cache.js';
11
- import { userEmailMap, apiKey, mj_core_schema } from './config.js';
11
+ import { userEmailMap, apiKey, mj_core_schema, configInfo } from './config.js';
12
+ import { BuildPostgreSQLConnectionConfig, PostgreSQLReadOnlyPool, ResolvePostgreSQLEndpoint, ResolvePostgreSQLReadOnlyCredentials } from './postgresqlPoolSettings.js';
12
13
  import { buildBoundaryLogPayload } from './logging/boundaryLogPayload.js';
13
14
  import { StartupLogger } from './logging/StartupLogger.js';
14
15
  import { DataSourceInfo, UserPayload } from './types.js';
@@ -748,36 +749,35 @@ async function createPerRequestProviders(
748
749
  { provider: p, type: 'Read-Write' }
749
750
  ];
750
751
 
751
- if (isPostgres) {
752
- const rp = await tryCreateReadOnlyPostgresProvider();
753
- if (rp) {
754
- providers.push({ provider: rp, type: 'Read-Only' });
755
- }
756
- } else {
757
- const rp = await tryCreateReadOnlyProvider(dataSources);
758
- if (rp) {
759
- providers.push({ provider: rp, type: 'Read-Only' });
760
- }
752
+ const rp = await CreateReadOnlyProvider(dataSources);
753
+ if (rp) {
754
+ providers.push({ provider: rp, type: 'Read-Only' });
761
755
  }
762
756
 
763
757
  return providers;
764
758
  }
765
759
 
760
+ /**
761
+ * Opens a provider on the read-only database login, for the current platform. Resolves null when no
762
+ * read-only login is configured; it never falls back to the read-write pool.
763
+ * @param dataSources the data sources MJAPI opened at startup
764
+ */
765
+ export async function CreateReadOnlyProvider(dataSources: DataSourceInfo[]): Promise<DatabaseProviderBase | null> {
766
+ return resolveDbPlatformFromEnv() === 'postgresql'
767
+ ? tryCreateReadOnlyPostgresProvider()
768
+ : tryCreateReadOnlyProvider(dataSources);
769
+ }
770
+
766
771
  /**
767
772
  * Creates a PostgreSQL per-request provider, sharing the connection pool
768
773
  * from the primary provider to avoid pool exhaustion.
769
774
  */
770
775
  async function createPostgresProvider(): Promise<DatabaseProviderBase> {
771
776
  const { PostgreSQLDataProvider, PostgreSQLProviderConfigData } = await import('@memberjunction/postgresql-dataprovider');
772
- const pgHost = process.env.PG_HOST || process.env.DB_HOST || 'localhost';
773
- const pgPort = parseInt(process.env.PG_PORT || process.env.DB_PORT || '5432', 10);
774
- const pgUser = process.env.PG_USERNAME || process.env.DB_USERNAME || 'postgres';
775
- const pgPass = process.env.PG_PASSWORD || process.env.DB_PASSWORD || '';
776
- const pgDatabase = process.env.PG_DATABASE || process.env.DB_DATABASE || '';
777
777
 
778
778
  const pgProvider = new PostgreSQLDataProvider();
779
779
  const pgConfig = new PostgreSQLProviderConfigData(
780
- { Host: pgHost, Port: pgPort, Database: pgDatabase, User: pgUser, Password: pgPass },
780
+ BuildPostgreSQLConnectionConfig(ResolvePostgreSQLEndpoint(), configInfo.databaseSettings, 'api'),
781
781
  mj_core_schema,
782
782
  0,
783
783
  undefined,
@@ -797,39 +797,30 @@ async function createPostgresProvider(): Promise<DatabaseProviderBase> {
797
797
  }
798
798
 
799
799
  /**
800
- * Attempts to create a read-only PostgreSQL provider using DB_READ_ONLY_USERNAME/PASSWORD.
801
- * Shares the connection pool from the primary provider. Returns null if no read-only credentials are configured.
800
+ * Creates a read-only PostgreSQL per-request provider on the read-only pool MJAPI opened at
801
+ * startup with the read-only login. Returns null when no read-only login is configured, so the
802
+ * caller never falls back to the read-write pool.
802
803
  */
803
804
  async function tryCreateReadOnlyPostgresProvider(): Promise<DatabaseProviderBase | null> {
804
- const roUser = process.env.PG_READ_ONLY_USERNAME || process.env.DB_READ_ONLY_USERNAME;
805
- const roPass = process.env.PG_READ_ONLY_PASSWORD || process.env.DB_READ_ONLY_PASSWORD;
806
- if (!roUser || !roPass) {
805
+ const readOnlyPool = PostgreSQLReadOnlyPool.Instance.Pool;
806
+ const credentials = ResolvePostgreSQLReadOnlyCredentials(configInfo);
807
+ if (!readOnlyPool || !credentials) {
807
808
  return null;
808
809
  }
809
810
 
810
811
  try {
811
812
  const { PostgreSQLDataProvider, PostgreSQLProviderConfigData } = await import('@memberjunction/postgresql-dataprovider');
812
- const pgHost = process.env.PG_HOST || process.env.DB_HOST || 'localhost';
813
- const pgPort = parseInt(process.env.PG_PORT || process.env.DB_PORT || '5432', 10);
814
- const pgDatabase = process.env.PG_DATABASE || process.env.DB_DATABASE || '';
815
813
 
816
814
  const roProvider = new PostgreSQLDataProvider();
817
815
  const roConfig = new PostgreSQLProviderConfigData(
818
- { Host: pgHost, Port: pgPort, Database: pgDatabase, User: roUser, Password: roPass },
816
+ BuildPostgreSQLConnectionConfig({ ...ResolvePostgreSQLEndpoint(), ...credentials }, configInfo.databaseSettings, 'read-only'),
819
817
  mj_core_schema,
820
818
  0,
821
819
  undefined,
822
820
  undefined,
823
821
  false,
824
822
  );
825
-
826
- const primaryProvider = Metadata.Provider as unknown as { DatabaseConnection?: import('pg').Pool }; // global-provider-ok: bootstrap (share primary provider's PG pool with the read-only provider)
827
- if (primaryProvider?.DatabaseConnection) {
828
- await roProvider.ConfigWithSharedPool(roConfig, primaryProvider.DatabaseConnection);
829
- } else {
830
- await roProvider.Config(roConfig);
831
- }
832
-
823
+ await roProvider.ConfigWithSharedPool(roConfig, readOnlyPool);
833
824
  return roProvider;
834
825
  } catch (_err) {
835
826
  return null;