@memberjunction/server 6.1.5 → 6.1.6

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 (189) hide show
  1. package/dist/.tsbuildinfo +1 -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 +129 -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/exampleNewUserSubClass.js +2 -1
  19. package/dist/auth/exampleNewUserSubClass.js.map +1 -1
  20. package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
  21. package/dist/auth/magicLink/MagicLinkService.js +6 -3
  22. package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
  23. package/dist/auth/newUsers.d.ts.map +1 -1
  24. package/dist/auth/newUsers.js +15 -11
  25. package/dist/auth/newUsers.js.map +1 -1
  26. package/dist/context.d.ts +6 -0
  27. package/dist/context.d.ts.map +1 -1
  28. package/dist/context.js +13 -11
  29. package/dist/context.js.map +1 -1
  30. package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
  31. package/dist/generated/graphql-schemas/__mj.js +3417 -3428
  32. package/dist/generated/graphql-schemas/__mj.js.map +1 -1
  33. package/dist/generic/CacheInvalidationResolver.d.ts +44 -8
  34. package/dist/generic/CacheInvalidationResolver.d.ts.map +1 -1
  35. package/dist/generic/CacheInvalidationResolver.js +59 -19
  36. package/dist/generic/CacheInvalidationResolver.js.map +1 -1
  37. package/dist/generic/ResolverBase.d.ts +95 -11
  38. package/dist/generic/ResolverBase.d.ts.map +1 -1
  39. package/dist/generic/ResolverBase.js +186 -76
  40. package/dist/generic/ResolverBase.js.map +1 -1
  41. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  42. package/dist/generic/RunViewResolver.js +12 -0
  43. package/dist/generic/RunViewResolver.js.map +1 -1
  44. package/dist/index.d.ts +1 -0
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +8 -1
  47. package/dist/index.js.map +1 -1
  48. package/dist/isolatedProvider.d.ts +20 -0
  49. package/dist/isolatedProvider.d.ts.map +1 -0
  50. package/dist/isolatedProvider.js +22 -0
  51. package/dist/isolatedProvider.js.map +1 -0
  52. package/dist/multiTenancy/index.d.ts.map +1 -1
  53. package/dist/multiTenancy/index.js +22 -2
  54. package/dist/multiTenancy/index.js.map +1 -1
  55. package/dist/realtimeWidget/widgetGuestElevation.d.ts +4 -2
  56. package/dist/realtimeWidget/widgetGuestElevation.d.ts.map +1 -1
  57. package/dist/realtimeWidget/widgetGuestElevation.js +4 -2
  58. package/dist/realtimeWidget/widgetGuestElevation.js.map +1 -1
  59. package/dist/resolvers/ActionResolver.d.ts +31 -5
  60. package/dist/resolvers/ActionResolver.d.ts.map +1 -1
  61. package/dist/resolvers/ActionResolver.js +75 -14
  62. package/dist/resolvers/ActionResolver.js.map +1 -1
  63. package/dist/resolvers/AdhocQueryResolver.d.ts +28 -0
  64. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  65. package/dist/resolvers/AdhocQueryResolver.js +46 -0
  66. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  67. package/dist/resolvers/DatasetResolver.d.ts +24 -3
  68. package/dist/resolvers/DatasetResolver.d.ts.map +1 -1
  69. package/dist/resolvers/DatasetResolver.js +57 -8
  70. package/dist/resolvers/DatasetResolver.js.map +1 -1
  71. package/dist/resolvers/EntityRecordNameResolver.d.ts +8 -23
  72. package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
  73. package/dist/resolvers/EntityRecordNameResolver.js +11 -48
  74. package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
  75. package/dist/resolvers/FetchEntityVectorsResolver.js +2 -2
  76. package/dist/resolvers/FetchEntityVectorsResolver.js.map +1 -1
  77. package/dist/resolvers/GetDataContextDataResolver.d.ts +33 -7
  78. package/dist/resolvers/GetDataContextDataResolver.d.ts.map +1 -1
  79. package/dist/resolvers/GetDataContextDataResolver.js +117 -38
  80. package/dist/resolvers/GetDataContextDataResolver.js.map +1 -1
  81. package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
  82. package/dist/resolvers/GetDataResolver.js +13 -1
  83. package/dist/resolvers/GetDataResolver.js.map +1 -1
  84. package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
  85. package/dist/resolvers/QuerySystemUserResolver.js +2 -1
  86. package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
  87. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +39 -16
  88. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  89. package/dist/resolvers/RealtimeClientSessionResolver.js +84 -38
  90. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  91. package/dist/resolvers/RemoteBrowserActionResolver.d.ts +2 -0
  92. package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
  93. package/dist/resolvers/RemoteBrowserActionResolver.js +5 -1
  94. package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
  95. package/dist/resolvers/RunTemplateResolver.d.ts.map +1 -1
  96. package/dist/resolvers/RunTemplateResolver.js +12 -11
  97. package/dist/resolvers/RunTemplateResolver.js.map +1 -1
  98. package/dist/resolvers/SearchKnowledgeResolver.d.ts +1 -0
  99. package/dist/resolvers/SearchKnowledgeResolver.d.ts.map +1 -1
  100. package/dist/resolvers/SearchKnowledgeResolver.js +5 -0
  101. package/dist/resolvers/SearchKnowledgeResolver.js.map +1 -1
  102. package/dist/resolvers/SearchKnowledgeSystemUserResolver.d.ts.map +1 -1
  103. package/dist/resolvers/SearchKnowledgeSystemUserResolver.js +1 -0
  104. package/dist/resolvers/SearchKnowledgeSystemUserResolver.js.map +1 -1
  105. package/dist/resolvers/SyncDataResolver.d.ts.map +1 -1
  106. package/dist/resolvers/SyncDataResolver.js +6 -2
  107. package/dist/resolvers/SyncDataResolver.js.map +1 -1
  108. package/dist/resolvers/VectorizeEntityResolver.d.ts +1 -1
  109. package/dist/resolvers/VectorizeEntityResolver.d.ts.map +1 -1
  110. package/dist/resolvers/VectorizeEntityResolver.js +14 -10
  111. package/dist/resolvers/VectorizeEntityResolver.js.map +1 -1
  112. package/dist/rest/ViewOperationsHandler.d.ts +3 -1
  113. package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
  114. package/dist/rest/ViewOperationsHandler.js +29 -17
  115. package/dist/rest/ViewOperationsHandler.js.map +1 -1
  116. package/dist/services/TaskGraphActionRunner.d.ts +8 -0
  117. package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
  118. package/dist/services/TaskGraphActionRunner.js +27 -1
  119. package/dist/services/TaskGraphActionRunner.js.map +1 -1
  120. package/package.json +92 -92
  121. package/src/__tests__/ActionResolver.authorization.test.ts +353 -0
  122. package/src/__tests__/AdhocQueryResolver.bugs.test.ts +4 -2
  123. package/src/__tests__/AdhocQueryResolver.sqlAuthorizer.test.ts +104 -0
  124. package/src/__tests__/DatasetResolver.itemFilters.test.ts +201 -0
  125. package/src/__tests__/EntityRecordNameResolver.fieldSecurity.test.ts +27 -72
  126. package/src/__tests__/EntityRecordNameResolver.rowSecurity.test.ts +152 -0
  127. package/src/__tests__/GetDataContextDataResolver.sqlItems.test.ts +416 -0
  128. package/src/__tests__/ListOperationsResolver.adhocScreen.test.ts +137 -0
  129. package/src/__tests__/RealtimeClientSessionResolver.configTamper.test.ts +383 -0
  130. package/src/__tests__/RealtimeClientSessionResolver.test.ts +106 -41
  131. package/src/__tests__/RemoteBrowserActionResolver.goalRunLink.test.ts +129 -0
  132. package/src/__tests__/ResolverBase.adhocReadScope.test.ts +227 -0
  133. package/src/__tests__/ResolverBase.aggregateScreen.test.ts +174 -0
  134. package/src/__tests__/ResolverBase.balancedClause.test.ts +155 -0
  135. package/src/__tests__/ResolverBase.clauseUnscopedRead.test.ts +127 -0
  136. package/src/__tests__/ResolverBase.filterEscaping.test.ts +24 -2
  137. package/src/__tests__/ResolverBase.isaPromotionCreate.test.ts +412 -0
  138. package/src/__tests__/RunTemplateResolver.test.ts +132 -0
  139. package/src/__tests__/RunViewResolver.cacheCheckScreen.test.ts +165 -0
  140. package/src/__tests__/SessionManager.test.ts +94 -17
  141. package/src/__tests__/SessionRunIDVerifier.test.ts +251 -0
  142. package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +2 -0
  143. package/src/__tests__/TaskGraphActionRunner.storableOutputs.test.ts +76 -0
  144. package/src/__tests__/UpdateMJDataContextItem.oldValues.test.ts +220 -0
  145. package/src/__tests__/VectorizeEntityResolver.test.ts +106 -0
  146. package/src/__tests__/ViewOperationsHandler.clauseScreen.test.ts +157 -0
  147. package/src/__tests__/actionAuthorization.test.ts +130 -0
  148. package/src/__tests__/cacheInvalidationRecordData.test.ts +64 -0
  149. package/src/__tests__/isolatedProviderTransactions.test.ts +169 -0
  150. package/src/__tests__/magicLinkService.consume.test.ts +6 -0
  151. package/src/__tests__/magicLinkService.provisionLog.test.ts +5 -0
  152. package/src/__tests__/multiTenancy.postgresRewrite.test.ts +181 -0
  153. package/src/__tests__/multiTenancy.security.test.ts +51 -0
  154. package/src/__tests__/multiTenancy.test.ts +1 -1
  155. package/src/__tests__/newUsers.test.ts +62 -5
  156. package/src/agentSessions/SessionManager.ts +23 -25
  157. package/src/agentSessions/SessionRunIDVerifier.ts +171 -0
  158. package/src/agentSessions/index.ts +1 -0
  159. package/src/auth/actionAuthorization.ts +119 -0
  160. package/src/auth/exampleNewUserSubClass.ts +2 -2
  161. package/src/auth/magicLink/MagicLinkService.ts +6 -3
  162. package/src/auth/newUsers.ts +15 -11
  163. package/src/context.ts +14 -10
  164. package/src/generated/graphql-schemas/__mj.ts +3417 -3428
  165. package/src/generic/CacheInvalidationResolver.ts +74 -19
  166. package/src/generic/ResolverBase.ts +207 -80
  167. package/src/generic/RunViewResolver.ts +17 -0
  168. package/src/index.ts +9 -1
  169. package/src/isolatedProvider.ts +22 -0
  170. package/src/multiTenancy/index.ts +24 -2
  171. package/src/realtimeWidget/widgetGuestElevation.ts +4 -2
  172. package/src/resolvers/ActionResolver.ts +84 -21
  173. package/src/resolvers/AdhocQueryResolver.ts +49 -1
  174. package/src/resolvers/DatasetResolver.ts +70 -10
  175. package/src/resolvers/EntityRecordNameResolver.ts +11 -52
  176. package/src/resolvers/FetchEntityVectorsResolver.ts +2 -2
  177. package/src/resolvers/GetDataContextDataResolver.ts +139 -41
  178. package/src/resolvers/GetDataResolver.ts +14 -1
  179. package/src/resolvers/QuerySystemUserResolver.ts +2 -1
  180. package/src/resolvers/RealtimeClientSessionResolver.ts +103 -42
  181. package/src/resolvers/RemoteBrowserActionResolver.ts +11 -1
  182. package/src/resolvers/RunTemplateResolver.ts +13 -12
  183. package/src/resolvers/SearchKnowledgeResolver.ts +4 -0
  184. package/src/resolvers/SearchKnowledgeSystemUserResolver.ts +1 -0
  185. package/src/resolvers/SyncDataResolver.ts +6 -2
  186. package/src/resolvers/VectorizeEntityResolver.ts +16 -11
  187. package/src/resolvers/__tests__/GetDataResolver.validation.test.ts +95 -0
  188. package/src/rest/ViewOperationsHandler.ts +37 -18
  189. package/src/services/TaskGraphActionRunner.ts +29 -1
