tm1npm 1.6.0 → 2.0.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 (34) hide show
  1. package/CHANGELOG.md +89 -0
  2. package/lib/services/ApplicationService.d.ts.map +1 -1
  3. package/lib/services/AsyncOperationService.d.ts +8 -1
  4. package/lib/services/AsyncOperationService.d.ts.map +1 -1
  5. package/lib/services/AsyncOperationService.js +69 -26
  6. package/lib/services/FileService.d.ts.map +1 -1
  7. package/lib/services/ProcessService.d.ts +18 -13
  8. package/lib/services/ProcessService.d.ts.map +1 -1
  9. package/lib/services/ProcessService.js +28 -17
  10. package/lib/services/RestService.d.ts +213 -25
  11. package/lib/services/RestService.d.ts.map +1 -1
  12. package/lib/services/RestService.js +840 -271
  13. package/lib/services/TM1Service.d.ts +42 -1
  14. package/lib/services/TM1Service.d.ts.map +1 -1
  15. package/lib/services/TM1Service.js +94 -4
  16. package/lib/tests/asyncOperationService.test.js +51 -45
  17. package/lib/tests/processService.comprehensive.test.js +2 -2
  18. package/lib/tests/processService.test.js +20 -6
  19. package/lib/tests/restService.test.d.ts +0 -4
  20. package/lib/tests/restService.test.d.ts.map +1 -1
  21. package/lib/tests/restService.test.js +1558 -143
  22. package/lib/tests/tm1Service.test.js +80 -8
  23. package/package.json +1 -1
  24. package/src/services/ApplicationService.ts +4 -4
  25. package/src/services/AsyncOperationService.ts +76 -29
  26. package/src/services/FileService.ts +3 -3
  27. package/src/services/ProcessService.ts +67 -37
  28. package/src/services/RestService.ts +1020 -278
  29. package/src/services/TM1Service.ts +124 -6
  30. package/src/tests/asyncOperationService.test.ts +52 -48
  31. package/src/tests/processService.comprehensive.test.ts +3 -3
  32. package/src/tests/processService.test.ts +21 -9
  33. package/src/tests/restService.test.ts +1844 -139
  34. package/src/tests/tm1Service.test.ts +95 -11
@@ -1,5 +1,12 @@
1
1
  import axios, { AxiosInstance, AxiosResponse, AxiosRequestConfig } from 'axios';
2
+ import * as https from 'https';
3
+ import * as fs from 'fs';
2
4
  import { TM1RestException, TM1TimeoutException } from '../exceptions/TM1Exception';
5
+ import { formatUrl, CaseAndSpaceInsensitiveSet, caseAndSpaceInsensitiveEquals } from '../utils/Utils';
6
+
7
+ type UrlTopology = 'base_url' | 'v11' | 'ibm_cloud' | 'pa_proxy' | 's2s';
8
+
9
+ const PRODUCT_VERSION_AUTH_SUFFIX = '/Configuration/ProductVersion/$value';
3
10
 
