@memberjunction/server 6.1.4 → 6.1.5

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 (71) hide show
  1. package/README.md +30 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/auth/magicLink/MagicLinkService.d.ts +17 -15
  4. package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
  5. package/dist/auth/magicLink/MagicLinkService.js +49 -35
  6. package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
  7. package/dist/auth/magicLink/magicLinkCore.d.ts +14 -33
  8. package/dist/auth/magicLink/magicLinkCore.d.ts.map +1 -1
  9. package/dist/auth/magicLink/magicLinkCore.js +20 -52
  10. package/dist/auth/magicLink/magicLinkCore.js.map +1 -1
  11. package/dist/context.d.ts.map +1 -1
  12. package/dist/context.js +11 -23
  13. package/dist/context.js.map +1 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +36 -44
  16. package/dist/index.js.map +1 -1
  17. package/dist/postgresqlCompat.d.ts +8 -0
  18. package/dist/postgresqlCompat.d.ts.map +1 -0
  19. package/dist/postgresqlCompat.js +19 -0
  20. package/dist/postgresqlCompat.js.map +1 -0
  21. package/dist/postgresqlPoolSettings.d.ts +108 -0
  22. package/dist/postgresqlPoolSettings.d.ts.map +1 -0
  23. package/dist/postgresqlPoolSettings.js +134 -0
  24. package/dist/postgresqlPoolSettings.js.map +1 -0
  25. package/dist/resolvers/AdhocQueryResolver.d.ts +10 -20
  26. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  27. package/dist/resolvers/AdhocQueryResolver.js +54 -174
  28. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  29. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +44 -6
  30. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  31. package/dist/resolvers/RealtimeClientSessionResolver.js +90 -34
  32. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  33. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  34. package/dist/resolvers/RunAIAgentResolver.js +2 -1
  35. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  36. package/dist/resolvers/adhocTimeout.d.ts +7 -0
  37. package/dist/resolvers/adhocTimeout.d.ts.map +1 -0
  38. package/dist/resolvers/adhocTimeout.js +12 -0
  39. package/dist/resolvers/adhocTimeout.js.map +1 -0
  40. package/dist/services/TaskGraphActionRunner.d.ts +13 -8
  41. package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
  42. package/dist/services/TaskGraphActionRunner.js +16 -15
  43. package/dist/services/TaskGraphActionRunner.js.map +1 -1
  44. package/package.json +92 -92
  45. package/src/__tests__/AdhocQueryResolver.bugs.test.ts +12 -35
  46. package/src/__tests__/AdhocQueryResolver.pagination.test.ts +6 -2
  47. package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +3 -3
  48. package/src/__tests__/RealtimeClientSessionResolver.test.ts +216 -2
  49. package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +129 -0
  50. package/src/__tests__/adhocTimeoutClamp.test.ts +26 -0
  51. package/src/__tests__/magicLink.test.ts +15 -76
  52. package/src/__tests__/magicLinkService.consume.test.ts +224 -0
  53. package/src/__tests__/postgresqlPoolSettings.test.ts +131 -0
  54. package/src/__tests__/translateBracketsToPG.test.ts +20 -0
  55. package/src/auth/magicLink/MagicLinkService.ts +55 -36
  56. package/src/auth/magicLink/magicLinkCore.ts +19 -56
  57. package/src/context.ts +11 -24
  58. package/src/index.ts +40 -45
  59. package/src/postgresqlCompat.ts +21 -0
  60. package/src/postgresqlPoolSettings.ts +213 -0
  61. package/src/resolvers/AdhocQueryResolver.ts +64 -194
  62. package/src/resolvers/RealtimeClientSessionResolver.ts +93 -35
  63. package/src/resolvers/RunAIAgentResolver.ts +4 -1
  64. package/src/resolvers/adhocTimeout.ts +12 -0
  65. package/src/services/TaskGraphActionRunner.ts +16 -15
  66. package/dist/resolvers/adhoc-query-helpers.d.ts +0 -27
  67. package/dist/resolvers/adhoc-query-helpers.d.ts.map +0 -1
  68. package/dist/resolvers/adhoc-query-helpers.js +0 -42
  69. package/dist/resolvers/adhoc-query-helpers.js.map +0 -1
  70. package/src/__tests__/adhoc-query-helpers.test.ts +0 -50
  71. package/src/resolvers/adhoc-query-helpers.ts +0 -50
