Skip to content

feat: KV v2 support + generic verbs (opt-in, non-breaking)#96

Merged
kurok merged 1 commit into
masterfrom
feat/kv-v2-generic-verbs
Jun 17, 2026
Merged

feat: KV v2 support + generic verbs (opt-in, non-breaking)#96
kurok merged 1 commit into
masterfrom
feat/kv-v2-generic-verbs

Conversation

@kurok

@kurok kurok commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

What

Lets the simple client work against KV v2 mounts and any other Vault backend, lazily and transparently — callers don't need to know the engine version — while staying non-breaking for current users.

  • Opt-in, default off. api.kv.autoDetect (default false) → with it and api.engines unset, read/list/write are byte-for-byte the old behavior and no sys/internal/ui/mounts call is made.
  • Lazy auto-detect. When autoDetect:true, each mount's KV version is detected on first use via GET sys/internal/ui/mounts/<path> (readable by least-privilege tokens), then paths are rewritten and responses unwrapped.
  • api.engines override. Static { secret: 2, legacy: 1 } map; listed mounts skip detection. With engines set but autoDetect:false, unlisted mounts passthrough (no detection call) — so it's a working fallback for Vaults that deny the detection endpoint.

Public API

Method Behavior
read/list/write/delete version-aware when enabled; passthrough otherwise
update(path, data) PATCH with application/merge-patch+json + {data} envelope (KV v2)
request(method, path, data) raw, literal path, no rewrite/unwrap, parsed body (transit/pki/database/sys/…)
deleteVersions/undeleteVersions/destroyVersions/readMetadata/deleteMetadata KV v2-only; throw UnsupportedOperationError on v1/non-kv
Lease.getMetadata() KV v2 version metadata; undefined on v1 (additive)

Design

  • src/kvTransform.js (new, pure, no I/O) — rewritePath / normalizeResponse encode the full op × {v1,v2} table.
  • src/MountResolver.js (new) — injected detectFn, cache by canonical mount path, longest-prefix lookup, in-flight Promise dedupe, engines bypass, actionable VaultError on detection failure.
  • VaultApiClient.makeRequest — minimal change: only set the default Content-Type when the caller didn't supply one (enables merge-patch).
  • Lease — optional metadata (additive); errors — add UnsupportedOperationError.

How this was built & reviewed

Implemented via a multi-agent workflow (TDD build → 3-reviewer panel: architecture/consistency, correctness, acceptance → fix), then independently validated. Bugs caught and fixed before this PR:

  • Trailing slash stripped in passthroughlist('secret/') hit secret instead of secret/, violating the byte-for-byte guarantee. Fixed: version!==2 now uses the caller's literal path. Regression tests added.
  • MountResolver concurrency collision — concurrent first-touch of two distinct multi-segment mounts sharing a first segment (team/kvA, team/kvB) collapsed into one detection and mis-classified the second. Fixed: followers verify the resolved canonical mount actually prefixes their path; else a fresh detection runs. Regression test added.
  • autoDetect:false + engines set — an unlisted mount still attempted detection. Fixed: detection only runs when autoDetect is on; engines-listed mounts resolve via override, everything else passthrough. Regression test added.
  • request() falsy bodydata || null dropped 0/''/false; now data === undefined ? null : data.
  • Double-rewrite hazard (secret/data/foo + autoDetect) documented in the README.

Tests

  • npm run test:unit237 passing (new kvTransform + MountResolver suites are exhaustive; VaultClient/conformance cover every acceptance criterion)
  • npm run lint — clean

Scope notes

Within the agreed file list, plus a 9-line addition to test/errors.test.mjs covering the new error class. No version bump / CHANGELOG (handled at release). Out of scope: auth backends, typed transit/database helpers, index.d.ts.

Add transparent KV v2 handling and generic backend access to the simple
client. Opt-in via api.kv.autoDetect (default false) or a static
api.engines map; with neither set, read/list/write are byte-for-byte
unchanged and no sys/internal/ui/mounts call is made.

New:
- src/kvTransform.js   — pure rewritePath/normalizeResponse per the KV op table
- src/MountResolver.js — lazy per-mount version detection with cache +
  in-flight dedupe; engines override bypasses detection

Changes:
- VaultClient: read/list/write/delete are version-aware when enabled
  (passthrough otherwise); new update() (PATCH merge-patch+json),
  request() (raw literal path, no Lease), and v2 helpers deleteVersions/
  undeleteVersions/destroyVersions/readMetadata/deleteMetadata (throw
  UnsupportedOperationError on non-v2 mounts)
- VaultApiClient: only set the default Content-Type when the caller did
  not supply one (enables application/merge-patch+json)
- Lease: optional metadata + getMetadata() (undefined on v1)
- errors: add UnsupportedOperationError

Detection only runs when autoDetect is on. With engines set but autoDetect
off, listed mounts use the override and any other mount is passthrough, so
engines is a working fallback for Vaults that deny the detection endpoint.

Signed-off-by: Yuriy R <22548029+kurok@users.noreply.github.com>
@kurok kurok requested a review from wRLSS as a code owner June 17, 2026 09:47
@kurok kurok merged commit 1ea5aa2 into master Jun 17, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant