@memberjunction/server 6.2.0-edge.2 → 6.2.0-edge.3

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 (150) hide show
  1. package/README.md +30 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/config.d.ts +2189 -71
  4. package/dist/config.d.ts.map +1 -1
  5. package/dist/config.js +123 -3
  6. package/dist/config.js.map +1 -1
  7. package/dist/context.d.ts.map +1 -1
  8. package/dist/context.js +11 -23
  9. package/dist/context.js.map +1 -1
  10. package/dist/generated/graphql-schemas/__mj.d.ts +928 -0
  11. package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
  12. package/dist/generated/graphql-schemas/__mj.js +8482 -3649
  13. package/dist/generated/graphql-schemas/__mj.js.map +1 -1
  14. package/dist/generic/ResolverBase.d.ts +43 -3
  15. package/dist/generic/ResolverBase.d.ts.map +1 -1
  16. package/dist/generic/ResolverBase.js +142 -27
  17. package/dist/generic/ResolverBase.js.map +1 -1
  18. package/dist/generic/RunViewResolver.d.ts +4 -0
  19. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  20. package/dist/generic/RunViewResolver.js +33 -1
  21. package/dist/generic/RunViewResolver.js.map +1 -1
  22. package/dist/index.d.ts +1 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +115 -49
  25. package/dist/index.js.map +1 -1
  26. package/dist/postgresqlCompat.d.ts +8 -0
  27. package/dist/postgresqlCompat.d.ts.map +1 -0
  28. package/dist/postgresqlCompat.js +19 -0
  29. package/dist/postgresqlCompat.js.map +1 -0
  30. package/dist/postgresqlPoolSettings.d.ts +108 -0
  31. package/dist/postgresqlPoolSettings.d.ts.map +1 -0
  32. package/dist/postgresqlPoolSettings.js +134 -0
  33. package/dist/postgresqlPoolSettings.js.map +1 -0
  34. package/dist/resolvers/AdhocQueryResolver.d.ts +23 -18
  35. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  36. package/dist/resolvers/AdhocQueryResolver.js +77 -167
  37. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  38. package/dist/resolvers/EntityRecordNameResolver.d.ts +7 -23
  39. package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
  40. package/dist/resolvers/EntityRecordNameResolver.js +9 -47
  41. package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
  42. package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
  43. package/dist/resolvers/GetDataResolver.js +9 -0
  44. package/dist/resolvers/GetDataResolver.js.map +1 -1
  45. package/dist/resolvers/HumanHandoffResolver.d.ts +107 -0
  46. package/dist/resolvers/HumanHandoffResolver.d.ts.map +1 -0
  47. package/dist/resolvers/HumanHandoffResolver.js +324 -0
  48. package/dist/resolvers/HumanHandoffResolver.js.map +1 -0
  49. package/dist/resolvers/MeetingResolver.d.ts +69 -0
  50. package/dist/resolvers/MeetingResolver.d.ts.map +1 -0
  51. package/dist/resolvers/MeetingResolver.js +381 -0
  52. package/dist/resolvers/MeetingResolver.js.map +1 -0
  53. package/dist/resolvers/RealtimeBridgeResolver.d.ts +34 -1
  54. package/dist/resolvers/RealtimeBridgeResolver.d.ts.map +1 -1
  55. package/dist/resolvers/RealtimeBridgeResolver.js +202 -7
  56. package/dist/resolvers/RealtimeBridgeResolver.js.map +1 -1
  57. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +1 -83
  58. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  59. package/dist/resolvers/RealtimeClientSessionResolver.js +4 -206
  60. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  61. package/dist/resolvers/adhocTimeout.d.ts +7 -0
  62. package/dist/resolvers/adhocTimeout.d.ts.map +1 -0
  63. package/dist/resolvers/adhocTimeout.js +12 -0
  64. package/dist/resolvers/adhocTimeout.js.map +1 -0
  65. package/dist/resolvers/meetings/index.d.ts +5 -0
  66. package/dist/resolvers/meetings/index.d.ts.map +1 -0
  67. package/dist/resolvers/meetings/index.js +5 -0
  68. package/dist/resolvers/meetings/index.js.map +1 -0
  69. package/dist/resolvers/meetings/meetingHelpers.d.ts +17 -0
  70. package/dist/resolvers/meetings/meetingHelpers.d.ts.map +1 -0
  71. package/dist/resolvers/meetings/meetingHelpers.js +212 -0
  72. package/dist/resolvers/meetings/meetingHelpers.js.map +1 -0
  73. package/dist/resolvers/meetings/meetingMutations.d.ts +12 -0
  74. package/dist/resolvers/meetings/meetingMutations.d.ts.map +1 -0
  75. package/dist/resolvers/meetings/meetingMutations.js +418 -0
  76. package/dist/resolvers/meetings/meetingMutations.js.map +1 -0
  77. package/dist/resolvers/meetings/meetingQueries.d.ts +7 -0
  78. package/dist/resolvers/meetings/meetingQueries.d.ts.map +1 -0
  79. package/dist/resolvers/meetings/meetingQueries.js +113 -0
  80. package/dist/resolvers/meetings/meetingQueries.js.map +1 -0
  81. package/dist/resolvers/meetings/meetingTypes.d.ts +89 -0
  82. package/dist/resolvers/meetings/meetingTypes.d.ts.map +1 -0
  83. package/dist/resolvers/meetings/meetingTypes.js +345 -0
  84. package/dist/resolvers/meetings/meetingTypes.js.map +1 -0
  85. package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
  86. package/dist/rest/ViewOperationsHandler.js +8 -5
  87. package/dist/rest/ViewOperationsHandler.js.map +1 -1
  88. package/dist/services/TaskGraphActionRunner.d.ts +8 -0
  89. package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
  90. package/dist/services/TaskGraphActionRunner.js +27 -1
  91. package/dist/services/TaskGraphActionRunner.js.map +1 -1
  92. package/dist/services/WorkQueueHostService.d.ts +36 -0
  93. package/dist/services/WorkQueueHostService.d.ts.map +1 -0
  94. package/dist/services/WorkQueueHostService.js +97 -0
  95. package/dist/services/WorkQueueHostService.js.map +1 -0
  96. package/dist/services/workQueueConfig.d.ts +103 -0
  97. package/dist/services/workQueueConfig.d.ts.map +1 -0
  98. package/dist/services/workQueueConfig.js +48 -0
  99. package/dist/services/workQueueConfig.js.map +1 -0
  100. package/dist/types.d.ts +5 -0
  101. package/dist/types.d.ts.map +1 -1
  102. package/dist/types.js.map +1 -1
  103. package/package.json +95 -94
  104. package/src/__tests__/AdhocQueryResolver.bugs.test.ts +14 -35
  105. package/src/__tests__/AdhocQueryResolver.pagination.test.ts +6 -2
  106. package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +3 -3
  107. package/src/__tests__/EntityRecordNameResolver.fieldSecurity.test.ts +27 -72
  108. package/src/__tests__/HumanHandoffResolver.test.ts +233 -0
  109. package/src/__tests__/MeetingResolver.test.ts +374 -0
  110. package/src/__tests__/RealtimeBridgeResolver.test.ts +240 -2
  111. package/src/__tests__/ResolverBase.adhocReadScope.test.ts +96 -0
  112. package/src/__tests__/ResolverBase.filterEscaping.test.ts +21 -2
  113. package/src/__tests__/ResolverBase.includeBinaryFields.test.ts +291 -0
  114. package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +41 -0
  115. package/src/__tests__/WorkQueueHostService.test.ts +133 -0
  116. package/src/__tests__/adhocTimeoutClamp.test.ts +26 -0
  117. package/src/__tests__/postgresqlPoolSettings.test.ts +131 -0
  118. package/src/__tests__/translateBracketsToPG.test.ts +20 -0
  119. package/src/config.ts +130 -3
  120. package/src/context.ts +11 -24
  121. package/src/generated/graphql-schemas/__mj.ts +22138 -18770
  122. package/src/generic/ResolverBase.ts +171 -27
  123. package/src/generic/RunViewResolver.ts +33 -2
  124. package/src/index.ts +127 -50
  125. package/src/postgresqlCompat.ts +21 -0
  126. package/src/postgresqlPoolSettings.ts +213 -0
  127. package/src/resolvers/AdhocQueryResolver.ts +88 -188
  128. package/src/resolvers/EntityRecordNameResolver.ts +9 -51
  129. package/src/resolvers/GetDataResolver.ts +10 -0
  130. package/src/resolvers/HumanHandoffResolver.ts +304 -0
  131. package/src/resolvers/MeetingResolver.ts +438 -0
  132. package/src/resolvers/RealtimeBridgeResolver.ts +227 -13
  133. package/src/resolvers/RealtimeClientSessionResolver.ts +6 -252
  134. package/src/resolvers/adhocTimeout.ts +12 -0
  135. package/src/resolvers/meetings/index.ts +4 -0
  136. package/src/resolvers/meetings/meetingHelpers.ts +307 -0
  137. package/src/resolvers/meetings/meetingMutations.ts +535 -0
  138. package/src/resolvers/meetings/meetingQueries.ts +172 -0
  139. package/src/resolvers/meetings/meetingTypes.ts +238 -0
  140. package/src/rest/ViewOperationsHandler.ts +8 -5
  141. package/src/services/TaskGraphActionRunner.ts +29 -1
  142. package/src/services/WorkQueueHostService.ts +129 -0
  143. package/src/services/workQueueConfig.ts +52 -0
  144. package/src/types.ts +5 -0
  145. package/dist/resolvers/adhoc-query-helpers.d.ts +0 -33
  146. package/dist/resolvers/adhoc-query-helpers.d.ts.map +0 -1
  147. package/dist/resolvers/adhoc-query-helpers.js +0 -50
  148. package/dist/resolvers/adhoc-query-helpers.js.map +0 -1
  149. package/src/__tests__/adhoc-query-helpers.test.ts +0 -50
  150. package/src/resolvers/adhoc-query-helpers.ts +0 -67
