@memberjunction/server 5.48.0 → 5.50.0

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 (127) hide show
  1. package/README.md +9 -0
  2. package/dist/auth/magicLink/MagicLinkKeys.d.ts.map +1 -1
  3. package/dist/auth/magicLink/MagicLinkKeys.js +11 -4
  4. package/dist/auth/magicLink/MagicLinkKeys.js.map +1 -1
  5. package/dist/auth/magicLink/MagicLinkRouter.js +2 -2
  6. package/dist/auth/magicLink/MagicLinkRouter.js.map +1 -1
  7. package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
  8. package/dist/auth/magicLink/MagicLinkService.js +4 -0
  9. package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
  10. package/dist/auth/newUsers.d.ts.map +1 -1
  11. package/dist/auth/newUsers.js +6 -4
  12. package/dist/auth/newUsers.js.map +1 -1
  13. package/dist/config.d.ts +40 -0
  14. package/dist/config.d.ts.map +1 -1
  15. package/dist/config.js +12 -0
  16. package/dist/config.js.map +1 -1
  17. package/dist/context.d.ts.map +1 -1
  18. package/dist/context.js +28 -2
  19. package/dist/context.js.map +1 -1
  20. package/dist/generated/generated.d.ts +373 -6
  21. package/dist/generated/generated.d.ts.map +1 -1
  22. package/dist/generated/generated.js +1975 -39
  23. package/dist/generated/generated.js.map +1 -1
  24. package/dist/generic/ResolverBase.d.ts.map +1 -1
  25. package/dist/generic/ResolverBase.js +4 -2
  26. package/dist/generic/ResolverBase.js.map +1 -1
  27. package/dist/generic/RunViewResolver.d.ts +1 -0
  28. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  29. package/dist/generic/RunViewResolver.js +12 -0
  30. package/dist/generic/RunViewResolver.js.map +1 -1
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +73 -11
  33. package/dist/index.js.map +1 -1
  34. package/dist/integration/CustomColumnPromoter.d.ts +15 -2
  35. package/dist/integration/CustomColumnPromoter.d.ts.map +1 -1
  36. package/dist/integration/CustomColumnPromoter.js +113 -11
  37. package/dist/integration/CustomColumnPromoter.js.map +1 -1
  38. package/dist/integration/EntityMapLifecycle.d.ts +66 -0
  39. package/dist/integration/EntityMapLifecycle.d.ts.map +1 -0
  40. package/dist/integration/EntityMapLifecycle.js +189 -0
  41. package/dist/integration/EntityMapLifecycle.js.map +1 -0
  42. package/dist/realtimeProxy/RealtimeProxyServer.d.ts +10 -0
  43. package/dist/realtimeProxy/RealtimeProxyServer.d.ts.map +1 -1
  44. package/dist/realtimeProxy/RealtimeProxyServer.js +67 -1
  45. package/dist/realtimeProxy/RealtimeProxyServer.js.map +1 -1
  46. package/dist/realtimeWidget/WidgetRouter.d.ts.map +1 -1
  47. package/dist/realtimeWidget/WidgetRouter.js +6 -2
  48. package/dist/realtimeWidget/WidgetRouter.js.map +1 -1
  49. package/dist/realtimeWidget/WidgetSessionService.d.ts +12 -1
  50. package/dist/realtimeWidget/WidgetSessionService.d.ts.map +1 -1
  51. package/dist/realtimeWidget/WidgetSessionService.js +23 -8
  52. package/dist/realtimeWidget/WidgetSessionService.js.map +1 -1
  53. package/dist/resolvers/AdhocQueryResolver.d.ts +17 -0
  54. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  55. package/dist/resolvers/AdhocQueryResolver.js +81 -25
  56. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  57. package/dist/resolvers/GetDataResolver.d.ts +16 -0
  58. package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
  59. package/dist/resolvers/GetDataResolver.js +26 -1
  60. package/dist/resolvers/GetDataResolver.js.map +1 -1
  61. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +53 -1
  62. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  63. package/dist/resolvers/IntegrationDiscoveryResolver.js +531 -64
  64. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  65. package/dist/resolvers/QueryResolver.d.ts.map +1 -1
  66. package/dist/resolvers/QueryResolver.js +0 -6
  67. package/dist/resolvers/QueryResolver.js.map +1 -1
  68. package/dist/resolvers/RSUResolver.d.ts +10 -0
  69. package/dist/resolvers/RSUResolver.d.ts.map +1 -1
  70. package/dist/resolvers/RSUResolver.js +28 -0
  71. package/dist/resolvers/RSUResolver.js.map +1 -1
  72. package/dist/resolvers/RunAIAgentResolver.d.ts +9 -6
  73. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  74. package/dist/resolvers/RunAIAgentResolver.js +135 -132
  75. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  76. package/dist/resolvers/TransactionGroupResolver.d.ts +8 -1
  77. package/dist/resolvers/TransactionGroupResolver.d.ts.map +1 -1
  78. package/dist/resolvers/TransactionGroupResolver.js +29 -1
  79. package/dist/resolvers/TransactionGroupResolver.js.map +1 -1
  80. package/dist/resolvers/adhoc-query-helpers.d.ts +27 -0
  81. package/dist/resolvers/adhoc-query-helpers.d.ts.map +1 -0
  82. package/dist/resolvers/adhoc-query-helpers.js +42 -0
  83. package/dist/resolvers/adhoc-query-helpers.js.map +1 -0
  84. package/dist/rest/OAuthCallbackHandler.d.ts +9 -0
  85. package/dist/rest/OAuthCallbackHandler.d.ts.map +1 -1
  86. package/dist/rest/OAuthCallbackHandler.js +21 -4
  87. package/dist/rest/OAuthCallbackHandler.js.map +1 -1
  88. package/dist/telephony/media-upgrade-router.d.ts +14 -0
  89. package/dist/telephony/media-upgrade-router.d.ts.map +1 -1
  90. package/dist/telephony/media-upgrade-router.js +17 -1
  91. package/dist/telephony/media-upgrade-router.js.map +1 -1
  92. package/package.json +91 -89
  93. package/src/__tests__/AdhocQueryResolver.pagination.test.ts +114 -0
  94. package/src/__tests__/EntityMapLifecycle.dagWarnings.test.ts +91 -0
  95. package/src/__tests__/OAuthCallbackHandler.xss.test.ts +83 -0
  96. package/src/__tests__/RealtimeProxyServer.test.ts +53 -1
  97. package/src/__tests__/WidgetSessionService.test.ts +96 -0
  98. package/src/__tests__/adhoc-query-helpers.test.ts +50 -0
  99. package/src/__tests__/magicLinkService.provisionLog.test.ts +106 -0
  100. package/src/__tests__/media-upgrade-router.test.ts +43 -0
  101. package/src/__tests__/unifiedAuth.test.ts +18 -3
  102. package/src/auth/magicLink/MagicLinkKeys.ts +12 -6
  103. package/src/auth/magicLink/MagicLinkRouter.ts +2 -2
  104. package/src/auth/magicLink/MagicLinkService.ts +4 -0
  105. package/src/auth/newUsers.ts +6 -5
  106. package/src/config.ts +14 -0
  107. package/src/context.ts +28 -2
  108. package/src/generated/generated.ts +1385 -28
  109. package/src/generic/ResolverBase.ts +5 -2
  110. package/src/generic/RunViewResolver.ts +11 -0
  111. package/src/index.ts +75 -11
  112. package/src/integration/CustomColumnPromoter.ts +123 -6
  113. package/src/integration/EntityMapLifecycle.ts +221 -0
  114. package/src/realtimeProxy/RealtimeProxyServer.ts +70 -1
  115. package/src/realtimeWidget/WidgetRouter.ts +6 -2
  116. package/src/realtimeWidget/WidgetSessionService.ts +24 -8
  117. package/src/resolvers/AdhocQueryResolver.ts +97 -30
  118. package/src/resolvers/GetDataResolver.ts +30 -1
  119. package/src/resolvers/IntegrationDiscoveryResolver.ts +523 -64
  120. package/src/resolvers/QueryResolver.ts +1 -7
  121. package/src/resolvers/RSUResolver.ts +28 -0
  122. package/src/resolvers/RunAIAgentResolver.ts +74 -70
  123. package/src/resolvers/TransactionGroupResolver.ts +36 -2
  124. package/src/resolvers/__tests__/GetDataResolver.tokens.test.ts +138 -0
  125. package/src/resolvers/adhoc-query-helpers.ts +50 -0
  126. package/src/rest/OAuthCallbackHandler.ts +22 -4
  127. package/src/telephony/media-upgrade-router.ts +18 -1
