@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
@@ -1,15 +1,27 @@
1
1
  import { BaseEntity, BaseEntityEvent, CompositeKey, EntityFieldTSType, LogDebug, LogError, LogStatus, Metadata, } from '@memberjunction/core';
2
2
  import { UserCache } from '@memberjunction/sqlserver-dataprovider';
3
+ import { ClientClauseScreen } from '@memberjunction/generic-database-provider';
3
4
  import { AuthorizationError } from 'type-graphql';
4
5
  import { GraphQLError } from 'graphql';
5
6
  import { GetAPIKeyEngine } from '@memberjunction/api-keys';
6
7
  import { httpTransport, CloudEvent, emitterFor } from 'cloudevents';
7
- import { MJEventType, MJGlobal, ENCRYPTED_SENTINEL, IsValueEncrypted, IsOnlyTimezoneShift } from '@memberjunction/global';
8
+ import { MJEventType, MJGlobal, ENCRYPTED_SENTINEL, EscapeSQLString, IsValueEncrypted, IsOnlyTimezoneShift } from '@memberjunction/global';
9
+ import { SQLParser, IsBalancedSQLFragment, LexSQL } from '@memberjunction/sql-parser';
8
10
  import { EncryptionEngine } from '@memberjunction/encryption';
9
- import { PUSH_STATUS_UPDATES_TOPIC } from './PushStatusResolver.js';
10
- import { CACHE_INVALIDATION_TOPIC } from './CacheInvalidationResolver.js';
11
+ import { publishStatusUpdate } from './PushStatusResolver.js';
12
+ import { CACHE_INVALIDATION_TOPIC, MayBroadcastRecordData } from './CacheInvalidationResolver.js';
11
13
  import { PubSubManager } from './PubSubManager.js';
12
14
  import { FieldMapper } from '@memberjunction/graphql-dataprovider';
15
+ /**
16
+ * Entities whose update is always hydrated from the stored row, never from the client's `OldValues___`, though they
17
+ * do not track record changes: their server-side write rules compare against the prior state, which a client could
18
+ * otherwise supply itself (lower-case names).
19
+ *
20
+ * `MJ: Data Context Items`: its server class lets only an administrator save an item that holds SQL, and decides
21
+ * that from the item's `Type` and `SQL`. Hydrated from OldValues, a client could leave both out, and the stored
22
+ * procedure would keep the stored SQL item while the rule saw none.
23
+ */
24
+ const ENTITIES_THAT_ALWAYS_LOAD_STORED_ROW = new Set(['mj: data context items']);
13
25
  export class ResolverBase {
14
26
  static { this._emit = process.env.CLOUDEVENTS_HTTP_TRANSPORT ? emitterFor(httpTransport(process.env.CLOUDEVENTS_HTTP_TRANSPORT)) : null; }
15
27
  static { this._cloudeventsHeaders = process.env.CLOUDEVENTS_HTTP_HEADERS ? JSON.parse(process.env.CLOUDEVENTS_HTTP_HEADERS) : {}; }
@@ -213,9 +225,11 @@ export class ResolverBase {
213
225
  * @param dataObject - The data object containing field values
214
226
  * @param encryptionEngine - Optional encryption engine for re-encryption (lazy loaded if needed)
215
227
  * @param contextUser - User context for encryption operations
228
+ * @param fieldKey - What the object's keys are: field CodeNames (GraphQL transport rows, the
229
+ * default) or field Names (raw provider rows, such as dataset item results)
216
230
  * @returns The filtered data object
217
231
  */
218
- async FilterEncryptedFieldsForAPI(entityName, dataObject, contextUser, provider) {
232
+ async FilterEncryptedFieldsForAPI(entityName, dataObject, contextUser, provider, fieldKey = 'CodeName') {
219
233
  if (!dataObject)
220
234
  return dataObject;
221
235
  const md = provider ?? new Metadata();
@@ -228,7 +242,7 @@ export class ResolverBase {
228
242
  return dataObject;
229
243
  // Process each encrypted field
230
244
  for (const field of encryptedFields) {
231
- const fieldName = field.CodeName;
245
+ const fieldName = fieldKey === 'Name' ? field.Name : field.CodeName;
232
246
  const value = dataObject[fieldName];
233
247
  // Skip null/undefined values
234
248
  if (value === null || value === undefined)
@@ -264,9 +278,10 @@ export class ResolverBase {
264
278
  return dataObject;
265
279
  }
266
280
  /**
267
- * Filters encrypted fields for an array of data objects
281
+ * Filters encrypted fields for an array of data objects. `fieldKey` is as for
282
+ * {@link FilterEncryptedFieldsForAPI}.
268
283
  */
269
- async ArrayFilterEncryptedFieldsForAPI(entityName, dataObjectArray, contextUser, provider) {
284
+ async ArrayFilterEncryptedFieldsForAPI(entityName, dataObjectArray, contextUser, provider, fieldKey = 'CodeName') {
270
285
  if (!dataObjectArray || dataObjectArray.length === 0)
271
286
  return dataObjectArray;
272
287
  // Check if entity has any encrypted fields first to avoid unnecessary processing
@@ -279,10 +294,56 @@ export class ResolverBase {
279
294
  return dataObjectArray;
280
295
  // Process each element
281
296
  for (const element of dataObjectArray) {
282
- await this.FilterEncryptedFieldsForAPI(entityName, element, contextUser, provider);
297
+ await this.FilterEncryptedFieldsForAPI(entityName, element, contextUser, provider, fieldKey);
283
298
  }
284
299
  return dataObjectArray;
285
300
  }
301
+ /**
302
+ * Renders one `findBy` predicate value as the SQL literal text it will become inside the
303
+ * `ExtraFilter` that method builds.
304
+ *
305
+ * The two slots have different exposure, so they are handled differently:
306
+ *
307
+ * - **Quoted slot** (`NeedsQuotes` — every type except Number and Boolean). The value sits
308
+ * inside a single-quoted literal, so it is escaped with `EscapeSQLString`: quotes are
309
+ * doubled and null bytes stripped, leaving the payload inert *inside* the literal.
310
+ * - **Unquoted slot** (Number and Boolean fields). There is no literal to break out of
311
+ * here — the value lands in the clause bare, so a string value simply *is* SQL and no
312
+ * amount of quote escaping helps. Only a genuine number or boolean is accepted; anything
313
+ * else is refused rather than interpolated. Numeric and boolean strings are accepted too
314
+ * (a client may legitimately send `"42"`), since neither can carry SQL.
315
+ *
316
+ * A `null`/`undefined` value is refused in either slot. `EscapeSQLString` maps both to `''`
317
+ * by design, which would silently turn a by-value lookup into `Field = ''` — for this
318
+ * caller a missing value is a bug, not a query.
319
+ *
320
+ * @param value the raw value supplied for the field
321
+ * @param fieldName field name, for the error message
322
+ * @param entity entity name, for the error message
323
+ * @param needsQuotes whether the field's type requires a quoted literal
324
+ * @returns the literal text (including quotes when the slot is quoted)
325
+ * @throws when the value cannot be rendered safely for the slot it is destined for
326
+ */
327
+ formatFilterValue(value, fieldName, entity, needsQuotes) {
328
+ if (value === null || value === undefined) {
329
+ throw new Error(`Field ${fieldName} in entity ${entity} was given a ${value === null ? 'null' : 'undefined'} value. findBy builds an equality predicate, which needs a value to compare against.`);
330
+ }
331
+ if (needsQuotes) {
332
+ return `'${EscapeSQLString(String(value))}'`;
333
+ }
334
+ // Unquoted slot — accept only what cannot carry SQL, and render it exactly as before.
335
+ if (typeof value === 'boolean' || (typeof value === 'number' && Number.isFinite(value))) {
336
+ return String(value);
337
+ }
338
+ if (typeof value === 'string') {
339
+ const trimmed = value.trim();
340
+ if (/^-?\d+(\.\d+)?$/.test(trimmed))
341
+ return trimmed;
342
+ if (/^(true|false)$/i.test(trimmed))
343
+ return trimmed.toLowerCase();
344
+ }
345
+ throw new Error(`Field ${fieldName} in entity ${entity} is a numeric or boolean field, so its value is not quoted in the filter — only a number or boolean can be used. Received: ${typeof value}.`);
346
+ }
286
347
  async findBy(provider, entity, params, contextUser) {
287
348
  // build the SQL query based on the params passed in
288
349
  const rv = provider;
@@ -299,8 +360,7 @@ export class ResolverBase {
299
360
  const field = e.Fields.find((f) => f.Name === k);
300
361
  if (!field)
301
362
  throw new Error(`Field ${k} not found in entity ${entity}`);
302
- const quotes = field.NeedsQuotes ? "'" : '';
303
- extraFilter += `${k} = ${quotes}${params[k]}${quotes}`;
363
+ extraFilter += `${k} = ${this.formatFilterValue(params[k], k, entity, field.NeedsQuotes)}`;
304
364
  });
305
365
  // ok, now we have a SQL string, run it and return the results
306
366
  // use the SQLServerDataProvider
@@ -324,7 +384,8 @@ export class ResolverBase {
324
384
  const rv = provider;
325
385
  const result = await rv.RunView({
326
386
  EntityName: 'MJ: User Views',
327
- ExtraFilter: "Name='" + viewInput.ViewName + "'",
387
+ // SECURITY: the view name is client-supplied and lands inside a SQL literal.
388
+ ExtraFilter: "Name='" + EscapeSQLString(viewInput.ViewName) + "'",
328
389
  }, userPayload.userRecord);
329
390
  if (result && result.Success && result.Results.length > 0) {
330
391
  const viewInfo = result.Results[0];
@@ -573,6 +634,158 @@ export class ResolverBase {
573
634
  `Denial reason: ${result.Reason}`);
574
635
  }
575
636
  }
637
+ /**
638
+ * SECURITY — GraphQL-boundary screen for one client-supplied SQL clause fragment.
639
+ *
640
+ * `ValidateUserProvidedSQLClause` still blocks stacked statements, DML, comments, UNION
641
+ * and WAITFOR, and still permits SELECT (server-internal engines pass richer filters).
642
+ * A keyword ban on SELECT/EXISTS at this boundary (#4253) broke first-party clients that
643
+ * use `IN (SELECT … FROM <entity base view>)` — that is a legitimate ExtraFilter.
644
+ *
645
+ * This screen uses `@memberjunction/sql-parser` (same wrap as EDS `assertReadOnlyClause`):
646
+ * wrap the fragment as a single SELECT, fail closed if it does not parse as a read, then
647
+ * allow a FROM only when it is an entity **BaseView**. Base tables (`Meeting`, `__mj.User`)
648
+ * and catalogs are rejected. Server-internal RunView callers get only the provider's
649
+ * statement-separator check, which cannot refuse a legitimate filter.
650
+ *
651
+ * The screen is `ClientClauseScreen` in `@memberjunction/generic-database-provider`, shared
652
+ * with the REST view routes, ad-hoc list sources, and the provider's saved-view checks.
653
+ *
654
+ * Before that parse, the fragment must be balanced on its own (`IsBalancedSQLFragment`). The
655
+ * provider and the tenant hook splice it inside parentheses and AND their own filters after
656
+ * it, so a fragment that closes those parentheses (`1=1) OR (1=1`) would leave row-level
657
+ * security and tenant scope on one side of an OR the caller controls.
658
+ * The parse wraps the fragment in parentheses too, which is why it cannot catch this alone.
659
+ * A backslash inside a quoted string or identifier is refused as well: the parser reads `\'`
660
+ * inside a plain literal as an escaped quote, but SQL Server and PostgreSQL end the literal
661
+ * there, so the text after it could be code that the parse-based checks never saw.
662
+ *
663
+ * RLS is applied by RunView as an outer WHERE around the entity being queried, not compiled
664
+ * into the view, and RunView removes denied fields from its own result only. A subquery reads a
665
+ * base view directly with neither, so the screen refuses one over any entity the user reads under
666
+ * a row filter or with denied fields (`ClientClauseScreen.AssertUnscopedRead`), the queried
667
+ * entity included.
668
+ */
669
+ assertClientClauseUsesEntityBaseViews(clause, label, provider, user, options) {
670
+ if (!clause?.trim())
671
+ return;
672
+ // The balance and backslash checks run before the shared screen: it parses the fragment
673
+ // inside its own parentheses, so it cannot see a fragment that closes the parentheses the
674
+ // provider and the tenant hook splice it into.
675
+ const dialect = this.dialectForProvider(provider);
676
+ if (SQLParser.HasStackedStatements(clause, dialect)) {
677
+ throw new Error(`Invalid ${label}: multiple statements are not permitted in client-supplied filters`);
678
+ }
679
+ const balance = IsBalancedSQLFragment(clause, dialect);
680
+ if (!balance.IsBalanced) {
681
+ throw new Error(`Invalid ${label}: ${balance.Reason}`);
682
+ }
683
+ if (this.hasBackslashInQuotes(clause, dialect)) {
684
+ throw new Error(`Invalid ${label}: a backslash inside a quoted string or identifier is not permitted in client-supplied filters`);
685
+ }
686
+ ClientClauseScreen.AssertClauseUsesEntityBaseViews(clause, label, provider?.Entities ?? [], dialect, user, options);
687
+ }
688
+ /**
689
+ * SECURITY — screen for a FULL client-supplied SQL statement (ad-hoc query path). In addition
690
+ * to the SELECT-only validation the caller performs, every table reference must resolve to an
691
+ * entity BaseView the acting user can read UNSCOPED. A reference is excluded only when it reads a
692
+ * CTE that is in scope there (`StatementRelation.IsCTEReference`): unqualified, declared by its
693
+ * own SELECT or an enclosing one, and matched by the dialect's identifier rules. A name reserved
694
+ * for the SQL parser is refused before parsing (`ClientClauseScreen.AssertNoIdentifierAliasNames`).
695
+ * Fails closed: no user, or an unresolvable reference, refuses.
696
+ *
697
+ * The references come from a walk of the whole parse tree
698
+ * (`ClientClauseScreen.CollectStatementReads`). The statement is refused when that walk does not
699
+ * account for every SELECT in the text, or when a FROM item names no table (a table function or
700
+ * a row list).
701
+ *
702
+ * Entity CanRead is not unscoped base-view read authority: RunView narrows a granted read with
703
+ * row-level security, and raw SQL does not apply it. Rather than try to rewrite arbitrary SQL
704
+ * (joins, CTEs) to compose that policy, this refuses any entity that carries a row filter for
705
+ * the caller — those reads belong on RunView. For
706
+ * the remaining entities CanRead genuinely means "every row and field of the view".
707
+ */
708
+ assertFullQueryUsesReadableEntityViews(sqlText, provider, user, label = 'SQL') {
709
+ if (!user) {
710
+ throw new Error(`Invalid ${label}: no acting user resolved for ad-hoc SQL — refusing`);
711
+ }
712
+ const dialect = this.dialectForProvider(provider);
713
+ ClientClauseScreen.AssertNoIdentifierAliasNames(sqlText, dialect, label);
714
+ const parser = new SQLParser(sqlText, dialect);
715
+ if (!parser.IsValid || parser.HasWriteStatement || parser.StatementKind !== 'select') {
716
+ throw new Error(`Invalid ${label}: not a safe read-only statement — refusing under uncertainty`);
717
+ }
718
+ if (this.astContainsWriteNode(parser.AST)) {
719
+ throw new Error(`Invalid ${label}: write/DDL nested in a subquery is not permitted`);
720
+ }
721
+ const reads = ClientClauseScreen.CollectStatementReads(parser);
722
+ ClientClauseScreen.AssertEverySelectAccountedFor(sqlText, dialect, reads, label);
723
+ const allowed = this.entityBaseViewAllowList(provider);
724
+ for (const relation of reads.Relations) {
725
+ if (relation.IsCTEReference)
726
+ continue;
727
+ const entity = ClientClauseScreen.AssertRelationReadable(allowed, relation, label, user);
728
+ if (entity)
729
+ this.assertUnscopedRead(entity, user, label);
730
+ }
731
+ }
732
+ /** Refuses an entity whose read is narrowed for this user; see `ClientClauseScreen.AssertUnscopedRead`. */
733
+ assertUnscopedRead(entity, user, label) {
734
+ ClientClauseScreen.AssertUnscopedRead(entity, user, label);
735
+ }
736
+ /**
737
+ * Applies {@link assertClientClauseUsesEntityBaseViews} to every client-supplied clause
738
+ * a view request can carry that is actually a SQL fragment. GraphQL entry points
739
+ * (RunViewByName, RunViewByID, RunDynamicView, RunViews) funnel through
740
+ * RunViewGenericInternal / RunViewsGenericInternal.
741
+ *
742
+ * 🚨 `UserSearchString` is deliberately NOT screened here (#4392). It is not a clause — it is
743
+ * the free text a person typed into a search box, and it never reaches SQL as a fragment.
744
+ * `GenericDatabaseProvider.createViewUserSearchSQL` builds the predicate itself from
745
+ * `IncludeInUserSearchAPI` metadata and lands the text only as a literal, doubling single
746
+ * quotes and escaping LIKE metacharacters with an explicit `ESCAPE`. Running it through a
747
+ * screen that parses its argument as SQL and fails closed rejected every term that is not
748
+ * coincidentally valid SQL: `Marcus Chen` parses as nothing, `O'Leary` as an unterminated
749
+ * literal — so essentially every real name search returned 0 rows.
750
+ *
751
+ * The provider-level denylist does NOT back this up — it was removed from `UserSearchString`
752
+ * in the same change, for the same reason (it refused `Union Pacific`). What protects the value
753
+ * is that it is never SQL: it reaches the database only as a quoted, quote-doubled literal.
754
+ * The single place that is not true is a field carrying `UserSearchParamFormatAPI`, whose
755
+ * admin-authored format may splice the term in unquoted; `createViewUserSearchSQL` re-applies
756
+ * `ValidateUserProvidedSQLClause` for exactly those entities.
757
+ *
758
+ * Each `Aggregates[].expression` gets the same screen, except that text the parser cannot read
759
+ * goes on to the provider, which refuses it per aggregate. The provider never runs the caller's
760
+ * aggregate text: it runs the SQL that `CheckAggregateExpression` rebuilds from a single
761
+ * aggregate call over the entity's columns, and refuses everything else per aggregate.
762
+ */
763
+ screenClientViewClauses(clauses, provider, user) {
764
+ this.assertClientClauseUsesEntityBaseViews(clauses.extraFilter, 'ExtraFilter', provider, user);
765
+ this.assertClientClauseUsesEntityBaseViews(clauses.orderBy, 'OrderBy', provider, user);
766
+ this.assertClientClauseUsesEntityBaseViews(clauses.overrideExcludeFilter, 'OverrideExcludeFilter', provider, user);
767
+ for (const aggregate of clauses.aggregates ?? []) {
768
+ this.assertClientClauseUsesEntityBaseViews(aggregate?.expression, 'Aggregate', provider, user, { LeaveUnparseableToProvider: true });
769
+ }
770
+ }
771
+ /**
772
+ * True when a string literal or quoted identifier in `clause`, read the way the database reads
773
+ * it, contains a backslash (see {@link assertClientClauseUsesEntityBaseViews}).
774
+ */
775
+ hasBackslashInQuotes(clause, dialect) {
776
+ return LexSQL(clause, dialect).some((t) => (t.Kind === 'string' || t.Kind === 'identifier') && t.Text.includes('\\'));
777
+ }
778
+ /** Write/DDL node walk shared with the clause screen; see `ClientClauseScreen.AstContainsWriteNode`. */
779
+ astContainsWriteNode(node) {
780
+ return ClientClauseScreen.AstContainsWriteNode(node);
781
+ }
782
+ dialectForProvider(provider) {
783
+ return ClientClauseScreen.DialectFor(provider);
784
+ }
785
+ /** The entity base-view allow-list for a provider's entities. */
786
+ entityBaseViewAllowList(provider) {
787
+ return ClientClauseScreen.BuildEntityBaseViewAllowList(provider?.Entities ?? []);
788
+ }
576
789
  /**
577
790
  * Optimized RunViewGenericInternal implementation with:
578
791
  * - Field filtering at source (Fix #7)
@@ -582,9 +795,6 @@ export class ResolverBase {
582
795
  try {
583
796
  if (!viewInfo || !userPayload)
584
797
  return null;
585
- // Check API key scope authorization for view operations
586
- await this.CheckAPIKeyScopeAuthorization('view:run', viewInfo.Entity, userPayload);
587
- const md = provider;
588
798
  // Prefer the authenticated session's payload user — it is the authoritative per-request
589
799
  // identity and (for magic-link sessions) carries the per-session resource scope / synthesized
590
800
  // roles that drive RLS. The cached lookup is a fallback for paths where the payload user
@@ -593,6 +803,13 @@ export class ResolverBase {
593
803
  ?? UserCache.Users.find((u) => u.Email.toLowerCase().trim() === userPayload?.email.toLowerCase().trim());
594
804
  if (!user)
595
805
  throw new Error(`User ${userPayload?.email} not found in metadata`);
806
+ // SECURITY: GraphQL ExtraFilter may contain IN (SELECT … FROM <base view>).
807
+ // Screen at this boundary: parse, reject writes, allow only entity BaseViews the
808
+ // acting user can read (subqueries do not inherit the top entity's permission check).
809
+ this.screenClientViewClauses({ extraFilter, orderBy, userSearchString, overrideExcludeFilter, aggregates }, provider, user);
810
+ // Check API key scope authorization for view operations
811
+ await this.CheckAPIKeyScopeAuthorization('view:run', viewInfo.Entity, userPayload);
812
+ const md = provider;
596
813
  const entityInfo = md.Entities.find((e) => e.Name === viewInfo.Entity);
597
814
  if (!entityInfo)
598
815
  throw new Error(`Entity ${viewInfo.Entity} not found in metadata`);
@@ -718,6 +935,14 @@ export class ResolverBase {
718
935
  md = params[0].provider;
719
936
  // Transform parameters
720
937
  for (const param of params) {
938
+ // SECURITY: same GraphQL-boundary BaseView screen as RunViewGenericInternal.
939
+ this.screenClientViewClauses({
940
+ extraFilter: param.extraFilter,
941
+ orderBy: param.orderBy,
942
+ userSearchString: param.userSearchString,
943
+ overrideExcludeFilter: param.overrideExcludeFilter,
944
+ aggregates: param.aggregates,
945
+ }, md, contextUser ?? undefined);
721
946
  if (param.viewInfo) {
722
947
  // Validate entity only once per entity type
723
948
  const entityName = param.viewInfo.Entity;
@@ -889,16 +1114,38 @@ export class ResolverBase {
889
1114
  * Publishes a CACHE_INVALIDATION event to connected browser clients after a successful
890
1115
  * entity save or delete. Includes the originSessionId so the originating browser can
891
1116
  * skip redundant re-fetches (it already handled the event locally).
1117
+ *
1118
+ * The row itself rides along ONLY for entities opted in via
1119
+ * `cacheSettings.recordDataBroadcastEntities` — this event reaches every connected client
1120
+ * unfiltered, so the row would otherwise be readable by sessions that could not read the record.
892
1121
  */
893
1122
  PublishCacheInvalidation(entityObject, action, userPayload) {
1123
+ const entityName = entityObject.EntityInfo.Name;
894
1124
  PubSubManager.Instance.Publish(CACHE_INVALIDATION_TOPIC, {
895
- entityName: entityObject.EntityInfo.Name,
1125
+ entityName,
896
1126
  primaryKeyValues: JSON.stringify(entityObject.PrimaryKey.KeyValuePairs),
897
1127
  action,
898
1128
  sourceServerId: MJGlobal.Instance.ProcessUUID,
899
1129
  timestamp: new Date(),
900
1130
  originSessionId: userPayload?.sessionId || null,
901
- recordData: action === 'save' ? JSON.stringify(entityObject.GetAll()) : undefined,
1131
+ recordData: action === 'save' && MayBroadcastRecordData(entityName)
1132
+ ? JSON.stringify(entityObject.GetAll())
1133
+ : undefined,
1134
+ });
1135
+ }
1136
+ /**
1137
+ * Publishes a push-status update to the client on {@link PUSH_STATUS_UPDATES_TOPIC}, stamping the
1138
+ * authenticated owner's user ID from `userPayload` so the subscription filter can bind delivery
1139
+ * to identity (see B49 / `statusUpdatesFilter`). The ergonomic wrapper every resolver should use
1140
+ * instead of calling `pubSub.publish` on the topic directly — it makes omitting identity
1141
+ * impossible. Non-resolver publishers (services, the liveness heartbeat) call the shared
1142
+ * `publishStatusUpdate()` function directly with an explicit `ownerUserId`.
1143
+ */
1144
+ PublishStatusUpdate(pubSub, sessionId, message, userPayload) {
1145
+ publishStatusUpdate(pubSub, {
1146
+ sessionId,
1147
+ ownerUserId: userPayload?.userRecord?.ID ?? '',
1148
+ message,
902
1149
  });
903
1150
  }
904
1151
  ListenForEntityMessages(entityObject, pubSub, userPayload) {
@@ -926,16 +1173,13 @@ export class ResolverBase {
926
1173
  const baseEntityEvent = event.args;
927
1174
  // message from our entity object, relay it to the client
928
1175
  LogDebug('ResolverBase.ListenForEntityMessages: About to publish PUSH_STATUS_UPDATES_TOPIC');
929
- pubSub.publish(PUSH_STATUS_UPDATES_TOPIC, {
930
- message: JSON.stringify({
931
- status: 'OK',
932
- type: 'EntityObjectStatusMessage',
933
- entityName: baseEntityEvent.baseEntity.EntityInfo.Name,
934
- primaryKey: baseEntityEvent.baseEntity.PrimaryKey,
935
- message: event.args.payload,
936
- }),
937
- sessionId: userPayload.sessionId,
938
- });
1176
+ this.PublishStatusUpdate(pubSub, userPayload.sessionId, JSON.stringify({
1177
+ status: 'OK',
1178
+ type: 'EntityObjectStatusMessage',
1179
+ entityName: baseEntityEvent.baseEntity.EntityInfo.Name,
1180
+ primaryKey: baseEntityEvent.baseEntity.PrimaryKey,
1181
+ message: event.args.payload,
1182
+ }), userPayload);
939
1183
  }
940
1184
  }
941
1185
  });
@@ -1004,8 +1248,9 @@ export class ResolverBase {
1004
1248
  clientNewValues[key] = input[key];
1005
1249
  }
1006
1250
  });
1007
- if (entityInfo.TrackRecordChanges || !input.OldValues___) {
1008
- // We get here because EITHER the entity tracks record changes OR the client did not provide OldValues, so we need to load the old values from the DB
1251
+ if (entityInfo.TrackRecordChanges || !input.OldValues___ || ENTITIES_THAT_ALWAYS_LOAD_STORED_ROW.has(entityInfo.Name.trim().toLowerCase())) {
1252
+ // We get here because the entity tracks record changes, the client did not provide OldValues, or the entity's
1253
+ // write rules need the stored row (ENTITIES_THAT_ALWAYS_LOAD_STORED_ROW), so we load the old values from the DB
1009
1254
  const cKey = new CompositeKey(entityInfo.PrimaryKeys.map((pk) => {
1010
1255
  return {
1011
1256
  FieldName: pk.Name,
@@ -1053,7 +1298,15 @@ export class ResolverBase {
1053
1298
  return await this.MapFieldNamesToCodeNames(entityName, entityObject.GetAll(), userInfo);
1054
1299
  }
1055
1300
  else {
1056
- throw new GraphQLError(entityObject.LatestResult?.Message ?? 'Unknown error', {
1301
+ // CompleteMessage, not Message. A validation refusal puts its reasons in `Errors` and leaves
1302
+ // `Message` unset, so reading `Message` here fell through to 'Unknown error' and discarded
1303
+ // every field-named explanation the entity had just produced. CreateRecord already read
1304
+ // CompleteMessage; this path did not, which is why the SAME rule on the SAME entity explained
1305
+ // itself on a create and said nothing on an update. CompleteMessage is a strict superset — it
1306
+ // starts from `Message`, then appends `Error` and `Errors` — so nothing previously reported is
1307
+ // lost, and it still yields undefined when there is genuinely nothing to say, leaving the
1308
+ // fallback below to fire rather than showing the user a blank error.
1309
+ throw new GraphQLError(entityObject.LatestResult?.CompleteMessage ?? 'Unknown error', {
1057
1310
  extensions: { code: 'SAVE_ENTITY_ERROR', entityName },
1058
1311
  });
1059
1312
  }
@@ -1260,7 +1513,11 @@ export class ResolverBase {
1260
1513
  return returnValue;
1261
1514
  }
1262
1515
  else {
1263
- throw new GraphQLError(entityObject.LatestResult?.Message ?? 'Unknown error', {
1516
+ // CompleteMessage, for the same reason as the update path above. It matters more going
1517
+ // forward: apps already refuse deletes by overriding Delete() and returning false with the
1518
+ // reason on LatestResult, and #3971 proposes a first-class delete-validation seam. Every one
1519
+ // of those reasons was being replaced by 'Unknown error' at the API boundary.
1520
+ throw new GraphQLError(entityObject.LatestResult?.CompleteMessage ?? 'Unknown error', {
1264
1521
  extensions: { code: 'DELETE_ENTITY_ERROR', entityName },
1265
1522
  });
1266
1523
  }