@@ -37,7 +37,7 @@ import { httpTransport, CloudEvent, emitterFor } from 'cloudevents';
37
37
  import { RunViewGenericParams, UserPayload } from '../types.js';
38
38
  import { RunDynamicViewInput, RunViewByIDInput, RunViewByNameInput } from './RunViewResolver.js';
39
39
  import { DeleteOptionsInput } from './DeleteOptionsInput.js';
40
- import { MJEvent, MJEventType, MJGlobal, ENCRYPTED_SENTINEL, EscapeSQLString, IsValueEncrypted, IsOnlyTimezoneShift } from '@memberjunction/global';
40
+ import { MJEvent, MJEventType, MJGlobal, ENCRYPTED_SENTINEL, ReplaceByteArraysWithBase64, EscapeSQLString, IsValueEncrypted, IsOnlyTimezoneShift } from '@memberjunction/global';
41
41
  import { SQLParser } from '@memberjunction/sql-parser';
42
42
  import { PostgreSQLDialect, SQLServerDialect, type SQLParserDialect } from '@memberjunction/sql-dialect';
43
43
  import { EncryptionEngine } from '@memberjunction/encryption';
@@ -99,7 +99,10 @@ export class ResolverBase {
99
99
  return null;
100
100
  }
101
101
  // Shallow copy up front so every write below lands on our object, never the caller's.
