@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
@@ -21,6 +21,7 @@ import {
21
21
  } from '@memberjunction/core';
22
22
  import { MJAuditLogEntity, MJErrorLogEntity, MJUserViewEntityExtended } from '@memberjunction/core-entities';
23
23
  import { SQLServerDataProvider, UserCache } from '@memberjunction/sqlserver-dataprovider';
24
+ import { ClientClauseScreen, type EntityBaseViewAllowList } from '@memberjunction/generic-database-provider';
24
25
  import { PubSubEngine, AuthorizationError } from 'type-graphql';
25
26
  import { GraphQLError } from 'graphql';
26
27
  import { GetAPIKeyEngine } from '@memberjunction/api-keys';
@@ -30,14 +31,27 @@ import { httpTransport, CloudEvent, emitterFor } from 'cloudevents';
30
31
  import { RunViewGenericParams, UserPayload } from '../types.js';
31
32
  import { RunDynamicViewInput, RunViewByIDInput, RunViewByNameInput } from './RunViewResolver.js';
32
33
  import { DeleteOptionsInput } from './DeleteOptionsInput.js';
33
- import { MJEvent, MJEventType, MJGlobal, ENCRYPTED_SENTINEL, IsValueEncrypted, IsOnlyTimezoneShift } from '@memberjunction/global';
34
+ import { MJEvent, MJEventType, MJGlobal, ENCRYPTED_SENTINEL, EscapeSQLString, IsValueEncrypted, IsOnlyTimezoneShift } from '@memberjunction/global';
35
+ import { SQLParser, IsBalancedSQLFragment, LexSQL } from '@memberjunction/sql-parser';
36
+ import type { SQLParserDialect } from '@memberjunction/sql-dialect';
34
37
  import { EncryptionEngine } from '@memberjunction/encryption';
35
- import { PUSH_STATUS_UPDATES_TOPIC } from './PushStatusResolver.js';
36
- import { CACHE_INVALIDATION_TOPIC } from './CacheInvalidationResolver.js';
38
+ import { PUSH_STATUS_UPDATES_TOPIC, publishStatusUpdate } from './PushStatusResolver.js';
39
+ import { CACHE_INVALIDATION_TOPIC, MayBroadcastRecordData } from './CacheInvalidationResolver.js';
37
40
  import { PubSubManager } from './PubSubManager.js';
38
41
  import { FieldMapper } from '@memberjunction/graphql-dataprovider';
39
42
  import { Subscription } from 'rxjs';
40
43
 
