@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.
- package/README.md +9 -0
- package/dist/auth/magicLink/MagicLinkKeys.d.ts.map +1 -1
- package/dist/auth/magicLink/MagicLinkKeys.js +11 -4
- package/dist/auth/magicLink/MagicLinkKeys.js.map +1 -1
- package/dist/auth/magicLink/MagicLinkRouter.js +2 -2
- package/dist/auth/magicLink/MagicLinkRouter.js.map +1 -1
- package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
- package/dist/auth/magicLink/MagicLinkService.js +4 -0
- package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
- package/dist/auth/newUsers.d.ts.map +1 -1
- package/dist/auth/newUsers.js +6 -4
- package/dist/auth/newUsers.js.map +1 -1
- package/dist/config.d.ts +40 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +12 -0
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +28 -2
- package/dist/context.js.map +1 -1
- package/dist/generated/generated.d.ts +373 -6
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +1975 -39
- package/dist/generated/generated.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +4 -2
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/generic/RunViewResolver.d.ts +1 -0
- package/dist/generic/RunViewResolver.d.ts.map +1 -1
- package/dist/generic/RunViewResolver.js +12 -0
- package/dist/generic/RunViewResolver.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +73 -11
- package/dist/index.js.map +1 -1
- package/dist/integration/CustomColumnPromoter.d.ts +15 -2
- package/dist/integration/CustomColumnPromoter.d.ts.map +1 -1
- package/dist/integration/CustomColumnPromoter.js +113 -11
- package/dist/integration/CustomColumnPromoter.js.map +1 -1
- package/dist/integration/EntityMapLifecycle.d.ts +66 -0
- package/dist/integration/EntityMapLifecycle.d.ts.map +1 -0
- package/dist/integration/EntityMapLifecycle.js +189 -0
- package/dist/integration/EntityMapLifecycle.js.map +1 -0
- package/dist/realtimeProxy/RealtimeProxyServer.d.ts +10 -0
- package/dist/realtimeProxy/RealtimeProxyServer.d.ts.map +1 -1
- package/dist/realtimeProxy/RealtimeProxyServer.js +67 -1
- package/dist/realtimeProxy/RealtimeProxyServer.js.map +1 -1
- package/dist/realtimeWidget/WidgetRouter.d.ts.map +1 -1
- package/dist/realtimeWidget/WidgetRouter.js +6 -2
- package/dist/realtimeWidget/WidgetRouter.js.map +1 -1
- package/dist/realtimeWidget/WidgetSessionService.d.ts +12 -1
- package/dist/realtimeWidget/WidgetSessionService.d.ts.map +1 -1
- package/dist/realtimeWidget/WidgetSessionService.js +23 -8
- package/dist/realtimeWidget/WidgetSessionService.js.map +1 -1
- package/dist/resolvers/AdhocQueryResolver.d.ts +17 -0
- package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
- package/dist/resolvers/AdhocQueryResolver.js +81 -25
- package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
- package/dist/resolvers/GetDataResolver.d.ts +16 -0
- package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
- package/dist/resolvers/GetDataResolver.js +26 -1
- package/dist/resolvers/GetDataResolver.js.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +53 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.js +531 -64
- package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
- package/dist/resolvers/QueryResolver.d.ts.map +1 -1
- package/dist/resolvers/QueryResolver.js +0 -6
- package/dist/resolvers/QueryResolver.js.map +1 -1
- package/dist/resolvers/RSUResolver.d.ts +10 -0
- package/dist/resolvers/RSUResolver.d.ts.map +1 -1
- package/dist/resolvers/RSUResolver.js +28 -0
- package/dist/resolvers/RSUResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts +9 -6
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +135 -132
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/TransactionGroupResolver.d.ts +8 -1
- package/dist/resolvers/TransactionGroupResolver.d.ts.map +1 -1
- package/dist/resolvers/TransactionGroupResolver.js +29 -1
- package/dist/resolvers/TransactionGroupResolver.js.map +1 -1
- package/dist/resolvers/adhoc-query-helpers.d.ts +27 -0
- package/dist/resolvers/adhoc-query-helpers.d.ts.map +1 -0
- package/dist/resolvers/adhoc-query-helpers.js +42 -0
- package/dist/resolvers/adhoc-query-helpers.js.map +1 -0
- package/dist/rest/OAuthCallbackHandler.d.ts +9 -0
- package/dist/rest/OAuthCallbackHandler.d.ts.map +1 -1
- package/dist/rest/OAuthCallbackHandler.js +21 -4
- package/dist/rest/OAuthCallbackHandler.js.map +1 -1
- package/dist/telephony/media-upgrade-router.d.ts +14 -0
- package/dist/telephony/media-upgrade-router.d.ts.map +1 -1
- package/dist/telephony/media-upgrade-router.js +17 -1
- package/dist/telephony/media-upgrade-router.js.map +1 -1
- package/package.json +91 -89
- package/src/__tests__/AdhocQueryResolver.pagination.test.ts +114 -0
- package/src/__tests__/EntityMapLifecycle.dagWarnings.test.ts +91 -0
- package/src/__tests__/OAuthCallbackHandler.xss.test.ts +83 -0
- package/src/__tests__/RealtimeProxyServer.test.ts +53 -1
- package/src/__tests__/WidgetSessionService.test.ts +96 -0
- package/src/__tests__/adhoc-query-helpers.test.ts +50 -0
- package/src/__tests__/magicLinkService.provisionLog.test.ts +106 -0
- package/src/__tests__/media-upgrade-router.test.ts +43 -0
- package/src/__tests__/unifiedAuth.test.ts +18 -3
- package/src/auth/magicLink/MagicLinkKeys.ts +12 -6
- package/src/auth/magicLink/MagicLinkRouter.ts +2 -2
- package/src/auth/magicLink/MagicLinkService.ts +4 -0
- package/src/auth/newUsers.ts +6 -5
- package/src/config.ts +14 -0
- package/src/context.ts +28 -2
- package/src/generated/generated.ts +1385 -28
- package/src/generic/ResolverBase.ts +5 -2
- package/src/generic/RunViewResolver.ts +11 -0
- package/src/index.ts +75 -11
- package/src/integration/CustomColumnPromoter.ts +123 -6
- package/src/integration/EntityMapLifecycle.ts +221 -0
- package/src/realtimeProxy/RealtimeProxyServer.ts +70 -1
- package/src/realtimeWidget/WidgetRouter.ts +6 -2
- package/src/realtimeWidget/WidgetSessionService.ts +24 -8
- package/src/resolvers/AdhocQueryResolver.ts +97 -30
- package/src/resolvers/GetDataResolver.ts +30 -1
- package/src/resolvers/IntegrationDiscoveryResolver.ts +523 -64
- package/src/resolvers/QueryResolver.ts +1 -7
- package/src/resolvers/RSUResolver.ts +28 -0
- package/src/resolvers/RunAIAgentResolver.ts +74 -70
- package/src/resolvers/TransactionGroupResolver.ts +36 -2
- package/src/resolvers/__tests__/GetDataResolver.tokens.test.ts +138 -0
- package/src/resolvers/adhoc-query-helpers.ts +50 -0
- package/src/rest/OAuthCallbackHandler.ts +22 -4
- 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
|
|
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
|
-
|
|
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
|
-
/**
|
|
287
|
-
|
|
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.
|
|
303
|
-
|
|
304
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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:
|
|
126
|
-
PageNumber:
|
|
127
|
-
PageSize: maxRows
|
|
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)) {
|