102
- dataObject = { ...dataObject };
102
+ // Binary values are base64 strings everywhere above the providers; any byte array that still
103
+ // reaches a resolver (custom code, an external driver) is converted here, because GraphQL's
104
+ // String scalar cannot serialize a Buffer and would fail the whole response.
105
+ dataObject = ReplaceByteArraysWithBase64({ ...dataObject });
103
106
 
104
107
  // for the given entity name provided, check to see if there are any fields
105
108
  // where the code name is different from the field name, and for just those
@@ -566,7 +569,8 @@ export class ResolverBase {
566
569
  ? CompositeKey.FromKeyValuePairs((viewInput.AfterKey as { KeyValuePairs: { FieldName: string; Value: string }[] }).KeyValuePairs)
567
570
  : undefined,
568
571
  viewInput.BypassCache,
569
- viewInput.DataSource
572
+ viewInput.DataSource,
573
+ viewInput.IncludeBinaryFields
570
574
  );
571
575
  }
572
576
  else {
@@ -610,7 +614,8 @@ export class ResolverBase {
610
614
  viewInput.Aggregates,
611
615
  undefined,
612
616
  viewInput.BypassCache,
613
- viewInput.DataSource
617
+ viewInput.DataSource,
618
+ viewInput.IncludeBinaryFields
614
619
  );
615
620
  } catch (err) {
616
621
  console.log(err);
@@ -657,7 +662,8 @@ export class ResolverBase {
657
662
  viewInput.Aggregates,
658
663
  undefined,
659
664
  viewInput.BypassCache,
660
- viewInput.DataSource
665
+ viewInput.DataSource,
666
+ viewInput.IncludeBinaryFields
661
667
  );
662
668
  } catch (err) {
663
669
  console.log(err);
@@ -732,6 +738,7 @@ export class ResolverBase {
732
738
  aggregates: viewInput.Aggregates,
733
739
  bypassCache: viewInput.BypassCache,
734
740
  dataSource: viewInput.DataSource,
741
+ includeBinaryFields: viewInput.IncludeBinaryFields,
735
742
  });
