@memberjunction/server 6.1.0-edge.6 → 6.1.0-edge.7

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 (177) hide show
  1. package/README.md +24 -9
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/config.d.ts +36 -0
  4. package/dist/config.d.ts.map +1 -1
  5. package/dist/config.js +9 -0
  6. package/dist/config.js.map +1 -1
  7. package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.d.ts +30 -0
  8. package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.d.ts.map +1 -1
  9. package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.js +58 -5
  10. package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.js.map +1 -1
  11. package/dist/generated/graphql-schemas/__mj.d.ts +2842 -2144
  12. package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
  13. package/dist/generated/graphql-schemas/__mj.js +5334 -2143
  14. package/dist/generated/graphql-schemas/__mj.js.map +1 -1
  15. package/dist/generic/ResolverBase.d.ts +119 -4
  16. package/dist/generic/ResolverBase.d.ts.map +1 -1
  17. package/dist/generic/ResolverBase.js +264 -11
  18. package/dist/generic/ResolverBase.js.map +1 -1
  19. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  20. package/dist/generic/RunViewResolver.js +26 -2
  21. package/dist/generic/RunViewResolver.js.map +1 -1
  22. package/dist/generic/refusalExtensions.d.ts +52 -0
  23. package/dist/generic/refusalExtensions.d.ts.map +1 -0
  24. package/dist/generic/refusalExtensions.js +16 -0
  25. package/dist/generic/refusalExtensions.js.map +1 -0
  26. package/dist/index.d.ts +9 -3
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +115 -91
  29. package/dist/index.js.map +1 -1
  30. package/dist/resolvers/EntityRecordNameResolver.d.ts +34 -5
  31. package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
  32. package/dist/resolvers/EntityRecordNameResolver.js +64 -7
  33. package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
  34. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +10 -2
  35. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  36. package/dist/resolvers/RealtimeClientSessionResolver.js +80 -4
  37. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  38. package/dist/rest/RealtimeSdpBrokerHandler.d.ts +23 -0
  39. package/dist/rest/RealtimeSdpBrokerHandler.d.ts.map +1 -0
  40. package/dist/rest/RealtimeSdpBrokerHandler.js +78 -0
  41. package/dist/rest/RealtimeSdpBrokerHandler.js.map +1 -0
  42. package/dist/serverExtensionReservedRoots.d.ts.map +1 -1
  43. package/dist/serverExtensionReservedRoots.js +2 -3
  44. package/dist/serverExtensionReservedRoots.js.map +1 -1
  45. package/package.json +92 -92
  46. package/src/__tests__/EntityRecordNameResolver.fieldSecurity.test.ts +118 -0
  47. package/src/__tests__/RealtimeClientSessionResolver.test.ts +20 -0
  48. package/src/__tests__/RealtimeSdpBrokerHandler.test.ts +191 -0
  49. package/src/__tests__/ResolverBase.filterEscaping.test.ts +63 -0
  50. package/src/__tests__/ResolverBase.readableFieldsTransport.test.ts +106 -0
  51. package/src/__tests__/generatedGraphQLSchema.structural.test.ts +46 -0
  52. package/src/__tests__/resolverBase.fls.test.ts +483 -0
  53. package/src/__tests__/resolverBase.recordChangesFls.test.ts +358 -0
  54. package/src/__tests__/resolverBase.refusalExtensions.test.ts +101 -0
  55. package/src/__tests__/serverExtensionReservedRoots.test.ts +79 -11
  56. package/src/config.ts +12 -0
  57. package/src/entitySubclasses/MJEntityPermissionEntityServer.server.ts +65 -6
  58. package/src/generated/graphql-schemas/__mj.ts +6590 -4287
  59. package/src/generic/ResolverBase.ts +313 -10
  60. package/src/generic/RunViewResolver.ts +28 -2
  61. package/src/generic/refusalExtensions.ts +59 -0
  62. package/src/index.ts +137 -100
  63. package/src/resolvers/EntityRecordNameResolver.ts +74 -8
  64. package/src/resolvers/RealtimeClientSessionResolver.ts +128 -2
  65. package/src/rest/RealtimeSdpBrokerHandler.ts +101 -0
  66. package/src/serverExtensionReservedRoots.ts +2 -3
  67. package/dist/resolvers/RingCentralTelephonyResolver.d.ts +0 -27
  68. package/dist/resolvers/RingCentralTelephonyResolver.d.ts.map +0 -1
  69. package/dist/resolvers/RingCentralTelephonyResolver.js +0 -87
  70. package/dist/resolvers/RingCentralTelephonyResolver.js.map +0 -1
  71. package/dist/resolvers/TeamsMeetingsResolver.d.ts +0 -28
  72. package/dist/resolvers/TeamsMeetingsResolver.d.ts.map +0 -1
  73. package/dist/resolvers/TeamsMeetingsResolver.js +0 -91
  74. package/dist/resolvers/TeamsMeetingsResolver.js.map +0 -1
  75. package/dist/resolvers/TelephonyResolver.d.ts +0 -26
  76. package/dist/resolvers/TelephonyResolver.d.ts.map +0 -1
  77. package/dist/resolvers/TelephonyResolver.js +0 -86
  78. package/dist/resolvers/TelephonyResolver.js.map +0 -1
  79. package/dist/resolvers/VonageTelephonyResolver.d.ts +0 -26
  80. package/dist/resolvers/VonageTelephonyResolver.d.ts.map +0 -1
  81. package/dist/resolvers/VonageTelephonyResolver.js +0 -86
  82. package/dist/resolvers/VonageTelephonyResolver.js.map +0 -1
  83. package/dist/telephony/RingCentralTelephonyService.d.ts +0 -115
  84. package/dist/telephony/RingCentralTelephonyService.d.ts.map +0 -1
  85. package/dist/telephony/RingCentralTelephonyService.js +0 -262
  86. package/dist/telephony/RingCentralTelephonyService.js.map +0 -1
  87. package/dist/telephony/TeamsMeetingsRouter.d.ts +0 -40
  88. package/dist/telephony/TeamsMeetingsRouter.d.ts.map +0 -1
  89. package/dist/telephony/TeamsMeetingsRouter.js +0 -112
  90. package/dist/telephony/TeamsMeetingsRouter.js.map +0 -1
  91. package/dist/telephony/TeamsMeetingsService.d.ts +0 -113
  92. package/dist/telephony/TeamsMeetingsService.d.ts.map +0 -1
  93. package/dist/telephony/TeamsMeetingsService.js +0 -196
  94. package/dist/telephony/TeamsMeetingsService.js.map +0 -1
  95. package/dist/telephony/TwilioTelephonyRouter.d.ts +0 -36
  96. package/dist/telephony/TwilioTelephonyRouter.d.ts.map +0 -1
  97. package/dist/telephony/TwilioTelephonyRouter.js +0 -143
  98. package/dist/telephony/TwilioTelephonyRouter.js.map +0 -1
  99. package/dist/telephony/TwilioTelephonyService.d.ts +0 -95
  100. package/dist/telephony/TwilioTelephonyService.d.ts.map +0 -1
  101. package/dist/telephony/TwilioTelephonyService.js +0 -193
  102. package/dist/telephony/TwilioTelephonyService.js.map +0 -1
  103. package/dist/telephony/VonageTelephonyRouter.d.ts +0 -41
  104. package/dist/telephony/VonageTelephonyRouter.d.ts.map +0 -1
  105. package/dist/telephony/VonageTelephonyRouter.js +0 -201
  106. package/dist/telephony/VonageTelephonyRouter.js.map +0 -1
  107. package/dist/telephony/VonageTelephonyService.d.ts +0 -90
  108. package/dist/telephony/VonageTelephonyService.d.ts.map +0 -1
  109. package/dist/telephony/VonageTelephonyService.js +0 -191
  110. package/dist/telephony/VonageTelephonyService.js.map +0 -1
  111. package/dist/telephony/calendar-scheduler.d.ts +0 -67
  112. package/dist/telephony/calendar-scheduler.d.ts.map +0 -1
  113. package/dist/telephony/calendar-scheduler.js +0 -133
  114. package/dist/telephony/calendar-scheduler.js.map +0 -1
  115. package/dist/telephony/index.d.ts +0 -29
  116. package/dist/telephony/index.d.ts.map +0 -1
  117. package/dist/telephony/index.js +0 -38
  118. package/dist/telephony/index.js.map +0 -1
  119. package/dist/telephony/media-upgrade-router.d.ts +0 -47
  120. package/dist/telephony/media-upgrade-router.d.ts.map +0 -1
  121. package/dist/telephony/media-upgrade-router.js +0 -72
  122. package/dist/telephony/media-upgrade-router.js.map +0 -1
  123. package/dist/telephony/ringcentral-runtime.d.ts +0 -14
  124. package/dist/telephony/ringcentral-runtime.d.ts.map +0 -1
  125. package/dist/telephony/ringcentral-runtime.js +0 -18
  126. package/dist/telephony/ringcentral-runtime.js.map +0 -1
  127. package/dist/telephony/teams-meetings-runtime.d.ts +0 -16
  128. package/dist/telephony/teams-meetings-runtime.d.ts.map +0 -1
  129. package/dist/telephony/teams-meetings-runtime.js +0 -20
  130. package/dist/telephony/teams-meetings-runtime.js.map +0 -1
  131. package/dist/telephony/teamsAcsMediaRegistry.d.ts +0 -69
  132. package/dist/telephony/teamsAcsMediaRegistry.d.ts.map +0 -1
  133. package/dist/telephony/teamsAcsMediaRegistry.js +0 -118
  134. package/dist/telephony/teamsAcsMediaRegistry.js.map +0 -1
  135. package/dist/telephony/telephony-runtime.d.ts +0 -14
  136. package/dist/telephony/telephony-runtime.d.ts.map +0 -1
  137. package/dist/telephony/telephony-runtime.js +0 -18
  138. package/dist/telephony/telephony-runtime.js.map +0 -1
  139. package/dist/telephony/twilioMediaRegistry.d.ts +0 -60
  140. package/dist/telephony/twilioMediaRegistry.d.ts.map +0 -1
  141. package/dist/telephony/twilioMediaRegistry.js +0 -106
  142. package/dist/telephony/twilioMediaRegistry.js.map +0 -1
  143. package/dist/telephony/vonage-runtime.d.ts +0 -14
  144. package/dist/telephony/vonage-runtime.d.ts.map +0 -1
  145. package/dist/telephony/vonage-runtime.js +0 -18
  146. package/dist/telephony/vonage-runtime.js.map +0 -1
  147. package/dist/telephony/vonageMediaRegistry.d.ts +0 -78
  148. package/dist/telephony/vonageMediaRegistry.d.ts.map +0 -1
  149. package/dist/telephony/vonageMediaRegistry.js +0 -158
  150. package/dist/telephony/vonageMediaRegistry.js.map +0 -1
  151. package/src/__tests__/media-upgrade-router.test.ts +0 -43
  152. package/src/__tests__/teams-meetings-service.test.ts +0 -65
  153. package/src/__tests__/teamsAcsMediaRegistry.test.ts +0 -82
  154. package/src/__tests__/twilio-telephony-service.test.ts +0 -23
  155. package/src/__tests__/twilioMediaRegistry.test.ts +0 -103
  156. package/src/__tests__/vonageMediaRegistry.test.ts +0 -180
  157. package/src/resolvers/RingCentralTelephonyResolver.ts +0 -64
  158. package/src/resolvers/TeamsMeetingsResolver.ts +0 -68
  159. package/src/resolvers/TelephonyResolver.ts +0 -63
  160. package/src/resolvers/VonageTelephonyResolver.ts +0 -63
  161. package/src/telephony/RingCentralTelephonyService.ts +0 -342
  162. package/src/telephony/TeamsMeetingsRouter.ts +0 -143
  163. package/src/telephony/TeamsMeetingsService.ts +0 -268
  164. package/src/telephony/TwilioTelephonyRouter.ts +0 -184
  165. package/src/telephony/TwilioTelephonyService.ts +0 -261
  166. package/src/telephony/VonageTelephonyRouter.ts +0 -239
  167. package/src/telephony/VonageTelephonyService.ts +0 -259
  168. package/src/telephony/calendar-scheduler.ts +0 -176
  169. package/src/telephony/index.ts +0 -43
  170. package/src/telephony/media-upgrade-router.ts +0 -79
  171. package/src/telephony/ringcentral-runtime.ts +0 -22
  172. package/src/telephony/teams-meetings-runtime.ts +0 -24
  173. package/src/telephony/teamsAcsMediaRegistry.ts +0 -152
  174. package/src/telephony/telephony-runtime.ts +0 -22
  175. package/src/telephony/twilioMediaRegistry.ts +0 -137
  176. package/src/telephony/vonage-runtime.ts +0 -22
  177. package/src/telephony/vonageMediaRegistry.ts +0 -200