@@ -1,14 +1,13 @@
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';
4
- import { RenderPipeline } from '@memberjunction/generic-database-provider';
2
+ import { LogError } from '@memberjunction/core';
3
+ import type { RunQueryResult } from '@memberjunction/core';
5
4
  import { AppContext } from '../types.js';
6
- import { GetReadOnlyDataSource, GetReadOnlyProvider } from '../util.js';
5
+ import { configInfo } from '../config.js';
6
+ import { GetReadOnlyProvider } from '../util.js';
7
7
  import { ResolverBase } from '../generic/ResolverBase.js';
8
8
  import { IsScopeLimitedPrincipal } from '../auth/scopeLimitedPrincipal.js';
9
9
  import { RunQueryResultType } from './QueryResolver.js';
10
- import { exactTotalFromPage, resolveAdhocTotalRowCount } from './adhoc-query-helpers.js';
11
- import sql from 'mssql';
10
+ import { ClampAdhocTimeoutSeconds } from './adhocTimeout.js';
12
11
 
13
12
  /**
14
13
  * Input type for executing ad-hoc SQL queries directly.
@@ -32,10 +31,15 @@ class AdhocQueryInput {
32
31
  /**
33
32
  * Resolver for executing ad-hoc (unsaved) SQL queries.
34
33
  *
34
+ * The query runs through the read-only provider's own ad-hoc path (`RunQuery` with `SQL`), the
35
+ * same one in-process callers use, so it behaves identically on every database platform: the SQL
36
+ * is validated and rendered there, paged in the database with a count, run under the caller's
37
+ * timeout, and on PostgreSQL inside a read-only transaction that is rolled back.
38
+ *
35
39
  * 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)
40
+ * - SQL validated by the provider (SQLExpressionValidator, single read query) — blocks mutations
41
+ * - Read-only provider only (no fallback to read-write)
42
+ * - Configurable timeout (default 30s), enforced by the database
39
43
  * - Requires authenticated user (standard GraphQL auth, no @RequireSystemUser)
40
44
  * - Refuses scope-limited principals (see {@link IsScopeLimitedPrincipal}). Raw SQL bypasses
41
45
  * RunView, entity permissions and row-level security, so the RLS scope tokens that confine a
@@ -52,201 +56,67 @@ export class AdhocQueryResolver extends ResolverBase {
52
56
  @Ctx() context: AppContext
53
57
  ): Promise<RunQueryResultType> {
54
58
  const startTime = Date.now();
55
-
59
+ // SECURITY: refuse principals whose read authority is narrower than their role grant.
60
+ // Ad-hoc SQL does not go through RunView, entity permissions or row-level security, so the
61
+ // RLS scope tokens that confine a magic-link session are never substituted. There is no
62
+ // filter to narrow, so the only correct answer for such a principal is no.
63
+ if (IsScopeLimitedPrincipal(context.userPayload?.userRecord)) {
64
+ return this.buildErrorResult('Ad-hoc SQL execution is not permitted for scope-limited sessions.');
65
+ }
66
+ const provider = GetReadOnlyProvider(context.providers, { allowFallbackToReadWrite: false });
67
+ if (!provider) {
68
+ return this.buildErrorResult('No read-only data source available for ad-hoc query execution');
69
+ }
70
+ const contextUser = context.userPayload?.userRecord;
71
+ const timeoutSeconds = ClampAdhocTimeoutSeconds(input.TimeoutSeconds, configInfo.databaseSettings.requestTimeout);
72
+ // A negative offset must not reach paging, where it would turn paging off.
73
+ const startRow = Math.max(0, Number.isInteger(input.StartRow) ? input.StartRow! : 0);
56
74
  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
- };
75
+ const result = await provider.RunQuery({
76
+ SQL: input.SQL,
77
+ MaxRows: input.MaxRows,
78
+ StartRow: startRow,
79
+ TimeoutSeconds: timeoutSeconds,
80
+ }, contextUser);
81
+ return this.toResultType(result, startRow, input.MaxRows, timeoutSeconds, Date.now() - startTime);
145
82
  } catch (err: unknown) {
146
- const executionTimeMs = Date.now() - startTime;
147
83
  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
84
  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
- };
85
+ return this.buildErrorResult(`Query execution failed: ${errorMessage}`, Date.now() - startTime);
178
86
  }
179
87
  }
180
88
 
181
- /**
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.
190
- */
191
- private async executeDataAndCount(
192
- ds: sql.ConnectionPool,
193
- dataSQL: string,
194
- countSQL: string | null,
89
+ /** Maps the provider's result to the GraphQL shape, naming a timeout plainly. */
90
+ private toResultType(
91
+ result: RunQueryResult,
195
92
  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;
210
- 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 };
217
- }
218
- }
219
-
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
- }
93
+ maxRows: number | undefined,
94
+ timeoutSeconds: number,
95
+ executionTimeMs: number,
96
+ ): RunQueryResultType {
97
+ if (!result.Success) {
98
+ const message = result.ErrorMessage ?? 'Ad-hoc query failed';
99
+ return this.buildErrorResult(
100
+ /timeout/i.test(message) ? `Query execution exceeded ${timeoutSeconds} second timeout` : message,
101
+ executionTimeMs,
102
+ );
246
103
  }
104
+ const paged = maxRows != null && Number.isInteger(maxRows) && maxRows > 0;
105
+ return {
106
+ QueryID: '',
107
+ QueryName: 'Ad-Hoc Query',
108
+ Success: true,
109
+ Results: JSON.stringify(result.Results),
110
+ RowCount: result.RowCount,
111
+ TotalRowCount: result.TotalRowCount,
112
+ PageNumber: paged ? Math.floor(startRow / maxRows!) + 1 : undefined,
113
+ PageSize: paged ? maxRows : undefined,
114
+ ExecutionTime: executionTimeMs,
115
+ ErrorMessage: ''
116
+ };
247
117
  }
