node-vault-client 3.0.2 → 3.1.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 +45 -9
- package/index.d.ts +2 -0
- package/package.json +5 -3
- package/src/VaultApiClient.js +121 -1
- package/src/VaultClient.js +99 -27
- package/src/errors.d.ts +5 -0
- package/src/errors.js +18 -0
package/README.md
CHANGED
|
@@ -505,6 +505,7 @@ Client constructor function.
|
|
|
505
505
|
| options.api.url | <code>String</code> | | the url of the vault server |
|
|
506
506
|
| [options.api.apiVersion] | <code>String</code> | `v1` | |
|
|
507
507
|
| [options.api.requestOptions] | <code>Object</code> | | extra options merged into every HTTP request (see [Custom transport](#custom-transport-proxy--self-signed-tls)) |
|
|
508
|
+
| [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
509
|
| [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
510
|
| [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
511
|
| [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 +560,35 @@ bundle undici 7.x, so pin `undici@^7`.
|
|
|
559
560
|
|
|
560
561
|
##### Request timeouts
|
|
561
562
|
|
|
562
|
-
|
|
563
|
-
`
|
|
563
|
+
Every request to Vault, including logins, `lookup-self` and background `renew-self`, has to
|
|
564
|
+
finish within `api.timeout` milliseconds: **120 000 (2 minutes) by default**. The limit covers the
|
|
565
|
+
whole request: response headers, the body and any redirects. A request that runs out of time
|
|
566
|
+
rejects with `RequestTimeoutError` (its `timeout` property holds the limit, `cause` the
|
|
567
|
+
`fetch()` rejection). With `api.kv.autoDetect`, a mount detection that times out fails the call
|
|
568
|
+
with the usual detection `VaultError`, with `RequestTimeoutError` on its `cause`. A renewal that
|
|
569
|
+
times out is handled like any other failed renewal.
|
|
570
|
+
|
|
571
|
+
`api.timeout` is in milliseconds; `null` or leaving it out gives the default, and a value under
|
|
572
|
+
1000 logs a warning in case it was meant as seconds. Anything but an integer from 0 to 2147483647
|
|
573
|
+
raises `InvalidArgumentsError` when the client is created.
|
|
574
|
+
|
|
575
|
+
The default sits above Vault's own default `max_request_duration` (90 s), so it does not cut
|
|
576
|
+
short a request that a Vault with default settings would still answer. Set it lower to fail
|
|
577
|
+
fast, or higher if your Vault allows longer requests:
|
|
578
|
+
|
|
579
|
+
```javascript
|
|
580
|
+
const vaultClient = VaultClient.boot('main', {
|
|
581
|
+
api: { url: 'https://vault.example.com:8200/', timeout: 10000 },
|
|
582
|
+
auth: { type: 'token', config: { token: '...' } },
|
|
583
|
+
});
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
`timeout: 0` turns the limit off, as it was before `api.timeout` existed. What remains then
|
|
587
|
+
are the timeouts of the dispatcher `fetch()` uses: Node's default one gives up after 300 s
|
|
588
|
+
without response headers (`TypeError: fetch failed`, `cause.code` `UND_ERR_HEADERS_TIMEOUT`) or
|
|
589
|
+
300 s without body data (`UND_ERR_BODY_TIMEOUT`). Those limits apply per phase, so a body that
|
|
590
|
+
keeps trickling in is never cut off. Any timeouts set on a custom dispatcher still apply
|
|
591
|
+
alongside `api.timeout`, and whichever fires first rejects the request:
|
|
564
592
|
|
|
565
593
|
```javascript
|
|
566
594
|
const { Agent } = require('undici');
|
|
@@ -574,12 +602,11 @@ const vaultClient = VaultClient.boot('main', {
|
|
|
574
602
|
});
|
|
575
603
|
```
|
|
576
604
|
|
|
577
|
-
A
|
|
578
|
-
`
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
later ones with `TimeoutError`. Pass a fresh signal per call, or use the dispatcher above.
|
|
605
|
+
A `signal` in `requestOptions` still aborts every request in flight when it fires (with the
|
|
606
|
+
signal's own reason, not `RequestTimeoutError`), so an `AbortController` can cancel requests
|
|
607
|
+
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
|
|
608
|
+
shared by every request the client makes, so it works for the first calls and then aborts all
|
|
609
|
+
later ones. Use `api.timeout` instead.
|
|
583
610
|
|
|
584
611
|
##### Redirects and the Vault token
|
|
585
612
|
|
|
@@ -816,6 +843,14 @@ the ones it was booted with, the client logs a warning rather than letting the d
|
|
|
816
843
|
silently. Use [`VaultClient.get(name)`](#VaultClient.get) to fetch an existing instance, or
|
|
817
844
|
[`VaultClient.clear(name)`](#VaultClient.clear) before booting to replace one.
|
|
818
845
|
|
|
846
|
+
Options are compared as hashes, so no credential is kept for the comparison. Plain objects and
|
|
847
|
+
arrays compare by content, in any key order. Functions and other objects compare by identity, and
|
|
848
|
+
a `Buffer`, `Date`, `URL` or `RegExp` by value. So a logger class instance, an undici `dispatcher`,
|
|
849
|
+
or a logger object built from new functions counts as different even when it would behave the
|
|
850
|
+
same; a plain object that reuses the same functions does not. The warning names the top-level
|
|
851
|
+
options that differ (for example `logger`) and is logged once per instance for each set of them.
|
|
852
|
+
Boot each name once and fetch it with `get()` elsewhere.
|
|
853
|
+
|
|
819
854
|
**Kind**: static method of [<code>VaultClient</code>](#VaultClient)
|
|
820
855
|
**Returns**: <code>VaultClient</code>
|
|
821
856
|
|
|
@@ -1061,10 +1096,11 @@ try {
|
|
|
1061
1096
|
|---|---|---|
|
|
1062
1097
|
| `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. |
|
|
1063
1098
|
| `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
|
|
1099
|
+
| `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
1100
|
| `InvalidAWSCredentialsError` | `InvalidArgumentsError` | `auth.config.credentials` was supplied but is not a usable `accessKeyId` / `secretAccessKey` pair. |
|
|
1066
1101
|
| `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
1102
|
| `UnsupportedOperationError` | `VaultError` | A v2-only method (`deleteVersions`, `undeleteVersions`, `destroyVersions`, `readMetadata`, `deleteMetadata`) was called against a mount that did not resolve as KV v2. |
|
|
1103
|
+
| `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
1104
|
|
|
1069
1105
|
## Contributing
|
|
1070
1106
|
|
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
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-vault-client",
|
|
3
|
-
"version": "3.0
|
|
3
|
+
"version": "3.1.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"
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"test:e2e": "mocha --exit \"test/e2e/e2e.test.mjs\"",
|
|
22
22
|
"test:e2e:kv2": "mocha --exit \"test/e2e/e2e.kv2.test.mjs\"",
|
|
23
23
|
"test:e2e:jwt": "mocha --exit \"test/e2e/e2e.jwt.test.mjs\"",
|
|
24
|
+
"test:e2e:generic": "mocha --exit \"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,10 +55,11 @@
|
|
|
54
55
|
},
|
|
55
56
|
"overrides": {
|
|
56
57
|
"serialize-javascript": "^7.1.4",
|
|
57
|
-
"glob": "^
|
|
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",
|
package/src/VaultApiClient.js
CHANGED
|
@@ -1,8 +1,86 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const { defaultMaxListeners, getMaxListeners, setMaxListeners } = require('events');
|
|
3
4
|
const errors = require('./errors');
|
|
4
5
|
const { trimTrailingSlashes } = require('./slashes');
|
|
5
6
|
|
|
7
|
+
// Above Vault's own default max_request_duration (90 s), so no request a default-configured
|
|
8
|
+
// Vault would still answer is cut short, and below the 300 s headers/body timeouts of the
|
|
9
|
+
// dispatcher Node's fetch() uses by default.
|
|
10
|
+
const DEFAULT_TIMEOUT_MS = 120000;
|
|
11
|
+
// setTimeout() fires after 1 ms for a delay that does not fit in a signed 32-bit integer.
|
|
12
|
+
const MAX_TIMEOUT_MS = 2147483647;
|
|
13
|
+
// The listener limit fetch() gives a signal it is passed (see deadline()).
|
|
14
|
+
const FETCH_MAX_LISTENERS = 1500;
|
|
15
|
+
|
|
16
|
+
function validateTimeout(timeout) {
|
|
17
|
+
// null reads as "not set", as it does in a config file.
|
|
18
|
+
if (timeout === undefined || timeout === null) {
|
|
19
|
+
return DEFAULT_TIMEOUT_MS;
|
|
20
|
+
}
|
|
21
|
+
if (!Number.isInteger(timeout) || timeout < 0 || timeout > MAX_TIMEOUT_MS) {
|
|
22
|
+
throw new errors.InvalidArgumentsError(
|
|
23
|
+
`"api.timeout" should be an integer number of milliseconds from 0 to ${MAX_TIMEOUT_MS}, ` +
|
|
24
|
+
`got ${String(timeout)}`
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
return timeout;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* What fetch() accepts as a signal: Node 20 and 22 take any object with a boolean `aborted`
|
|
32
|
+
* and an `addEventListener` (an abort-controller polyfill, a signal from another realm).
|
|
33
|
+
*/
|
|
34
|
+
function isSignalLike(value) {
|
|
35
|
+
return value !== null && typeof value === 'object'
|
|
36
|
+
&& typeof value.aborted === 'boolean' && typeof value.addEventListener === 'function';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A per-request deadline. A plain timer, cleared once the request settles, rather than
|
|
41
|
+
* AbortSignal.timeout(), whose timer cannot be cancelled and so outlives every fast request.
|
|
42
|
+
* A `requestOptions.signal` still aborts the request: it is followed through a listener that
|
|
43
|
+
* is removed at the same point, since that signal lives as long as the client.
|
|
44
|
+
*
|
|
45
|
+
* @param {number} ms
|
|
46
|
+
* @param {AbortSignal} [signal] - `requestOptions.signal`
|
|
47
|
+
* @returns {{signal: AbortSignal, isTimeout: function(*): boolean, clear: function(): void}}
|
|
48
|
+
*/
|
|
49
|
+
function deadline(ms, signal) {
|
|
50
|
+
const controller = new AbortController();
|
|
51
|
+
const reason = new DOMException('The operation was aborted due to timeout', 'TimeoutError');
|
|
52
|
+
const timer = setTimeout(() => controller.abort(reason), ms);
|
|
53
|
+
const follow = () => controller.abort(signal.reason);
|
|
54
|
+
if (signal) {
|
|
55
|
+
if (signal.aborted) {
|
|
56
|
+
follow();
|
|
57
|
+
} else {
|
|
58
|
+
// fetch() raised a signal's listener limit to 1500 when it was still at the default;
|
|
59
|
+
// it now gets the client's own signal, so do the same here, or more than ten
|
|
60
|
+
// requests in flight on one signal print MaxListenersExceededWarning (Node 20).
|
|
61
|
+
try {
|
|
62
|
+
if (getMaxListeners(signal) === defaultMaxListeners) {
|
|
63
|
+
setMaxListeners(FETCH_MAX_LISTENERS, signal);
|
|
64
|
+
}
|
|
65
|
+
} catch {
|
|
66
|
+
// not an EventTarget (a polyfill): it has no listener limit to raise
|
|
67
|
+
}
|
|
68
|
+
signal.addEventListener('abort', follow, { once: true });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
signal: controller.signal,
|
|
74
|
+
isTimeout: (err) => err === reason,
|
|
75
|
+
clear: () => {
|
|
76
|
+
clearTimeout(timer);
|
|
77
|
+
if (signal && typeof signal.removeEventListener === 'function') {
|
|
78
|
+
signal.removeEventListener('abort', follow);
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
6
84
|
/**
|
|
7
85
|
* Joins URL segments with single slashes while preserving the leading
|
|
8
86
|
* protocol (e.g. "https://"). Replaces the former `url-join` dependency.
|
|
@@ -35,6 +113,9 @@ class VaultApiClient {
|
|
|
35
113
|
* (`method`, `body`) always take precedence; `headers` are merged with the
|
|
36
114
|
* per-request headers winning. Stored by reference (not deep-cloned) so live
|
|
37
115
|
* objects such as a Dispatcher keep their prototype and remain usable.
|
|
116
|
+
* @param {number} [config.timeout=120000] - milliseconds a request may take, response
|
|
117
|
+
* headers, body and redirects included, before it rejects with
|
|
118
|
+
* {@link RequestTimeoutError}. `0` turns the limit off; `null` gives the default.
|
|
38
119
|
* @param {Object} logger
|
|
39
120
|
* @param {String} [namespace] - Vault namespace. When set, `X-Vault-Namespace` is added to
|
|
40
121
|
* every request (login, token lookup/renewal, and secret operations) so namespacing is
|
|
@@ -55,6 +136,12 @@ class VaultApiClient {
|
|
|
55
136
|
this.__config.requestOptions = requestOptions;
|
|
56
137
|
}
|
|
57
138
|
|
|
139
|
+
this.__timeout = validateTimeout(this.__config.timeout);
|
|
140
|
+
// 3.0.x ignored an api.timeout key, so a config may still carry one written in seconds.
|
|
141
|
+
if (this.__timeout > 0 && this.__timeout < 1000) {
|
|
142
|
+
logger.warn('"api.timeout" is %d ms; it is in milliseconds, not seconds', this.__timeout);
|
|
143
|
+
}
|
|
144
|
+
|
|
58
145
|
this.__namespace = namespace !== undefined ? namespace : this.__config.namespace;
|
|
59
146
|
this._logger = logger;
|
|
60
147
|
}
|
|
@@ -102,7 +189,28 @@ class VaultApiClient {
|
|
|
102
189
|
uri
|
|
103
190
|
);
|
|
104
191
|
|
|
105
|
-
|
|
192
|
+
// A requestOptions.signal that fetch() would reject is passed on untouched, without the
|
|
193
|
+
// deadline, so fetch() rejects it as it always has. The deadline is armed last, and
|
|
194
|
+
// cleared if fetch() throws synchronously, so no timer outlives a failed call.
|
|
195
|
+
const userSignal = requestOptions.signal;
|
|
196
|
+
const limit = this.__timeout > 0 && (userSignal === undefined || userSignal === null
|
|
197
|
+
|| isSignalLike(userSignal))
|
|
198
|
+
? deadline(this.__timeout, userSignal || undefined)
|
|
199
|
+
: null;
|
|
200
|
+
let pending;
|
|
201
|
+
if (limit === null) {
|
|
202
|
+
pending = fetch(uri, options);
|
|
203
|
+
} else {
|
|
204
|
+
options.signal = limit.signal;
|
|
205
|
+
try {
|
|
206
|
+
pending = fetch(uri, options);
|
|
207
|
+
} catch (err) {
|
|
208
|
+
limit.clear();
|
|
209
|
+
throw err;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const request = pending.then((response) => {
|
|
106
214
|
return response.text().then((text) => {
|
|
107
215
|
let body;
|
|
108
216
|
if (text) {
|
|
@@ -127,6 +235,18 @@ class VaultApiClient {
|
|
|
127
235
|
return body;
|
|
128
236
|
});
|
|
129
237
|
});
|
|
238
|
+
if (limit === null) {
|
|
239
|
+
return request;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return request
|
|
243
|
+
.catch((err) => {
|
|
244
|
+
if (limit.isTimeout(err)) {
|
|
245
|
+
throw new errors.RequestTimeoutError(this.__timeout, { cause: err });
|
|
246
|
+
}
|
|
247
|
+
throw err;
|
|
248
|
+
})
|
|
249
|
+
.finally(limit.clear);
|
|
130
250
|
}
|
|
131
251
|
}
|
|
132
252
|
|
package/src/VaultClient.js
CHANGED
|
@@ -18,51 +18,105 @@ const { trimTrailingSlashes } = require('./slashes');
|
|
|
18
18
|
const noop = () => {};
|
|
19
19
|
const vaultInstances = {};
|
|
20
20
|
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
|
|
21
|
+
// Functions, and objects that are not plain data (an undici dispatcher, a logger class
|
|
22
|
+
// instance), compare by identity. Their own enumerable keys say nothing about what they do:
|
|
23
|
+
// structurally, any two dispatchers look alike, and so do two loggers built from different
|
|
24
|
+
// functions, so a boot() that passed a different one was ignored without a warning (#224).
|
|
25
|
+
// A Buffer, Date, URL or RegExp compares by value, so an equal one re-created per call still
|
|
26
|
+
// matches. No user code (getters, toJSON, Proxy traps) decides the comparison.
|
|
27
|
+
//
|
|
28
|
+
// Per-process ids, in a WeakMap so that fingerprinting never keeps an option (or the closure
|
|
29
|
+
// state it captures) alive.
|
|
30
|
+
const identityIds = new WeakMap();
|
|
31
|
+
let nextIdentityId = 0;
|
|
32
|
+
|
|
33
|
+
function identityId(value) {
|
|
34
|
+
if (!identityIds.has(value)) {
|
|
35
|
+
identityIds.set(value, ++nextIdentityId);
|
|
36
|
+
}
|
|
37
|
+
return identityIds.get(value);
|
|
38
|
+
}
|
|
25
39
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
40
|
+
/**
|
|
41
|
+
* How an object takes part in the fingerprint: 'array' and 'plain' by content, 'value' by
|
|
42
|
+
* {@link valueOf}, anything else by identity. Plain includes a null prototype and an
|
|
43
|
+
* Object.prototype from another realm (vm, a test runner's structuredClone). A Proxy whose
|
|
44
|
+
* traps throw compares by identity rather than making boot() throw.
|
|
45
|
+
*/
|
|
46
|
+
function kindOf(value) {
|
|
47
|
+
try {
|
|
48
|
+
if (Array.isArray(value)) {
|
|
49
|
+
return 'array';
|
|
50
|
+
}
|
|
51
|
+
if (Buffer.isBuffer(value) || value instanceof Date || value instanceof URL || value instanceof RegExp) {
|
|
52
|
+
return 'value';
|
|
53
|
+
}
|
|
54
|
+
const proto = Object.getPrototypeOf(value);
|
|
55
|
+
return proto === null || Object.getPrototypeOf(proto) === null ? 'plain' : 'identity';
|
|
56
|
+
} catch {
|
|
57
|
+
return 'identity';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
30
60
|
|
|
31
|
-
function
|
|
32
|
-
if (
|
|
33
|
-
|
|
61
|
+
function valueOf(value) {
|
|
62
|
+
if (Buffer.isBuffer(value)) {
|
|
63
|
+
return `buffer:${value.toString('base64')}`;
|
|
34
64
|
}
|
|
35
|
-
|
|
65
|
+
if (value instanceof Date) {
|
|
66
|
+
return `date:${value.getTime()}`;
|
|
67
|
+
}
|
|
68
|
+
return `${value instanceof URL ? 'url' : 'regexp'}:${JSON.stringify(String(value))}`;
|
|
36
69
|
}
|
|
37
70
|
|
|
38
71
|
/**
|
|
39
|
-
* Order-insensitive canonical form of
|
|
40
|
-
*
|
|
41
|
-
*
|
|
72
|
+
* Order-insensitive canonical form of a boot option, hashed so that no credential is retained
|
|
73
|
+
* just to compare two boot() calls. Plain objects and arrays compare by content, functions and
|
|
74
|
+
* other objects by identity, and a Buffer, Date, URL or RegExp by value.
|
|
42
75
|
*/
|
|
43
|
-
function fingerprintOptions(value, seen
|
|
76
|
+
function fingerprintOptions(value, seen) {
|
|
44
77
|
if (typeof value === 'function') {
|
|
45
|
-
return
|
|
78
|
+
return `fn#${identityId(value)}`;
|
|
79
|
+
}
|
|
80
|
+
if (typeof value === 'bigint') {
|
|
81
|
+
return `${value}n`;
|
|
46
82
|
}
|
|
47
83
|
if (value === null || typeof value !== 'object') {
|
|
48
84
|
return JSON.stringify(value) || String(value);
|
|
49
85
|
}
|
|
86
|
+
const kind = kindOf(value);
|
|
87
|
+
if (kind === 'value') {
|
|
88
|
+
return valueOf(value);
|
|
89
|
+
}
|
|
90
|
+
if (kind === 'identity') {
|
|
91
|
+
return `obj#${identityId(value)}`;
|
|
92
|
+
}
|
|
50
93
|
seen = seen || new Set();
|
|
51
94
|
if (seen.has(value)) {
|
|
52
95
|
return 'circular';
|
|
53
96
|
}
|
|
54
97
|
seen.add(value);
|
|
55
|
-
if (
|
|
98
|
+
if (kind === 'array') {
|
|
56
99
|
return `[${value.map((item) => fingerprintOptions(item, seen)).join(',')}]`;
|
|
57
100
|
}
|
|
58
101
|
const body = Object.keys(value).sort()
|
|
59
|
-
.map((key) => `${JSON.stringify(key)}:${fingerprintOptions(value[key], seen
|
|
102
|
+
.map((key) => `${JSON.stringify(key)}:${fingerprintOptions(value[key], seen)}`)
|
|
60
103
|
.join(',');
|
|
61
104
|
return `{${body}}`;
|
|
62
105
|
}
|
|
63
106
|
|
|
64
|
-
|
|
65
|
-
|
|
107
|
+
/** One digest per top-level option, so a mismatch can name the options that differ. */
|
|
108
|
+
function optionsDigests(options) {
|
|
109
|
+
const digests = new Map();
|
|
110
|
+
for (const key of Object.keys(options)) {
|
|
111
|
+
digests.set(key, crypto.createHash('sha256').update(fingerprintOptions(options[key])).digest('hex'));
|
|
112
|
+
}
|
|
113
|
+
return digests;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The top-level options whose digests differ, sorted; empty when the options match. */
|
|
117
|
+
function differingOptions(before, after) {
|
|
118
|
+
const keys = new Set([...before.keys(), ...after.keys()]);
|
|
119
|
+
return [...keys].filter((key) => before.get(key) !== after.get(key)).sort();
|
|
66
120
|
}
|
|
67
121
|
|
|
68
122
|
// fetch() parses the joined URL per the WHATWG URL Standard: '?' starts the query, '#' the
|
|
@@ -145,6 +199,9 @@ class VaultClient {
|
|
|
145
199
|
* @param {Object} [options.api.requestOptions] - extra options merged into every HTTP
|
|
146
200
|
* request (e.g. an undici `dispatcher` for a proxy/SOCKS agent or custom TLS/CA trust).
|
|
147
201
|
* See {@link VaultApiClient#constructor}.
|
|
202
|
+
* @param {number} [options.api.timeout=120000] - milliseconds a request may take, response
|
|
203
|
+
* headers, body and redirects included, before it rejects with RequestTimeoutError.
|
|
204
|
+
* `0` turns the limit off.
|
|
148
205
|
* @param {Object} [options.api.kv] - KV engine options
|
|
149
206
|
* @param {boolean} [options.api.kv.autoDetect=false] - When true, auto-detect KV version per mount
|
|
150
207
|
* @param {Object} [options.api.engines] - Mount-to-version overrides, e.g. { secret: 2, legacy: 1 }
|
|
@@ -227,8 +284,15 @@ class VaultClient {
|
|
|
227
284
|
* `options` are used only when the instance is first created. A later call for a name
|
|
228
285
|
* that already exists returns the existing instance and ignores the options it was
|
|
229
286
|
* given; when those options differ from the ones the instance was booted with, that is
|
|
230
|
-
* logged as a warning
|
|
231
|
-
*
|
|
287
|
+
* logged as a warning, naming the top-level options that differ, rather than passing
|
|
288
|
+
* silently. The warning is logged once per instance for each set of differing options.
|
|
289
|
+
* Use {@link VaultClient.get} to fetch an existing instance, or {@link VaultClient.clear}
|
|
290
|
+
* to replace one.
|
|
291
|
+
*
|
|
292
|
+
* Options are compared as hashes, so no credential is kept for the comparison. Plain
|
|
293
|
+
* objects and arrays compare by content, in any key order. Functions and other objects (a
|
|
294
|
+
* logger class instance, an undici dispatcher) compare by identity, and a Buffer, Date,
|
|
295
|
+
* URL or RegExp by value.
|
|
232
296
|
*
|
|
233
297
|
* @param {String} name - Vault instance name
|
|
234
298
|
* @param {Object} options - options for {@link VaultClient#constructor}. Required on
|
|
@@ -243,14 +307,22 @@ class VaultClient {
|
|
|
243
307
|
let instance = vaultInstances[name];
|
|
244
308
|
if (instance === undefined) {
|
|
245
309
|
vaultInstances[name] = instance = new VaultClient(options);
|
|
246
|
-
instance.
|
|
247
|
-
|
|
310
|
+
instance.__bootDigests = optionsDigests(options);
|
|
311
|
+
instance.__bootWarned = new Set();
|
|
312
|
+
return instance;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Once per instance for each set of differing options: code that re-creates its logger
|
|
316
|
+
// for every boot() call would otherwise log this on every call.
|
|
317
|
+
const differing = differingOptions(instance.__bootDigests, optionsDigests(options)).join(', ');
|
|
318
|
+
if (differing !== '' && !instance.__bootWarned.has(differing)) {
|
|
319
|
+
instance.__bootWarned.add(differing);
|
|
248
320
|
instance.__log.warn(
|
|
249
321
|
'VaultClient.boot("%s") was called with different options than the existing ' +
|
|
250
322
|
'instance was booted with; the existing instance is returned and these options ' +
|
|
251
323
|
'are ignored. Use VaultClient.get("%s") to fetch it, or VaultClient.clear("%s") ' +
|
|
252
|
-
'first to replace it.',
|
|
253
|
-
name, name, name
|
|
324
|
+
'first to replace it. Differing options: %s.',
|
|
325
|
+
name, name, name, differing
|
|
254
326
|
);
|
|
255
327
|
}
|
|
256
328
|
return instance;
|
package/src/errors.d.ts
CHANGED
package/src/errors.js
CHANGED
|
@@ -41,6 +41,23 @@ class VaultHttpError extends VaultError {
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* A request to Vault did not complete within `api.timeout` milliseconds: response headers,
|
|
46
|
+
* body and any redirects included. The `fetch()` rejection (a `TimeoutError` DOMException)
|
|
47
|
+
* is on `cause`.
|
|
48
|
+
*/
|
|
49
|
+
class RequestTimeoutError extends VaultError {
|
|
50
|
+
/**
|
|
51
|
+
* @param {number} timeout - the `api.timeout` that elapsed, in milliseconds. Exposed as
|
|
52
|
+
* `error.timeout`.
|
|
53
|
+
* @param {{cause?: *}} [options]
|
|
54
|
+
*/
|
|
55
|
+
constructor(timeout, options) {
|
|
56
|
+
super(`Vault request timed out after ${timeout} ms (api.timeout)`, options);
|
|
57
|
+
this.timeout = timeout;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
44
61
|
module.exports = {
|
|
45
62
|
VaultError,
|
|
46
63
|
InvalidArgumentsError,
|
|
@@ -48,4 +65,5 @@ module.exports = {
|
|
|
48
65
|
AuthTokenExpiredError,
|
|
49
66
|
UnsupportedOperationError,
|
|
50
67
|
VaultHttpError,
|
|
68
|
+
RequestTimeoutError,
|
|
51
69
|
};
|