44
+ /**
45
+ * Entities whose update is always hydrated from the stored row, never from the client's `OldValues___`, though they
46
+ * do not track record changes: their server-side write rules compare against the prior state, which a client could
47
+ * otherwise supply itself (lower-case names).
48
+ *
49
+ * `MJ: Data Context Items`: its server class lets only an administrator save an item that holds SQL, and decides
50
+ * that from the item's `Type` and `SQL`. Hydrated from OldValues, a client could leave both out, and the stored
51
+ * procedure would keep the stored SQL item while the rule saw none.
52
+ */
53
+ const ENTITIES_THAT_ALWAYS_LOAD_STORED_ROW: ReadonlySet<string> = new Set(['mj: data context items']);
54
+
41
55
  export class ResolverBase {
42
56
  private static _emit = process.env.CLOUDEVENTS_HTTP_TRANSPORT ? emitterFor(httpTransport(process.env.CLOUDEVENTS_HTTP_TRANSPORT)) : null;
43
57
  private static _cloudeventsHeaders = process.env.CLOUDEVENTS_HTTP_HEADERS ? JSON.parse(process.env.CLOUDEVENTS_HTTP_HEADERS) : {};
@@ -252,13 +266,16 @@ export class ResolverBase {
252
266
  * @param dataObject - The data object containing field values
253
267
  * @param encryptionEngine - Optional encryption engine for re-encryption (lazy loaded if needed)
254
268
  * @param contextUser - User context for encryption operations
269
+ * @param fieldKey - What the object's keys are: field CodeNames (GraphQL transport rows, the
270
+ * default) or field Names (raw provider rows, such as dataset item results)
255
271
  * @returns The filtered data object
256
272
  */
257
273
  protected async FilterEncryptedFieldsForAPI(
258
274
  entityName: string,
259
275
  dataObject: Record<string, unknown>,
260
276
  contextUser: UserInfo,
261
- provider?: IMetadataProvider
277
+ provider?: IMetadataProvider,
278
+ fieldKey: 'CodeName' | 'Name' = 'CodeName'
262
279
  ): Promise<Record<string, unknown>> {
263
280
  if (!dataObject) return dataObject;
264
281
 
@@ -272,7 +289,7 @@ export class ResolverBase {
272
289
 
273
290
  // Process each encrypted field
274
291
  for (const field of encryptedFields) {
275
- const fieldName = field.CodeName;
292
+ const fieldName = fieldKey === 'Name' ? field.Name : field.CodeName;
276
293
  const value = dataObject[fieldName];
277
294
 
278
295
  // Skip null/undefined values
@@ -313,13 +330,15 @@ export class ResolverBase {
313
330
  }
314
331
 
315
332
  /**
316
- * Filters encrypted fields for an array of data objects
333
+ * Filters encrypted fields for an array of data objects. `fieldKey` is as for
334
+ * {@link FilterEncryptedFieldsForAPI}.
317
335
  */
318
336
  protected async ArrayFilterEncryptedFieldsForAPI(
319
337
  entityName: string,
320
338
  dataObjectArray: Record<string, unknown>[],
321
339
  contextUser: UserInfo,
322
- provider?: IMetadataProvider
340
+ provider?: IMetadataProvider,
341
+ fieldKey: 'CodeName' | 'Name' = 'CodeName'
323
342
  ): Promise<Record<string, unknown>[]> {
324
343
  if (!dataObjectArray || dataObjectArray.length === 0) return dataObjectArray;
325
344
 
@@ -333,12 +352,64 @@ export class ResolverBase {
333
352
 
334
353
  // Process each element
335
354
  for (const element of dataObjectArray) {
336
- await this.FilterEncryptedFieldsForAPI(entityName, element, contextUser, provider);
355
+ await this.FilterEncryptedFieldsForAPI(entityName, element, contextUser, provider, fieldKey);
337
356
  }
338
357
 
339
358
  return dataObjectArray;
340
359
  }
341
360
 
361
+ /**
362
+ * Renders one `findBy` predicate value as the SQL literal text it will become inside the
363
+ * `ExtraFilter` that method builds.
364
+ *
365
+ * The two slots have different exposure, so they are handled differently:
366
+ *
367
+ * - **Quoted slot** (`NeedsQuotes` — every type except Number and Boolean). The value sits
368
+ * inside a single-quoted literal, so it is escaped with `EscapeSQLString`: quotes are
369
+ * doubled and null bytes stripped, leaving the payload inert *inside* the literal.
370
+ * - **Unquoted slot** (Number and Boolean fields). There is no literal to break out of
371
+ * here — the value lands in the clause bare, so a string value simply *is* SQL and no
372
+ * amount of quote escaping helps. Only a genuine number or boolean is accepted; anything
373
+ * else is refused rather than interpolated. Numeric and boolean strings are accepted too
374
+ * (a client may legitimately send `"42"`), since neither can carry SQL.
375
+ *
376
+ * A `null`/`undefined` value is refused in either slot. `EscapeSQLString` maps both to `''`
377
+ * by design, which would silently turn a by-value lookup into `Field = ''` — for this
378
+ * caller a missing value is a bug, not a query.
379
+ *
380
+ * @param value the raw value supplied for the field
381
+ * @param fieldName field name, for the error message
382
+ * @param entity entity name, for the error message
383
+ * @param needsQuotes whether the field's type requires a quoted literal
384
+ * @returns the literal text (including quotes when the slot is quoted)
385
+ * @throws when the value cannot be rendered safely for the slot it is destined for
386
+ */
387
+ private formatFilterValue(value: unknown, fieldName: string, entity: string, needsQuotes: boolean): string {
388
+ if (value === null || value === undefined) {
389
+ throw new Error(
390
+ `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.`
391
+ );
392
+ }
393
+
394
+ if (needsQuotes) {
395
+ return `'${EscapeSQLString(String(value))}'`;
396
+ }
397
+
398
+ // Unquoted slot — accept only what cannot carry SQL, and render it exactly as before.
399
+ if (typeof value === 'boolean' || (typeof value === 'number' && Number.isFinite(value))) {
400
+ return String(value);
401
+ }
402
+ if (typeof value === 'string') {
403
+ const trimmed = value.trim();
404
+ if (/^-?\d+(\.\d+)?$/.test(trimmed)) return trimmed;
405
+ if (/^(true|false)$/i.test(trimmed)) return trimmed.toLowerCase();
406
+ }
407
+
408
+ throw new Error(
409
+ `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}.`
410
+ );
411
+ }
412
+
342
413
  protected async findBy<T = any>(provider: DatabaseProviderBase, entity: string, params: any, contextUser: UserInfo): Promise<Array<T>> {
343
414
  // build the SQL query based on the params passed in
344
415
  const rv = provider as any as IRunViewProvider;
@@ -352,8 +423,7 @@ export class ResolverBase {
352
423
  // look up the field in the entityInfo to see if it needs quotes
353
424
  const field = e.Fields.find((f) => f.Name === k);
354
425
  if (!field) throw new Error(`Field ${k} not found in entity ${entity}`);
355
- const quotes = field.NeedsQuotes ? "'" : '';
356
- extraFilter += `${k} = ${quotes}${params[k]}${quotes}`;
426
+ extraFilter += `${k} = ${this.formatFilterValue(params[k], k, entity, field.NeedsQuotes)}`;
357
427
  });
358
428
 
359
429
  // ok, now we have a SQL string, run it and return the results
@@ -380,7 +450,8 @@ export class ResolverBase {
380
450
  const rv = provider as any as IRunViewProvider;
381
451
  const result = await rv.RunView<MJUserViewEntityExtended>({
382
452
  EntityName: 'MJ: User Views',
383
- ExtraFilter: "Name='" + viewInput.ViewName + "'",
453
+ // SECURITY: the view name is client-supplied and lands inside a SQL literal.
454
+ ExtraFilter: "Name='" + EscapeSQLString(viewInput.ViewName) + "'",
384
455
  }, userPayload.userRecord);
385
456
  if (result && result.Success && result.Results.length > 0) {
386
457
  const viewInfo = result.Results[0];
@@ -733,6 +804,186 @@ export class ResolverBase {
733
804
  }
734
805
  }
735
806
 
807
+ /**
808
+ * SECURITY — GraphQL-boundary screen for one client-supplied SQL clause fragment.
809
+ *
810
+ * `ValidateUserProvidedSQLClause` still blocks stacked statements, DML, comments, UNION
811
+ * and WAITFOR, and still permits SELECT (server-internal engines pass richer filters).
812
+ * A keyword ban on SELECT/EXISTS at this boundary (#4253) broke first-party clients that
813
+ * use `IN (SELECT … FROM <entity base view>)` — that is a legitimate ExtraFilter.
814
+ *
815
+ * This screen uses `@memberjunction/sql-parser` (same wrap as EDS `assertReadOnlyClause`):
816
+ * wrap the fragment as a single SELECT, fail closed if it does not parse as a read, then
817
+ * allow a FROM only when it is an entity **BaseView**. Base tables (`Meeting`, `__mj.User`)
818
+ * and catalogs are rejected. Server-internal RunView callers get only the provider's
819
+ * statement-separator check, which cannot refuse a legitimate filter.
820
+ *
821
+ * The screen is `ClientClauseScreen` in `@memberjunction/generic-database-provider`, shared
822
+ * with the REST view routes, ad-hoc list sources, and the provider's saved-view checks.
823
+ *
824
+ * Before that parse, the fragment must be balanced on its own (`IsBalancedSQLFragment`). The
825
+ * provider and the tenant hook splice it inside parentheses and AND their own filters after
826
+ * it, so a fragment that closes those parentheses (`1=1) OR (1=1`) would leave row-level
827
+ * security and tenant scope on one side of an OR the caller controls.
828
+ * The parse wraps the fragment in parentheses too, which is why it cannot catch this alone.
829
+ * A backslash inside a quoted string or identifier is refused as well: the parser reads `\'`
830
+ * inside a plain literal as an escaped quote, but SQL Server and PostgreSQL end the literal
831
+ * there, so the text after it could be code that the parse-based checks never saw.
832
+ *
833
+ * RLS is applied by RunView as an outer WHERE around the entity being queried, not compiled
834
+ * into the view, and RunView removes denied fields from its own result only. A subquery reads a
835
+ * base view directly with neither, so the screen refuses one over any entity the user reads under
836
+ * a row filter or with denied fields (`ClientClauseScreen.AssertUnscopedRead`), the queried
837
+ * entity included.
838
+ */
839
+ protected assertClientClauseUsesEntityBaseViews(
840
+ clause: string | undefined | null,
841
+ label: string,
842
+ provider?: IMetadataProvider,
843
+ user?: UserInfo,
844
+ options?: { LeaveUnparseableToProvider?: boolean },
845
+ ): void {
846
+ if (!clause?.trim()) return;
847
+
848
+ // The balance and backslash checks run before the shared screen: it parses the fragment
849
+ // inside its own parentheses, so it cannot see a fragment that closes the parentheses the
850
+ // provider and the tenant hook splice it into.
851
+ const dialect = this.dialectForProvider(provider);
852
+ if (SQLParser.HasStackedStatements(clause, dialect)) {
853
+ throw new Error(`Invalid ${label}: multiple statements are not permitted in client-supplied filters`);
854
+ }
855
+ const balance = IsBalancedSQLFragment(clause, dialect);
856
+ if (!balance.IsBalanced) {
857
+ throw new Error(`Invalid ${label}: ${balance.Reason}`);
858
+ }
859
+ if (this.hasBackslashInQuotes(clause, dialect)) {
860
+ throw new Error(`Invalid ${label}: a backslash inside a quoted string or identifier is not permitted in client-supplied filters`);
861
+ }
862
+
863
+ ClientClauseScreen.AssertClauseUsesEntityBaseViews(clause, label, provider?.Entities ?? [], dialect, user, options);
864
+ }
865
+
866
+ /**
867
+ * SECURITY — screen for a FULL client-supplied SQL statement (ad-hoc query path). In addition
868
+ * to the SELECT-only validation the caller performs, every table reference must resolve to an
869
+ * entity BaseView the acting user can read UNSCOPED. A reference is excluded only when it reads a
870
+ * CTE that is in scope there (`StatementRelation.IsCTEReference`): unqualified, declared by its
871
+ * own SELECT or an enclosing one, and matched by the dialect's identifier rules. A name reserved
872
+ * for the SQL parser is refused before parsing (`ClientClauseScreen.AssertNoIdentifierAliasNames`).
873
+ * Fails closed: no user, or an unresolvable reference, refuses.
874
+ *
875
+ * The references come from a walk of the whole parse tree
876
+ * (`ClientClauseScreen.CollectStatementReads`). The statement is refused when that walk does not
877
+ * account for every SELECT in the text, or when a FROM item names no table (a table function or
878
+ * a row list).
879
+ *
880
+ * Entity CanRead is not unscoped base-view read authority: RunView narrows a granted read with
881
+ * row-level security, and raw SQL does not apply it. Rather than try to rewrite arbitrary SQL
882
+ * (joins, CTEs) to compose that policy, this refuses any entity that carries a row filter for
883
+ * the caller — those reads belong on RunView. For
884
+ * the remaining entities CanRead genuinely means "every row and field of the view".
885
+ */
886
+ protected assertFullQueryUsesReadableEntityViews(
887
+ sqlText: string,
888
+ provider: IMetadataProvider | undefined,
889
+ user: UserInfo | undefined,
890
+ label = 'SQL',
891
+ ): void {
892
+ if (!user) {
893
+ throw new Error(`Invalid ${label}: no acting user resolved for ad-hoc SQL — refusing`);
894
+ }
895
+ const dialect = this.dialectForProvider(provider);
896
+ ClientClauseScreen.AssertNoIdentifierAliasNames(sqlText, dialect, label);
897
+ const parser = new SQLParser(sqlText, dialect);
898
+ if (!parser.IsValid || parser.HasWriteStatement || parser.StatementKind !== 'select') {
899
+ throw new Error(`Invalid ${label}: not a safe read-only statement — refusing under uncertainty`);
900
+ }
901
+ if (this.astContainsWriteNode(parser.AST)) {
902
+ throw new Error(`Invalid ${label}: write/DDL nested in a subquery is not permitted`);
903
+ }
904
+ const reads = ClientClauseScreen.CollectStatementReads(parser);
905
+ ClientClauseScreen.AssertEverySelectAccountedFor(sqlText, dialect, reads, label);
906
+ const allowed = this.entityBaseViewAllowList(provider);
907
+ for (const relation of reads.Relations) {
908
+ if (relation.IsCTEReference) continue;
909
+ const entity = ClientClauseScreen.AssertRelationReadable(allowed, relation, label, user);
910
+ if (entity) this.assertUnscopedRead(entity, user, label);
911
+ }
912
+ }
913
+
914
+ /** Refuses an entity whose read is narrowed for this user; see `ClientClauseScreen.AssertUnscopedRead`. */
915
+ private assertUnscopedRead(entity: EntityInfo, user: UserInfo, label: string): void {
916
+ ClientClauseScreen.AssertUnscopedRead(entity, user, label);
917
+ }
918
+
919
+ /**
920
+ * Applies {@link assertClientClauseUsesEntityBaseViews} to every client-supplied clause
921
+ * a view request can carry that is actually a SQL fragment. GraphQL entry points
922
+ * (RunViewByName, RunViewByID, RunDynamicView, RunViews) funnel through
923
+ * RunViewGenericInternal / RunViewsGenericInternal.
924
+ *
925
+ * 🚨 `UserSearchString` is deliberately NOT screened here (#4392). It is not a clause — it is
926
+ * the free text a person typed into a search box, and it never reaches SQL as a fragment.
927
+ * `GenericDatabaseProvider.createViewUserSearchSQL` builds the predicate itself from
928
+ * `IncludeInUserSearchAPI` metadata and lands the text only as a literal, doubling single
929
+ * quotes and escaping LIKE metacharacters with an explicit `ESCAPE`. Running it through a
930
+ * screen that parses its argument as SQL and fails closed rejected every term that is not
931
+ * coincidentally valid SQL: `Marcus Chen` parses as nothing, `O'Leary` as an unterminated
932
+ * literal — so essentially every real name search returned 0 rows.
933
+ *
934
+ * The provider-level denylist does NOT back this up — it was removed from `UserSearchString`
935
+ * in the same change, for the same reason (it refused `Union Pacific`). What protects the value
936
+ * is that it is never SQL: it reaches the database only as a quoted, quote-doubled literal.
937
+ * The single place that is not true is a field carrying `UserSearchParamFormatAPI`, whose
938
+ * admin-authored format may splice the term in unquoted; `createViewUserSearchSQL` re-applies
939
+ * `ValidateUserProvidedSQLClause` for exactly those entities.
940
+ *
941
+ * Each `Aggregates[].expression` gets the same screen, except that text the parser cannot read
942
+ * goes on to the provider, which refuses it per aggregate. The provider never runs the caller's
943
+ * aggregate text: it runs the SQL that `CheckAggregateExpression` rebuilds from a single
944
+ * aggregate call over the entity's columns, and refuses everything else per aggregate.
945
+ */
946
+ protected screenClientViewClauses(
947
+ clauses: {
948
+ extraFilter?: string | null;
949
+ orderBy?: string | null;
950
+ userSearchString?: string | null;
951
+ overrideExcludeFilter?: string | null;
952
+ aggregates?: AggregateExpression[] | null;
953
+ },
954
+ provider?: IMetadataProvider,
955
+ user?: UserInfo,
956
+ ): void {
957
+ this.assertClientClauseUsesEntityBaseViews(clauses.extraFilter, 'ExtraFilter', provider, user);
958
+ this.assertClientClauseUsesEntityBaseViews(clauses.orderBy, 'OrderBy', provider, user);
959
+ this.assertClientClauseUsesEntityBaseViews(clauses.overrideExcludeFilter, 'OverrideExcludeFilter', provider, user);
960
+ for (const aggregate of clauses.aggregates ?? []) {
961
+ this.assertClientClauseUsesEntityBaseViews(aggregate?.expression, 'Aggregate', provider, user, { LeaveUnparseableToProvider: true });
962
+ }
963
+ }
964
+
965
+ /**
966
+ * True when a string literal or quoted identifier in `clause`, read the way the database reads
967
+ * it, contains a backslash (see {@link assertClientClauseUsesEntityBaseViews}).
968
+ */
969
+ private hasBackslashInQuotes(clause: string, dialect: SQLParserDialect): boolean {
970
+ return LexSQL(clause, dialect).some((t) => (t.Kind === 'string' || t.Kind === 'identifier') && t.Text.includes('\\'));
971
+ }
972
+
973
+ /** Write/DDL node walk shared with the clause screen; see `ClientClauseScreen.AstContainsWriteNode`. */
974
+ private astContainsWriteNode(node: unknown): boolean {
975
+ return ClientClauseScreen.AstContainsWriteNode(node);
976
+ }
977
+
978
+ private dialectForProvider(provider?: IMetadataProvider): SQLParserDialect {
979
+ return ClientClauseScreen.DialectFor(provider);
980
+ }
981
+
982
+ /** The entity base-view allow-list for a provider's entities. */
983
+ private entityBaseViewAllowList(provider?: IMetadataProvider): EntityBaseViewAllowList {
984
+ return ClientClauseScreen.BuildEntityBaseViewAllowList(provider?.Entities ?? []);
985
+ }
986
+
736
987
  /**
737
988
  * Optimized RunViewGenericInternal implementation with:
738
989
  * - Field filtering at source (Fix #7)
@@ -763,10 +1014,6 @@ export class ResolverBase {
763
1014
  try {
764
1015
  if (!viewInfo || !userPayload) return null;
765
1016
 
766
- // Check API key scope authorization for view operations
767
- await this.CheckAPIKeyScopeAuthorization('view:run', viewInfo.Entity, userPayload);
768
-
769
- const md = provider
770
1017
  // Prefer the authenticated session's payload user — it is the authoritative per-request
771
1018
  // identity and (for magic-link sessions) carries the per-session resource scope / synthesized
772
1019
  // roles that drive RLS. The cached lookup is a fallback for paths where the payload user
@@ -775,6 +1022,20 @@ export class ResolverBase {
775
1022
  ?? UserCache.Users.find((u) => u.Email.toLowerCase().trim() === userPayload?.email.toLowerCase().trim());
776
1023
  if (!user) throw new Error(`User ${userPayload?.email} not found in metadata`);
777
1024
 
1025
+ // SECURITY: GraphQL ExtraFilter may contain IN (SELECT … FROM <base view>).
1026
+ // Screen at this boundary: parse, reject writes, allow only entity BaseViews the
1027
+ // acting user can read (subqueries do not inherit the top entity's permission check).
1028
+ this.screenClientViewClauses(
1029
+ { extraFilter, orderBy, userSearchString, overrideExcludeFilter, aggregates },
1030
+ provider as unknown as IMetadataProvider,
1031
+ user,
1032
+ );
1033
+
1034
+ // Check API key scope authorization for view operations
1035
+ await this.CheckAPIKeyScopeAuthorization('view:run', viewInfo.Entity, userPayload);
1036
+
1037
+ const md = provider
1038
+
778
1039
  const entityInfo = md.Entities.find((e) => e.Name === viewInfo.Entity);
779
1040
  if (!entityInfo) throw new Error(`Entity ${viewInfo.Entity} not found in metadata`);
780
1041
 
@@ -920,6 +1181,19 @@ export class ResolverBase {
920
1181
 
921
1182
  // Transform parameters
922
1183
  for (const param of params) {
1184
+ // SECURITY: same GraphQL-boundary BaseView screen as RunViewGenericInternal.
1185
+ this.screenClientViewClauses(
1186
+ {
1187
+ extraFilter: param.extraFilter,
1188
+ orderBy: param.orderBy,
1189
+ userSearchString: param.userSearchString,
1190
+ overrideExcludeFilter: param.overrideExcludeFilter,
1191
+ aggregates: param.aggregates,
1192
+ },
1193
+ md,
1194
+ contextUser ?? undefined,
1195
+ );
1196
+
923
1197
  if (param.viewInfo) {
924
1198
  // Validate entity only once per entity type
925
1199
  const entityName = param.viewInfo.Entity;
@@ -1118,16 +1392,40 @@ export class ResolverBase {
1118
1392
  * Publishes a CACHE_INVALIDATION event to connected browser clients after a successful
1119
1393
  * entity save or delete. Includes the originSessionId so the originating browser can
1120
1394
  * skip redundant re-fetches (it already handled the event locally).
1395
+ *
1396
+ * The row itself rides along ONLY for entities opted in via
1397
+ * `cacheSettings.recordDataBroadcastEntities` — this event reaches every connected client
1398
+ * unfiltered, so the row would otherwise be readable by sessions that could not read the record.
1121
1399
  */
1122
1400
  protected PublishCacheInvalidation(entityObject: BaseEntity, action: 'save' | 'delete', userPayload: UserPayload): void {
1401
+ const entityName = entityObject.EntityInfo.Name;
1123
1402
  PubSubManager.Instance.Publish(CACHE_INVALIDATION_TOPIC, {
1124
- entityName: entityObject.EntityInfo.Name,
1403
+ entityName,
1125
1404
  primaryKeyValues: JSON.stringify(entityObject.PrimaryKey.KeyValuePairs),
1126
1405
  action,
1127
1406
  sourceServerId: MJGlobal.Instance.ProcessUUID,
1128
1407
  timestamp: new Date(),
1129
1408
  originSessionId: userPayload?.sessionId || null,
1130
- recordData: action === 'save' ? JSON.stringify(entityObject.GetAll()) : undefined,
1409
+ recordData:
1410
+ action === 'save' && MayBroadcastRecordData(entityName)
1411
+ ? JSON.stringify(entityObject.GetAll())
1412
+ : undefined,
1413
+ });
1414
+ }
1415
+
1416
+ /**
1417
+ * Publishes a push-status update to the client on {@link PUSH_STATUS_UPDATES_TOPIC}, stamping the
1418
+ * authenticated owner's user ID from `userPayload` so the subscription filter can bind delivery
1419
+ * to identity (see B49 / `statusUpdatesFilter`). The ergonomic wrapper every resolver should use
1420
+ * instead of calling `pubSub.publish` on the topic directly — it makes omitting identity
1421
+ * impossible. Non-resolver publishers (services, the liveness heartbeat) call the shared
1422
+ * `publishStatusUpdate()` function directly with an explicit `ownerUserId`.
1423
+ */
1424
+ protected PublishStatusUpdate(pubSub: PubSubEngine, sessionId: string, message: string | undefined, userPayload: UserPayload): void {
1425
+ publishStatusUpdate(pubSub, {
1426
+ sessionId,
1427
+ ownerUserId: userPayload?.userRecord?.ID ?? '',
1428
+ message,
1131
1429
  });
1132
1430
  }
1133
1431
 
@@ -1160,16 +1458,13 @@ export class ResolverBase {
1160
1458
  const baseEntityEvent = event.args as BaseEntityEvent;
1161
1459
  // message from our entity object, relay it to the client
1162
1460
  LogDebug('ResolverBase.ListenForEntityMessages: About to publish PUSH_STATUS_UPDATES_TOPIC');
1163
- pubSub.publish(PUSH_STATUS_UPDATES_TOPIC, {
1164
- message: JSON.stringify({
1165
- status: 'OK',
1166
- type: 'EntityObjectStatusMessage',
1167
- entityName: baseEntityEvent.baseEntity.EntityInfo.Name,
1168
- primaryKey: baseEntityEvent.baseEntity.PrimaryKey,
1169
- message: event.args.payload,
1170
- }),
1171
- sessionId: userPayload.sessionId,
1172
- });
1461
+ this.PublishStatusUpdate(pubSub, userPayload.sessionId, JSON.stringify({
1462
+ status: 'OK',
1463
+ type: 'EntityObjectStatusMessage',
1464
+ entityName: baseEntityEvent.baseEntity.EntityInfo.Name,
1465
+ primaryKey: baseEntityEvent.baseEntity.PrimaryKey,
1466
+ message: event.args.payload,
1467
+ }), userPayload);
1173
1468
  }
1174
1469
  }
1175
1470
  });
@@ -1246,8 +1541,9 @@ export class ResolverBase {
1246
1541
  }
1247
1542
  });
1248
1543
 
1249
- if (entityInfo.TrackRecordChanges || !input.OldValues___) {
1250
- // 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
1544
+ if (entityInfo.TrackRecordChanges || !input.OldValues___ || ENTITIES_THAT_ALWAYS_LOAD_STORED_ROW.has(entityInfo.Name.trim().toLowerCase())) {
1545
+ // We get here because the entity tracks record changes, the client did not provide OldValues, or the entity's
1546
+ // write rules need the stored row (ENTITIES_THAT_ALWAYS_LOAD_STORED_ROW), so we load the old values from the DB
1251
1547
  const cKey = new CompositeKey(
1252
1548
  entityInfo.PrimaryKeys.map((pk) => {
1253
1549
  return {
@@ -1300,7 +1596,15 @@ export class ResolverBase {
1300
1596
  // MapFieldNamesToCodeNames now handles encryption filtering as well
1301
1597
  return await this.MapFieldNamesToCodeNames(entityName, entityObject.GetAll(), userInfo);
1302
1598
  } else {
1303
- throw new GraphQLError(entityObject.LatestResult?.Message ?? 'Unknown error', {
1599
+ // CompleteMessage, not Message. A validation refusal puts its reasons in `Errors` and leaves
1600
+ // `Message` unset, so reading `Message` here fell through to 'Unknown error' and discarded
1601
+ // every field-named explanation the entity had just produced. CreateRecord already read
1602
+ // CompleteMessage; this path did not, which is why the SAME rule on the SAME entity explained
1603
+ // itself on a create and said nothing on an update. CompleteMessage is a strict superset — it
1604
+ // starts from `Message`, then appends `Error` and `Errors` — so nothing previously reported is
1605
+ // lost, and it still yields undefined when there is genuinely nothing to say, leaving the
1606
+ // fallback below to fire rather than showing the user a blank error.
1607
+ throw new GraphQLError(entityObject.LatestResult?.CompleteMessage ?? 'Unknown error', {
1304
1608
  extensions: { code: 'SAVE_ENTITY_ERROR', entityName },
1305
1609
  });
1306
1610
  }
@@ -1526,7 +1830,11 @@ export class ResolverBase {
1526
1830
  // Cache invalidation is now handled globally by the MJGlobal listener in index.ts
1527
1831
  return returnValue;
1528
1832
  } else {
1529
- throw new GraphQLError(entityObject.LatestResult?.Message ?? 'Unknown error', {
1833
+ // CompleteMessage, for the same reason as the update path above. It matters more going
1834
+ // forward: apps already refuse deletes by overriding Delete() and returning false with the
1835
+ // reason on LatestResult, and #3971 proposes a first-class delete-validation seam. Every one
1836
+ // of those reasons was being replaced by 'Unknown error' at the API boundary.
1837
+ throw new GraphQLError(entityObject.LatestResult?.CompleteMessage ?? 'Unknown error', {
1530
1838
  extensions: { code: 'DELETE_ENTITY_ERROR', entityName },
1531
1839
  });
1532
1840
  }
@@ -1055,6 +1055,8 @@ export class RunViewResolver extends ResolverBase {
1055
1055
  @Ctx() { providers, userPayload }: AppContext
1056
1056
  ): Promise<RunViewsWithCacheCheckOutput> {
1057
1057
  try {
1058
+ // Same scope as RunViews: this is the transport RunViews uses for a CacheLocal batch.
1059
+ await this.CheckAPIKeyScopeAuthorization('view:batch', '*', userPayload);
1058
1060
  const provider = GetReadOnlyProvider(providers, { allowFallbackToReadWrite: true });
1059
1061
 
1060
1062
  // Cast provider to SQLServerDataProvider to access RunViewsWithCacheCheck method
@@ -1063,6 +1065,21 @@ export class RunViewResolver extends ResolverBase {
1063
1065
  throw new Error('Provider does not support RunViewsWithCacheCheck');
1064
1066
  }
1065
1067
 
1068
+ // SECURITY: same GraphQL-boundary clause screen as RunViews, before the provider runs.
1069
+ const user = this.GetUserFromPayload(userPayload);
1070
+ for (const item of input) {
1071
+ this.screenClientViewClauses(
1072
+ {
1073
+ extraFilter: item.params.ExtraFilter,
1074
+ orderBy: item.params.OrderBy,
1075
+ userSearchString: item.params.UserSearchString,
1076
+ overrideExcludeFilter: item.params.OverrideExcludeFilter,
1077
+ },
1078
+ provider,
1079
+ user,
1080
+ );
1081
+ }
1082
+
1066
1083
  // Convert GraphQL input types to core types
1067
1084
  const coreParams: RunViewWithCacheCheckParams[] = input.map(item => ({
1068
1085
  params: {