248
118
 
249
- private buildErrorResult(errorMessage: string): RunQueryResultType {
119
+ private buildErrorResult(errorMessage: string, executionTimeMs = 0): RunQueryResultType {
250
120
  return {
251
121
  QueryID: '',
252
122
  QueryName: 'Ad-Hoc Query',
@@ -256,7 +126,7 @@ export class AdhocQueryResolver extends ResolverBase {
256
126
  TotalRowCount: 0,
257
127
  PageNumber: undefined,
258
128
  PageSize: undefined,
259
- ExecutionTime: 0,
129
+ ExecutionTime: executionTimeMs,
260
130
  ErrorMessage: errorMessage
261
131
  };
262
132
  }
@@ -26,7 +26,7 @@
26
26
  */
27
27
  import { Resolver, Mutation, Arg, Ctx, Int, Float, ObjectType, Field, PubSub, PubSubEngine } from 'type-graphql';
28
28
  import { AppContext, UserPayload } from '../types.js';
29
- import { AuthorizationEvaluator, UserInfo, IMetadataProvider, LogError, LogStatus, RunView } from '@memberjunction/core';
29
+ import { AuthorizationEvaluator, UserInfo, IMetadataProvider, LogError, LogStatus, RunView, BaseEntity } from '@memberjunction/core';
30
30
  import { UUIDsEqual, IsValidUUID } from '@memberjunction/global';
31
31
  import {
32
32
  MJAIAgentEntity,
@@ -376,6 +376,31 @@ export class RealtimeClientSessionResolver extends ResolverBase {
376
376
  private readonly clientSessionService = new RealtimeClientSessionService();
377
377
  private readonly sessionManager = new SessionManager(this.clientSessionService);
378
378
 
379
+ /**
380
+ * The reason a `Save()`/`Delete()` returned false, for a log line. `CompleteMessage` renders
381
+ * `Message`, `Error` and every `Errors[]` entry (validation errors included, since `Save()`
382
+ * runs `Validate()` and records its errors there), so there is no separate `Validate()` call.
383
+ * When a subclass refused the write without recording anything, say so explicitly instead of
384
+ * "unknown error", which is what hid MJ#4791.
385
+ *
386
+ * Only a NULL `LatestResult` proves the refusal happened before the provider (nothing was
387
+ * registered at all). A SUCCESS `LatestResult` is an earlier save on the same instance — the
388
+ * refusal registered nothing — so its text must never be reported as this failure's reason.
389
+ */
390
+ private describeSaveFailure(entity: BaseEntity): string {
391
+ const latest = entity.LatestResult;
392
+ if (!latest) {
393
+ return `no failure detail recorded (LatestResult null; ResultHistory length ${entity.ResultHistory.length}) ` +
394
+ '— the write was refused before reaching the provider';
395
+ }
396
+ if (latest.Success) {
397
+ return 'no failure detail recorded (latest result entry is a prior success; ' +
398
+ `ResultHistory length ${entity.ResultHistory.length})`;
399
+ }
400
+ const detail = latest.CompleteMessage?.trim();
401
+ return detail || `no failure detail recorded (LatestResult empty; ResultHistory length ${entity.ResultHistory.length})`;
402
+ }
403
+
379
404
  /**
380
405
  * Start a client-direct realtime voice session targeting `targetAgentId`.
381
406
  *
@@ -581,7 +606,9 @@ export class RealtimeClientSessionResolver extends ResolverBase {
581
606
  // Junction-link any artifacts the delegated run produced into the session's conversation
582
607
  // history (best-effort) — so chat, session review, and resume carryover can all see them.
583
608
  // Runs as `runUser`: the junction entity is not among an anonymous caller's relay grants.
584
- await this.linkDelegatedArtifactsToConversation(session, Artifacts, runUser, provider);
609
+ // The hidden anchor detail it may need to create, though, is written by the CALLER (#4791)
610
+ // — same reason `persistDirectActionTurn` is: the System user is not the conversation owner.
611
+ await this.linkDelegatedArtifactsToConversation(session, Artifacts, runUser, contextUser, provider);
585
612
 
586
613
  await this.sessionManager.Heartbeat(agentSessionId, contextUser, provider);
587
614
  return ResultJson;
@@ -645,7 +672,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
645
672
  session.Config_ = JSON.stringify(next);
646
673
  if (!(await session.Save())) {
647
674
  LogError(
648
- `RealtimeClientSessionResolver.updatePendingFeedbackRunID save failed: ${session.LatestResult?.CompleteMessage ?? 'unknown error'}`,
675
+ `RealtimeClientSessionResolver.updatePendingFeedbackRunID save failed for session ${session.ID}: ${this.describeSaveFailure(session)}`,
649
676
  );
650
677
  }
651
678
  }
@@ -1797,7 +1824,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1797
1824
  const saved = await session.Save();
1798
1825
  if (!saved) {
1799
1826
  LogError(
1800
- `RealtimeClientSessionResolver.persistObservabilityRunIDs save failed: ${session.LatestResult?.CompleteMessage ?? 'unknown error'}`,
1827
+ `RealtimeClientSessionResolver.persistObservabilityRunIDs save failed for session ${session.ID}: ${this.describeSaveFailure(session)}`,
1801
1828
  );
1802
1829
  }
1803
1830
  }
@@ -1915,7 +1942,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1915
1942
  if (!saved) {
1916
1943
  LogError(
1917
1944
  `SaveSessionChannelState: save failed for session ${agentSessionID} / channel ${channelID}: ` +
1918
- `${row.LatestResult?.CompleteMessage ?? 'unknown error'}`,
1945
+ `${this.describeSaveFailure(row)}`,
1919
1946
  );
1920
1947
  }
1921
1948
  return saved;
@@ -2200,7 +2227,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2200
2227
  artifact.UserID = contextUser.ID;
2201
2228
  artifact.Visibility = 'Always';
2202
2229
  if (!(await artifact.Save())) {
2203
- const message = `SaveSessionChannelArtifact: artifact save failed: ${artifact.LatestResult?.CompleteMessage ?? 'unknown error'}`;
2230
+ const message = `SaveSessionChannelArtifact: artifact save failed for session ${session.ID}: ${this.describeSaveFailure(artifact)}`;
2204
2231
  LogError(message);
2205
2232
  return { Success: false, ErrorMessage: message, ConversationDetailLinked: false };
2206
2233
  }
@@ -2212,7 +2239,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2212
2239
  version.Content = contentJson;
2213
2240
  version.UserID = contextUser.ID;
2214
2241
  if (!(await version.Save())) {
2215
- const message = `SaveSessionChannelArtifact: artifact version save failed: ${version.LatestResult?.CompleteMessage ?? 'unknown error'}`;
2242
+ const message = `SaveSessionChannelArtifact: artifact version save failed for session ${session.ID} (artifact ${artifact.ID}): ${this.describeSaveFailure(version)}`;
2216
2243
  LogError(message);
2217
2244
  return { Success: false, ErrorMessage: message, ArtifactID: artifact.ID, ConversationDetailLinked: false };
2218
2245
  }
@@ -2270,13 +2297,22 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2270
2297
  * queries (which scan junctions across ALL details) still surface the artifact. This is the
2271
2298
  * least-invasive correct anchor — no fake visible message, no orphaned artifact.
2272
2299
  *
2300
+ * TWO PRINCIPALS, deliberately: the lookup (`findLatestSessionDetailID`) and the junction row
2301
+ * itself (`saveDetailArtifactJunction`, `MJ: Conversation Detail Artifacts`) run as `runUser`
2302
+ * (elevated for a scoped-anonymous caller) — that junction entity is outside an anonymous
2303
+ * caller's relay grants. The hidden ANCHOR `Conversation Detail` this may need to create runs as
2304
+ * `callerUser` instead: like every other `MJ: Conversation Details` write, the System user is
2305
+ * refused because it is not the conversation's owner (see {@link persistDirectActionTurn},
2306
+ * issue #4791) — only the actual caller can create it.
2307
+ *
2273
2308
  * Strictly best-effort: every failure path logs and returns; a relayed tool call NEVER fails
2274
2309
  * because history linking did.
2275
2310
  */
2276
2311
  private async linkDelegatedArtifactsToConversation(
2277
2312
  session: MJAIAgentSessionEntity,
2278
2313
  artifacts: DelegatedRunArtifact[] | undefined,
2279
- contextUser: UserInfo,
2314
+ runUser: UserInfo,
2315
+ callerUser: UserInfo,
2280
2316
  provider: IMetadataProvider,
2281
2317
  ): Promise<void> {
2282
2318
  if (!artifacts || artifacts.length === 0 || !session.ConversationID) {
@@ -2284,13 +2320,13 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2284
2320
  }
2285
2321
  try {
2286
2322
  const detailID =
2287
- (await this.findLatestSessionDetailID(session, contextUser, provider)) ??
2288
- (await this.createHiddenSessionAnchorDetail(session, contextUser, provider));
2323
+ (await this.findLatestSessionDetailID(session, runUser, provider)) ??
2324
+ (await this.createHiddenSessionAnchorDetail(session, callerUser, provider));
2289
2325
  if (!detailID) {
2290
2326
  return; // anchor unavailable — logged in the helpers
2291
2327
  }
2292
2328
  for (const artifact of artifacts) {
2293
- await this.saveDetailArtifactJunction(detailID, artifact.ArtifactVersionID, contextUser, provider);
2329
+ await this.saveDetailArtifactJunction(detailID, artifact.ArtifactVersionID, runUser, provider);
2294
2330
  }
2295
2331
  } catch (error) {
2296
2332
  LogError(`ExecuteRealtimeSessionTool: delegated-artifact history link failed: ${(error as Error).message}`);
@@ -2331,25 +2367,25 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2331
2367
  */
2332
2368
  private async createHiddenSessionAnchorDetail(
2333
2369
  session: MJAIAgentSessionEntity,
2334
- contextUser: UserInfo,
2370
+ callerUser: UserInfo,
2335
2371
  provider: IMetadataProvider,
2336
2372
  ): Promise<string | null> {
2337
- const detail = await provider.GetEntityObject<MJConversationDetailEntity>(CONVERSATION_DETAIL_ENTITY, contextUser);
2373
+ const detail = await provider.GetEntityObject<MJConversationDetailEntity>(CONVERSATION_DETAIL_ENTITY, callerUser);
2338
2374
  detail.NewRecord();
2339
2375
  detail.ConversationID = session.ConversationID;
2340
2376
  detail.Role = 'AI';
2341
2377
  detail.HiddenToUser = true;
2342
2378
  detail.Message = 'Artifacts produced during a realtime session (system anchor).';
2343
2379
  detail.AgentSessionID = session.ID;
2344
- // Attribute the anchor to the SESSION owner, not the (possibly elevated) writer — identical
2345
- // for every non-elevated caller, whose ownership of the session is already proven.
2380
+ // Attribute to the SESSION owner — the caller writing this row, whose ownership
2381
+ // `loadOwnedActiveSession` already proved.
2346
2382
  detail.UserID = session.UserID;
2347
2383
  if (await detail.Save()) {
2348
2384
  return detail.ID;
2349
2385
  }
2350
2386
  LogError(
2351
2387
  `ExecuteRealtimeSessionTool: hidden anchor detail save failed for session ${session.ID}: ` +
2352
- `${detail.LatestResult?.CompleteMessage ?? 'unknown error'}`,
2388
+ `${this.describeSaveFailure(detail)}`,
2353
2389
  );
2354
2390
  return null;
2355
2391
  }
@@ -2376,7 +2412,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2376
2412
  if (!saved) {
2377
2413
  LogError(
2378
2414
  `RealtimeClientSessionResolver: artifact junction save failed for detail ${conversationDetailID} / version ${artifactVersionID}: ` +
2379
- `${junction.LatestResult?.CompleteMessage ?? 'unknown error'}`,
2415
+ `${this.describeSaveFailure(junction)}`,
2380
2416
  );
2381
2417
  }
2382
2418
  return saved;
@@ -2416,7 +2452,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2416
2452
  if (!(await row.Save())) {
2417
2453
  LogError(
2418
2454
  `SaveSessionChannelArtifact: LastActiveAt stamp failed for session ${agentSessionID} / channel ${channelID}: ` +
2419
- `${row.LatestResult?.CompleteMessage ?? 'unknown error'}`,
2455
+ `${this.describeSaveFailure(row)}`,
2420
2456
  );
2421
2457
  }
2422
2458
  } catch (error) {
@@ -2560,12 +2596,6 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2560
2596
  return minutes && minutes > 0 ? minutes * 60 : undefined;
2561
2597
  }
2562
2598
 
2563
- /**
2564
- * Persists a single transcript turn as a `Conversation Detail` stamped with the session's
2565
- * conversation, the mapped role, the turn text, the session id, and the owning user.
2566
- *
2567
- * @returns The boolean save result (logs `CompleteMessage` on failure).
2568
- */
2569
2599
  /**
2570
2600
  * Stamps `RecordingStartedAt` (the recording `t0` alignment origin) + `RecordingMedia` on a
2571
2601
  * just-started session when the browser captured WITH consent. Best-effort: a parse/save failure
@@ -2592,11 +2622,18 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2592
2622
  session.RecordingMedia = 'Audio';
2593
2623
  if (!(await session.Save())) {
2594
2624
  LogError(
2595
- `RealtimeClientSessionResolver.stampRecordingStart save failed: ${session.LatestResult?.CompleteMessage ?? 'unknown error'}`,
2625
+ `RealtimeClientSessionResolver.stampRecordingStart save failed for session ${session.ID}: ${this.describeSaveFailure(session)}`,
2596
2626
  );
2597
2627
  }
2598
2628
  }
2599
2629
 
2630
+ /**
2631
+ * Persists a single transcript turn as a `Conversation Detail` stamped with the session's
2632
+ * conversation, the mapped role, the turn text, the session id, and the owning user.
2633
+ *
2634
+ * @returns The boolean save result. On failure it logs the session, the write user and the
2635
+ * reason from {@link describeSaveFailure}.
2636
+ */
2600
2637
  private async persistTranscriptTurn(
2601
2638
  session: MJAIAgentSessionEntity,
2602
2639
  role: string,
@@ -2626,7 +2663,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2626
2663
  const saved = await detail.Save();
2627
2664
  if (!saved) {
2628
2665
  LogError(
2629
- `RealtimeClientSessionResolver.persistTranscriptTurn save failed: ${detail.LatestResult?.CompleteMessage ?? 'unknown error'}`,
2666
+ `RealtimeClientSessionResolver.persistTranscriptTurn save failed for session ${session.ID} (write user ${contextUser.ID}): ${this.describeSaveFailure(detail)}`,
2630
2667
  );
2631
2668
  }
2632
2669
  return saved;
@@ -2667,8 +2704,13 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2667
2704
  // ⚠️ ELEVATING INSTEAD WAS TRIED AND IS A TRAP. `ResolveScopedAnonymousRunUser` returns
2668
2705
  // `UserCache.GetSystemUser()`, which on this deployment resolves to the unconfigured
2669
2706
  // placeholder `not.set@nowhere.com` ("Configured provisioning user not found; falling back
2670
- // to an Owner"). Saving as a user that does not exist returns false with a NULL
2671
- // `LatestResult` — indistinguishable from a permission denial, and just as silent.
2707
+ // to an Owner"). That user EXISTS — it is not a missing/invalid principal — and even carries
2708
+ // full CRUD on `MJ: Conversation Details`. It is refused anyway:
2709
+ // `MJConversationDetailEntityExtended`'s owner gate accepts only the conversation's OWNER or
2710
+ // an Edit/Owner Resource Permission grantee, and the System user is neither. Measured on
2711
+ // MJ#4791 (before the core-entities fix that now records the denial): `Save()` → `false`,
2712
+ // `LatestResult` null — indistinguishable from a permission denial, and just as silent. The
2713
+ // denial itself still happens; that fix only made it stop being silent.
2672
2714
  const writeUser = contextUser;
2673
2715
  const rv = RunView.FromMetadataProvider(provider);
2674
2716
  const result = await rv.RunView<MJConversationDetailEntity>(
@@ -2689,8 +2731,8 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2689
2731
  //
2690
2732
  // `ResultType: 'entity_object'` hands back hydrated entities, but they do not carry the
2691
2733
  // context user the way `GetEntityObject(entity, user)` does — so `Save()` ran with no
2692
- // principal and failed with an EMPTY message ("unknown error"), which is what the elevation
2693
- // fix above looked like when it was still broken. The insert path beside this one has
2734
+ // principal and failed with an EMPTY message ("unknown error"), which is what an empty-message
2735
+ // failure here looked like before the re-load. The insert path beside this one has
2694
2736
  // always used `GetEntityObject`; this now matches it, which is also why they now succeed
2695
2737
  // and fail for the same reasons.
2696
2738
  const previous = await provider.GetEntityObject<MJConversationDetailEntity>(
@@ -2722,7 +2764,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2722
2764
  `RealtimeClientSessionResolver.replacePreviousTranscriptTurn save failed for session `
2723
2765
  + `${session.ID} — the ${mappedRole} turn keeps its PREVIOUS, shorter text and the `
2724
2766
  + `transcript now understates what was said: `
2725
- + `${previous.LatestResult?.CompleteMessage || JSON.stringify(previous.LatestResult ?? null)}`,
2767
+ + `${this.describeSaveFailure(previous)}`,
2726
2768
  );
2727
2769
  }
2728
2770
  return saved;
@@ -2742,6 +2784,23 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2742
2784
  * Stored as stable facts (Role: 'AI', HiddenToUser: true, ExternalID: callId, CompletionTime: durationMs,
2743
2785
  * Message: JSON-serialized tool facts) so that session review can reconstruct the action card dynamically
2744
2786
  * on load (Option 1) without polluting user-facing speech chat bubbles.
2787
+ *
2788
+ * Writes as the CALLER, never the elevated run user (issue #4791). Owning the session
2789
+ * (`loadOwnedActiveSession`'s gate has already run on every path here) does NOT imply owning
2790
+ * the conversation: the conversation id was client-supplied at session start. What authorizes
2791
+ * this write is the save itself, evaluated for the caller — the conversation owner gate in
2792
+ * `MJConversationDetailEntityExtended`, plus the caller's own entity permission and Create RLS
2793
+ * on `MJ: Conversation Details` (for the magic-link role that RLS scopes by the invite's
2794
+ * `ScopeResourceID`, and the role belongs to a downstream app, not MJ core). That is why
2795
+ * writing as the caller is safe even though the conversation id originated client-side.
2796
+ * Elevating to the System user would bypass RLS entirely, and the owner gate refuses it anyway
2797
+ * because the System user is not the conversation owner. Measured on MJ#4791 (before the
2798
+ * core-entities fix that now records the denial): `Save()` returned `false` with a NULL `LatestResult` and an empty
2799
+ * `ResultHistory` — indistinguishable from any other silent failure. Elevating this write is
2800
+ * what silently dropped both hidden tool-execution turns of a real magic-link session; the
2801
+ * denial itself still happens today, only its silence was fixed separately.
2802
+ * `ResolveScopedAnonymousRunUser` elevates AI-**run**-entity writes only (prompt runs, agent
2803
+ * runs, the relayed dispatch itself) — never this one.
2745
2804
  */
2746
2805
  private async persistDirectActionTurn(
2747
2806
  session: MJAIAgentSessionEntity,
@@ -2757,10 +2816,9 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2757
2816
  return false;
2758
2817
  }
2759
2818
  try {
2760
- const writeUser = ResolveScopedAnonymousRunUser(contextUser);
2761
2819
  const detail = await provider.GetEntityObject<MJConversationDetailEntity>(
2762
2820
  CONVERSATION_DETAIL_ENTITY,
2763
- writeUser,
2821
+ contextUser,
2764
2822
  );
2765
2823
  detail.NewRecord();
2766
2824
  detail.ConversationID = session.ConversationID;
@@ -2802,13 +2860,13 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2802
2860
  const saved = await detail.Save();
2803
2861
  if (!saved) {
2804
2862
  LogError(
2805
- `RealtimeClientSessionResolver.persistDirectActionTurn save failed: ${detail.LatestResult?.CompleteMessage ?? 'unknown error'}`,
2863
+ `RealtimeClientSessionResolver.persistDirectActionTurn save failed for session ${session.ID} (tool '${toolName}', call ${callId ?? 'n/a'}, write user ${contextUser.ID}): ${this.describeSaveFailure(detail)}`,
2806
2864
  );
2807
2865
  }
2808
2866
  return saved;
2809
2867
  } catch (error) {
2810
2868
  const message = error instanceof Error ? error.message : String(error);
2811
- LogError(`RealtimeClientSessionResolver.persistDirectActionTurn unexpected error: ${message}`);
2869
+ LogError(`RealtimeClientSessionResolver.persistDirectActionTurn unexpected error for session ${session.ID}: ${message}`);
2812
2870
  return false;
2813
2871
  }
2814
2872
  }
@@ -598,7 +598,10 @@ export class RunAIAgentResolver extends ResolverBase {
598
598
  detail.Message = errorMessage;
599
599
  detail.Error = errorMessage;
600
600
  if (!(await detail.Save())) {
601
- LogError(`Failed to persist Error on conversation detail ${conversationDetailId}`);
601
+ LogError(
602
+ `Failed to persist Error on conversation detail ${conversationDetailId}: ` +
603
+ `${detail.LatestResult?.CompleteMessage?.trim() || 'no failure detail recorded'}`,
604
+ );
602
605
  }
603
606
  }
604
607
  }
@@ -0,0 +1,12 @@
1
+ /** The timeout an ad-hoc query gets when the caller names none. */
2
+ const DEFAULT_TIMEOUT_SECONDS = 30;
3
+
4
+ /**
5
+ * The timeout an ad-hoc query runs with: what the caller asked for (or the default), but never
6
+ * longer than the server's `requestTimeout`, so a caller cannot hold a read-only connection longer
7
+ * than any other request may. `requestTimeoutMs` of 0 or less means the server sets no limit.
8
+ */
9
+ export function ClampAdhocTimeoutSeconds(requested: number | undefined, requestTimeoutMs: number): number {
10
+ const seconds = requested != null && requested > 0 ? requested : DEFAULT_TIMEOUT_SECONDS;
11
+ return requestTimeoutMs > 0 ? Math.min(seconds, Math.max(1, Math.floor(requestTimeoutMs / 1000))) : seconds;
12
+ }