@@ -14,6 +14,9 @@ import {
14
14
  LogError,
15
15
  LogStatus,
16
16
  Metadata,
17
+ ReadableFieldsTransportKey,
18
+ RecordChangeFieldSecurityProjector,
19
+ RecordChangePayloadFields,
17
20
  RunView,
18
21
  RunViewParams,
19
22
  RunViewResult,
@@ -24,6 +27,7 @@ import { SQLServerDataProvider } from '@memberjunction/sqlserver-dataprovider';
24
27
  import { UserCache } from '@memberjunction/generic-database-provider';
25
28
  import { PubSubEngine, AuthorizationError } from 'type-graphql';
26
29
  import { GraphQLError } from 'graphql';
30
+ import { RefusalExtensions } from './refusalExtensions.js';
27
31
  import { GetAPIKeyEngine } from '@memberjunction/api-keys';
28
32
  import sql from 'mssql';
29
33
  import { httpTransport, CloudEvent, emitterFor } from 'cloudevents';
@@ -80,7 +84,14 @@ export class ResolverBase {
80
84
  * @param contextUser - Optional user context for decryption (required for encrypted fields)
81
85
  * @returns A new object in transport shape, or null when there is nothing to map
82
86
  */
83
- protected async MapFieldNamesToCodeNames(entityName: string, dataObject: any, contextUser?: UserInfo, provider?: IMetadataProvider): Promise<any> {
87
+ protected async MapFieldNamesToCodeNames(
88
+ entityName: string,
89
+ dataObject: any,
90
+ contextUser?: UserInfo,
91
+ provider?: IMetadataProvider,
92
+ deniedReadFields?: Set<string>,
93
+ recordChangeProjector?: RecordChangeFieldSecurityProjector
94
+ ): Promise<any> {
84
95
  // Return null for empty objects (e.g. when no rows found due to RLS filtering)
85
96
  if (!dataObject || Object.keys(dataObject).length === 0) {
86
97
  return null;
@@ -98,11 +109,35 @@ export class ResolverBase {
98
109
  const entityInfo = md.EntityByName(entityName);
99
110
  if (!entityInfo) throw new Error(`Entity ${entityName} not found in metadata`);
100
111
  // const fields = entityInfo.Fields.filter((f) => f.Name !== f.CodeName || f.Name.startsWith('__mj_'));
112
+ // FIELD-LEVEL SECURITY — this is the authoritative read boundary for every GraphQL
113
+ // return path (single-record resolvers, external-data-source loads, and anything else
114
+ // routed through here), sitting exactly where encryption masking already lives.
115
+ //
116
+ // The denied set is normally computed ONCE per (entity, user) by the caller and passed
117
+ // in: this method runs once per ROW, so resolving it here would cost fields x rows
118
+ // aggregations. We fall back to computing it when a caller doesn't supply it (the
119
+ // single-record resolvers, where "once per row" and "once per request" are the same
120
+ // thing) rather than failing open — a missing argument must never mean missing security.
121
+ const denied = deniedReadFields ?? (
122
+ entityInfo.EnableFieldLevelSecurity && contextUser
123
+ ? entityInfo.GetDeniedReadFields(contextUser)
124
+ : null
125
+ );
126
+
101
127
  const mapper = new FieldMapper();
102
128
  entityInfo.Fields.forEach((f) => {
103
129
  if (dataObject.hasOwnProperty(f.Name)) {
104
130
  // GraphQL doesn't allow us to pass back fields with __ so we are mapping our special field cases that start with __mj_ to _mj__ for transport - they are converted back on the other side automatically
105
131
  const mappedFieldName = mapper.MapFieldName(f.CodeName);
132
+ if (denied?.has(f.Name.trim().toLowerCase())) {
133
+ // Omit entirely rather than nulling — a null is indistinguishable from a real
134
+ // null value, and the client should see the field as absent, not as empty.
135
+ // Both key shapes are removed because callers reach this method with rows keyed
136
+ // either way depending on whether mapping has already run.
137
+ delete dataObject[f.Name];
138
+ delete dataObject[mappedFieldName];
139
+ return;
140
+ }
106
141
  if (mappedFieldName !== f.Name) {
107
142
  dataObject[mappedFieldName] = dataObject[f.Name];
108
143
  delete dataObject[f.Name];
@@ -110,6 +145,33 @@ export class ResolverBase {
110
145
  }
111
146
  });
112
147
 
148
+ // Field security, second half: say IN-BAND which fields this caller may read. Deleting the
149
+ // key above is not enough on its own — GraphQL emits every SELECTED field regardless, so a
150
+ // denied field the client asked for arrives as an explicit null it cannot tell apart from a
151
+ // genuine one. The client must not settle that from its own metadata: during the window
152
+ // after a permission change its copy disagrees with ours, and once metadata filtering lands
153
+ // (issue #3485) it may not hold the permission rows at all. Null for unrestricted callers,
154
+ // which is nearly every request.
155
+ const readableFields = this.BuildReadableFieldsTransportValue(entityInfo, denied);
156
+ if (readableFields) {
157
+ dataObject[ReadableFieldsTransportKey] = readableFields;
158
+ }
159
+
160
+ // Field security, third part — the audit trail. A 'MJ: Record Changes' row carries the old
161
+ // and new values of ANOTHER entity's fields, and its own EnableFieldLevelSecurity flag is
162
+ // off, so everything above is a no-op for it and the payload would flow out untouched. The
163
+ // projector recomputes the denied set against the entity each row is about; see
164
+ // RecordChangeFieldSecurityProjector for what each payload column gets.
165
+ //
166
+ // Same "a missing argument must never mean missing security" rule as the denied set above:
167
+ // the projector is normally built ONCE per request by the array caller and passed in, and
168
+ // we build one here when it wasn't — the single-record resolvers, where once-per-row and
169
+ // once-per-request are the same thing.
170
+ if (contextUser && RecordChangeFieldSecurityProjector.IsRecordChangesEntity(entityInfo)) {
171
+ const projector = recordChangeProjector ?? new RecordChangeFieldSecurityProjector(md, contextUser);
172
+ dataObject = projector.ProjectRow(dataObject);
173
+ }
174
+
113
175
  // Handle encrypted fields - data from raw SQL queries is still encrypted
114
176
  const encryptedFields = entityInfo.EncryptedFields;
115
177
  if (encryptedFields.length > 0) {
@@ -209,13 +271,52 @@ export class ResolverBase {
209
271
  if (!dataObjectArray || dataObjectArray.length === 0) {
210
272
  return dataObjectArray;
211
273
  }
274
+ // Resolve the field-security denied set ONCE for the whole array and pass it into the
275
+ // per-row mapper. MapFieldNamesToCodeNames runs once per row, so computing this inside
276
+ // the loop would be fields x rows aggregations — 40,000 for a 1,000-row x 40-column
277
+ // result, each re-scanning the user's roles and allocating. Gated on the entity-level
278
+ // flag so non-FLS entities (nearly all of them) don't even resolve the entity twice.
279
+ const md = provider ?? new Metadata();
280
+ const entityInfo = md.EntityByName(entityName);
281
+ const deniedReadFields = entityInfo?.EnableFieldLevelSecurity && contextUser
282
+ ? entityInfo.GetDeniedReadFields(contextUser)
283
+ : undefined;
284
+ // Record Changes payload security is resolved per DISTINCT target entity, and one array can
285
+ // span many — so the projector, which owns that memo, must outlive the row loop. Built once
286
+ // here for the same reason the denied set above is: GetDeniedReadFields walks every field on
287
+ // an entity and must never be called per row.
288
+ const recordChangeProjector = contextUser && RecordChangeFieldSecurityProjector.IsRecordChangesEntity(entityInfo)
289
+ ? new RecordChangeFieldSecurityProjector(md, contextUser)
290
+ : undefined;
212
291
  const mapped: any[] = [];
213
292
  for (const element of dataObjectArray) {
214
- mapped.push(await this.MapFieldNamesToCodeNames(entityName, element, contextUser, provider));
293
+ mapped.push(await this.MapFieldNamesToCodeNames(entityName, element, contextUser, provider, deniedReadFields, recordChangeProjector));
215
294
  }
216
295
  return mapped;
217
296
  }
218
297
 
298
+ /**
299
+ * The CodeNames this user may read on this entity, for the field-security transport key — or
300
+ * null when there is nothing to state (no denials, so every field is readable and the client
301
+ * needs no help distinguishing a withheld null from a genuine one).
302
+ *
303
+ * Carries entity field NAMES, not CodeNames and not the `_mj__` transport shape. The client
304
+ * consumes this after it has already reversed the transport mapping, and it matches names
305
+ * against `EntityFieldInfo.Name` — the same key the denied set itself is built from. Sending
306
+ * the one shape both sides already agree on avoids a second mapping that could drift.
307
+ */
308
+ protected BuildReadableFieldsTransportValue(
309
+ entityInfo: EntityInfo,
310
+ deniedReadFields: Set<string> | null | undefined
311
+ ): string[] | null {
312
+ if (!deniedReadFields || deniedReadFields.size === 0) {
313
+ return null;
314
+ }
315
+ return entityInfo.Fields
316
+ .filter((f) => !deniedReadFields.has(f.Name.trim().toLowerCase()))
317
+ .map((f) => f.Name);
318
+ }
319
+
219
320
  /**
220
321
  * Loads a single external-data-source-backed entity record by primary key and returns it in
221
322
  * GraphQL field-name (CodeName) shape, or null if not found.
@@ -860,8 +961,25 @@ export class ResolverBase {
860
961
 
861
962
  /**
862
963
  * Applies {@link assertClientClauseUsesEntityBaseViews} to every client-supplied clause
863
- * a view request can carry. GraphQL entry points (RunViewByName, RunViewByID,
864
- * RunDynamicView, RunViews) funnel through RunViewGenericInternal / RunViewsGenericInternal.
964
+ * a view request can carry that is actually a SQL fragment. GraphQL entry points
965
+ * (RunViewByName, RunViewByID, RunDynamicView, RunViews) funnel through
966
+ * RunViewGenericInternal / RunViewsGenericInternal.
967
+ *
968
+ * 🚨 `UserSearchString` is deliberately NOT screened here (#4392). It is not a clause — it is
969
+ * the free text a person typed into a search box, and it never reaches SQL as a fragment.
970
+ * `GenericDatabaseProvider.createViewUserSearchSQL` builds the predicate itself from
971
+ * `IncludeInUserSearchAPI` metadata and lands the text only as a literal, doubling single
972
+ * quotes and escaping LIKE metacharacters with an explicit `ESCAPE`. Running it through a
973
+ * screen that parses its argument as SQL and fails closed rejected every term that is not
974
+ * coincidentally valid SQL: `Marcus Chen` parses as nothing, `O'Leary` as an unterminated
975
+ * literal — so essentially every real name search returned 0 rows.
976
+ *
977
+ * The provider-level denylist does NOT back this up — it was removed from `UserSearchString`
978
+ * in the same change, for the same reason (it refused `Union Pacific`). What protects the value
979
+ * is that it is never SQL: it reaches the database only as a quoted, quote-doubled literal.
980
+ * The single place that is not true is a field carrying `UserSearchParamFormatAPI`, whose
981
+ * admin-authored format may splice the term in unquoted; `createViewUserSearchSQL` re-applies
982
+ * `ValidateUserProvidedSQLClause` for exactly those entities.
865
983
  */
866
984
  protected screenClientViewClauses(
867
985
  clauses: {
@@ -874,7 +992,6 @@ export class ResolverBase {
874
992
  ): void {
875
993
  this.assertClientClauseUsesEntityBaseViews(clauses.extraFilter, 'ExtraFilter', provider);
876
994
  this.assertClientClauseUsesEntityBaseViews(clauses.orderBy, 'OrderBy', provider);
877
- this.assertClientClauseUsesEntityBaseViews(clauses.userSearchString, 'UserSearchString', provider);
878
995
  this.assertClientClauseUsesEntityBaseViews(clauses.overrideExcludeFilter, 'OverrideExcludeFilter', provider);
879
996
  }
880
997
 
@@ -1440,8 +1557,10 @@ export class ResolverBase {
1440
1557
  }
1441
1558
  // save failed, throw error with message
1442
1559
  else {
1560
+ // The message is the prose a toast shows; `validationErrors` is the same refusal with its
1561
+ // field names intact, so the client can paint the fields a server-side ValidateAsync named.
1443
1562
  throw new GraphQLError(entityObject.LatestResult?.CompleteMessage ?? 'Unknown error creating record', {
1444
- extensions: { code: 'CREATE_ENTITY_ERROR', entityName },
1563
+ extensions: RefusalExtensions('CREATE_ENTITY_ERROR', entityName, entityObject.LatestResult),
1445
1564
  });
1446
1565
  }
1447
1566
  } else return null;
@@ -1473,8 +1592,21 @@ export class ResolverBase {
1473
1592
  }
1474
1593
  });
1475
1594
 
1476
- if (entityInfo.TrackRecordChanges || !input.OldValues___) {
1477
- // 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
1595
+ // Field-level security: any value the client sends for a field it cannot READ is
1596
+ // fabricated by construction (the field was stripped from every payload the client ever
1597
+ // received), so strip such values before anything applies them. When any field is
1598
+ // denied we also force the truth-load branch below — client OldValues for denied fields
1599
+ // are equally fabricated, so hydrating from them (the no-DB-load path) would write
1600
+ // fabricated state into the denied columns on save.
1601
+ const hasDeniedReadFields = this.StripDeniedReadFieldsFromClientInput(entityInfo, userInfo, input, clientNewValues);
1602
+ // The audit-trail equivalent. Its `true` matters most on THIS entity: 'MJ: Record Changes'
1603
+ // has TrackRecordChanges off, so without it a client sending OldValues takes the branch below
1604
+ // that hydrates from those values — never loading what the database actually holds.
1605
+ const hasNarrowedAuditPayload = this.StripRecordChangePayloadFromClientInput(entityInfo, userInfo, input, clientNewValues);
1606
+
1607
+ if (this.MustLoadTruthFromDatabase(entityInfo, input, hasDeniedReadFields, hasNarrowedAuditPayload)) {
1608
+ // We get here because the entity tracks record changes, OR the client did not provide OldValues,
1609
+ // OR field-level security is in play — in every case we need the true old values from the DB
1478
1610
  const cKey = new CompositeKey(
1479
1611
  entityInfo.PrimaryKeys.map((pk) => {
1480
1612
  return {
@@ -1536,7 +1668,7 @@ export class ResolverBase {
1536
1668
  // lost, and it still yields undefined when there is genuinely nothing to say, leaving the
1537
1669
  // fallback below to fire rather than showing the user a blank error.
1538
1670
  throw new GraphQLError(entityObject.LatestResult?.CompleteMessage ?? 'Unknown error', {
1539
- extensions: { code: 'SAVE_ENTITY_ERROR', entityName },
1671
+ extensions: RefusalExtensions('SAVE_ENTITY_ERROR', entityName, entityObject.LatestResult),
1540
1672
  });
1541
1673
  }
1542
1674
  } else
@@ -1545,6 +1677,177 @@ export class ResolverBase {
1545
1677
  });
1546
1678
  }
1547
1679
 
1680
+ /**
1681
+ * Whether `UpdateRecord` must hydrate the entity from the DATABASE rather than from the
1682
+ * client's `OldValues___`.
1683
+ *
1684
+ * The `OldValues___` path exists as an optimization: when nothing needs the true prior state,
1685
+ * the client already holds it and a round trip is wasted. Each condition below is a reason
1686
+ * that assumption fails.
1687
+ *
1688
+ * `EnableFieldLevelSecurity` is the security-critical one, and it is NOT redundant with
1689
+ * `hasDeniedReadFields`. That flag reports READ denials, and the canonical FLS configuration is
1690
+ * Read Allow + Update Deny — which leaves it false. Such a caller would be hydrated from its own
1691
+ * `OldValues___`, and a value it supplies there for an update-denied field arrives through
1692
+ * `LoadFromData`, which the `EntityField` setter records as that field's INITIAL value. The
1693
+ * field is therefore not dirty, `BaseEntity.CheckFieldLevelUpdatePermissions` only rejects
1694
+ * `field.Dirty && denied`, and `GenerateSaveSQL` sends every `IsSPParameter` field regardless of
1695
+ * dirtiness (it skips only `NotLoaded`). The fabricated value reached `spUpdate` having passed
1696
+ * every check: pinning a value in `OldValues___` was a write to a field the caller may not write.
1697
+ *
1698
+ * Forcing the truth-load closes that rather than relocating it. The entity is hydrated by
1699
+ * `InnerLoad` from the real row, and `TestAndSetClientOldValuesToDBValues` reads the client's
1700
+ * OldValues only to detect concurrent-edit overlap before ending in `SetMany(clientNewValues)` —
1701
+ * it never applies them to the entity. An update-denied field can then only become dirty by being
1702
+ * named in the mutation input itself, which is precisely the case that check does catch.
1703
+ *
1704
+ * Ordered so the boolean flag is evaluated last: the extra load lands only on entities that have
1705
+ * the feature switched on, which is almost none of them.
1706
+ */
1707
+ protected MustLoadTruthFromDatabase(
1708
+ entityInfo: EntityInfo,
1709
+ input: { OldValues___?: Array<{ Key: string; Value: unknown }> },
1710
+ hasDeniedReadFields: boolean,
1711
+ hasNarrowedAuditPayload: boolean
1712
+ ): boolean {
1713
+ return (
1714
+ entityInfo.TrackRecordChanges ||
1715
+ !input.OldValues___ ||
1716
+ hasDeniedReadFields ||
1717
+ hasNarrowedAuditPayload ||
1718
+ entityInfo.EnableFieldLevelSecurity
1719
+ );
1720
+ }
1721
+
1722
+ /**
1723
+ * Field-level security guard for the update path.
1724
+ *
1725
+ * Every denied-read field is stripped — new values AND OldValues. A field the user cannot
1726
+ * read was absent from every payload that client ever received, so any value coming back for
1727
+ * it is the transport's invention rather than user intent, and applying it would silently
1728
+ * overwrite the real column. Stripping is what makes "load a record, edit an unrelated field,
1729
+ * save" safe for a restricted user.
1730
+ *
1731
+ * There is no split by update permission: Read is required for Update, so a user denied read
1732
+ * is denied update too and denied-read ∩ denied-update is just denied-read.
1733
+ *
1734
+ * Silent narrowing, not rejection — consistent with the output projection the client already
1735
+ * experiences, and with the ambiguous-error rule (naming the field would confirm it exists
1736
+ * and is restricted).
1737
+ *
1738
+ * Returns true when the user has a non-empty denied-read set on this entity. The caller
1739
+ * must then hydrate the entity from the DATABASE (never from client OldValues), so denied
1740
+ * fields hold true values that an omitted key leaves untouched.
1741
+ */
1742
+ protected StripDeniedReadFieldsFromClientInput(
1743
+ entityInfo: EntityInfo,
1744
+ userInfo: UserInfo,
1745
+ input: { OldValues___?: Array<{ Key: string; Value: unknown }> } & Record<string, unknown>,
1746
+ clientNewValues: Record<string, unknown>
1747
+ ): boolean {
1748
+ if (!entityInfo.EnableFieldLevelSecurity || !userInfo) {
1749
+ return false;
1750
+ }
1751
+ const deniedReadNames = entityInfo.GetDeniedReadFields(userInfo);
1752
+ if (deniedReadNames.size === 0) {
1753
+ return false;
1754
+ }
1755
+
1756
+ // Input keys are entity CodeNames (post ReverseMapInputFieldNames); the denied set holds
1757
+ // lowercased field Names — bridge via the field metadata once.
1758
+ const deniedReadCodeNames = new Set<string>();
1759
+ for (const field of entityInfo.Fields) {
1760
+ if (deniedReadNames.has(field.Name.trim().toLowerCase())) {
1761
+ deniedReadCodeNames.add(field.CodeName.trim().toLowerCase());
1762
+ }
1763
+ }
1764
+
1765
+ const stripped: string[] = [];
1766
+ for (const key of Object.keys(clientNewValues)) {
1767
+ if (deniedReadCodeNames.has(key.trim().toLowerCase())) {
1768
+ delete clientNewValues[key];
1769
+ delete input[key];
1770
+ stripped.push(key);
1771
+ }
1772
+ }
1773
+ if (Array.isArray(input.OldValues___)) {
1774
+ input.OldValues___ = input.OldValues___.filter(
1775
+ (item) => !deniedReadCodeNames.has(String(item.Key).trim().toLowerCase())
1776
+ );
1777
+ }
1778
+ if (stripped.length > 0) {
1779
+ LogDebug(
1780
+ `[FieldSecurity] UpdateRecord on '${entityInfo.Name}' for user ${userInfo.Email}: ` +
1781
+ `stripped client-sent value(s) for denied-read field(s) ${stripped.join(', ')}`
1782
+ );
1783
+ }
1784
+ return true;
1785
+ }
1786
+
1787
+ /**
1788
+ * Refuses a client-sent `MJ: Record Changes` payload column from a caller who carries field
1789
+ * denials, and forces the update onto the load-truth-from-DB branch.
1790
+ *
1791
+ * This is the write half of the audit-trail projection, and without it that projection would
1792
+ * itself destroy audit history. A restricted caller is served a NARROWED `ChangesJSON` /
1793
+ * `FullRecordJSON` and no `ChangesDescription`. Those values hydrate a client-side entity as
1794
+ * ordinary loaded values, and `GenerateSaveSQL` writes EVERY `IsSPParameter` field rather than
1795
+ * only dirty ones — so a user who edits `Comments` on that record and saves would silently
1796
+ * overwrite the stored payload with the narrowed one they were shown. The row would keep looking
1797
+ * like a complete audit entry while the pruned fields were gone for everyone, permanently.
1798
+ *
1799
+ * The rule is the one field security already applies elsewhere: **a value a client could not
1800
+ * have seen in full is not user intent, it is a transport artifact, and it is ignored.** Here
1801
+ * that is widened from "fields the caller cannot read" to "the audit payload of a caller who
1802
+ * carries any denial", because the column is readable — it is its CONTENTS that were narrowed,
1803
+ * and which row's target entity did the narrowing is not knowable from the input alone.
1804
+ *
1805
+ * Returning true routes the caller through `InnerLoad`, so the stored values are restored from
1806
+ * the database before the save. Legitimate payload writes are unaffected: they happen
1807
+ * server-side (`SnapshotBuilder`, replay) and never through this resolver.
1808
+ */
1809
+ protected StripRecordChangePayloadFromClientInput(
1810
+ entityInfo: EntityInfo,
1811
+ userInfo: UserInfo,
1812
+ input: { OldValues___?: Array<{ Key: string; Value: unknown }> } & Record<string, unknown>,
1813
+ clientNewValues: Record<string, unknown>,
1814
+ provider?: IMetadataProvider
1815
+ ): boolean {
1816
+ if (!userInfo || !RecordChangeFieldSecurityProjector.IsRecordChangesEntity(entityInfo)) {
1817
+ return false;
1818
+ }
1819
+ const md = provider ?? new Metadata();
1820
+ if (!new RecordChangeFieldSecurityProjector(md, userInfo).CallerCarriesAnyDenial()) {
1821
+ return false; // this caller was served the payload whole, so what they send back is theirs
1822
+ }
1823
+
1824
+ const payloadKeys = new Set(RecordChangePayloadFields.map((f) => f.toLowerCase()));
1825
+ const stripped: string[] = [];
1826
+ for (const key of Object.keys(clientNewValues)) {
1827
+ if (payloadKeys.has(key.trim().toLowerCase())) {
1828
+ delete clientNewValues[key];
1829
+ delete input[key];
1830
+ stripped.push(key);
1831
+ }
1832
+ }
1833
+ // OldValues too: a narrowed payload in the client's old values is not what the database holds,
1834
+ // so leaving it would read as a concurrent-edit conflict on a field nobody edited.
1835
+ if (Array.isArray(input.OldValues___)) {
1836
+ input.OldValues___ = input.OldValues___.filter((item) => !payloadKeys.has(String(item.Key).trim().toLowerCase()));
1837
+ }
1838
+ if (stripped.length > 0) {
1839
+ LogDebug(
1840
+ `[FieldSecurity] UpdateRecord on '${entityInfo.Name}' for user ${userInfo.Email}: ` +
1841
+ `ignored client-sent audit payload column(s) ${stripped.join(', ')} — the caller carries field ` +
1842
+ `denials, so the value they hold may be a narrowed projection rather than the stored one`
1843
+ );
1844
+ }
1845
+ // True even when nothing was stripped: the DB truth-load is what guarantees the payload the
1846
+ // save writes is the stored one, and a client that sent no payload key at all would otherwise
1847
+ // fall through to the OldValues branch and write whatever it did send.
1848
+ return true;
1849
+ }
1850
+
1548
1851
  /**
1549
1852
  * This routine compares the OldValues property in the input object to the values in the DB that we just loaded. If there are differences, we need to check to see if the client
1550
1853
  * is trying to update any of those fields (e.g. overlap). If there is overlap, we throw an error. If there is no overlap, we can proceed with the update even if the DB Values
@@ -1770,7 +2073,7 @@ export class ResolverBase {
1770
2073
  // reason on LatestResult, and #3971 proposes a first-class delete-validation seam. Every one
1771
2074
  // of those reasons was being replaced by 'Unknown error' at the API boundary.
1772
2075
  throw new GraphQLError(entityObject.LatestResult?.CompleteMessage ?? 'Unknown error', {
1773
- extensions: { code: 'DELETE_ENTITY_ERROR', entityName },
2076
+ extensions: RefusalExtensions('DELETE_ENTITY_ERROR', entityName, entityObject.LatestResult),
1774
2077
  });
1775
2078
  }
1776
2079
  } else {
@@ -1,7 +1,8 @@
1
1
  import { Arg, Ctx, Field, InputType, Int, ObjectType, PubSubEngine, Query, Resolver } from 'type-graphql';
2
2
  import { AppContext } from '../types.js';
3
3
  import { ResolverBase } from './ResolverBase.js';
4
- import { LogError, LogStatus, EntityInfo, RunViewWithCacheCheckResult, RunViewsWithCacheCheckResponse, RunViewWithCacheCheckParams, AggregateResult, CompositeKey } from '@memberjunction/core';
4
+ import { LogError, LogStatus, EntityInfo, FieldSecurityError, RunViewWithCacheCheckResult, RunViewsWithCacheCheckResponse, RunViewWithCacheCheckParams, AggregateResult, CompositeKey } from '@memberjunction/core';
5
+
5
6
  import { UUIDsEqual } from '@memberjunction/global';
6
7
  import { RequireSystemUser } from '../directives/RequireSystemUser.js';
7
8
  import { GetReadOnlyProvider } from '../util.js';
@@ -9,6 +10,21 @@ import { MJUserViewEntityExtended } from '@memberjunction/core-entities';
9
10
  import { CompositeKeyInputType, KeyValuePairOutputType } from './KeyInputOutputTypes.js';
10
11
  import { SQLServerDataProvider } from '@memberjunction/sqlserver-dataprovider';
11
12
 
13
+ /**
14
+ * Rethrows a field-level-security denial so its deliberately ambiguous wording reaches the
15
+ * client as a GraphQL error. The surrounding catch blocks rightly swallow arbitrary resolver
16
+ * errors (their messages can carry SQL text or internal state) and return null — but this one
17
+ * message was DESIGNED to be shown to the caller (see FieldSecurityDenialMessage), and
18
+ * swallowing it degrades a deliberate security rejection into a generic
19
+ * "Cannot return null for non-nullable field" transport error. Matched by name, not
20
+ * instanceof, so a bundler duplicating the class cannot break the recognition.
21
+ */
22
+ function rethrowFieldSecurityDenial(err: unknown): void {
23
+ if (err instanceof Error && err.name === FieldSecurityError.ErrorName) {
24
+ throw err;
25
+ }
26
+ }
27
+
12
28
  /********************************************************************************
13
29
  * The PURPOSE of this resolver is to provide a generic way to run a view and return the results.
14
30
  * The best practice is to use the strongly typed sub-class of this resolver for each entity.
@@ -769,6 +785,7 @@ export class RunViewResolver extends ResolverBase {
769
785
  AggregateExecutionTime: rawData?.AggregateExecutionTime,
770
786
  };
771
787
  } catch (err) {
788
+ rethrowFieldSecurityDenial(err);
772
789
  console.log(err);
773
790
  return null;
774
791
  }
@@ -801,6 +818,7 @@ export class RunViewResolver extends ResolverBase {
801
818
  AggregateExecutionTime: rawData?.AggregateExecutionTime,
802
819
  };
803
820
  } catch (err) {
821
+ rethrowFieldSecurityDenial(err);
804
822
  console.log(err);
805
823
  return null;
806
824
  }
@@ -831,6 +849,7 @@ export class RunViewResolver extends ResolverBase {
831
849
  AggregateExecutionTime: rawData?.AggregateExecutionTime,
832
850
  };
833
851
  } catch (err) {
852
+ rethrowFieldSecurityDenial(err);
834
853
  console.log(err);
835
854
  return null;
836
855
  }
@@ -872,6 +891,7 @@ export class RunViewResolver extends ResolverBase {
872
891
 
873
892
  return results;
874
893
  } catch (err) {
894
+ rethrowFieldSecurityDenial(err);
875
895
  LogError(err);
876
896
  return null;
877
897
  }
@@ -1067,6 +1087,7 @@ export class RunViewResolver extends ResolverBase {
1067
1087
 
1068
1088
  return results;
1069
1089
  } catch (err) {
1090
+ rethrowFieldSecurityDenial(err);
1070
1091
  LogError(err);
1071
1092
  return null;
1072
1093
  }
@@ -1105,7 +1126,12 @@ export class RunViewResolver extends ResolverBase {
1105
1126
  MaxRows: item.params.MaxRows,
1106
1127
  ForceAuditLog: item.params.ForceAuditLog,
1107
1128
  AuditLogDescription: item.params.AuditLogDescription,
1108
- ResultType: (item.params.ResultType || 'simple') as 'simple' | 'entity_object' | 'count_only',
1129
+ // entity_object is forced to 'simple', matching RunViewGenericInternal: over the
1130
+ // wire the server always returns plain rows (the CLIENT materializes entities),
1131
+ // and server-side enforcement (field-security projection) deliberately exempts
1132
+ // genuine server-internal entity_object results — a wire caller must never be
1133
+ // able to claim that exemption. count_only passes through unchanged.
1134
+ ResultType: (item.params.ResultType === 'entity_object' ? 'simple' : (item.params.ResultType || 'simple')) as 'simple' | 'entity_object' | 'count_only',
1109
1135
  StartRow: item.params.StartRow,
1110
1136
  // Forward the aggregate request to the engine (B40) — omitted here as well as in the
1111
1137
  // client's input map, so aggregates never reached InternalRunView on this transport.
@@ -0,0 +1,59 @@
1
+ /**
2
+ * @fileoverview The `extensions` object every write-refusal `GraphQLError` in `ResolverBase` carries.
3
+ *
4
+ * A save refused by `Validate()` / `ValidateAsync()` leaves its reasons — one `ValidationErrorInfo`
5
+ * per field — in `LatestResult.Errors`. Until now the resolver threw only `CompleteMessage`, the
6
+ * flattened prose, so the client got readable text but lost the *structure*: which field each
7
+ * sentence belonged to. That is why a server-side `ValidateAsync()` refusal could only ever toast,
8
+ * while the same rule written in a synchronous `Validate()` painted the offending field red — the
9
+ * client runs sync validation locally and never needed the wire to tell it the field.
10
+ *
11
+ * `RefusalExtensions` keeps the message exactly as before and ADDS `validationErrors`, the
12
+ * `Errors` array flattened by `SerializeValidationErrors` (so a class instance never has to survive
13
+ * JSON), plus `messageIncludesValidationErrors: true` — the server stating that the message it threw
14
+ * is `CompleteMessage`, so the client can keep its own `CompleteMessage` from repeating the text
15
+ * without assuming anything about what the server put in `message`. `GraphQLDataProvider.Save()` rehydrates it into `LatestResult.Errors` on the client, and the
16
+ * form paints fields from there — the same path the sync branch already uses.
17
+ *
18
+ * Kept out of `ResolverBase.ts` so it is unit-testable without booting the resolver's dependency
19
+ * graph, and so the three resolvers (create / update / delete) share one definition of the shape.
20
+ */
21
+ import type { BaseEntityResult } from '@memberjunction/core';
22
+ import { SerializeValidationErrors, type SerializedValidationError } from '@memberjunction/global';
23
+
24
+ /** Error codes the write resolvers use; kept distinct so a client can tell the operations apart. */
25
+ export type WriteRefusalCode = 'CREATE_ENTITY_ERROR' | 'SAVE_ENTITY_ERROR' | 'DELETE_ENTITY_ERROR';
26
+
27
+ /** The `extensions` payload of a write-refusal `GraphQLError`. */
28
+ export type WriteRefusalExtensions = {
29
+ code: WriteRefusalCode;
30
+ entityName: string;
31
+ /**
32
+ * Present only when the refusal carried structured errors. Absent (not an empty array) otherwise,
33
+ * so a client can distinguish "validation said no" from "the database said no".
34
+ */
35
+ validationErrors?: SerializedValidationError[];
36
+ /**
37
+ * Present (and `true`) alongside `validationErrors`: a statement by THIS producer that the
38
+ * error's `message` is `LatestResult.CompleteMessage`, i.e. it already renders every entry of
39
+ * `validationErrors`. The client sets `BaseEntityResult.MessageIncludesErrors` from this — a
40
+ * fact the server asserts on the wire — rather than assuming what the server put in `message`.
41
+ * A server that does not send it gets the client's pre-existing behaviour (the text may repeat).
42
+ */
43
+ messageIncludesValidationErrors?: true;
44
+ };
45
+
46
+ /**
47
+ * Builds the `extensions` for a refused create / update / delete.
48
+ *
49
+ * @param code - Which operation was refused.
50
+ * @param entityName - The entity the operation targeted.
51
+ * @param result - The entity's `LatestResult` after the failed call, if any.
52
+ * @returns `{ code, entityName }`, plus `validationErrors` when `result.Errors` had anything to carry.
53
+ */
54
+ export function RefusalExtensions(code: WriteRefusalCode, entityName: string, result: BaseEntityResult | null | undefined): WriteRefusalExtensions {
55
+ const validationErrors = SerializeValidationErrors(result?.Errors);
56
+ return validationErrors.length > 0
57
+ ? { code, entityName, validationErrors, messageIncludesValidationErrors: true }
58
+ : { code, entityName };
59
+ }