4.7 KiB
adapter
(no submodules)
Purpose
Stage-8 concrete SecretsLike implementation backed by a
VaultSecretsBackend (real @suaveplan/vault.Vault via fromVault,
or any backend honouring the structural contract). Suitable for
multi-host / cloud deployments that need an encrypted, replicated
container with a controlled unlock flow alongside per-plugin scoping.
The vault is passed in unlocked by the host startup sequence; the
adapter never sees the master key, mirrors the env adapter's allow-
list semantics, and emits the canonical
api.secrets.reads_total{backend="vault"} counter on every
successful read.
The adapter is structurally a SecretsLike from
@suaveplan/api-types and passes the runSecretsConformance
harness from @suaveplan/api-secrets/testing unchanged.
Features
createVaultSecretsAdapter({ vault, allowlist }): VaultSecretsAdapter— factory returning a frozen-shapedSecretsLikeplus an idempotentdispose().- Per-plugin allow-list enforced BEFORE the vault is touched —
rejections throw
SECRETS_VAULT.NOT_ALLOWED. - Locked-vault detection: every
get/requirechecksvault.isLockedand raisesSECRETS_VAULT.VAULT_LOCKEDwith no plaintext fallback. - Backend error wrapping: an unexpected throw from the backend
surfaces as
SECRETS_VAULT.VAULT_UNAVAILABLEwith the original error attached oncause. requireraisesSECRETS_VAULT.NOT_FOUNDfor permitted-but- absent names;getresolves toundefined.- OTel
api.secrets.reads_total{backend="vault"}counter increments on every successful resolution (bothgetandrequire). - Zod-validated options;
SECRETS_VAULT.INVALID_OPTIONSon failure.
Usage: Basic
import { createVaultSecretsAdapter, fromVault }
from "@suaveplan/api-secrets-vault";
import { Vault } from "@suaveplan/vault";
const vault = Vault.open(vaultConfig);
await vault.unlock(password, secretKey);
const secrets = createVaultSecretsAdapter({
vault: fromVault(vault),
allowlist: ["STRIPE_SECRET_KEY", "JWT_SIGNING_KEY"],
});
const stripeKey = await secrets.get("STRIPE_SECRET_KEY");
const jwt = await secrets.require("JWT_SIGNING_KEY");
Usage: Advanced
Wire a deterministic backend for tests (no vault unlock required):
import { createVaultSecretsAdapter }
from "@suaveplan/api-secrets-vault";
import type { VaultSecretsBackend }
from "@suaveplan/api-secrets-vault/types";
function makeFixtureBackend(seed: Record<string, string>): VaultSecretsBackend {
const store = new Map(Object.entries(seed));
const listeners = new Set<(p: { name: string }) => void>();
return {
get isLocked() {
return false;
},
async get(name) {
return store.get(name);
},
async set(name, value) {
store.set(name, value);
for (const l of listeners) l({ name });
},
async delete(name) {
store.delete(name);
},
async listNames() {
return [...store.keys()];
},
onSecretUpdated(l) {
listeners.add(l);
return () => listeners.delete(l);
},
};
}
const adapter = createVaultSecretsAdapter({
vault: makeFixtureBackend({ STRIPE: "sk_test_123" }),
allowlist: ["STRIPE"],
});
API Reference
interface CreateVaultSecretsAdapterOptionsvault: VaultSecretsBackend— pre-unlocked.allowlist: readonly string[]
interface VaultSecretsAdapter extends SecretsLikereadonly allowlist: Allowlistdispose(): void— idempotent
createVaultSecretsAdapter(options): VaultSecretsAdapter- Throws
SECRETS_VAULT.INVALID_OPTIONSon bad options.
- Throws
- Returned
SecretsLike:get(name): Promise<string | undefined>require(name): Promise<string>list(): readonly string[]
Implementation Notes
The allow-list check ordering matters — running it BEFORE the
locked-state check means a non-permitted name on a locked vault
raises NOT_ALLOWED, not VAULT_LOCKED. This is deliberate: the
allow-list rejection is the canonical "you cannot ask about this
name" answer regardless of vault state, and the alternative would
leak liveness information about the vault to plugins that aren't
allowed to read from it.
Backend exceptions are wrapped in VAULT_UNAVAILABLE only if they
are not already a SecretsVaultError — a backend that throws our
own typed error (e.g. via the fromVault write-path) surfaces
through unchanged so codes don't double-wrap.
dispose() is idempotent and currently a no-op at the adapter
level; the watcher owns the live subscription. Kept on the surface
so capability-adapter lifecycles look uniform across the family.