4
11
  export enum AuthenticationMode {
5
12
  BASIC = 1,
@@ -28,6 +35,9 @@ export interface RestServiceConfig {
28
35
  timeout?: number;
29
36
  cancelAtTimeout?: boolean;
30
37
  asyncRequestsMode?: boolean;
38
+ asyncPollingInitialDelay?: number;
39
+ asyncPollingMaxDelay?: number;
40
+ asyncPollingBackoffFactor?: number;
31
41
  connectionPoolSize?: number;
32
42
  poolConnections?: number;
33
43
  instance?: string;
@@ -39,6 +49,42 @@ export interface RestServiceConfig {
39
49
  apiKey?: string;
40
50
  accessToken?: string;
41
51
  tenant?: string;
52
+
53
+ // v12 / Cloud URL components
54
+ iamUrl?: string;
55
+ paUrl?: string;
56
+ cpdUrl?: string;
57
+
58
+ // SSO / CAM
59
+ gateway?: string;
60
+
61
+ // Integrated Windows Auth / Kerberos (config surface; auth flow unchanged)
62
+ integratedLogin?: boolean;
63
+ integratedLoginDomain?: string;
64
+ integratedLoginService?: string;
65
+ integratedLoginHost?: string;
66
+ integratedLoginDelegate?: boolean;
67
+
68
+ // Network / TLS
69
+ proxies?: { http?: string; https?: string };
70
+ sslContext?: https.Agent;
71
+ cert?: string | [string, string];
72
+
73
+ // Reconnect behavior (mirrors tm1py kwargs)
74
+ reConnectOnSessionTimeout?: boolean;
75
+ reConnectOnRemoteDisconnect?: boolean;
76
+ remoteDisconnectMaxRetries?: number;
77
+ remoteDisconnectRetryDelay?: number;
78
+ remoteDisconnectMaxDelay?: number;
79
+ }
80
+
81
+ export interface RequestOptions extends Omit<AxiosRequestConfig, 'timeout'> {
82
+ asyncRequestsMode?: boolean;
83
+ returnAsyncId?: boolean;
84
+ timeout?: number;
85
+ cancelAtTimeout?: boolean;
86
+ idempotent?: boolean;
87
+ verifyResponse?: boolean;
42
88
  }
43
89
 
44
90
  export class RestService {
@@ -53,55 +99,300 @@ export class RestService {
53
99
  private static readonly DEFAULT_CONNECTION_POOL_SIZE = 10;
54
100
  private static readonly DEFAULT_POOL_CONNECTIONS = 1;
55
101
 
102
+ private static readonly SESSION_COOKIE_NAMES = ['TM1SessionId', 'paSession'] as const;
103
+
56
104
  private axiosInstance!: AxiosInstance;
57
105
  private config: RestServiceConfig;
58
- private sessionId?: string;
106
+ private sessionCookies: Map<string, string> = new Map();
59
107
  private sandboxName?: string;
60
108
  private isConnected: boolean = false;
61
109
  private _serverVersion?: string;
110
+ private _asyncRequestsMode: boolean;
111
+ private _cancelAtTimeout: boolean;
112
+ private _timeout: number;
113
+ private _asyncPollingInitialDelay: number;
114
+ private _asyncPollingMaxDelay: number;
115
+ private _asyncPollingBackoffFactor: number;
116
+ private _reConnectOnSessionTimeout: boolean;
117
+ private _reConnectOnRemoteDisconnect: boolean;
118
+ private _remoteDisconnectMaxRetries: number;
119
+ private _remoteDisconnectRetryDelay: number;
120
+ private _remoteDisconnectMaxDelay: number;
121
+ private _isAdmin?: boolean;
122
+ private _isDataAdmin?: boolean;
123
+ private _isSecurityAdmin?: boolean;
124
+ private _isOpsAdmin?: boolean;
125
+ private _activeUserGroupsPromise?: Promise<CaseAndSpaceInsensitiveSet>;
62
126
 
63
127
  public get version(): string | undefined {
64
128
  return this._serverVersion;
65
129
  }
66
130
 
131
+ // Sync accessors for cached role flags. Return false before the first
132
+ // is_*_admin() call resolves — callers that need the real value must
133
+ // await is_admin() / is_data_admin() / etc. first.
134
+ public get isAdmin(): boolean { return this._isAdmin ?? false; }
135
+ public get isDataAdmin(): boolean { return this._isDataAdmin ?? false; }
136
+ public get isSecurityAdmin(): boolean { return this._isSecurityAdmin ?? false; }
137
+ public get isOpsAdmin(): boolean { return this._isOpsAdmin ?? false; }
138
+
67
139
  constructor(config: RestServiceConfig) {
68
140
  this.config = { ...config };
141
+ this._asyncRequestsMode = config.asyncRequestsMode ?? false;
142
+ this._cancelAtTimeout = config.cancelAtTimeout ?? false;
143
+ this._timeout = config.timeout ?? 60;
144
+ this._asyncPollingInitialDelay = config.asyncPollingInitialDelay ?? 0.1;
145
+ this._asyncPollingMaxDelay = config.asyncPollingMaxDelay ?? 1.0;
146
+ this._asyncPollingBackoffFactor = config.asyncPollingBackoffFactor ?? 2.0;
147
+ this._reConnectOnSessionTimeout = config.reConnectOnSessionTimeout ?? true;
148
+ this._reConnectOnRemoteDisconnect = config.reConnectOnRemoteDisconnect ?? true;
149
+ this._remoteDisconnectMaxRetries = config.remoteDisconnectMaxRetries ?? 5;
150
+ this._remoteDisconnectRetryDelay = config.remoteDisconnectRetryDelay ?? 1;
151
+ this._remoteDisconnectMaxDelay = config.remoteDisconnectMaxDelay ?? 30;
152
+
153
+ // Pre-populate admin flags for the built-in ADMIN user (mirrors tm1py)
154
+ if (config.user && caseAndSpaceInsensitiveEquals(config.user, 'ADMIN')) {
155
+ this._isAdmin = true;
156
+ this._isDataAdmin = true;
157
+ this._isSecurityAdmin = true;
158
+ this._isOpsAdmin = true;
159
+ }
160
+
69
161
  this.setupAxiosInstance();
162
+ if (this.config.sessionId) {
163
+ // Mirror tm1py's _set_session_id_cookie: v12 topologies use paSession,
164
+ // v11 and baseUrl overrides use TM1SessionId.
165
+ const topo = this.determineTopology();
166
+ const cookieName = (topo === 'ibm_cloud' || topo === 'pa_proxy' || topo === 's2s')
167
+ ? 'paSession'
168
+ : 'TM1SessionId';
169
+ this.sessionCookies.set(cookieName, this.config.sessionId);
170
+ }
171
+ }
172
+
173
+ private getSessionCookieValue(): string | undefined {
174
+ for (const name of RestService.SESSION_COOKIE_NAMES) {
175
+ const value = this.sessionCookies.get(name);
176
+ if (value) return value;
177
+ }
178
+ return undefined;
179
+ }
180
+
181
+ private buildCookieHeader(): string | undefined {
182
+ if (this.sessionCookies.size === 0) return undefined;
183
+ const parts: string[] = [];
184
+ for (const [name, value] of this.sessionCookies) {
185
+ parts.push(`${name}=${value}`);
186
+ }
187
+ return parts.join('; ');
188
+ }
189
+
190
+ private parseSetCookieHeaders(setCookie: string[] | string | undefined): void {
191
+ if (!setCookie) return;
192
+ const list = RestService.normaliseSetCookie(setCookie);
193
+ for (const raw of list) {
194
+ const firstSegment = raw.split(';')[0];
195
+ const eqIdx = firstSegment.indexOf('=');
196
+ if (eqIdx <= 0) continue;
197
+ // Strip CR/LF/NUL defensively to block header-injection via compromised response
198
+ const sanitize = (s: string) => s.replace(/[\r\n\0]/g, '').trim();
199
+ const name = sanitize(firstSegment.slice(0, eqIdx));
200
+ const value = sanitize(firstSegment.slice(eqIdx + 1));
201
+ if (!(RestService.SESSION_COOKIE_NAMES as readonly string[]).includes(name)) continue;
202
+ if (value === '') {
203
+ this.sessionCookies.delete(name);
204
+ } else {
205
+ this.sessionCookies.set(name, value);
206
+ }
207
+ }
208
+ }
209
+
210
+ private removeAuthorizationHeader(): void {
211
+ delete this.axiosInstance.defaults.headers.common['Authorization'];
212
+ }
213
+
214
+ private deleteHeaderCaseInsensitive(headers: Record<string, unknown> | undefined, name: string): void {
215
+ if (!headers) return;
216
+ // axios 1.x may supply an AxiosHeaders instance with case-insensitive lookup; plain objects
217
+ // (common in retry paths and test mocks) are case-sensitive and require explicit iteration
218
+ const target = name.toLowerCase();
219
+ for (const key of Object.keys(headers)) {
220
+ if (key.toLowerCase() === target) {
221
+ delete (headers as Record<string, unknown>)[key];
222
+ }
223
+ }
70
224
  }
71
225
 
72
226
  private setupAxiosInstance(): void {
73
227
  const baseURL = this.buildBaseUrl();
74
-
75
- this.axiosInstance = axios.create({
228
+
229
+ const axiosConfig: AxiosRequestConfig = {
76
230
  baseURL,
77
- timeout: (this.config.timeout || 60) * 1000,
231
+ timeout: this._timeout * 1000,
78
232
  headers: {
79
233
  ...RestService.HEADERS,
80
234
  ...(this.config.sessionContext && { 'TM1-SessionContext': this.config.sessionContext })
81
235
  }
82
- });
236
+ };
237
+
238
+ if (this.config.proxies) {
239
+ const proxyUrl = this.config.proxies.https || this.config.proxies.http;
240
+ if (proxyUrl) {
241
+ const parsed = new URL(proxyUrl);
242
+ axiosConfig.proxy = {
243
+ host: parsed.hostname,
244
+ port: parsed.port
245
+ ? parseInt(parsed.port, 10)
246
+ : (parsed.protocol === 'https:' ? 443 : 80),
247
+ protocol: parsed.protocol.replace(':', ''),
248
+ ...(parsed.username && {
249
+ auth: {
250
+ username: decodeURIComponent(parsed.username),
251
+ password: decodeURIComponent(parsed.password)
252
+ }
253
+ })
254
+ };
255
+ }
256
+ }
257
+
258
+ if (this.config.sslContext) {
259
+ axiosConfig.httpsAgent = this.config.sslContext;
260
+ } else if (this.config.cert) {
261
+ const [certPath, keyPath] = Array.isArray(this.config.cert)
262
+ ? this.config.cert
263
+ : [this.config.cert, undefined];
264
+ axiosConfig.httpsAgent = new https.Agent({
265
+ cert: fs.readFileSync(certPath),
266
+ key: keyPath ? fs.readFileSync(keyPath) : undefined
267
+ });
268
+ }
269
+
270
+ this.axiosInstance = axios.create(axiosConfig);
83
271
 
84
272
  this.setupInterceptors();
85
273
  }
86
274
 
87
275
  private buildBaseUrl(): string {
88
- if (this.config.baseUrl) {
89
- return this.config.baseUrl;
276
+ return this.resolveRoots().serviceRoot;
277
+ }
278
+
279
+ /**
280
+ * Pick the deployment topology based on the provided config, mirroring
281
+ * tm1py's _determine_auth_mode + _construct_service_and_auth_root dispatch.
282
+ *
283
+ * Note: authUrl is intentionally excluded from the v12 signal set because
284
+ * tm1npm historically uses authUrl for CAM SSO (unlike tm1py, where auth_url
285
+ * is a v12-only field). apiKey is also excluded to avoid collision with the
286
+ * existing BASIC_API_KEY auth flow.
287
+ */
288
+ private determineTopology(): UrlTopology {
289
+ const c = this.config;
290
+ const hasV12Signal = !!(c.instance || c.database || c.iamUrl || c.paUrl || c.tenant);
291
+ // tm1py's _construct_service_and_auth_root routes v12 modes (IBM Cloud / PA
292
+ // Proxy / S2S) through their dedicated constructors even if base_url is
293
+ // supplied. Only non-v12 configs fall through to the base_url override.
294
+ if (!hasV12Signal) return c.baseUrl ? 'base_url' : 'v11';
295
+ if (c.iamUrl) return 'ibm_cloud';
296
+ if (c.address && c.user && !c.instance) return 'pa_proxy';
297
+ return 's2s';
298
+ }
299
+
300
+ /**
301
+ * Resolve the TM1 service root and auth root URLs for the configured topology.
302
+ * Mirrors tm1py's _construct_service_and_auth_root return tuple.
303
+ */
304
+ private resolveRoots(): { serviceRoot: string; authRoot: string } {
305
+ switch (this.determineTopology()) {
306
+ case 'base_url': return this.rootsFromBaseUrl();
307
+ case 'ibm_cloud': return this.rootsIbmCloud();
308
+ case 'pa_proxy': return this.rootsPaProxy();
309
+ case 's2s': return this.rootsS2s();
310
+ case 'v11':
311
+ default: return this.rootsV11();
90
312
  }
313
+ }
91
314
 
315
+ private rootsV11(): { serviceRoot: string; authRoot: string } {
92
316
  const protocol = this.config.ssl ? 'https' : 'http';
93
317
  const address = this.config.address || 'localhost';
94
- const port = this.config.port || 8001;
95
-
96
- return `${protocol}://${address}:${port}/api/v1`;
318
+ const port = this.config.port ?? 8001;
319
+ const serviceRoot = `${protocol}://${address}:${port}/api/v1`;
320
+ return { serviceRoot, authRoot: serviceRoot + PRODUCT_VERSION_AUTH_SUFFIX };
321
+ }
322
+
323
+ private rootsIbmCloud(): { serviceRoot: string; authRoot: string } {
324
+ const { address, tenant, database, ssl } = this.config;
325
+ if (!address || !tenant || !database) {
326
+ throw new Error("'address', 'tenant' and 'database' must be provided to connect to TM1 > v12 in IBM Cloud");
327
+ }
328
+ if (!ssl) {
329
+ throw new Error("'ssl' must be true to connect to TM1 > v12 in IBM Cloud");
330
+ }
331
+ const t = encodeURIComponent(tenant);
332
+ const d = encodeURIComponent(database);
333
+ const serviceRoot = `https://${address}/api/${t}/v0/tm1/${d}`;
334
+ return { serviceRoot, authRoot: serviceRoot + PRODUCT_VERSION_AUTH_SUFFIX };
335
+ }
336
+
337
+ private rootsPaProxy(): { serviceRoot: string; authRoot: string } {
338
+ const { address, database, ssl } = this.config;
339
+ if (!address || !database) {
340
+ throw new Error("'address' and 'database' must be provided to connect to TM1 > v12 using PA Proxy");
341
+ }
342
+ const protocol = ssl ? 'https' : 'http';
343
+ const d = encodeURIComponent(database);
344
+ const serviceRoot = `${protocol}://${address}/tm1/${d}/api/v1`;
345
+ const authRoot = `${protocol}://${address}/login`;
346
+ return { serviceRoot, authRoot };
347
+ }
348
+
349
+ private rootsS2s(): { serviceRoot: string; authRoot: string } {
350
+ const { instance, database, ssl, port } = this.config;
351
+ if (!instance || !database) {
352
+ throw new Error("'instance' and 'database' arguments are required for v12 authentication with 'address'");
353
+ }
354
+ const protocol = ssl ? 'https' : 'http';
355
+ const address = this.config.address && this.config.address.length > 0
356
+ ? this.config.address
357
+ : 'localhost';
358
+ const portPart = port != null ? `:${port}` : '';
359
+ const i = encodeURIComponent(instance);
360
+ const d = encodeURIComponent(database);
361
+ const serviceRoot = `${protocol}://${address}${portPart}/${i}/api/v1/Databases('${d}')`;
362
+ const authRoot = `${protocol}://${address}${portPart}/${i}/auth/v1/session`;
363
+ return { serviceRoot, authRoot };
364
+ }
365
+
366
+ private rootsFromBaseUrl(): { serviceRoot: string; authRoot: string } {
367
+ const base = this.config.baseUrl!;
368
+ if (this.config.address) {
369
+ throw new Error("Base URL and Address cannot be specified at the same time");
370
+ }
371
+ if (/api\/v1\/Databases/.test(base)) {
372
+ if (!this.config.authUrl) {
373
+ throw new Error("Auth_url missing — when connecting to planning analytics engine using base_url, you must specify a corresponding auth_url");
374
+ }
375
+ return { serviceRoot: base, authRoot: this.config.authUrl };
376
+ }
377
+ // Recognize baseUrl shapes documented in docs/connection-guide.md
378
+ // (TM1 11 IBM Cloud `/tm1/api/tm1`, TM1 12 PaaS/access-token `/v0/tm1/...`)
379
+ // and use them verbatim. Only fall through to /api/v1 suffixing when the
380
+ // URL clearly lacks any TM1 API path — matching tm1py's fallback.
381
+ const trimmed = base.replace(/\/+$/, '');
382
+ // Each alternative is $-anchored (after trailing-slash trim above) so the
383
+ // match intent is explicit: the URL already ends in a TM1 API suffix.
384
+ const hasApiSuffix = /\/api\/v1$|\/v0\/tm1\/[^/]+$|\/tm1\/api\/tm1$/.test(trimmed);
385
+ const serviceRoot = hasApiSuffix ? trimmed : `${trimmed}/api/v1`;
386
+ return { serviceRoot, authRoot: serviceRoot + PRODUCT_VERSION_AUTH_SUFFIX };
97
387
  }
98
388
 
99
389
  private setupInterceptors(): void {
100
390
  // Request interceptor
101
391
  this.axiosInstance.interceptors.request.use(
102
392
  (config) => {
103
- if (this.sessionId) {
104
- config.headers['TM1SessionId'] = this.sessionId;
393
+ const cookieHeader = this.buildCookieHeader();
394
+ if (cookieHeader) {
395
+ config.headers['Cookie'] = cookieHeader;
105
396
  }
106
397
  if (this.sandboxName) {
107
398
  config.headers['TM1-Sandbox'] = this.sandboxName;
@@ -113,29 +404,34 @@ export class RestService {
113
404
 
114
405
  // Response interceptor with retry logic
115
406
  this.axiosInstance.interceptors.response.use(
116
- (response) => response,
407
+ (response) => {
408
+ this.parseSetCookieHeaders(response.headers?.['set-cookie']);
409
+ return response;
410
+ },
117
411
  async (error) => {
412
+ if (error.response) {
413
+ this.parseSetCookieHeaders(error.response.headers?.['set-cookie']);
414
+ }
118
415
  const originalRequest = error.config;
119
416
 
120
417
  // Handle timeout errors
121
- if (error.code === 'ECONNABORTED' || error.message.includes('timeout')) {
418
+ if (error.code === 'ECONNABORTED' || error.message?.includes?.('timeout')) {
122
419
  throw new TM1TimeoutException(`Request timeout: ${error.message}`);
123
420
  }
124
421
 
125
- // Handle authentication errors with retry
126
- if (error.response?.status === 401 && !originalRequest._retry) {
422
+ // Handle authentication errors with retry. Guarded by this.isConnected so a 401
423
+ // during disconnect()'s tm1.Close POST cannot recurse back into reAuthenticate().
424
+ if (error.response?.status === 401 && originalRequest && !originalRequest._retry
425
+ && this.isConnected && this._reConnectOnSessionTimeout) {
127
426
  originalRequest._retry = true;
128
427
 
129
428
  try {
130
- // Attempt re-authentication
131
429
  await this.reAuthenticate();
132
430
 
133
- // Update the request with new session/token
134
- if (this.sessionId) {
135
- originalRequest.headers['TM1SessionId'] = this.sessionId;
136
- }
431
+ // Stale values would defeat the rebuild by the request interceptor on replay
432
+ this.deleteHeaderCaseInsensitive(originalRequest.headers, 'Cookie');
433
+ this.deleteHeaderCaseInsensitive(originalRequest.headers, 'Authorization');
137
434
 
138
- // Retry the original request
139
435
  return this.axiosInstance(originalRequest);
140
436
  } catch (reAuthError) {
141
437
  // Re-authentication failed, throw original error
@@ -144,7 +440,7 @@ export class RestService {
144
440
  }
145
441
 
146
442
  // Handle connection errors with retry
147
- if (this.shouldRetryRequest(error) && this.canRetryRequest(originalRequest)) {
443
+ if (originalRequest && this.shouldRetryRequest(error) && this.canRetryRequest(originalRequest)) {
148
444
  return this.retryRequest(originalRequest);
149
445
  }
150
446
 
@@ -163,6 +459,7 @@ export class RestService {
163
459
  * Determine if a request should be retried
164
460
  */
165
461
  private shouldRetryRequest(error: any): boolean {
462
+ if (!this._reConnectOnRemoteDisconnect) return false;
166
463
  // Retry on network errors, timeouts, and 5xx server errors
167
464
  return !error.response ||
168
465
  error.code === 'ECONNRESET' ||
@@ -175,21 +472,45 @@ export class RestService {
175
472
  * Check if a request can be retried
176
473
  */
177
474
  private canRetryRequest(config: any): boolean {
475
+ if (config._idempotent === false) {
476
+ return false;
477
+ }
178
478
  // Don't retry if already retried maximum times
179
479
  config._retryCount = config._retryCount || 0;
180
- return config._retryCount < 3;
480
+ return config._retryCount < this._remoteDisconnectMaxRetries;
181
481
  }
182
482
 
183
483
  /**
184
- * Retry a failed request with exponential backoff
484
+ * Retry a failed request with exponential backoff, reconnecting the
485
+ * session before replay. Mirrors tm1py's _handle_remote_disconnect,
486
+ * which calls _manage_http_adapter() + connect() prior to retrying
487
+ * so a dropped session is re-established rather than replayed dead.
185
488
  */
186
489
  private async retryRequest(config: any): Promise<any> {
187
490
  config._retryCount = config._retryCount || 0;
188
491
  config._retryCount++;
189
492
 
190
- const retryDelay = Math.pow(2, config._retryCount) * 1000; // Exponential backoff
493
+ const baseDelay = this._remoteDisconnectRetryDelay * Math.pow(2, config._retryCount - 1);
494
+ const retryDelay = Math.min(baseDelay, this._remoteDisconnectMaxDelay) * 1000;
191
495
  await new Promise(resolve => setTimeout(resolve, retryDelay));
192
496
 
497
+ // Re-establish session before replay. Clear sessionCookies first so
498
+ // connect() runs a full setupAuthentication (mirrors tm1py, whose
499
+ // connect() always re-runs _start_session / _set_session_id_cookie
500
+ // regardless of prior cookie state). Without this, a stale cookie
501
+ // from a server-invalidated session would be reused, the probe GET
502
+ // would 401, and the whole retry path would silently fail.
503
+ // Flip isConnected=false so the 401 re-auth branch cannot recurse
504
+ // through the probe.
505
+ this.sessionCookies.clear();
506
+ this.isConnected = false;
507
+ await this.connect();
508
+
509
+ // Drop stale Cookie/Authorization on the original config — the request
510
+ // interceptor rebuilds Cookie from sessionCookies on replay.
511
+ this.deleteHeaderCaseInsensitive(config.headers, 'Cookie');
512
+ this.deleteHeaderCaseInsensitive(config.headers, 'Authorization');
513
+
193
514
  return this.axiosInstance(config);
194
515
  }
195
516
 
@@ -207,24 +528,197 @@ export class RestService {
207
528
  }
208
529
  }
209
530
 
531
+ private *waitTimeGenerator(timeout: number): Generator<number> {
532
+ let delay = this._asyncPollingInitialDelay;
533
+ let elapsed = 0;
534
+
535
+ if (timeout) {
536
+ while (elapsed < timeout) {
537
+ yield delay;
538
+ elapsed += delay;
539
+ delay = Math.min(delay * this._asyncPollingBackoffFactor, this._asyncPollingMaxDelay);
540
+ }
541
+ } else {
542
+ while (true) {
543
+ yield delay;
544
+ delay = Math.min(delay * this._asyncPollingBackoffFactor, this._asyncPollingMaxDelay);
545
+ }
546
+ }
547
+ }
548
+
549
+ private async _executeSyncRequest(
550
+ method: string,
551
+ url: string,
552
+ data?: any,
553
+ timeout?: number,
554
+ idempotent?: boolean,
555
+ axiosExtras?: Partial<AxiosRequestConfig>
556
+ ): Promise<AxiosResponse> {
557
+ const config: AxiosRequestConfig = {
558
+ method: method as AxiosRequestConfig['method'],
559
+ url,
560
+ data,
561
+ ...axiosExtras
562
+ };
563
+
564
+ if (timeout !== undefined) {
565
+ config.timeout = timeout * 1000;
566
+ }
567
+
568
+ (config as any)._idempotent = idempotent ?? false;
569
+
570
+ return this.axiosInstance.request(config);
571
+ }
572
+
573
+ private async _executeAsyncRequest(
574
+ method: string,
575
+ url: string,
576
+ data?: any,
577
+ timeout?: number,
578
+ cancelAtTimeout?: boolean,
579
+ returnAsyncId?: boolean,
580
+ idempotent?: boolean,
581
+ axiosExtras?: Partial<AxiosRequestConfig>
582
+ ): Promise<AxiosResponse | string> {
583
+ const preferValue = returnAsyncId ? 'respond-async' : 'respond-async,wait=55';
584
+ const config: AxiosRequestConfig = {
585
+ method: method as AxiosRequestConfig['method'],
586
+ url,
587
+ data,
588
+ ...axiosExtras,
589
+ headers: {
590
+ ...axiosExtras?.headers,
591
+ Prefer: preferValue
592
+ }
593
+ };
594
+
595
+ if (timeout !== undefined) {
596
+ config.timeout = timeout * 1000;
597
+ }
598
+
599
+ (config as any)._idempotent = idempotent ?? false;
600
+
601
+ const response = await this.axiosInstance.request(config);
602
+
603
+ if (response.status !== 202) {
604
+ // Server completed synchronously. If the caller asked for an async
605
+ // ID (returnAsyncId: true) there is none to return — hand back the
606
+ // full response so the caller can inspect the result directly.
607
+ return response;
608
+ }
609
+
610
+ const location = response.headers['location'] || '';
611
+ const match = typeof location === 'string' ? location.match(/\('([^']+)'\)/) : null;
612
+ const asyncId = match ? match[1] : undefined;
613
+
614
+ if (!asyncId) {
615
+ throw new TM1RestException(
616
+ `Async request returned 202 but no valid async ID in Location header: ${location}`
617
+ );
618
+ }
619
+
620
+ if (returnAsyncId) {
621
+ return asyncId;
622
+ }
623
+
624
+ return this._pollAsyncResponse(
625
+ asyncId,
626
+ timeout ?? this._timeout,
627
+ cancelAtTimeout ?? this._cancelAtTimeout
628
+ );
629
+ }
630
+
631
+ private async _pollAsyncResponse(
632
+ asyncId: string,
633
+ timeout: number,
634
+ cancelAtTimeout: boolean
635
+ ): Promise<AxiosResponse> {
636
+ for (const wait of this.waitTimeGenerator(timeout)) {
637
+ const response = await this.retrieve_async_response(asyncId);
638
+
639
+ if (response.status === 200 || response.status === 201) {
640
+ this.verifyAsyncResultHeader(response);
641
+ return response;
642
+ }
643
+
644
+ await new Promise(resolve => setTimeout(resolve, wait * 1000));
645
+ }
646
+
647
+ if (cancelAtTimeout) {
648
+ try {
649
+ await this.cancel_async_operation(asyncId);
650
+ } catch (cancelError) {
651
+ console.warn(`Failed to cancel async operation ${asyncId} at timeout:`, cancelError);
652
+ }
653
+ }
654
+
655
+ throw new TM1TimeoutException(
656
+ `Async operation ${asyncId} timed out after ${timeout} seconds`,
657
+ timeout
658
+ );
659
+ }
660
+
661
+ private async _request(
662
+ method: string,
663
+ url: string,
664
+ data?: any,
665
+ options?: RequestOptions
666
+ ): Promise<AxiosResponse | string> {
667
+ const timeout = options?.timeout ?? this._timeout;
668
+ const cancelAtTimeout = options?.cancelAtTimeout ?? this._cancelAtTimeout;
669
+ const asyncMode = options?.returnAsyncId || (options?.asyncRequestsMode ?? this._asyncRequestsMode);
670
+ const verifyResponse = options?.verifyResponse ?? true;
671
+ const idempotent = options?.idempotent ?? false;
672
+ const {
673
+ asyncRequestsMode: _asyncModeOpt,
674
+ returnAsyncId,
675
+ cancelAtTimeout: _cancelAtTimeoutOpt,
676
+ idempotent: _idempotentOpt,
677
+ verifyResponse: _verifyResponseOpt,
678
+ timeout: _timeoutOpt,
679
+ ...axiosExtras
680
+ } = options ?? {};
681
+
682
+ if (!verifyResponse && axiosExtras.validateStatus === undefined) {
683
+ axiosExtras.validateStatus = () => true;
684
+ }
685
+
686
+ if (asyncMode) {
687
+ return this._executeAsyncRequest(
688
+ method,
689
+ url,
690
+ data,
691
+ timeout,
692
+ cancelAtTimeout,
693
+ returnAsyncId,
694
+ idempotent,
695
+ axiosExtras
696
+ );
697
+ }
698
+
699
+ return this._executeSyncRequest(method, url, data, timeout, idempotent, axiosExtras);
700
+ }
701
+
210
702
  public async connect(): Promise<void> {
211
703
  try {
212
- // Set up authentication based on configuration
213
- await this.setupAuthentication();
214
-
215
- // Test connection
216
- const response = await this.axiosInstance.get('/Configuration/ServerName');
217
-
218
- // Extract session ID from response headers
219
- const setCookie = response.headers['set-cookie'];
220
- if (setCookie) {
221
- for (const cookie of setCookie) {
222
- const match = cookie.match(/TM1SessionId=([^;]+)/);
223
- if (match) {
224
- this.sessionId = match[1];
225
- break;
226
- }
227
- }
704
+ if (this.getSessionCookieValue() === undefined) {
705
+ await this.setupAuthentication();
706
+ }
707
+
708
+ // Mark probe non-idempotent so the response interceptor's retry
709
+ // branch skips it. Without this, a retry-triggered connect() whose
710
+ // probe also fails would spawn another connect(), recursively,
711
+ // bypassing remoteDisconnectMaxRetries (each probe has its own
712
+ // fresh _retryCount).
713
+ await this.axiosInstance.get(
714
+ '/Configuration/ServerName',
715
+ { _idempotent: false } as AxiosRequestConfig
716
+ );
717
+
718
+ // Strip Authorization only if the session cookie is established; Bearer/API-key
719
+ // modes that never issue a cookie must keep Authorization to stay authenticated
720
+ if (this.getSessionCookieValue()) {
721
+ this.removeAuthorizationHeader();
228
722
  }
229
723
 
230
724
  this.isConnected = true;
@@ -234,39 +728,57 @@ export class RestService {
234
728
  }
235
729
 
236
730
  public async disconnect(): Promise<void> {
237
- if (this.isConnected && this.sessionId) {
731
+ const shouldClose = this.isConnected;
732
+ // Flip isConnected first so a 401 on tm1.Close cannot trigger reAuthenticate recursion
733
+ this.isConnected = false;
734
+ if (shouldClose) {
238
735
  try {
239
736
  await this.axiosInstance.post('/ActiveSession/tm1.Close', {});
240
737
  } catch (error) {
241
738
  // Ignore errors during disconnect
242
739
  }
243
- this.isConnected = false;
244
- this.sessionId = undefined;
245
740
  }
741
+ this.sessionCookies.clear();
246
742
  }
247
743
 
248
- public async get(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse> {
249
- return this.axiosInstance.get(url, config);
744
+ /**
745
+ * When `returnAsyncId: true`, the caller receives the async id string
746
+ * iff the server returns `202 Accepted`. If TM1 short-circuits with
747
+ * `200/201`, the full `AxiosResponse` is returned instead — the
748
+ * declared `Promise<string>` return type is a best-effort narrowing.
749
+ */
750
+ public async get(url: string, options: RequestOptions & { returnAsyncId: true }): Promise<string | AxiosResponse>;
751
+ public async get(url: string, options?: RequestOptions): Promise<AxiosResponse>;
752
+ public async get(url: string, options?: RequestOptions): Promise<AxiosResponse | string> {
753
+ return this._request('GET', url, undefined, { idempotent: true, ...options });
250
754
  }
251
755
 
252
- public async post(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse> {
253
- return this.axiosInstance.post(url, data, config);
756
+ public async post(url: string, data: any, options: RequestOptions & { returnAsyncId: true }): Promise<string | AxiosResponse>;
757
+ public async post(url: string, data?: any, options?: RequestOptions): Promise<AxiosResponse>;
758
+ public async post(url: string, data?: any, options?: RequestOptions): Promise<AxiosResponse | string> {
759
+ return this._request('POST', url, data, options);
254
760
  }
255
761
 
256
- public async patch(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse> {
257
- return this.axiosInstance.patch(url, data, config);
762
+ public async patch(url: string, data: any, options: RequestOptions & { returnAsyncId: true }): Promise<string | AxiosResponse>;
763
+ public async patch(url: string, data?: any, options?: RequestOptions): Promise<AxiosResponse>;
764
+ public async patch(url: string, data?: any, options?: RequestOptions): Promise<AxiosResponse | string> {
765
+ return this._request('PATCH', url, data, options);
258
766
  }
259
767
 
260
- public async put(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse> {
261
- return this.axiosInstance.put(url, data, config);
768
+ public async put(url: string, data: any, options: RequestOptions & { returnAsyncId: true }): Promise<string | AxiosResponse>;
769
+ public async put(url: string, data?: any, options?: RequestOptions): Promise<AxiosResponse>;
770
+ public async put(url: string, data?: any, options?: RequestOptions): Promise<AxiosResponse | string> {
771
+ return this._request('PUT', url, data, options);
262
772
  }
263
773
 
264
- public async delete(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse> {
265
- return this.axiosInstance.delete(url, config);
774
+ public async delete(url: string, options: RequestOptions & { returnAsyncId: true }): Promise<string | AxiosResponse>;
775
+ public async delete(url: string, options?: RequestOptions): Promise<AxiosResponse>;
776
+ public async delete(url: string, options?: RequestOptions): Promise<AxiosResponse | string> {
777
+ return this._request('DELETE', url, undefined, options);
266
778
  }
267
779
 
268
780
  public getSessionId(): string | undefined {
269
- return this.sessionId;
781
+ return this.getSessionCookieValue();
270
782
  }
271
783
 
272
784
  public setSandbox(sandboxName?: string): void {
@@ -278,7 +790,10 @@ export class RestService {
278
790
  }
279
791
 
280
792
  public isLoggedIn(): boolean {
281
- return this.isConnected && !!this.sessionId;
793
+ return this.isConnected && (
794
+ !!this.getSessionCookieValue() ||
795
+ !!this.axiosInstance.defaults.headers.common['Authorization']
796
+ );
282
797
  }
283
798
 
284
799
  public async getApiMetadata(): Promise<any> {
@@ -286,181 +801,347 @@ export class RestService {
286
801
  return response.data;
287
802
  }
288
803
 
804
+ // =========================================================================
805
+ // Authentication helpers — mirror tm1py's _build_authorization_token*,
806
+ // _generate_*_access_token, and _start_session flows
807
+ // =========================================================================
808
+
289
809
  /**
290
- * Set up authentication based on configuration
810
+ * Build an httpsAgent option that skips TLS verification when verify is false.
291
811
  */
292
- private async setupAuthentication(): Promise<void> {
293
- // Access Token authentication (TM1 12+ with JWT tokens)
294
- if (this.config.accessToken) {
295
- this.axiosInstance.defaults.headers.common['Authorization'] = `Bearer ${this.config.accessToken}`;
296
- return;
297
- }
812
+ private static insecureAgentOption(
813
+ verify?: boolean | string
814
+ ): { httpsAgent: https.Agent } | Record<string, never> {
815
+ return verify === false
816
+ ? { httpsAgent: new https.Agent({ rejectUnauthorized: false }) }
817
+ : {};
818
+ }
298
819
 
299
- // API Key authentication (TM1 12+ PAaaS)
300
- if (this.config.apiKey) {
301
- if (this.config.user === 'apikey') {
302
- // IBM Cloud API Key style
303
- this.axiosInstance.defaults.headers.common['Authorization'] = `Basic ${Buffer.from(`apikey:${this.config.apiKey}`).toString('base64')}`;
304
- } else {
305
- // Basic API Key style
306
- this.axiosInstance.defaults.headers.common['API-Key'] = this.config.apiKey;
820
+ /**
821
+ * Normalise a Set-Cookie header value (string | string[] | undefined) into a string[].
822
+ */
823
+ private static normaliseSetCookie(raw: string | string[] | undefined): string[] {
824
+ if (!raw) return [];
825
+ return Array.isArray(raw) ? raw : [raw];
826
+ }
827
+
828
+ /**
829
+ * Extract a named cookie value from raw Set-Cookie headers.
830
+ */
831
+ private static extractCookieValue(
832
+ raw: string | string[] | undefined,
833
+ name: string
834
+ ): string | undefined {
835
+ const prefix = name + '=';
836
+ for (const header of RestService.normaliseSetCookie(raw)) {
837
+ const segment = header.split(';')[0];
838
+ if (segment.startsWith(prefix)) {
839
+ return segment.slice(prefix.length);
307
840
  }
308
- return;
309
841
  }
842
+ return undefined;
843
+ }
310
844
 
311
- // CAM (Cognos Access Manager) authentication
312
- if (this.config.authUrl && this.config.camPassport) {
313
- await this.setupCamAuthentication();
314
- return;
315
- }
845
+ /**
846
+ * Build Basic Authorization header.
847
+ * Mirrors tm1py's _build_authorization_token_basic.
848
+ */
849
+ private static _buildAuthorizationTokenBasic(user: string, password: string): string {
850
+ return 'Basic ' + Buffer.from(`${user}:${password}`).toString('base64');
851
+ }
316
852
 
317
- // CAM SSO authentication
318
- if (this.config.authUrl && this.config.user && this.config.password && this.config.namespace) {
319
- await this.setupCamSsoAuthentication();
320
- return;
321
- }
853
+ /**
854
+ * Build CAMNamespace Authorization header.
855
+ * Mirrors tm1py's _build_authorization_token_cam (non-gateway path).
856
+ */
857
+ private static _buildAuthorizationTokenCam(
858
+ user: string,
859
+ password: string,
860
+ namespace: string
861
+ ): string {
862
+ return 'CAMNamespace ' + Buffer.from(`${user}:${password}:${namespace}`).toString('base64');
863
+ }
322
864
 
323
- // Service-to-Service authentication
324
- if (this.config.applicationClientId && this.config.applicationClientSecret) {
325
- await this.setupServiceToServiceAuthentication();
326
- return;
865
+ /**
866
+ * Build CAMPassport Authorization token via gateway SSO.
867
+ * Mirrors tm1py's _build_authorization_token_cam (gateway path).
868
+ * Makes a GET request to the gateway URL with CAMNamespace as a query
869
+ * parameter and extracts the cam_passport cookie from the response.
870
+ *
871
+ * Note: tm1py uses HttpNegotiateAuth (NTLM/Kerberos) for gateway requests,
872
+ * which is Windows-only. This implementation sends a plain GET and relies on
873
+ * the gateway being accessible without NTLM. For environments requiring NTLM,
874
+ * pass a pre-obtained cam_passport via config.camPassport instead.
875
+ */
876
+ private static async _buildAuthorizationTokenCamSso(
877
+ gateway: string,
878
+ namespace: string,
879
+ verify?: boolean | string
880
+ ): Promise<string> {
881
+ const response = await axios.get(gateway, {
882
+ params: { CAMNamespace: namespace },
883
+ ...RestService.insecureAgentOption(verify)
884
+ });
885
+ if (response.status !== 200) {
886
+ throw new Error(
887
+ 'Failed to authenticate through CAM. Expected status_code 200, received status_code: ' +
888
+ response.status
889
+ );
327
890
  }
328
-
329
- // Basic authentication (default)
330
- if (this.config.user && this.config.password) {
331
- const credentials = Buffer.from(`${this.config.user}:${this.config.password}`).toString('base64');
332
- this.axiosInstance.defaults.headers.common['Authorization'] = `Basic ${credentials}`;
333
-
334
- // Add namespace for TM1 Cloud
335
- if (this.config.namespace) {
336
- this.axiosInstance.defaults.headers.common['TM1-Namespace'] = this.config.namespace;
337
- }
338
- return;
891
+ const passport = RestService.extractCookieValue(
892
+ response.headers['set-cookie'], 'cam_passport'
893
+ );
894
+ if (!passport) {
895
+ throw new Error(
896
+ "Failed to authenticate through CAM. HTTP response does not contain 'cam_passport' cookie"
897
+ );
339
898
  }
340
-
341
- throw new Error('No valid authentication configuration provided');
899
+ return 'CAMPassport ' + passport;
342
900
  }
343
901
 
344
902
  /**
345
- * Set up CAM (Cognos Access Manager) authentication
903
+ * Generate IBM IAM Cloud access token.
904
+ * Mirrors tm1py's _generate_ibm_iam_cloud_access_token.
346
905
  */
347
- private async setupCamAuthentication(): Promise<void> {
348
- if (!this.config.authUrl || !this.config.camPassport) {
349
- throw new Error('CAM authentication requires authUrl and camPassport');
906
+ private async _generateIbmIamCloudAccessToken(): Promise<string> {
907
+ const { iamUrl, apiKey } = this.config;
908
+ if (!iamUrl || !apiKey) {
909
+ throw new Error("'iamUrl' and 'apiKey' must be provided to generate access token from IBM Cloud");
350
910
  }
351
-
352
- try {
353
- const authResponse = await axios.post(this.config.authUrl, {
354
- parameters: [{
355
- name: 'CAMPassport',
356
- value: this.config.camPassport
357
- }]
358
- }, {
359
- headers: {
360
- 'Content-Type': 'application/json'
361
- }
362
- });
363
-
364
- if (authResponse.data && authResponse.data.sessionId) {
365
- this.sessionId = authResponse.data.sessionId;
366
- this.axiosInstance.defaults.headers.common['TM1SessionId'] = this.sessionId;
367
- } else {
368
- throw new Error('CAM authentication failed: No session ID returned');
369
- }
370
- } catch (error) {
371
- throw new Error(`CAM authentication failed: ${error}`);
911
+ const payload = `grant_type=urn%3Aibm%3Aparams%3Aoauth%3Agrant-type%3Aapikey&apikey=${encodeURIComponent(apiKey)}`;
912
+ const headers = {
913
+ 'Accept': 'application/json',
914
+ 'Content-Type': 'application/x-www-form-urlencoded'
915
+ };
916
+ const response = await axios.post(iamUrl, payload, {
917
+ headers,
918
+ ...RestService.insecureAgentOption(this.config.verify)
919
+ });
920
+ if (!response.data?.access_token) {
921
+ throw new Error(`Failed to generate access_token from URL: '${iamUrl}'`);
372
922
  }
923
+ return response.data.access_token;
373
924
  }
374
925
 
375
926
  /**
376
- * Set up CAM SSO authentication
927
+ * Generate CPD (Cloud Pak for Data) access token.
928
+ * Mirrors tm1py's _generate_cpd_access_token.
377
929
  */
378
- private async setupCamSsoAuthentication(): Promise<void> {
379
- if (!this.config.authUrl || !this.config.user || !this.config.password || !this.config.namespace) {
380
- throw new Error('CAM SSO authentication requires authUrl, user, password, and namespace');
930
+ private async _generateCpdAccessToken(
931
+ credentials: { username: string; password: string }
932
+ ): Promise<string> {
933
+ const { cpdUrl } = this.config;
934
+ if (!cpdUrl) {
935
+ throw new Error("'cpdUrl' must be provided to authenticate via CPD/Cloud Pak for Data");
381
936
  }
937
+ const url = `${cpdUrl}/v1/preauth/signin`;
938
+ const headers = { 'Content-Type': 'application/json;charset=UTF-8' };
939
+ const response = await axios.post(url, credentials, {
940
+ headers,
941
+ ...RestService.insecureAgentOption(this.config.verify)
942
+ });
943
+ if (!response.data?.token) {
944
+ throw new Error(`Failed to generate CPD access token from URL: '${url}'`);
945
+ }
946
+ return response.data.token;
947
+ }
382
948
 
383
- try {
384
- const authPayload = {
385
- username: this.config.user,
386
- password: this.config.password,
387
- namespace: this.config.namespace
388
- };
389
-
390
- const authResponse = await axios.post(this.config.authUrl, authPayload, {
391
- headers: {
392
- 'Content-Type': 'application/json'
393
- }
394
- });
395
-
396
- if (authResponse.data && authResponse.data.token) {
397
- this.axiosInstance.defaults.headers.common['Authorization'] = `Bearer ${authResponse.data.token}`;
398
- } else if (authResponse.data && authResponse.data.sessionId) {
399
- this.sessionId = authResponse.data.sessionId;
400
- this.axiosInstance.defaults.headers.common['TM1SessionId'] = this.sessionId;
401
- } else {
402
- throw new Error('CAM SSO authentication failed: No token or session ID returned');
403
- }
404
- } catch (error) {
405
- throw new Error(`CAM SSO authentication failed: ${error}`);
949
+ /**
950
+ * Authenticate with PA Proxy using a CPD JWT token.
951
+ * Mirrors tm1py's PA_PROXY flow in _start_session.
952
+ */
953
+ private async _authenticateWithPaProxy(jwt: string): Promise<void> {
954
+ const authRoot = this.resolveRoots().authRoot;
955
+ const headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
956
+ const payload = `jwt=${jwt}`;
957
+ const response = await axios.post(authRoot, payload, {
958
+ headers,
959
+ ...RestService.insecureAgentOption(this.config.verify)
960
+ });
961
+ const setCookie = response.headers['set-cookie'];
962
+ const csrfValue = RestService.extractCookieValue(setCookie, 'ba-sso-csrf');
963
+ if (csrfValue) {
964
+ this.axiosInstance.defaults.headers.common['ba-sso-authenticity'] = csrfValue;
406
965
  }
966
+ this.parseSetCookieHeaders(setCookie);
407
967
  }
408
968
 
409
969
  /**
410
- * Set up Service-to-Service authentication
970
+ * Authenticate Service-to-Service (v12).
971
+ * Mirrors tm1py's SERVICE_TO_SERVICE flow in _start_session:
972
+ * Uses Basic auth with applicationClientId:applicationClientSecret,
973
+ * then POSTs {"User": user} to the auth endpoint.
411
974
  */
412
- private async setupServiceToServiceAuthentication(): Promise<void> {
413
- if (!this.config.applicationClientId || !this.config.applicationClientSecret) {
414
- throw new Error('Service-to-Service authentication requires applicationClientId and applicationClientSecret');
975
+ private async _authenticateServiceToService(): Promise<void> {
976
+ const { applicationClientId, applicationClientSecret, user } = this.config;
977
+ if (!applicationClientId || !applicationClientSecret) {
978
+ throw new Error(
979
+ 'Service-to-Service authentication requires applicationClientId and applicationClientSecret'
980
+ );
415
981
  }
416
982
 
417
- try {
418
- const tokenEndpoint = this.config.authUrl || `${this.buildBaseUrl()}/oauth/token`;
983
+ // Guard: v11 and plain baseUrl topologies resolve authRoot to a metadata
984
+ // probe URL, not a token endpoint. Require explicit authUrl in those cases.
985
+ if (!this.config.authUrl) {
986
+ const topo = this.determineTopology();
987
+ const baseUrlIsV12 = topo === 'base_url'
988
+ && /api\/v1\/Databases/.test(this.config.baseUrl ?? '');
989
+ if (topo === 'v11' || (topo === 'base_url' && !baseUrlIsV12)) {
990
+ throw new Error(
991
+ "'authUrl' is required for Service-to-Service authentication on v11 topology"
992
+ );
993
+ }
994
+ }
419
995
 
420
- const tokenPayload = {
421
- grant_type: 'client_credentials',
422
- client_id: this.config.applicationClientId,
423
- client_secret: this.config.applicationClientSecret
424
- };
996
+ const authRoot = this.config.authUrl || this.resolveRoots().authRoot;
997
+ const basicAuth = Buffer.from(
998
+ `${applicationClientId}:${applicationClientSecret}`
999
+ ).toString('base64');
425
1000
 
426
- const tokenResponse = await axios.post(tokenEndpoint, tokenPayload, {
427
- headers: {
428
- 'Content-Type': 'application/x-www-form-urlencoded'
429
- }
430
- });
1001
+ this.axiosInstance.defaults.headers.common['Authorization'] = `Basic ${basicAuth}`;
431
1002
 
432
- if (tokenResponse.data && tokenResponse.data.access_token) {
433
- this.axiosInstance.defaults.headers.common['Authorization'] = `Bearer ${tokenResponse.data.access_token}`;
434
- } else {
435
- throw new Error('Service-to-Service authentication failed: No access token returned');
1003
+ const response = await axios.post(
1004
+ authRoot,
1005
+ JSON.stringify({ User: user }),
1006
+ {
1007
+ headers: {
1008
+ ...RestService.HEADERS,
1009
+ 'Authorization': `Basic ${basicAuth}`
1010
+ },
1011
+ ...RestService.insecureAgentOption(this.config.verify)
436
1012
  }
437
- } catch (error) {
438
- throw new Error(`Service-to-Service authentication failed: ${error}`);
439
- }
1013
+ );
1014
+
1015
+ this.parseSetCookieHeaders(response.headers['set-cookie']);
440
1016
  }
441
1017
 
442
1018
  /**
443
- * Get the authentication mode being used
1019
+ * Determine the authentication mode from config.
1020
+ * Mirrors tm1py's _determine_auth_mode, using the URL topology as the
1021
+ * primary discriminator for v12 modes.
444
1022
  */
445
1023
  public getAuthenticationMode(): AuthenticationMode {
446
- if (this.config.accessToken) {
447
- return AuthenticationMode.ACCESS_TOKEN;
448
- }
449
- if (this.config.apiKey) {
450
- return this.config.user === 'apikey' ?
451
- AuthenticationMode.IBM_CLOUD_API_KEY :
452
- AuthenticationMode.BASIC_API_KEY;
453
- }
454
- if (this.config.authUrl && this.config.camPassport) {
455
- return AuthenticationMode.CAM;
456
- }
457
- if (this.config.authUrl && this.config.user && this.config.password && this.config.namespace) {
458
- return AuthenticationMode.CAM_SSO;
1024
+ const topo = this.determineTopology();
1025
+ const c = this.config;
1026
+
1027
+ switch (topo) {
1028
+ case 'ibm_cloud':
1029
+ return AuthenticationMode.IBM_CLOUD_API_KEY;
1030
+ case 'pa_proxy':
1031
+ return AuthenticationMode.PA_PROXY;
1032
+ case 's2s':
1033
+ return AuthenticationMode.SERVICE_TO_SERVICE;
1034
+
1035
+ case 'v11':
1036
+ case 'base_url':
1037
+ default: {
1038
+ // v11 / base_url: check auth-specific config flags
1039
+ if (c.accessToken) return AuthenticationMode.ACCESS_TOKEN;
1040
+ if (c.apiKey) return AuthenticationMode.BASIC_API_KEY;
1041
+ if (c.applicationClientId && c.applicationClientSecret) {
1042
+ return AuthenticationMode.SERVICE_TO_SERVICE;
1043
+ }
1044
+ if (c.camPassport) return AuthenticationMode.CAM;
1045
+ if (c.gateway && c.namespace) return AuthenticationMode.CAM_SSO;
1046
+ if (c.integratedLogin) return AuthenticationMode.WIA;
1047
+ if (c.namespace) return AuthenticationMode.CAM;
1048
+ return AuthenticationMode.BASIC;
1049
+ }
459
1050
  }
460
- if (this.config.applicationClientId && this.config.applicationClientSecret) {
461
- return AuthenticationMode.SERVICE_TO_SERVICE;
1051
+ }
1052
+
1053
+ /**
1054
+ * Set up authentication based on configuration.
1055
+ * Mirrors tm1py's _start_session routing.
1056
+ */
1057
+ private async setupAuthentication(): Promise<void> {
1058
+ const authMode = this.getAuthenticationMode();
1059
+ const password = this.config.decodeB64 && this.config.password
1060
+ ? RestService.b64_decode_password(this.config.password)
1061
+ : this.config.password;
1062
+
1063
+ switch (authMode) {
1064
+ case AuthenticationMode.ACCESS_TOKEN:
1065
+ this.axiosInstance.defaults.headers.common['Authorization'] =
1066
+ `Bearer ${this.config.accessToken}`;
1067
+ break;
1068
+
1069
+ case AuthenticationMode.BASIC_API_KEY:
1070
+ if (this.config.user === 'apikey') {
1071
+ this.axiosInstance.defaults.headers.common['Authorization'] =
1072
+ RestService._buildAuthorizationTokenBasic('apikey', this.config.apiKey!);
1073
+ } else {
1074
+ this.axiosInstance.defaults.headers.common['API-Key'] = this.config.apiKey!;
1075
+ }
1076
+ break;
1077
+
1078
+ case AuthenticationMode.IBM_CLOUD_API_KEY: {
1079
+ const accessToken = await this._generateIbmIamCloudAccessToken();
1080
+ this.axiosInstance.defaults.headers.common['Authorization'] =
1081
+ `Bearer ${accessToken}`;
1082
+ break;
1083
+ }
1084
+
1085
+ case AuthenticationMode.PA_PROXY: {
1086
+ if (!this.config.user || !password) {
1087
+ throw new Error('PA Proxy authentication requires user and password');
1088
+ }
1089
+ const jwt = await this._generateCpdAccessToken({
1090
+ username: this.config.user,
1091
+ password
1092
+ });
1093
+ await this._authenticateWithPaProxy(jwt);
1094
+ break;
1095
+ }
1096
+
1097
+ case AuthenticationMode.SERVICE_TO_SERVICE:
1098
+ await this._authenticateServiceToService();
1099
+ break;
1100
+
1101
+ case AuthenticationMode.CAM_SSO: {
1102
+ // CAM_SSO is only reached when gateway is set (see getAuthenticationMode)
1103
+ const token = await RestService._buildAuthorizationTokenCamSso(
1104
+ this.config.gateway!,
1105
+ this.config.namespace!,
1106
+ this.config.verify
1107
+ );
1108
+ this.axiosInstance.defaults.headers.common['Authorization'] = token;
1109
+ break;
1110
+ }
1111
+
1112
+ case AuthenticationMode.CAM: {
1113
+ if (this.config.camPassport) {
1114
+ this.axiosInstance.defaults.headers.common['Authorization'] =
1115
+ 'CAMPassport ' + this.config.camPassport;
1116
+ } else if (this.config.namespace && this.config.user && password) {
1117
+ this.axiosInstance.defaults.headers.common['Authorization'] =
1118
+ RestService._buildAuthorizationTokenCam(
1119
+ this.config.user, password, this.config.namespace
1120
+ );
1121
+ } else {
1122
+ throw new Error(
1123
+ 'CAM authentication requires either camPassport or user/password/namespace'
1124
+ );
1125
+ }
1126
+ break;
1127
+ }
1128
+
1129
+ case AuthenticationMode.WIA:
1130
+ throw new Error(
1131
+ 'Windows Integrated Authentication (WIA) is not supported in Node.js. ' +
1132
+ 'Use CAM or Basic authentication instead.'
1133
+ );
1134
+
1135
+ case AuthenticationMode.BASIC:
1136
+ default: {
1137
+ if (!this.config.user || !password) {
1138
+ throw new Error('No valid authentication configuration provided');
1139
+ }
1140
+ this.axiosInstance.defaults.headers.common['Authorization'] =
1141
+ RestService._buildAuthorizationTokenBasic(this.config.user, password);
1142
+ break;
1143
+ }
462
1144
  }
463
- return AuthenticationMode.BASIC;
464
1145
  }
465
1146
 
466
1147
  /**
@@ -513,10 +1194,10 @@ export class RestService {
513
1194
  } {
514
1195
  return {
515
1196
  isConnected: this.isConnected,
516
- sessionId: this.sessionId,
1197
+ sessionId: this.getSessionCookieValue(),
517
1198
  authMode: this.getAuthenticationMode(),
518
1199
  baseUrl: this.buildBaseUrl(),
519
- timeout: (this.config.timeout || 60) * 1000,
1200
+ timeout: this._timeout,
520
1201
  sandbox: this.sandboxName
521
1202
  };
522
1203
  }
@@ -618,14 +1299,17 @@ export class RestService {
618
1299
  * Check if currently connected to TM1
619
1300
  */
620
1301
  public is_connected(): boolean {
621
- return this.isConnected && !!this.sessionId;
1302
+ return this.isConnected && (
1303
+ !!this.getSessionCookieValue() ||
1304
+ !!this.axiosInstance.defaults.headers.common['Authorization']
1305
+ );
622
1306
  }
623
1307
 
624
1308
  /**
625
1309
  * Get the current session ID
626
1310
  */
627
1311
  public session_id(): string | undefined {
628
- return this.sessionId;
1312
+ return this.getSessionCookieValue();
629
1313
  }
630
1314
 
631
1315
  /**
@@ -652,55 +1336,71 @@ export class RestService {
652
1336
  }
653
1337
 
654
1338
  /**
655
- * Check if current user is admin
1339
+ * Fetch the active user's group names as a CaseAndSpaceInsensitiveSet so
1340
+ * membership tests are case- and whitespace-insensitive (mirrors tm1py).
1341
+ * Concurrent callers (e.g. Promise.all([is_admin(), is_data_admin(), ...]))
1342
+ * coalesce onto a single in-flight request.
1343
+ */
1344
+ private fetchActiveUserGroupNames(): Promise<CaseAndSpaceInsensitiveSet> {
1345
+ if (this._activeUserGroupsPromise) return this._activeUserGroupsPromise;
1346
+
1347
+ const clear = () => { this._activeUserGroupsPromise = undefined; };
1348
+ const fetch = this.get('/ActiveUser/Groups').then(
1349
+ (response) => {
1350
+ clear();
1351
+ const set = new CaseAndSpaceInsensitiveSet();
1352
+ const value = response.data?.value ?? [];
1353
+ for (const group of value) {
1354
+ if (group?.Name) set.add(group.Name);
1355
+ }
1356
+ return set;
1357
+ },
1358
+ (err) => { clear(); throw err; }
1359
+ );
1360
+
1361
+ this._activeUserGroupsPromise = fetch;
1362
+ return fetch;
1363
+ }
1364
+
1365
+ /**
1366
+ * Check if current user is admin. Result is cached after the first
1367
+ * computation, and pre-populated when the configured user is ADMIN.
656
1368
  */
657
1369
  public async is_admin(): Promise<boolean> {
658
- try {
659
- const response = await this.get('/ActiveUser/Groups');
660
- const groups = response.data.value || [];
661
- return groups.some((g: any) => g.Name === 'ADMIN');
662
- } catch (error) {
663
- return false;
664
- }
1370
+ if (this._isAdmin !== undefined) return this._isAdmin;
1371
+ const groups = await this.fetchActiveUserGroupNames();
1372
+ this._isAdmin = groups.has('ADMIN');
1373
+ return this._isAdmin;
665
1374
  }
666
1375
 
667
1376
  /**
668
- * Check if current user is data admin
1377
+ * Check if current user is data admin (member of Admin or DataAdmin).
669
1378
  */
670
1379
  public async is_data_admin(): Promise<boolean> {
671
- try {
672
- const response = await this.get('/ActiveUser/Groups');
673
- const groups = response.data.value || [];
674
- return groups.some((g: any) => g.Name === 'ADMIN' || g.Name === 'DataAdmin');
675
- } catch (error) {
676
- return false;
677
- }
1380
+ if (this._isDataAdmin !== undefined) return this._isDataAdmin;
1381
+ const groups = await this.fetchActiveUserGroupNames();
1382
+ this._isDataAdmin = groups.has('Admin') || groups.has('DataAdmin');
1383
+ return this._isDataAdmin;
678
1384
  }
679
1385
 
680
1386
  /**
681
- * Check if current user is ops admin
1387
+ * Check if current user is ops admin (member of Admin or OperationsAdmin).
682
1388
  */
683
1389
  public async is_ops_admin(): Promise<boolean> {
684
- try {
685
- const response = await this.get('/ActiveUser/Groups');
686
- const groups = response.data.value || [];
687
- return groups.some((g: any) => g.Name === 'ADMIN' || g.Name === 'OperationsAdmin');
688
- } catch (error) {
689
- return false;
690
- }
1390
+ if (this._isOpsAdmin !== undefined) return this._isOpsAdmin;
1391
+ const groups = await this.fetchActiveUserGroupNames();
1392
+ this._isOpsAdmin = groups.has('Admin') || groups.has('OperationsAdmin');
1393
+ return this._isOpsAdmin;
691
1394
  }
692
1395
 
693
1396
  /**
694
- * Check if current user is security admin
1397
+ * Check if current user is security admin (member of Admin or SecurityAdmin).
695
1398
  */
696
1399
  public async is_security_admin(): Promise<boolean> {
697
- try {
698
- const response = await this.get('/ActiveUser/Groups');
699
- const groups = response.data.value || [];
700
- return groups.some((g: any) => g.Name === 'ADMIN' || g.Name === 'SecurityAdmin');
701
- } catch (error) {
702
- return false;
703
- }
1400
+ if (this._isSecurityAdmin !== undefined) return this._isSecurityAdmin;
1401
+ const groups = await this.fetchActiveUserGroupNames();
1402
+ this._isSecurityAdmin = groups.has('Admin') || groups.has('SecurityAdmin');
1403
+ return this._isSecurityAdmin;
704
1404
  }
705
1405
 
706
1406
  /**
@@ -734,82 +1434,124 @@ export class RestService {
734
1434
  return headers;
735
1435
  }
736
1436
 
1437
+ /**
1438
+ * Insert `tm1.compact=v0` into the Accept header (after the
1439
+ * `application/json` segment) and return the previous header value.
1440
+ * Mirrors tm1py's add_compact_json_header.
1441
+ */
1442
+ public add_compact_json_header(): string {
1443
+ const original = (this.axiosInstance.defaults.headers.common['Accept'] as string | undefined) ?? '';
1444
+ const parts = original.split(';');
1445
+ // Insertion point matters: must come immediately after `application/json`
1446
+ parts.splice(1, 0, 'tm1.compact=v0');
1447
+ this.axiosInstance.defaults.headers.common['Accept'] = parts.join(';');
1448
+ return original;
1449
+ }
1450
+
1451
+ /**
1452
+ * Decode a Base64-encoded password to its UTF-8 plaintext form
1453
+ * (mirrors tm1py's b64_decode_password).
1454
+ */
1455
+ public static b64_decode_password(encryptedPassword: string): string {
1456
+ return Buffer.from(encryptedPassword, 'base64').toString('utf-8');
1457
+ }
1458
+
1459
+ /**
1460
+ * Coerce a boolean/number/string config value to a boolean. Strings
1461
+ * are stripped of whitespace and lowercased before comparison with
1462
+ * `'true'` (mirrors tm1py's translate_to_boolean).
1463
+ */
1464
+ public static translate_to_boolean(value: unknown): boolean {
1465
+ if (typeof value === 'boolean') return value;
1466
+ if (typeof value === 'number') return Boolean(value);
1467
+ if (typeof value === 'string') {
1468
+ return value.replace(/\s+/g, '').toLowerCase() === 'true';
1469
+ }
1470
+ throw new Error(
1471
+ `Invalid argument: '${String(value)}'. Must be type 'boolean', 'number', or 'string'`
1472
+ );
1473
+ }
1474
+
737
1475
  /**
738
1476
  * Cancel an async operation by ID
739
1477
  */
740
1478
  public async cancel_async_operation(async_id: string): Promise<void> {
741
- try {
742
- await this.post(`/AsyncOperations('${async_id}')/tm1.Cancel`);
743
- } catch (error) {
744
- throw new TM1RestException(`Failed to cancel async operation ${async_id}: ${error}`);
745
- }
1479
+ await this.delete(formatUrl("/_async('{}')", async_id), { asyncRequestsMode: false });
746
1480
  }
747
1481
 
748
1482
  /**
749
1483
  * Retrieve async operation response
750
1484
  */
751
- public async retrieve_async_response(async_id: string): Promise<any> {
752
- try {
753
- const response = await this.get(`/AsyncOperations('${async_id}')`);
754
- // Axios treats 2xx as success, but 202 means the operation is still running
755
- if (response.status === 202) {
756
- throw new TM1RestException('Async operation still running', 202, response);
757
- }
758
- return response.data;
759
- } catch (error: any) {
760
- if (error instanceof TM1RestException) {
761
- throw error;
762
- }
763
- const status = error?.status ?? error?.response?.status;
764
- throw new TM1RestException(
765
- `Failed to retrieve async response ${async_id}: ${error}`,
766
- status,
767
- error?.response
768
- );
769
- }
1485
+ public async retrieve_async_response(async_id: string): Promise<AxiosResponse> {
1486
+ // tm1py's retrieve_async_response returns the raw response without
1487
+ // raising on non-2xx because its caller (_poll_async_response) gates
1488
+ // on status_code in [200, 201]. Mirror that: accept all statuses so
1489
+ // transient 404s (resource not yet materialized) or 202s (still
1490
+ // running) flow through to the polling loop rather than aborting it.
1491
+ return this.get(formatUrl("/_async('{}')", async_id), {
1492
+ asyncRequestsMode: false,
1493
+ verifyResponse: false,
1494
+ validateStatus: () => true
1495
+ }) as Promise<AxiosResponse>;
770
1496
  }
771
1497
 
772
1498
  /**
773
- * Get async operation status
1499
+ * TM1 v12 returns completed async results with HTTP 200 and encodes
1500
+ * the true operation status in the `asyncresult` header (e.g.
1501
+ * "500 Internal Server Error"). Mirror tm1py's
1502
+ * `_transform_async_response` by throwing on any embedded non-2xx
1503
+ * status so callers are not handed a 500 as "success".
774
1504
  */
775
- public async get_async_operation_status(async_id: string): Promise<string> {
776
- try {
777
- const response = await this.get(`/AsyncOperations('${async_id}')/Status/$value`);
778
- return response.data;
779
- } catch (error) {
780
- throw new TM1RestException(`Failed to get async operation status ${async_id}: ${error}`);
781
- }
1505
+ private verifyAsyncResultHeader(response: AxiosResponse): void {
1506
+ const headerValue = response.headers?.['asyncresult'];
1507
+ if (typeof headerValue !== 'string') return;
1508
+ const embeddedStatus = parseInt(headerValue.trim().split(/\s+/)[0], 10);
1509
+ if (Number.isNaN(embeddedStatus)) return;
1510
+ if (embeddedStatus >= 200 && embeddedStatus < 300) return;
1511
+ throw new TM1RestException(
1512
+ `Async operation failed with status ${headerValue}`,
1513
+ embeddedStatus,
1514
+ response
1515
+ );
782
1516
  }
783
1517
 
784
1518
  /**
785
- * Wait for async operation to complete
1519
+ * Wait for async operation to complete using a fixed polling cadence.
1520
+ *
1521
+ * Unlike the internal dispatcher's {@link waitTimeGenerator} (capped
1522
+ * exponential backoff), this public helper polls every
1523
+ * {@link poll_interval_seconds} seconds so existing callers who tuned
1524
+ * the cadence keep their original behavior.
786
1525
  */
787
1526
  public async wait_for_async_operation(
788
1527
  async_id: string,
789
1528
  timeout_seconds: number = 300,
790
- poll_interval_seconds: number = 1
1529
+ poll_interval_seconds: number = 1,
1530
+ cancel_at_timeout: boolean = false
791
1531
  ): Promise<any> {
792
- const start_time = Date.now();
793
- const timeout_ms = timeout_seconds * 1000;
794
- const poll_interval_ms = poll_interval_seconds * 1000;
1532
+ const deadline = Date.now() + timeout_seconds * 1000;
795
1533
 
796
- while (Date.now() - start_time < timeout_ms) {
797
- const status = await this.get_async_operation_status(async_id);
798
-
799
- if (status === 'Completed' || status === 'CompletedSuccessfully') {
800
- return await this.retrieve_async_response(async_id);
1534
+ while (Date.now() < deadline) {
1535
+ const response = await this.retrieve_async_response(async_id);
1536
+ if (response.status === 200 || response.status === 201) {
1537
+ this.verifyAsyncResultHeader(response);
1538
+ return response.data;
801
1539
  }
1540
+ await new Promise(resolve => setTimeout(resolve, poll_interval_seconds * 1000));
1541
+ }
802
1542
 
803
- if (status === 'Failed' || status === 'CompletedWithError') {
804
- const response = await this.retrieve_async_response(async_id);
805
- throw new TM1RestException(`Async operation failed: ${JSON.stringify(response)}`);
1543
+ if (cancel_at_timeout) {
1544
+ try {
1545
+ await this.cancel_async_operation(async_id);
1546
+ } catch (cancelError) {
1547
+ console.warn(`Failed to cancel async operation ${async_id} at timeout:`, cancelError);
806
1548
  }
807
-
808
- // Wait before polling again
809
- await new Promise(resolve => setTimeout(resolve, poll_interval_ms));
810
1549
  }
811
1550
 
812
- throw new TM1TimeoutException(`Async operation ${async_id} timed out after ${timeout_seconds} seconds`);
1551
+ throw new TM1TimeoutException(
1552
+ `Async operation ${async_id} timed out after ${timeout_seconds} seconds`,
1553
+ timeout_seconds
1554
+ );
813
1555
  }
814
1556
 
815
1557
  /**
@@ -885,4 +1627,4 @@ export class RestService {
885
1627
  throw new TM1RestException(`Failed to get data directory: ${error}`);
886
1628
  }
887
1629
  }
888
- }
1630
+ }