appmesh 1.2.3 → 1.3.1

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