736
743
  } catch (err) {
737
744
  LogError(err);
@@ -924,6 +931,7 @@ export class ResolverBase {
924
931
  clause: string | undefined | null,
925
932
  label: string,
926
933
  provider?: IMetadataProvider,
934
+ user?: UserInfo,
927
935
  ): void {
928
936
  if (!clause?.trim()) return;
929
937
 
@@ -952,15 +960,87 @@ export class ResolverBase {
952
960
  const table = this.stripSqlIdent(t.TableName);
953
961
  const schema = this.stripSqlIdent(t.SchemaName);
954
962
  if (table.toLowerCase() === '__mj_clause_screen') continue;
955
- const qualified = `${schema}.${table}`.toLowerCase();
956
- const bare = table.toLowerCase();
957
- if (allowed.qualified.has(qualified) || allowed.bare.has(bare)) continue;
963
+ this.assertTableRefReadable(allowed, schema, table, label, user);
964
+ }
965
+ }
966
+
967
+ /**
968
+ * SECURITY — screen for a FULL client-supplied SQL statement (ad-hoc query path). In addition
969
+ * to the SELECT-only validation the caller performs, every table reference must resolve to an
970
+ * entity BaseView the acting user can read UNSCOPED. CTE names defined by the statement itself
971
+ * are excluded from the check. Fails closed: no user, or an unresolvable reference, refuses.
972
+ *
973
+ * Entity CanRead is not unscoped base-view read authority: RunView narrows a granted read with
974
+ * row-level security and denied-field projection, and raw SQL applies neither. Rather than try
975
+ * to rewrite arbitrary SQL (joins, CTEs) to compose that policy, this refuses any entity that
976
+ * carries a row filter or a denied field for the caller — those reads belong on RunView. For
977
+ * the remaining entities CanRead genuinely means "every row and field of the view".
978
+ */
979
+ protected assertFullQueryUsesReadableEntityViews(
980
+ sqlText: string,
981
+ provider: IMetadataProvider | undefined,
982
+ user: UserInfo | undefined,
983
+ label = 'SQL',
984
+ ): void {
985
+ if (!user) {
986
+ throw new Error(`Invalid ${label}: no acting user resolved for ad-hoc SQL — refusing`);
987
+ }
988
+ const dialect = this.dialectForProvider(provider);
989
+ const parser = new SQLParser(sqlText, dialect);
990
+ if (!parser.IsValid || parser.HasWriteStatement || parser.StatementKind !== 'select') {
991
+ throw new Error(`Invalid ${label}: not a safe read-only statement — refusing under uncertainty`);
992
+ }
993
+ if (this.astContainsWriteNode(parser.AST)) {
994
+ throw new Error(`Invalid ${label}: write/DDL nested in a subquery is not permitted`);
995
+ }
996
+ const cteNames = new Set<string>();
997
+ this.collectCTENames(parser.AST, cteNames);
998
+ const allowed = this.entityBaseViewAllowList(provider);
999
+ const tables = SQLParser.ExtractTableRefs(sqlText, dialect);
1000
+ for (const t of tables) {
1001
+ const table = this.stripSqlIdent(t.TableName);
1002
+ const schema = this.stripSqlIdent(t.SchemaName);
1003
+ if (this.isUnqualifiedSchema(schema) && cteNames.has(table.toLowerCase())) continue;
1004
+ const entity = this.assertTableRefReadable(allowed, schema, table, label, user);
1005
+ this.assertUnscopedRead(entity, user, label);
1006
+ }
1007
+ }
1008
+
1009
+ /** Refuses an entity whose read is narrowed for this user by row-level security or field-level denials. */
1010
+ private assertUnscopedRead(entity: EntityInfo, user: UserInfo, label: string): void {
1011
+ if (entity.GetEffectiveRowFilterWhereClause(user, EntityPermissionType.Read, '').length > 0) {
958
1012
  throw new Error(
959
- `Invalid ${label}: subquery must use an entity base view, not '${schema}.${table}'`,
1013
+ `Invalid ${label}: entity '${entity.Name}' is row-level-security filtered for you — ad-hoc SQL cannot apply that filter; use RunView`,
1014
+ );
1015
+ }
1016
+ if (entity.GetDeniedReadFields(user).size > 0) {
1017
+ throw new Error(
1018
+ `Invalid ${label}: entity '${entity.Name}' has fields you are not permitted to read — ad-hoc SQL cannot project them away; use RunView`,
960
1019
  );
961
1020
  }
962
1021
  }
963
1022
 
1023
+ /** Walks a parsed AST collecting the names of CTEs the statement itself defines. */
1024
+ private collectCTENames(node: unknown, names: Set<string>): void {
1025
+ if (!node || typeof node !== 'object') return;
1026
+ if (Array.isArray(node)) {
1027
+ for (const n of node) this.collectCTENames(n, names);
1028
+ return;
1029
+ }
1030
+ const obj = node as Record<string, unknown>;
1031
+ const withClause = obj.with;
1032
+ if (Array.isArray(withClause)) {
1033
+ for (const cte of withClause) {
1034
+ const name = (cte as Record<string, unknown>)?.name;
1035
+ if (typeof name === 'string') names.add(name.toLowerCase());
1036
+ else if (name && typeof name === 'object' && typeof (name as Record<string, unknown>).value === 'string') {
1037
+ names.add(((name as Record<string, unknown>).value as string).toLowerCase());
1038
+ }
1039
+ }
1040
+ }
1041
+ for (const v of Object.values(obj)) this.collectCTENames(v, names);
1042
+ }
1043
+
964
1044
  /**
965
1045
  * Applies {@link assertClientClauseUsesEntityBaseViews} to every client-supplied clause
966
1046
  * a view request can carry that is actually a SQL fragment. GraphQL entry points
@@ -991,10 +1071,11 @@ export class ResolverBase {
991
1071
  overrideExcludeFilter?: string | null;
992
1072
  },
993
1073
  provider?: IMetadataProvider,
1074
+ user?: UserInfo,
994
1075
  ): void {
995
- this.assertClientClauseUsesEntityBaseViews(clauses.extraFilter, 'ExtraFilter', provider);
996
- this.assertClientClauseUsesEntityBaseViews(clauses.orderBy, 'OrderBy', provider);
997
- this.assertClientClauseUsesEntityBaseViews(clauses.overrideExcludeFilter, 'OverrideExcludeFilter', provider);
1076
+ this.assertClientClauseUsesEntityBaseViews(clauses.extraFilter, 'ExtraFilter', provider, user);
1077
+ this.assertClientClauseUsesEntityBaseViews(clauses.orderBy, 'OrderBy', provider, user);
1078
+ this.assertClientClauseUsesEntityBaseViews(clauses.overrideExcludeFilter, 'OverrideExcludeFilter', provider, user);
998
1079
  }
999
1080
 
1000
1081
  /** Same write-node walk as EDS `sqlReadOnlyScreen.astContainsWriteNode`. */
@@ -1020,28 +1101,84 @@ export class ResolverBase {
1020
1101
  return new SQLServerDialect();
1021
1102
  }
1022
1103
 
1104
+ /** True for a reference with no schema — which `SQLParser.ExtractTableRefs` reports as `dbo`. */
1105
+ private isUnqualifiedSchema(schema: string): boolean {
1106
+ return !schema || schema.toLowerCase() === 'dbo';
1107
+ }
1108
+
1023
1109
  private stripSqlIdent(name: string | null | undefined): string {
1024
1110
  if (!name) return '';
1025
1111
  return name.replace(/^\[|\]$/g, '').replace(/^"|"$/g, '').replace(/^`|`$/g, '');
1026
1112
  }
1027
1113
 
1114
+ /**
1115
+ * `bare` maps an unqualified view name to its entity, or to `null` when the name exists in
1116
+ * more than one schema — the database, not this screen, would decide which one runs.
1117
+ */
1028
1118
  private entityBaseViewAllowList(provider?: IMetadataProvider): {
1029
- qualified: Set<string>;
1030
- bare: Set<string>;
1119
+ qualified: Map<string, EntityInfo>;
1120
+ bare: Map<string, EntityInfo | null>;
1031
1121
  } {
1032
- const qualified = new Set<string>();
1033
- const bare = new Set<string>();
1122
+ const qualified = new Map<string, EntityInfo>();
1123
+ const bare = new Map<string, EntityInfo | null>();
1034
1124
  const entities = provider?.Entities ?? [];
1035
1125
  for (const e of entities) {
1036
1126
  const view = this.stripSqlIdent(e.BaseView);
1037
1127
  if (!view) continue;
1038
1128
  const schema = this.stripSqlIdent(e.SchemaName);
1039
- bare.add(view.toLowerCase());
1040
- if (schema) qualified.add(`${schema}.${view}`.toLowerCase());
1129
+ const bareKey = view.toLowerCase();
1130
+ bare.set(bareKey, bare.has(bareKey) ? null : e);
1131
+ if (schema) qualified.set(`${schema}.${view}`.toLowerCase(), e);
1041
1132
  }
1042
1133
  return { qualified, bare };
1043
1134
  }
1044
1135
 
1136
+ /**
1137
+ * SECURITY — resolves a table reference against the entity BaseView allow-list and, when an
1138
+ * acting user is supplied, additionally requires that user to hold CanRead on the referenced
1139
+ * entity. Base views do not embed RLS and entity permissions are otherwise checked only on
1140
+ * the TOP entity of a request, so without this check a subquery (or ad-hoc query) could read
1141
+ * entities the caller has no read grant on.
1142
+ *
1143
+ * A schema-qualified reference must match that exact schema — `secret.vwFoo` never resolves
1144
+ * through another schema's `vwFoo`. An unqualified reference resolves only when the view name
1145
+ * is unique across schemas; an ambiguous one must be qualified, because the database's
1146
+ * default-schema resolution, not this screen, would pick which view actually runs.
1147
+ *
1148
+ * `SQLParser.ExtractTableRefs` reports an unqualified reference as schema `dbo`, so `dbo` is
1149
+ * indistinguishable from "no schema" here and both take the unqualified path (an exact
1150
+ * `dbo.<view>` entity wins first).
1151
+ */
1152
+ private assertTableRefReadable(
1153
+ allowed: { qualified: Map<string, EntityInfo>; bare: Map<string, EntityInfo | null> },
1154
+ schema: string,
1155
+ table: string,
1156
+ label: string,
1157
+ user?: UserInfo,
1158
+ ): EntityInfo {
1159
+ const qualified = allowed.qualified.get(`${schema}.${table}`.toLowerCase());
1160
+ const entity = this.isUnqualifiedSchema(schema) ? qualified ?? allowed.bare.get(table.toLowerCase()) : qualified;
1161
+ if (entity === null) {
1162
+ throw new Error(
1163
+ `Invalid ${label}: '${table}' is a base view in more than one schema — qualify it with its schema`,
1164
+ );
1165
+ }
1166
+ if (!entity) {
1167
+ throw new Error(
1168
+ `Invalid ${label}: subquery must use an entity base view, not '${schema ? schema + '.' : ''}${table}'`,
1169
+ );
1170
+ }
1171
+ if (user) {
1172
+ const perms = entity.GetUserPermisions(user);
1173
+ if (!perms.CanRead) {
1174
+ throw new Error(
1175
+ `Invalid ${label}: you do not have read permission on entity '${entity.Name}' referenced by '${schema ? schema + '.' : ''}${table}'`,
1176
+ );
1177
+ }
1178
+ }
1179
+ return entity;
1180
+ }
1181
+
1045
1182
  /**
1046
1183
  * Optimized RunViewGenericInternal implementation with:
1047
1184
  * - Field filtering at source (Fix #7)
@@ -1068,29 +1205,33 @@ export class ResolverBase {
1068
1205
  aggregates?: AggregateExpression[],
1069
1206
  afterKey?: CompositeKey,
1070
1207
  bypassCache?: boolean,
1071
- dataSource?: 'Live' | 'Materialized'
1208
+ dataSource?: 'Live' | 'Materialized',
1209
+ includeBinaryFields?: boolean
1072
1210
  ) {
1073
1211
  try {
1074
1212
  if (!viewInfo || !userPayload) return null;
1075
1213
 
1214
+ // Prefer the authenticated session's payload user — it is the authoritative per-request
1215
+ // identity and (for magic-link sessions) carries the per-session resource scope / synthesized
1216
+ // roles that drive RLS. The cached lookup is a fallback for paths where the payload user
1217
+ // isn't populated. For normal users the two are the same instance, so this is a no-op.
1218
+ const user = this.GetUserFromPayload(userPayload)
1219
+ ?? UserCache.Users.find((u) => u.Email.toLowerCase().trim() === userPayload?.email.toLowerCase().trim());
1220
+ if (!user) throw new Error(`User ${userPayload?.email} not found in metadata`);
1221
+
1076
1222
  // SECURITY: GraphQL ExtraFilter may contain IN (SELECT … FROM <base view>).
1077
- // Screen at this boundary: parse, reject writes, allow only entity BaseViews.
1223
+ // Screen at this boundary: parse, reject writes, allow only entity BaseViews the
1224
+ // acting user can read (subqueries do not inherit the top entity's permission check).
1078
1225
  this.screenClientViewClauses(
1079
1226
  { extraFilter, orderBy, userSearchString, overrideExcludeFilter },
1080
1227
  provider as unknown as IMetadataProvider,
1228
+ user,
1081
1229
  );
1082
1230
 
1083
1231
  // Check API key scope authorization for view operations
1084
1232
  await this.CheckAPIKeyScopeAuthorization('view:run', viewInfo.Entity, userPayload);
1085
1233
 
1086
1234
  const md = provider
1087
- // Prefer the authenticated session's payload user — it is the authoritative per-request
1088
- // identity and (for magic-link sessions) carries the per-session resource scope / synthesized
1089
- // roles that drive RLS. The cached lookup is a fallback for paths where the payload user
1090
- // isn't populated. For normal users the two are the same instance, so this is a no-op.
1091
- const user = this.GetUserFromPayload(userPayload)
1092
- ?? UserCache.Users.find((u) => u.Email.toLowerCase().trim() === userPayload?.email.toLowerCase().trim());
1093
- if (!user) throw new Error(`User ${userPayload?.email} not found in metadata`);
1094
1235
 
1095
1236
  const entityInfo = md.Entities.find((e) => e.Name === viewInfo.Entity);
1096
1237
  if (!entityInfo) throw new Error(`Entity ${viewInfo.Entity} not found in metadata`);
@@ -1146,6 +1287,7 @@ export class ResolverBase {
1146
1287
  Aggregates: aggregates,
1147
1288
  BypassCache: bypassCache,
1148
1289
  DataSource: dataSource,
1290
+ IncludeBinaryFields: includeBinaryFields,
1149
1291
  },
1150
1292
  user
1151
1293
  );
@@ -1250,6 +1392,7 @@ export class ResolverBase {
1250
1392
  overrideExcludeFilter: param.overrideExcludeFilter,
1251
1393
  },
1252
1394
  md,
1395
+ contextUser ?? undefined,
1253
1396
  );
1254
1397
 
1255
1398
  if (param.viewInfo) {
@@ -1293,6 +1436,7 @@ export class ResolverBase {
1293
1436
  Aggregates: param.aggregates,
1294
1437
  BypassCache: param.bypassCache,
1295
1438
  DataSource: param.dataSource,
1439
+ IncludeBinaryFields: param.includeBinaryFields,
1296
1440
  });
1297
1441
  }
1298
1442
 
@@ -3,7 +3,7 @@ import { AppContext } from '../types.js';
3
3
  import { ResolverBase } from './ResolverBase.js';
4
4
  import { LogError, LogStatus, EntityInfo, FieldSecurityError, RunViewWithCacheCheckResult, RunViewsWithCacheCheckResponse, RunViewWithCacheCheckParams, AggregateResult, CompositeKey } from '@memberjunction/core';
5
5
 
6
- import { UUIDsEqual } from '@memberjunction/global';
6
+ import { ReplaceByteArraysWithBase64, UUIDsEqual } from '@memberjunction/global';
7
7
  import { RequireSystemUser } from '../directives/RequireSystemUser.js';
8
8
  import { GetReadOnlyProvider } from '../util.js';
9
9
  import { MJUserViewEntityExtended } from '@memberjunction/core-entities';
@@ -200,6 +200,13 @@ export class RunViewByIDInput {
200
200
  "Optional source-of-truth selector for a base-view materialization: 'Live' (default) reads the entity's live base view; 'Materialized' reads its materialized snapshot wrapper view (materialized_vw<CodeName>). Only request 'Materialized' for an entity that actually has a base-view materialization: it resolves to the wrapper view by naming convention with NO existence check or fallback, so requesting it for a non-materialized entity targets a view that does not exist and the query errors.",
201
201
  })
202
202
  DataSource?: 'Live' | 'Materialized';
203
+
204
+ @Field(() => Boolean, {
205
+ nullable: true,
206
+ description:
207
+ 'Optional, when true the binary fields of the entity (varbinary / bytea columns) are returned, as base64 strings. They are omitted by default because they are usually large and lists rarely read them. A binary field named explicitly in Fields is always returned.',
208
+ })
209
+ IncludeBinaryFields?: boolean;
203
210
  }
204
211
 
205
212
  @InputType()
@@ -321,6 +328,13 @@ export class RunViewByNameInput {
321
328
  "Optional source-of-truth selector for a base-view materialization: 'Live' (default) reads the entity's live base view; 'Materialized' reads its materialized snapshot wrapper view (materialized_vw<CodeName>). Only request 'Materialized' for an entity that actually has a base-view materialization: it resolves to the wrapper view by naming convention with NO existence check or fallback, so requesting it for a non-materialized entity targets a view that does not exist and the query errors.",
322
329
  })
323
330
  DataSource?: 'Live' | 'Materialized';
331
+
332
+ @Field(() => Boolean, {
333
+ nullable: true,
334
+ description:
335
+ 'Optional, when true the binary fields of the entity (varbinary / bytea columns) are returned, as base64 strings. They are omitted by default because they are usually large and lists rarely read them. A binary field named explicitly in Fields is always returned.',
336
+ })
337
+ IncludeBinaryFields?: boolean;
324
338
  }
325
339
 
326
340
  @InputType()
@@ -428,6 +442,13 @@ export class RunDynamicViewInput {
428
442
  "Optional source-of-truth selector for a base-view materialization: 'Live' (default) reads the entity's live base view; 'Materialized' reads its materialized snapshot wrapper view (materialized_vw<CodeName>). Only request 'Materialized' for an entity that actually has a base-view materialization: it resolves to the wrapper view by naming convention with NO existence check or fallback, so requesting it for a non-materialized entity targets a view that does not exist and the query errors.",
429
443
  })
430
444
  DataSource?: 'Live' | 'Materialized';
445
+
446
+ @Field(() => Boolean, {
447
+ nullable: true,
448
+ description:
449
+ 'Optional, when true the binary fields of the entity (varbinary / bytea columns) are returned, as base64 strings. They are omitted by default because they are usually large and lists rarely read them. A binary field named explicitly in Fields is always returned.',
450
+ })
451
+ IncludeBinaryFields?: boolean;
431
452
  }
432
453
 
433
454
  @InputType()
@@ -564,6 +585,13 @@ export class RunViewGenericInput {
564
585
  "Optional source-of-truth selector for a base-view materialization: 'Live' (default) reads the entity's live base view; 'Materialized' reads its materialized snapshot wrapper view (materialized_vw<CodeName>). Only request 'Materialized' for an entity that actually has a base-view materialization: it resolves to the wrapper view by naming convention with NO existence check or fallback, so requesting it for a non-materialized entity targets a view that does not exist and the query errors.",
565
586
  })
566
587
  DataSource?: 'Live' | 'Materialized';
588
+
589
+ @Field(() => Boolean, {
590
+ nullable: true,
591
+ description:
592
+ 'Optional, when true the binary fields of the entity (varbinary / bytea columns) are returned, as base64 strings. They are omitted by default because they are usually large and lists rarely read them. A binary field named explicitly in Fields is always returned.',
593
+ })
594
+ IncludeBinaryFields?: boolean;
567
595
  }
568
596
 
569
597
  //****************************************************************************
@@ -1219,6 +1247,7 @@ export class RunViewResolver extends ResolverBase {
1219
1247
  // DataSource must be forwarded on this transport too (same as the InternalRunView/RunViews maps):
1220
1248
  // otherwise a CacheLocal batch's DataSource:'Materialized' request silently reads the live view.
1221
1249
  DataSource: item.params.DataSource,
1250
+ IncludeBinaryFields: item.params.IncludeBinaryFields,
1222
1251
  AfterKey: item.params.AfterKey
1223
1252
  ? CompositeKey.FromKeyValuePairs(item.params.AfterKey.KeyValuePairs)
1224
1253
  : undefined,
@@ -1322,7 +1351,9 @@ export class RunViewResolver extends ResolverBase {
1322
1351
  returnResult.push({
1323
1352
  PrimaryKey: primaryKey,
1324
1353
  EntityID: entityId,
1325
- Data: JSON.stringify(row),
1354
+ // Binary columns arrive as base64 from the providers; this is the safety net for any
1355
+ // row that still holds a byte array, which JSON.stringify would emit as {type,data}.
1356
+ Data: JSON.stringify(ReplaceByteArraysWithBase64(row)),
1326
1357
  });
1327
1358
  }
1328
1359
  return returnResult;