Documentation
Versioned KV example with VaultClientV2
This example walks through the core features of the KV v2 secrets engine using the typed nanvc v2 client.
This example walks through the core features of the KV v2 secrets engine
using the typed nanvc v2 client.
What the workflow demonstrates
- Create a clean KV v2 mount at
secret-versionedso the run is repeatable. - Read the engine config with
readConfig. - Write versions 1 and 2 of
customer/acme. - Patch the secret to create version 3 without replacing untouched fields.
- Add custom metadata labels to the secret path.
- Read a specific historical version.
- Inspect the full version history with
readMetadata. - Configure engine-wide and per-path
max_versions. - Write additional versions and assert that the oldest version advances.
- Soft-delete versions 5 and 6.
- Undelete version 5.
- Permanently destroy version 6.
- Configure
delete_version_afterfor an automatically scheduled deletion. - Configure
cas_requiredand demonstrate successful and stale CAS writes. - Delete all versions and metadata for
customer/acme.
This example uses the shared decorator-based runner and personas described in
examples/README.md.
Vault steps
The single admin workflow performs the following operations against
secret-versioned:
- Disable the mount if it already exists, ignoring
404. - Enable KV v2 at
secret-versioned. readConfigto confirm the engine is readable.writetwice tocustomer/acme, creating versions 1 and 2.patchcontact_email, creating version 3 while preservingcustomer_name.patchMetadatato addMembershipandRegionlabels.readwith{ version: 1 }to retrieve historical data.readMetadatato inspectcurrent_versionand the version map.writeConfigandwriteMetadatato setmax_versionsto 4.- Write versions 4 through 7 and assert rollover behavior.
deleteVersions([5, 6])to soft-delete data.undeleteVersions([5])to restore one soft-deleted version.destroyVersions([6])to permanently erase version 6.- Set
delete_version_after: '24h'oncustomer/timed. - Write to
customer/timedand assert thatdeletion_timeis scheduled. - Set
cas_required: trueoncustomer/partner. - Write with
cas: 0, then write withcas: 1. - Attempt a stale
cas: 1write and assert HTTP400. deleteMetadataforcustomer/acmeand assert metadata is gone.
Local Vault
From the repository root, start only the plain Vault service:
docker compose up -d vault
One Vault instance is enough. You do not need vault_tls or vault_mtls unless
you are specifically testing TLS.
For a fresh Vault state:
docker compose down --volumes --remove-orphans
docker compose up -d vault
Run
Install dependencies from the repository root:
npm install
Then run the example:
NANVC_VAULT_CLUSTER_ADDRESS=http://127.0.0.1:8200 npx tsx examples/versioned-kv/main.ts
The helper defaults to http://vault.local:8200. Use the environment variable
above when vault.local is not mapped on your machine. The mount
secret-versioned is removed and re-created on every run, so this example is
idempotent.
Environment
For an existing Vault server, set:
export NANVC_VAULT_CLUSTER_ADDRESS=http://127.0.0.1:8200
export TEST_NANVC_VAULT_AUTH_TOKEN=<root-or-admin-token>
export TEST_NANVC_VAULT_UNSEAL_KEY=<unseal-key>
If the local Vault server is initialized by any example or integration helper, the helper writes a shared cache file under your OS temp directory with:
TEST_NANVC_VAULT_AUTH_TOKENTEST_NANVC_VAULT_UNSEAL_KEY
Shell-exported TEST_NANVC_* variables take precedence over cached values. If
Vault reports invalid token, the cached credentials probably belong to another
Vault instance or an older Docker volume. Export valid TEST_NANVC_* values, or
reset local Vault with the fresh-state commands above.
import assert from 'node:assert';
import type { AdminPersona } from '../common/personas/admin.js';
import { VaultClientError } from '../../src/main.js';
import { toExampleAuthError } from '../common/personas/helpers.js';
import { example, workflow, runAs, runExample } from '../common/workflow/decorators.js';
const MOUNT = 'secret-versioned';
const SECRET_PATH = 'customer/acme';
@example('Versioned KV example')
class VersionedKVExample {
@workflow('admin', 'Demonstrate KV v2 features')
@runAs({ persona: 'admin' })
public async demonstrateKVFeatures(admin: AdminPersona<'v2'>): Promise<void> {
const vault = admin.vault;
// Start with a clean mount so the example is repeatable.
const disableError = await vault.sys.mount.disable(MOUNT).intoErr();
if (disableError && disableError.status !== 404) {
throw toExampleAuthError(disableError);
}
await vault.sys.mount
.enable(MOUNT, {
type: 'kv',
options: {
version: '2',
},
})
.unwrap();
// ── Step 1: Check the engine version ──────────────────────────────────
// readConfig confirms that the KV v2 engine configuration is readable.
const engineConfig = await vault.secret.kv.v2.readConfig(MOUNT).unwrap();
assert.ok(engineConfig.max_versions !== undefined, 'Engine config must be readable');
// ── Step 2: Write secrets ─────────────────────────────────────────────
// First write creates version 1.
await vault.secret.kv.v2
.write(MOUNT, SECRET_PATH, {
customer_name: 'ACME Inc.',
contact_email: 'john.smith@acme.com',
})
.unwrap();
// Writing to the same path again performs a full replace and creates version 2.
await vault.secret.kv.v2
.write(MOUNT, SECRET_PATH, {
customer_name: 'ACME Inc.',
contact_email: 'jsmith@acme.com',
})
.unwrap();
const v2 = await vault.secret.kv.v2
.read<{ customer_name: string; contact_email: string }>(MOUNT, SECRET_PATH)
.unwrap();
assert.strictEqual(v2.metadata.version, 2, 'Expected version 2 after second write');
assert.strictEqual(v2.data.contact_email, 'jsmith@acme.com');
// ── Step 3: Patch (partial update) ────────────────────────────────────
// patch merges only the supplied fields; untouched fields are preserved.
// This is equivalent to `vault kv patch` and creates version 3.
await vault.secret.kv.v2
.patch(MOUNT, SECRET_PATH, {
contact_email: 'admin@acme.com',
})
.unwrap();
const v3 = await vault.secret.kv.v2
.read<{ customer_name: string; contact_email: string }>(MOUNT, SECRET_PATH)
.unwrap();
assert.strictEqual(v3.metadata.version, 3, 'Expected version 3 after patch');
assert.strictEqual(v3.data.contact_email, 'admin@acme.com');
assert.strictEqual(v3.data.customer_name, 'ACME Inc.', 'Patch must preserve untouched fields');
// ── Step 4: Add custom metadata ───────────────────────────────────────
// patchMetadata stores arbitrary string-to-string labels alongside the
// secret path without touching the versioned secret data.
await vault.secret.kv.v2
.patchMetadata(MOUNT, SECRET_PATH, {
custom_metadata: {
Membership: 'Platinum',
Region: 'US West',
},
})
.unwrap();
const withMeta = await vault.secret.kv.v2.read<{ customer_name: string }>(MOUNT, SECRET_PATH).unwrap();
const customMeta = withMeta.metadata.custom_metadata as Record<string, string> | null | undefined;
assert.strictEqual(customMeta?.['Membership'], 'Platinum');
assert.strictEqual(customMeta?.['Region'], 'US West');
// ── Step 5: Read a specific version ───────────────────────────────────
// Older versions remain accessible even after newer writes.
const v1 = await vault.secret.kv.v2
.read<{ customer_name: string; contact_email: string }>(MOUNT, SECRET_PATH, { version: 1 })
.unwrap();
assert.strictEqual(v1.metadata.version, 1);
assert.strictEqual(v1.data.contact_email, 'john.smith@acme.com', 'v1 must have the original email');
// ── Step 6: Read full version history ─────────────────────────────────
// readMetadata returns the full metadata record including all version entries.
const meta = await vault.secret.kv.v2.readMetadata(MOUNT, SECRET_PATH).unwrap();
assert.strictEqual(meta.current_version, 3, 'current_version must be 3');
const versionMap = meta.versions as Record<string, { destroyed: boolean; deletion_time: string }>;
assert.ok(versionMap['1'], 'Version 1 must exist in metadata');
assert.ok(versionMap['2'], 'Version 2 must exist in metadata');
assert.ok(versionMap['3'], 'Version 3 must exist in metadata');
// ── Step 7: Specify the number of versions to keep ────────────────────
// Set the engine-wide default: keep at most 4 versions per secret.
await vault.secret.kv.v2.writeConfig(MOUNT, { max_versions: 4 }).unwrap();
const updatedConfig = await vault.secret.kv.v2.readConfig(MOUNT).unwrap();
assert.strictEqual(updatedConfig.max_versions, 4, 'Engine max_versions must be 4');
// A per-path override takes precedence over the engine-level setting.
await vault.secret.kv.v2.writeMetadata(MOUNT, SECRET_PATH, { max_versions: 4 }).unwrap();
// Write 4 more versions so that version 3 becomes the new oldest surviving version
// (versions 1–3 existed before the limit was set, but Vault enforces the limit
// from the next write onward, pruning the oldest once the cap is exceeded).
for (let i = 4; i <= 7; i++) {
await vault.secret.kv.v2
.write(MOUNT, SECRET_PATH, {
customer_name: 'ACME Inc.',
contact_email: `v${i}@acme.com`,
})
.unwrap();
}
const metaAfterRollover = await vault.secret.kv.v2.readMetadata(MOUNT, SECRET_PATH).unwrap();
assert.strictEqual(metaAfterRollover.current_version, 7, 'current_version must be 7 after rollover');
assert.ok(
(metaAfterRollover.oldest_version ?? 0) >= 4,
'oldest_version must advance past the cap once max_versions is exceeded',
);
// ── Step 8: Soft-delete and undelete versions ─────────────────────────
// deleteVersions marks versions with a deletion_time but does not destroy data.
await vault.secret.kv.v2.deleteVersions(MOUNT, SECRET_PATH, [5, 6]).unwrap();
const metaAfterSoftDelete = await vault.secret.kv.v2.readMetadata(MOUNT, SECRET_PATH).unwrap();
const v5 = (metaAfterSoftDelete.versions as Record<string, { destroyed: boolean; deletion_time: string }>)['5'];
assert.ok(v5.deletion_time, 'v5 must have a deletion_time after soft-delete');
assert.strictEqual(v5.destroyed, false, 'v5 must not be destroyed (soft-delete only)');
// undeleteVersions restores a soft-deleted version.
await vault.secret.kv.v2.undeleteVersions(MOUNT, SECRET_PATH, [5]).unwrap();
const metaAfterUndelete = await vault.secret.kv.v2.readMetadata(MOUNT, SECRET_PATH).unwrap();
const v5Restored = (metaAfterUndelete.versions as Record<string, { deletion_time: string }>)['5'];
assert.strictEqual(v5Restored.deletion_time, '', 'v5 deletion_time must be empty after undelete');
// ── Step 9: Permanently destroy a version ─────────────────────────────
// destroyVersions permanently removes version data (destroyed=true, unrecoverable).
await vault.secret.kv.v2.destroyVersions(MOUNT, SECRET_PATH, [6]).unwrap();
const metaAfterDestroy = await vault.secret.kv.v2.readMetadata(MOUNT, SECRET_PATH).unwrap();
const v6 = (metaAfterDestroy.versions as Record<string, { destroyed: boolean }>)['6'];
assert.strictEqual(v6.destroyed, true, 'v6 must be permanently destroyed');
// ── Step 10: Configure automatic data deletion ────────────────────────
// Writing metadata with delete_version_after makes Vault automatically
// set a deletion_time on every new version at this path.
const timedPath = 'customer/timed';
await vault.secret.kv.v2
.writeMetadata(MOUNT, timedPath, {
delete_version_after: '24h',
})
.unwrap();
await vault.secret.kv.v2.write(MOUNT, timedPath, { message: 'ephemeral secret' }).unwrap();
const timedMeta = await vault.secret.kv.v2.readMetadata(MOUNT, timedPath).unwrap();
const timedV1 = (timedMeta.versions as Record<string, { deletion_time: string }>)['1'];
// Vault pre-populates deletion_time with the scheduled auto-deletion timestamp.
assert.ok(timedV1.deletion_time, 'Auto-deletion path must have deletion_time set on new versions');
// ── Step 11: Check-and-Set operations ─────────────────────────────────
// CAS prevents unintentional overwrites by requiring the current version number.
const casPath = 'customer/partner';
await vault.secret.kv.v2.writeMetadata(MOUNT, casPath, { cas_required: true }).unwrap();
// First write: cas=0 means "only succeed if the key does not yet exist".
await vault.secret.kv.v2
.write(
MOUNT,
casPath,
{
name: 'Example Co.',
partner_id: '123456789',
},
{ cas: 0 },
)
.unwrap();
// Second write: cas=1 matches the current version so the write succeeds.
await vault.secret.kv.v2
.write(
MOUNT,
casPath,
{
name: 'Example Co.',
partner_id: 'ABCDEFGHIJKLMN',
},
{ cas: 1 },
)
.unwrap();
// Stale CAS: current version is 2 but we pass cas=1 — Vault rejects with 400.
const casError = await vault.secret.kv.v2
.write(
MOUNT,
casPath,
{
name: 'Example Co.',
},
{ cas: 1 },
)
.unwrapErr();
assert.ok(casError instanceof VaultClientError, 'Stale CAS must produce a VaultClientError');
assert.strictEqual(casError.status, 400, 'Stale CAS must return HTTP 400');
// ── Step 12: Delete all versions and metadata ──────────────────────────
// deleteMetadata permanently removes all versions and the metadata record.
await vault.secret.kv.v2.deleteMetadata(MOUNT, SECRET_PATH).unwrap();
const [, notFoundError] = await vault.secret.kv.v2.readMetadata(MOUNT, SECRET_PATH);
assert.strictEqual(notFoundError?.status, 404, 'Metadata must be gone after deleteMetadata');
}
}
runExample(VersionedKVExample).catch((error) => {
console.error(error);
process.exitCode = 1;
});Source Files
- README source:
examples/versioned-kv/README.md - Runnable source:
examples/versioned-kv/main.ts
This page is generated from the example README. Edit the source README and run
npm run generate:docsto update it.