@truenas/api-client 3.0.5 → 3.0.7

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/dist/index.cjs CHANGED
@@ -2401,6 +2401,7 @@ var AuthErrorCode = /* @__PURE__ */ ((AuthErrorCode2) => {
2401
2401
  AuthErrorCode2["OtpAuthFailed"] = "OTP_AUTH_FAILED";
2402
2402
  AuthErrorCode2["ApiKeyAuthFailed"] = "API_KEY_AUTH_FAILED";
2403
2403
  AuthErrorCode2["TokenAuthFailed"] = "TOKEN_AUTH_FAILED";
2404
+ AuthErrorCode2["LoginSuperseded"] = "LOGIN_SUPERSEDED";
2404
2405
  AuthErrorCode2["FullAdminRequired"] = "FULL_ADMIN_REQUIRED";
2405
2406
  return AuthErrorCode2;
2406
2407
  })(AuthErrorCode || {});
@@ -2439,24 +2440,70 @@ var TrueNasAuthenticator = class _TrueNasAuthenticator {
2439
2440
  this.authenticating$ = new rxjs.BehaviorSubject(false);
2440
2441
  this.credentials = { username: "", password: "", key: "" };
2441
2442
  this.sessionLifetime = _TrueNasAuthenticator.DefaultSessionLifetime;
2443
+ /**
2444
+ * Claimed by each login when it sends and checked when its answer lands: a
2445
+ * difference means another login or a logout was issued in between, so this
2446
+ * answer is stale and must not write session state. Responses on one socket
2447
+ * are not ordered, which is why arrival is not enough to make an answer
2448
+ * current.
2449
+ *
2450
+ * `logout()` bumps it without claiming one. It has no answer to guard —
2451
+ * it settles its own state at the call — and bumping is what invalidates the
2452
+ * logins already on the wire that a logout is meant to override.
2453
+ */
2454
+ this.authEpoch = 0;
2455
+ /**
2456
+ * Caller-issued logins still awaiting an answer, keyed by the epoch each
2457
+ * claimed. The value is whether its frame has been written to a socket yet.
2458
+ *
2459
+ * The auto-relogin below defers while any entry exists. It is this class
2460
+ * retrying a cached credential, not a request anyone made, so it must never
2461
+ * outrank an explicit login — and it would: `TrueNasConnection.send` holds a
2462
+ * frame through an outage rather than dropping it, so a login submitted while
2463
+ * the socket is down is still pending when `opened` fires, and a relogin
2464
+ * claiming the newer epoch has that login answered `LoginSuperseded` while the
2465
+ * client authenticates as the previously cached account.
2466
+ *
2467
+ * Keyed rather than counted, because a count cannot say *which* login a
2468
+ * removal belongs to: a login torn down by its caller would retire a slot
2469
+ * belonging to a different login that is still waiting, and the retry would
2470
+ * overtake it again. Identity also makes a double-subscribed login harmless.
2471
+ */
2472
+ this.liveCallerLogins = /* @__PURE__ */ new Map();
2473
+ /** True only while the constructor's relogin is being constructed. */
2474
+ this.reloginInProgress = false;
2475
+ this.connection.opened.subscribe((isOpen) => {
2476
+ if (!isOpen) return;
2477
+ for (const epoch of this.liveCallerLogins.keys()) {
2478
+ this.liveCallerLogins.set(epoch, true);
2479
+ }
2480
+ });
2442
2481
  this.connection.opened.pipe(
2443
2482
  rxjs.filter(
2444
- (isOpen) => !!(isOpen && this.credentials?.username && (this.credentials.password || this.credentials.key))
2483
+ (isOpen) => !!(isOpen && this.credentials?.username && (this.credentials.password || this.credentials.key) && // An explicit login already on the wire outranks this retry.
2484
+ this.liveCallerLogins.size === 0)
2445
2485
  ),
2446
2486
  rxjs.switchMap(() => {
2447
- if (this.credentials.password) {
2448
- return this.loginWithUserPass(
2487
+ this.reloginInProgress = true;
2488
+ let relogin;
2489
+ try {
2490
+ relogin = this.credentials.password ? this.loginWithUserPass(
2449
2491
  this.credentials.username,
2450
2492
  this.credentials.password
2451
- );
2493
+ ) : this.loginWithApiKey({
2494
+ username: this.credentials.username,
2495
+ key: this.credentials.key
2496
+ });
2497
+ } finally {
2498
+ this.reloginInProgress = false;
2452
2499
  }
2453
- return this.loginWithApiKey({
2454
- username: this.credentials.username,
2455
- key: this.credentials.key
2456
- });
2500
+ return relogin.pipe(rxjs.catchError(() => rxjs.EMPTY));
2457
2501
  })
2458
2502
  ).subscribe();
2459
2503
  connection.closed.subscribe(() => {
2504
+ for (const [epoch, flushed] of this.liveCallerLogins) {
2505
+ if (flushed) this.liveCallerLogins.delete(epoch);
2506
+ }
2460
2507
  this.sessionLifetime = _TrueNasAuthenticator.DefaultSessionLifetime;
2461
2508
  this.authenticated$.next(false);
2462
2509
  });
@@ -2487,7 +2534,36 @@ var TrueNasAuthenticator = class _TrueNasAuthenticator {
2487
2534
  if (!this.version || this.version.year <= legacyCutoffYear) return void 0;
2488
2535
  return { login_options: { reconnect_token: true } };
2489
2536
  }
2537
+ /**
2538
+ * Throws when another login or a logout was issued after this one was sent,
2539
+ * so a stale answer cannot be reported to its caller as a successful login.
2540
+ */
2541
+ assertNotSuperseded(sentDuring) {
2542
+ if (this.authEpoch === sentDuring) return;
2543
+ throw new AuthError(
2544
+ "LOGIN_SUPERSEDED" /* LoginSuperseded */,
2545
+ "Login was superseded by a later logout or login and was discarded."
2546
+ );
2547
+ }
2548
+ /** Claim the next epoch for a login, and record it if a caller asked for it. */
2549
+ beginLogin() {
2550
+ const callerInitiated = !this.reloginInProgress;
2551
+ const sentDuring = ++this.authEpoch;
2552
+ if (callerInitiated) {
2553
+ this.liveCallerLogins.set(sentDuring, this.connection.opened.value);
2554
+ }
2555
+ return { sentDuring, callerInitiated };
2556
+ }
2557
+ endLogin(sentDuring, callerInitiated, answered) {
2558
+ const flushed = this.liveCallerLogins.get(sentDuring);
2559
+ if (callerInitiated && (answered || flushed)) {
2560
+ this.liveCallerLogins.delete(sentDuring);
2561
+ }
2562
+ this.authenticating$.next(false);
2563
+ }
2490
2564
  loginWithUserPass(username, password) {
2565
+ const { sentDuring, callerInitiated } = this.beginLogin();
2566
+ let answered = false;
2491
2567
  const message = createJsonRpcMessage("auth.login_ex", [
2492
2568
  {
2493
2569
  mechanism: "PASSWORD_PLAIN" /* Password */,
@@ -2501,6 +2577,9 @@ var TrueNasAuthenticator = class _TrueNasAuthenticator {
2501
2577
  const messageId = message.id ?? "";
2502
2578
  return this.connection.messages().pipe(
2503
2579
  withId(messageId),
2580
+ rxjs.tap(() => {
2581
+ answered = true;
2582
+ }),
2504
2583
  rxjs.map((msg) => {
2505
2584
  if (msg.error) {
2506
2585
  const errorMessage = getApiErrorMessage(
@@ -2517,28 +2596,21 @@ var TrueNasAuthenticator = class _TrueNasAuthenticator {
2517
2596
  ),
2518
2597
  rxjs.tap((res) => {
2519
2598
  if (res.response_type === "SUCCESS" /* Success */) {
2520
- if (res.user_info?.privilege.roles.$set.includes("FULL_ADMIN" /* FullAdmin */)) {
2521
- this.credentials.username = username;
2522
- this.credentials.password = password;
2523
- this.sessionLifetime = res.user_info?.attributes?.preferences?.lifetime ?? _TrueNasAuthenticator.DefaultSessionLifetime;
2524
- this.authenticated$.next(true);
2525
- } else {
2526
- this.logout();
2527
- this.authenticated$.next(false);
2528
- throw new AuthError(
2529
- "FULL_ADMIN_REQUIRED" /* FullAdminRequired */,
2530
- "User account must have full admin privileges"
2531
- );
2532
- }
2599
+ this.assertNotSuperseded(sentDuring);
2600
+ this.credentials = { username, password, key: "" };
2601
+ this.sessionLifetime = res.user_info?.attributes?.preferences?.lifetime ?? _TrueNasAuthenticator.DefaultSessionLifetime;
2602
+ this.authenticated$.next(true);
2533
2603
  }
2534
2604
  }),
2535
2605
  rxjs.finalize(() => {
2536
- this.authenticating$.next(false);
2606
+ this.endLogin(sentDuring, callerInitiated, answered);
2537
2607
  }),
2538
2608
  rxjs.take(1)
2539
2609
  );
2540
2610
  }
2541
2611
  loginWithOtp(code) {
2612
+ const { sentDuring, callerInitiated } = this.beginLogin();
2613
+ let answered = false;
2542
2614
  const message = createJsonRpcMessage("auth.login_ex", [
2543
2615
  {
2544
2616
  mechanism: "OTP_TOKEN" /* Otp */,
@@ -2550,6 +2622,9 @@ var TrueNasAuthenticator = class _TrueNasAuthenticator {
2550
2622
  const messageId = message.id ?? "";
2551
2623
  return this.connection.messages().pipe(
2552
2624
  withId(messageId),
2625
+ rxjs.tap(() => {
2626
+ answered = true;
2627
+ }),
2553
2628
  rxjs.map((msg) => {
2554
2629
  if (msg.error) {
2555
2630
  const errorMessage = getApiErrorMessage(
@@ -2562,6 +2637,7 @@ var TrueNasAuthenticator = class _TrueNasAuthenticator {
2562
2637
  }),
2563
2638
  rxjs.tap((res) => {
2564
2639
  if (res?.response_type === "SUCCESS" /* Success */) {
2640
+ this.assertNotSuperseded(sentDuring);
2565
2641
  this.sessionLifetime = res.user_info?.attributes?.preferences?.lifetime ?? _TrueNasAuthenticator.DefaultSessionLifetime;
2566
2642
  this.authenticated$.next(true);
2567
2643
  }
@@ -2571,7 +2647,7 @@ var TrueNasAuthenticator = class _TrueNasAuthenticator {
2571
2647
  "TrueNAS authentication failed. Please verify your one-time passcode and try again."
2572
2648
  ),
2573
2649
  rxjs.finalize(() => {
2574
- this.authenticating$.next(false);
2650
+ this.endLogin(sentDuring, callerInitiated, answered);
2575
2651
  }),
2576
2652
  rxjs.take(1)
2577
2653
  );
@@ -2596,6 +2672,8 @@ var TrueNasAuthenticator = class _TrueNasAuthenticator {
2596
2672
  * reason the socket dropped in the first place.
2597
2673
  */
2598
2674
  loginWithToken(token) {
2675
+ const { sentDuring, callerInitiated } = this.beginLogin();
2676
+ let answered = false;
2599
2677
  const message = createJsonRpcMessage("auth.login_ex", [
2600
2678
  {
2601
2679
  mechanism: "TOKEN_PLAIN" /* Token */,
@@ -2608,6 +2686,9 @@ var TrueNasAuthenticator = class _TrueNasAuthenticator {
2608
2686
  const messageId = message.id ?? "";
2609
2687
  return this.connection.messages().pipe(
2610
2688
  withId(messageId),
2689
+ rxjs.tap(() => {
2690
+ answered = true;
2691
+ }),
2611
2692
  rxjs.map((msg) => {
2612
2693
  if (msg.error) {
2613
2694
  const errorMessage = getApiErrorMessage(
@@ -2634,21 +2715,13 @@ var TrueNasAuthenticator = class _TrueNasAuthenticator {
2634
2715
  }),
2635
2716
  rxjs.tap((res) => {
2636
2717
  if (res.response_type === "SUCCESS" /* Success */) {
2637
- if (res.user_info?.privilege.roles.$set.includes("FULL_ADMIN" /* FullAdmin */)) {
2638
- this.sessionLifetime = res.user_info?.attributes?.preferences?.lifetime ?? _TrueNasAuthenticator.DefaultSessionLifetime;
2639
- this.authenticated$.next(true);
2640
- } else {
2641
- this.logout();
2642
- this.authenticated$.next(false);
2643
- throw new AuthError(
2644
- "FULL_ADMIN_REQUIRED" /* FullAdminRequired */,
2645
- "User account must have full admin privileges"
2646
- );
2647
- }
2718
+ this.assertNotSuperseded(sentDuring);
2719
+ this.sessionLifetime = res.user_info?.attributes?.preferences?.lifetime ?? _TrueNasAuthenticator.DefaultSessionLifetime;
2720
+ this.authenticated$.next(true);
2648
2721
  }
2649
2722
  }),
2650
2723
  rxjs.finalize(() => {
2651
- this.authenticating$.next(false);
2724
+ this.endLogin(sentDuring, callerInitiated, answered);
2652
2725
  }),
2653
2726
  rxjs.take(1)
2654
2727
  );
@@ -2659,6 +2732,8 @@ var TrueNasAuthenticator = class _TrueNasAuthenticator {
2659
2732
  * and replayed. The token exists for the credential that cannot be.
2660
2733
  */
2661
2734
  loginWithApiKey(credentials) {
2735
+ const { sentDuring, callerInitiated } = this.beginLogin();
2736
+ let answered = false;
2662
2737
  const { username, key } = credentials;
2663
2738
  const message = createJsonRpcMessage("auth.login_ex", [
2664
2739
  {
@@ -2672,6 +2747,9 @@ var TrueNasAuthenticator = class _TrueNasAuthenticator {
2672
2747
  const messageId = message.id ?? "";
2673
2748
  return this.connection.messages().pipe(
2674
2749
  withId(messageId),
2750
+ rxjs.tap(() => {
2751
+ answered = true;
2752
+ }),
2675
2753
  rxjs.map((msg) => {
2676
2754
  if (msg.error) {
2677
2755
  const errorMessage = getApiErrorMessage(
@@ -2687,13 +2765,13 @@ var TrueNasAuthenticator = class _TrueNasAuthenticator {
2687
2765
  "TrueNAS authentication failed. Has the TrueNAS Connect API key been removed from your TrueNAS server?"
2688
2766
  ),
2689
2767
  rxjs.tap((res) => {
2690
- this.credentials.username = username;
2691
- this.credentials.key = key;
2768
+ this.assertNotSuperseded(sentDuring);
2769
+ this.credentials = { username, password: "", key };
2692
2770
  this.sessionLifetime = res.user_info?.attributes?.preferences?.lifetime ?? _TrueNasAuthenticator.DefaultSessionLifetime;
2693
2771
  this.authenticated$.next(true);
2694
2772
  }),
2695
2773
  rxjs.finalize(() => {
2696
- this.authenticating$.next(false);
2774
+ this.endLogin(sentDuring, callerInitiated, answered);
2697
2775
  }),
2698
2776
  rxjs.take(1)
2699
2777
  );
@@ -2720,6 +2798,10 @@ var TrueNasAuthenticator = class _TrueNasAuthenticator {
2720
2798
  );
2721
2799
  }
2722
2800
  logout() {
2801
+ this.credentials = { username: "", password: "", key: "" };
2802
+ this.sessionLifetime = _TrueNasAuthenticator.DefaultSessionLifetime;
2803
+ this.authEpoch += 1;
2804
+ this.authenticated$.next(false);
2723
2805
  const message = createJsonRpcMessage("auth.logout");
2724
2806
  this.connection.send(message);
2725
2807
  const messageId = message.id ?? "";
@@ -2731,10 +2813,6 @@ var TrueNasAuthenticator = class _TrueNasAuthenticator {
2731
2813
  }
2732
2814
  return msg.result;
2733
2815
  }),
2734
- rxjs.tap((success) => {
2735
- this.sessionLifetime = _TrueNasAuthenticator.DefaultSessionLifetime;
2736
- this.authenticated$.next(!success);
2737
- }),
2738
2816
  rxjs.take(1)
2739
2817
  );
2740
2818
  }
@@ -2758,6 +2836,7 @@ var noopLogger = {
2758
2836
  };
2759
2837
 
2760
2838
  // src/utils/truenas-connection.utils.ts
2839
+ var policyViolationCloseCode = 1008;
2761
2840
  function isHttpStatusError(reason) {
2762
2841
  return ["404", "502", "503"].some((code) => reason.includes(code));
2763
2842
  }
@@ -2866,7 +2945,25 @@ var TrueNasConnection = class {
2866
2945
  rxjs.switchMap((enabled) => {
2867
2946
  if (enabled) {
2868
2947
  return this.connect().pipe(
2869
- rxjs.map((conn) => makeActiveConnection(conn.ws, conn.hostname))
2948
+ rxjs.map((conn) => makeActiveConnection(conn.ws, conn.hostname)),
2949
+ // A refusal becomes a value here rather than an error, so it never
2950
+ // reaches the `retry` below and nothing reconnects on its own.
2951
+ //
2952
+ // Handled at this depth on purpose: letting it out completes the
2953
+ // pipeline and unsubscribes `enabledChange$`, and `setEnabled` is the
2954
+ // documented way an app asks for another attempt once the network
2955
+ // changes. Not retrying is a statement about what this client does on
2956
+ // its own; it is not a reason to refuse the caller asking again.
2957
+ rxjs.catchError(
2958
+ (err) => isTerminalClose(err) ? rxjs.of(
2959
+ makeConnectionError(
2960
+ err.message,
2961
+ err.hostname,
2962
+ err.closeCode,
2963
+ err.closeReason
2964
+ )
2965
+ ) : rxjs.throwError(() => err)
2966
+ )
2870
2967
  );
2871
2968
  }
2872
2969
  return rxjs.of(closedConnection);
@@ -2892,7 +2989,14 @@ var TrueNasConnection = class {
2892
2989
  rxjs.catchError((err) => {
2893
2990
  this.logger.error("All connections failed - retrying", { message: err?.message, hostname: err?.hostname });
2894
2991
  return rxjs.concat(
2895
- rxjs.of(makeConnectionError(err.message, err.hostname)),
2992
+ rxjs.of(
2993
+ makeConnectionError(
2994
+ err.message,
2995
+ err.hostname,
2996
+ err.closeCode,
2997
+ err.closeReason
2998
+ )
2999
+ ),
2896
3000
  // since this error will immediately get caught by `retry` there's no reason to build a real error.
2897
3001
  rxjs.throwError(() => null)
2898
3002
  );
@@ -3001,6 +3105,11 @@ var TrueNasConnection = class {
3001
3105
  /**
3002
3106
  * enables or disables the connection gate. the app calls this when its `SystemState`
3003
3107
  * changes (mapping `SystemState.Active -> true`, everything else -> `false`).
3108
+ *
3109
+ * This is also how a caller asks for another attempt after the appliance has
3110
+ * refused the client — nothing reconnects on its own from there. The gate is
3111
+ * `distinctUntilChanged`, so re-asserting `true` while it is already `true`
3112
+ * does nothing: the round trip through `false` is what asks again.
3004
3113
  */
3005
3114
  setEnabled(enabled) {
3006
3115
  this.enabled$.next(enabled);
@@ -3058,7 +3167,9 @@ var TrueNasConnection = class {
3058
3167
  errorMessage = getWebSocketError(event.code);
3059
3168
  }
3060
3169
  this.connectionAttempts.next(this.connectionAttempts.value + 1);
3061
- subscriber.error(makeConnectionError(errorMessage, hostname));
3170
+ subscriber.error(
3171
+ makeConnectionError(errorMessage, hostname, event.code, reason)
3172
+ );
3062
3173
  }
3063
3174
  }
3064
3175
  });
@@ -3108,12 +3219,15 @@ var makeActiveConnection = (ws, hostname) => ({
3108
3219
  hostname,
3109
3220
  state: "active"
3110
3221
  });
3111
- var makeConnectionError = (message, hostname) => ({
3222
+ var makeConnectionError = (message, hostname, closeCode, closeReason) => ({
3112
3223
  name: "ConnectionError",
3113
3224
  message,
3114
3225
  hostname,
3226
+ closeCode,
3227
+ closeReason,
3115
3228
  state: "error"
3116
3229
  });
3230
+ var isTerminalClose = (err) => err?.closeCode === policyViolationCloseCode;
3117
3231
  var closedConnection = { state: "closed" };
3118
3232
 
3119
3233
  // src/client/truenas-api-client.ts
@@ -3925,6 +4039,12 @@ function errorMessageOrDefault(error, fallback) {
3925
4039
  return fallback;
3926
4040
  }
3927
4041
 
4042
+ // src/enums/user-role.enum.ts
4043
+ var UserRole = /* @__PURE__ */ ((UserRole2) => {
4044
+ UserRole2["FullAdmin"] = "FULL_ADMIN";
4045
+ return UserRole2;
4046
+ })(UserRole || {});
4047
+
3928
4048
  exports.AppState = AppState;
3929
4049
  exports.AuthError = AuthError;
3930
4050
  exports.AuthErrorCode = AuthErrorCode;
@@ -3937,6 +4057,7 @@ exports.TrueNasApiClientV2510 = TrueNasApiClientV2510;
3937
4057
  exports.TrueNasApiClientV26 = TrueNasApiClientV26;
3938
4058
  exports.TrueNasApiClientV27 = TrueNasApiClientV27;
3939
4059
  exports.TrueNasAuthMechanism = TrueNasAuthMechanism;
4060
+ exports.UserRole = UserRole;
3940
4061
  exports.VersionCompatibility = VersionCompatibility;
3941
4062
  exports.VersionDiscovery = VersionDiscovery;
3942
4063
  exports.VersionDiscoveryError = VersionDiscoveryError;