@truenas/api-client 4.0.0 → 4.0.2

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/README.md CHANGED
@@ -183,6 +183,65 @@ discovery route that resolves against whatever the appliance turned out to be.
183
183
  On the named route it cannot: the client class is picked from the version you
184
184
  stated, so `ops` is that version's mappings whether or not the server agrees.
185
185
 
186
+ `ops` is deliberately flat — `OperationMappings`, not parameterized by the
187
+ directory — so every operation is callable whether or not you pinned a version.
188
+ That is what lets an operation paper over a difference the type system would
189
+ otherwise force the caller to handle.
190
+
191
+ ```ts
192
+ // The same call on every supported version.
193
+ const sessions = await firstValueFrom(
194
+ client.ops.smbStatus({ infoLevel: 'SESSIONS' })
195
+ );
196
+
197
+ // A client count is this composed, not a second operation.
198
+ const count = await firstValueFrom(
199
+ client.ops.smbStatus({
200
+ infoLevel: 'SESSIONS',
201
+ options: { count: true },
202
+ statusOptions: { fast: true },
203
+ })
204
+ );
205
+ ```
206
+
207
+ `smbStatus` is worth singling out, because it is the first operation whose
208
+ v25.10 leg is invisible to `middlewared --dump-api`. `smb.status` is public on
209
+ v26+ and generated like any other method; on v25.10 it is the same method taking
210
+ the same four positional arguments and returning the same `list | dict | int`,
211
+ but middleware declares it `private=True`, so the dump omits it and no generated
212
+ v25.10 type mentions it. The v25.10 client therefore *asserts* the method exists
213
+ rather than reading it from the directory — a single narrow declaration admitting
214
+ one method name, one argument tuple and one return type, checked against
215
+ middleware source instead of against the dump.
216
+
217
+ Three consequences worth knowing before you reach for it.
218
+
219
+ **It needs a full-admin session on v25.10.** Being private there decides
220
+ authorization, not just documentation. `smb.status` declares no roles on
221
+ v25.10, and middleware role-registers a method only `if roles:` — so it is in no
222
+ role's allowlist, and only a **non-STIG full-admin** session, whose allowlist is
223
+ the wildcard `{ method: '*', resource: '*' }`, reaches it. A session holding
224
+ exactly `SHARING_SMB_READ` is refused with `EACCES` on v25.10 and succeeds on
225
+ v26+, where the method carries that role. Under STIG, full admin is expanded to
226
+ the union of its roles' allowlists rather than the wildcard, so it is refused on
227
+ v25.10 as well. The client does not pre-empt any of this: you get middleware's
228
+ own error, on the version where it applies.
229
+
230
+ **It logs server-side on every dispatch**, which matters if you poll it. v25.10
231
+ is in maintenance and that cost is accepted there, but it is not a pattern to
232
+ extend.
233
+
234
+ **The result is the union middleware sends** — which arm arrives is decided by
235
+ the options, not the info level — so callers narrow it themselves.
236
+
237
+ These types are deliberately not the generated ones. `SmbStatusOptions` is not
238
+ v26's `SMBStatusOptions`, and `SmbStatusResponse` is not that method's generated
239
+ response: the generated types describe one version's dump, while these describe
240
+ the contract both versions honour. They are named `Smb…` rather than `SMB…` so
241
+ the two cannot be confused at a call site — and `SmbStatusOptions` is the one
242
+ pair where the names sit closest, since it is the fourth positional argument on
243
+ both versions.
244
+
186
245
  ## Documentation
187
246
 
188
247
  The API reference is generated from the TSDoc comments in the source with
package/dist/index.cjs CHANGED
@@ -2389,8 +2389,11 @@ var apiVersionConfig = {
2389
2389
  * - DNS lookup failure
2390
2390
  * - Server unreachable
2391
2391
  *
2392
- * Using this fallback means systems with genuine network issues will still
2393
- * attempt connection (and fail during WebSocket handshake instead of immediately).
2392
+ * Which is why it is no longer reached on all of them. A discovery failure is
2393
+ * now put to a reachability probe and a second attempt first, and only an
2394
+ * appliance that answers the probe and still will not serve `/api/versions`
2395
+ * is assumed to be this version. One that answers nothing is reported as
2396
+ * unreachable rather than pinned to a version nobody confirmed.
2394
2397
  */
2395
2398
  FALLBACK_VERSION: "v25.10.0"
2396
2399
  };