@@ -52,6 +52,13 @@ export class RealtimeProxyServer extends BaseSingleton<RealtimeProxyServer> impl
52
52
  return false; // not ours — leave it for the GraphQL WS server
53
53
  }
54
54
  this.ensureRegistered();
55
+ // Optional Origin allowlist (MJ_REALTIME_PROXY_ALLOWED_ORIGINS: comma-separated origins).
56
+ // Unset ⇒ prior behavior (the single-use short-TTL ticket remains the primary guard);
57
+ // set ⇒ a browser page on a foreign origin cannot ride a leaked ticket id.
58
+ if (!RealtimeProxyServer.originAllowed(request.headers?.origin)) {
59
+ RealtimeProxyServer.rejectUpgrade(socket, 403, 'Forbidden');
60
+ return true;
61
+ }
55
62
  const ticketId = url.searchParams.get('ticket') ?? '';
56
63
  const entry = RealtimeProxyRegistry.Instance.Consume(ticketId);
57
64
  if (!entry) {
@@ -62,6 +69,28 @@ export class RealtimeProxyServer extends BaseSingleton<RealtimeProxyServer> impl
62
69
  return true;
63
70
  }
64
71
 
72
+ /**
73
+ * Evaluates the optional Origin allowlist. Comparison is exact on the normalized origin
74
+ * (scheme://host[:port], lowercased, no trailing slash). An upgrade WITHOUT an Origin header
75
+ * (non-browser client, e.g. a native app or test rig) passes — the allowlist targets
76
+ * cross-origin BROWSER pages, which always send the header.
77
+ *
78
+ * @param origin The upgrade request's Origin header, if any.
79
+ * @returns True when allowed (or no allowlist is configured).
80
+ */
81
+ private static originAllowed(origin: string | undefined): boolean {
82
+ const raw = process.env['MJ_REALTIME_PROXY_ALLOWED_ORIGINS'];
83
+ if (!raw || raw.trim().length === 0) {
84
+ return true; // no allowlist configured — prior behavior
85
+ }
86
+ if (!origin) {
87
+ return true; // non-browser upgrade (no Origin header) — tickets remain the guard
88
+ }
89
+ const normalize = (value: string): string => value.trim().toLowerCase().replace(/\/+$/, '');
90
+ const allowed = raw.split(',').map(normalize).filter((v) => v.length > 0);
91
+ return allowed.includes(normalize(origin));
92
+ }
93
+
65
94
  /** Opens the upstream leg and wires a bidirectional pump between the browser socket and it. */
