@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
@@ -1,14 +1,14 @@
1
1
  import { Arg, Ctx, Query, Resolver, Field, Int, InputType } from 'type-graphql';
2
- import { DatabasePlatform, LogError } from '@memberjunction/core';
3
- import { SQLExpressionValidator } from '@memberjunction/global';
2
+ import { LogError } from '@memberjunction/core';
3
+ import type { DatabasePlatform, IMetadataProvider, RunQueryResult, UserInfo } from '@memberjunction/core';
4
4
  import { RenderPipeline } from '@memberjunction/generic-database-provider';
5
5
  import { AppContext } from '../types.js';
6
- import { GetReadOnlyDataSource, GetReadOnlyProvider } from '../util.js';
6
+ import { configInfo } from '../config.js';
7
+ import { GetReadOnlyProvider } from '../util.js';
7
8
  import { ResolverBase } from '../generic/ResolverBase.js';
8
9
  import { IsScopeLimitedPrincipal } from '../auth/scopeLimitedPrincipal.js';
9
10
  import { RunQueryResultType } from './QueryResolver.js';
10
- import { ExactTotalFromPage, ResolveAdhocTotalRowCount } from './adhoc-query-helpers.js';
11
- import sql from 'mssql';
11
+ import { ClampAdhocTimeoutSeconds } from './adhocTimeout.js';
12
12
 
