@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,5 +1,5 @@
1
- import { BaseEntity, BaseEntityEvent, CompositeKey, EntityFieldTSType, LogDebug, LogError, LogStatus, Metadata, ReadableFieldsTransportKey, RecordChangeFieldSecurityProjector, RecordChangePayloadFields, } from '@memberjunction/core';
2
- import { UserCache } from '@memberjunction/generic-database-provider';
1
+ import { BaseEntity, BaseEntityEvent, CompositeKey, EntityFieldTSType, KeyValuePair, LogDebug, LogError, LogStatus, Metadata, ReadableFieldsTransportKey, RecordChangeFieldSecurityProjector, RecordChangePayloadFields, } from '@memberjunction/core';
2
+ import { ClientClauseScreen, UserCache } from '@memberjunction/generic-database-provider';
3
3
  import { AuthorizationError } from 'type-graphql';
4
4
  import { GraphQLError } from 'graphql';
5
5
  import { RefusalExtensions } from './refusalExtensions.js';
@@ -7,13 +7,20 @@ import { GetAPIKeyEngine } from '@memberjunction/api-keys';
7
7
  import { httpTransport, CloudEvent, emitterFor } from 'cloudevents';
8
8
  import { DeleteOptionsInput } from './DeleteOptionsInput.js';
9
9
  import { MJEventType, MJGlobal, ENCRYPTED_SENTINEL, EscapeSQLString, IsValueEncrypted, IsOnlyTimezoneShift } from '@memberjunction/global';
10
- import { SQLParser } from '@memberjunction/sql-parser';
11
- import { PostgreSQLDialect, SQLServerDialect } from '@memberjunction/sql-dialect';
10
+ import { SQLParser, IsBalancedSQLFragment, LexSQL } from '@memberjunction/sql-parser';
12
11
  import { EncryptionEngine } from '@memberjunction/encryption';
13
12
  import { publishStatusUpdate } from './PushStatusResolver.js';
14
13
  import { CACHE_INVALIDATION_TOPIC, MayBroadcastRecordData } from './CacheInvalidationResolver.js';
15
14
  import { PubSubManager } from './PubSubManager.js';
16
15
  import { FieldMapper } from '@memberjunction/graphql-dataprovider';
