@truenas/api-client 3.0.4 → 3.0.6

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
@@ -367,775 +367,153 @@ var TrueNasApi = class {
367
367
  }
368
368
  };
369
369
 
370
- // src/enums/truenas-auth-mechanism.enum.ts
371
- var TrueNasAuthMechanism = /* @__PURE__ */ ((TrueNasAuthMechanism2) => {
372
- TrueNasAuthMechanism2["ApiKey"] = "API_KEY_PLAIN";
373
- TrueNasAuthMechanism2["Password"] = "PASSWORD_PLAIN";
374
- TrueNasAuthMechanism2["Otp"] = "OTP_TOKEN";
375
- TrueNasAuthMechanism2["Token"] = "TOKEN_PLAIN";
376
- return TrueNasAuthMechanism2;
377
- })(TrueNasAuthMechanism || {});
370
+ // src/generated/v25_10_0/index.ts
371
+ var v25_10_0_exports = {};
372
+ __export(v25_10_0_exports, {
373
+ ACLTemplateEntryAcltypeInput: () => ACLTemplateEntryAcltypeInput,
374
+ Aclmode: () => Aclmode,
375
+ Acltype: () => Acltype,
376
+ Action: () => Action,
377
+ Advpowermgmt: () => Advpowermgmt,
378
+ Algorithm: () => Algorithm,
379
+ AppContainerDetailsStateInput: () => AppContainerDetailsStateInput,
380
+ Atime: () => Atime,
381
+ Authenticator: () => Authenticator,
382
+ Authmethod: () => Authmethod,
383
+ Autotrim: () => Autotrim,
384
+ Basic: () => Basic,
385
+ Blocksize: () => Blocksize,
386
+ Bootloader: () => Bootloader,
387
+ Casesensitivity: () => Casesensitivity,
388
+ CloudBackupSnapshotItemType: () => CloudBackupSnapshotItemType,
389
+ CloudSyncCreateDirection: () => CloudSyncCreateDirection,
390
+ CloudSyncCreateDirectionInput: () => CloudSyncCreateDirectionInput,
391
+ CloudTaskAttributesEncryptionInput: () => CloudTaskAttributesEncryptionInput,
392
+ Code: () => Code,
393
+ Compression: () => Compression,
394
+ CorePingRemoteTypeInput: () => CorePingRemoteTypeInput,
395
+ Credentials: () => Credentials,
396
+ DeviceGetInfoOtherTypeInput: () => DeviceGetInfoOtherTypeInput,
397
+ DiskDetailsTypeInput: () => DiskDetailsTypeInput,
398
+ DiskWipeModeInput: () => DiskWipeModeInput,
399
+ DockerStatusInfoStatusInput: () => DockerStatusInfoStatusInput,
400
+ Enclosure2SetSlotStatusStatusInput: () => Enclosure2SetSlotStatusStatusInput,
401
+ Encryption: () => Encryption,
402
+ Endpoint: () => Endpoint,
403
+ Exec: () => Exec,
404
+ Feature: () => Feature,
405
+ FilesystemDirEntryType: () => FilesystemDirEntryType,
406
+ Function: () => Function,
407
+ GetDisplayDeviceType: () => GetDisplayDeviceType,
408
+ Hddstandby: () => Hddstandby,
409
+ InitShutdownScriptCreateType: () => InitShutdownScriptCreateType,
410
+ InitShutdownScriptCreateTypeInput: () => InitShutdownScriptCreateTypeInput,
411
+ InterfaceCreateAliasTypeInput: () => InterfaceCreateAliasTypeInput,
412
+ InterfaceCreateTypeInput: () => InterfaceCreateTypeInput,
413
+ Iotype: () => Iotype,
414
+ IpmiChassisIdentifyVerb: () => IpmiChassisIdentifyVerb,
415
+ IscsiExtentCreateType: () => IscsiExtentCreateType,
416
+ IscsiExtentCreateTypeInput: () => IscsiExtentCreateTypeInput,
417
+ IscsiTargetCreateMode: () => IscsiTargetCreateMode,
418
+ IscsiTargetCreateModeInput: () => IscsiTargetCreateModeInput,
419
+ KeychainCredentialEntryType: () => KeychainCredentialEntryType,
420
+ KeychainCredentialEntryTypeInput: () => KeychainCredentialEntryTypeInput,
421
+ Level: () => Level,
422
+ NFS4ACEType: () => NFS4ACEType,
423
+ NFS4ACETypeInput: () => NFS4ACETypeInput,
424
+ Name: () => Name,
425
+ Policy: () => Policy,
426
+ PoolCreateChecksum: () => PoolCreateChecksum,
427
+ PoolCreateDeduplication: () => PoolCreateDeduplication,
428
+ PoolCreateTopologyDataVdevDRAIDTypeInput: () => PoolCreateTopologyDataVdevDRAIDTypeInput,
429
+ PoolDatasetCreateFilesystemAcltypeInput: () => PoolDatasetCreateFilesystemAcltypeInput,
430
+ PoolDatasetCreateFilesystemChecksum: () => PoolDatasetCreateFilesystemChecksum,
431
+ PoolDatasetCreateFilesystemDeduplication: () => PoolDatasetCreateFilesystemDeduplication,
432
+ PoolDatasetCreateFilesystemReadonlyInput: () => PoolDatasetCreateFilesystemReadonlyInput,
433
+ PoolDatasetDeleteResult: () => PoolDatasetDeleteResult,
434
+ PoolScanStateInput: () => PoolScanStateInput,
435
+ Protocol: () => Protocol,
436
+ Purpose: () => Purpose,
437
+ Readonly: () => Readonly,
438
+ ReplicationCountEligibleManualSnapshotsTransportInput: () => ReplicationCountEligibleManualSnapshotsTransportInput,
439
+ ReplicationCreateReadonlyInput: () => ReplicationCreateReadonlyInput,
440
+ Resolution: () => Resolution,
441
+ Rpm: () => Rpm,
442
+ RsyncTaskCreateDirection: () => RsyncTaskCreateDirection,
443
+ RsyncTaskCreateDirectionInput: () => RsyncTaskCreateDirectionInput,
444
+ RsyncTaskCreateMode: () => RsyncTaskCreateMode,
445
+ RsyncTaskCreateModeInput: () => RsyncTaskCreateModeInput,
446
+ SMBEntryEncryptionInput: () => SMBEntryEncryptionInput,
447
+ STIGType: () => STIGType,
448
+ Schema: () => Schema,
449
+ Security: () => Security,
450
+ Serialspeed: () => Serialspeed,
451
+ ServiceControlVerb: () => ServiceControlVerb,
452
+ SharingSMBEntryPurposeInput: () => SharingSMBEntryPurposeInput,
453
+ SharingSMBEntryPurposeInput2: () => SharingSMBEntryPurposeInput2,
454
+ Shutdown: () => Shutdown,
455
+ Snapdev: () => Snapdev,
456
+ Snapdir: () => Snapdir,
457
+ Source: () => Source,
458
+ SourceInput: () => SourceInput,
459
+ State: () => State,
460
+ Status: () => Status,
461
+ SupportNewTicketCommunityTypeInput: () => SupportNewTicketCommunityTypeInput,
462
+ Sync: () => Sync,
463
+ SyslogServerTransportInput: () => SyslogServerTransportInput,
464
+ Sysloglevel: () => Sysloglevel,
465
+ SystemProductTypeResult: () => SystemProductTypeResult,
466
+ SystemStateResult: () => SystemStateResult,
467
+ Tag: () => Tag,
468
+ Target: () => Target,
469
+ Time: () => Time,
470
+ Transport: () => Transport,
471
+ TruecommandConfigChangedEventFieldsStatus: () => TruecommandConfigChangedEventFieldsStatus,
472
+ TruecommandConfigChangedEventFieldsStatusInput: () => TruecommandConfigChangedEventFieldsStatusInput,
473
+ TunableCreateType: () => TunableCreateType,
474
+ TunableCreateTypeInput: () => TunableCreateTypeInput,
475
+ UPSEntryMode: () => UPSEntryMode,
476
+ UPSEntryModeInput: () => UPSEntryModeInput,
477
+ Unixcharset: () => Unixcharset,
478
+ Username: () => Username,
479
+ VMDiskDeviceType: () => VMDiskDeviceType,
480
+ VMDiskDeviceTypeInput: () => VMDiskDeviceTypeInput,
481
+ VMWareEntryStateStateInput: () => VMWareEntryStateStateInput,
482
+ VMWareMatchDatastoresWithDatasetsResultFilesystemType: () => VMWareMatchDatastoresWithDatasetsResultFilesystemType,
483
+ Vendor: () => Vendor,
484
+ Volblocksize: () => Volblocksize,
485
+ When: () => When,
486
+ ZfsTierRewriteJobEntryStatusInput: () => ZfsTierRewriteJobEntryStatusInput
487
+ });
378
488
 
