node-vault-client 3.0.2 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -116,6 +116,21 @@ when Vault's `sts_region` / `sts_endpoint` is configured for a non-`us-east-1` r
116
116
  `SignatureDoesNotMatch — Credential should be scoped to a valid region`. Omitting `region`
117
117
  preserves the previous (global-endpoint) behavior.
118
118
 
119
+ #### `iam_server_id_header_value`
120
+
121
+ Set it to the same value as `iam_server_id_header_value` in the mount's `auth/<mount>/config/client`.
122
+ The client then sends it in the signed request as `X-Vault-AWS-IAM-Server-ID`, so the signature
123
+ names your Vault server. A Vault server that enforces its own `iam_server_id_header_value` then
124
+ rejects a request captured from, or meant for, a different server; one that does not set the value
125
+ checks nothing. Vault rejects a login with a missing or wrong value only when the mount sets the
126
+ value. The header does not stop a replay to the same server while the signature is valid (about 15
127
+ minutes).
128
+
129
+ When `config.iam_server_id_header_value` is missing, empty or only whitespace, the first login
130
+ logs one warning and logs in as before, sending the value it has. Each logger gets the warning once
131
+ per Vault server, namespace and mount: clients on the default console logger share it, so the
132
+ process logs it once, and a client with `logger: false` logs nothing and leaves it to the others.
133
+
119
134
  ### AppRole auth
120
135
 
