Files
skills/docs/api-stack/api/api-secrets-vault/src/adapter/adapter.md
2026-05-29 08:20:56 +01:00

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-shaped SecretsLike plus an idempotent dispose().
  • Per-plugin allow-list enforced BEFORE the vault is touched — rejections throw SECRETS_VAULT.NOT_ALLOWED.
  • Locked-vault detection: every get / require checks vault.isLocked and raises SECRETS_VAULT.VAULT_LOCKED with no plaintext fallback.
  • Backend error wrapping: an unexpected throw from the backend surfaces as SECRETS_VAULT.VAULT_UNAVAILABLE with the original error attached on cause.
  • require raises SECRETS_VAULT.NOT_FOUND for permitted-but- absent names; get resolves to undefined.
  • OTel api.secrets.reads_total{backend="vault"} counter increments on every successful resolution (both get and require).
  • Zod-validated options; SECRETS_VAULT.INVALID_OPTIONS on 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 CreateVaultSecretsAdapterOptions
    • vault: VaultSecretsBackend — pre-unlocked.
    • allowlist: readonly string[]
  • interface VaultSecretsAdapter extends SecretsLike
    • readonly allowlist: Allowlist
    • dispose(): void — idempotent
  • createVaultSecretsAdapter(options): VaultSecretsAdapter
    • Throws SECRETS_VAULT.INVALID_OPTIONS on bad options.
  • 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.