379
- // src/errors/auth.errors.ts
380
- var AuthErrorCode = /* @__PURE__ */ ((AuthErrorCode2) => {
381
- AuthErrorCode2["PasswordAuthFailed"] = "PASSWORD_AUTH_FAILED";
382
- AuthErrorCode2["OtpAuthFailed"] = "OTP_AUTH_FAILED";
383
- AuthErrorCode2["ApiKeyAuthFailed"] = "API_KEY_AUTH_FAILED";
384
- AuthErrorCode2["FullAdminRequired"] = "FULL_ADMIN_REQUIRED";
385
- return AuthErrorCode2;
386
- })(AuthErrorCode || {});
387
- var AuthError = class _AuthError extends Error {
388
- constructor(code, message) {
389
- super(message);
390
- this.code = code;
391
- this.name = "AuthError";
392
- Object.setPrototypeOf(this, _AuthError.prototype);
393
- }
489
+ // src/generated/v25_10_0/api-types.ts
490
+ var Aclmode = {
491
+ Passthrough: "PASSTHROUGH",
492
+ Restricted: "RESTRICTED",
493
+ Discard: "DISCARD",
494
+ Inherit: "INHERIT"
394
495
  };
395
-
396
- // src/auth/truenas-authenticator.ts
397
- var throwOnAuthenticationFailure = (code, message) => switchMap((response) => {
398
- if (response.response_type === "AUTH_ERR" /* AuthErr */) {
399
- return throwError(() => new AuthError(code, message));
400
- }
401
- return of(response);
402
- });
403
- var TrueNasAuthenticator = class _TrueNasAuthenticator {
404
- constructor(connection) {
405
- this.connection = connection;
406
- // in seconds (5 minutes)
407
- /**
408
- * whether or not the system is currently authenticated and accessible.
409
- * this is a `BehaviorSubject` instead of a signal, since we use its `getValue` method
410
- * throughout a lot of the codebase.
411
- */
412
- this.authenticated$ = new BehaviorSubject(false);
413
- /**
414
- * `true` when the system is currently being authenticated to, and `false` otherwise.
415
- * does not indicate whatsoever if authentication is successful or not, just whether the process
416
- * is ongoing.
417
- */
418
- this.authenticating$ = new BehaviorSubject(false);
419
- this.credentials = { username: "", password: "", key: "" };
420
- this.sessionLifetime = _TrueNasAuthenticator.DefaultSessionLifetime;
421
- this.connection.opened.pipe(
422
- filter(
423
- (isOpen) => !!(isOpen && this.credentials?.username && (this.credentials.password || this.credentials.key))
424
- ),
425
- switchMap(() => {
426
- if (this.credentials.password) {
427
- return this.loginWithUserPass(
428
- this.credentials.username,
429
- this.credentials.password
430
- );
431
- }
432
- return this.loginWithApiKey({
433
- username: this.credentials.username,
434
- key: this.credentials.key
435
- });
436
- })
437
- ).subscribe();
438
- connection.closed.subscribe(() => {
439
- this.sessionLifetime = _TrueNasAuthenticator.DefaultSessionLifetime;
440
- this.authenticated$.next(false);
441
- });
442
- }
443
- static {
444
- this.DefaultSessionLifetime = 300;
445
- }
446
- loginWithUserPass(username, password) {
447
- const message = createJsonRpcMessage("auth.login_ex", [
448
- {
449
- mechanism: "PASSWORD_PLAIN" /* Password */,
450
- username,
451
- password
452
- }
453
- ]);
454
- this.authenticating$.next(true);
455
- this.connection.send(message);
456
- const messageId = message.id ?? "";
457
- return this.connection.messages().pipe(
458
- withId(messageId),
459
- map((msg) => {
460
- if (msg.error) {
461
- const errorMessage = getApiErrorMessage(
462
- msg.error,
463
- "Authentication failed"
464
- );
465
- throw new Error(errorMessage);
466
- }
467
- return msg.result;
468
- }),
469
- throwOnAuthenticationFailure(
470
- "PASSWORD_AUTH_FAILED" /* PasswordAuthFailed */,
471
- "TrueNAS authentication failed. Please verify your TrueNAS user credentials and try again."
472
- ),
473
- tap((res) => {
474
- if (res.response_type === "SUCCESS" /* Success */) {
475
- if (res.user_info?.privilege.roles.$set.includes("FULL_ADMIN" /* FullAdmin */)) {
476
- this.credentials.username = username;
477
- this.credentials.password = password;
478
- this.sessionLifetime = res.user_info?.attributes?.preferences?.lifetime ?? _TrueNasAuthenticator.DefaultSessionLifetime;
479
- this.authenticated$.next(true);
480
- } else {
481
- this.logout();
482
- this.authenticated$.next(false);
483
- throw new AuthError(
484
- "FULL_ADMIN_REQUIRED" /* FullAdminRequired */,
485
- "User account must have full admin privileges"
486
- );
487
- }
488
- }
489
- }),
490
- finalize(() => {
491
- this.authenticating$.next(false);
492
- }),
493
- take(1)
494
- );
495
- }
496
- loginWithOtp(code) {
497
- const message = createJsonRpcMessage("auth.login_ex", [
498
- {
499
- mechanism: "OTP_TOKEN" /* Otp */,
500
- otp_token: code
501
- }
502
- ]);
503
- this.authenticating$.next(true);
504
- this.connection.send(message);
505
- const messageId = message.id ?? "";
506
- return this.connection.messages().pipe(
507
- withId(messageId),
508
- map((msg) => {
509
- if (msg.error) {
510
- const errorMessage = getApiErrorMessage(
511
- msg.error,
512
- "Authentication failed"
513
- );
514
- throw new Error(errorMessage);
515
- }
516
- return msg.result;
517
- }),
518
- tap((res) => {
519
- if (res?.response_type === "SUCCESS" /* Success */) {
520
- this.sessionLifetime = res.user_info?.attributes?.preferences?.lifetime ?? _TrueNasAuthenticator.DefaultSessionLifetime;
521
- this.authenticated$.next(true);
522
- }
523
- }),
524
- throwOnAuthenticationFailure(
525
- "OTP_AUTH_FAILED" /* OtpAuthFailed */,
526
- "TrueNAS authentication failed. Please verify your one-time passcode and try again."
527
- ),
528
- finalize(() => {
529
- this.authenticating$.next(false);
530
- }),
531
- take(1)
532
- );
533
- }
534
- loginWithApiKey(credentials) {
535
- const { username, key } = credentials;
536
- const message = createJsonRpcMessage("auth.login_ex", [
537
- {
538
- mechanism: "API_KEY_PLAIN" /* ApiKey */,
539
- username,
540
- api_key: key
541
- }
542
- ]);
543
- this.authenticating$.next(true);
544
- this.connection.send(message);
545
- const messageId = message.id ?? "";
546
- return this.connection.messages().pipe(
547
- withId(messageId),
548
- map((msg) => {
549
- if (msg.error) {
550
- const errorMessage = getApiErrorMessage(
551
- msg.error,
552
- "Authentication failed"
553
- );
554
- throw new Error(errorMessage);
555
- }
556
- return msg.result;
557
- }),
558
- throwOnAuthenticationFailure(
559
- "API_KEY_AUTH_FAILED" /* ApiKeyAuthFailed */,
560
- "TrueNAS authentication failed. Has the TrueNAS Connect API key been removed from your TrueNAS server?"
561
- ),
562
- tap((res) => {
563
- this.credentials.username = username;
564
- this.credentials.key = key;
565
- this.sessionLifetime = res.user_info?.attributes?.preferences?.lifetime ?? _TrueNasAuthenticator.DefaultSessionLifetime;
566
- this.authenticated$.next(true);
567
- }),
568
- finalize(() => {
569
- this.authenticating$.next(false);
570
- }),
571
- take(1)
572
- );
573
- }
574
- newApiKey(username) {
575
- const message = createJsonRpcMessage("api_key.create", [
576
- { name: `tnc-${randomUUID()}`, username }
577
- ]);
578
- this.connection.send(message);
579
- const messageId = message.id ?? "";
580
- return this.connection.messages().pipe(
581
- withId(messageId),
582
- map((msg) => {
583
- if (msg.error) {
584
- const errorMessage = getApiErrorMessage(
585
- msg.error,
586
- "Failed to create API key"
587
- );
588
- throw new Error(errorMessage);
589
- }
590
- return msg.result;
591
- }),
592
- take(1)
593
- );
594
- }
595
- logout() {
596
- const message = createJsonRpcMessage("auth.logout");
597
- this.connection.send(message);
598
- const messageId = message.id ?? "";
599
- return this.connection.messages().pipe(
600
- withId(messageId),
601
- map((msg) => {
602
- if (msg.error) {
603
- return false;
604
- }
605
- return msg.result;
606
- }),
607
- tap((success) => {
608
- this.sessionLifetime = _TrueNasAuthenticator.DefaultSessionLifetime;
609
- this.authenticated$.next(!success);
610
- }),
611
- take(1)
612
- );
613
- }
496
+ var ACLTemplateEntryAcltypeInput = {
497
+ Nfs4: "NFS4",
498
+ Posix1e: "POSIX1E"
614
499
  };
615
-
616
- // src/logger.ts
617
- var consoleLogger = {
618
- trace: (message, ...args) => console.trace(message, ...args),
619
- debug: (message, ...args) => console.debug(message, ...args),
620
- info: (message, ...args) => console.info(message, ...args),
621
- warn: (message, ...args) => console.warn(message, ...args),
622
- error: (message, ...args) => console.error(message, ...args)
500
+ var Acltype = {
501
+ Nfs4: "NFS4",
502
+ Posix1e: "POSIX1E"
623
503
  };
624
- var noop = () => void 0;
625
- var noopLogger = {
626
- trace: noop,
627
- debug: noop,
628
- info: noop,
629
- warn: noop,
630
- error: noop
504
+ var Action = {
505
+ Start: "START",
506
+ Stop: "STOP",
507
+ Pause: "PAUSE"
631
508
  };
632
-
633
- // src/utils/truenas-connection.utils.ts
634
- function isHttpStatusError(reason) {
635
- return ["404", "502", "503"].some((code) => reason.includes(code));
636
- }
637
- function getHttpError(reason) {
638
- if (reason.includes("404")) {
639
- return "API endpoint not found - System may not support versioned API";
640
- }
641
- if (reason.includes("502")) {
642
- return "Bad Gateway - System may be starting up";
643
- }
644
- if (reason.includes("503")) {
645
- return "Service Unavailable - System may be overloaded";
646
- }
647
- return "";
648
- }
649
- function getWebSocketError(code) {
650
- switch (code) {
651
- case 1e3:
652
- return "Connection closed normally";
653
- case 1001:
654
- return "Server is shutting down or restarting";
655
- case 1002:
656
- return "Protocol error - Invalid WebSocket communication";
657
- case 1003:
658
- return "Unsupported data format received";
659
- case 1006:
660
- return "Connection lost unexpectedly - Check network connectivity";
661
- case 1007:
662
- return "Invalid data received from server";
663
- case 1008:
664
- return "Connection terminated due to policy violation";
665
- case 1009:
666
- return "Message size exceeded server limits";
667
- case 1011:
668
- return "Server encountered an internal error";
669
- case 1012:
670
- return "Service is restarting";
671
- case 1013:
672
- return "Server temporarily overloaded - Try again later";
673
- case 1015:
674
- return "TLS/Certificate error - Certificate may be expired";
675
- default:
676
- return "Network connection failed - Check IP address and connectivity";
677
- }
678
- }
679
- var TrueNasSocket = class {
680
- constructor(config) {
681
- this.config = config;
682
- this.socket = webSocket(this.config);
683
- }
684
- messages() {
685
- return this.socket;
686
- }
687
- next(msg) {
688
- this.socket.next(msg);
689
- }
690
- complete() {
691
- this.socket.complete();
692
- }
693
- };
694
-
695
- // src/types/transport.type.ts
696
- function httpScheme(protocol) {
697
- return protocol === "http:" ? "http:" : "https:";
698
- }
699
- function socketScheme(protocol) {
700
- return protocol === "http:" ? "ws:" : "wss:";
701
- }
702
-
703
- // src/connection/truenas-connection.ts
704
- var tenSeconds = 10 * 1e3;
705
- var twentySeconds = 20 * 1e3;
706
- var TrueNasConnection = class {
707
- constructor(initialEnabled, hostnames, systemUuid, websocketPath, systemName, retryDelay = tenSeconds, maxRetry = 3, logger = noopLogger, protocol = "https:") {
708
- this.hostnames = hostnames;
709
- this.systemUuid = systemUuid;
710
- this.websocketPath = websocketPath;
711
- this.systemName = systemName;
712
- this.retryDelay = retryDelay;
713
- this.maxRetry = maxRetry;
714
- this.logger = logger;
715
- this.protocol = protocol;
716
- // compatibility properties
717
- this.opened = new BehaviorSubject(false);
718
- this.closed = new Subject();
719
- this.hostname = new BehaviorSubject("");
720
- this.connectionAttempts = new BehaviorSubject(0);
721
- this.lastErrorMessage = new BehaviorSubject(null);
722
- /**
723
- * emits when the connection is manually closed.
724
- */
725
- this.closeConnection = new Subject();
726
- /**
727
- * the connection only establishes a socket while this gate is `true`. consumers
728
- * flip it via `setEnabled()` (the app maps `SystemState.Active -> true`).
729
- */
730
- this.enabled$ = new BehaviorSubject(false);
731
- /**
732
- * observable which emits the current gate value and only emits again when it changes.
733
- */
734
- this.enabledChange$ = this.enabled$.pipe(distinctUntilChanged());
735
- /**
736
- * observable that handles the entire connection lifecycle.
737
- */
738
- this.connection$ = this.enabledChange$.pipe(
739
- switchMap((enabled) => {
740
- if (enabled) {
741
- return this.connect().pipe(
742
- map((conn) => makeActiveConnection(conn.ws, conn.hostname))
743
- );
744
- }
745
- return of(closedConnection);
746
- }),
747
- switchMap((connection) => {
748
- if (connection.state !== "active") {
749
- return of(connection);
750
- }
751
- const initMessage = createJsonRpcMessage("core.set_options", [
752
- { legacy_jobs: false }
753
- ]);
754
- connection.ws.next(initMessage);
755
- return connection.ws.messages().pipe(
756
- filter((msg) => msg.id === initMessage.id),
757
- take(1),
758
- map(() => connection)
759
- );
760
- }),
761
- // in the event of an error, do two things:
762
- // 1. emit an errored connection downstream to inform consumers that
763
- // the connection is dead.
764
- // 2. re-throw an error so the downstream `retry` will re-subscribe.
765
- catchError$1((err) => {
766
- this.logger.error("All connections failed - retrying", { message: err?.message, hostname: err?.hostname });
767
- return concat(
768
- of(makeConnectionError(err.message, err.hostname)),
769
- // since this error will immediately get caught by `retry` there's no reason to build a real error.
770
- throwError(() => null)
771
- );
772
- }),
773
- retry(),
774
- // start with a closed connection.
775
- startWith(closedConnection),
776
- // prevent multiple subscriptions from re-evaluating the entire pipeline.
777
- shareReplay$1({ bufferSize: 1, refCount: false }),
778
- takeUntil(this.closeConnection)
779
- );
780
- /**
781
- * observable which is either an instance of the current `TrueNasSocket` used for the connection
782
- * or `null` if the connection is closed or errored for any reason.
783
- */
784
- this.ws$ = this.connection$.pipe(
785
- map((conn) => conn.state === "active" ? conn.ws : null)
786
- );
787
- /**
788
- * current hostname we used to build the URL for this connection.
789
- */
790
- this.hostname$ = this.connection$.pipe(
791
- map((conn) => conn.state === "active" ? conn.hostname : null)
792
- );
793
- /**
794
- * emits error messages from the connection pipeline. this is used to derive the
795
- * corresponding compatibility property.
796
- */
797
- this.lastErrorMessage$ = this.connection$.pipe(
798
- map((conn) => conn.state === "error" ? conn.message : null)
799
- );
800
- /**
801
- * emits when the connection state *changes*, so it'll never emit `true` twice
802
- * in a row nor `false` twice in a row.
803
- */
804
- this.opened$ = this.connection$.pipe(
805
- map((conn) => conn.state),
806
- distinctUntilChanged(),
807
- map((state) => state === "active")
808
- );
809
- /**
810
- * emits when the connection state goes from open to closed.
811
- */
812
- this.closed$ = this.connection$.pipe(
813
- map((conn) => conn.state),
814
- distinctUntilChanged(),
815
- filter((state) => state === "closed" || state === "error"),
816
- map(() => {
817
- })
818
- );
819
- /**
820
- * whether to display a connection error to the user — the **live** signal.
821
- *
822
- * `true` only while `connection$` is currently a `ConnectionError`, meaning the entire race +
823
- * retry cycle has been exhausted for all hostnames right now; it flips back to `false` once a
824
- * connection is re-established. individual socket losses during a race are expected and not surfaced.
825
- *
826
- * NOTE: this is NOT the same as the `hasExhaustedRetries()` method, which is a **cumulative**
827
- * snapshot (see there). The two can disagree — prefer this observable for "is the connection
828
- * errored right now?".
829
- */
830
- this.hasConnectionError$ = this.connection$.pipe(
831
- map((conn) => conn.state === "error"),
832
- takeUntil(this.closeConnection)
833
- );
834
- /**
835
- * observable which always emits messages from the current socket.
836
- */
837
- this.messages$ = this.ws$.pipe(
838
- filter((ws) => ws !== null),
839
- switchMap((ws) => ws.messages().pipe(
840
- catchError$1(() => EMPTY)
841
- ))
842
- );
843
- combineLatest([
844
- interval(twentySeconds),
845
- this.ws$
846
- ]).pipe(
847
- takeUntil(this.closeConnection)
848
- ).subscribe(([, ws]) => {
849
- if (ws) {
850
- const pingMessage = createJsonRpcMessage("core.ping");
851
- ws.next(pingMessage);
852
- }
853
- });
854
- this.opened$.subscribe((val) => this.opened.next(val));
855
- this.closed$.subscribe(() => this.closed.next());
856
- this.hostname$.pipe(filter(Boolean)).subscribe((name) => this.hostname.next(name));
857
- this.ws$.pipe(filter(Boolean)).subscribe((ws) => this.ws = ws);
858
- this.lastErrorMessage$.subscribe((msg) => this.lastErrorMessage.next(msg));
859
- this.enabled$.next(initialEnabled);
860
- }
861
- /**
862
- * whether the connection has exhausted its retries — the **cumulative** snapshot, read
863
- * synchronously. (Formerly `hasConnectionError()`; renamed to disambiguate it from the
864
- * live `hasConnectionError$` observable, with which it can disagree.)
865
- *
866
- * `true` when the lifetime `connectionAttempts` count exceeds `hostnames.length * maxRetry`,
867
- * OR when an error message is currently set. Ported from the source (tncui) behavior.
868
- */
869
- hasExhaustedRetries() {
870
- const attemptsExhausted = this.connectionAttempts.value > this.hostnames.length * this.maxRetry;
871
- const hasErrorMessage = this.lastErrorMessage.value !== null;
872
- return attemptsExhausted || hasErrorMessage;
873
- }
874
- /**
875
- * enables or disables the connection gate. the app calls this when its `SystemState`
876
- * changes (mapping `SystemState.Active -> true`, everything else -> `false`).
877
- */
878
- setEnabled(enabled) {
879
- this.enabled$.next(enabled);
880
- }
881
- /**
882
- * compatibility method which just returns the public `messages$` observable.
883
- * this is used in a few places in the codebase that relied upon the old implementation.
884
- */
885
- messages() {
886
- return this.messages$;
887
- }
888
- /**
889
- * sends a message over the current websocket OR queues it to send once
890
- * the next websocket is opened. messages sent via `send` will not be lost until after
891
- * the next `closed$` emission.
892
- */
893
- send(message) {
894
- return this.ws$.pipe(
895
- filter((ws) => ws !== null),
896
- take(1)
897
- ).subscribe((ws) => ws.next(message));
898
- }
899
- /**
900
- * manually closes this connection and prevents it from retrying/opening any more.
901
- */
902
- close() {
903
- this.closeConnection.next();
904
- this.closeConnection.complete();
905
- }
906
- /**
907
- * helper method which creates an `Observable` which emits an `ActiveConnection`
908
- * after it establishes a connection to the given hostname. the observable will emit an
909
- * error if the connection is never established and will not complete until unsubscribed from or closed.
910
- */
911
- createSocket(hostname) {
912
- const url = `${socketScheme(this.protocol)}//${hostname}${this.websocketPath}`;
913
- let hasOpened = false;
914
- return new Observable((subscriber) => {
915
- const ws = new TrueNasSocket({
916
- url,
917
- openObserver: {
918
- next: () => {
919
- hasOpened = true;
920
- this.connectionAttempts.next(0);
921
- subscriber.next({ ws, hostname });
922
- }
923
- },
924
- closeObserver: {
925
- next: (event) => {
926
- const reason = event.reason || "";
927
- let errorMessage;
928
- if (isHttpStatusError(reason)) {
929
- errorMessage = getHttpError(reason);
930
- } else {
931
- errorMessage = getWebSocketError(event.code);
932
- }
933
- this.connectionAttempts.next(this.connectionAttempts.value + 1);
934
- subscriber.error(makeConnectionError(errorMessage, hostname));
935
- }
936
- }
937
- });
938
- const startupSub = ws.socket.subscribe({
939
- error: () => {
940
- }
941
- });
942
- return () => {
943
- startupSub.unsubscribe();
944
- ws.complete();
945
- };
946
- }).pipe(
947
- // retry logic:
948
- // * if a connection is not established in 10 seconds, consider that an error
949
- timeout({ first: tenSeconds }),
950
- retry({
951
- // * while still *establishing*: wait `retryDelay` before trying again, giving up
952
- // after `maxRetry` retries.
953
- count: this.maxRetry,
954
- // * we use a custom `delay` function here to ensure no retries are performed
955
- // once a socket is *opened*.
956
- // basically: a later death must propagate out rather than being retried
957
- // so it can be handled by the `connection$` observable.
958
- delay: (error) => {
959
- if (hasOpened) {
960
- return throwError(() => error);
961
- }
962
- return timer(this.retryDelay);
963
- }
964
- })
965
- );
966
- }
967
- /**
968
- * helper function which actually performs the parallel connection `race`.
969
- */
970
- connect() {
971
- return race(
972
- this.hostnames.map(this.createSocket.bind(this))
973
- ).pipe(
974
- tap((conn) => this.logger.debug(`TrueNas socket opened to ${conn.hostname}.`)),
975
- takeUntil(this.closeConnection)
976
- );
977
- }
978
- };
979
- var makeActiveConnection = (ws, hostname) => ({
980
- ws,
981
- hostname,
982
- state: "active"
983
- });
984
- var makeConnectionError = (message, hostname) => ({
985
- name: "ConnectionError",
986
- message,
987
- hostname,
988
- state: "error"
989
- });
990
- var closedConnection = { state: "closed" };
991
-
992
- // src/generated/v25_10_0/index.ts
993
- var v25_10_0_exports = {};
994
- __export(v25_10_0_exports, {
995
- ACLTemplateEntryAcltypeInput: () => ACLTemplateEntryAcltypeInput,
996
- Aclmode: () => Aclmode,
997
- Acltype: () => Acltype,
998
- Action: () => Action,
999
- Advpowermgmt: () => Advpowermgmt,
1000
- Algorithm: () => Algorithm,
1001
- AppContainerDetailsStateInput: () => AppContainerDetailsStateInput,
1002
- Atime: () => Atime,
1003
- Authenticator: () => Authenticator,
1004
- Authmethod: () => Authmethod,
1005
- Autotrim: () => Autotrim,
1006
- Basic: () => Basic,
1007
- Blocksize: () => Blocksize,
1008
- Bootloader: () => Bootloader,
1009
- Casesensitivity: () => Casesensitivity,
1010
- CloudBackupSnapshotItemType: () => CloudBackupSnapshotItemType,
1011
- CloudSyncCreateDirection: () => CloudSyncCreateDirection,
1012
- CloudSyncCreateDirectionInput: () => CloudSyncCreateDirectionInput,
1013
- CloudTaskAttributesEncryptionInput: () => CloudTaskAttributesEncryptionInput,
1014
- Code: () => Code,
1015
- Compression: () => Compression,
1016
- CorePingRemoteTypeInput: () => CorePingRemoteTypeInput,
1017
- Credentials: () => Credentials,
1018
- DeviceGetInfoOtherTypeInput: () => DeviceGetInfoOtherTypeInput,
1019
- DiskDetailsTypeInput: () => DiskDetailsTypeInput,
1020
- DiskWipeModeInput: () => DiskWipeModeInput,
1021
- DockerStatusInfoStatusInput: () => DockerStatusInfoStatusInput,
1022
- Enclosure2SetSlotStatusStatusInput: () => Enclosure2SetSlotStatusStatusInput,
1023
- Encryption: () => Encryption,
1024
- Endpoint: () => Endpoint,
1025
- Exec: () => Exec,
1026
- Feature: () => Feature,
1027
- FilesystemDirEntryType: () => FilesystemDirEntryType,
1028
- Function: () => Function,
1029
- GetDisplayDeviceType: () => GetDisplayDeviceType,
1030
- Hddstandby: () => Hddstandby,
1031
- InitShutdownScriptCreateType: () => InitShutdownScriptCreateType,
1032
- InitShutdownScriptCreateTypeInput: () => InitShutdownScriptCreateTypeInput,
1033
- InterfaceCreateAliasTypeInput: () => InterfaceCreateAliasTypeInput,
1034
- InterfaceCreateTypeInput: () => InterfaceCreateTypeInput,
1035
- Iotype: () => Iotype,
1036
- IpmiChassisIdentifyVerb: () => IpmiChassisIdentifyVerb,
1037
- IscsiExtentCreateType: () => IscsiExtentCreateType,
1038
- IscsiExtentCreateTypeInput: () => IscsiExtentCreateTypeInput,
1039
- IscsiTargetCreateMode: () => IscsiTargetCreateMode,
1040
- IscsiTargetCreateModeInput: () => IscsiTargetCreateModeInput,
1041
- KeychainCredentialEntryType: () => KeychainCredentialEntryType,
1042
- KeychainCredentialEntryTypeInput: () => KeychainCredentialEntryTypeInput,
1043
- Level: () => Level,
1044
- NFS4ACEType: () => NFS4ACEType,
1045
- NFS4ACETypeInput: () => NFS4ACETypeInput,
1046
- Name: () => Name,
1047
- Policy: () => Policy,
1048
- PoolCreateChecksum: () => PoolCreateChecksum,
1049
- PoolCreateDeduplication: () => PoolCreateDeduplication,
1050
- PoolCreateTopologyDataVdevDRAIDTypeInput: () => PoolCreateTopologyDataVdevDRAIDTypeInput,
1051
- PoolDatasetCreateFilesystemAcltypeInput: () => PoolDatasetCreateFilesystemAcltypeInput,
1052
- PoolDatasetCreateFilesystemChecksum: () => PoolDatasetCreateFilesystemChecksum,
1053
- PoolDatasetCreateFilesystemDeduplication: () => PoolDatasetCreateFilesystemDeduplication,
1054
- PoolDatasetCreateFilesystemReadonlyInput: () => PoolDatasetCreateFilesystemReadonlyInput,
1055
- PoolDatasetDeleteResult: () => PoolDatasetDeleteResult,
1056
- PoolScanStateInput: () => PoolScanStateInput,
1057
- Protocol: () => Protocol,
1058
- Purpose: () => Purpose,
1059
- Readonly: () => Readonly,
1060
- ReplicationCountEligibleManualSnapshotsTransportInput: () => ReplicationCountEligibleManualSnapshotsTransportInput,
1061
- ReplicationCreateReadonlyInput: () => ReplicationCreateReadonlyInput,
1062
- Resolution: () => Resolution,
1063
- Rpm: () => Rpm,
1064
- RsyncTaskCreateDirection: () => RsyncTaskCreateDirection,
1065
- RsyncTaskCreateDirectionInput: () => RsyncTaskCreateDirectionInput,
1066
- RsyncTaskCreateMode: () => RsyncTaskCreateMode,
1067
- RsyncTaskCreateModeInput: () => RsyncTaskCreateModeInput,
1068
- SMBEntryEncryptionInput: () => SMBEntryEncryptionInput,
1069
- STIGType: () => STIGType,
1070
- Schema: () => Schema,
1071
- Security: () => Security,
1072
- Serialspeed: () => Serialspeed,
1073
- ServiceControlVerb: () => ServiceControlVerb,
1074
- SharingSMBEntryPurposeInput: () => SharingSMBEntryPurposeInput,
1075
- SharingSMBEntryPurposeInput2: () => SharingSMBEntryPurposeInput2,
1076
- Shutdown: () => Shutdown,
1077
- Snapdev: () => Snapdev,
1078
- Snapdir: () => Snapdir,
1079
- Source: () => Source,
1080
- SourceInput: () => SourceInput,
1081
- State: () => State,
1082
- Status: () => Status,
1083
- SupportNewTicketCommunityTypeInput: () => SupportNewTicketCommunityTypeInput,
1084
- Sync: () => Sync,
1085
- SyslogServerTransportInput: () => SyslogServerTransportInput,
1086
- Sysloglevel: () => Sysloglevel,
1087
- SystemProductTypeResult: () => SystemProductTypeResult,
1088
- SystemStateResult: () => SystemStateResult,
1089
- Tag: () => Tag,
1090
- Target: () => Target,
1091
- Time: () => Time,
1092
- Transport: () => Transport,
1093
- TruecommandConfigChangedEventFieldsStatus: () => TruecommandConfigChangedEventFieldsStatus,
1094
- TruecommandConfigChangedEventFieldsStatusInput: () => TruecommandConfigChangedEventFieldsStatusInput,
1095
- TunableCreateType: () => TunableCreateType,
1096
- TunableCreateTypeInput: () => TunableCreateTypeInput,
1097
- UPSEntryMode: () => UPSEntryMode,
1098
- UPSEntryModeInput: () => UPSEntryModeInput,
1099
- Unixcharset: () => Unixcharset,
1100
- Username: () => Username,
1101
- VMDiskDeviceType: () => VMDiskDeviceType,
1102
- VMDiskDeviceTypeInput: () => VMDiskDeviceTypeInput,
1103
- VMWareEntryStateStateInput: () => VMWareEntryStateStateInput,
1104
- VMWareMatchDatastoresWithDatasetsResultFilesystemType: () => VMWareMatchDatastoresWithDatasetsResultFilesystemType,
1105
- Vendor: () => Vendor,
1106
- Volblocksize: () => Volblocksize,
1107
- When: () => When,
1108
- ZfsTierRewriteJobEntryStatusInput: () => ZfsTierRewriteJobEntryStatusInput
1109
- });
1110
-
1111
- // src/generated/v25_10_0/api-types.ts
1112
- var Aclmode = {
1113
- Passthrough: "PASSTHROUGH",
1114
- Restricted: "RESTRICTED",
1115
- Discard: "DISCARD",
1116
- Inherit: "INHERIT"
1117
- };
1118
- var ACLTemplateEntryAcltypeInput = {
1119
- Nfs4: "NFS4",
1120
- Posix1e: "POSIX1E"
1121
- };
1122
- var Acltype = {
1123
- Nfs4: "NFS4",
1124
- Posix1e: "POSIX1E"
1125
- };
1126
- var Action = {
1127
- Start: "START",
1128
- Stop: "STOP",
1129
- Pause: "PAUSE"
1130
- };
1131
- var Advpowermgmt = {
1132
- Disabled: "DISABLED",
1133
- "1": "1",
1134
- "64": "64",
1135
- "127": "127",
1136
- "128": "128",
1137
- "192": "192",
1138
- "254": "254"
509
+ var Advpowermgmt = {
510
+ Disabled: "DISABLED",
511
+ "1": "1",
512
+ "64": "64",
513
+ "127": "127",
514
+ "128": "128",
515
+ "192": "192",
516
+ "254": "254"
1139
517
  };
1140
518
  var Algorithm = {
1141
519
  Aes128Ccm: "AES-128-CCM",
@@ -2870,141 +2248,949 @@ __export(v27_0_0_exports, {
2870
2248
  ZpoolScrubRunAction: () => ZpoolScrubRunAction
2871
2249
  });
2872
2250
 
2873
- // src/generated/index.ts
2874
- var SUPPORTED_API_VERSIONS = [
2875
- "v25.10.0",
2876
- "v25.10.1",
2877
- "v25.10.2",
2878
- "v25.10.3",
2879
- "v25.10.4",
2880
- "v25.10.5",
2881
- "v26.0.0",
2882
- "v27.0.0"
2883
- ];
2251
+ // src/generated/index.ts
2252
+ var SUPPORTED_API_VERSIONS = [
2253
+ "v25.10.0",
2254
+ "v25.10.1",
2255
+ "v25.10.2",
2256
+ "v25.10.3",
2257
+ "v25.10.4",
2258
+ "v25.10.5",
2259
+ "v26.0.0",
2260
+ "v27.0.0"
2261
+ ];
2262
+
2263
+ // src/config/api-version.config.ts
2264
+ var apiVersionConfig = {
2265
+ /**
2266
+ * Minimum supported API version. Systems below it are rejected.
2267
+ *
2268
+ * DERIVED, not declared. The client supports exactly the versions it ships
2269
+ * types for, so the oldest generated version *is* the minimum — there is no
2270
+ * second decision to make, and therefore nothing to keep in sync.
2271
+ *
2272
+ * To move the floor, change `--min-version` in the `generate:api` script in
2273
+ * package.json and regenerate; this follows on its own. Written as a literal
2274
+ * it would duplicate that value with nothing enforcing agreement, so raising
2275
+ * one and forgetting the other would silently keep generating types for
2276
+ * versions the client rejects.
2277
+ */
2278
+ MIN_SUPPORTED_VERSION: SUPPORTED_API_VERSIONS[0],
2279
+ /**
2280
+ * Maximum supported API version. Systems above it are rejected.
2281
+ *
2282
+ * NOT derived, deliberately — and for a different reason than it used to
2283
+ * lag. It sat at v26.0.0 while types for v27 already shipped, because
2284
+ * `CLIENT_BY_VERSION_KEY` had no `27` and a v27 system would have passed the
2285
+ * range check only to throw on client selection. `TrueNasApiClientV27` now
2286
+ * exists, so this moves up with it.
2287
+ *
2288
+ * What it must not become is `SUPPORTED_API_VERSIONS[length - 1]`, the mirror
2289
+ * of how MIN is derived. MIN is safe to derive because the oldest generated
2290
+ * version always has a client — it is the floor the clients were built from.
2291
+ * The ceiling is not symmetric: generating types for v28 does not write a v28
2292
+ * client, so deriving this would re-create the exact gap described above on
2293
+ * the next regeneration, silently. The real invariant is "the newest version
2294
+ * a client can be built for", which lives in `CLIENT_BY_VERSION_KEY`; a
2295
+ * factory test asserts the two agree, which is the check that keeps this
2296
+ * literal honest without importing the factory here.
2297
+ */
2298
+ MAX_SUPPORTED_VERSION: "v27.0.0",
2299
+ /**
2300
+ * Fallback version to use when version discovery fails due to CORS/network errors.
2301
+ * When /api/versions returns HTTP status 0 (CORS block, network down, etc.),
2302
+ * the system will attempt to connect using this version as a best-effort fallback.
2303
+ *
2304
+ * WARNING: Status 0 errors have multiple causes:
2305
+ * - CORS policy blocking the request
2306
+ * - Network disconnected
2307
+ * - DNS lookup failure
2308
+ * - Server unreachable
2309
+ *
2310
+ * Using this fallback means systems with genuine network issues will still
2311
+ * attempt connection (and fail during WebSocket handshake instead of immediately).
2312
+ */
2313
+ FALLBACK_VERSION: "v25.10.0"
2314
+ };
2315
+
2316
+ // src/types/api-version.type.ts
2317
+ var VersionCompatibility = /* @__PURE__ */ ((VersionCompatibility2) => {
2318
+ VersionCompatibility2["Compatible"] = "compatible";
2319
+ VersionCompatibility2["TooOld"] = "too-old";
2320
+ VersionCompatibility2["TooNew"] = "too-new";
2321
+ VersionCompatibility2["Invalid"] = "invalid";
2322
+ return VersionCompatibility2;
2323
+ })(VersionCompatibility || {});
2324
+
2325
+ // src/utils/api-version.utils.ts
2326
+ var legacyCutoffYear = 25;
2327
+ function parseApiVersion(versionString) {
2328
+ const regex = /^v(\d{2})\.(\d+)\.(\d+)$/;
2329
+ const match = versionString.match(regex);
2330
+ if (!match) {
2331
+ return null;
2332
+ }
2333
+ const [, yearStr, minorStr, patchStr] = match;
2334
+ const year = parseInt(yearStr, 10);
2335
+ const minor = parseInt(minorStr, 10);
2336
+ const patch = parseInt(patchStr, 10);
2337
+ if (year <= legacyCutoffYear) {
2338
+ if (minor < 1 || minor > 12) {
2339
+ return null;
2340
+ }
2341
+ } else if (year >= 26) {
2342
+ if (minor < 0 || minor > 99) {
2343
+ return null;
2344
+ }
2345
+ }
2346
+ return {
2347
+ version: versionString,
2348
+ year,
2349
+ minor,
2350
+ patch,
2351
+ websocketPath: `/api/${versionString}`
2352
+ };
2353
+ }
2354
+ function compareVersions(a, b) {
2355
+ if (a.year !== b.year) {
2356
+ return a.year - b.year;
2357
+ }
2358
+ if (a.minor !== b.minor) {
2359
+ return a.minor - b.minor;
2360
+ }
2361
+ return a.patch - b.patch;
2362
+ }
2363
+ function checkVersionCompatibility(version) {
2364
+ const minVersion = parseApiVersion(apiVersionConfig.MIN_SUPPORTED_VERSION);
2365
+ const maxVersion = parseApiVersion(apiVersionConfig.MAX_SUPPORTED_VERSION);
2366
+ if (!minVersion || !maxVersion) {
2367
+ return "invalid" /* Invalid */;
2368
+ }
2369
+ if (compareVersions(version, minVersion) < 0) {
2370
+ return "too-old" /* TooOld */;
2371
+ }
2372
+ if (compareVersions(version, maxVersion) > 0) {
2373
+ return "too-new" /* TooNew */;
2374
+ }
2375
+ return "compatible" /* Compatible */;
2376
+ }
2377
+ function isVersionSupported(version) {
2378
+ return checkVersionCompatibility(version) === "compatible" /* Compatible */;
2379
+ }
2380
+ function filterCompatibleVersions(versions) {
2381
+ return versions.filter(isVersionSupported);
2382
+ }
2383
+ function getWebSocketPath(version) {
2384
+ return version.websocketPath;
2385
+ }
2386
+
2387
+ // src/enums/truenas-auth-mechanism.enum.ts
2388
+ var TrueNasAuthMechanism = /* @__PURE__ */ ((TrueNasAuthMechanism2) => {
2389
+ TrueNasAuthMechanism2["ApiKey"] = "API_KEY_PLAIN";
2390
+ TrueNasAuthMechanism2["Password"] = "PASSWORD_PLAIN";
2391
+ TrueNasAuthMechanism2["Otp"] = "OTP_TOKEN";
2392
+ TrueNasAuthMechanism2["Token"] = "TOKEN_PLAIN";
2393
+ return TrueNasAuthMechanism2;
2394
+ })(TrueNasAuthMechanism || {});
2395
+
2396
+ // src/errors/auth.errors.ts
2397
+ var AuthErrorCode = /* @__PURE__ */ ((AuthErrorCode2) => {
2398
+ AuthErrorCode2["PasswordAuthFailed"] = "PASSWORD_AUTH_FAILED";
2399
+ AuthErrorCode2["OtpAuthFailed"] = "OTP_AUTH_FAILED";
2400
+ AuthErrorCode2["ApiKeyAuthFailed"] = "API_KEY_AUTH_FAILED";
2401
+ AuthErrorCode2["TokenAuthFailed"] = "TOKEN_AUTH_FAILED";
2402
+ AuthErrorCode2["LoginSuperseded"] = "LOGIN_SUPERSEDED";
2403
+ AuthErrorCode2["FullAdminRequired"] = "FULL_ADMIN_REQUIRED";
2404
+ return AuthErrorCode2;
2405
+ })(AuthErrorCode || {});
2406
+ var AuthError = class _AuthError extends Error {
2407
+ constructor(code, message) {
2408
+ super(message);
2409
+ this.code = code;
2410
+ this.name = "AuthError";
2411
+ Object.setPrototypeOf(this, _AuthError.prototype);
2412
+ }
2413
+ };
2414
+
2415
+ // src/auth/truenas-authenticator.ts
2416
+ var throwOnAuthenticationFailure = (code, message) => switchMap((response) => {
2417
+ if (response.response_type === "AUTH_ERR" /* AuthErr */) {
2418
+ return throwError(() => new AuthError(code, message));
2419
+ }
2420
+ return of(response);
2421
+ });
2422
+ var TrueNasAuthenticator = class _TrueNasAuthenticator {
2423
+ constructor(connection, version) {
2424
+ this.connection = connection;
2425
+ this.version = version;
2426
+ // in seconds (5 minutes)
2427
+ /**
2428
+ * whether or not the system is currently authenticated and accessible.
2429
+ * this is a `BehaviorSubject` instead of a signal, since we use its `getValue` method
2430
+ * throughout a lot of the codebase.
2431
+ */
2432
+ this.authenticated$ = new BehaviorSubject(false);
2433
+ /**
2434
+ * `true` when the system is currently being authenticated to, and `false` otherwise.
2435
+ * does not indicate whatsoever if authentication is successful or not, just whether the process
2436
+ * is ongoing.
2437
+ */
2438
+ this.authenticating$ = new BehaviorSubject(false);
2439
+ this.credentials = { username: "", password: "", key: "" };
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
+ });
2479
+ this.connection.opened.pipe(
2480
+ filter(
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)
2483
+ ),
2484
+ switchMap(() => {
2485
+ this.reloginInProgress = true;
2486
+ let relogin;
2487
+ try {
2488
+ relogin = this.credentials.password ? this.loginWithUserPass(
2489
+ this.credentials.username,
2490
+ this.credentials.password
2491
+ ) : this.loginWithApiKey({
2492
+ username: this.credentials.username,
2493
+ key: this.credentials.key
2494
+ });
2495
+ } finally {
2496
+ this.reloginInProgress = false;
2497
+ }
2498
+ return relogin.pipe(catchError$1(() => EMPTY));
2499
+ })
2500
+ ).subscribe();
2501
+ connection.closed.subscribe(() => {
2502
+ for (const [epoch, flushed] of this.liveCallerLogins) {
2503
+ if (flushed) this.liveCallerLogins.delete(epoch);
2504
+ }
2505
+ this.sessionLifetime = _TrueNasAuthenticator.DefaultSessionLifetime;
2506
+ this.authenticated$.next(false);
2507
+ });
2508
+ }
2509
+ static {
2510
+ this.DefaultSessionLifetime = 300;
2511
+ }
2512
+ /**
2513
+ * `login_options` asking for a reconnect token, when the server understands it.
2514
+ *
2515
+ * Omitted below v26. `AuthCommonOptions` is `additionalProperties: false`
2516
+ * there and has only `user_info`, so sending the member is a validation
2517
+ * error, not an ignored field — it would fail login outright on the oldest
2518
+ * version this client supports.
2519
+ */
2520
+ /**
2521
+ * Two things this does not do, both deliberate and both worth knowing.
2522
+ *
2523
+ * There is no way for a consumer to decline: every v26+ password login now
2524
+ * mints a single-use credential carrying that session's roles, whether or not
2525
+ * the caller wants one. And the auto-relogin in the constructor subscribes
2526
+ * with no observer, so the token it mints is dropped — a caller reconnecting
2527
+ * repeatedly holds an ageing token while the appliance mints fresh ones
2528
+ * nobody reads. Tokens are single-use with a 600s TTL, so that is the
2529
+ * reconnect case the feature is named for.
2530
+ */
2531
+ reconnectTokenOption() {
2532
+ if (!this.version || this.version.year <= legacyCutoffYear) return void 0;
2533
+ return { login_options: { reconnect_token: true } };
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
+ }
2562
+ loginWithUserPass(username, password) {
2563
+ const { sentDuring, callerInitiated } = this.beginLogin();
2564
+ let answered = false;
2565
+ const message = createJsonRpcMessage("auth.login_ex", [
2566
+ {
2567
+ mechanism: "PASSWORD_PLAIN" /* Password */,
2568
+ username,
2569
+ password,
2570
+ ...this.reconnectTokenOption()
2571
+ }
2572
+ ]);
2573
+ this.authenticating$.next(true);
2574
+ this.connection.send(message);
2575
+ const messageId = message.id ?? "";
2576
+ return this.connection.messages().pipe(
2577
+ withId(messageId),
2578
+ tap(() => {
2579
+ answered = true;
2580
+ }),
2581
+ map((msg) => {
2582
+ if (msg.error) {
2583
+ const errorMessage = getApiErrorMessage(
2584
+ msg.error,
2585
+ "Authentication failed"
2586
+ );
2587
+ throw new Error(errorMessage);
2588
+ }
2589
+ return msg.result;
2590
+ }),
2591
+ throwOnAuthenticationFailure(
2592
+ "PASSWORD_AUTH_FAILED" /* PasswordAuthFailed */,
2593
+ "TrueNAS authentication failed. Please verify your TrueNAS user credentials and try again."
2594
+ ),
2595
+ tap((res) => {
2596
+ if (res.response_type === "SUCCESS" /* Success */) {
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);
2601
+ }
2602
+ }),
2603
+ finalize(() => {
2604
+ this.endLogin(sentDuring, callerInitiated, answered);
2605
+ }),
2606
+ take(1)
2607
+ );
2608
+ }
2609
+ loginWithOtp(code) {
2610
+ const { sentDuring, callerInitiated } = this.beginLogin();
2611
+ let answered = false;
2612
+ const message = createJsonRpcMessage("auth.login_ex", [
2613
+ {
2614
+ mechanism: "OTP_TOKEN" /* Otp */,
2615
+ otp_token: code
2616
+ }
2617
+ ]);
2618
+ this.authenticating$.next(true);
2619
+ this.connection.send(message);
2620
+ const messageId = message.id ?? "";
2621
+ return this.connection.messages().pipe(
2622
+ withId(messageId),
2623
+ tap(() => {
2624
+ answered = true;
2625
+ }),
2626
+ map((msg) => {
2627
+ if (msg.error) {
2628
+ const errorMessage = getApiErrorMessage(
2629
+ msg.error,
2630
+ "Authentication failed"
2631
+ );
2632
+ throw new Error(errorMessage);
2633
+ }
2634
+ return msg.result;
2635
+ }),
2636
+ tap((res) => {
2637
+ if (res?.response_type === "SUCCESS" /* Success */) {
2638
+ this.assertNotSuperseded(sentDuring);
2639
+ this.sessionLifetime = res.user_info?.attributes?.preferences?.lifetime ?? _TrueNasAuthenticator.DefaultSessionLifetime;
2640
+ this.authenticated$.next(true);
2641
+ }
2642
+ }),
2643
+ throwOnAuthenticationFailure(
2644
+ "OTP_AUTH_FAILED" /* OtpAuthFailed */,
2645
+ "TrueNAS authentication failed. Please verify your one-time passcode and try again."
2646
+ ),
2647
+ finalize(() => {
2648
+ this.endLogin(sentDuring, callerInitiated, answered);
2649
+ }),
2650
+ take(1)
2651
+ );
2652
+ }
2653
+ /**
2654
+ * Re-authenticate with a token from a previous login's `reconnect_token`.
2655
+ *
2656
+ * This is what lets a second connection to the same appliance authenticate
2657
+ * without asking the user for a password again — middleware sessions are
2658
+ * per-connection, so a second socket has its own to establish.
2659
+ *
2660
+ * The token is single-use and short-lived. On v26+ a successful login mints
2661
+ * another on the response, so a caller keeping a session alive across
2662
+ * reconnects stores the newest each time; below v26 nothing is minted and
2663
+ * there is no chain to keep.
2664
+ *
2665
+ * Re-login is the caller's to drive. A token session is not covered by the
2666
+ * automatic reconnect this class does for password and api-key sessions,
2667
+ * which is deliberate — the token is single-use — but it means a dropped
2668
+ * socket needs the stored token spending explicitly. Middleware holds tokens
2669
+ * in memory, so a `middlewared` restart voids them, and that is a common
2670
+ * reason the socket dropped in the first place.
2671
+ */
2672
+ loginWithToken(token) {
2673
+ const { sentDuring, callerInitiated } = this.beginLogin();
2674
+ let answered = false;
2675
+ const message = createJsonRpcMessage("auth.login_ex", [
2676
+ {
2677
+ mechanism: "TOKEN_PLAIN" /* Token */,
2678
+ token,
2679
+ ...this.reconnectTokenOption()
2680
+ }
2681
+ ]);
2682
+ this.authenticating$.next(true);
2683
+ this.connection.send(message);
2684
+ const messageId = message.id ?? "";
2685
+ return this.connection.messages().pipe(
2686
+ withId(messageId),
2687
+ tap(() => {
2688
+ answered = true;
2689
+ }),
2690
+ map((msg) => {
2691
+ if (msg.error) {
2692
+ const errorMessage = getApiErrorMessage(
2693
+ msg.error,
2694
+ "Authentication failed"
2695
+ );
2696
+ throw new Error(errorMessage);
2697
+ }
2698
+ return msg.result;
2699
+ }),
2700
+ // Password login lets non-`AUTH_ERR` answers through because it has to
2701
+ // hand `OTP_REQUIRED` back to the caller. Token login has no such case:
2702
+ // middleware answers `EXPIRED` for an account locked or expired between
2703
+ // minting and spending, and `DENIED` for a permission refusal, so letting
2704
+ // those reach `next` would report a failed login as a successful one.
2705
+ map((res) => {
2706
+ if (res.response_type !== "SUCCESS" /* Success */) {
2707
+ throw new AuthError(
2708
+ "TOKEN_AUTH_FAILED" /* TokenAuthFailed */,
2709
+ `TrueNAS token authentication failed (${res.response_type}). The token may have expired or been used, or the account may no longer be permitted to log in.`
2710
+ );
2711
+ }
2712
+ return res;
2713
+ }),
2714
+ tap((res) => {
2715
+ if (res.response_type === "SUCCESS" /* Success */) {
2716
+ this.assertNotSuperseded(sentDuring);
2717
+ this.sessionLifetime = res.user_info?.attributes?.preferences?.lifetime ?? _TrueNasAuthenticator.DefaultSessionLifetime;
2718
+ this.authenticated$.next(true);
2719
+ }
2720
+ }),
2721
+ finalize(() => {
2722
+ this.endLogin(sentDuring, callerInitiated, answered);
2723
+ }),
2724
+ take(1)
2725
+ );
2726
+ }
2727
+ /**
2728
+ * No reconnect token is requested here, though v26+ would mint one: an
2729
+ * api-key session already reconnects without a prompt, since the key is held
2730
+ * and replayed. The token exists for the credential that cannot be.
2731
+ */
2732
+ loginWithApiKey(credentials) {
2733
+ const { sentDuring, callerInitiated } = this.beginLogin();
2734
+ let answered = false;
2735
+ const { username, key } = credentials;
2736
+ const message = createJsonRpcMessage("auth.login_ex", [
2737
+ {
2738
+ mechanism: "API_KEY_PLAIN" /* ApiKey */,
2739
+ username,
2740
+ api_key: key
2741
+ }
2742
+ ]);
2743
+ this.authenticating$.next(true);
2744
+ this.connection.send(message);
2745
+ const messageId = message.id ?? "";
2746
+ return this.connection.messages().pipe(
2747
+ withId(messageId),
2748
+ tap(() => {
2749
+ answered = true;
2750
+ }),
2751
+ map((msg) => {
2752
+ if (msg.error) {
2753
+ const errorMessage = getApiErrorMessage(
2754
+ msg.error,
2755
+ "Authentication failed"
2756
+ );
2757
+ throw new Error(errorMessage);
2758
+ }
2759
+ return msg.result;
2760
+ }),
2761
+ throwOnAuthenticationFailure(
2762
+ "API_KEY_AUTH_FAILED" /* ApiKeyAuthFailed */,
2763
+ "TrueNAS authentication failed. Has the TrueNAS Connect API key been removed from your TrueNAS server?"
2764
+ ),
2765
+ tap((res) => {
2766
+ this.assertNotSuperseded(sentDuring);
2767
+ this.credentials = { username, password: "", key };
2768
+ this.sessionLifetime = res.user_info?.attributes?.preferences?.lifetime ?? _TrueNasAuthenticator.DefaultSessionLifetime;
2769
+ this.authenticated$.next(true);
2770
+ }),
2771
+ finalize(() => {
2772
+ this.endLogin(sentDuring, callerInitiated, answered);
2773
+ }),
2774
+ take(1)
2775
+ );
2776
+ }
2777
+ newApiKey(username) {
2778
+ const message = createJsonRpcMessage("api_key.create", [
2779
+ { name: `tnc-${randomUUID()}`, username }
2780
+ ]);
2781
+ this.connection.send(message);
2782
+ const messageId = message.id ?? "";
2783
+ return this.connection.messages().pipe(
2784
+ withId(messageId),
2785
+ map((msg) => {
2786
+ if (msg.error) {
2787
+ const errorMessage = getApiErrorMessage(
2788
+ msg.error,
2789
+ "Failed to create API key"
2790
+ );
2791
+ throw new Error(errorMessage);
2792
+ }
2793
+ return msg.result;
2794
+ }),
2795
+ take(1)
2796
+ );
2797
+ }
2798
+ logout() {
2799
+ this.credentials = { username: "", password: "", key: "" };
2800
+ this.sessionLifetime = _TrueNasAuthenticator.DefaultSessionLifetime;
2801
+ this.authEpoch += 1;
2802
+ this.authenticated$.next(false);
2803
+ const message = createJsonRpcMessage("auth.logout");
2804
+ this.connection.send(message);
2805
+ const messageId = message.id ?? "";
2806
+ return this.connection.messages().pipe(
2807
+ withId(messageId),
2808
+ map((msg) => {
2809
+ if (msg.error) {
2810
+ return false;
2811
+ }
2812
+ return msg.result;
2813
+ }),
2814
+ take(1)
2815
+ );
2816
+ }
2817
+ };
2818
+
2819
+ // src/logger.ts
2820
+ var consoleLogger = {
2821
+ trace: (message, ...args) => console.trace(message, ...args),
2822
+ debug: (message, ...args) => console.debug(message, ...args),
2823
+ info: (message, ...args) => console.info(message, ...args),
2824
+ warn: (message, ...args) => console.warn(message, ...args),
2825
+ error: (message, ...args) => console.error(message, ...args)
2826
+ };
2827
+ var noop = () => void 0;
2828
+ var noopLogger = {
2829
+ trace: noop,
2830
+ debug: noop,
2831
+ info: noop,
2832
+ warn: noop,
2833
+ error: noop
2834
+ };
2884
2835
 
2885
- // src/config/api-version.config.ts
2886
- var apiVersionConfig = {
2836
+ // src/utils/truenas-connection.utils.ts
2837
+ function isHttpStatusError(reason) {
2838
+ return ["404", "502", "503"].some((code) => reason.includes(code));
2839
+ }
2840
+ function getHttpError(reason) {
2841
+ if (reason.includes("404")) {
2842
+ return "API endpoint not found - System may not support versioned API";
2843
+ }
2844
+ if (reason.includes("502")) {
2845
+ return "Bad Gateway - System may be starting up";
2846
+ }
2847
+ if (reason.includes("503")) {
2848
+ return "Service Unavailable - System may be overloaded";
2849
+ }
2850
+ return "";
2851
+ }
2852
+ function getWebSocketError(code) {
2853
+ switch (code) {
2854
+ case 1e3:
2855
+ return "Connection closed normally";
2856
+ case 1001:
2857
+ return "Server is shutting down or restarting";
2858
+ case 1002:
2859
+ return "Protocol error - Invalid WebSocket communication";
2860
+ case 1003:
2861
+ return "Unsupported data format received";
2862
+ case 1006:
2863
+ return "Connection lost unexpectedly - Check network connectivity";
2864
+ case 1007:
2865
+ return "Invalid data received from server";
2866
+ case 1008:
2867
+ return "Connection terminated due to policy violation";
2868
+ case 1009:
2869
+ return "Message size exceeded server limits";
2870
+ case 1011:
2871
+ return "Server encountered an internal error";
2872
+ case 1012:
2873
+ return "Service is restarting";
2874
+ case 1013:
2875
+ return "Server temporarily overloaded - Try again later";
2876
+ case 1015:
2877
+ return "TLS/Certificate error - Certificate may be expired";
2878
+ default:
2879
+ return "Network connection failed - Check IP address and connectivity";
2880
+ }
2881
+ }
2882
+ var TrueNasSocket = class {
2883
+ constructor(config) {
2884
+ this.config = config;
2885
+ this.socket = webSocket(this.config);
2886
+ }
2887
+ messages() {
2888
+ return this.socket;
2889
+ }
2890
+ next(msg) {
2891
+ this.socket.next(msg);
2892
+ }
2893
+ complete() {
2894
+ this.socket.complete();
2895
+ }
2896
+ };
2897
+
2898
+ // src/types/transport.type.ts
2899
+ function httpScheme(protocol) {
2900
+ return protocol === "http:" ? "http:" : "https:";
2901
+ }
2902
+ function socketScheme(protocol) {
2903
+ return protocol === "http:" ? "ws:" : "wss:";
2904
+ }
2905
+
2906
+ // src/connection/truenas-connection.ts
2907
+ var tenSeconds = 10 * 1e3;
2908
+ var twentySeconds = 20 * 1e3;
2909
+ var TrueNasConnection = class {
2910
+ constructor(initialEnabled, hostnames, systemUuid, websocketPath, systemName, retryDelay = tenSeconds, maxRetry = 3, logger = noopLogger, protocol = "https:") {
2911
+ this.hostnames = hostnames;
2912
+ this.systemUuid = systemUuid;
2913
+ this.websocketPath = websocketPath;
2914
+ this.systemName = systemName;
2915
+ this.retryDelay = retryDelay;
2916
+ this.maxRetry = maxRetry;
2917
+ this.logger = logger;
2918
+ this.protocol = protocol;
2919
+ // compatibility properties
2920
+ this.opened = new BehaviorSubject(false);
2921
+ this.closed = new Subject();
2922
+ this.hostname = new BehaviorSubject("");
2923
+ this.connectionAttempts = new BehaviorSubject(0);
2924
+ this.lastErrorMessage = new BehaviorSubject(null);
2925
+ /**
2926
+ * emits when the connection is manually closed.
2927
+ */
2928
+ this.closeConnection = new Subject();
2929
+ /**
2930
+ * the connection only establishes a socket while this gate is `true`. consumers
2931
+ * flip it via `setEnabled()` (the app maps `SystemState.Active -> true`).
2932
+ */
2933
+ this.enabled$ = new BehaviorSubject(false);
2934
+ /**
2935
+ * observable which emits the current gate value and only emits again when it changes.
2936
+ */
2937
+ this.enabledChange$ = this.enabled$.pipe(distinctUntilChanged());
2938
+ /**
2939
+ * observable that handles the entire connection lifecycle.
2940
+ */
2941
+ this.connection$ = this.enabledChange$.pipe(
2942
+ switchMap((enabled) => {
2943
+ if (enabled) {
2944
+ return this.connect().pipe(
2945
+ map((conn) => makeActiveConnection(conn.ws, conn.hostname))
2946
+ );
2947
+ }
2948
+ return of(closedConnection);
2949
+ }),
2950
+ switchMap((connection) => {
2951
+ if (connection.state !== "active") {
2952
+ return of(connection);
2953
+ }
2954
+ const initMessage = createJsonRpcMessage("core.set_options", [
2955
+ { legacy_jobs: false }
2956
+ ]);
2957
+ connection.ws.next(initMessage);
2958
+ return connection.ws.messages().pipe(
2959
+ filter((msg) => msg.id === initMessage.id),
2960
+ take(1),
2961
+ map(() => connection)
2962
+ );
2963
+ }),
2964
+ // in the event of an error, do two things:
2965
+ // 1. emit an errored connection downstream to inform consumers that
2966
+ // the connection is dead.
2967
+ // 2. re-throw an error so the downstream `retry` will re-subscribe.
2968
+ catchError$1((err) => {
2969
+ this.logger.error("All connections failed - retrying", { message: err?.message, hostname: err?.hostname });
2970
+ return concat(
2971
+ of(makeConnectionError(err.message, err.hostname)),
2972
+ // since this error will immediately get caught by `retry` there's no reason to build a real error.
2973
+ throwError(() => null)
2974
+ );
2975
+ }),
2976
+ retry(),
2977
+ // start with a closed connection.
2978
+ startWith(closedConnection),
2979
+ // prevent multiple subscriptions from re-evaluating the entire pipeline.
2980
+ shareReplay$1({ bufferSize: 1, refCount: false }),
2981
+ takeUntil(this.closeConnection)
2982
+ );
2983
+ /**
2984
+ * observable which is either an instance of the current `TrueNasSocket` used for the connection
2985
+ * or `null` if the connection is closed or errored for any reason.
2986
+ */
2987
+ this.ws$ = this.connection$.pipe(
2988
+ map((conn) => conn.state === "active" ? conn.ws : null)
2989
+ );
2990
+ /**
2991
+ * current hostname we used to build the URL for this connection.
2992
+ */
2993
+ this.hostname$ = this.connection$.pipe(
2994
+ map((conn) => conn.state === "active" ? conn.hostname : null)
2995
+ );
2996
+ /**
2997
+ * emits error messages from the connection pipeline. this is used to derive the
2998
+ * corresponding compatibility property.
2999
+ */
3000
+ this.lastErrorMessage$ = this.connection$.pipe(
3001
+ map((conn) => conn.state === "error" ? conn.message : null)
3002
+ );
3003
+ /**
3004
+ * emits when the connection state *changes*, so it'll never emit `true` twice
3005
+ * in a row nor `false` twice in a row.
3006
+ */
3007
+ this.opened$ = this.connection$.pipe(
3008
+ map((conn) => conn.state),
3009
+ distinctUntilChanged(),
3010
+ map((state) => state === "active")
3011
+ );
3012
+ /**
3013
+ * emits when the connection state goes from open to closed.
3014
+ */
3015
+ this.closed$ = this.connection$.pipe(
3016
+ map((conn) => conn.state),
3017
+ distinctUntilChanged(),
3018
+ filter((state) => state === "closed" || state === "error"),
3019
+ map(() => {
3020
+ })
3021
+ );
3022
+ /**
3023
+ * whether to display a connection error to the user — the **live** signal.
3024
+ *
3025
+ * `true` only while `connection$` is currently a `ConnectionError`, meaning the entire race +
3026
+ * retry cycle has been exhausted for all hostnames right now; it flips back to `false` once a
3027
+ * connection is re-established. individual socket losses during a race are expected and not surfaced.
3028
+ *
3029
+ * NOTE: this is NOT the same as the `hasExhaustedRetries()` method, which is a **cumulative**
3030
+ * snapshot (see there). The two can disagree — prefer this observable for "is the connection
3031
+ * errored right now?".
3032
+ */
3033
+ this.hasConnectionError$ = this.connection$.pipe(
3034
+ map((conn) => conn.state === "error"),
3035
+ takeUntil(this.closeConnection)
3036
+ );
3037
+ /**
3038
+ * observable which always emits messages from the current socket.
3039
+ */
3040
+ this.messages$ = this.ws$.pipe(
3041
+ filter((ws) => ws !== null),
3042
+ switchMap((ws) => ws.messages().pipe(
3043
+ catchError$1(() => EMPTY)
3044
+ ))
3045
+ );
3046
+ combineLatest([
3047
+ interval(twentySeconds),
3048
+ this.ws$
3049
+ ]).pipe(
3050
+ takeUntil(this.closeConnection)
3051
+ ).subscribe(([, ws]) => {
3052
+ if (ws) {
3053
+ const pingMessage = createJsonRpcMessage("core.ping");
3054
+ ws.next(pingMessage);
3055
+ }
3056
+ });
3057
+ this.opened$.subscribe((val) => this.opened.next(val));
3058
+ this.closed$.subscribe(() => this.closed.next());
3059
+ this.hostname$.pipe(filter(Boolean)).subscribe((name) => this.hostname.next(name));
3060
+ this.ws$.pipe(filter(Boolean)).subscribe((ws) => this.ws = ws);
3061
+ this.lastErrorMessage$.subscribe((msg) => this.lastErrorMessage.next(msg));
3062
+ this.enabled$.next(initialEnabled);
3063
+ }
2887
3064
  /**
2888
- * Minimum supported API version. Systems below it are rejected.
2889
- *
2890
- * DERIVED, not declared. The client supports exactly the versions it ships
2891
- * types for, so the oldest generated version *is* the minimum — there is no
2892
- * second decision to make, and therefore nothing to keep in sync.
3065
+ * whether the connection has exhausted its retries the **cumulative** snapshot, read
3066
+ * synchronously. (Formerly `hasConnectionError()`; renamed to disambiguate it from the
3067
+ * live `hasConnectionError$` observable, with which it can disagree.)
2893
3068
  *
2894
- * To move the floor, change `--min-version` in the `generate:api` script in
2895
- * package.json and regenerate; this follows on its own. Written as a literal
2896
- * it would duplicate that value with nothing enforcing agreement, so raising
2897
- * one and forgetting the other would silently keep generating types for
2898
- * versions the client rejects.
3069
+ * `true` when the lifetime `connectionAttempts` count exceeds `hostnames.length * maxRetry`,
3070
+ * OR when an error message is currently set. Ported from the source (tncui) behavior.
2899
3071
  */
2900
- MIN_SUPPORTED_VERSION: SUPPORTED_API_VERSIONS[0],
3072
+ hasExhaustedRetries() {
3073
+ const attemptsExhausted = this.connectionAttempts.value > this.hostnames.length * this.maxRetry;
3074
+ const hasErrorMessage = this.lastErrorMessage.value !== null;
3075
+ return attemptsExhausted || hasErrorMessage;
3076
+ }
2901
3077
  /**
2902
- * Maximum supported API version. Systems above it are rejected.
2903
- *
2904
- * NOT derived, deliberately — and for a different reason than it used to
2905
- * lag. It sat at v26.0.0 while types for v27 already shipped, because
2906
- * `CLIENT_BY_VERSION_KEY` had no `27` and a v27 system would have passed the
2907
- * range check only to throw on client selection. `TrueNasApiClientV27` now
2908
- * exists, so this moves up with it.
2909
- *
2910
- * What it must not become is `SUPPORTED_API_VERSIONS[length - 1]`, the mirror
2911
- * of how MIN is derived. MIN is safe to derive because the oldest generated
2912
- * version always has a client — it is the floor the clients were built from.
2913
- * The ceiling is not symmetric: generating types for v28 does not write a v28
2914
- * client, so deriving this would re-create the exact gap described above on
2915
- * the next regeneration, silently. The real invariant is "the newest version
2916
- * a client can be built for", which lives in `CLIENT_BY_VERSION_KEY`; a
2917
- * factory test asserts the two agree, which is the check that keeps this
2918
- * literal honest without importing the factory here.
3078
+ * enables or disables the connection gate. the app calls this when its `SystemState`
3079
+ * changes (mapping `SystemState.Active -> true`, everything else -> `false`).
2919
3080
  */
2920
- MAX_SUPPORTED_VERSION: "v27.0.0",
3081
+ setEnabled(enabled) {
3082
+ this.enabled$.next(enabled);
3083
+ }
2921
3084
  /**
2922
- * Fallback version to use when version discovery fails due to CORS/network errors.
2923
- * When /api/versions returns HTTP status 0 (CORS block, network down, etc.),
2924
- * the system will attempt to connect using this version as a best-effort fallback.
2925
- *
2926
- * WARNING: Status 0 errors have multiple causes:
2927
- * - CORS policy blocking the request
2928
- * - Network disconnected
2929
- * - DNS lookup failure
2930
- * - Server unreachable
2931
- *
2932
- * Using this fallback means systems with genuine network issues will still
2933
- * attempt connection (and fail during WebSocket handshake instead of immediately).
3085
+ * compatibility method which just returns the public `messages$` observable.
3086
+ * this is used in a few places in the codebase that relied upon the old implementation.
2934
3087
  */
2935
- FALLBACK_VERSION: "v25.10.0"
2936
- };
2937
-
2938
- // src/types/api-version.type.ts
2939
- var VersionCompatibility = /* @__PURE__ */ ((VersionCompatibility2) => {
2940
- VersionCompatibility2["Compatible"] = "compatible";
2941
- VersionCompatibility2["TooOld"] = "too-old";
2942
- VersionCompatibility2["TooNew"] = "too-new";
2943
- VersionCompatibility2["Invalid"] = "invalid";
2944
- return VersionCompatibility2;
2945
- })(VersionCompatibility || {});
2946
-
2947
- // src/utils/api-version.utils.ts
2948
- var legacyCutoffYear = 25;
2949
- function parseApiVersion(versionString) {
2950
- const regex = /^v(\d{2})\.(\d+)\.(\d+)$/;
2951
- const match = versionString.match(regex);
2952
- if (!match) {
2953
- return null;
2954
- }
2955
- const [, yearStr, minorStr, patchStr] = match;
2956
- const year = parseInt(yearStr, 10);
2957
- const minor = parseInt(minorStr, 10);
2958
- const patch = parseInt(patchStr, 10);
2959
- if (year <= legacyCutoffYear) {
2960
- if (minor < 1 || minor > 12) {
2961
- return null;
2962
- }
2963
- } else if (year >= 26) {
2964
- if (minor < 0 || minor > 99) {
2965
- return null;
2966
- }
2967
- }
2968
- return {
2969
- version: versionString,
2970
- year,
2971
- minor,
2972
- patch,
2973
- websocketPath: `/api/${versionString}`
2974
- };
2975
- }
2976
- function compareVersions(a, b) {
2977
- if (a.year !== b.year) {
2978
- return a.year - b.year;
3088
+ messages() {
3089
+ return this.messages$;
2979
3090
  }
2980
- if (a.minor !== b.minor) {
2981
- return a.minor - b.minor;
3091
+ /**
3092
+ * sends a message over the current websocket OR queues it to send once
3093
+ * the next websocket is opened. messages sent via `send` will not be lost until after
3094
+ * the next `closed$` emission.
3095
+ */
3096
+ send(message) {
3097
+ return this.ws$.pipe(
3098
+ filter((ws) => ws !== null),
3099
+ take(1)
3100
+ ).subscribe((ws) => ws.next(message));
2982
3101
  }
2983
- return a.patch - b.patch;
2984
- }
2985
- function checkVersionCompatibility(version) {
2986
- const minVersion = parseApiVersion(apiVersionConfig.MIN_SUPPORTED_VERSION);
2987
- const maxVersion = parseApiVersion(apiVersionConfig.MAX_SUPPORTED_VERSION);
2988
- if (!minVersion || !maxVersion) {
2989
- return "invalid" /* Invalid */;
3102
+ /**
3103
+ * manually closes this connection and prevents it from retrying/opening any more.
3104
+ */
3105
+ close() {
3106
+ this.closeConnection.next();
3107
+ this.closeConnection.complete();
2990
3108
  }
2991
- if (compareVersions(version, minVersion) < 0) {
2992
- return "too-old" /* TooOld */;
3109
+ /**
3110
+ * helper method which creates an `Observable` which emits an `ActiveConnection`
3111
+ * after it establishes a connection to the given hostname. the observable will emit an
3112
+ * error if the connection is never established and will not complete until unsubscribed from or closed.
3113
+ */
3114
+ createSocket(hostname) {
3115
+ const url = `${socketScheme(this.protocol)}//${hostname}${this.websocketPath}`;
3116
+ let hasOpened = false;
3117
+ return new Observable((subscriber) => {
3118
+ const ws = new TrueNasSocket({
3119
+ url,
3120
+ openObserver: {
3121
+ next: () => {
3122
+ hasOpened = true;
3123
+ this.connectionAttempts.next(0);
3124
+ subscriber.next({ ws, hostname });
3125
+ }
3126
+ },
3127
+ closeObserver: {
3128
+ next: (event) => {
3129
+ const reason = event.reason || "";
3130
+ let errorMessage;
3131
+ if (isHttpStatusError(reason)) {
3132
+ errorMessage = getHttpError(reason);
3133
+ } else {
3134
+ errorMessage = getWebSocketError(event.code);
3135
+ }
3136
+ this.connectionAttempts.next(this.connectionAttempts.value + 1);
3137
+ subscriber.error(makeConnectionError(errorMessage, hostname));
3138
+ }
3139
+ }
3140
+ });
3141
+ const startupSub = ws.socket.subscribe({
3142
+ error: () => {
3143
+ }
3144
+ });
3145
+ return () => {
3146
+ startupSub.unsubscribe();
3147
+ ws.complete();
3148
+ };
3149
+ }).pipe(
3150
+ // retry logic:
3151
+ // * if a connection is not established in 10 seconds, consider that an error
3152
+ timeout({ first: tenSeconds }),
3153
+ retry({
3154
+ // * while still *establishing*: wait `retryDelay` before trying again, giving up
3155
+ // after `maxRetry` retries.
3156
+ count: this.maxRetry,
3157
+ // * we use a custom `delay` function here to ensure no retries are performed
3158
+ // once a socket is *opened*.
3159
+ // basically: a later death must propagate out rather than being retried
3160
+ // so it can be handled by the `connection$` observable.
3161
+ delay: (error) => {
3162
+ if (hasOpened) {
3163
+ return throwError(() => error);
3164
+ }
3165
+ return timer(this.retryDelay);
3166
+ }
3167
+ })
3168
+ );
2993
3169
  }
2994
- if (compareVersions(version, maxVersion) > 0) {
2995
- return "too-new" /* TooNew */;
3170
+ /**
3171
+ * helper function which actually performs the parallel connection `race`.
3172
+ */
3173
+ connect() {
3174
+ return race(
3175
+ this.hostnames.map(this.createSocket.bind(this))
3176
+ ).pipe(
3177
+ tap((conn) => this.logger.debug(`TrueNas socket opened to ${conn.hostname}.`)),
3178
+ takeUntil(this.closeConnection)
3179
+ );
2996
3180
  }
2997
- return "compatible" /* Compatible */;
2998
- }
2999
- function isVersionSupported(version) {
3000
- return checkVersionCompatibility(version) === "compatible" /* Compatible */;
3001
- }
3002
- function filterCompatibleVersions(versions) {
3003
- return versions.filter(isVersionSupported);
3004
- }
3005
- function getWebSocketPath(version) {
3006
- return version.websocketPath;
3007
- }
3181
+ };
3182
+ var makeActiveConnection = (ws, hostname) => ({
3183
+ ws,
3184
+ hostname,
3185
+ state: "active"
3186
+ });
3187
+ var makeConnectionError = (message, hostname) => ({
3188
+ name: "ConnectionError",
3189
+ message,
3190
+ hostname,
3191
+ state: "error"
3192
+ });
3193
+ var closedConnection = { state: "closed" };
3008
3194
 
3009
3195
  // src/client/truenas-api-client.ts
3010
3196
  var TrueNasApiClient = class {
@@ -3074,7 +3260,7 @@ var TrueNasApiClient = class {
3074
3260
  * Override in subclasses to provide version-specific authentication.
3075
3261
  */
3076
3262
  createAuthenticator() {
3077
- return new TrueNasAuthenticator(this.connection);
3263
+ return new TrueNasAuthenticator(this.connection, this.version);
3078
3264
  }
3079
3265
  };
3080
3266
 
@@ -3815,6 +4001,12 @@ function errorMessageOrDefault(error, fallback) {
3815
4001
  return fallback;
3816
4002
  }
3817
4003
 
3818
- 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 };
4004
+ // src/enums/user-role.enum.ts
4005
+ var UserRole = /* @__PURE__ */ ((UserRole2) => {
4006
+ UserRole2["FullAdmin"] = "FULL_ADMIN";
4007
+ return UserRole2;
4008
+ })(UserRole || {});
4009
+
4010
+ 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 };
3819
4011
  //# sourceMappingURL=index.js.map
3820
4012
  //# sourceMappingURL=index.js.map