appmesh 1.2.3 → 1.3.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/src/appmesh.js CHANGED
@@ -1,9 +1,10 @@
1
1
  // appmesh.js
2
2
  import axios from 'axios';
3
3
 
4
- // Lazy-resolved Node.js https module (null in browser)
4
+ // Lazy-resolved Node.js https/fs modules (null in browser)
5
5
  let _https = null;
6
- let _httpsReady = null; // Promise that resolves when https is loaded
6
+ let _fs = null;
7
+ let _httpsReady = null; // Promise that resolves when https/fs are loaded
7
8
 
8
9
  // Constants using Object.freeze to prevent modifications
9
10
  const CONSTANTS = Object.freeze({
@@ -44,16 +45,68 @@ class AppMeshError extends Error {
44
45
  * @param {string} message - Error message
45
46
  * @param {number|null} statusCode - HTTP status code
46
47
  * @param {any} responseData - Raw response data
48
+ * @param {string|null} errorCode - Machine-readable error code
47
49
  */
48
- constructor(message, statusCode = null, responseData = null) {
50
+ constructor(message, statusCode = null, responseData = null, errorCode = null) {
49
51
  super(message);
50
52
  this.name = 'AppMeshError';
51
53
  this.statusCode = statusCode;
52
54
  this.responseData = responseData;
55
+ this.errorCode = errorCode;
53
56
  this.timestamp = new Date().toISOString();
54
57
  }
55
58
  }
56
59
 
60
+ /**
61
+ * Error thrown when the server requires a TOTP code to complete login (HTTP 428).
62
+ *
63
+ * Subclasses AppMeshError with `statusCode` fixed to 428, so existing catch-based
64
+ * callers keep working. Carries the server-issued challenge as `totpChallenge`,
65
+ * ready to pass to `validate_totp(username, totpChallenge, totpCode)`.
66
+ */
67
+ class TotpRequiredError extends AppMeshError {
68
+ /**
69
+ * @param {string} message - Error message
70
+ * @param {any} responseData - Raw 428 response body (object or JSON string containing `totp_challenge`)
71
+ */
72
+ constructor(message, responseData = null) {
73
+ super(message, 428, responseData, 'TOTP_REQUIRED');
74
+ this.name = 'TotpRequiredError';
75
+ let data = responseData;
76
+ if (typeof data === 'string') {
77
+ try { data = JSON.parse(data); } catch (_) { data = null; }
78
+ }
79
+ /** @type {string|null} Server-issued TOTP challenge for validate_totp() */
80
+ this.totpChallenge = (data && typeof data === 'object' && typeof data.totp_challenge === 'string')
81
+ ? data.totp_challenge
82
+ : null;
83
+ }
84
+ }
85
+
86
+ /**
87
+ * App removed before its async-run exit was observed. Typed replacement for the
88
+ * old -1 sentinel, keeping real (possibly negative) exit codes unambiguous.
89
+ */
90
+ class AppRemovedError extends AppMeshError {
91
+ /** @param {string} message - Error message */
92
+ constructor(message) {
93
+ super(message, null, null, 'APP_REMOVED');
94
+ this.name = 'AppRemovedError';
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Transport disconnected while waiting for an async run, or the daemon delivered
100
+ * an unparseable exit code. Typed replacement for the old -2 sentinel exit code.
101
+ */
102
+ class TransportDisconnectedError extends AppMeshError {
103
+ /** @param {string} message - Error message */
104
+ constructor(message) {
105
+ super(message, null, null, 'TRANSPORT_DISCONNECTED');
106
+ this.name = 'TransportDisconnectedError';
107
+ }
108
+ }
109
+
57
110
  // Default output handler
58
111
  const defaultOutputHandler = (output, position) => {
59
112
  if (ENV.isNode) {
@@ -141,6 +194,20 @@ function parseDuration(duration) {
141
194
  return seconds;
142
195
  }
143
196
 
197
+ /**
198
+ * Case-insensitive header lookup (axios lowercases names; the TCP transport keeps the daemon's case).
199
+ * @param {Object} headers - Response headers
200
+ * @param {string} name - Header name (any case)
201
+ * @returns {*} Header value or undefined
202
+ * @private
203
+ */
204
+ function _getHeader(headers, name) {
205
+ if (!headers) return undefined;
206
+ const lower = name.toLowerCase();
207
+ const key = Object.keys(headers).find(k => k.toLowerCase() === lower);
208
+ return key === undefined ? undefined : headers[key];
209
+ }
210
+
144
211
  /**
145
212
  * Resolve a user name or numeric string to a UID (Node.js/Unix only).
146
213
  * @param {string} user - User name or numeric UID
@@ -199,6 +266,31 @@ function _decodeJwtExp(token) {
199
266
  }
200
267
  }
201
268
 
269
+ // Default App Mesh CA bundle, preferred when no sslConfig is given and the file exists
270
+ const DEFAULT_CA_FILE = '/opt/appmesh/ssl/ca.pem';
271
+
272
+ // PEM material: Buffers/inline PEM pass through; any other string is a file path —
273
+ // a missing path is a hard error, never a silent no-verify fallback.
274
+ function _loadPem(value) {
275
+ return (typeof value === 'string' && !value.includes('-----BEGIN')) ? _fs.readFileSync(value) : value;
276
+ }
277
+
278
+ // Resolve the sslConfig tri-state into https.Agent TLS options (Node.js only):
279
+ // null/undefined = App Mesh default CA if installed else system CAs; false = explicit
280
+ // insecure; object = custom ca/cert/key (rejectUnauthorized defaults to true).
281
+ function _resolveSslOptions(sslConfig) {
282
+ if (sslConfig === false) return { rejectUnauthorized: false };
283
+ if (!sslConfig) {
284
+ return _fs.existsSync(DEFAULT_CA_FILE)
285
+ ? { ca: _fs.readFileSync(DEFAULT_CA_FILE), rejectUnauthorized: true }
286
+ : { rejectUnauthorized: true }; // fall back to system trust roots, verification stays on
287
+ }
288
+ const opts = { ...sslConfig };
289
+ for (const k of ['ca', 'cert', 'key']) if (opts[k]) opts[k] = _loadPem(opts[k]);
290
+ if (typeof opts.rejectUnauthorized !== 'boolean') opts.rejectUnauthorized = true;
291
+ return opts;
292
+ }
293
+
202
294
  /**
203
295
  * AppMesh REST Service client
204
296
  */
@@ -206,7 +298,10 @@ class AppMeshClient {
206
298
  /**
207
299
  * Initialize AppMesh client
208
300
  * @param {string} baseURL - Service URL
209
- * @param {Object} [sslConfig] - SSL config
301
+ * @param {Object|false|null} [sslConfig] - SSL tri-state: `null`/`undefined` (default) verifies
302
+ * against the App Mesh default CA (/opt/appmesh/ssl/ca.pem) if installed, else the system CAs;
303
+ * `false` disables verification; an object supplies custom `ca`/`cert`/`key` (Buffer, inline
304
+ * PEM, or file path — a missing path is a hard error).
210
305
  * @example
211
306
  * const sslConfig = {
212
307
  * cert: fs.readFileSync("client.pem"),
@@ -222,6 +317,10 @@ class AppMeshClient {
222
317
  // Host to forward requests to
223
318
  this.forwardingHost = null;
224
319
 
320
+ // Current JWT token known to this client (single token store; transports
321
+ // sync from it via _handleTokenUpdate/_getAccessToken)
322
+ this._token = null;
323
+
225
324
  // Configure axios instance
226
325
  const axiosConfig = {
227
326
  baseURL,
@@ -233,10 +332,11 @@ class AppMeshClient {
233
332
  this._sslConfig = sslConfig;
234
333
  this._client = axios.create(axiosConfig);
235
334
 
236
- // Node.js only: start loading https module (resolved before first request)
335
+ // Node.js only: start loading https/fs modules (resolved before first request)
237
336
  if (ENV.isNode && !_httpsReady) {
238
- _httpsReady = import('https').then(mod => {
239
- _https = mod.default || mod;
337
+ _httpsReady = Promise.all([import('https'), import('fs')]).then(([h, f]) => {
338
+ _https = h.default || h;
339
+ _fs = f.default || f;
240
340
  }).catch(() => { /* browser bundle — ignore */ });
241
341
  }
242
342
 
@@ -258,9 +358,10 @@ class AppMeshClient {
258
358
  this._client.interceptors.response.use(
259
359
  response => response,
260
360
  error => {
261
- // Handle response errors
262
- const err = new AppMeshError('Request failed: ' + (error.message || 'Unknown error'), error.response?.status, error.response?.data);
263
- return Promise.resolve(err);
361
+ // Network-level failure (DNS, connection refused, timeout, ...)
362
+ const err = new AppMeshError('Request failed: ' + (error.message || 'Unknown error'), error.response?.status ?? null, error.response?.data ?? null);
363
+ err.cause = error;
364
+ return Promise.reject(err);
264
365
  }
265
366
  );
266
367
  }
@@ -272,8 +373,20 @@ class AppMeshClient {
272
373
  * @param {string} [totpCode] - TOTP code if 2FA is enabled
273
374
  * @param {string|number} [tokenExpire] - Token expiry (integer seconds or ISO 8601 string)
274
375
  * @param {string} [audience] - JWT audience
275
- * @returns {Promise<void>} Resolves when login succeeds. If the server requires TOTP and no
276
- * valid code is supplied, `_request()` rejects with the HTTP 428 response details.
376
+ * @returns {Promise<void>} Resolves when login succeeds.
377
+ * @throws {TotpRequiredError} When the server requires TOTP (HTTP 428) and no valid code was
378
+ * supplied. Catch it, read `error.totpChallenge`, then complete the login with
379
+ * `validate_totp(username, error.totpChallenge, totpCode)`:
380
+ * @example
381
+ * try {
382
+ * await client.login(user, pass);
383
+ * } catch (err) {
384
+ * if (err instanceof TotpRequiredError) {
385
+ * await client.validate_totp(user, err.totpChallenge, totpCode);
386
+ * } else {
387
+ * throw err;
388
+ * }
389
+ * }
277
390
  */
278
391
  async login(username, password, totpCode = null, tokenExpire = CONSTANTS.DEFAULT_TOKEN_EXPIRE_SECONDS, audience = CONSTANTS.DEFAULT_JWT_AUDIENCE) {
279
392
  // Validate inputs
@@ -302,21 +415,17 @@ class AppMeshClient {
302
415
  * @param {boolean} [updateSession=true] - When true, updates this client session with the
303
416
  * verified token and persists local auth state on success. When false, the token is only
304
417
  * verified and local state is unchanged.
305
- * @returns {Promise<{success: boolean, responseText: string}>} Verification result with success flag and response text.
418
+ * @returns {Promise<{success: boolean, responseText: string}>} Verification result (success is always true).
419
+ * @throws {AppMeshError} If verification fails (invalid token, permission or audience mismatch, network error).
306
420
  */
307
421
  async authenticate(token, permission = null, audience = CONSTANTS.DEFAULT_JWT_AUDIENCE, updateSession = true) {
308
422
  const headers = { Authorization: `Bearer ${token}` };
309
423
  if (permission) headers["X-Permission"] = permission;
310
424
  if (audience) headers["X-Audience"] = audience;
311
425
  if (updateSession) headers["X-Set-Cookie"] = "true";
312
- try {
313
- const response = await this._request("post", "/appmesh/auth", null, { headers });
314
- const responseText = typeof response.data === 'string' ? response.data : JSON.stringify(response.data);
315
- return { success: true, responseText };
316
- } catch (error) {
317
- const responseText = error instanceof AppMeshError ? error.message : String(error);
318
- return { success: false, responseText };
319
- }
426
+ const response = await this._request("post", "/appmesh/auth", null, { headers });
427
+ const responseText = typeof response.data === 'string' ? response.data : JSON.stringify(response.data);
428
+ return { success: true, responseText };
320
429
  }
321
430
 
322
431
  /**
@@ -327,20 +436,8 @@ class AppMeshClient {
327
436
  * header; in browsers it cannot replace the HttpOnly auth cookie.
328
437
  */
329
438
  set_token(token) {
330
- const COOKIE_NAME = 'appmesh_auth_token';
331
439
  if (ENV.isNode) {
332
- const existingCookies = this._client.defaults.headers.Cookie || '';
333
- const cookies = existingCookies.split('; ').filter(c =>
334
- c && !c.startsWith(COOKIE_NAME + '=')
335
- );
336
- cookies.push(`${COOKIE_NAME}=${token}`);
337
- this._client.defaults.headers.Cookie = cookies.join('; ');
338
- this._autoRefreshJwt = token;
339
- if (this._autoRefreshEnabled) {
340
- this._stopAutoRefresh();
341
- this._autoRefreshEnabled = true;
342
- this._scheduleTokenRefresh();
343
- }
440
+ this._handleTokenUpdate(token);
344
441
  } else {
345
442
  // Browser: auth token is HttpOnly (set by server via Set-Cookie),
346
443
  // document.cookie cannot access or override HttpOnly cookies.
@@ -349,6 +446,41 @@ class AppMeshClient {
349
446
  }
350
447
  }
351
448
 
449
+ /**
450
+ * Get the current access token known to this client.
451
+ * @returns {string|null} Current JWT token or null
452
+ * @protected
453
+ */
454
+ _getAccessToken() {
455
+ return this._token || null;
456
+ }
457
+
458
+ /**
459
+ * Store a new access token and propagate it to the transport layer.
460
+ * Called after login/renew/authenticate responses and by set_token().
461
+ * Base implementation keeps the token in memory, syncs the Node.js outgoing
462
+ * Cookie header, and reschedules auto-refresh when enabled.
463
+ * Subclasses using other transports (e.g. TCP) override this to sync their own store.
464
+ * @param {string|null} token - New JWT token (falsy clears the in-memory token)
465
+ * @protected
466
+ */
467
+ _handleTokenUpdate(token) {
468
+ const COOKIE_NAME = 'appmesh_auth_token';
469
+ this._token = token || null;
470
+ if (ENV.isNode && this._token) {
471
+ const existingCookies = this._client.defaults.headers.Cookie || '';
472
+ const cookies = existingCookies.split('; ').filter(c =>
473
+ c && !c.startsWith(COOKIE_NAME + '=')
474
+ );
475
+ cookies.push(`${COOKIE_NAME}=${this._token}`);
476
+ this._client.defaults.headers.Cookie = cookies.join('; ');
477
+ }
478
+ this._autoRefreshJwt = this._token;
479
+ if (this._autoRefreshEnabled) {
480
+ this._scheduleTokenRefresh();
481
+ }
482
+ }
483
+
352
484
  /**
353
485
  * Logout from the current session.
354
486
  */
@@ -364,6 +496,7 @@ class AppMeshClient {
364
496
  }
365
497
 
366
498
  this._stopAutoRefresh();
499
+ this._token = null;
367
500
 
368
501
  // Remove CSRF token and cookies
369
502
  if (ENV.isNode) {
@@ -379,11 +512,11 @@ class AppMeshClient {
379
512
  * @param {boolean} enable - true to start, false to stop
380
513
  * @param {string} [jwtToken] - Optional token used only to calculate the first refresh delay
381
514
  */
382
- setAutoRefreshToken(enable, jwtToken = null) {
515
+ set_auto_refresh_token(enable, jwtToken = null) {
383
516
  this._stopAutoRefresh();
384
517
  this._autoRefreshEnabled = enable;
385
518
  if (enable) {
386
- this._autoRefreshJwt = jwtToken || this._getAccessToken?.() || null;
519
+ this._autoRefreshJwt = jwtToken || this._getAccessToken() || null;
387
520
  this._scheduleTokenRefresh();
388
521
  }
389
522
  }
@@ -401,13 +534,19 @@ class AppMeshClient {
401
534
  _scheduleTokenRefresh() {
402
535
  if (!this._autoRefreshEnabled) return;
403
536
 
537
+ // Replace any pending timer so re-scheduling (e.g. from _handleTokenUpdate) never stacks timers
538
+ if (this._refreshTimer) {
539
+ clearTimeout(this._refreshTimer);
540
+ this._refreshTimer = null;
541
+ }
542
+
404
543
  const REFRESH_INTERVAL = 300; // 5 min default check
405
544
  const REFRESH_MARGIN = 30; // refresh 30s before expiry
406
545
 
407
546
  let delaySec = REFRESH_INTERVAL;
408
547
 
409
548
  // Try to compute precise delay from JWT exp
410
- const token = this._autoRefreshJwt || this._getAccessToken?.();
549
+ const token = this._autoRefreshJwt || this._getAccessToken();
411
550
  if (token) {
412
551
  const exp = _decodeJwtExp(token);
413
552
  if (exp) {
@@ -424,14 +563,9 @@ class AppMeshClient {
424
563
  this._refreshTimer = setTimeout(async () => {
425
564
  if (!this._autoRefreshEnabled) return;
426
565
  try {
566
+ // _request captures the renewed token cookie and routes it through
567
+ // _handleTokenUpdate, which updates _autoRefreshJwt for precise delays
427
568
  await this.renew_token();
428
- // Update stored token from cookie for precise delay calculation (Node.js only;
429
- // in browsers the auth cookie is HttpOnly and not accessible via JS)
430
- if (ENV.isNode) {
431
- const cookieStr = this._client?.defaults?.headers?.Cookie || '';
432
- const match = cookieStr.split('; ').find(c => c.startsWith('appmesh_auth_token='));
433
- if (match) this._autoRefreshJwt = match.split('=').slice(1).join('=');
434
- }
435
569
  } catch (err) {
436
570
  console.warn("Auto-refresh: token renewal failed:", err.message);
437
571
  }
@@ -460,7 +594,7 @@ class AppMeshClient {
460
594
  * Get the decoded OTP provisioning URI for the current user.
461
595
  * @returns {Promise<string>} Decoded `otpauth://...` URI, not just the raw secret field
462
596
  */
463
- async get_totp_secret() {
597
+ async get_totp_uri() {
464
598
  const response = await this._request("post", "/appmesh/totp/secret");
465
599
  return base64Utils.decode(response.data["mfa_uri"]);
466
600
  }
@@ -497,11 +631,11 @@ class AppMeshClient {
497
631
  /**
498
632
  * Disable TOTP for user
499
633
  * @param {string} [user='self'] - Username
500
- * @returns {boolean} Success status
634
+ * @returns {Promise<boolean>} true on success; failures throw AppMeshError
501
635
  */
502
636
  async disable_totp(user = "self") {
503
- const response = await this._request("post", `/appmesh/totp/${user}/disable`);
504
- return response.status === 200;
637
+ await this._request("post", `/appmesh/totp/${user}/disable`);
638
+ return true;
505
639
  }
506
640
 
507
641
  /**
@@ -603,21 +737,21 @@ class AppMeshClient {
603
737
  /**
604
738
  * Enable application
605
739
  * @param {string} name - App name
606
- * @returns {Promise<boolean>} Success status
740
+ * @returns {Promise<boolean>} true on success; failures throw AppMeshError
607
741
  */
608
742
  async enable_app(name) {
609
- const response = await this._request("post", `/appmesh/app/${name}/enable`);
610
- return response.status === 200;
743
+ await this._request("post", `/appmesh/app/${name}/enable`);
744
+ return true;
611
745
  }
612
746
 
613
747
  /**
614
748
  * Disable application
615
749
  * @param {string} name - App name
616
- * @returns {Promise<boolean>} Success status
750
+ * @returns {Promise<boolean>} true on success; failures throw AppMeshError
617
751
  */
618
752
  async disable_app(name) {
619
- const response = await this._request("post", `/appmesh/app/${name}/disable`);
620
- return response.status === 200;
753
+ await this._request("post", `/appmesh/app/${name}/disable`);
754
+ return true;
621
755
  }
622
756
 
623
757
  /**
@@ -641,9 +775,11 @@ class AppMeshClient {
641
775
  };
642
776
 
643
777
  const response = await this._request("get", `/appmesh/app/${app_name}/output`, null, { params });
644
- // axios response header use lower case
645
- const outPosition = response.headers["X-Output-Position".toLowerCase()] ? parseInt(response.headers["X-Output-Position".toLowerCase()], 10) : null;
646
- const exitCode = response.headers["X-Exit-Code".toLowerCase()] ? parseInt(response.headers["X-Exit-Code".toLowerCase()], 10) : null;
778
+ // axios lowercases header names; the TCP transport keeps the daemon's exact case
779
+ const outPositionHeader = _getHeader(response.headers, "X-Output-Position");
780
+ const exitCodeHeader = _getHeader(response.headers, "X-Exit-Code");
781
+ const outPosition = outPositionHeader ? parseInt(outPositionHeader, 10) : null;
782
+ const exitCode = exitCodeHeader ? parseInt(exitCodeHeader, 10) : null;
647
783
  return new AppOutput(response.status, response.data, outPosition, exitCode);
648
784
  }
649
785
 
@@ -668,9 +804,10 @@ class AppMeshClient {
668
804
  if (stdoutHandler) {
669
805
  stdoutHandler(response.data, 0);
670
806
  }
671
- // axios response header use lower case
672
- if (response.headers["X-Exit-Code".toLowerCase()]) {
673
- exitCode = parseInt(response.headers["X-Exit-Code".toLowerCase()], 10);
807
+ // axios lowercases header names; the TCP transport keeps the daemon's exact case
808
+ const exitCodeHeader = _getHeader(response.headers, "X-Exit-Code");
809
+ if (exitCodeHeader) {
810
+ exitCode = parseInt(exitCodeHeader, 10);
674
811
  }
675
812
  } else if (stdoutHandler) {
676
813
  stdoutHandler(response.data, 0);
@@ -701,8 +838,9 @@ class AppMeshClient {
701
838
  * @param {AppRun} run - AppRun object
702
839
  * @param {Function} [stdoutHandler=defaultOutputHandler] - Stdout handler callback(data, position)
703
840
  * @param {number} [timeout=0] - Max wait time
704
- * @returns {Promise<number|null>} Exit code, or `null` on timeout/polling failure. On success
841
+ * @returns {Promise<number|null>} Exit code, or `null` only on timeout. On success
705
842
  * the SDK also attempts to delete the temporary run app.
843
+ * @throws {AppMeshError} If polling the app output fails.
706
844
  */
707
845
  async wait_for_async_run(run, stdoutHandler = defaultOutputHandler, timeout = 0) {
708
846
  if (run) {
@@ -711,37 +849,27 @@ class AppMeshClient {
711
849
  const interval = 1;
712
850
 
713
851
  while (run.procUid.length > 0) {
714
- try {
715
- const appOut = await this.get_app_output(run.appName, lastOutputPosition, 0, 20480, run.procUid, interval);
716
- if (appOut.output && stdoutHandler) {
717
- stdoutHandler(appOut.output, lastOutputPosition);
718
- }
719
-
720
- if (appOut.outPosition !== null) {
721
- lastOutputPosition = appOut.outPosition;
722
- }
852
+ const appOut = await this.get_app_output(run.appName, lastOutputPosition, 0, 20480, run.procUid, interval);
853
+ if (appOut.output && stdoutHandler) {
854
+ stdoutHandler(appOut.output, lastOutputPosition);
855
+ }
723
856
 
724
- if (appOut.exitCode !== null) {
725
- // Process finished
726
- await this.delete_app(run.appName);
727
- return appOut.exitCode;
728
- }
857
+ if (appOut.outPosition !== null) {
858
+ lastOutputPosition = appOut.outPosition;
859
+ }
729
860
 
730
- if (appOut.statusCode !== 200) {
731
- // Request failed
732
- break;
733
- }
861
+ if (appOut.exitCode !== null) {
862
+ // Process finished
863
+ await this.delete_app(run.appName);
864
+ return appOut.exitCode;
865
+ }
734
866
 
735
- if (timeout > 0 && (new Date() - start) / 1000 > timeout) {
736
- // Timeout reached
737
- break;
738
- }
739
- // Small delay to prevent tight looping
740
- await new Promise((resolve) => setTimeout(resolve, 100));
741
- } catch (error) {
742
- console.error(error);
867
+ if (timeout > 0 && (new Date() - start) / 1000 > timeout) {
868
+ // Timeout reached
743
869
  break;
744
870
  }
871
+ // Small delay to prevent tight looping
872
+ await new Promise((resolve) => setTimeout(resolve, 100));
745
873
  }
746
874
  }
747
875
  return null;
@@ -767,19 +895,27 @@ class AppMeshClient {
767
895
  /**
768
896
  * Cancel running task
769
897
  * @param {string} appName - App name
770
- * @returns {Promise<boolean>} Success status
898
+ * @returns {Promise<boolean>} true on success; failures throw AppMeshError
771
899
  */
772
900
  async cancel_task(appName) {
773
- const response = await this._request("delete", `/appmesh/app/${appName}/task`);
774
- return response.status === 200;
901
+ await this._request("delete", `/appmesh/app/${appName}/task`);
902
+ return true;
775
903
  }
776
904
 
777
905
  /**
778
- * Download a remote file.
906
+ * Download a remote file. Behavior differs by environment:
907
+ *
908
+ * - **Node.js**: `localFile` is a filesystem path — the response body is written to it,
909
+ * and when `applyAttrs` is true the returned mode and best-effort owner/group metadata
910
+ * are applied on non-Windows platforms.
911
+ * - **Browser**: `localFile` is only the *suggested download filename* (its basename is
912
+ * used); no path is honored — the browser's download UI decides where the file goes,
913
+ * and `applyAttrs` has no effect.
914
+ *
779
915
  * @param {string} filePath - Remote file path
780
- * @param {string} localFile - Local file path
781
- * @param {boolean} [applyAttrs=true] - In Node.js, apply returned mode and best-effort owner/group
782
- * metadata on non-Windows platforms
916
+ * @param {string} localFile - Local file path (Node.js) or suggested filename (browser)
917
+ * @param {boolean} [applyAttrs=true] - Node.js only: apply returned mode and best-effort
918
+ * owner/group metadata on non-Windows platforms
783
919
  */
784
920
  async download_file(filePath, localFile, applyAttrs = true) {
785
921
  const headers = { [CONSTANTS.HTTP_HEADER_KEY_X_FILE_PATH]: encodeURIComponent(filePath) };
@@ -948,22 +1084,22 @@ class AppMeshClient {
948
1084
  * Add label to server
949
1085
  * @param {string} labelName - Label name
950
1086
  * @param {string} labelValue - Label value
951
- * @returns {boolean} Success status
1087
+ * @returns {Promise<boolean>} true on success; failures throw AppMeshError
952
1088
  */
953
1089
  async add_label(labelName, labelValue) {
954
- const response = await this._request("put", `/appmesh/label/${labelName}`, null, { params: { value: labelValue } });
955
- return response.status === 200;
1090
+ await this._request("put", `/appmesh/label/${labelName}`, null, { params: { value: labelValue } });
1091
+ return true;
956
1092
  }
957
1093
 
958
1094
 
959
1095
  /**
960
1096
  * Delete label from server
961
1097
  * @param {string} labelName - Label name
962
- * @returns {boolean} Success status
1098
+ * @returns {Promise<boolean>} true on success; failures throw AppMeshError
963
1099
  */
964
1100
  async delete_label(labelName) {
965
- const response = await this._request("delete", `/appmesh/label/${labelName}`);
966
- return response.status === 200;
1101
+ await this._request("delete", `/appmesh/label/${labelName}`);
1102
+ return true;
967
1103
  }
968
1104
 
969
1105
 
@@ -982,56 +1118,56 @@ class AppMeshClient {
982
1118
  * @param {string} oldPassword - Old password
983
1119
  * @param {string} newPassword - New password
984
1120
  * @param {string} [username="self"] - Username
985
- * @returns {Promise<boolean>} Success status
1121
+ * @returns {Promise<boolean>} true on success; failures throw AppMeshError
986
1122
  */
987
1123
  async update_password(oldPassword, newPassword, username = "self") {
988
1124
  const body = {
989
1125
  "old_password": base64Utils.encode(oldPassword),
990
1126
  "new_password": base64Utils.encode(newPassword)
991
1127
  };
992
- const response = await this._request("post", `/appmesh/user/${username}/passwd`, body);
993
- return response.status === 200;
1128
+ await this._request("post", `/appmesh/user/${username}/passwd`, body);
1129
+ return true;
994
1130
  }
995
1131
 
996
1132
  /**
997
1133
  * Add new user
998
1134
  * @param {string} username - Username
999
1135
  * @param {Object} userData - User definition
1000
- * @returns {Promise<boolean>} Success status
1136
+ * @returns {Promise<boolean>} true on success; failures throw AppMeshError
1001
1137
  */
1002
1138
  async add_user(username, userData) {
1003
- const response = await this._request("put", `/appmesh/user/${username}`, userData);
1004
- return response.status === 200;
1139
+ await this._request("put", `/appmesh/user/${username}`, userData);
1140
+ return true;
1005
1141
  }
1006
1142
 
1007
1143
  /**
1008
1144
  * Delete user
1009
1145
  * @param {string} username - Username
1010
- * @returns {Promise<boolean>} Success status
1146
+ * @returns {Promise<boolean>} true on success; failures throw AppMeshError
1011
1147
  */
1012
1148
  async delete_user(username) {
1013
- const response = await this._request("delete", `/appmesh/user/${username}`);
1014
- return response.status === 200;
1149
+ await this._request("delete", `/appmesh/user/${username}`);
1150
+ return true;
1015
1151
  }
1016
1152
 
1017
1153
  /**
1018
1154
  * Lock user account
1019
1155
  * @param {string} username - Username
1020
- * @returns {Promise<boolean>} Success status
1156
+ * @returns {Promise<boolean>} true on success; failures throw AppMeshError
1021
1157
  */
1022
1158
  async lock_user(username) {
1023
- const response = await this._request("post", `/appmesh/user/${username}/lock`);
1024
- return response.status === 200;
1159
+ await this._request("post", `/appmesh/user/${username}/lock`);
1160
+ return true;
1025
1161
  }
1026
1162
 
1027
1163
  /**
1028
1164
  * Unlock user account
1029
1165
  * @param {string} username - Username
1030
- * @returns {Promise<boolean>} Success status
1166
+ * @returns {Promise<boolean>} true on success; failures throw AppMeshError
1031
1167
  */
1032
1168
  async unlock_user(username) {
1033
- const response = await this._request("post", `/appmesh/user/${username}/unlock`);
1034
- return response.status === 200;
1169
+ await this._request("post", `/appmesh/user/${username}/unlock`);
1170
+ return true;
1035
1171
  }
1036
1172
 
1037
1173
  /**
@@ -1092,34 +1228,64 @@ class AppMeshClient {
1092
1228
  * Update or add role
1093
1229
  * @param {string} roleName - Role name
1094
1230
  * @param {Object} rolePermissionJson - Permission IDs
1095
- * @returns {Promise<boolean>} Success status
1231
+ * @returns {Promise<boolean>} true on success; failures throw AppMeshError
1096
1232
  */
1097
1233
  async update_role(roleName, rolePermissionJson) {
1098
- const response = await this._request("post", `/appmesh/role/${roleName}`, rolePermissionJson);
1099
- return response.status === 200;
1234
+ await this._request("post", `/appmesh/role/${roleName}`, rolePermissionJson);
1235
+ return true;
1100
1236
  }
1101
1237
 
1102
1238
  /**
1103
1239
  * Delete role
1104
1240
  * @param {string} roleName - Role name
1105
- * @returns {boolean} Success status
1241
+ * @returns {Promise<boolean>} true on success; failures throw AppMeshError
1106
1242
  */
1107
1243
  async delete_role(roleName) {
1108
- const response = await this._request("delete", `/appmesh/role/${roleName}`);
1109
- return response.status === 200;
1244
+ await this._request("delete", `/appmesh/role/${roleName}`);
1245
+ return true;
1110
1246
  }
1111
1247
 
1112
1248
  /**
1113
1249
  * Get raw Prometheus metrics text from the server.
1114
1250
  * @returns {Promise<string>} Metrics text
1115
1251
  */
1116
- async metrics() {
1252
+ async get_metrics() {
1117
1253
  const response = await this._request("get", "/appmesh/metrics", null, {
1118
1254
  config: { responseType: "text" }
1119
1255
  });
1120
1256
  return response.data;
1121
1257
  }
1122
1258
 
1259
+ /**
1260
+ * Event subscription is only available over the TCP transport.
1261
+ * @throws {AppMeshError} Always — use AppMeshClientTCP for event subscriptions.
1262
+ */
1263
+ async subscribe() {
1264
+ throw new AppMeshError("subscribe requires the TCP client (AppMeshClientTCP, imported from 'appmesh/tcp'); the HTTP client does not support event subscriptions");
1265
+ }
1266
+
1267
+ /**
1268
+ * Event subscription is only available over the TCP transport.
1269
+ * @throws {AppMeshError} Always — use AppMeshClientTCP for event subscriptions.
1270
+ */
1271
+ async unsubscribe() {
1272
+ throw new AppMeshError("unsubscribe requires the TCP client (AppMeshClientTCP, imported from 'appmesh/tcp'); the HTTP client does not support event subscriptions");
1273
+ }
1274
+
1275
+ /**
1276
+ * Perform a raw App Mesh REST request using this client's transport, auth and error handling.
1277
+ * Stable seam for worker-role wrappers (AppMeshWorker) and advanced callers.
1278
+ * @param {string} method - HTTP method (get, post, put, delete, ...)
1279
+ * @param {string} path - Endpoint path (e.g. "/appmesh/applications")
1280
+ * @param {Object|string|Buffer} [body=null] - Request payload
1281
+ * @param {Object} [options={}] - Request options: { headers, params, config }
1282
+ * @returns {Promise<any>} Response object ({ status, headers, data, ... })
1283
+ * @throws {AppMeshError} If the request fails (non-2xx)
1284
+ */
1285
+ async request(method, path, body = null, options = {}) {
1286
+ return this._request(method, path, body, options);
1287
+ }
1288
+
1123
1289
  /**
1124
1290
  * Generate common request headers
1125
1291
  * @private
@@ -1177,7 +1343,7 @@ class AppMeshClient {
1177
1343
  await _httpsReady;
1178
1344
  if (_https) {
1179
1345
  this._client.defaults.httpsAgent = new _https.Agent({
1180
- ...(this._sslConfig || { rejectUnauthorized: false }),
1346
+ ..._resolveSslOptions(this._sslConfig),
1181
1347
  keepAlive: true,
1182
1348
  keepAliveMsecs: 3000
1183
1349
  });
@@ -1202,10 +1368,10 @@ class AppMeshClient {
1202
1368
 
1203
1369
  const response = await this._client(requestConfig);
1204
1370
  if (response.status !== 200) {
1205
- if (path === "/appmesh/self/logoff") {
1206
- return response; // Allow logoff to "fail" gracefully
1207
- }
1208
1371
  const errMsg = this._extractErrorMessage(response.data);
1372
+ if (response.status === CONSTANTS.HTTP_STATUS_PRECONDITION_REQUIRED) {
1373
+ throw new TotpRequiredError(errMsg, response.data);
1374
+ }
1209
1375
  throw new AppMeshError(errMsg, response.status, response.data);
1210
1376
  }
1211
1377
 
@@ -1218,6 +1384,15 @@ class AppMeshClient {
1218
1384
 
1219
1385
  // Join all cookies with semicolon separator
1220
1386
  this._client.defaults.headers.Cookie = cookies.join('; ');
1387
+
1388
+ // Keep the in-memory token store in sync with the server-issued cookie
1389
+ // (login, renew, authenticate, TOTP setup/validate)
1390
+ const authCookie = cookies
1391
+ .map(c => c.split(';')[0].trim())
1392
+ .find(c => c.startsWith('appmesh_auth_token='));
1393
+ if (authCookie) {
1394
+ this._handleTokenUpdate(authCookie.substring('appmesh_auth_token='.length));
1395
+ }
1221
1396
  }
1222
1397
 
1223
1398
  return response;
@@ -1225,10 +1400,6 @@ class AppMeshClient {
1225
1400
  if (error instanceof AppMeshError && error.statusCode === CONSTANTS.HTTP_STATUS_PRECONDITION_REQUIRED) {
1226
1401
  throw error;
1227
1402
  }
1228
- if (path === "/appmesh/self/logoff") {
1229
- // console.log("Logoff error:", error.message);
1230
- return;
1231
- }
1232
1403
  throw this.onError(error);
1233
1404
  }
1234
1405
  }
@@ -1332,11 +1503,14 @@ class AppRun {
1332
1503
  }
1333
1504
 
1334
1505
  /**
1335
- * Context manager for forward host override to self._client
1506
+ * Context manager for forward host override to self._client.
1507
+ * Note: this temporarily mutates the shared client's forwardingHost for the duration of the
1508
+ * callback — do not run concurrent requests on the same client that rely on a different
1509
+ * forwarding host.
1336
1510
  * @param {function} callback - Function to execute within the forward host context
1337
1511
  * @returns {Promise<*>} Result of the callback function
1338
1512
  */
1339
- async withForwardingHost(callback) {
1513
+ async with_forwarding_host(callback) {
1340
1514
  const originalValue = this._client.forwardingHost;
1341
1515
  this._client.forwardingHost = this._forwardingHost;
1342
1516
  try {
@@ -1353,12 +1527,12 @@ class AppRun {
1353
1527
  * @returns {Promise<number|null>} Return exit code if process finished, return null for timeout or exception
1354
1528
  */
1355
1529
  async wait(stdoutHandler = defaultOutputHandler, timeout = 0) {
1356
- return this.withForwardingHost(() =>
1530
+ return this.with_forwarding_host(() =>
1357
1531
  this._client.wait_for_async_run(this, stdoutHandler, timeout)
1358
1532
  );
1359
1533
  }
1360
1534
  }
1361
1535
 
1362
1536
  // Export the main classes
1363
- export { AppMeshClient, AppOutput, AppRun, AppMeshError };
1537
+ export { AppMeshClient, AppOutput, AppRun, AppMeshError, TotpRequiredError, AppRemovedError, TransportDisconnectedError, DEFAULT_CA_FILE };
1364
1538
  export default AppMeshClient;