16
+ /**
17
+ * Entities whose update is always hydrated from the stored row, never from the client's `OldValues___`, though they
18
+ * do not track record changes: their server-side write rules compare against the prior state, which a client could
19
+ * otherwise supply itself (lower-case names). See `ResolverBase.MustLoadTruthFromDatabase`.
20
+ */
21
+ const ENTITIES_THAT_ALWAYS_LOAD_STORED_ROW = new Set([
22
+ 'mj: data context items',
23
+ ]);
17
24
  export class ResolverBase {
18
25
  static { this._emit = process.env.CLOUDEVENTS_HTTP_TRANSPORT ? emitterFor(httpTransport(process.env.CLOUDEVENTS_HTTP_TRANSPORT)) : null; }
19
26
  static { this._cloudeventsHeaders = process.env.CLOUDEVENTS_HTTP_HEADERS ? JSON.parse(process.env.CLOUDEVENTS_HTTP_HEADERS) : {}; }
@@ -299,9 +306,11 @@ export class ResolverBase {
299
306
  * @param dataObject - The data object containing field values
300
307
  * @param encryptionEngine - Optional encryption engine for re-encryption (lazy loaded if needed)
301
308
  * @param contextUser - User context for encryption operations
309
+ * @param fieldKey - What the object's keys are: field CodeNames (GraphQL transport rows, the
310
+ * default) or field Names (raw provider rows, such as dataset item results)
302
311
  * @returns The filtered data object
303
312
  */
304
- async FilterEncryptedFieldsForAPI(entityName, dataObject, contextUser, provider) {
313
+ async FilterEncryptedFieldsForAPI(entityName, dataObject, contextUser, provider, fieldKey = 'CodeName') {
305
314
  if (!dataObject)
306
315
  return dataObject;
307
316
  const md = provider ?? new Metadata();
@@ -314,7 +323,7 @@ export class ResolverBase {
314
323
  return dataObject;
315
324
  // Process each encrypted field
316
325
  for (const field of encryptedFields) {
317
- const fieldName = field.CodeName;
326
+ const fieldName = fieldKey === 'Name' ? field.Name : field.CodeName;
318
327
  const value = dataObject[fieldName];
319
328
  // Skip null/undefined values
320
329
  if (value === null || value === undefined)
@@ -350,9 +359,10 @@ export class ResolverBase {
350
359
  return dataObject;
351
360
  }
352
361
  /**
353
- * Filters encrypted fields for an array of data objects
362
+ * Filters encrypted fields for an array of data objects. `fieldKey` is as for
363
+ * {@link FilterEncryptedFieldsForAPI}.
354
364
  */
355
- async ArrayFilterEncryptedFieldsForAPI(entityName, dataObjectArray, contextUser, provider) {
365
+ async ArrayFilterEncryptedFieldsForAPI(entityName, dataObjectArray, contextUser, provider, fieldKey = 'CodeName') {
356
366
  if (!dataObjectArray || dataObjectArray.length === 0)
357
367
  return dataObjectArray;
358
368
  // Check if entity has any encrypted fields first to avoid unnecessary processing
@@ -365,7 +375,7 @@ export class ResolverBase {
365
375
  return dataObjectArray;
366
376
  // Process each element
367
377
  for (const element of dataObjectArray) {
368
- await this.FilterEncryptedFieldsForAPI(entityName, element, contextUser, provider);
378
+ await this.FilterEncryptedFieldsForAPI(entityName, element, contextUser, provider, fieldKey);
369
379
  }
370
380
  return dataObjectArray;
371
381
  }
@@ -723,43 +733,94 @@ export class ResolverBase {
723
733
  * This screen uses `@memberjunction/sql-parser` (same wrap as EDS `assertReadOnlyClause`):
724
734
  * wrap the fragment as a single SELECT, fail closed if it does not parse as a read, then
725
735
  * allow a FROM only when it is an entity **BaseView**. Base tables (`Meeting`, `__mj.User`)
726
- * and catalogs are rejected. Server-internal RunView callers never hit this.
736
+ * and catalogs are rejected. Server-internal RunView callers get only the provider's
737
+ * statement-separator check, which cannot refuse a legitimate filter.
738
+ *
739
+ * The screen is `ClientClauseScreen` in `@memberjunction/generic-database-provider`, shared
740
+ * with the REST view routes, ad-hoc list sources, and the provider's saved-view checks.
741
+ *
742
+ * Before that parse, the fragment must be balanced on its own (`IsBalancedSQLFragment`). The
743
+ * provider and the tenant hook splice it inside parentheses and AND their own filters after
744
+ * it, so a fragment that closes those parentheses (`1=1) OR (1=1`) would leave row-level
745
+ * security, API-key row filters and tenant scope on one side of an OR the caller controls.
746
+ * The parse wraps the fragment in parentheses too, which is why it cannot catch this alone.
747
+ * A backslash inside a quoted string or identifier is refused as well: the parser reads `\'`
748
+ * inside a plain literal as an escaped quote, but SQL Server and PostgreSQL end the literal
749
+ * there, so the text after it could be code that the parse-based checks never saw.
727
750
  *
728
751
  * RLS is applied by RunView as an outer WHERE around the entity being queried, not compiled
729
- * into the view. Subqueries against another entity's BaseView therefore do not inherit that
730
- * entity's RLS; they are still restricted to the view (not the table).
752
+ * into the view, and RunView removes denied fields from its own result only. A subquery reads a
753
+ * base view directly with neither, so the screen refuses one over any entity the user reads under
754
+ * a row filter or with denied fields (`ClientClauseScreen.AssertUnscopedRead`), the queried
755
+ * entity included.
731
756
  */
732
- assertClientClauseUsesEntityBaseViews(clause, label, provider) {
757
+ assertClientClauseUsesEntityBaseViews(clause, label, provider, user, options) {
733
758
  if (!clause?.trim())
734
759
  return;
760
+ // The balance and backslash checks run before the shared screen: it parses the fragment
761
+ // inside its own parentheses, so it cannot see a fragment that closes the parentheses the
762
+ // provider and the tenant hook splice it into.
735
763
  const dialect = this.dialectForProvider(provider);
736
764
  if (SQLParser.HasStackedStatements(clause, dialect)) {
737
765
  throw new Error(`Invalid ${label}: multiple statements are not permitted in client-supplied filters`);
738
766
  }
739
- const wrapped = label === 'OrderBy'
740
- ? `SELECT 1 FROM __mj_clause_screen ORDER BY ${clause}`
741
- : `SELECT 1 FROM __mj_clause_screen WHERE (${clause})`;
742
- const parser = new SQLParser(wrapped, dialect);
767
+ const balance = IsBalancedSQLFragment(clause, dialect);
768
+ if (!balance.IsBalanced) {
769
+ throw new Error(`Invalid ${label}: ${balance.Reason}`);
770
+ }
771
+ if (this.hasBackslashInQuotes(clause, dialect)) {
772
+ throw new Error(`Invalid ${label}: a backslash inside a quoted string or identifier is not permitted in client-supplied filters`);
773
+ }
774
+ ClientClauseScreen.AssertClauseUsesEntityBaseViews(clause, label, provider?.Entities ?? [], dialect, user, options);
775
+ }
776
+ /**
777
+ * SECURITY — screen for a FULL client-supplied SQL statement (ad-hoc query path). In addition
778
+ * to the SELECT-only validation the caller performs, every table reference must resolve to an
779
+ * entity BaseView the acting user can read UNSCOPED. A reference is excluded only when it reads a
780
+ * CTE that is in scope there (`StatementRelation.IsCTEReference`): unqualified, declared by its
781
+ * own SELECT or an enclosing one, and matched by the dialect's identifier rules. A name reserved
782
+ * for the SQL parser is refused before parsing (`ClientClauseScreen.AssertNoIdentifierAliasNames`).
783
+ * Fails closed: no user, or an unresolvable reference, refuses.
784
+ *
785
+ * The references come from a walk of the whole parse tree
786
+ * (`ClientClauseScreen.CollectStatementReads`). The statement is refused when that walk does not
787
+ * account for every SELECT in the text, or when a FROM item names no table (a table function or
788
+ * a row list).
789
+ *
790
+ * Entity CanRead is not unscoped base-view read authority: RunView narrows a granted read with
791
+ * row-level security and denied-field projection, and raw SQL applies neither. Rather than try
792
+ * to rewrite arbitrary SQL (joins, CTEs) to compose that policy, this refuses any entity that
793
+ * carries a row filter or a denied field for the caller — those reads belong on RunView. For
794
+ * the remaining entities CanRead genuinely means "every row and field of the view".
795
+ */
796
+ assertFullQueryUsesReadableEntityViews(sqlText, provider, user, label = 'SQL') {
797
+ if (!user) {
798
+ throw new Error(`Invalid ${label}: no acting user resolved for ad-hoc SQL — refusing`);
799
+ }
800
+ const dialect = this.dialectForProvider(provider);
801
+ ClientClauseScreen.AssertNoIdentifierAliasNames(sqlText, dialect, label);
802
+ const parser = new SQLParser(sqlText, dialect);
743
803
  if (!parser.IsValid || parser.HasWriteStatement || parser.StatementKind !== 'select') {
744
- throw new Error(`Invalid ${label}: not a safe read-only filter fragment — refusing under uncertainty`);
804
+ throw new Error(`Invalid ${label}: not a safe read-only statement — refusing under uncertainty`);
745
805
  }
746
806
  if (this.astContainsWriteNode(parser.AST)) {
747
807
  throw new Error(`Invalid ${label}: write/DDL nested in a subquery is not permitted`);
748
808
  }
809
+ const reads = ClientClauseScreen.CollectStatementReads(parser);
810
+ ClientClauseScreen.AssertEverySelectAccountedFor(sqlText, dialect, reads, label);
749
811
  const allowed = this.entityBaseViewAllowList(provider);
750
- const tables = SQLParser.ExtractTableRefs(wrapped, dialect);
751
- for (const t of tables) {
752
- const table = this.stripSqlIdent(t.TableName);
753
- const schema = this.stripSqlIdent(t.SchemaName);
754
- if (table.toLowerCase() === '__mj_clause_screen')
755
- continue;
756
- const qualified = `${schema}.${table}`.toLowerCase();
757
- const bare = table.toLowerCase();
758
- if (allowed.qualified.has(qualified) || allowed.bare.has(bare))
812
+ for (const relation of reads.Relations) {
813
+ if (relation.IsCTEReference)
759
814
  continue;
760
- throw new Error(`Invalid ${label}: subquery must use an entity base view, not '${schema}.${table}'`);
815
+ const entity = ClientClauseScreen.AssertRelationReadable(allowed, relation, label, user);
816
+ if (entity)
817
+ this.assertUnscopedRead(entity, user, label);
761
818
  }
762
819
  }
820
+ /** Refuses an entity whose read is narrowed for this user; see `ClientClauseScreen.AssertUnscopedRead`. */
821
+ assertUnscopedRead(entity, user, label) {
822
+ ClientClauseScreen.AssertUnscopedRead(entity, user, label);
823
+ }
763
824
  /**
764
825
  * Applies {@link assertClientClauseUsesEntityBaseViews} to every client-supplied clause
765
826
  * a view request can carry that is actually a SQL fragment. GraphQL entry points
@@ -781,54 +842,37 @@ export class ResolverBase {
781
842
  * The single place that is not true is a field carrying `UserSearchParamFormatAPI`, whose
782
843
  * admin-authored format may splice the term in unquoted; `createViewUserSearchSQL` re-applies
783
844
  * `ValidateUserProvidedSQLClause` for exactly those entities.
845
+ *
846
+ * Each `Aggregates[].expression` gets the same screen, except that text the parser cannot read
847
+ * goes on to the provider, which refuses it per aggregate. The provider never runs the caller's
848
+ * aggregate text: it runs the SQL that `CheckAggregateExpression` rebuilds from a single
849
+ * aggregate call over the entity's columns, and refuses everything else per aggregate.
850
+ */
851
+ screenClientViewClauses(clauses, provider, user) {
852
+ this.assertClientClauseUsesEntityBaseViews(clauses.extraFilter, 'ExtraFilter', provider, user);
853
+ this.assertClientClauseUsesEntityBaseViews(clauses.orderBy, 'OrderBy', provider, user);
854
+ this.assertClientClauseUsesEntityBaseViews(clauses.overrideExcludeFilter, 'OverrideExcludeFilter', provider, user);
855
+ for (const aggregate of clauses.aggregates ?? []) {
856
+ this.assertClientClauseUsesEntityBaseViews(aggregate?.expression, 'Aggregate', provider, user, { LeaveUnparseableToProvider: true });
857
+ }
858
+ }
859
+ /**
860
+ * True when a string literal or quoted identifier in `clause`, read the way the database reads
861
+ * it, contains a backslash (see {@link assertClientClauseUsesEntityBaseViews}).
784
862
  */
785
- screenClientViewClauses(clauses, provider) {
786
- this.assertClientClauseUsesEntityBaseViews(clauses.extraFilter, 'ExtraFilter', provider);
787
- this.assertClientClauseUsesEntityBaseViews(clauses.orderBy, 'OrderBy', provider);
788
- this.assertClientClauseUsesEntityBaseViews(clauses.overrideExcludeFilter, 'OverrideExcludeFilter', provider);
863
+ hasBackslashInQuotes(clause, dialect) {
864
+ return LexSQL(clause, dialect).some((t) => (t.Kind === 'string' || t.Kind === 'identifier') && t.Text.includes('\\'));
789
865
  }
790
- /** Same write-node walk as EDS `sqlReadOnlyScreen.astContainsWriteNode`. */
866
+ /** Write/DDL node walk shared with the clause screen; see `ClientClauseScreen.AstContainsWriteNode`. */
791
867
  astContainsWriteNode(node) {
792
- if (!node || typeof node !== 'object')
793
- return false;
794
- if (Array.isArray(node))
795
- return node.some((n) => this.astContainsWriteNode(n));
796
- const obj = node;
797
- const type = obj.type;
798
- if (typeof type === 'string' && ResolverBase.WRITE_NODE_TYPES.has(type.toLowerCase())) {
799
- return true;
800
- }
801
- return Object.values(obj).some((v) => this.astContainsWriteNode(v));
868
+ return ClientClauseScreen.AstContainsWriteNode(node);
802
869
  }
803
- static { this.WRITE_NODE_TYPES = new Set([
804
- 'insert', 'update', 'delete', 'merge', 'replace', 'drop', 'create', 'alter', 'truncate',
805
- 'rename', 'call', 'exec', 'execute', 'grant', 'revoke', 'use', 'load', 'copy', 'do',
806
- ]); }
807
870
  dialectForProvider(provider) {
808
- const name = provider?.constructor?.name ?? '';
809
- if (/postgres/i.test(name))
810
- return new PostgreSQLDialect();
811
- return new SQLServerDialect();
812
- }
813
- stripSqlIdent(name) {
814
- if (!name)
815
- return '';
816
- return name.replace(/^\[|\]$/g, '').replace(/^"|"$/g, '').replace(/^`|`$/g, '');
871
+ return ClientClauseScreen.DialectFor(provider);
817
872
  }
873
+ /** The entity base-view allow-list for a provider's entities. */
818
874
  entityBaseViewAllowList(provider) {
819
- const qualified = new Set();
820
- const bare = new Set();
821
- const entities = provider?.Entities ?? [];
822
- for (const e of entities) {
823
- const view = this.stripSqlIdent(e.BaseView);
824
- if (!view)
825
- continue;
826
- const schema = this.stripSqlIdent(e.SchemaName);
827
- bare.add(view.toLowerCase());
828
- if (schema)
829
- qualified.add(`${schema}.${view}`.toLowerCase());
830
- }
831
- return { qualified, bare };
875
+ return ClientClauseScreen.BuildEntityBaseViewAllowList(provider?.Entities ?? []);
832
876
  }
833
877
  /**
834
878
  * Optimized RunViewGenericInternal implementation with:
@@ -839,12 +883,6 @@ export class ResolverBase {
839
883
  try {
840
884
  if (!viewInfo || !userPayload)
841
885
  return null;
842
- // SECURITY: GraphQL ExtraFilter may contain IN (SELECT … FROM <base view>).
843
- // Screen at this boundary: parse, reject writes, allow only entity BaseViews.
844
- this.screenClientViewClauses({ extraFilter, orderBy, userSearchString, overrideExcludeFilter }, provider);
845
- // Check API key scope authorization for view operations
846
- await this.CheckAPIKeyScopeAuthorization('view:run', viewInfo.Entity, userPayload);
847
- const md = provider;
848
886
  // Prefer the authenticated session's payload user — it is the authoritative per-request
849
887
  // identity and (for magic-link sessions) carries the per-session resource scope / synthesized
850
888
  // roles that drive RLS. The cached lookup is a fallback for paths where the payload user
@@ -853,6 +891,13 @@ export class ResolverBase {
853
891
  ?? UserCache.Users.find((u) => u.Email.toLowerCase().trim() === userPayload?.email.toLowerCase().trim());
854
892
  if (!user)
855
893
  throw new Error(`User ${userPayload?.email} not found in metadata`);
894
+ // SECURITY: GraphQL ExtraFilter may contain IN (SELECT … FROM <base view>).
895
+ // Screen at this boundary: parse, reject writes, allow only entity BaseViews the
896
+ // acting user can read (subqueries do not inherit the top entity's permission check).
897
+ this.screenClientViewClauses({ extraFilter, orderBy, userSearchString, overrideExcludeFilter, aggregates }, provider, user);
898
+ // Check API key scope authorization for view operations
899
+ await this.CheckAPIKeyScopeAuthorization('view:run', viewInfo.Entity, userPayload);
900
+ const md = provider;
856
901
  const entityInfo = md.Entities.find((e) => e.Name === viewInfo.Entity);
857
902
  if (!entityInfo)
858
903
  throw new Error(`Entity ${viewInfo.Entity} not found in metadata`);
@@ -988,7 +1033,8 @@ export class ResolverBase {
988
1033
  orderBy: param.orderBy,
989
1034
  userSearchString: param.userSearchString,
990
1035
  overrideExcludeFilter: param.overrideExcludeFilter,
991
- }, md);
1036
+ aggregates: param.aggregates,
1037
+ }, md, contextUser ?? undefined);
992
1038
  if (param.viewInfo) {
993
1039
  // Validate entity only once per entity type
994
1040
  const entityName = param.viewInfo.Entity;
@@ -1233,6 +1279,60 @@ export class ResolverBase {
1233
1279
  this.EventSubscriptions.set(uniqueKey, theSub);
1234
1280
  }
1235
1281
  }
1282
+ /**
1283
+ * IS-A PROMOTION over the wire: an EXISTING parent record gaining a subtype — an Animal that is
1284
+ * now also a Dog. It arrives as the CHILD's create mutation carrying the existing parent's
1285
+ * primary key, because in IS-A the shared key is the relationship and the child has no key of
1286
+ * its own to mint. `NewRecord()` has just reset the whole chain to "new", so left alone the
1287
+ * parent would save as a CREATE: a second copy of a row that already exists (a unique-constraint
1288
+ * violation at best, a silently duplicated parent at worst).
1289
+ *
1290
+ * When a child create carries a complete primary key, bind the new child to the existing parent
1291
+ * row with `AttachToParent` (#3825): it loads the parent chain, so the parent saves as an UPDATE
1292
+ * and only the child is INSERTed. A key that matches no row leaves the fresh chain exactly as
1293
+ * `NewRecord()` built it — the ordinary whole-chain create. A non-IS-A entity, or a create with
1294
+ * no key, never gets past the guards.
1295
+ *
1296
+ * @returns true when the child was attached to an existing parent row.
1297
+ */
1298
+ async attachToExistingParentIfPromotion(entityObject, input) {
1299
+ if (!entityObject.EntityInfo.IsChildType) {
1300
+ return false;
1301
+ }
1302
+ const key = this.primaryKeyFromInput(entityObject.EntityInfo, input);
1303
+ if (!key) {
1304
+ return false;
1305
+ }
1306
+ const attached = await entityObject.AttachToParent(key);
1307
+ if (!attached) {
1308
+ // No parent row under that key: this is a whole-chain create on the CALLER's key (the
1309
+ // client mints the shared key at the root and sends it, so honoring it keeps the client's
1310
+ // in-memory chain and the stored rows on one key). AttachToParent restored the fresh chain,
1311
+ // but the root's ReadOnly key slot is no longer writable — a later SetMany would silently
1312
+ // keep the server-minted value — so re-seed the chain with the supplied key explicitly.
1313
+ entityObject.NewRecord(key);
1314
+ }
1315
+ return attached;
1316
+ }
1317
+ /**
1318
+ * The entity's primary key as supplied on a mutation input, or null when any part of it is
1319
+ * missing. Composite keys are all-or-nothing: a half-specified key identifies nothing. An empty
1320
+ * string counts as missing, the same as null — it is never a valid key value, and a GUID or
1321
+ * string key that is "" identifies no row, so the create proceeds as a whole-chain create rather
1322
+ * than failing the lookup. A malformed non-empty key (e.g. a non-GUID for a uniqueidentifier)
1323
+ * is NOT swallowed: the load throws and the mutation fails loudly.
1324
+ */
1325
+ primaryKeyFromInput(entityInfo, input) {
1326
+ const pairs = [];
1327
+ for (const pk of entityInfo.PrimaryKeys) {
1328
+ const value = input[pk.Name] ?? input[pk.CodeName];
1329
+ if (value === null || value === undefined || value === '') {
1330
+ return null;
1331
+ }
1332
+ pairs.push(new KeyValuePair(pk.Name, value));
1333
+ }
1334
+ return pairs.length > 0 ? CompositeKey.FromKeyValuePairs(pairs) : null;
1335
+ }
1236
1336
  async CreateRecord(entityName, input, provider, userPayload, pubSub) {
1237
1337
  // Check API key scope authorization for entity create operations
1238
1338
  await this.CheckAPIKeyScopeAuthorization('entity:create', entityName, userPayload);
@@ -1249,6 +1349,10 @@ export class ResolverBase {
1249
1349
  if (key !== 'RestoreContext___')
1250
1350
  fieldsForSet[key] = input[key];
1251
1351
  }
1352
+ // IS-A promotion: bind the new child to its EXISTING parent row BEFORE the field
1353
+ // assignments, so values the client sent for parent fields land on the loaded parent
1354
+ // (as an update) instead of being wiped by the load.
1355
+ await this.attachToExistingParentIfPromotion(entityObject, fieldsForSet);
1252
1356
  entityObject.SetMany(fieldsForSet);
1253
1357
  // Reconstruct the client-side restore context, if any, on this server
1254
1358
  // entity so the data provider writes the lineage columns on Save().
@@ -1402,9 +1506,15 @@ export class ResolverBase {
1402
1506
  *
1403
1507
  * Ordered so the boolean flag is evaluated last: the extra load lands only on entities that have
1404
1508
  * the feature switched on, which is almost none of them.
1509
+ *
1510
+ * `MJ: Data Context Items` always loads from the database: its server class lets only an
1511
+ * administrator save an item that holds SQL, and decides that from the item's `Type` and `SQL`.
1512
+ * Hydrated from OldValues, a client could leave both out, and the stored procedure would keep the
1513
+ * stored SQL item while the rule saw none. See {@link ENTITIES_THAT_ALWAYS_LOAD_STORED_ROW}.
1405
1514
  */
1406
1515
  MustLoadTruthFromDatabase(entityInfo, input, hasDeniedReadFields, hasNarrowedAuditPayload) {
1407
1516
  return (entityInfo.TrackRecordChanges ||
1517
+ ENTITIES_THAT_ALWAYS_LOAD_STORED_ROW.has(entityInfo.Name.trim().toLowerCase()) ||
1408
1518
  !input.OldValues___ ||
1409
1519
  hasDeniedReadFields ||
1410
1520
  hasNarrowedAuditPayload ||