3xui-api-client 3.1.2 → 3.2.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/CHANGELOG.md CHANGED
@@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [3.2.0] - 2026-08-26
9
+
10
+ ### Added
11
+ - 🆕 **`testOutbound(outbound, options)`** - Test connectivity through an outbound configuration via `/panel/api/xray/testOutbound`. The endpoint reads its body as `application/x-www-form-urlencoded` rather than JSON (a JSON body always fails with `"outbound parameter is required"`, regardless of field name or nesting) - the `outbound` field must hold the JSON-stringified outbound object. Accepts an object or a pre-stringified outbound, plus optional `allOutbounds` (to resolve `sockopt.dialerProxy` dependencies between outbounds) and `mode`. `freedom`/`blackhole`-type outbounds return `success: false` with a clear `error` since there's nothing to dial through - that's expected panel behavior, not a request failure. Reverse-engineered and verified against `ghcr.io/mhsanaei/3x-ui:v3.7.0`.
12
+
13
+ ### Fixed
14
+ - 🔴 **Forced re-login could fail with a masking `403`** - When `_request()` classified a response as a stale session (`401`/`404`/HTML login page) and forced a re-login via `_retryAfterRelogin`, the axios instance still carried the *previous* session's cookie as a default header. Some panels' `/csrf-token` endpoint does not reissue a `Set-Cookie` when a cookie is already attached to the request, so `_getCsrfToken()` found nothing fresh to pair with the CSRF token and returned `null`. The retried `/login` POST then went out without `X-CSRF-Token` and the panel rejected it with `403` — masking the original error (e.g. a genuine `404`) behind an unrelated "login failed" exception. `login(forceRefresh)` now clears the stale cookie/CSRF token before the handshake, so a forced re-login always starts from a clean, anonymous state. Found via local Docker testing against `ghcr.io/mhsanaei/3x-ui:v3.7.0`.
15
+ - 🔴 **`addClientWithCredentials`/`updateClientWithCredentials` were broken on v3.x panels** - Both routed through the legacy `/panel/api/inbounds/addClient` and `/panel/api/inbounds/updateClient/:id` endpoints, which 3x-ui v3.x removed (see the Legacy API note below and `CLAUDE.md`). `addClientWithCredentials` threw an unhandled `404`; `updateClientWithCredentials` additionally called `JSON.parse()` unconditionally on `inbound.settings`, which v3.7.0+ panels return as an already-parsed object (not a JSON string), throwing `"[object Object]" is not valid JSON` before even reaching the dead route. Both now route through the Modern Client API (`addModernClient`/`/panel/api/clients/update/:email`), handle `settings` as either a string or object, and — since the Modern update endpoint replaces the whole client row rather than patching it — merge over the client's full current record (fetched via `getClient`) so untouched fields like `flow` are no longer silently cleared. The merged payload also drops the read-only numeric `id` and the `allowedIPs` field (string on read, array on write, for WireGuard peers), both of which the update endpoint rejects if round-tripped verbatim. `updateClientWithCredentials` now matches clients by UUID (`id`) or Trojan/Shadowsocks `password`, not just `id`. Found via local Docker testing against `ghcr.io/mhsanaei/3x-ui:v3.7.0`.
16
+ - 🔴 **`importDB` always failed with `403`** - It bypassed `_request()` entirely and called `this.api.post()` directly (to dodge `_request()`'s forced `Content-Type: application/json`, which collides with a multipart boundary), so it never got the `X-CSRF-Token` header this panel requires on non-GET requests. It now routes through `_request()`, passing the `FormData`'s own multipart headers as `extraHeaders` so they override the default JSON content type while still getting CSRF attachment and session recovery. Verified end-to-end (backup via the fixed `getDb()` below, successful re-import) against `ghcr.io/mhsanaei/3x-ui:v3.7.0`.
17
+ - 🔴 **`getDb()` silently corrupted the downloaded database** - The panel streams the raw SQLite file directly rather than wrapping it in the standard `{success, msg, obj}` envelope, and without `responseType: 'arraybuffer'`, axios's default UTF-8 text decoding is lossy for arbitrary binary content - confirmed on a live panel: a real DB download came back 10 bytes short and byte-different from the raw response, and the *pre-existing* validation logic (which assumed the envelope shape) threw `getDb: Invalid response format` on every call, meaning this method has likely never worked. It now forces `responseType: 'arraybuffer'` and decodes with latin1 (`'binary'`), which round-trips every byte value 1:1; verified byte-identical against a raw `arraybuffer` request. `_request()`/`_retryAfterRelogin()` gained an optional 5th `requestOptions` parameter to support this (and any future binary endpoint).
18
+ - 🐛 **`package.json`'s `files` array listed two nonexistent files** (`SECURITY.md`, `USAGE_EXAMPLES.md`) - removed. Confirmed via `npm pack --dry-run` that they were never actually in the published tarball; `publint` and a strict `tsc --noEmit` type-check of `index.d.ts` are both now part of the release checklist and pass clean.
19
+
20
+ ### Changed
21
+ - ⬆️ **Minimum Node.js raised from 14/16 to 18** - `package.json engines.node` was inconsistently `>=16.0.0` while `README.md` claimed `>=14.0.0`; both now say `>=18.0.0`, matching the CI test matrix (`ci.yml`/`release.yml` now both test Node 18/20/22, dropping the long-EOL Node 16). `engines.npm` raised from `>=7.0.0` to `>=9.0.0` to match.
22
+ - ⬆️ **Dependencies updated to latest**: `axios` ^1.10.0 → ^1.20.0, `jest` ^29.7.0 → ^30.4.2, `eslint` ^9.0.0 → ^10.9.1, `@eslint/js` ^9.0.0 → ^10.9.1 (aligned with `eslint`). ESLint 10's stricter `recommended` config flagged 3 spots re-throwing an error without `{ cause }` - fixed, so the original error is now preserved for debugging instead of discarded.
23
+ - 🗑️ Removed **`dotenv`** and **`chai`** dev dependencies - both were unused (`dotenv`: no `require` anywhere in the codebase; `chai`: no assertions written against it, tests use Jest's built-in `expect`).
24
+
8
25
  ## [3.1.2] - 2026-06-30
9
26
 
10
27
  ### Fixed
@@ -216,7 +233,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
216
233
  ## [Unreleased]
217
234
 
218
235
  ### Planned
219
- - GitHub Actions CI/CD pipeline
220
236
  - Automated semantic releases
221
237
  - Enhanced unit test coverage
222
238
  - Performance benchmarking tools
package/README.md CHANGED
@@ -31,13 +31,13 @@ This library is available through **[Context7 MCP](https://context7.com/iamhelit
31
31
 
32
32
  ## Features
33
33
 
34
- - ✅ **Dual Panel Support** - Works with both modern (React, v2.x+) and legacy (Vue, v1.x) 3x-ui panels
34
+ - ✅ **Dual Panel Support** - Works with both modern (React, v3.x+) and legacy (Vue, v2.x) 3x-ui panels
35
35
  - ✅ **Auto-Detection** - Automatically detects panel version and uses correct endpoints
36
36
  - ✅ **API Token Authentication** - Support for API token auth (3x-ui v3.0.2+) and cookie-based login
37
37
  - ✅ **Automatic Credential Generation** - Built-in UUID, password, and key pair generators
38
38
  - ✅ **Session Management** - Automatic login, session caching, and expiry handling
39
39
  - ✅ **Security** - Input validation, secure headers, rate limiting, and error sanitization
40
- - ✅ **Modern API Support** - Complete modern API (v2.x+) with advanced client management
40
+ - ✅ **Modern API Support** - Complete modern API (v3.x+) with advanced client management
41
41
  - ✅ **Legacy API Support** - Full backward compatibility with legacy API methods
42
42
  - ✅ **TypeScript Definitions** - Complete type definitions for IDE support
43
43
  - ✅ **124 API Methods** - Comprehensive coverage of all 3x-ui panel operations
@@ -90,8 +90,8 @@ const client = new ThreeXUI('https://your-3xui-server.com', 'username', 'passwor
90
90
  ```
91
91
 
92
92
  **Supported Panel Types:**
93
- - ✅ **Modern Panels** - React-based, v2.x+ with `/panel/api/*` endpoints
94
- - ✅ **Legacy Panels** - Vue-based, v1.x with `/login` endpoint
93
+ - ✅ **Modern Panels** - React-based, v3.x+ with `/panel/api/*` endpoints
94
+ - ✅ **Legacy Panels** - Vue-based, v2.x with `/login` endpoint
95
95
  - ✅ **Auto-Detection** - Tries modern first, falls back to legacy if needed
96
96
  - ✅ **Session Caching** - Detected version is cached for faster subsequent logins
97
97
 
@@ -99,7 +99,7 @@ See [PANEL-VERSION-SUPPORT.md](./PANEL-VERSION-SUPPORT.md) for detailed version
99
99
 
100
100
  ## API Methods
101
101
 
102
- ### Client Management (Modern API - v2.x+)
102
+ ### Client Management (Modern API - v3.x+)
103
103
 
104
104
  #### Read Operations
105
105
  - `getClients()` - Get all clients
@@ -218,6 +218,7 @@ See [PANEL-VERSION-SUPPORT.md](./PANEL-VERSION-SUPPORT.md) for detailed version
218
218
  - `getXrayConfig()` - Get Xray configuration
219
219
  - `updateXrayConfig(config)` - Update Xray configuration
220
220
  - `manageWarp(action, data)` - Manage WARP settings
221
+ - `testOutbound(outbound, options)` - Test connectivity through an outbound configuration
221
222
  - `getOutboundsTraffic()` - Get outbound traffic statistics
222
223
  - `resetOutboundsTraffic()` - Reset outbound traffic
223
224
  - `getXrayResult()` - Get Xray execution result
@@ -279,7 +280,7 @@ For comprehensive guides, examples, and implementation patterns, visit our **[Wi
279
280
 
280
281
  ## Requirements
281
282
 
282
- - Node.js >= 14.0.0
283
+ - Node.js >= 18.0.0
283
284
  - 3x-ui panel v2.0+ (or v3.0.2+ for API token authentication)
284
285
  - API access enabled on your 3x-ui server
285
286
 
package/index.d.ts CHANGED
@@ -59,6 +59,13 @@ declare module '3xui-api-client' {
59
59
  enable?: boolean;
60
60
  }
61
61
 
62
+ /**
63
+ * Raw config for `addClient`/`updateClient`. `settings` is sent to the panel exactly
64
+ * as given — any `totalGB` inside it must already be in BYTES (the panel's field is
65
+ * named totalGB but stores bytes). This is NOT auto-converted, unlike
66
+ * `addClientWithCredentials`/`updateClientWithCredentials`/`ModernClient.totalGB`,
67
+ * which accept gigabytes. See https://github.com/iamhelitha/3xui-api-client/issues/5.
68
+ */
62
69
  export interface ClientConfig {
63
70
  id: number;
64
71
  settings: string;
@@ -573,10 +580,17 @@ declare module '3xui-api-client' {
573
580
  updateInbound(id: number, inboundConfig: InboundConfig): Promise<any>;
574
581
  importInbounds(inbounds: InboundConfig | InboundConfig[]): Promise<any[]>;
575
582
  getLastOnline(): Promise<any>;
583
+ /** Raw passthrough — no unit conversion. See {@link ClientConfig}: any `totalGB` in `clientConfig.settings` must already be in bytes. */
576
584
  addClient(clientConfig: ClientConfig): Promise<any>;
577
585
  deleteClient(inboundId: number, clientId: string): Promise<any>;
586
+ /** Raw passthrough — no unit conversion. See {@link ClientConfig}: any `totalGB` in `clientConfig.settings` must already be in bytes. */
578
587
  updateClient(clientId: string, clientConfig: ClientConfig): Promise<any>;
579
- /** @param trafficConfig.totalGB Data limit in gigabytes (auto-converted to bytes internally) */
588
+ /**
589
+ * Raw passthrough — no unit conversion.
590
+ * @param trafficConfig.totalGB Data limit in BYTES, not gigabytes (the panel field is named
591
+ * totalGB but stores bytes). Convert yourself: `gbValue * 1024 ** 3`. For automatic
592
+ * GB->bytes conversion use `updateClientWithCredentials`/`updateModernClient` instead.
593
+ */
580
594
  updateClientTraffic(email: string, trafficConfig: { totalGB?: number; expiryTime?: number }): Promise<any>;
581
595
  deleteClientByEmail(inboundId: number, email: string): Promise<any>;
582
596
  getClientTrafficsByEmail(email: string): Promise<any>;
@@ -671,6 +685,21 @@ declare module '3xui-api-client' {
671
685
  * @param data - Additional data for the action
672
686
  */
673
687
  manageWarp(action: string, data?: Record<string, any>): Promise<any>;
688
+ /**
689
+ * Test connectivity through an outbound configuration.
690
+ * Sent as `application/x-www-form-urlencoded`, not JSON.
691
+ * `freedom`/`blackhole`-type outbounds cannot be tested (nothing to
692
+ * dial through) - the panel returns `success: false` with a clear
693
+ * `error` message for those, which is expected, not a request failure.
694
+ * @param outbound - Outbound config object (or its JSON string)
695
+ * @param options.allOutbounds - Full outbounds list, to resolve `sockopt.dialerProxy`
696
+ * dependencies between outbounds
697
+ * @param options.mode - Test mode flag (panel-defined; observed value: 'http')
698
+ */
699
+ testOutbound(
700
+ outbound: object | string,
701
+ options?: { allOutbounds?: object[]; mode?: string }
702
+ ): Promise<ModernApiResponse<{ tag: string; success: boolean; delay: number; error: string; mode: string }>>;
674
703
  getOutboundsTraffic(): Promise<any>;
675
704
  resetOutboundsTraffic(): Promise<any>;
676
705
  getXrayResult(): Promise<any>;
package/index.js CHANGED
@@ -196,6 +196,22 @@ class ThreeXUI {
196
196
  }
197
197
 
198
198
  try {
199
+ // On a forced re-login (e.g. stale-session recovery in
200
+ // _retryAfterRelogin), the axios instance still carries the
201
+ // previous, now-stale session cookie as a default header. Some
202
+ // panels' /csrf-token endpoint does not reissue a Set-Cookie when
203
+ // a session cookie is already attached to the request, which
204
+ // makes _getCsrfToken() below return null (no fresh cookie to
205
+ // pair with the token) and sends /login without the
206
+ // X-CSRF-Token it requires - failing with a confusing 403 that
207
+ // masks the original error. Clear the stale cookie first so the
208
+ // CSRF handshake starts from a clean, anonymous state.
209
+ if (forceRefresh) {
210
+ this.cookie = null;
211
+ this.csrfToken = null;
212
+ delete this.api.defaults.headers.Cookie;
213
+ }
214
+
199
215
  const params = new URLSearchParams();
200
216
  params.append('username', this.username);
201
217
  params.append('password', this.password);
@@ -392,7 +408,15 @@ class ThreeXUI {
392
408
  return this._request('post', '/getTwoFactorEnable');
393
409
  }
394
410
 
395
- async _request(method, path, data = {}, extraHeaders = {}) {
411
+ /**
412
+ * @param {string} method
413
+ * @param {string} path
414
+ * @param {*} data
415
+ * @param {Object} extraHeaders
416
+ * @param {Object} [requestOptions] - Extra axios request config to merge in verbatim
417
+ * (e.g. `{ responseType: 'arraybuffer' }` for binary downloads like `getDb()`).
418
+ */
419
+ async _request(method, path, data = {}, extraHeaders = {}, requestOptions = {}) {
396
420
  // Check session validity first with mutex protection if token is not provided
397
421
  if (!this.token) {
398
422
  if (!this.loginMutex && this.sessionManager && !await this.sessionManager.hasValidSession(this.baseURL, this.username)) {
@@ -407,7 +431,8 @@ class ThreeXUI {
407
431
  method,
408
432
  url: path,
409
433
  data,
410
- headers: { ...this._buildRequestHeaders(method), ...extraHeaders }
434
+ headers: { ...this._buildRequestHeaders(method), ...extraHeaders },
435
+ ...requestOptions
411
436
  });
412
437
 
413
438
  // Some panels answer a stale/expired session with HTTP 200 plus an
@@ -415,7 +440,7 @@ class ThreeXUI {
415
440
  // 401/404. For cookie auth, treat that HTML body where JSON was
416
441
  // expected as a lost session and recover transparently.
417
442
  if (!this.token && this._looksLikeLoginPage(response.data)) {
418
- return await this._retryAfterRelogin(method, path, data, extraHeaders);
443
+ return await this._retryAfterRelogin(method, path, data, extraHeaders, requestOptions);
419
444
  }
420
445
 
421
446
  // Reset retry counter on successful request
@@ -429,7 +454,7 @@ class ThreeXUI {
429
454
  // resource (the token never goes "stale"), so surface it
430
455
  // unchanged.
431
456
  if (error.response && error.response.status === 401) {
432
- throw new Error('API Token is invalid or expired. Please check your credentials.');
457
+ throw new Error('API Token is invalid or expired. Please check your credentials.', { cause: error });
433
458
  }
434
459
  throw error;
435
460
  }
@@ -437,7 +462,7 @@ class ThreeXUI {
437
462
  // are inconsistent about the signal (401, 404, or an HTML login
438
463
  // page), so we recover the same way for each: one bounded,
439
464
  // backed-off forced re-login + retry.
440
- return await this._retryAfterRelogin(method, path, data, extraHeaders);
465
+ return await this._retryAfterRelogin(method, path, data, extraHeaders, requestOptions);
441
466
  }
442
467
  throw error;
443
468
  }
@@ -493,7 +518,7 @@ class ThreeXUI {
493
518
  * @param {Object} extraHeaders - Additional headers to merge
494
519
  * @returns {Promise<*>} The retried response data
495
520
  */
496
- async _retryAfterRelogin(method, path, data, extraHeaders) {
521
+ async _retryAfterRelogin(method, path, data, extraHeaders, requestOptions = {}) {
497
522
  // Bound the recovery to a single retry budget to avoid login storms
498
523
  // (e.g. tripping fail2ban) when many instances race to re-authenticate.
499
524
  if (this.loginRetryCount >= this.maxLoginRetries) {
@@ -507,7 +532,8 @@ class ThreeXUI {
507
532
  method,
508
533
  url: path,
509
534
  data,
510
- headers: { ...this._buildRequestHeaders(method), ...extraHeaders }
535
+ headers: { ...this._buildRequestHeaders(method), ...extraHeaders },
536
+ ...requestOptions
511
537
  });
512
538
  this.loginRetryCount = 0;
513
539
  return response.data;
@@ -668,23 +694,22 @@ class ThreeXUI {
668
694
  */
669
695
  async addClientWithCredentials(inboundId, protocol, options = {}) {
670
696
  const credentials = this.generateCredentials(protocol, options);
671
- const convertedOptions = convertBandwidthFields(options);
672
-
673
- const clientConfig = {
674
- id: inboundId,
675
- settings: JSON.stringify({
676
- clients: [{
677
- ...credentials,
678
- enable: true,
679
- expiryTime: convertedOptions.expiryTime || 0,
680
- limitIp: convertedOptions.limitIp || 0,
681
- totalGB: convertedOptions.totalGB || 0,
682
- subId: convertedOptions.subId || this.generateUUID()
683
- }]
684
- })
697
+
698
+ // Routed through the Modern Client API (addModernClient), which
699
+ // does its own GB->bytes conversion - pass totalGB in GB here, not
700
+ // pre-converted, to avoid converting it twice. The legacy
701
+ // /panel/api/inbounds/addClient route this used to call is removed
702
+ // on v3.x panels (see CLAUDE.md).
703
+ const client = {
704
+ ...credentials,
705
+ enable: true,
706
+ expiryTime: options.expiryTime || 0,
707
+ limitIp: options.limitIp || 0,
708
+ totalGB: options.totalGB || 0,
709
+ subId: options.subId || this.generateUUID()
685
710
  };
686
711
 
687
- const result = await this.addClient(clientConfig);
712
+ const result = await this.addModernClient({ inboundIds: [inboundId], client });
688
713
  return {
689
714
  ...result,
690
715
  credentials,
@@ -701,56 +726,65 @@ class ThreeXUI {
701
726
  */
702
727
  async updateClientWithCredentials(clientId, inboundId, options = {}) {
703
728
  try {
704
- // First, get the current inbound to obtain all existing clients
729
+ // First, get the current inbound to locate the client by its
730
+ // VLESS/VMess UUID (id) or Trojan/Shadowsocks password.
705
731
  const inboundData = await this.getInbound(inboundId);
706
732
 
707
733
  if (!inboundData.success || !inboundData.obj) {
708
734
  throw new Error(`Failed to get inbound ${inboundId} for client update`);
709
735
  }
710
736
 
711
- // Parse existing settings to get all clients
712
- const currentSettings = JSON.parse(inboundData.obj.settings);
737
+ // `settings` comes back as a JSON string on some panel versions
738
+ // and as an already-parsed object on others (v3.7.0+) - handle both.
739
+ const currentSettings = typeof inboundData.obj.settings === 'string'
740
+ ? JSON.parse(inboundData.obj.settings)
741
+ : inboundData.obj.settings;
713
742
  const existingClients = currentSettings.clients || [];
714
743
 
715
- // Find the client to update
716
- const clientIndex = existingClients.findIndex(client => client.id === clientId);
744
+ const clientIndex = existingClients.findIndex(
745
+ client => client.id === clientId || client.password === clientId
746
+ );
717
747
  if (clientIndex === -1) {
718
748
  throw new Error(`Client with ID ${clientId} not found in inbound ${inboundId}`);
719
749
  }
750
+ const email = existingClients[clientIndex].email;
751
+ if (!email) {
752
+ throw new Error(`Client with ID ${clientId} has no email - cannot update via the Modern Client API`);
753
+ }
754
+
755
+ // The Modern Client API's update endpoint replaces the entire
756
+ // client row rather than patching it, so every field must be
757
+ // resent or it gets silently cleared (e.g. `flow`). Load the
758
+ // authoritative full record first.
759
+ const fullClient = await this.getClient(email);
760
+ if (!fullClient.success || !fullClient.obj || !fullClient.obj.client) {
761
+ throw new Error(`Failed to load full client record for ${email}`);
762
+ }
763
+ const current = fullClient.obj.client;
720
764
 
721
765
  // Convert bandwidth fields (GB to bytes)
722
766
  const convertedOptions = convertBandwidthFields(options);
723
767
 
724
- // Convert user-friendly options to API format
768
+ // Convert user-friendly options to API format, merged over the
769
+ // full existing record so untouched fields are preserved as-is.
770
+ // `id` (the read-only numeric row id from getClient()) and
771
+ // `allowedIPs` (typed as a string on read but an array on
772
+ // write, for WireGuard peers) are dropped: resending either as
773
+ // read from GET fails the update endpoint's own validation.
725
774
  const processedOptions = {
726
- email: convertedOptions.email || existingClients[clientIndex].email,
727
- limitIp: convertedOptions.limitIp !== undefined ? convertedOptions.limitIp : existingClients[clientIndex].limitIp,
728
- totalGB: convertedOptions.totalGB !== undefined ? convertedOptions.totalGB : existingClients[clientIndex].totalGB,
729
- expiryTime: convertedOptions.expiryDays ? Date.now() + (convertedOptions.expiryDays * 24 * 60 * 60 * 1000) : existingClients[clientIndex].expiryTime,
730
- enable: convertedOptions.enable !== undefined ? convertedOptions.enable : existingClients[clientIndex].enable,
731
- flow: convertedOptions.flow || existingClients[clientIndex].flow,
732
- encryption: convertedOptions.encryption || existingClients[clientIndex].encryption || 'none',
733
- subId: convertedOptions.subId || existingClients[clientIndex].subId
775
+ ...current,
776
+ email: convertedOptions.email || current.email,
777
+ limitIp: convertedOptions.limitIp !== undefined ? convertedOptions.limitIp : current.limitIp,
778
+ totalGB: convertedOptions.totalGB !== undefined ? convertedOptions.totalGB : current.totalGB,
779
+ expiryTime: convertedOptions.expiryDays ? Date.now() + (convertedOptions.expiryDays * 24 * 60 * 60 * 1000) : current.expiryTime,
780
+ enable: convertedOptions.enable !== undefined ? convertedOptions.enable : current.enable,
781
+ flow: convertedOptions.flow || current.flow,
782
+ subId: convertedOptions.subId || current.subId
734
783
  };
784
+ delete processedOptions.id;
785
+ delete processedOptions.allowedIPs;
735
786
 
736
- // Update the specific client while preserving others
737
- existingClients[clientIndex] = {
738
- ...existingClients[clientIndex],
739
- ...processedOptions
740
- };
741
-
742
- // Prepare the complete settings with all clients
743
- const updatedSettings = {
744
- ...currentSettings,
745
- clients: existingClients
746
- };
747
-
748
- const clientConfig = {
749
- id: inboundId,
750
- settings: JSON.stringify(updatedSettings)
751
- };
752
-
753
- const result = await this.updateClient(clientId, clientConfig);
787
+ const result = await this._request('post', `/panel/api/clients/update/${encodeURIComponent(email)}`, processedOptions);
754
788
  return {
755
789
  ...result,
756
790
  updatedOptions: processedOptions,
@@ -1181,6 +1215,14 @@ class ThreeXUI {
1181
1215
  }
1182
1216
 
1183
1217
  // Clients
1218
+ /**
1219
+ * Add a client (raw passthrough — no unit conversion).
1220
+ * `clientConfig.settings` is sent to the panel exactly as given, so any `totalGB`
1221
+ * inside it must already be in BYTES (the panel stores this field in bytes despite
1222
+ * its name). For a GB input with automatic conversion, use `addClientWithCredentials`
1223
+ * or `addModernClient` instead. See issue #5 for background.
1224
+ * @param {ClientConfig} clientConfig
1225
+ */
1184
1226
  addClient(clientConfig) {
1185
1227
  // Validate client configuration for security
1186
1228
  const validatedConfig = InputValidator.validateClientConfig(clientConfig);
@@ -1191,6 +1233,14 @@ class ThreeXUI {
1191
1233
  return this._request('post', `/panel/api/inbounds/${inboundId}/delClient/${clientId}`);
1192
1234
  }
1193
1235
 
1236
+ /**
1237
+ * Update a client (raw passthrough — no unit conversion).
1238
+ * Same caveat as `addClient`: any `totalGB` inside `clientConfig.settings` must
1239
+ * already be in BYTES. Use `updateClientWithCredentials`/`updateModernClient` for
1240
+ * automatic GB->bytes conversion.
1241
+ * @param {string} clientId
1242
+ * @param {ClientConfig} clientConfig
1243
+ */
1194
1244
  updateClient(clientId, clientConfig) {
1195
1245
  // Validate client configuration for security
1196
1246
  const validatedConfig = InputValidator.validateClientConfig(clientConfig);
@@ -1198,9 +1248,12 @@ class ThreeXUI {
1198
1248
  }
1199
1249
 
1200
1250
  /**
1201
- * Update client traffic limit and expiry by email
1251
+ * Update client traffic limit and expiry by email (raw passthrough — no unit conversion).
1202
1252
  * @param {string} email - Client email
1203
- * @param {Object} trafficConfig - Traffic configuration (totalGB, expiryTime)
1253
+ * @param {Object} trafficConfig - Traffic configuration
1254
+ * @param {number} [trafficConfig.totalGB] - Data limit in BYTES, not gigabytes (the panel
1255
+ * field is named totalGB but stores bytes). Convert yourself: `gbValue * 1024 ** 3`.
1256
+ * @param {number} [trafficConfig.expiryTime]
1204
1257
  */
1205
1258
  updateClientTraffic(email, trafficConfig) {
1206
1259
  return this._request('post', `/panel/api/inbounds/updateClientTraffic/${email}`, trafficConfig);
@@ -1335,11 +1388,33 @@ class ThreeXUI {
1335
1388
 
1336
1389
  /**
1337
1390
  * Download database
1338
- * @returns {Promise<string>} Raw SQLite database file content as a string (starts with "SQLite format 3 ..."), not a Buffer
1391
+ * @returns {Promise<ModernApiResponse<string>>} `obj` is the raw SQLite database file
1392
+ * content as a binary-safe string (starts with "SQLite format 3 ..."), not a Buffer.
1393
+ * Pass it to `Buffer.from(obj, 'binary')` to get the actual file bytes back losslessly.
1339
1394
  */
1340
1395
  async getDb() {
1341
- const response = await this._request('get', '/panel/api/server/getDb');
1342
- // Add response validation
1396
+ // The panel streams the raw SQLite file directly rather than wrapping it in
1397
+ // the standard {success, msg, obj} envelope every other endpoint uses, and the
1398
+ // response is arbitrary binary data - axios's default text decoding (UTF-8) is
1399
+ // lossy for that (confirmed: a real DB download came back 10 bytes short and
1400
+ // byte-different from the raw response). Force responseType: 'arraybuffer' to
1401
+ // get the exact bytes, then use latin1 ('binary'), which round-trips every byte
1402
+ // value 1:1, to produce the string this method's signature promises.
1403
+ const response = await this._request('get', '/panel/api/server/getDb', {}, {}, { responseType: 'arraybuffer' });
1404
+ if (Buffer.isBuffer(response) || response instanceof ArrayBuffer || ArrayBuffer.isView(response)) {
1405
+ const buf = Buffer.from(response);
1406
+ if (buf.length === 0) {
1407
+ throw new Error('getDb: Response missing database content');
1408
+ }
1409
+ return { success: true, msg: '', obj: buf.toString('binary') };
1410
+ }
1411
+ if (typeof response === 'string') {
1412
+ if (!response) {
1413
+ throw new Error('getDb: Response missing database content');
1414
+ }
1415
+ return { success: true, msg: '', obj: response };
1416
+ }
1417
+ // Defensive fallback in case some panel fork/version does wrap it.
1343
1418
  if (!response || typeof response !== 'object') {
1344
1419
  throw new Error('getDb: Invalid response format');
1345
1420
  }
@@ -1416,16 +1491,19 @@ class ThreeXUI {
1416
1491
  }
1417
1492
 
1418
1493
  /**
1419
- * Import database
1494
+ * Import database (destructive - replaces the panel's entire database).
1420
1495
  * @param {FormData} formData - FormData containing the database file
1421
1496
  */
1422
- async importDB(formData) {
1423
- // Ensure authenticated before direct API call
1424
- if (!this.cookie) {
1425
- await this._ensureAuthenticated();
1426
- }
1427
- // Use direct api call to handle multipart/form-data correctly
1428
- return this.api.post('/panel/api/server/importDB', formData);
1497
+ importDB(formData) {
1498
+ // Route through _request() (not a direct api.post) so this gets the
1499
+ // same X-CSRF-Token attachment, session recovery, and stale-session
1500
+ // retry as every other write - without it, this always failed with
1501
+ // a 403 on panels that require CSRF on non-GET requests. The
1502
+ // FormData's own multipart boundary Content-Type is passed as
1503
+ // extraHeaders so it overrides _request()'s default
1504
+ // "Content-Type: application/json" for POST bodies.
1505
+ const extraHeaders = typeof formData.getHeaders === 'function' ? formData.getHeaders() : {};
1506
+ return this._request('post', '/panel/api/server/importDB', formData, extraHeaders);
1429
1507
  }
1430
1508
 
1431
1509
  // ===========================================
@@ -1555,7 +1633,7 @@ class ThreeXUI {
1555
1633
  if (this.sessionManager) {
1556
1634
  await this.sessionManager.deleteSession(this.baseURL, newUsername);
1557
1635
  }
1558
- throw new Error(`Credential update succeeded but re-authentication failed: ${error.message}`);
1636
+ throw new Error(`Credential update succeeded but re-authentication failed: ${error.message}`, { cause: error });
1559
1637
  }
1560
1638
  }
1561
1639
 
@@ -1604,7 +1682,7 @@ class ThreeXUI {
1604
1682
  try {
1605
1683
  config = JSON.parse(config);
1606
1684
  } catch (error) {
1607
- throw new Error(`updateXrayConfig: Invalid JSON: ${error.message}`);
1685
+ throw new Error(`updateXrayConfig: Invalid JSON: ${error.message}`, { cause: error });
1608
1686
  }
1609
1687
  }
1610
1688
  // Validate it's an object
@@ -1623,6 +1701,40 @@ class ThreeXUI {
1623
1701
  return this._request('post', `/panel/api/xray/warp/${action}`, data);
1624
1702
  }
1625
1703
 
1704
+ /**
1705
+ * Test connectivity through an outbound configuration.
1706
+ *
1707
+ * Sent as `application/x-www-form-urlencoded`, not JSON - a JSON body
1708
+ * always fails with "outbound parameter is required" regardless of
1709
+ * field name or nesting tried. The `outbound` field must be the
1710
+ * JSON-stringified outbound object.
1711
+ *
1712
+ * `freedom`/`blackhole`-type outbounds cannot be tested (nothing to
1713
+ * dial through) - the panel returns `success: false` with a clear
1714
+ * `error` message for those, which is expected, not a request failure.
1715
+ * @param {Object|string} outbound - Outbound config object (or its JSON string)
1716
+ * @param {Object} [options]
1717
+ * @param {Array<Object>} [options.allOutbounds] - Full outbounds list, to resolve
1718
+ * `sockopt.dialerProxy` dependencies between outbounds. Field name inferred from
1719
+ * the endpoint's own description ("optionally all outbounds") - not confirmed
1720
+ * against a config that actually needs it.
1721
+ * @param {string} [options.mode] - Test mode flag (panel-defined; observed value: 'http')
1722
+ * @returns {Promise<Object>} `{ tag, success, delay, error, mode }`
1723
+ */
1724
+ testOutbound(outbound, options = {}) {
1725
+ const params = new URLSearchParams();
1726
+ params.append('outbound', typeof outbound === 'string' ? outbound : JSON.stringify(outbound));
1727
+ if (options.allOutbounds) {
1728
+ params.append('outbounds', JSON.stringify(options.allOutbounds));
1729
+ }
1730
+ if (options.mode) {
1731
+ params.append('mode', options.mode);
1732
+ }
1733
+ return this._request('post', '/panel/api/xray/testOutbound', params.toString(), {
1734
+ 'Content-Type': 'application/x-www-form-urlencoded'
1735
+ });
1736
+ }
1737
+
1626
1738
  /**
1627
1739
  * Get outbound traffic statistics
1628
1740
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "3xui-api-client",
3
- "version": "3.1.2",
3
+ "version": "3.2.0",
4
4
  "description": "Node.js API client for the 3x-ui panel (VLESS, VMess, Trojan, Shadowsocks, WireGuard, Reality) with automatic session management, credential generation, and full REST API coverage",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -75,8 +75,8 @@
75
75
  "url": "https://github.com/sponsors/iamhelitha"
76
76
  },
77
77
  "engines": {
78
- "node": ">=16.0.0",
79
- "npm": ">=7.0.0"
78
+ "node": ">=18.0.0",
79
+ "npm": ">=9.0.0"
80
80
  },
81
81
  "os": [
82
82
  "linux",
@@ -89,19 +89,15 @@
89
89
  "src/",
90
90
  "README.md",
91
91
  "LICENSE",
92
- "CHANGELOG.md",
93
- "SECURITY.md",
94
- "USAGE_EXAMPLES.md"
92
+ "CHANGELOG.md"
95
93
  ],
96
94
  "dependencies": {
97
- "axios": "^1.10.0"
95
+ "axios": "^1.20.0"
98
96
  },
99
97
  "devDependencies": {
100
- "dotenv": "^16.5.0",
101
- "jest": "^29.7.0",
102
- "eslint": "^9.0.0",
103
- "@eslint/js": "^9.0.0",
104
- "chai": "^4.3.4"
98
+ "jest": "^30.4.2",
99
+ "eslint": "^10.9.1",
100
+ "@eslint/js": "^10.0.1"
105
101
  },
106
102
  "jest": {
107
103
  "testEnvironment": "node",