13
13
  /**
14
14
  * Input type for executing ad-hoc SQL queries directly.
@@ -32,10 +32,19 @@ class AdhocQueryInput {
32
32
  /**
33
33
  * Resolver for executing ad-hoc (unsaved) SQL queries.
34
34
  *
35
+ * The query runs through the read-only provider's own ad-hoc path (`RunQuery` with `SQL`), the
36
+ * same one in-process callers use, so it behaves identically on every database platform: the SQL
37
+ * is validated and rendered there, paged in the database with a count, run under the caller's
38
+ * timeout, and on PostgreSQL inside a read-only transaction that is rolled back.
39
+ *
35
40
  * Security:
36
- * - SQL validated via SQLExpressionValidator (full_query context) — blocks mutations, dangerous operations
37
- * - Executes on read-only connection pool only (no fallback to read-write)
38
- * - Configurable timeout (default 30s)
41
+ * - SQL validated by the provider (SQLExpressionValidator, single read query) — blocks mutations
42
+ * - Authorizes the RENDERED SQL: every table reference must be an entity base view the caller
43
+ * reads unscoped (CanRead, no row-level filter, no denied fields). Entities with row/field
44
+ * policy for this user are refused — raw SQL cannot apply that policy, so those reads belong
45
+ * on RunView.
46
+ * - Read-only provider only (no fallback to read-write)
47
+ * - Configurable timeout (default 30s), enforced by the database
39
48
  * - Requires authenticated user (standard GraphQL auth, no @RequireSystemUser)
40
49
  * - Refuses scope-limited principals (see {@link IsScopeLimitedPrincipal}). Raw SQL bypasses
41
50
  * RunView, entity permissions and row-level security, so the RLS scope tokens that confine a
@@ -52,201 +61,92 @@ export class AdhocQueryResolver extends ResolverBase {
52
61
  @Ctx() context: AppContext
53
62
  ): Promise<RunQueryResultType> {
54
63
  const startTime = Date.now();
55
-
64
+ // SECURITY: refuse principals whose read authority is narrower than their role grant.
65
+ // Ad-hoc SQL does not go through RunView, entity permissions or row-level security, so the
66
+ // RLS scope tokens that confine a magic-link session are never substituted. There is no
67
+ // filter to narrow, so the only correct answer for such a principal is no.
68
+ if (IsScopeLimitedPrincipal(context.userPayload?.userRecord)) {
69
+ return this.buildErrorResult('Ad-hoc SQL execution is not permitted for scope-limited sessions.');
70
+ }
71
+ const provider = GetReadOnlyProvider(context.providers, { allowFallbackToReadWrite: false });
72
+ if (!provider) {
73
+ return this.buildErrorResult('No read-only data source available for ad-hoc query execution');
74
+ }
75
+ const contextUser = context.userPayload?.userRecord;
76
+ const refusal = this.authorizeRenderedSQL(input.SQL, provider.PlatformKey, context, contextUser);
77
+ if (refusal) {
78
+ return this.buildErrorResult(refusal, Date.now() - startTime);
79
+ }
80
+ const timeoutSeconds = ClampAdhocTimeoutSeconds(input.TimeoutSeconds, configInfo.databaseSettings.requestTimeout);
81
+ // A negative offset must not reach paging, where it would turn paging off.
82
+ const startRow = Math.max(0, Number.isInteger(input.StartRow) ? input.StartRow! : 0);
56
83
  try {
57
- // 1. SECURITY: refuse principals whose read authority is narrower than their role
58
- // grant. This resolver runs a raw SELECT on the read-only pool — it does NOT go
59
- // through RunView, entity permissions or row-level security, so the RLS scope tokens
60
- // that confine a magic-link session are never substituted here. There is no filter
61
- // to narrow, so the only correct answer for such a principal is no. Checked before
62
- // the SQL is even validated: authorization gates the work, it does not race it.
63
- if (IsScopeLimitedPrincipal(context.userPayload?.userRecord)) {
64
- return this.buildErrorResult('Ad-hoc SQL execution is not permitted for scope-limited sessions.');
65
- }
66
-
67
- // 2. Security: validate SQL using SQLExpressionValidator
68
- const validator = SQLExpressionValidator.Instance;
69
- const validation = validator.validateFullQuery(input.SQL);
70
- if (!validation.valid) {
71
- return this.buildErrorResult(validation.error || 'SQL validation failed');
72
- }
73
-
74
- // 3. Get READ-ONLY data source (no fallback to read-write)
75
- let readOnlyDS: sql.ConnectionPool;
76
- try {
77
- readOnlyDS = GetReadOnlyDataSource(context.dataSources, { allowFallbackToReadWrite: false });
78
- } catch {
79
- return this.buildErrorResult('No read-only data source available for ad-hoc query execution');
80
- }
81
-
82
- // 4. Resolve platform from the read-only provider for the render pipeline.
83
- let platform: DatabasePlatform = 'sqlserver';
84
- try {
85
- const provider = GetReadOnlyProvider(context.providers, { allowFallbackToReadWrite: false });
86
- if (provider?.PlatformKey) platform = provider.PlatformKey;
87
- } catch {
88
- // Provider not configured — keep the default platform.
89
- }
90
- const contextUser = context.userPayload?.userRecord;
91
-
92
- // 5. Route the SQL through RenderPipeline so composition tokens
93
- // resolve, comments and templates are processed, and the row cap
94
- // is applied at the database (via TOP / LIMIT / OFFSET-FETCH).
95
- //
96
- // Page whenever a positive MaxRows is requested — INCLUDING the first
97
- // page (StartRow 0). This yields OFFSET/FETCH data SQL *and* a
98
- // COUNT(*) query so the response reports the true total row count
99
- // rather than just the returned page size. It mirrors the saved-query
100
- // path (GenericDatabaseProvider.InternalRunQuery → WrapWithPaging).
101
- // (Previously paging was gated on StartRow > 0, so page 1 fell back to
102
- // a TOP-N cap with TotalRowCount = page size — hiding the pager.)
103
- // Clamp StartRow to a non-negative integer. A negative offset must not slip
104
- // past the paging gate (which would then run the query with no row cap at all).
105
- const startRow = Math.max(0, Number.isInteger(input.StartRow) ? input.StartRow! : 0);
106
- const maxRows = input.MaxRows;
107
- const usePaging = maxRows != null && Number.isInteger(maxRows) && maxRows > 0;
108
-
109
- let dataSQL: string;
110
- let countSQL: string | null = null;
111
- try {
112
- const rendered = RenderPipeline.Run(input.SQL, {
113
- Platform: platform,
114
- ContextUser: contextUser,
115
- ...(usePaging ? { Paging: { StartRow: startRow, MaxRows: maxRows! } } : {}),
116
- });
117
- dataSQL = rendered.FinalSQL;
118
- countSQL = rendered.PagingResult?.CountSQL ?? null;
119
- } catch (renderErr) {
120
- const renderMsg = renderErr instanceof Error ? renderErr.message : String(renderErr);
121
- return this.buildErrorResult(`Ad-hoc query rendering failed: ${renderMsg}`);
122
- }
123
-
124
- // 6. Execute the page (and, only when a full page needs it, the count) under
125
- // a shared wall-clock deadline derived from the request's timeout budget.
126
- const deadline = startTime + (input.TimeoutSeconds ?? 30) * 1000;
127
- const { recordset, totalRowCount } = await this.executeDataAndCount(
128
- readOnlyDS, dataSQL, countSQL, startRow, usePaging ? maxRows! : null, deadline
129
- );
130
- const executionTimeMs = Date.now() - startTime;
131
-
132
- // 7. Return as RunQueryResultType
133
- return {
134
- QueryID: '',
135
- QueryName: 'Ad-Hoc Query',
136
- Success: true,
137
- Results: JSON.stringify(recordset),
138
- RowCount: recordset.length,
139
- TotalRowCount: totalRowCount,
140
- PageNumber: usePaging ? Math.floor(startRow / maxRows!) + 1 : undefined,
141
- PageSize: usePaging ? maxRows! : undefined,
142
- ExecutionTime: executionTimeMs,
143
- ErrorMessage: ''
144
- };
84
+ const result = await provider.RunQuery({
85
+ SQL: input.SQL,
86
+ MaxRows: input.MaxRows,
87
+ StartRow: startRow,
88
+ TimeoutSeconds: timeoutSeconds,
89
+ }, contextUser);
90
+ return this.toResultType(result, startRow, input.MaxRows, timeoutSeconds, Date.now() - startTime);
145
91
  } catch (err: unknown) {
146
- const executionTimeMs = Date.now() - startTime;
147
92
  const errorMessage = err instanceof Error ? err.message : String(err);
148
-
149
- // Handle timeout
150
- if (errorMessage.includes('timeout') || errorMessage.includes('Timeout')) {
151
- return {
152
- QueryID: '',
153
- QueryName: 'Ad-Hoc Query',
154
- Success: false,
155
- Results: '[]',
156
- RowCount: 0,
157
- TotalRowCount: 0,
158
- PageNumber: undefined,
159
- PageSize: undefined,
160
- ExecutionTime: executionTimeMs,
161
- ErrorMessage: `Query execution exceeded ${input.TimeoutSeconds ?? 30} second timeout`
162
- };
163
- }
164
-
165
93
  LogError(`Ad-hoc query execution failed: ${errorMessage}`);
166
- return {
167
- QueryID: '',
168
- QueryName: 'Ad-Hoc Query',
169
- Success: false,
170
- Results: '[]',
171
- RowCount: 0,
172
- TotalRowCount: 0,
173
- PageNumber: undefined,
174
- PageSize: undefined,
175
- ExecutionTime: executionTimeMs,
176
- ErrorMessage: `Query execution failed: ${errorMessage}`
177
- };
94
+ return this.buildErrorResult(`Query execution failed: ${errorMessage}`, Date.now() - startTime);
178
95
  }
179
96
  }
180
97
 
181
98
  /**
182
- * Runs the page's data SQL, then — only when the page is FULL (so more rows may
183
- * exist) — a COUNT(*) for the true total. A short (or unpaged) page needs no
184
- * count: the exact total is `startRow + rowsReturned` (see {@link exactTotalFromPage}).
185
- *
186
- * The count is NON-FATAL: some queries page fine but cannot be counted — e.g.
187
- * duplicate column names are legal in a result set but rejected inside the COUNT
188
- * CTE wrap. A count failure must never sink the whole result, so we log it and
189
- * report a lower-bound total (`startRow + rowsReturned`) and let the data render.
99
+ * SECURITY: authorizes the READ itself, against the SQL that will actually run. The provider
100
+ * validates that the SQL is a single read statement but places no restriction on WHAT is read.
101
+ * Rendering comes first because composition (`{{query:"..."}}`) and templates can introduce
102
+ * table references the raw input never named. Every table reference must be an entity base
103
+ * view the caller can read UNSCOPED — CanRead with no row-level filter and no denied fields —
104
+ * because raw SQL applies neither (see assertFullQueryUsesReadableEntityViews). The provider
105
+ * renders the same input the same way and only wraps it in server-built paging and a count,
106
+ * so the rendered SQL is the full set of reads. Returns the refusal message, or null.
190
107
  */