121
136
  ```javascript
@@ -423,6 +438,27 @@ that token expires, and this client does not renew secret leases — only the au
423
438
  dynamic credentials (`database/creds/*`, cloud credentials) whose lease outlives the auth token,
424
439
  leaving renewal on is what currently keeps them alive. KV reads are unaffected.
425
440
 
441
+ #### Tokens revoked on the Vault side
442
+
443
+ A token can stop working before its expiry, for example when an operator revokes it
444
+ (`vault token revoke`, `sys/leases/revoke-prefix`). Vault then answers 403 to every request that
445
+ carries it. When a secret request or a mount detection gets a 403, the client asks
446
+ `auth/token/lookup-self`, which every login already calls, whether Vault still accepts the token:
447
+
448
+ * If it does, the 403 was a policy denial. The call rejects with it as before, and no login is made.
449
+ * If it does not, the client drops the token and its renewal timer, logs `Vault no longer accepts
450
+ the token (accessor=…); logging in again` at `warn`, logs in, and sends the request once more.
451
+ The call then returns what that attempt got: its result, or its error (a failed login, a 404, a
452
+ timeout). Like any re-login, it replays the configured credential (see the table above).
453
+
454
+ Concurrent requests rejected with the same token share one lookup and one login. A 403 on the login
455
+ itself is returned as before. Under `token` auth there is no credential to log in with, so the 403
456
+ is returned as before, without a lookup.
457
+
458
+ So revoking a client's token no longer locks it out: it logs straight back in with the same
459
+ credential. To cut a client off, also revoke or rotate what it logs in with (the AppRole
460
+ `secret_id` or role, the Kubernetes or JWT role binding, the IAM role).
461
+
426
462
  #### Tuning renewal instead of disabling it
427
463
 
428
464
  Two further keys, also on `auth`, shape *how* renewal happens. Both are optional, and leaving them
@@ -502,9 +538,10 @@ Client constructor function.
502
538
  | --- | --- | --- | --- |
503
539
  | options | `Object` | | |
504
540
  | options.api | <code>Object</code> | | |
505
- | options.api.url | <code>String</code> | | the url of the vault server |
541
+ | options.api.url | <code>String</code> | | the url of the vault server. Plain `http://` to a host other than this machine logs a warning; see "Plain `http://` api.url" |
506
542
  | [options.api.apiVersion] | <code>String</code> | `v1` | |
507
- | [options.api.requestOptions] | <code>Object</code> | | extra options merged into every HTTP request (see [Custom transport](#custom-transport-proxy--self-signed-tls)) |
543
+ | [options.api.requestOptions] | <code>Object</code> | | extra options merged into every HTTP request (see [Custom transport](#custom-transport-proxy--self-signed-tls)); a `redirect` there replaces the same-origin redirect policy (see [Redirects and the Vault token](#redirects-and-the-vault-token)) |
544
+ | [options.api.timeout] | <code>number</code> | `120000` | milliseconds a request may take, response headers, body and redirects included, before it rejects with `RequestTimeoutError`; `0` turns the limit off, `null` gives the default (see [Request timeouts](#request-timeouts)) |
508
545
  | [options.api.namespace] | <code>String</code> | | Optional. Vault namespace, sent as the `X-Vault-Namespace` header on **every** request — login, token lookup/renewal, and all secret operations — for every auth type. This is the canonical location; `auth.config.namespace` is still honored for backward compatibility. |
509
546
  | [options.api.kv.autoDetect] | <code>boolean</code> | `false` | auto-detect the KV version of each mount on first use (see [KV v2 & generic backends](#kv-v2--generic-backends)) |
510
547
  | [options.api.engines] | <code>Object</code> | `{}` | static mount-to-version map, e.g. `{ secret: 2, legacy: 1 }` (see [KV v2 & generic backends](#kv-v2--generic-backends)) |
@@ -559,8 +596,35 @@ bundle undici 7.x, so pin `undici@^7`.
559
596
 
560
597
  ##### Request timeouts
561
598
 
562
- There is no default timeout. A Vault that accepts the connection and never answers will leave
563
- `read()` (and every other call) pending indefinitely. Set one through the dispatcher:
599
+ Every request to Vault, including logins, `lookup-self` and background `renew-self`, has to
600
+ finish within `api.timeout` milliseconds: **120 000 (2 minutes) by default**. The limit covers the
601
+ whole request: response headers, the body and any redirects. A request that runs out of time
602
+ rejects with `RequestTimeoutError` (its `timeout` property holds the limit, `cause` the
603
+ `fetch()` rejection). With `api.kv.autoDetect`, a mount detection that times out fails the call
604
+ with the usual detection `VaultError`, with `RequestTimeoutError` on its `cause`. A renewal that
605
+ times out is handled like any other failed renewal.
606
+
607
+ `api.timeout` is in milliseconds; `null` or leaving it out gives the default, and a value under
608
+ 1000 logs a warning in case it was meant as seconds. Anything but an integer from 0 to 2147483647
609
+ raises `InvalidArgumentsError` when the client is created.
610
+
611
+ The default sits above Vault's own default `max_request_duration` (90 s), so it does not cut
612
+ short a request that a Vault with default settings would still answer. Set it lower to fail
613
+ fast, or higher if your Vault allows longer requests:
614
+
615
+ ```javascript
616
+ const vaultClient = VaultClient.boot('main', {
617
+ api: { url: 'https://vault.example.com:8200/', timeout: 10000 },
618
+ auth: { type: 'token', config: { token: '...' } },
619
+ });
620
+ ```
621
+
622
+ `timeout: 0` turns the limit off, as it was before `api.timeout` existed. What remains then
623
+ are the timeouts of the dispatcher `fetch()` uses: Node's default one gives up after 300 s
624
+ without response headers (`TypeError: fetch failed`, `cause.code` `UND_ERR_HEADERS_TIMEOUT`) or
625
+ 300 s without body data (`UND_ERR_BODY_TIMEOUT`). Those limits apply per phase, so a body that
626
+ keeps trickling in is never cut off. Any timeouts set on a custom dispatcher still apply
627
+ alongside `api.timeout`, and whichever fires first rejects the request:
564
628
 
565
629
  ```javascript
566
630
  const { Agent } = require('undici');
@@ -574,24 +638,54 @@ const vaultClient = VaultClient.boot('main', {
574
638
  });
575
639
  ```
576
640
 
577
- A failed request rejects with `TypeError: fetch failed`, with `cause.code` set to
578
- `UND_ERR_HEADERS_TIMEOUT` or `UND_ERR_BODY_TIMEOUT`.
579
-
580
- Do **not** put `signal: AbortSignal.timeout(ms)` in `requestOptions`. It is created once and
581
- shared by every request the client makes, so it works for the first call and then aborts all
582
- later ones with `TimeoutError`. Pass a fresh signal per call, or use the dispatcher above.
641
+ A `signal` in `requestOptions` still aborts every request in flight when it fires (with the
642
+ signal's own reason, not `RequestTimeoutError`), so an `AbortController` can cancel requests
643
+ on shutdown. `fetch()` then receives the client's own per-request signal, which follows it. Do **not** use `signal: AbortSignal.timeout(ms)` there: it is created once and
644
+ shared by every request the client makes, so it works for the first calls and then aborts all
645
+ later ones. Use `api.timeout` instead.
646
+
647
+ ##### Plain `http://` api.url
648
+
649
+ When `api.url` is `http://` and its host is not this machine, the client logs one warning on its
650
+ first request: `"api.url" uses plain http:// for <host:port>, which is not a loopback address; the
651
+ Vault token and secrets travel unencrypted. …` (on port 80 the message names the bare host).
652
+ Nothing else changes: the client connects and works as before. `localhost`, `127.0.0.0/8` (also
653
+ as `::ffff:127.x.y.z`), `::1`, and the unspecified addresses `0.0.0.0` and `::` count as this
654
+ machine; subdomains of `localhost` do not, since the system resolver can send them anywhere. The
655
+ warning names only the host and port, never credentials or a path from the URL. Each logger gets
656
+ it once per host and port: clients on the default console logger share it, so the process logs it
657
+ once, and a client with `logger: false` logs nothing and leaves the warning to the others. If the
658
+ logger throws, the client tries again on its next request. Only `api.url` is checked, not where a
659
+ proxy `dispatcher` or `requestOptions.redirect: 'follow'` sends the request.
660
+
661
+ If the connection is protected another way (a service mesh with mTLS, a TLS-terminating sidecar on
662
+ another host), filter that message in your `logger`, or switch to `https://`.
583
663
 
584
664
  ##### Redirects and the Vault token
585
665
 
586
- Requests follow HTTP redirects, because Vault HA clusters answer standby nodes with a 307 to the
587
- active node. Node's `fetch()` strips only `Authorization`, `Cookie` and `Proxy-Authorization` when
588
- a redirect crosses origins — `X-Vault-Token` is **not** on that list and is forwarded. A 307 or 308
589
- also re-sends the request body, so a login is replayed to the redirect target with its credentials:
590
- the AppRole `secret_id`, the Kubernetes or JWT token, the signed IAM request and, with
591
- `distributedClaimAccessToken`, a Microsoft Graph access token that is a credential for systems other
592
- than Vault. A host that can answer for `api.url` can therefore redirect the client and receive its
593
- Vault token and its login credentials, so treat `api.url` (and the DNS and any load balancer in
594
- front of it) as trusted infrastructure.
666
+ Requests follow HTTP redirects only within the origin (scheme, host and port) of `api.url`, the
667
+ way `fetch()` follows them: up to 20 hops, a 307 or 308 re-sends the method and body, and a 303,
668
+ or a 301 or 302 after a `POST`, becomes a `GET` without a body. A redirect to any other origin
669
+ rejects with a `VaultError` ("Refusing to follow a redirect from … to …") and nothing is sent
670
+ there. That includes a change of scheme on the same host: if a load balancer redirects an
671
+ `http://` `api.url` to `https://`, use the `https://` URL. Where `fetch()` itself gives up on a
672
+ redirect (more than 20 hops, or a `Location` that does not parse, is not http(s) or carries
673
+ credentials), the request rejects as it always has, with `TypeError: fetch failed` and the reason
674
+ on `cause`, so catch both if you handle redirect failures.
675
+
676
+ Earlier versions followed every redirect. Node's `fetch()` strips only `Authorization`,
677
+ `Cookie` and `Proxy-Authorization` when a redirect crosses origins, so `X-Vault-Token` went to the
678
+ new origin, a 307 or 308 replayed a login body with its credentials (the AppRole `secret_id`, the
679
+ Kubernetes or JWT token, the signed IAM request, a `distributedClaimAccessToken` Graph token), and
680
+ that origin's answer was returned as Vault's.
681
+
682
+ A Vault HA standby forwards requests to the active node. It answers with a 307 to the active
683
+ node's `api_addr` only when forwarding fails or the client sends `X-Vault-No-Request-Forwarding`.
684
+ If that address is another origin, point `api.url` at a load balancer that routes to the active
685
+ node. A `redirect` in `api.requestOptions` (other than `null`, which reads as not set) is passed
686
+ to `fetch()` as it is: `'follow'` restores
687
+ the old behaviour (then treat `api.url`, its DNS and anything in front of it as trusted with the
688
+ token), `'manual'` returns a redirect as a `VaultHttpError`, and `'error'` fails it.
595
689
 
596
690
  <a name="VaultClient+fillNodeConfig"></a>
597
691
 
@@ -788,6 +882,14 @@ discarded rather than arming a fresh timer, so the client stops holding the even
788
882
  same applies when a token is replaced by a new login — a renewal still running against the previous
789
883
  token cannot overwrite the newer one or cancel its timer.
790
884
 
885
+ The same goes for a login that is in flight when `close()` runs, such as the first `read` of a
886
+ client closed before it settles: the read still resolves and the token is cached for later calls,
887
+ but no refresh timer is armed for it. A call made after `close()` that joins that same login
888
+ shares its token and gets no timer either, just as if `close()` had run right after the login. A
889
+ login that starts after `close()` arms one as before. As after any `close()`, the token is then
890
+ used until it expires; under `token` auth, which cannot log in again, later calls reject with
891
+ `AuthTokenExpiredError`, so close a client only once nothing will use it any more.
892
+
791
893
  <a name="Lease"></a>
792
894
 
793
895
  ### Lease
@@ -795,7 +897,7 @@ token cannot overwrite the newer one or cancel its timer.
795
897
  The object returned by `read()` and `list()` (they resolve to `Promise<Lease>`). Use its
796
898
  accessors to extract the secret data:
797
899
 
798
- * `getValue(key)` ⇒ <code>String</code> — value for a single key. Throws `Requested key does not exist` when the key is absent.
900
+ * `getValue(key)` ⇒ <code>\*</code> — value for a single key, as Vault returned it: usually a string, but `null`, `false`, `0`, objects and arrays come back as they are. Throws `Requested key does not exist` when the key is not an own property of the response data (for an array, such as a `list()` result, that means an index or `length`). A name such as `constructor` or `toString` counts only when Vault sent a field by that name, never as an inherited member.
799
901
  * `getData()` ⇒ <code>Object</code> — a deep-cloned copy of the whole secret data object.
800
902
  * `isRenewable()` ⇒ <code>boolean</code> — whether the underlying lease is renewable.
801
903
  * `getMetadata()` ⇒ <code>Object</code> | <code>undefined</code> — KV v2 version metadata
@@ -816,6 +918,14 @@ the ones it was booted with, the client logs a warning rather than letting the d
816
918
  silently. Use [`VaultClient.get(name)`](#VaultClient.get) to fetch an existing instance, or
817
919
  [`VaultClient.clear(name)`](#VaultClient.clear) before booting to replace one.
818
920
 
921
+ Options are compared as hashes, so no credential is kept for the comparison. Plain objects and
922
+ arrays compare by content, in any key order. Functions and other objects compare by identity, and
923
+ a `Buffer`, `Date`, `URL` or `RegExp` by value. So a logger class instance, an undici `dispatcher`,
924
+ or a logger object built from new functions counts as different even when it would behave the
925
+ same; a plain object that reuses the same functions does not. The warning names the top-level
926
+ options that differ (for example `logger`) and is logged once per instance for each set of them.
927
+ Boot each name once and fetch it with `get()` elsewhere.
928
+
819
929
  **Kind**: static method of [<code>VaultClient</code>](#VaultClient)
820
930
  **Returns**: <code>VaultClient</code>
821
931
 
@@ -846,6 +956,18 @@ Clear named Vault instance
846
956
 
847
957
  If no name passed all named instances will be cleared.
848
958
 
959
+ Only a string names one instance. Any other argument (a number, `null`, an object, a symbol)
960
+ clears every instance not named by a symbol, as it always has. That is rarely what was meant:
961
+ `clear(42)`, or `clear` passed as a callback that receives a code or a resolved value
962
+ (`promise.then(VaultClient.clear)`). So the first such call in a process also emits a
963
+ `DeprecationWarning` with code `NVC_DEP_CLEAR_NON_STRING`, naming the argument's type but not its
964
+ value. Call `clear()` with no argument to clear every instance.
965
+
966
+ Node delivers warnings on the next tick, so the warning is not shown when `clear` runs inside a
967
+ `process.on('exit')` listener, which still clears every instance. `--no-deprecation` silences the
968
+ warning. Under `--throw-deprecation` it becomes an uncaught exception on the next tick, not an
969
+ error thrown by `clear()`.
970
+
849
971
  **Kind**: static method of [<code>VaultClient</code>](#VaultClient)
850
972
 
851
973
  | Param | Type | Description |
@@ -1059,12 +1181,13 @@ try {
1059
1181
 
1060
1182
  | Class | Extends | When thrown |
1061
1183
  |---|---|---|
1062
- | `VaultError` | `Error` | Base class for every error below. Raised directly when mount detection fails (e.g. permission denied) and no `api.engines` override was provided. |
1184
+ | `VaultError` | `Error` | Base class for every error below. Raised directly when mount detection fails (e.g. permission denied) and no `api.engines` override was provided; the failure is on `cause`, so Vault's HTTP status is `err.cause.statusCode`. Vault answers 403 both for a mount the token may not use and for one that does not exist. |
1063
1185
  | `VaultHttpError` | `VaultError` | Vault answered with a non-2xx status. Carries the status as `statusCode` and the parsed body as `error`. `message` (`"<status> - <raw body text>"`) and `error` hold the full response body, which can include secret material (e.g. a partially failed transit batch), so log `statusCode` and `error.errors` rather than the error object itself. |
1064
- | `InvalidArgumentsError` | `VaultError` | Bad arguments or configuration: `boot()` called without options, an unknown instance name passed to `get()`, an unsupported `auth.type`, invalid renewal options, an invalid node-config substitution map, or a KV method path that fails [path validation](#path-validation). |
1186
+ | `InvalidArgumentsError` | `VaultError` | Bad arguments or configuration: `boot()` called without options, an unknown instance name passed to `get()`, an unsupported `auth.type`, invalid renewal options or `api.timeout`, an invalid node-config substitution map, or a KV method path that fails [path validation](#path-validation). |
1065
1187
  | `InvalidAWSCredentialsError` | `InvalidArgumentsError` | `auth.config.credentials` was supplied but is not a usable `accessKeyId` / `secretAccessKey` pair. |
1066
1188
  | `AuthTokenExpiredError` | `VaultError` | The Vault token expired and the backend cannot obtain a new one. `token` auth can never re-authenticate and reaches this error at max TTL even with renewal enabled; other backends can also reach it when re-authentication is disabled or their credential can no longer be replayed. |
1067
1189
  | `UnsupportedOperationError` | `VaultError` | A v2-only method (`deleteVersions`, `undeleteVersions`, `destroyVersions`, `readMetadata`, `deleteMetadata`) was called against a mount that did not resolve as KV v2. |
1190
+ | `RequestTimeoutError` | `VaultError` | A request to Vault did not finish within `api.timeout` milliseconds (see [Request timeouts](#request-timeouts)). Carries the limit as `timeout` and the `fetch()` rejection (a `TimeoutError` DOMException) as `cause`. |
1068
1191
 
1069
1192
  ## Contributing
1070
1193
 
package/index.d.ts CHANGED
@@ -39,6 +39,8 @@ declare namespace VaultClient {
39
39
  apiVersion?: string;
40
40
  namespace?: string;
41
41
  requestOptions?: Record<string, any>;
42
+ /** Milliseconds a request may take before it rejects with RequestTimeoutError; `0` turns it off. Default 120000. */
43
+ timeout?: number | null;
42
44
  kv?: { autoDetect?: boolean };