@@ -3446,6 +3449,16 @@ function toAppState(state) {
3446
3449
  }
3447
3450
  }
3448
3451
 
3452
+ // src/utils/smb-status.utils.ts
3453
+ function toSmbStatusParams(request = {}) {
3454
+ return [
3455
+ request.infoLevel ?? "ALL",
3456
+ request.filters ?? [],
3457
+ request.options ?? {},
3458
+ request.statusOptions ?? {}
3459
+ ];
3460
+ }
3461
+
3449
3462
  // src/client/truenas-api-client-v25-10.ts
3450
3463
  var TrueNasApiClientV2510 = class extends TrueNasApiClient {
3451
3464
  /**
@@ -3485,6 +3498,42 @@ var TrueNasApiClientV2510 = class extends TrueNasApiClient {
3485
3498
  );
3486
3499
  }
3487
3500
  return this.api.job("virt.instance.delete", [id]);
3501
+ },
3502
+ // The private leg. See `PrivateSmbStatusCall` for why this is a cast and
3503
+ // not a directory lookup.
3504
+ //
3505
+ // v25.10 answers rather than refusing, for a caller with the authority.
3506
+ // Being private does not stop the method running: `ws_handler/rpc.py`
3507
+ // logs a warning for a private call over websocket and dispatches it
3508
+ // anyway — the `# FIXME: Eventually, prohibit this` above that branch is
3509
+ // still unresolved on master.
3510
+ //
3511
+ // It does decide who may run it. `smb.status` carries no `roles=` here,
3512
+ // and middleware role-registers a method only `if roles:`, so it is in no
3513
+ // role's allowlist and only the non-STIG full-admin wildcard reaches it.
3514
+ // That is documented on `OperationMappings.smbStatus`; this leg does not
3515
+ // pre-empt it. A caller without the authority gets middleware's `EACCES`,
3516
+ // which is the honest answer and the server's to give.
3517
+ //
3518
+ // The server-side warning is per dispatch, and a client count is exactly
3519
+ // the sort of thing a caller polls. Reported at debug rather than warn,
3520
+ // because nothing here is degraded or unhonoured — the request is served
3521
+ // in full — and left to the caller to weigh.
3522
+ //
3523
+ // Future tense, and for the same reason as `containerDelete` above: this
3524
+ // runs when the operation is built, and `api.call` is cold, so no request
3525
+ // has gone out yet and may never. One built observable subscribed twice
3526
+ // dispatches twice; this line is what will happen, not a record that it
3527
+ // did.
3528
+ smbStatus: (request) => {
3529
+ this.logger.debug(
3530
+ "smbStatus: smb.status is private on v25.10; each dispatch will log a warning server-side and needs a non-STIG full-admin session",
3531
+ { method: "smb.status", infoLevel: request?.infoLevel ?? "ALL" }
3532
+ );
3533
+ return this.api.call(
3534
+ "smb.status",
3535
+ toSmbStatusParams(request)
3536
+ );
3488
3537
  }
3489
3538
  };
3490
3539
  }
@@ -3572,7 +3621,16 @@ var TrueNasApiClientV26 = class extends TrueNasApiClient {
3572
3621
  containerDelete: (id, options) => this.api.job(
3573
3622
  "container.delete",
3574
3623
  options ? [parseInt(id, 10), options] : [parseInt(id, 10)]
3575
- )
3624
+ ),
3625
+ // `smb.status` is public here and gated on `SHARING_SMB_READ`, so it is
3626
+ // an ordinary `call` read straight out of the generated directory — the
3627
+ // one leg of this operation that needs no assertion about the server.
3628
+ //
3629
+ // Not a query verb: middleware returns the same `list | dict | int`
3630
+ // polymorphism a `.query` does, but the generator did not mark the entry
3631
+ // with an `entity`, so `api.query` does not accept it and the union is
3632
+ // handed to the caller to narrow.
3633
+ smbStatus: (request) => this.api.call("smb.status", toSmbStatusParams(request))
3576
3634
  };
3577
3635
  }
3578
3636
  };
@@ -3653,7 +3711,16 @@ var TrueNasApiClientV27 = class extends TrueNasApiClient {
3653
3711
  containerDelete: (id, options) => this.api.job(
3654
3712
  "container.delete",
3655
3713
  options ? [parseInt(id, 10), options] : [parseInt(id, 10)]
3656
- )
3714
+ ),
3715
+ // `smb.status` is public here and gated on `SHARING_SMB_READ`, so it is
3716
+ // an ordinary `call` read straight out of the generated directory — the
3717
+ // one leg of this operation that needs no assertion about the server.
3718
+ //
3719
+ // Not a query verb: middleware returns the same `list | dict | int`
3720
+ // polymorphism a `.query` does, but the generator did not mark the entry
3721
+ // with an `entity`, so `api.query` does not accept it and the union is
3722
+ // handed to the caller to narrow.
3723
+ smbStatus: (request) => this.api.call("smb.status", toSmbStatusParams(request))
3657
3724
  };
3658
3725
  }
3659
3726
  };
@@ -3753,6 +3820,12 @@ var InvalidVersionResponseError = class extends VersionDiscoveryError {
3753
3820
  }
3754
3821
  };
3755
3822
  var discoveryTimeoutMs = 5e3;
3823
+ var probeTimeoutMs = 5e3;
3824
+ function corsIsEnforced() {
3825
+ const hasDocument = typeof window !== "undefined" && typeof window.document !== "undefined";
3826
+ const inWorker = typeof globalThis.WorkerGlobalScope !== "undefined";
3827
+ return hasDocument || inWorker;
3828
+ }
3756
3829
  function hasErrorName(error, expected) {
3757
3830
  return typeof error === "object" && error !== null && "name" in error && error.name === expected;
3758
3831
  }
@@ -3765,6 +3838,42 @@ var VersionDiscovery = class {
3765
3838
  versionsUrl(hostname) {
3766
3839
  return `${httpScheme(this.protocol)}//${hostname}/api/versions`;
3767
3840
  }
3841
+ /**
3842
+ * Whether the appliance answers at all, asked in a way CORS cannot block.
3843
+ *
3844
+ * A `no-cors` request yields a response the page may not read, but its
3845
+ * *arrival* is the whole answer here: something served it. That separates the
3846
+ * two failures `fetch` reports identically — a box that refused to share its
3847
+ * versions with this origin, and a box that is not there.
3848
+ *
3849
+ * `cannot-ask` where CORS is not enforced: nothing is blocking anything, so a
3850
+ * failed fetch already means unreachable and a probe would only repeat what
3851
+ * discovery just found. It is not a quieter way of saying "no".
3852
+ */
3853
+ async probeReachable(hostname) {
3854
+ if (!corsIsEnforced()) return "cannot-ask";
3855
+ const url = this.versionsUrl(hostname);
3856
+ const controller = new AbortController();
3857
+ const timer2 = setTimeout(() => controller.abort(), probeTimeoutMs);
3858
+ try {
3859
+ await fetch(url, {
3860
+ mode: "no-cors",
3861
+ cache: "no-store",
3862
+ signal: controller.signal
3863
+ });
3864
+ this.logger.info("Reachability probe answered", { hostname, url });
3865
+ return "reachable";
3866
+ } catch (error) {
3867
+ this.logger.warn("Reachability probe got no answer", {
3868
+ hostname,
3869
+ url,
3870
+ error
3871
+ });
3872
+ return "silent";
3873
+ } finally {
3874
+ clearTimeout(timer2);
3875
+ }
3876
+ }
3768
3877
  /**
3769
3878
  * Discovers the API version for a given hostname.
3770
3879
  *
@@ -4024,30 +4133,81 @@ async function createTrueNasClient(opts) {
4024
4133
  });
4025
4134
  throw error;
4026
4135
  }
4027
- const fallbackVersionString = apiVersionConfig.FALLBACK_VERSION;
4028
- const fallbackVersion = parseApiVersion(fallbackVersionString);
4029
- if (!fallbackVersion) {
4030
- logger.error("Invalid fallback version configuration", {
4031
- uuid: uuid.slice(0, 8),
4032
- hostnames: hostnames.join(", "),
4033
- fallbackVersion: fallbackVersionString
4034
- });
4035
- throw error;
4136
+ const reachable = await probeAnyHostname(hostnames, versionDiscovery);
4137
+ if (reachable === "silent") {
4138
+ await sleep(unreachableRetryDelayMs);
4036
4139
  }
4037
- logger.warn(
4038
- "Version discovery failed with a network error (possible CORS or network issue), falling back to assumed version",
4039
- {
4140
+ try {
4141
+ const retryWinner = await discoverVersionFromAnyHostname(
4142
+ hostnames,
4143
+ versionDiscovery
4144
+ );
4145
+ logger.info("Version discovery succeeded on retry", {
4040
4146
  uuid: uuid.slice(0, 8),
4041
- hostnames: hostnames.join(", "),
4042
- fallbackVersion: fallbackVersionString,
4043
- originalError: errorMessage,
4044
- warning: "A network error has multiple causes (CORS, network down, DNS failure). The connection may still fail during the WebSocket handshake."
4147
+ hostname: retryWinner.hostname,
4148
+ version: retryWinner.version.version,
4149
+ firstError: errorMessage
4150
+ });
4151
+ version = retryWinner.version;
4152
+ } catch (retryError) {
4153
+ if (!(retryError instanceof VersionDiscoveryNetworkError)) {
4154
+ throw retryError;
4045
4155
  }
4046
- );
4047
- version = fallbackVersion;
4156
+ if (reachable !== "reachable") {
4157
+ logger.error(
4158
+ "Version discovery failed and the appliance did not answer a reachability probe; not assuming a version",
4159
+ {
4160
+ uuid: uuid.slice(0, 8),
4161
+ hostnames: hostnames.join(", "),
4162
+ probe: reachable === "silent" ? "no answer" : "not applicable",
4163
+ error: errorMessageOrDefault(retryError, "Unknown error")
4164
+ }
4165
+ );
4166
+ throw retryError;
4167
+ }
4168
+ const fallbackVersionString = apiVersionConfig.FALLBACK_VERSION;
4169
+ const fallbackVersion = parseApiVersion(fallbackVersionString);
4170
+ if (!fallbackVersion) {
4171
+ logger.error("Invalid fallback version configuration", {
4172
+ uuid: uuid.slice(0, 8),
4173
+ hostnames: hostnames.join(", "),
4174
+ fallbackVersion: fallbackVersionString
4175
+ });
4176
+ throw retryError;
4177
+ }
4178
+ logger.warn(
4179
+ "Appliance is reachable but version discovery is still blocked; falling back to assumed version",
4180
+ {
4181
+ uuid: uuid.slice(0, 8),
4182
+ hostnames: hostnames.join(", "),
4183
+ fallbackVersion: fallbackVersionString,
4184
+ originalError: errorMessage,
4185
+ warning: "The appliance answered a probe but not /api/versions, which is what v25.10.0 looks like from a browser. If it is newer than that, this client is now pinned to the wrong surface."
4186
+ }
4187
+ );
4188
+ version = fallbackVersion;
4189
+ }
4048
4190
  }
4049
4191
  return instantiateClientForVersion(version, opts, logger);
4050
4192
  }
4193
+ var unreachableRetryDelayMs = 2500;
4194
+ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
4195
+ async function probeAnyHostname(hostnames, versionDiscovery) {
4196
+ const probes = hostnames.map(
4197
+ (hostname) => versionDiscovery.probeReachable(hostname)
4198
+ );
4199
+ const remaining = new Set(probes);
4200
+ let sawSilence = false;
4201
+ while (remaining.size > 0) {
4202
+ const settled = await Promise.race(
4203
+ [...remaining].map((probe) => probe.then((result) => ({ probe, result })))
4204
+ );
4205
+ if (settled.result === "reachable") return "reachable";
4206
+ if (settled.result === "silent") sawSilence = true;
4207
+ remaining.delete(settled.probe);
4208
+ }
4209
+ return sawSilence ? "silent" : "cannot-ask";
4210
+ }
4051
4211
  async function discoverVersionFromAnyHostname(hostnames, versionDiscovery) {
4052
4212
  const attempts = hostnames.map(
4053
4213
  (hostname) => rxjs.firstValueFrom(versionDiscovery.discoverVersion(hostname)).then(