191
- private async executeDataAndCount(
192
- ds: sql.ConnectionPool,
193
- dataSQL: string,
194
- countSQL: string | null,
195
- startRow: number,
196
- maxRows: number | null,
197
- deadline: number,
198
- ): Promise<{ recordset: Record<string, unknown>[]; totalRowCount: number }> {
199
- const dataResult = await this.runSqlWithDeadline<Record<string, unknown>>(ds, dataSQL, deadline);
200
- const recordset = (dataResult.recordset ?? []) as Record<string, unknown>[];
201
-
202
- // Total already known from the page alone (unpaged, or a short page)? Skip the count.
203
- const exact = ExactTotalFromPage(startRow, recordset.length, maxRows);
204
- if (exact != null || !countSQL) {
205
- return { recordset, totalRowCount: exact ?? recordset.length };
206
- }
207
-
208
- // Full page — a COUNT(*) is required to know the true total.
209
- const lowerBound = startRow + recordset.length;
108
+ private authorizeRenderedSQL(sqlText: string, platform: DatabasePlatform, context: AppContext, contextUser: UserInfo | undefined): string | null {
210
109
  try {
211
- const countResult = await this.runSqlWithDeadline<{ TotalRowCount: number }>(ds, countSQL, deadline);
212
- return { recordset, totalRowCount: ResolveAdhocTotalRowCount(countResult.recordset, lowerBound) };
213
- } catch (countErr) {
214
- const msg = countErr instanceof Error ? countErr.message : String(countErr);
215
- LogError(`Ad-hoc query row-count failed; reporting a lower-bound total (${lowerBound}). ${msg}`);
216
- return { recordset, totalRowCount: lowerBound };
110
+ const rendered = RenderPipeline.Run(sqlText, { Platform: platform, ContextUser: contextUser, RequireReadStatement: true });
111
+ const mdProvider: IMetadataProvider | undefined = GetReadOnlyProvider(context.providers, { allowFallbackToReadWrite: true }) ?? undefined;
112
+ this.assertFullQueryUsesReadableEntityViews(rendered.Trace.AfterTemplates, mdProvider, contextUser, 'ad-hoc SQL');
113
+ return null;
114
+ } catch (err) {
115
+ return err instanceof Error ? err.message : String(err);
217
116
  }
218
117
  }
219
118
 
220
- /**
221
- * Executes one SQL statement on the read-only pool, racing it against the shared
222
- * wall-clock `deadline`. The timer is always cleared on completion so a settled
223
- * query never leaves a dangling timeout armed.
224
- */
225
- private async runSqlWithDeadline<T>(
226
- ds: sql.ConnectionPool,
227
- sqlText: string,
228
- deadline: number,
229
- ): Promise<sql.IResult<T>> {
230
- const remaining = deadline - Date.now();
231
- if (remaining <= 0) {
232
- throw new Error('Query timeout exceeded');
233
- }
234
- let timer: ReturnType<typeof setTimeout> | undefined;
235
- try {
236
- return await Promise.race([
237
- new sql.Request(ds).query<T>(sqlText),
238
- new Promise<never>((_, reject) => {
239
- timer = setTimeout(() => reject(new Error('Query timeout exceeded')), remaining);
240
- }),
241
- ]);
242
- } finally {
243
- if (timer) {
244
- clearTimeout(timer);
245
- }
119
+ /** Maps the provider's result to the GraphQL shape, naming a timeout plainly. */
120
+ private toResultType(
121
+ result: RunQueryResult,
122
+ startRow: number,
123
+ maxRows: number | undefined,
124
+ timeoutSeconds: number,
125
+ executionTimeMs: number,
126
+ ): RunQueryResultType {
127
+ if (!result.Success) {
128
+ const message = result.ErrorMessage ?? 'Ad-hoc query failed';
129
+ return this.buildErrorResult(
130
+ /timeout/i.test(message) ? `Query execution exceeded ${timeoutSeconds} second timeout` : message,
131
+ executionTimeMs,
132
+ );
246
133
  }
134
+ const paged = maxRows != null && Number.isInteger(maxRows) && maxRows > 0;
135
+ return {
136
+ QueryID: '',
137
+ QueryName: 'Ad-Hoc Query',
138
+ Success: true,
139
+ Results: JSON.stringify(result.Results),
140
+ RowCount: result.RowCount,
141
+ TotalRowCount: result.TotalRowCount,
142
+ PageNumber: paged ? Math.floor(startRow / maxRows!) + 1 : undefined,
143
+ PageSize: paged ? maxRows : undefined,
144
+ ExecutionTime: executionTimeMs,
145
+ ErrorMessage: ''
146
+ };
247
147
  }
248
148
 
249
- private buildErrorResult(errorMessage: string): RunQueryResultType {
149
+ private buildErrorResult(errorMessage: string, executionTimeMs = 0): RunQueryResultType {
250
150
  return {
251
151
  QueryID: '',
252
152
  QueryName: 'Ad-Hoc Query',
@@ -256,7 +156,7 @@ export class AdhocQueryResolver extends ResolverBase {
256
156
  TotalRowCount: 0,
257
157
  PageNumber: undefined,
258
158
  PageSize: undefined,
259
- ExecutionTime: 0,
159
+ ExecutionTime: executionTimeMs,
260
160
  ErrorMessage: errorMessage
261
161
  };
262
162
  }
@@ -1,4 +1,4 @@
1
- import { CompositeKey, DatabaseProviderBase, EntityInfo, LogDebug, UserInfo } from '@memberjunction/core';
1
+ import { CompositeKey, DatabaseProviderBase } from '@memberjunction/core';
2
2
  import { Arg, Ctx, Field, InputType, ObjectType, Query, Resolver } from 'type-graphql';
3
3
  import { AppContext, UserPayload } from '../types.js';
4
4
  import { CompositeKeyInputType, CompositeKeyOutputType } from '../generic/KeyInputOutputTypes.js';
@@ -36,20 +36,13 @@ export class EntityRecordNameResult {
36
36
  * Resolves a record's display NAME from its primary key — the lookup behind every foreign-key
37
37
  * link, breadcrumb and picker label in the UI.
38
38
  *
39
- * **This is a read of entity data and is gated like one.** It historically took no user context
40
- * at all, which made it an open side channel: a caller denied READ on an entity's name field
41
- * could still obtain that name here, because the name never travelled through the resolver that
42
- * strips denied fields. Field-level security turned that latent gap into a live bypass — the
43
- * form-field FK control falls through to this query precisely WHEN the joined display column is
44
- * denied, so the one case the fallback exists for was the case that leaked.
39
+ * Entity-level read permission is checked here ({@link ResolverBase.CheckUserReadPermissions}).
40
+ * Field- and row-level security are applied by the provider's lookup, for the acting user passed
41
+ * to it: when any field the name is built from is withheld, it answers with no name.
45
42
  *
46
- * Two gates now apply, matching what the generated single-record resolvers do:
47
- * 1. entity-level read permission ({@link ResolverBase.CheckUserReadPermissions});
48
- * 2. field-level read permission on the entity's own name field.
49
- *
50
- * A field-level denial answers `Success: false` with no name rather than throwing. Callers
51
- * already treat "no name" as "show the primary key", which is the intended degradation — and it
52
- * keeps this query from becoming a probe that distinguishes "restricted" from "missing".
43
+ * A withheld name answers `Success: false` with the same status as a record that does not exist.
44
+ * Callers already treat "no name" as "show the primary key", and the identical answer keeps this
45
+ * query from becoming a probe that distinguishes "restricted" from "missing".
53
46
  */
54
47
  @Resolver(EntityRecordNameResult)
55
48
  export class EntityRecordNameResolver extends ResolverBase {
@@ -91,53 +84,18 @@ export class EntityRecordNameResolver extends ResolverBase {
91
84
  const e = md.Entities.find((e) => e.Name === EntityName);
92
85
  if (e) {
93
86
  const contextUser = userPayload ? this.GetUserFromPayload(userPayload) : undefined;
94
- if (this.IsNameFieldDeniedToUser(e, contextUser)) {
95
- LogDebug(
96
- `[FieldSecurity] Suppressed GetEntityRecordName for '${EntityName}': ` +
97
- `the name field is not readable by this user`
98
- );
99
- return {
100
- Success: false,
101
- Status: `Name for record, or record ${pk.ToString()} itself not found, could be an access issue`,
102
- CompositeKey: pk,
103
- EntityName,
104
- };
105
- }
106
- // Pass the acting user through: the provider signature accepts it, and omitting it ran the
107
- // underlying lookup with no identity at all — so row-level security had nothing to filter on
108
- // either. The field-level gate above is the column half; this is the row half.
87
+ // The acting user is what field- and row-level security are applied for.
109
88
  const recordName = await md.GetEntityRecordName(e.Name, pk, contextUser);
110
89
  if (recordName) return { Success: true, Status: 'OK', CompositeKey: pk, RecordName: recordName, EntityName };
111
90
  else
112
91
  return {
113
92
  Success: false,
114
- Status: `Name for record, or record ${pk.ToString()} itself not found, could be an access issue if user doesn't have Row Level Access (RLS) if RLS is enabled for this entity`,
93
+ Status: `Name for record, or record ${pk.ToString()} itself not found, could be an access issue`,
115
94
  CompositeKey: pk,
116
95
  EntityName,
117
96
  };
118
97
  } else return { Success: false, Status: `Entity ${EntityName} not found`, CompositeKey: pk, EntityName };
119
98
  }
120
-
121
- /**
122
- * Whether field security withholds this entity's NAME field from the user.
123
- *
124
- * Resolves the name field the same way the provider's own lookup does — the designated
125
- * `IsNameField`, else a field literally called `Name` — so the check cannot drift from the
126
- * value it is guarding. Fails OPEN only where field security does not apply at all (flag off,
127
- * no user, no name field), matching every other FLS gate.
128
- */
129
- protected IsNameFieldDeniedToUser(entity: EntityInfo, contextUser?: UserInfo): boolean {
130
- if (!entity?.EnableFieldLevelSecurity || !contextUser) {
131
- return false;
132
- }
133
- const nameField =
134
- entity.Fields?.find(f => f.IsNameField) ??
135
- entity.Fields?.find(f => f.Name?.trim().toLowerCase() === 'name');
136
- if (!nameField) {
137
- return false;
138
- }
139
- return entity.GetDeniedReadFields(contextUser).has(nameField.Name.trim().toLowerCase());
140
- }
141
99
  }
142
100
 
143
101
  export default EntityRecordNameResolver;
@@ -1,4 +1,5 @@
1
1
  import { Arg, Ctx, Field, InputType, ObjectType, Query } from 'type-graphql';
2
+ import { SQLExpressionValidator } from '@memberjunction/global';
2
3
  import { AppContext } from '../types.js';
3
4
  import { LogError, LogStatus, LogStatusEx, Metadata } from '@memberjunction/core';
4
5
  import { QueryCompositionEngine } from '@memberjunction/generic-database-provider';
@@ -154,6 +155,15 @@ export class GetDataResolver {
154
155
  resolvedSQL = compositionResult.ResolvedSQL;
155
156
  }
156
157
 
158
+ // Defense-in-depth: even system-token holders get SELECT-only SQL here.
159
+ // The read-only pool is the primary control, but if it is ever
160
+ // misconfigured to a read-write login this validator still refuses DML/DDL
161
+ // (mirrors AdhocQueryResolver).
162
+ const validation = SQLExpressionValidator.Instance.validateFullQuery(resolvedSQL);
163
+ if (!validation.valid) {
164
+ return { result: null, error: validation.error || 'SQL validation failed' };
165
+ }
166
+
157
167
  const request = new sql.Request(readOnlyDataSource);
158
168
  const result = await request.query(resolvedSQL);
159
169
  return { result: result.recordset, error: null };