43
45
  engines?: Record<string, 1 | 2>;
44
46
  }
@@ -119,7 +121,7 @@ declare namespace VaultClient {
119
121
  }
120
122
 
121
123
  interface Lease {
122
- getValue<T = any>(key: string): T;
124
+ getValue<T = any>(key: string | number): T;
123
125
  getData<T = Record<string, any>>(): T;
124
126
  isRenewable(): boolean;
125
127
  getMetadata(): Record<string, any> | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-vault-client",
3
- "version": "3.0.2",
3
+ "version": "3.2.0",
4
4
  "description": "A Vault Client implemented in pure javascript for HashiCorp Vault. It supports variety of Auth Backends and performs lease renewal for issued auth token.",
5
5
  "repository": {
6
6
  "url": "git+https://github.com/namecheap/node-vault-client.git"
@@ -18,9 +18,10 @@
18
18
  "test": "mocha --exit \"test/**/*.test.mjs\"",
19
19
  "test:unit": "mocha \"test/*.test.mjs\"",
20
20
  "test:types": "tsc -p types/tsconfig.json",
21
- "test:e2e": "mocha --exit \"test/e2e/e2e.test.mjs\"",
22
- "test:e2e:kv2": "mocha --exit \"test/e2e/e2e.kv2.test.mjs\"",
23
- "test:e2e:jwt": "mocha --exit \"test/e2e/e2e.jwt.test.mjs\"",
21
+ "test:e2e": "mocha --exit --require test/e2e/renewal-timer-guard.mjs \"test/e2e/e2e.test.mjs\"",
22
+ "test:e2e:kv2": "mocha --exit --require test/e2e/renewal-timer-guard.mjs \"test/e2e/e2e.kv2.test.mjs\"",
23
+ "test:e2e:jwt": "mocha --exit --require test/e2e/renewal-timer-guard.mjs \"test/e2e/e2e.jwt.test.mjs\"",
24
+ "test:e2e:generic": "mocha --exit --require test/e2e/renewal-timer-guard.mjs \"test/e2e/e2e.generic.test.mjs\"",
24
25
  "test:example": "node examples/jwt-auth/vault-jwt-demo.mjs",
25
26
  "coverage": "c8 npm run test:unit",
26
27
  "lint": "eslint src/ test/ examples/",
@@ -54,15 +55,17 @@
54
55
  },
55
56
  "overrides": {
56
57
  "serialize-javascript": "^7.1.4",
57
- "glob": "^13.0.0",
58
+ "glob": "^14.0.3",
58
59
  "diff": "^8.0.3",
59
60
  "brace-expansion": "^5.0.12",
60
- "js-yaml": "^4.3.2"
61
+ "js-yaml": "^4.3.2",
62
+ "minimatch": "^11.0.4"
61
63
  },
62
64
  "devDependencies": {
63
65
  "@eslint/js": "^10.0.1",
64
66
  "c8": "^12.0.0",
65
67
  "chai": "^6.2.2",
68
+ "config": "^4.4.2",
66
69
  "deep-freeze": "^0.0.1",
67
70
  "eslint": "^10.10.0",
68
71
  "globals": "^17.8.0",
package/src/Lease.js CHANGED
@@ -29,15 +29,21 @@ class Lease {
29
29
  }
30
30
 
31
31
  /**
32
- * @param {String} key
33
- * @returns {String}
32
+ * @param {String|number} key - a field name, or an index when the data is an array (a list)
33
+ * @returns {*} the field's value as Vault returned it: a string for most secrets, but
34
+ * `null`, `false`, `0`, an object or an array are returned as they are
35
+ * @throws {Error} 'Requested key does not exist' unless `key` is an own property of the
36
+ * response data whose value is not `undefined`
34
37
  */
35
38
  getValue(key) {
36
- if (this.__data[key] === undefined) {
39
+ const data = this.__data;
40
+ // Own fields only: an inherited member such as `constructor` or `__proto__` is not
41
+ // something Vault returned.
42
+ if (data === null || typeof data !== 'object' || !Object.hasOwn(data, key) || data[key] === undefined) {
37
43
  throw new Error('Requested key does not exist');
38
44
  }
39
45
 
40
- return this.__data[key];
46
+ return data[key];
41
47
  }
42
48
 
43
49
  /**
@@ -36,6 +36,26 @@ function decodePath(path) {
36
36
  }
37
37
  }
38
38
 
39
+ /**
40
+ * The error a caller gets when the detection it started or joined failed. It is built for each
41
+ * caller, so its message names that caller's own path rather than the first caller's. The
42
+ * failure stays on `cause`.
43
+ * safeMessage: this message is logged by the operation's catch handler, so it must not embed a
44
+ * raw response body.
45
+ *
46
+ * @param {Error} err - why the detection failed
47
+ * @param {string} interimKey - the mount the detection was keyed on
48
+ * @param {string} path - the caller's path
49
+ * @returns {VaultError}
50
+ */
51
+ function detectionError(err, interimKey, path) {
52
+ return new VaultError(
53
+ `Failed to detect KV engine version for mount "${interimKey}" (path: ${path}): ${safeMessage(err)}. ` +
54
+ 'Set api.engines or disable autoDetect to bypass detection.',
55
+ { cause: err }
56
+ );
57
+ }
58
+
39
59
  /**
40
60
  * Resolves the KV engine version for a given secret path.
41
61
  *
@@ -215,6 +235,11 @@ class MountResolver {
215
235
  // The shared detection resolved to a different mount — start our own.
216
236
  // Use the full path as key so it won't collide with the first-segment key.
217
237
  return this.__detectWithCache(path, path);
238
+ }, (err) => {
239
+ // A failed detection is shared, as it always was: one request, whatever it
240
+ // answered, so a rate limit or a failed login is not repeated per caller. Only
241
+ // the error is built per caller, so it names that caller's own path.
242
+ throw detectionError(err, interimKey, path);
218
243
  });
219
244
  }
220
245
 
@@ -255,17 +280,14 @@ class MountResolver {
255
280
  .catch((err) => {
256
281
  // Clear in-flight so callers can retry
257
282
  this.__inflight.delete(interimKey);
258
- // safeMessage: this message is logged by the operation's catch handler, so it
259
- // must not embed a raw response body; the original error stays on `cause`.
260
- throw new VaultError(
261
- `Failed to detect KV engine version for mount "${interimKey}" (path: ${path}): ${safeMessage(err)}. ` +
262
- 'Set api.engines or disable autoDetect to bypass detection.',
263
- { cause: err }
264
- );
283
+ throw err;
265
284
  });
266
285
 
286
+ // Callers that join get the raw failure and build their own error (see above).
267
287
  this.__inflight.set(interimKey, promise);
268
- return promise;
288
+ return promise.catch((err) => {
289
+ throw detectionError(err, interimKey, path);
290
+ });
269
291
  }
270
292
 
271
293
  /**