diff --git a/README.md b/README.md
index 6910081..421c959 100644
--- a/README.md
+++ b/README.md
@@ -315,6 +315,152 @@ If no name passed all named instances will be cleared.
| --- | --- | --- |
| [name] | String | Vault instance name, all instances will be cleared if no name were passed |
+## KV v2 & generic backends
+
+### Overview
+
+By default the client behaves exactly as before (KV v1 / raw passthrough). To enable transparent
+KV v2 support set `api.kv.autoDetect: true` **or** supply a static `api.engines` map. Either
+option activates path-rewriting and response-unwrapping; callers do not need to know the engine
+version.
+
+### Configuration options
+
+| Option | Type | Default | Description |
+|---|---|---|---|
+| `api.kv.autoDetect` | `boolean` | `false` | Auto-detect the KV version of each mount on first use via `GET sys/internal/ui/mounts/`. |
+| `api.engines` | `Object` | `{}` | Static mount-to-version map, e.g. `{ secret: 2, legacy: 1 }`. Overrides detection; use this when the token lacks permission on `sys/internal/ui/mounts`. |
+
+Both options can be combined: `engines` acts as an override — matching mounts skip detection
+while unmatched mounts are auto-detected (when `autoDetect: true`).
+
+### Auto-detect example
+
+```javascript
+const client = VaultClient.boot('main', {
+ api: {
+ url: 'https://vault.example.com:8200/',
+ kv: { autoDetect: true },
+ },
+ auth: { type: 'token', config: { token: '...' } },
+});
+
+// Works transparently on both KV v1 and KV v2 mounts
+const lease = await client.read('secret/my-app/config');
+console.log(lease.getData()); // the secret object
+console.log(lease.getMetadata()); // KV v2 version metadata (undefined on v1)
+```
+
+### Static engines override example
+
+```javascript
+const client = VaultClient.boot('main', {
+ api: {
+ url: 'https://vault.example.com:8200/',
+ engines: { secret: 2, legacy: 1 },
+ },
+ auth: { type: 'token', config: { token: '...' } },
+});
+```
+
+### KV v2-specific methods
+
+These methods require a KV v2 mount and throw `UnsupportedOperationError` on v1 / non-KV mounts.
+
+```javascript
+// Soft-delete specific versions
+await client.deleteVersions('secret/foo', [1, 2]);
+
+// Restore soft-deleted versions
+await client.undeleteVersions('secret/foo', [1]);
+
+// Permanently destroy versions
+await client.destroyVersions('secret/foo', [1, 2]);
+
+// Read version metadata (current_version, versions map, etc.)
+const meta = await client.readMetadata('secret/foo');
+
+// Delete all metadata and version history (permanent)
+await client.deleteMetadata('secret/foo');
+```
+
+### update() — merge-patch
+
+```javascript
+// PATCH a subset of keys without overwriting others (KV v2)
+await client.update('secret/foo', { password: 'new-value' });
+// Sends PATCH secret/data/foo with Content-Type: application/merge-patch+json
+```
+
+### delete()
+
+```javascript
+// Soft-delete the latest version on KV v2; DELETE on v1/passthrough
+await client.delete('secret/foo');
+```
+
+### request() — raw escape hatch
+
+For any Vault backend that does not benefit from KV path rewriting use `request()`. It sends the
+literal path with no rewriting or response normalisation and returns the parsed body directly.
+
+```javascript
+// Encrypt with Transit engine — path must not be rewritten
+const result = await client.request('POST', 'transit/encrypt/my-key', {
+ plaintext: Buffer.from('hello').toString('base64'),
+});
+console.log(result.data.ciphertext);
+```
+
+### Lease.getMetadata()
+
+`getMetadata()` is additive — existing code is unaffected.
+
+```javascript
+const lease = await client.read('secret/my-app/db');
+lease.getData(); // the secret values
+lease.getMetadata(); // { version, created_time, deletion_time, destroyed, custom_metadata }
+ // undefined on KV v1 / passthrough mounts
+```
+
+### Path requirements when autoDetect / engines are active
+
+When `autoDetect: true` or `api.engines` is set, the client rewrites logical paths to the
+correct KV v2 API paths automatically (e.g. `secret/foo` → `secret/data/foo` for reads).
+**Callers must pass logical paths — do not include the internal KV v2 segments** (`data/`,
+`metadata/`, `delete/`, `undelete/`, `destroy/`) in the path argument:
+
+```javascript
+// Correct — logical path only
+await client.read('secret/my-app/config');
+
+// Wrong — double-rewrite: 'secret/data/foo' becomes 'secret/data/data/foo' on the wire
+await client.read('secret/data/foo');
+```
+
+If you need to send a fully-literal Vault API path (e.g. when working with non-KV backends or
+when you have already constructed the complete path), use `request()` which bypasses all path
+rewriting:
+
+```javascript
+// Literal path, no rewriting
+await client.request('GET', 'secret/data/foo');
+```
+
+### Mount detection caching
+
+- Each canonical mount is detected at most once per `VaultClient` instance.
+- Concurrent first-touch requests for the same mount share a single in-flight detection promise.
+- The detection endpoint used is `GET sys/internal/ui/mounts/` (readable by any authenticated token).
+- When the token lacks permission on that endpoint, set `api.engines` to skip detection.
+
+### Error classes
+
+| Class | When thrown |
+|---|---|
+| `UnsupportedOperationError` | A v2-only method (`deleteVersions`, `undeleteVersions`, `destroyVersions`, `readMetadata`, `deleteMetadata`) is called against a non-v2 mount. |
+| `VaultError` | Mount detection fails (e.g. permission denied) and no `api.engines` override was provided. |
+
## Contributing
Contributions are welcome! Please read the [contributing guide](CONTRIBUTING.md) to get started,
diff --git a/src/Lease.js b/src/Lease.js
index 43e70bb..6476cfc 100644
--- a/src/Lease.js
+++ b/src/Lease.js
@@ -6,13 +6,15 @@ class Lease {
leaseId,
leaseDuration,
renewable,
- data
+ data,
+ metadata
) {
this.__requestId = requestId;
this.__leaseId = leaseId;
this.__leaseDuration = leaseDuration;
this.__renewable = renewable;
this.__data = data === undefined ? {} : data;
+ this.__metadata = metadata;
}
static fromResponse(response) {
@@ -21,7 +23,8 @@ class Lease {
response.lease_id,
response.lease_duration,
response.renewable,
- response.data
+ response.data,
+ response.metadata
);
}
@@ -50,6 +53,16 @@ class Lease {
isRenewable() {
return this.__renewable;
}
+
+ /**
+ * KV v2 version metadata object (created_time, version, etc.).
+ * Returns undefined for KV v1 / non-KV leases.
+ *
+ * @returns {Object|undefined}
+ */
+ getMetadata() {
+ return this.__metadata;
+ }
}
module.exports = Lease;
diff --git a/src/MountResolver.js b/src/MountResolver.js
new file mode 100644
index 0000000..713ef74
--- /dev/null
+++ b/src/MountResolver.js
@@ -0,0 +1,167 @@
+'use strict';
+
+const { VaultError } = require('./errors');
+
+/**
+ * Resolves the KV engine version for a given secret path.
+ *
+ * Responsibilities:
+ * 1. Check engines override map first (longest-prefix match, no I/O).
+ * 2. Auto-detect via detectFn(path) -> { data: { path, type, options } }.
+ * 3. Cache by canonical mount path; de-duplicate in-flight detections.
+ * 4. On failure, throw VaultError with actionable guidance.
+ *
+ * @param {Function} detectFn - async (path: string) => Vault mount-info response
+ * @param {Object} enginesOverride - { [mountPrefix]: version }
+ * @param {Object} logger - logger with .debug(), .error() etc.
+ * @param {Object} [opts] - additional options
+ * @param {boolean} [opts.disabled] - if true, always return passthrough (version 1)
+ */
+class MountResolver {
+ constructor(detectFn, enginesOverride, logger, opts) {
+ this.__detectFn = detectFn;
+ this.__engines = enginesOverride || {};
+ this.__log = logger;
+ this.__disabled = opts && opts.disabled === true;
+
+ // Cache: canonical mount path (no trailing slash) -> { mount, version, type }
+ this.__cache = new Map();
+ // In-flight promises: canonical mount path -> Promise<{mount,version,type}>
+ this.__inflight = new Map();
+ }
+
+ /**
+ * Resolve the engine version for the given path.
+ *
+ * @param {string} path - full logical path (e.g. "secret/foo/bar")
+ * @returns {Promise<{mount: string, version: number, type: string}>}
+ */
+ resolve(path) {
+ // 1. Check engines override (longest-prefix match). This applies even when
+ // auto-detection is disabled, so listed mounts resolve with no I/O.
+ const override = this.__enginesOverrideLookup(path);
+ if (override !== null) {
+ this.__log.debug('MountResolver: engines override for %s -> v%d', path, override.version);
+ return Promise.resolve(override);
+ }
+
+ // 2. When detection is disabled (autoDetect off), an unlisted mount is a
+ // passthrough (v1) — never issue a sys/internal/ui/mounts round-trip.
+ // engines is the documented fallback for Vaults that deny detection.
+ if (this.__disabled) {
+ return Promise.resolve({ mount: this.__extractMount(path), version: 1, type: 'kv' });
+ }
+
+ // 3. Auto-detect: use canonical mount path as cache key once we know it.
+ // For the in-flight grouping key we use the first path segment so that
+ // concurrent reads of sub-paths of the SAME mount (e.g. secret/a and
+ // secret/b) share one detection. After the shared promise resolves we
+ // verify that the canonical mount returned actually prefixes this path;
+ // if it does not (two distinct multi-segment mounts share a first segment,
+ // e.g. team/kvA/* and team/kvB/*), we start a fresh detection using the
+ // full path as key so each mount gets its own call.
+ const interimKey = path.split('/')[0];
+ return this.__detectWithCache(interimKey, path);
+ }
+
+ // -------------------------------------------------------------------------
+ // Private helpers
+ // -------------------------------------------------------------------------
+
+ /**
+ * Longest-prefix match against the engines override map.
+ * Returns { mount, version } or null if no match found.
+ */
+ __enginesOverrideLookup(path) {
+ const entries = Object.entries(this.__engines);
+ if (entries.length === 0) return null;
+
+ // Sort by descending key length (longest first) for prefix match
+ entries.sort((a, b) => b[0].length - a[0].length);
+
+ for (const [prefix, version] of entries) {
+ const normalised = prefix.replace(/\/+$/, '');
+ if (path === normalised || path.startsWith(normalised + '/')) {
+ return { mount: normalised, version: Number(version), type: 'kv' };
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Resolve via cache or detectFn. Groups concurrent calls by interimKey so
+ * only one detectFn call is in-flight per first-segment at a time.
+ *
+ * Followers that join a shared in-flight promise verify that the resolved
+ * canonical mount actually prefixes their path. If it does not (two distinct
+ * multi-segment mounts share a first segment, e.g. "team/kvA" and "team/kvB"),
+ * the follower falls through to start its own fresh detection keyed on its
+ * full path, so each mount gets its own accurate detection result.
+ */
+ __detectWithCache(interimKey, path) {
+ // Check persistent cache first (keyed on canonical mount path)
+ for (const [mountKey, entry] of this.__cache) {
+ if (path === mountKey || path.startsWith(mountKey + '/')) {
+ return Promise.resolve(entry);
+ }
+ }
+
+ // Check in-flight for this interim key (first segment or full path)
+ if (this.__inflight.has(interimKey)) {
+ // Join the existing in-flight promise but verify the result matches our path.
+ // If the detection was for a different sub-mount, start a fresh detection.
+ return this.__inflight.get(interimKey).then((entry) => {
+ if (path === entry.mount || path.startsWith(entry.mount + '/')) {
+ return entry;
+ }
+ // The shared detection resolved to a different mount — start our own.
+ // Use the full path as key so it won't collide with the first-segment key.
+ return this.__detectWithCache(path, path);
+ });
+ }
+
+ // Start a new detection
+ const promise = this.__detectFn(path)
+ .then((response) => {
+ const info = response && response.data;
+ if (!info) {
+ throw new Error('Unexpected empty detection response');
+ }
+
+ const canonicalMount = (info.path || interimKey).replace(/\/+$/, '');
+ const type = info.type || 'unknown';
+ const optVersion = info.options && info.options.version;
+ // Only kv v2 gets special treatment; everything else is passthrough
+ const version = (type === 'kv' && optVersion === '2') ? 2 : 1;
+
+ const entry = { mount: canonicalMount, version, type };
+ this.__log.debug('MountResolver: detected %s as type=%s version=%d', canonicalMount, type, version);
+
+ // Store in persistent cache
+ this.__cache.set(canonicalMount, entry);
+ // Clear in-flight
+ this.__inflight.delete(interimKey);
+ return entry;
+ })
+ .catch((err) => {
+ // Clear in-flight so callers can retry
+ this.__inflight.delete(interimKey);
+ throw new VaultError(
+ `Failed to detect KV engine version for mount "${interimKey}" (path: ${path}): ${err.message}. ` +
+ 'Set api.engines or disable autoDetect to bypass detection.'
+ );
+ });
+
+ this.__inflight.set(interimKey, promise);
+ return promise;
+ }
+
+ /**
+ * Simple first-segment extraction used only for the disabled path.
+ */
+ __extractMount(path) {
+ return path.split('/')[0];
+ }
+}
+
+module.exports = MountResolver;
diff --git a/src/VaultApiClient.js b/src/VaultApiClient.js
index ffc161a..fa61897 100644
--- a/src/VaultApiClient.js
+++ b/src/VaultApiClient.js
@@ -73,7 +73,13 @@ class VaultApiClient {
);
if (data !== null) {
options.body = JSON.stringify(data);
- options.headers['Content-Type'] = 'application/json';
+ // Only set the default content-type if the caller did not already supply one.
+ // Case-insensitive check to honour merge-patch+json and other overrides.
+ const hasContentType = Object.keys(options.headers)
+ .some((k) => k.toLowerCase() === 'content-type');
+ if (!hasContentType) {
+ options.headers['Content-Type'] = 'application/json';
+ }
}
this._logger.debug(
diff --git a/src/VaultClient.js b/src/VaultClient.js
index a67a9eb..194523d 100644
--- a/src/VaultClient.js
+++ b/src/VaultClient.js
@@ -8,6 +8,8 @@ const VaultTokenAuth = require('./auth/VaultTokenAuth');
const VaultIAMAuth = require('./auth/VaultIAMAuth');
const VaultNodeConfig = require('./VaultNodeConfig');
const VaultKubernetesAuth = require('./auth/VaultKubernetesAuth');
+const MountResolver = require('./MountResolver');
+const { rewritePath, normalizeResponse } = require('./kvTransform');
const noop = () => {};
const vaultInstances = {};
@@ -24,6 +26,9 @@ class VaultClient {
* @param {Object} [options.api.requestOptions] - extra options merged into every HTTP
* request (e.g. an undici `dispatcher` for a proxy/SOCKS agent or custom TLS/CA trust).
* See {@link VaultApiClient#constructor}.
+ * @param {Object} [options.api.kv] - KV engine options
+ * @param {boolean} [options.api.kv.autoDetect=false] - When true, auto-detect KV version per mount
+ * @param {Object} [options.api.engines] - Mount-to-version overrides, e.g. { secret: 2, legacy: 1 }
* @param {Object} options.auth
* @param {String} options.auth.type
* @param {Object} options.auth.config - auth configuration variables
@@ -47,6 +52,25 @@ class VaultClient {
);
this.__namespace = options.auth.config.namespace;
+
+ // KV v2 support
+ const kvOpts = (options.api && options.api.kv) || {};
+ const autoDetect = kvOpts.autoDetect === true;
+ const engines = (options.api && options.api.engines) || {};
+
+ // Detection (the sys/internal/ui/mounts round-trip) only happens when
+ // autoDetect is on. With engines set but autoDetect off, listed mounts use
+ // the override and any other mount is passthrough (v1) — no detection call
+ // is ever made. This keeps the default byte-for-byte and makes engines a
+ // working fallback for Vaults that deny the detection endpoint.
+ const resolverDisabled = !autoDetect;
+
+ // Build detectFn lazily — it uses __auth and __api which are already set above.
+ const detectFn = (path) => this.__detectMount(path);
+
+ this.__resolver = new MountResolver(detectFn, engines, this.__log, {
+ disabled: resolverDisabled,
+ });
}
/**
@@ -199,6 +223,82 @@ class VaultClient {
return {'X-Vault-Token': token.getId()}
}
+ // -------------------------------------------------------------------------
+ // Detection helper (used by MountResolver's detectFn)
+ // -------------------------------------------------------------------------
+
+ /**
+ * Calls GET sys/internal/ui/mounts/ with the current auth token.
+ * Returns the parsed Vault response body.
+ * @private
+ */
+ __detectMount(path) {
+ return this.__auth.getAuthToken()
+ .then((token) => {
+ const detectPath = `sys/internal/ui/mounts/${path}`;
+ return this.__api.makeRequest('GET', detectPath, null, this.getHeaders(token));
+ });
+ }
+
+ // -------------------------------------------------------------------------
+ // Internal resolve + request helper
+ // -------------------------------------------------------------------------
+
+ /**
+ * Resolve the mount version for a path, rewrite the path, make the request,
+ * and normalise the response. Returns a { apiPath, body, version, mount } object.
+ *
+ * When the resolver is disabled (autoDetect:false, no engines), it skips
+ * resolution and passes through raw, preserving identical behavior to before.
+ *
+ * @private
+ */
+ __resolveAndRequest(op, method, path, data, extraHeaders) {
+ return this.__auth.getAuthToken()
+ .then((token) => {
+ const headers = Object.assign({}, this.getHeaders(token), extraHeaders || {});
+
+ return this.__resolver.resolve(path)
+ .then(({ mount, version }) => {
+ let apiPath;
+ let requestData = data;
+
+ if (version === 2) {
+ // Split the path into mount + logical sub-path and rewrite for KV v2
+ const logicalPath = this.__logicalPath(path, mount);
+ apiPath = rewritePath(version, op, mount, logicalPath);
+ // Wrap data in { data: ... } on v2 write/update
+ if ((op === 'write' || op === 'update') && data !== null && data !== undefined) {
+ requestData = { data };
+ }
+ } else {
+ // v1 / disabled: preserve the caller's literal path byte-for-byte,
+ // including any trailing slash, to maintain the old wire behavior.
+ apiPath = path;
+ }
+
+ return this.__api.makeRequest(method, apiPath, requestData, headers)
+ .then((body) => ({ body, version, mount, apiPath }));
+ });
+ });
+ }
+
+ /**
+ * Extract the logical path after the mount prefix.
+ * e.g. path='secret/foo/bar', mount='secret' => 'foo/bar'
+ * @private
+ */
+ __logicalPath(path, mount) {
+ const prefix = mount.replace(/\/+$/, '');
+ if (path === prefix) return '';
+ if (path.startsWith(prefix + '/')) return path.slice(prefix.length + 1);
+ return path;
+ }
+
+ // -------------------------------------------------------------------------
+ // Public API — existing methods (with KV v2 awareness)
+ // -------------------------------------------------------------------------
+
/**
* Read secret from Vault
* @param {string} path - path to the secret
@@ -206,11 +306,11 @@ class VaultClient {
*/
read(path) {
this.__log.debug('read secret %s', path);
- return this.__auth.getAuthToken()
- .then(token => this.__api.makeRequest('GET', path, null, this.getHeaders(token)))
- .then(res => {
+ return this.__resolveAndRequest('read', 'GET', path, null)
+ .then(({ body, version }) => {
this.__log.debug('receive secret %s', path);
- return Lease.fromResponse(res);
+ const normalised = normalizeResponse(version, 'read', body);
+ return Lease.fromResponse(normalised);
})
.catch((reason) => {
this.__log.error('read secret failed: %s', reason.message);
@@ -226,11 +326,11 @@ class VaultClient {
*/
list(path) {
this.__log.debug('list secrets %s', path);
- return this.__auth.getAuthToken()
- .then(token => this.__api.makeRequest('LIST', path, null, this.getHeaders(token)))
- .then(res => {
+ return this.__resolveAndRequest('list', 'LIST', path, null)
+ .then(({ body, version }) => {
this.__log.debug('got secrets list %s', path);
- return Lease.fromResponse(res);
+ const normalised = normalizeResponse(version, 'list', body);
+ return Lease.fromResponse(normalised);
})
.catch((reason) => {
this.__log.error('list secrets failed: %s', reason.message);
@@ -247,11 +347,10 @@ class VaultClient {
*/
write(path, data) {
this.__log.debug('write secret %s', path);
- return this.__auth.getAuthToken()
- .then((token) => this.__api.makeRequest('POST', path, data, this.getHeaders(token)))
- .then((response) => {
+ return this.__resolveAndRequest('write', 'POST', path, data)
+ .then(({ body }) => {
this.__log.debug('secret %s was written', path);
- return response;
+ return body;
})
.catch((reason) => {
this.__log.error('write secret failed: %s', reason.message);
@@ -259,6 +358,205 @@ class VaultClient {
});
}
+ /**
+ * Delete (soft-delete latest version) a secret.
+ *
+ * On KV v1 / non-kv mounts this sends DELETE to the raw path.
+ * On KV v2 mounts this sends DELETE to the data/ path, soft-deleting the latest version.
+ *
+ * @param {string} path
+ * @returns {Promise