66
95
  private openTunnel(browserWs: WsClient, entry: RealtimeProxyTicketEntry): void {
67
96
  const tunnel = new RealtimeProxyTunnel(browserWs, entry, () => this.tunnels.delete(tunnel));
@@ -118,9 +147,21 @@ export class RealtimeProxyServer extends BaseSingleton<RealtimeProxyServer> impl
118
147
  * then pumps both directions byte-transparently (text and binary alike). Closing either side closes the other.
119
148
  */
120
149
  class RealtimeProxyTunnel {
150
+ /** How long the upstream websocket may take to OPEN before the tunnel is torn down (ms). */
151
+ private static readonly UPSTREAM_OPEN_TIMEOUT_MS = 15_000;
152
+ /**
153
+ * Cap on frames buffered browser→upstream before the upstream opens. A half-connected
154
+ * upstream (TCP accepted, WS handshake never completes) must not let a browser streaming PCM
155
+ * grow an unbounded in-memory array on MJAPI; past the cap the OLDEST frames are dropped
156
+ * (voice frames are perishable — late delivery of stale audio is worthless anyway).
157
+ */
158
+ private static readonly MAX_PENDING_FRAMES = 512;
159
+
121
160
  private upstream: WsClient | null = null;
122
161
  /** Frames the browser sent before the upstream opened; flushed in order once it's ready. */
123
162
  private readonly pending: Array<{ data: RawData; isBinary: boolean }> = [];
163
+ private openDeadline: ReturnType<typeof setTimeout> | null = null;
164
+ private droppedPendingFrames = 0;
124
165
  private closed = false;
125
166
 
126
167
  constructor(
@@ -135,7 +176,24 @@ class RealtimeProxyTunnel {
135
176
  const upstream = new WsClient(this.entry.UpstreamUrl, { headers });
136
177
  this.upstream = upstream;
137
178
 
138
- upstream.on('open', () => this.flushPending());
179
+ // Deadline: an upstream that accepts TCP but never completes the WS handshake would
180
+ // otherwise hold the tunnel (and its buffer) open forever.
181
+ this.openDeadline = setTimeout(() => {
182
+ this.openDeadline = null;
183
+ if (!this.closed && upstream.readyState !== WsClient.OPEN) {
184
+ console.warn(`[RealtimeProxy] upstream did not open within ${RealtimeProxyTunnel.UPSTREAM_OPEN_TIMEOUT_MS}ms — closing tunnel`);
185
+ this.Close();
186
+ }
187
+ }, RealtimeProxyTunnel.UPSTREAM_OPEN_TIMEOUT_MS);
188
+ (this.openDeadline as { unref?: () => void }).unref?.();
189
+
190
+ upstream.on('open', () => {
191
+ if (this.openDeadline) {
192
+ clearTimeout(this.openDeadline);
193
+ this.openDeadline = null;
194
+ }
195
+ this.flushPending();
196
+ });
139
197
  upstream.on('message', (data: RawData, isBinary: boolean) => this.forward(this.browser, data, isBinary));
140
198
  upstream.on('close', () => this.Close());
141
199
  upstream.on('error', () => this.Close());
@@ -151,6 +209,13 @@ class RealtimeProxyTunnel {
151
209
  this.forward(this.upstream, data, isBinary);
152
210
  } else {
153
211
  this.pending.push({ data, isBinary });
212
+ if (this.pending.length > RealtimeProxyTunnel.MAX_PENDING_FRAMES) {
213
+ this.pending.shift(); // drop-oldest: stale voice frames are worthless anyway
214
+ this.droppedPendingFrames++;
215
+ if (this.droppedPendingFrames === 1 || this.droppedPendingFrames % 100 === 0) {
216
+ console.warn(`[RealtimeProxy] pre-open buffer cap hit — dropped ${this.droppedPendingFrames} oldest frame(s) awaiting upstream open`);
217
+ }
218
+ }
154
219
  }
155
220
  }
156
221
 
@@ -179,6 +244,10 @@ class RealtimeProxyTunnel {
179
244
 
180
245
  /** Closes both legs and detaches the tunnel from the server. Idempotent. */
181
246
  public Close(): void {
247
+ if (this.openDeadline) {
248
+ clearTimeout(this.openDeadline);
249
+ this.openDeadline = null;
250
+ }
182
251
  if (this.closed) {
183
252
  return;
184
253
  }
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import { Router, json, type Request, type Response } from 'express';
14
- import { rateLimit } from 'express-rate-limit';
14
+ import { rateLimit, ipKeyGenerator } from 'express-rate-limit';
15
15
  import { LogStatus } from '@memberjunction/core';
16
16
  import { configInfo, type WidgetConfig } from '../config.js';
17
17
  import { MagicLinkKeyManager } from '../auth/magicLink/MagicLinkKeys.js';
@@ -65,7 +65,11 @@ export function createWidgetHandler(publicUrl: string, config: WidgetConfig): {
65
65
  windowMs: config.rateLimitWindowMs,
66
66
  standardHeaders: 'draft-7',
67
67
  legacyHeaders: false,
68
- keyGenerator: (req: Request) => `${readWidgetKey(req)}:${req.ip ?? 'noip'}`,
68
+ // ipKeyGenerator collapses IPv6 addresses to their /56 subnet before keying. Raw `req.ip`
69
+ // would give every IPv6 client a practically unlimited supply of distinct keys (one per
70
+ // address in their delegated prefix), letting them sidestep the per-IP bucket entirely —
71
+ // express-rate-limit v8 refuses to start with a keyGenerator that reads req.ip without it.
72
+ keyGenerator: (req: Request) => `${readWidgetKey(req)}:${req.ip ? ipKeyGenerator(req.ip) : 'noip'}`,
69
73
  limit: async (req: Request) => service.ResolvePerInstanceRateLimit(readWidgetKey(req)),
70
74
  message: { success: false, errorCode: 'rate_limited', error: 'Too many widget session requests. Try again later.' },
71
75
  });
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  import { Metadata, RunView, UserInfo, LogError, LogStatus } from '@memberjunction/core';
18
- import { UUIDsEqual } from '@memberjunction/global';
18
+ import { MJLruCache, UUIDsEqual } from '@memberjunction/global';
19
19
  import type { MJConversationWidgetInstanceEntity, MJConversationEntity } from '@memberjunction/core-entities';
20
20
  import { UserCache } from '@memberjunction/sqlserver-dataprovider';
21
21
  import { MagicLinkKeyManager } from '../auth/magicLink/MagicLinkKeys.js';
@@ -283,9 +283,26 @@ export class WidgetSessionService {
283
283
  return this.MintGuestSession(input);
284
284
  }
285
285
 
286
- /** Short-TTL cache of resolved per-instance rate limits, so the limiter doesn't hit the DB per request. */
287
- private readonly rateLimitCache = new Map<string, { limit: number; expiresAtMs: number }>();
286
+ /**
287
+ * Short-TTL, size-bounded cache of resolved per-instance rate limits, so the limiter doesn't hit
288
+ * the DB per request. The cache key (`widgetKey`) is presented by an UNAUTHENTICATED caller on the
289
+ * public `/widget/session` endpoint, so an unbounded `Map` here lets anyone grow server memory
290
+ * forever by sending an endless stream of distinct garbage keys (Memory Leak Audit Round 8,
291
+ * Critical finding). `MJLruCache` evicts the least-recently-used entry once `RATE_LIMIT_CACHE_MAX_SIZE`
292
+ * is reached, in addition to the existing TTL — `RATE_LIMIT_CACHE_MAX_SIZE` is sized well above any
293
+ * realistic deployment's widget-instance count so legitimate lookups are never evicted prematurely.
294
+ */
295
+ private readonly rateLimitCache = new MJLruCache<string, number>({
296
+ maxSize: WidgetSessionService.RATE_LIMIT_CACHE_MAX_SIZE,
297
+ ttlMs: WidgetSessionService.RATE_LIMIT_CACHE_TTL_MS,
298
+ });
288
299
  private static readonly RATE_LIMIT_CACHE_TTL_MS = 60_000;
300
+ private static readonly RATE_LIMIT_CACHE_MAX_SIZE = 10_000;
301
+
302
+ /** Current entry count in the per-instance rate-limit cache (diagnostic / test hook). */
303
+ public get RateLimitCacheSize(): number {
304
+ return this.rateLimitCache.Size;
305
+ }
289
306
 
290
307
  /**
291
308
  * Resolves the per-instance `RateLimitPerMinute` for a widget key (W6 hardening), falling back to the
@@ -299,10 +316,9 @@ export class WidgetSessionService {
299
316
  if (!key) {
300
317
  return fallback;
301
318
  }
302
- const cached = this.rateLimitCache.get(key);
303
- const now = Date.now();
304
- if (cached && cached.expiresAtMs > now) {
305
- return cached.limit;
319
+ const cached = this.rateLimitCache.Get(key);
320
+ if (cached !== undefined) {
321
+ return cached;
306
322
  }
307
323
  let limit = fallback;
308
324
  try {
@@ -316,7 +332,7 @@ export class WidgetSessionService {
316
332
  } catch (e) {
317
333
  LogError(e);
318
334
  }
319
- this.rateLimitCache.set(key, { limit, expiresAtMs: now + WidgetSessionService.RATE_LIMIT_CACHE_TTL_MS });
335
+ this.rateLimitCache.Set(key, limit);
320
336
  return limit;
321
337
  }
322
338
 
@@ -6,6 +6,7 @@ import { AppContext } from '../types.js';
6
6
  import { GetReadOnlyDataSource, GetReadOnlyProvider } from '../util.js';
7
7
  import { ResolverBase } from '../generic/ResolverBase.js';
8
8
  import { RunQueryResultType } from './QueryResolver.js';
9
+ import { exactTotalFromPage, resolveAdhocTotalRowCount } from './adhoc-query-helpers.js';
9
10
  import sql from 'mssql';
10
11
 
11
12
  /**
@@ -23,7 +24,7 @@ class AdhocQueryInput {
23
24
  @Field(() => Int, { nullable: true, description: 'Maximum number of rows to return; applied at the database via the render pipeline.' })
24
25
  MaxRows?: number;
25
26
 
26
- @Field(() => Int, { nullable: true, description: 'Zero-based offset for pagination. When > 0, the row cap switches to OFFSET/FETCH pagination.' })
27
+ @Field(() => Int, { nullable: true, description: 'Zero-based offset for pagination. Whenever MaxRows > 0 the query is paged via OFFSET/FETCH — including the first page (StartRow 0).' })
27
28
  StartRow?: number;
28
29
  }
29
30
 
@@ -76,55 +77,53 @@ export class AdhocQueryResolver extends ResolverBase {
76
77
  // 4. Route the SQL through RenderPipeline so composition tokens
77
78
  // resolve, comments and templates are processed, and the row cap
78
79
  // is applied at the database (via TOP / LIMIT / OFFSET-FETCH).
79
- const startRow = input.StartRow ?? 0;
80
+ //
81
+ // Page whenever a positive MaxRows is requested — INCLUDING the first
82
+ // page (StartRow 0). This yields OFFSET/FETCH data SQL *and* a
83
+ // COUNT(*) query so the response reports the true total row count
84
+ // rather than just the returned page size. It mirrors the saved-query
85
+ // path (GenericDatabaseProvider.InternalRunQuery → WrapWithPaging).
86
+ // (Previously paging was gated on StartRow > 0, so page 1 fell back to
87
+ // a TOP-N cap with TotalRowCount = page size — hiding the pager.)
88
+ // Clamp StartRow to a non-negative integer. A negative offset must not slip
89
+ // past the paging gate (which would then run the query with no row cap at all).
90
+ const startRow = Math.max(0, Number.isInteger(input.StartRow) ? input.StartRow! : 0);
80
91
  const maxRows = input.MaxRows;
81
- const usePaging =
82
- maxRows != null &&
83
- Number.isInteger(maxRows) &&
84
- maxRows > 0 &&
85
- Number.isInteger(startRow) &&
86
- startRow > 0;
87
- let executableSql: string;
92
+ const usePaging = maxRows != null && Number.isInteger(maxRows) && maxRows > 0;
93
+
94
+ let dataSQL: string;
95
+ let countSQL: string | null = null;
88
96
  try {
89
97
  const rendered = RenderPipeline.Run(input.SQL, {
90
98
  Platform: platform,
91
99
  ContextUser: contextUser,
92
- ...(usePaging
93
- ? { Paging: { StartRow: startRow, MaxRows: maxRows! } }
94
- : maxRows != null && maxRows > 0
95
- ? { MaxRows: maxRows }
96
- : {}),
100
+ ...(usePaging ? { Paging: { StartRow: startRow, MaxRows: maxRows! } } : {}),
97
101
  });
98
- executableSql = rendered.FinalSQL;
102
+ dataSQL = rendered.FinalSQL;
103
+ countSQL = rendered.PagingResult?.CountSQL ?? null;
99
104
  } catch (renderErr) {
100
105
  const renderMsg = renderErr instanceof Error ? renderErr.message : String(renderErr);
101
106
  return this.buildErrorResult(`Ad-hoc query rendering failed: ${renderMsg}`);
102
107
  }
103
108
 
104
- // 5. Execute with timeout
105
- const timeoutMs = (input.TimeoutSeconds ?? 30) * 1000;
106
- const request = new sql.Request(readOnlyDS);
107
-
108
- const result = await Promise.race([
109
- request.query(executableSql),
110
- new Promise<never>((_, reject) =>
111
- setTimeout(() => reject(new Error('Query timeout exceeded')), timeoutMs)
112
- )
113
- ]);
109
+ // 5. Execute the page (and, only when a full page needs it, the count) under
110
+ // a shared wall-clock deadline derived from the request's timeout budget.
111
+ const deadline = startTime + (input.TimeoutSeconds ?? 30) * 1000;
112
+ const { recordset, totalRowCount } = await this.executeDataAndCount(
113
+ readOnlyDS, dataSQL, countSQL, startRow, usePaging ? maxRows! : null, deadline
114
+ );
114
115
  const executionTimeMs = Date.now() - startTime;
115
116
 
116
117
  // 6. Return as RunQueryResultType
117
- const recordset = result.recordset ?? [];
118
-
119
118
  return {
120
119
  QueryID: '',
121
120
  QueryName: 'Ad-Hoc Query',
122
121
  Success: true,
123
122
  Results: JSON.stringify(recordset),
124
123
  RowCount: recordset.length,
125
- TotalRowCount: recordset.length,
126
- PageNumber: maxRows != null && maxRows > 0 ? Math.floor(startRow / maxRows) + 1 : undefined,
127
- PageSize: maxRows ?? undefined,
124
+ TotalRowCount: totalRowCount,
125
+ PageNumber: usePaging ? Math.floor(startRow / maxRows!) + 1 : undefined,
126
+ PageSize: usePaging ? maxRows! : undefined,
128
127
  ExecutionTime: executionTimeMs,
129
128
  ErrorMessage: ''
130
129
  };
@@ -164,6 +163,74 @@ export class AdhocQueryResolver extends ResolverBase {
164
163
  }
165
164
  }
166
165
 
166
+ /**
167
+ * Runs the page's data SQL, then — only when the page is FULL (so more rows may
168
+ * exist) — a COUNT(*) for the true total. A short (or unpaged) page needs no
169
+ * count: the exact total is `startRow + rowsReturned` (see {@link exactTotalFromPage}).
170
+ *
171
+ * The count is NON-FATAL: some queries page fine but cannot be counted — e.g.
172
+ * duplicate column names are legal in a result set but rejected inside the COUNT
173
+ * CTE wrap. A count failure must never sink the whole result, so we log it and
174
+ * report a lower-bound total (`startRow + rowsReturned`) and let the data render.
175
+ */
176
+ private async executeDataAndCount(
177
+ ds: sql.ConnectionPool,
178
+ dataSQL: string,
179
+ countSQL: string | null,
180
+ startRow: number,
181
+ maxRows: number | null,
182
+ deadline: number,
183
+ ): Promise<{ recordset: Record<string, unknown>[]; totalRowCount: number }> {
184
+ const dataResult = await this.runSqlWithDeadline<Record<string, unknown>>(ds, dataSQL, deadline);
185
+ const recordset = (dataResult.recordset ?? []) as Record<string, unknown>[];
186
+
187
+ // Total already known from the page alone (unpaged, or a short page)? Skip the count.
188
+ const exact = exactTotalFromPage(startRow, recordset.length, maxRows);
189
+ if (exact != null || !countSQL) {
190
+ return { recordset, totalRowCount: exact ?? recordset.length };
191
+ }
192
+
193
+ // Full page — a COUNT(*) is required to know the true total.
194
+ const lowerBound = startRow + recordset.length;
195
+ try {
196
+ const countResult = await this.runSqlWithDeadline<{ TotalRowCount: number }>(ds, countSQL, deadline);
197
+ return { recordset, totalRowCount: resolveAdhocTotalRowCount(countResult.recordset, lowerBound) };
198
+ } catch (countErr) {
199
+ const msg = countErr instanceof Error ? countErr.message : String(countErr);
200
+ LogError(`Ad-hoc query row-count failed; reporting a lower-bound total (${lowerBound}). ${msg}`);
201
+ return { recordset, totalRowCount: lowerBound };
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Executes one SQL statement on the read-only pool, racing it against the shared
207
+ * wall-clock `deadline`. The timer is always cleared on completion so a settled
208
+ * query never leaves a dangling timeout armed.
209
+ */
210
+ private async runSqlWithDeadline<T>(
211
+ ds: sql.ConnectionPool,
212
+ sqlText: string,
213
+ deadline: number,
214
+ ): Promise<sql.IResult<T>> {
215
+ const remaining = deadline - Date.now();
216
+ if (remaining <= 0) {
217
+ throw new Error('Query timeout exceeded');
218
+ }
219
+ let timer: ReturnType<typeof setTimeout> | undefined;
220
+ try {
221
+ return await Promise.race([
222
+ new sql.Request(ds).query<T>(sqlText),
223
+ new Promise<never>((_, reject) => {
224
+ timer = setTimeout(() => reject(new Error('Query timeout exceeded')), remaining);
225
+ }),
226
+ ]);
227
+ } finally {
228
+ if (timer) {
229
+ clearTimeout(timer);
230
+ }
231
+ }
232
+ }
233
+
167
234
  private buildErrorResult(errorMessage: string): RunQueryResultType {
168
235
  return {
169
236
  QueryID: '',
@@ -281,8 +281,37 @@ export class GetDataAccessToken {
281
281
  TokenUses: TokenUseLog[];
282
282
  }
283
283
  const __accessTokens: GetDataAccessToken[] = [];
284
- const __defaultTokenLifeSpan = 1000 * 60 * 5; // 5 minutes
284
+ const __defaultTokenLifeSpan = 1000 * 60 * 5; // 5 minutes
285
+
286
+ /**
287
+ * Removes every token whose `ExpiresAt` has already passed (and its `TokenUses`
288
+ * log along with it). Without this sweep `__accessTokens` grows forever — nothing
289
+ * else in this module ever removes an expired token, `deleteAccessToken()` has no
290
+ * production caller (tokens are consumed implicitly via short-lived `GetData`
291
+ * calls, never explicitly released by the requesting system), and `isTokenValid()`
292
+ * only checks the expiry timestamp without pruning it. Called from
293
+ * `registerAccessToken()` — the natural "allocation" moment — so the array
294
+ * self-bounds to roughly "tokens registered within one lifespan window" instead
295
+ * of growing with total lifetime registrations (Memory Leak Audit Round 7,
296
+ * Critical finding).
297
+ * @param now injectable for tests; defaults to the real current time
298
+ */
299
+ export function pruneExpiredTokens(now: Date = new Date()): void {
300
+ for (let i = __accessTokens.length - 1; i >= 0; i--) {
301
+ if (__accessTokens[i].ExpiresAt <= now) {
302
+ __accessTokens.splice(i, 1);
303
+ }
304
+ }
305
+ }
306
+
307
+ /** Number of tokens currently tracked (post-sweep, at last mutation). Exposed for tests/diagnostics. */
308
+ export function getAccessTokenCount(): number {
309
+ return __accessTokens.length;
310
+ }
311
+
285
312
  export function registerAccessToken(token?: string, lifeSpan: number = __defaultTokenLifeSpan, requestorPayload?: any): GetDataAccessToken {
313
+ pruneExpiredTokens();
314
+
286
315
  const tokenToUse = token || uuidv4();
287
316
 
288
317
  if (tokenExists(tokenToUse)) {