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