@@ -1,4 +1,4 @@
1
- import { Field, ObjectType, Resolver, Root, Subscription } from 'type-graphql';
1
+ import { Ctx, Field, ObjectType, Resolver, Root, Subscription } from 'type-graphql';
2
2
  import { Metadata, UserInfo } from '@memberjunction/core';
3
3
 
4
4
  export const CACHE_INVALIDATION_TOPIC = 'CACHE_INVALIDATION';
@@ -151,6 +151,64 @@ export function cacheInvalidationFilter(data: {
151
151
  return entity.GetUserPermisions(user).CanRead;
152
152
  }
153
153
 
154
+ /** The part of a permission row and of a user this module reads, so the rule can be tested on plain objects. */
155
+ export interface ReadPermissionRow {
156
+ RoleID: string;
157
+ CanRead: boolean | null;
158
+ /** `Deny` rows subtract; they never carry the read the rule looks at. */
159
+ IsDeny?: boolean;
160
+ Type?: string | null;
161
+ ReadRLSFilterID: string | null;
162
+ }
163
+ export interface RoleHolder {
164
+ UserRoles?: ReadonlyArray<{ RoleID: string }> | null;
165
+ }
166
+
167
+ /**
168
+ * Is this user's read of the entity row-filtered? True when every read the user holds on the entity comes through a permission
169
+ * row that carries a `ReadRLSFilterID`: there is a row-level security filter between them and the rows, so a record's key, its
170
+ * existence and when it changed are not theirs to learn unless the row passes the filter — which a subscription filter cannot
171
+ * evaluate per event. False when any of their read rows is unfiltered (they could read the row anyway), and false when they hold
172
+ * no read at all (the entity-level filter has already withheld the event).
173
+ *
174
+ * The same in-memory match `GetUserPermisions` does, with no I/O, so it can run per subscriber per event.
175
+ */
176
+ export function ReadIsRowFiltered(entity: { Permissions: ReadonlyArray<ReadPermissionRow> }, user: RoleHolder): boolean {
177
+ const roles = new Set((user.UserRoles ?? []).map((role) => (role.RoleID ?? '').trim().toLowerCase()));
178
+ let filteredReads = 0;
179
+ for (const row of entity.Permissions) {
180
+ if (!row.CanRead) continue;
181
+ const isDeny = row.IsDeny ?? (row.Type ?? 'Allow').trim().toLowerCase() === 'deny';
182
+ if (isDeny) continue;
183
+ if (!roles.has((row.RoleID ?? '').trim().toLowerCase())) continue;
184
+ if (!row.ReadRLSFilterID) return false;
185
+ filteredReads += 1;
186
+ }
187
+ return filteredReads > 0;
188
+ }
189
+
190
+ /**
191
+ * The event as one subscriber may see it. A subscriber whose read of the entity is row-filtered gets the entity, the action and
192
+ * the time, and neither the key nor the row: "something in this entity changed", which the client already takes as a
193
+ * whole-entity invalidation (a null key on `remote-invalidate`). Everyone else gets the event as published.
194
+ */
195
+ export function ShapeCacheInvalidationEvent(
196
+ payload: CacheInvalidationPayload,
197
+ user: RoleHolder | undefined,
198
+ entity: { Permissions: ReadonlyArray<ReadPermissionRow> } | null | undefined,
199
+ ): CacheInvalidationNotification {
200
+ const withholdKey = !!user && !!entity && ReadIsRowFiltered(entity, user);
201
+ return {
202
+ EntityName: payload.entityName,
203
+ PrimaryKeyValues: withholdKey ? undefined : (payload.primaryKeyValues ?? undefined),
204
+ Action: payload.action,
205
+ SourceServerID: payload.sourceServerId,
206
+ Timestamp: payload.timestamp,
207
+ OriginSessionID: payload.originSessionId ?? undefined,
208
+ RecordData: withholdKey ? undefined : (payload.recordData ?? undefined),
209
+ };
210
+ }
211
+
154
212
  @Resolver()
155
213
  export class CacheInvalidationResolver {
156
214
  /**
@@ -173,30 +231,27 @@ export class CacheInvalidationResolver {
173
231
  * its stable key, and every time it changes; across entities it learns the cadence and volume
174
232
  * of another tenant's activity.
175
233
  *
176
- * The key is kept deliberately, because consumers need it to re-read the record they were
177
- * told about (see `ResolveEntityEventKey` in @memberjunction/core) and because the alternative
178
- * — a coarse "something in this entity changed" — forces whole-entity invalidation on the
179
- * hottest write paths. That is a trade, not an absence of cost. Narrowing it means filtering
180
- * delivery per subscriber, which needs an authorization predicate cheap enough to run per
181
- * subscriber per event; entity-level permissions (`EntityInfo.GetUserPermisions`, an in-memory
182
- * role match) would cover part of it, row-level tenancy would not.
234
+ * The key is kept for a subscriber who could read the row anyway, because consumers need it to
235
+ * re-read the record they were told about (see `ResolveEntityEventKey` in @memberjunction/core),
236
+ * and the coarse "something in this entity changed" forces whole-entity invalidation on the
237
+ * hottest write paths. It is withheld, with the row, from a subscriber whose every read of the
238
+ * entity goes through a row-level security filter ({@link ReadIsRowFiltered}): a filter the
239
+ * subscription cannot evaluate per event stands between them and the row, so they learn only
240
+ * that a row of the entity changed, and their caches refresh the entity as a whole. That is the
241
+ * same in-memory role match as the entity-level filter, once more per subscriber per event.
183
242
  */
184
243
  @Subscription(() => CacheInvalidationNotification, {
185
244
  topics: CACHE_INVALIDATION_TOPIC,
186
245
  filter: (data: { payload: CacheInvalidationPayload; context: CacheInvalidationFilterContext | undefined }) =>
187
246
  cacheInvalidationFilter(data),
188
247
  })
189
- cacheInvalidation(
190
- @Root() payload: CacheInvalidationPayload
248
+ cacheInvalidation( // case-violation-ok-legacy-back-compat: the property name is the GraphQL schema field name — renaming it breaks every client query
249
+ @Root() payload: CacheInvalidationPayload,
250
+ @Ctx() context: CacheInvalidationFilterContext | undefined
191
251
  ): CacheInvalidationNotification {
192
- return {
193
- EntityName: payload.entityName,
194
- PrimaryKeyValues: payload.primaryKeyValues ?? undefined,
195
- Action: payload.action,
196
- SourceServerID: payload.sourceServerId,
197
- Timestamp: payload.timestamp,
198
- OriginSessionID: payload.originSessionId ?? undefined,
199
- RecordData: payload.recordData ?? undefined,
200
- };
252
+ const user = context?.userPayload?.userRecord as UserInfo | undefined;
253
+ // The same server-wide metadata the filter read; an entity unknown here was delivered whole by the filter's own choice
254
+ const entity = payload?.entityName ? new Metadata().EntityByName(payload.entityName) : null; // global-provider-ok: subscription resolver — server-wide metadata, no request-scoped provider exists here
255
+ return ShapeCacheInvalidationEvent(payload, user, entity);
201
256
  }
202
257
  }
@@ -10,6 +10,7 @@ import {
10
10
  EntitySaveOptions,
11
11
  IMetadataProvider,
12
12
  IRunViewProvider,
13
+ KeyValuePair,
13
14
  LogDebug,
14
15
  LogError,
15
16
  LogStatus,
@@ -24,7 +25,7 @@ import {
24
25
  } from '@memberjunction/core';
25
26
  import { MJAuditLogEntity, MJErrorLogEntity, MJUserViewEntityExtended } from '@memberjunction/core-entities';
26
27
  import { SQLServerDataProvider } from '@memberjunction/sqlserver-dataprovider';
27
- import { UserCache } from '@memberjunction/generic-database-provider';
28
+ import { ClientClauseScreen, UserCache, type EntityBaseViewAllowList } from '@memberjunction/generic-database-provider';
28
29
  import { PubSubEngine, AuthorizationError } from 'type-graphql';
29
30
  import { GraphQLError } from 'graphql';
30
31
  import { RefusalExtensions } from './refusalExtensions.js';
@@ -36,8 +37,8 @@ import { RunViewGenericParams, UserPayload } from '../types.js';
36
37
  import { RunDynamicViewInput, RunViewByIDInput, RunViewByNameInput } from './RunViewResolver.js';
37
38
  import { DeleteOptionsInput } from './DeleteOptionsInput.js';
38
39
  import { MJEvent, MJEventType, MJGlobal, ENCRYPTED_SENTINEL, EscapeSQLString, IsValueEncrypted, IsOnlyTimezoneShift } from '@memberjunction/global';
39
- import { SQLParser } from '@memberjunction/sql-parser';
40
- import { PostgreSQLDialect, SQLServerDialect, type SQLParserDialect } from '@memberjunction/sql-dialect';
40
+ import { SQLParser, IsBalancedSQLFragment, LexSQL } from '@memberjunction/sql-parser';
41
+ import type { SQLParserDialect } from '@memberjunction/sql-dialect';
41
42
  import { EncryptionEngine } from '@memberjunction/encryption';
42
43
  import { PUSH_STATUS_UPDATES_TOPIC, publishStatusUpdate } from './PushStatusResolver.js';
43
44
  import { CACHE_INVALIDATION_TOPIC, MayBroadcastRecordData } from './CacheInvalidationResolver.js';
@@ -45,6 +46,15 @@ import { PubSubManager } from './PubSubManager.js';
45
46
  import { FieldMapper } from '@memberjunction/graphql-dataprovider';
46
47
  import { Subscription } from 'rxjs';
47
48
 
49
+ /**
50
+ * Entities whose update is always hydrated from the stored row, never from the client's `OldValues___`, though they
51
+ * do not track record changes: their server-side write rules compare against the prior state, which a client could
52
+ * otherwise supply itself (lower-case names). See `ResolverBase.MustLoadTruthFromDatabase`.
53
+ */
54
+ const ENTITIES_THAT_ALWAYS_LOAD_STORED_ROW: ReadonlySet<string> = new Set([
55
+ 'mj: data context items',
56
+ ]);
57
+
48
58
  export class ResolverBase {
49
59
  private static _emit = process.env.CLOUDEVENTS_HTTP_TRANSPORT ? emitterFor(httpTransport(process.env.CLOUDEVENTS_HTTP_TRANSPORT)) : null;
50
60
  private static _cloudeventsHeaders = process.env.CLOUDEVENTS_HTTP_HEADERS ? JSON.parse(process.env.CLOUDEVENTS_HTTP_HEADERS) : {};
@@ -357,13 +367,16 @@ export class ResolverBase {
357
367
  * @param dataObject - The data object containing field values
358
368
  * @param encryptionEngine - Optional encryption engine for re-encryption (lazy loaded if needed)
359
369
  * @param contextUser - User context for encryption operations
370
+ * @param fieldKey - What the object's keys are: field CodeNames (GraphQL transport rows, the
371
+ * default) or field Names (raw provider rows, such as dataset item results)
360
372
  * @returns The filtered data object
361
373
  */
362
374
  protected async FilterEncryptedFieldsForAPI(
363
375
  entityName: string,
364
376
  dataObject: Record<string, unknown>,
365
377
  contextUser: UserInfo,
366
- provider?: IMetadataProvider
378
+ provider?: IMetadataProvider,
379
+ fieldKey: 'CodeName' | 'Name' = 'CodeName'
367
380
  ): Promise<Record<string, unknown>> {
368
381
  if (!dataObject) return dataObject;
369
382
 
@@ -377,7 +390,7 @@ export class ResolverBase {
377
390
 
378
391
  // Process each encrypted field
379
392
  for (const field of encryptedFields) {
380
- const fieldName = field.CodeName;
393
+ const fieldName = fieldKey === 'Name' ? field.Name : field.CodeName;
381
394
  const value = dataObject[fieldName];
382
395
 
383
396
  // Skip null/undefined values
@@ -418,13 +431,15 @@ export class ResolverBase {
418
431
  }
419
432
 
420
433
  /**
421
- * Filters encrypted fields for an array of data objects
434
+ * Filters encrypted fields for an array of data objects. `fieldKey` is as for
435
+ * {@link FilterEncryptedFieldsForAPI}.
422
436
  */
423
437
  protected async ArrayFilterEncryptedFieldsForAPI(
424
438
  entityName: string,
425
439
  dataObjectArray: Record<string, unknown>[],
426
440
  contextUser: UserInfo,
427
- provider?: IMetadataProvider
441
+ provider?: IMetadataProvider,
442
+ fieldKey: 'CodeName' | 'Name' = 'CodeName'
428
443
  ): Promise<Record<string, unknown>[]> {
429
444
  if (!dataObjectArray || dataObjectArray.length === 0) return dataObjectArray;
430
445
 
@@ -438,7 +453,7 @@ export class ResolverBase {
438
453
 
439
454
  // Process each element
440
455
  for (const element of dataObjectArray) {
441
- await this.FilterEncryptedFieldsForAPI(entityName, element, contextUser, provider);
456
+ await this.FilterEncryptedFieldsForAPI(entityName, element, contextUser, provider, fieldKey);
442
457
  }
443
458
 
444
459
  return dataObjectArray;
@@ -912,53 +927,107 @@ export class ResolverBase {
912
927
  * This screen uses `@memberjunction/sql-parser` (same wrap as EDS `assertReadOnlyClause`):
913
928
  * wrap the fragment as a single SELECT, fail closed if it does not parse as a read, then
914
929
  * allow a FROM only when it is an entity **BaseView**. Base tables (`Meeting`, `__mj.User`)
915
- * and catalogs are rejected. Server-internal RunView callers never hit this.
930
+ * and catalogs are rejected. Server-internal RunView callers get only the provider's
931
+ * statement-separator check, which cannot refuse a legitimate filter.
932
+ *
933
+ * The screen is `ClientClauseScreen` in `@memberjunction/generic-database-provider`, shared
934
+ * with the REST view routes, ad-hoc list sources, and the provider's saved-view checks.
935
+ *
936
+ * Before that parse, the fragment must be balanced on its own (`IsBalancedSQLFragment`). The
937
+ * provider and the tenant hook splice it inside parentheses and AND their own filters after
938
+ * it, so a fragment that closes those parentheses (`1=1) OR (1=1`) would leave row-level
939
+ * security, API-key row filters and tenant scope on one side of an OR the caller controls.
940
+ * The parse wraps the fragment in parentheses too, which is why it cannot catch this alone.
941
+ * A backslash inside a quoted string or identifier is refused as well: the parser reads `\'`
942
+ * inside a plain literal as an escaped quote, but SQL Server and PostgreSQL end the literal
943
+ * there, so the text after it could be code that the parse-based checks never saw.
916
944
  *
917
945
  * RLS is applied by RunView as an outer WHERE around the entity being queried, not compiled
918
- * into the view. Subqueries against another entity's BaseView therefore do not inherit that
919
- * entity's RLS; they are still restricted to the view (not the table).
946
+ * into the view, and RunView removes denied fields from its own result only. A subquery reads a
947
+ * base view directly with neither, so the screen refuses one over any entity the user reads under
948
+ * a row filter or with denied fields (`ClientClauseScreen.AssertUnscopedRead`), the queried
949
+ * entity included.
920
950
  */
921
951
  protected assertClientClauseUsesEntityBaseViews(
922
952
  clause: string | undefined | null,
923
953
  label: string,
924
954
  provider?: IMetadataProvider,
955
+ user?: UserInfo,
956
+ options?: { LeaveUnparseableToProvider?: boolean },
925
957
  ): void {
926
958
  if (!clause?.trim()) return;
927
959
 
960
+ // The balance and backslash checks run before the shared screen: it parses the fragment
961
+ // inside its own parentheses, so it cannot see a fragment that closes the parentheses the
962
+ // provider and the tenant hook splice it into.
928
963
  const dialect = this.dialectForProvider(provider);
929
964
  if (SQLParser.HasStackedStatements(clause, dialect)) {
930
965
  throw new Error(`Invalid ${label}: multiple statements are not permitted in client-supplied filters`);
931
966
  }
967
+ const balance = IsBalancedSQLFragment(clause, dialect);
968
+ if (!balance.IsBalanced) {
969
+ throw new Error(`Invalid ${label}: ${balance.Reason}`);
970
+ }
971
+ if (this.hasBackslashInQuotes(clause, dialect)) {
972
+ throw new Error(`Invalid ${label}: a backslash inside a quoted string or identifier is not permitted in client-supplied filters`);
973
+ }
932
974
 
933
- const wrapped =
934
- label === 'OrderBy'
935
- ? `SELECT 1 FROM __mj_clause_screen ORDER BY ${clause}`
936
- : `SELECT 1 FROM __mj_clause_screen WHERE (${clause})`;
937
- const parser = new SQLParser(wrapped, dialect);
975
+ ClientClauseScreen.AssertClauseUsesEntityBaseViews(clause, label, provider?.Entities ?? [], dialect, user, options);
976
+ }
977
+
978
+ /**
979
+ * SECURITY — screen for a FULL client-supplied SQL statement (ad-hoc query path). In addition
980
+ * to the SELECT-only validation the caller performs, every table reference must resolve to an
981
+ * entity BaseView the acting user can read UNSCOPED. A reference is excluded only when it reads a
982
+ * CTE that is in scope there (`StatementRelation.IsCTEReference`): unqualified, declared by its
983
+ * own SELECT or an enclosing one, and matched by the dialect's identifier rules. A name reserved
984
+ * for the SQL parser is refused before parsing (`ClientClauseScreen.AssertNoIdentifierAliasNames`).
985
+ * Fails closed: no user, or an unresolvable reference, refuses.
986
+ *
987
+ * The references come from a walk of the whole parse tree
988
+ * (`ClientClauseScreen.CollectStatementReads`). The statement is refused when that walk does not
989
+ * account for every SELECT in the text, or when a FROM item names no table (a table function or
990
+ * a row list).
991
+ *
992
+ * Entity CanRead is not unscoped base-view read authority: RunView narrows a granted read with
993
+ * row-level security and denied-field projection, and raw SQL applies neither. Rather than try
994
+ * to rewrite arbitrary SQL (joins, CTEs) to compose that policy, this refuses any entity that
995
+ * carries a row filter or a denied field for the caller — those reads belong on RunView. For
996
+ * the remaining entities CanRead genuinely means "every row and field of the view".
997
+ */
998
+ protected assertFullQueryUsesReadableEntityViews(
999
+ sqlText: string,
1000
+ provider: IMetadataProvider | undefined,
1001
+ user: UserInfo | undefined,
1002
+ label = 'SQL',
1003
+ ): void {
1004
+ if (!user) {
1005
+ throw new Error(`Invalid ${label}: no acting user resolved for ad-hoc SQL — refusing`);
1006
+ }
1007
+ const dialect = this.dialectForProvider(provider);
1008
+ ClientClauseScreen.AssertNoIdentifierAliasNames(sqlText, dialect, label);
1009
+ const parser = new SQLParser(sqlText, dialect);
938
1010
  if (!parser.IsValid || parser.HasWriteStatement || parser.StatementKind !== 'select') {
939
- throw new Error(
940
- `Invalid ${label}: not a safe read-only filter fragment — refusing under uncertainty`,
941
- );
1011
+ throw new Error(`Invalid ${label}: not a safe read-only statement — refusing under uncertainty`);
942
1012
  }
943
1013
  if (this.astContainsWriteNode(parser.AST)) {
944
1014
  throw new Error(`Invalid ${label}: write/DDL nested in a subquery is not permitted`);
945
1015
  }
946
-
1016
+ const reads = ClientClauseScreen.CollectStatementReads(parser);
1017
+ ClientClauseScreen.AssertEverySelectAccountedFor(sqlText, dialect, reads, label);
947
1018
  const allowed = this.entityBaseViewAllowList(provider);
948
- const tables = SQLParser.ExtractTableRefs(wrapped, dialect);
949
- for (const t of tables) {
950
- const table = this.stripSqlIdent(t.TableName);
951
- const schema = this.stripSqlIdent(t.SchemaName);
952
- if (table.toLowerCase() === '__mj_clause_screen') continue;
953
- const qualified = `${schema}.${table}`.toLowerCase();
954
- const bare = table.toLowerCase();
955
- if (allowed.qualified.has(qualified) || allowed.bare.has(bare)) continue;
956
- throw new Error(
957
- `Invalid ${label}: subquery must use an entity base view, not '${schema}.${table}'`,
958
- );
1019
+ for (const relation of reads.Relations) {
1020
+ if (relation.IsCTEReference) continue;
1021
+ const entity = ClientClauseScreen.AssertRelationReadable(allowed, relation, label, user);
1022
+ if (entity) this.assertUnscopedRead(entity, user, label);
959
1023
  }
960
1024
  }
961
1025
 
1026
+ /** Refuses an entity whose read is narrowed for this user; see `ClientClauseScreen.AssertUnscopedRead`. */
1027
+ private assertUnscopedRead(entity: EntityInfo, user: UserInfo, label: string): void {
1028
+ ClientClauseScreen.AssertUnscopedRead(entity, user, label);
1029
+ }
1030
+
962
1031
  /**
963
1032
  * Applies {@link assertClientClauseUsesEntityBaseViews} to every client-supplied clause
964
1033
  * a view request can carry that is actually a SQL fragment. GraphQL entry points
@@ -980,6 +1049,11 @@ export class ResolverBase {
980
1049
  * The single place that is not true is a field carrying `UserSearchParamFormatAPI`, whose
981
1050
  * admin-authored format may splice the term in unquoted; `createViewUserSearchSQL` re-applies
982
1051
  * `ValidateUserProvidedSQLClause` for exactly those entities.
1052
+ *
1053
+ * Each `Aggregates[].expression` gets the same screen, except that text the parser cannot read
1054
+ * goes on to the provider, which refuses it per aggregate. The provider never runs the caller's
1055
+ * aggregate text: it runs the SQL that `CheckAggregateExpression` rebuilds from a single
1056
+ * aggregate call over the entity's columns, and refuses everything else per aggregate.
983
1057
  */
984
1058
  protected screenClientViewClauses(
985
1059
  clauses: {
@@ -987,57 +1061,39 @@ export class ResolverBase {
987
1061
  orderBy?: string | null;
988
1062
  userSearchString?: string | null;
989
1063
  overrideExcludeFilter?: string | null;
1064
+ aggregates?: AggregateExpression[] | null;
990
1065
  },
991
1066
  provider?: IMetadataProvider,
1067
+ user?: UserInfo,
992
1068
  ): void {
993
- this.assertClientClauseUsesEntityBaseViews(clauses.extraFilter, 'ExtraFilter', provider);
994
- this.assertClientClauseUsesEntityBaseViews(clauses.orderBy, 'OrderBy', provider);
995
- this.assertClientClauseUsesEntityBaseViews(clauses.overrideExcludeFilter, 'OverrideExcludeFilter', provider);
996
- }
997
-
998
- /** Same write-node walk as EDS `sqlReadOnlyScreen.astContainsWriteNode`. */
999
- private astContainsWriteNode(node: unknown): boolean {
1000
- if (!node || typeof node !== 'object') return false;
1001
- if (Array.isArray(node)) return node.some((n) => this.astContainsWriteNode(n));
1002
- const obj = node as Record<string, unknown>;
1003
- const type = obj.type;
1004
- if (typeof type === 'string' && ResolverBase.WRITE_NODE_TYPES.has(type.toLowerCase())) {
1005
- return true;
1069
+ this.assertClientClauseUsesEntityBaseViews(clauses.extraFilter, 'ExtraFilter', provider, user);
1070
+ this.assertClientClauseUsesEntityBaseViews(clauses.orderBy, 'OrderBy', provider, user);
1071
+ this.assertClientClauseUsesEntityBaseViews(clauses.overrideExcludeFilter, 'OverrideExcludeFilter', provider, user);
1072
+ for (const aggregate of clauses.aggregates ?? []) {
1073
+ this.assertClientClauseUsesEntityBaseViews(aggregate?.expression, 'Aggregate', provider, user, { LeaveUnparseableToProvider: true });
1006
1074
  }
1007
- return Object.values(obj).some((v) => this.astContainsWriteNode(v));
1008
1075
  }
1009
1076
 
1010
- private static readonly WRITE_NODE_TYPES = new Set<string>([
1011
- 'insert', 'update', 'delete', 'merge', 'replace', 'drop', 'create', 'alter', 'truncate',
1012
- 'rename', 'call', 'exec', 'execute', 'grant', 'revoke', 'use', 'load', 'copy', 'do',
1013
- ]);
1077
+ /**
1078
+ * True when a string literal or quoted identifier in `clause`, read the way the database reads
1079
+ * it, contains a backslash (see {@link assertClientClauseUsesEntityBaseViews}).
1080
+ */
1081
+ private hasBackslashInQuotes(clause: string, dialect: SQLParserDialect): boolean {
1082
+ return LexSQL(clause, dialect).some((t) => (t.Kind === 'string' || t.Kind === 'identifier') && t.Text.includes('\\'));
1083
+ }
1014
1084
 
1015
- private dialectForProvider(provider?: IMetadataProvider): SQLParserDialect {
1016
- const name = provider?.constructor?.name ?? '';
1017
- if (/postgres/i.test(name)) return new PostgreSQLDialect();
1018
- return new SQLServerDialect();
1085
+ /** Write/DDL node walk shared with the clause screen; see `ClientClauseScreen.AstContainsWriteNode`. */
1086
+ private astContainsWriteNode(node: unknown): boolean {
1087
+ return ClientClauseScreen.AstContainsWriteNode(node);
1019
1088
  }
1020
1089
 
1021
- private stripSqlIdent(name: string | null | undefined): string {
1022
- if (!name) return '';
1023
- return name.replace(/^\[|\]$/g, '').replace(/^"|"$/g, '').replace(/^`|`$/g, '');
1090
+ private dialectForProvider(provider?: IMetadataProvider): SQLParserDialect {
1091
+ return ClientClauseScreen.DialectFor(provider);
1024
1092
  }
1025
1093
 
1026
- private entityBaseViewAllowList(provider?: IMetadataProvider): {
1027
- qualified: Set<string>;
1028
- bare: Set<string>;
1029
- } {
1030
- const qualified = new Set<string>();
1031
- const bare = new Set<string>();
1032
- const entities = provider?.Entities ?? [];
1033
- for (const e of entities) {
1034
- const view = this.stripSqlIdent(e.BaseView);
1035
- if (!view) continue;
1036
- const schema = this.stripSqlIdent(e.SchemaName);
1037
- bare.add(view.toLowerCase());
1038
- if (schema) qualified.add(`${schema}.${view}`.toLowerCase());
1039
- }
1040
- return { qualified, bare };
1094
+ /** The entity base-view allow-list for a provider's entities. */
1095
+ private entityBaseViewAllowList(provider?: IMetadataProvider): EntityBaseViewAllowList {
1096
+ return ClientClauseScreen.BuildEntityBaseViewAllowList(provider?.Entities ?? []);
1041
1097
  }
1042
1098
 
1043
1099
  /**
@@ -1071,24 +1127,27 @@ export class ResolverBase {
1071
1127
  try {
1072
1128
  if (!viewInfo || !userPayload) return null;
1073
1129
 
1130
+ // Prefer the authenticated session's payload user — it is the authoritative per-request
1131
+ // identity and (for magic-link sessions) carries the per-session resource scope / synthesized
1132
+ // roles that drive RLS. The cached lookup is a fallback for paths where the payload user
1133
+ // isn't populated. For normal users the two are the same instance, so this is a no-op.
1134
+ const user = this.GetUserFromPayload(userPayload)
1135
+ ?? UserCache.Users.find((u) => u.Email.toLowerCase().trim() === userPayload?.email.toLowerCase().trim());
1136
+ if (!user) throw new Error(`User ${userPayload?.email} not found in metadata`);
1137
+
1074
1138
  // SECURITY: GraphQL ExtraFilter may contain IN (SELECT … FROM <base view>).
1075
- // Screen at this boundary: parse, reject writes, allow only entity BaseViews.
1139
+ // Screen at this boundary: parse, reject writes, allow only entity BaseViews the
1140
+ // acting user can read (subqueries do not inherit the top entity's permission check).
1076
1141
  this.screenClientViewClauses(
1077
- { extraFilter, orderBy, userSearchString, overrideExcludeFilter },
1142
+ { extraFilter, orderBy, userSearchString, overrideExcludeFilter, aggregates },
1078
1143
  provider as unknown as IMetadataProvider,
1144
+ user,
1079
1145
  );
1080
1146
 
1081
1147
  // Check API key scope authorization for view operations
1082
1148
  await this.CheckAPIKeyScopeAuthorization('view:run', viewInfo.Entity, userPayload);
1083
1149
 
1084
1150
  const md = provider
1085
- // Prefer the authenticated session's payload user — it is the authoritative per-request
1086
- // identity and (for magic-link sessions) carries the per-session resource scope / synthesized
1087
- // roles that drive RLS. The cached lookup is a fallback for paths where the payload user
1088
- // isn't populated. For normal users the two are the same instance, so this is a no-op.
1089
- const user = this.GetUserFromPayload(userPayload)
1090
- ?? UserCache.Users.find((u) => u.Email.toLowerCase().trim() === userPayload?.email.toLowerCase().trim());
1091
- if (!user) throw new Error(`User ${userPayload?.email} not found in metadata`);
1092
1151
 
1093
1152
  const entityInfo = md.Entities.find((e) => e.Name === viewInfo.Entity);
1094
1153
  if (!entityInfo) throw new Error(`Entity ${viewInfo.Entity} not found in metadata`);
@@ -1246,8 +1305,10 @@ export class ResolverBase {
1246
1305
  orderBy: param.orderBy,
1247
1306
  userSearchString: param.userSearchString,
1248
1307
  overrideExcludeFilter: param.overrideExcludeFilter,
1308
+ aggregates: param.aggregates,
1249
1309
  },
1250
1310
  md,
1311
+ contextUser ?? undefined,
1251
1312
  );
1252
1313
 
1253
1314
  if (param.viewInfo) {
@@ -1529,6 +1590,62 @@ export class ResolverBase {
1529
1590
  }
1530
1591
  }
1531
1592
 
1593
+ /**
1594
+ * IS-A PROMOTION over the wire: an EXISTING parent record gaining a subtype — an Animal that is
1595
+ * now also a Dog. It arrives as the CHILD's create mutation carrying the existing parent's
1596
+ * primary key, because in IS-A the shared key is the relationship and the child has no key of
1597
+ * its own to mint. `NewRecord()` has just reset the whole chain to "new", so left alone the
1598
+ * parent would save as a CREATE: a second copy of a row that already exists (a unique-constraint
1599
+ * violation at best, a silently duplicated parent at worst).
1600
+ *
1601
+ * When a child create carries a complete primary key, bind the new child to the existing parent
1602
+ * row with `AttachToParent` (#3825): it loads the parent chain, so the parent saves as an UPDATE
1603
+ * and only the child is INSERTed. A key that matches no row leaves the fresh chain exactly as
1604
+ * `NewRecord()` built it — the ordinary whole-chain create. A non-IS-A entity, or a create with
1605
+ * no key, never gets past the guards.
1606
+ *
1607
+ * @returns true when the child was attached to an existing parent row.
1608
+ */
1609
+ protected async attachToExistingParentIfPromotion(entityObject: BaseEntity, input: Record<string, unknown>): Promise<boolean> {
1610
+ if (!entityObject.EntityInfo.IsChildType) {
1611
+ return false;
1612
+ }
1613
+ const key = this.primaryKeyFromInput(entityObject.EntityInfo, input);
1614
+ if (!key) {
1615
+ return false;
1616
+ }
1617
+ const attached = await entityObject.AttachToParent(key);
1618
+ if (!attached) {
1619
+ // No parent row under that key: this is a whole-chain create on the CALLER's key (the
1620
+ // client mints the shared key at the root and sends it, so honoring it keeps the client's
1621
+ // in-memory chain and the stored rows on one key). AttachToParent restored the fresh chain,
1622
+ // but the root's ReadOnly key slot is no longer writable — a later SetMany would silently
1623
+ // keep the server-minted value — so re-seed the chain with the supplied key explicitly.
1624
+ entityObject.NewRecord(key);
1625
+ }
1626
+ return attached;
1627
+ }
1628
+
1629
+ /**
1630
+ * The entity's primary key as supplied on a mutation input, or null when any part of it is
1631
+ * missing. Composite keys are all-or-nothing: a half-specified key identifies nothing. An empty
1632
+ * string counts as missing, the same as null — it is never a valid key value, and a GUID or
1633
+ * string key that is "" identifies no row, so the create proceeds as a whole-chain create rather
1634
+ * than failing the lookup. A malformed non-empty key (e.g. a non-GUID for a uniqueidentifier)
1635
+ * is NOT swallowed: the load throws and the mutation fails loudly.
1636
+ */
1637
+ protected primaryKeyFromInput(entityInfo: EntityInfo, input: Record<string, unknown>): CompositeKey | null {
1638
+ const pairs: KeyValuePair[] = [];
1639
+ for (const pk of entityInfo.PrimaryKeys) {
1640
+ const value = input[pk.Name] ?? input[pk.CodeName];
1641
+ if (value === null || value === undefined || value === '') {
1642
+ return null;
1643
+ }
1644
+ pairs.push(new KeyValuePair(pk.Name, value));
1645
+ }
1646
+ return pairs.length > 0 ? CompositeKey.FromKeyValuePairs(pairs) : null;
1647
+ }
1648
+
1532
1649
  protected async CreateRecord(entityName: string, input: any, provider: DatabaseProviderBase, userPayload: UserPayload, pubSub: PubSubEngine) {
1533
1650
  // Check API key scope authorization for entity create operations
1534
1651
  await this.CheckAPIKeyScopeAuthorization('entity:create', entityName, userPayload);
@@ -1546,6 +1663,10 @@ export class ResolverBase {
1546
1663
  for (const key of Object.keys(input)) {
1547
1664
  if (key !== 'RestoreContext___') fieldsForSet[key] = input[key];
1548
1665
  }
1666
+ // IS-A promotion: bind the new child to its EXISTING parent row BEFORE the field
1667
+ // assignments, so values the client sent for parent fields land on the loaded parent
1668
+ // (as an update) instead of being wiped by the load.
1669
+ await this.attachToExistingParentIfPromotion(entityObject, fieldsForSet);
1549
1670
  entityObject.SetMany(fieldsForSet);
1550
1671
 
1551
1672
  // Reconstruct the client-side restore context, if any, on this server
@@ -1711,6 +1832,11 @@ export class ResolverBase {
1711
1832
  *
1712
1833
  * Ordered so the boolean flag is evaluated last: the extra load lands only on entities that have
1713
1834
  * the feature switched on, which is almost none of them.
1835
+ *
1836
+ * `MJ: Data Context Items` always loads from the database: its server class lets only an
1837
+ * administrator save an item that holds SQL, and decides that from the item's `Type` and `SQL`.
1838
+ * Hydrated from OldValues, a client could leave both out, and the stored procedure would keep the
1839
+ * stored SQL item while the rule saw none. See {@link ENTITIES_THAT_ALWAYS_LOAD_STORED_ROW}.
1714
1840
  */
1715
1841
  protected MustLoadTruthFromDatabase(
1716
1842
  entityInfo: EntityInfo,
@@ -1720,6 +1846,7 @@ export class ResolverBase {
1720
1846
  ): boolean {
1721
1847
  return (
1722
1848
  entityInfo.TrackRecordChanges ||
1849
+ ENTITIES_THAT_ALWAYS_LOAD_STORED_ROW.has(entityInfo.Name.trim().toLowerCase()) ||
1723
1850
  !input.OldValues___ ||
1724
1851
  hasDeniedReadFields ||
1725
1852
  hasNarrowedAuditPayload ||
@@ -1184,6 +1184,8 @@ export class RunViewResolver extends ResolverBase {
1184
1184
  @Ctx() { providers, userPayload }: AppContext
1185
1185
  ): Promise<RunViewsWithCacheCheckOutput> {
1186
1186
  try {
1187
+ // Same scope as RunViews: this is the transport RunViews uses for a CacheLocal batch.
1188
+ await this.CheckAPIKeyScopeAuthorization('view:batch', '*', userPayload);
1187
1189
  const provider = GetReadOnlyProvider(providers, { allowFallbackToReadWrite: true });
1188
1190
 
1189
1191
  // Cast provider to SQLServerDataProvider to access RunViewsWithCacheCheck method
@@ -1192,6 +1194,21 @@ export class RunViewResolver extends ResolverBase {
1192
1194
  throw new Error('Provider does not support RunViewsWithCacheCheck');
1193
1195
  }
1194
1196
 
1197
+ // SECURITY: same GraphQL-boundary clause screen as RunViews, before the provider runs.
1198
+ const user = this.GetUserFromPayload(userPayload);
1199
+ for (const item of input) {
1200
+ this.screenClientViewClauses(
1201
+ {
1202
+ extraFilter: item.params.ExtraFilter,
1203
+ orderBy: item.params.OrderBy,
1204
+ userSearchString: item.params.UserSearchString,
1205
+ overrideExcludeFilter: item.params.OverrideExcludeFilter,
1206
+ },
1207
+ provider,
1208
+ user,
1209
+ );
1210
+ }
1211
+
1195
1212
  // Convert GraphQL input types to core types
1196
1213
  const coreParams: RunViewWithCacheCheckParams[] = input.map(item => ({
1197
1214
  params: {
package/src/index.ts CHANGED
@@ -34,7 +34,7 @@ import { configInfo, configFilePath, dbDatabase, dbHost, dbPort, dbUsername, gra
34
34
  import { TranslateBracketsToPG } from './postgresqlCompat.js';
35
35
  import { BuildPostgreSQLConnectionConfig, DescribeReadOnlyLoginOverreach, PostgreSQLReadOnlyPool, ResolvePostgreSQLEndpoint, ResolvePostgreSQLReadOnlyCredentials, ToPGPoolConfig } from './postgresqlPoolSettings.js';
36
36
  import { default as jwt } from 'jsonwebtoken';
37
- import { contextFunction, createUnifiedAuthMiddleware, getUserPayload } from './context.js';
37
+ import { contextFunction, CreateReadOnlyProvider, createUnifiedAuthMiddleware, getUserPayload } from './context.js';
38
38
  import { UserPayload } from './types.js';
39
39
  import { requireSystemUserDirective, publicDirective } from './directives/index.js';
40
40
  import { variablesLoggingMiddleware } from './logging/variablesLoggingMiddleware.js';
@@ -62,6 +62,8 @@ import { createAuthProviderCatalogRouter, AUTH_CATALOG_MOUNT_PATH } from './auth
62
62
  import { GetAPIKeyEngine } from '@memberjunction/api-keys';
63
63
  import { RedisLocalStorageProvider } from '@memberjunction/redis-provider';
64
64
  import { GenericDatabaseProvider } from '@memberjunction/generic-database-provider';
65
+ import { ActionEngineServer } from '@memberjunction/actions';
66
+ import { CreateAdhocSQLAuthorizer } from './resolvers/AdhocQueryResolver.js';
65
67
  import { PubSubManager } from './generic/PubSubManager.js';
66
68
  import { IntegrationProgressEmitter } from '@memberjunction/integration-progress-artifacts';
67
69
  import { PublishIntegrationProgress } from './resolvers/IntegrationProgressResolver.js';
@@ -110,6 +112,7 @@ export * from 'type-graphql';
110
112
  // later importers, which makes schema build fail on ViewResult.RowCount.
111
113
  export { Int, Float, ID } from 'type-graphql';
112
114
  export { NewUserBase } from './auth/newUsers.js';
115
+ export { CreateIsolatedProvider } from './isolatedProvider.js';
113
116
  export { configInfo, DEFAULT_SERVER_CONFIG } from './config.js';
114
117
  export { ServerExtensionLoader, BaseServerExtension, DefaultServerExtensionServiceRegistry } from '@memberjunction/server-extensions-core';
115
118
  export type {
@@ -737,6 +740,11 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
737
740
  // This must happen before any request handler calls GetAPIKeyEngine()
738
741
  GetAPIKeyEngine();
739
742
 
743
+ // Actions that run caller-supplied SQL (Run Ad-hoc Query) run only on the read-only login, with
744
+ // ExecuteAdhocQuery's table check and timeout limit.
745
+ ActionEngineServer.Instance.SetReadOnlyProviderFactory(() => CreateReadOnlyProvider(dataSources));
746
+ ActionEngineServer.Instance.SetAdhocSQLAuthorizer(CreateAdhocSQLAuthorizer(configInfo.databaseSettings.requestTimeout));
747
+
740
748
  setupComplete$.next(true);
741
749
  raiseEvent('setupComplete', dataSources, null, this);
742
750