VaultSDK Overview Agent markdown GitHub

VaultSDK

One TypeScript interface for secret managers. Read a customer's credentials from the vault they already run, and store only an address.

read · list · verify · probe ESM + CJS + types Zero dependencies Apache-2.0

Install

One package. There is nothing to install alongside it — every adapter talks plain HTTP, so there are no vendor SDKs and no peer dependencies.

bashnpm install @devicai/vaultsdk

Everything goes through WebCrypto and fetch, with no Buffer and no process.env, so the same build runs on Node 18+, Cloudflare Workers, Deno, Bun and the browser.

The idea

If your product calls an API on a customer's behalf, you hold their credential. That is a liability for you and a leap of faith for them — and for a lot of buyers it is where the deal stalls.

The alternative: they keep the credential in the secret manager they already run, and you store an address. They rotate on their own schedule and it keeps working. They revoke your access and you stop, immediately, with no support ticket. This SDK is the part in the middle.

typescriptimport { awsVault } from '@devicai/vaultsdk/aws';

// hold on to this — see the warning below
const vault = awsVault({ credentials: customer.vaultCredentials });

const { value, version } = await vault.read(reference);
Keep the vault. Its credentials are parsed once and its access token is cached inside, so a vault rebuilt per request mints a token per request — which some issuers rate-limit harder than they rate-limit reads. Memoize it per customer. Since credentials are per customer, one instance is already one customer: there is no tenant argument to get wrong, and no shared cache to leak through.

References

No syntax is invented here. Each vault's native reference form is accepted verbatim, so an operator pastes the exact string their infrastructure tooling already gives them, and the vault is inferred from the shape.

VaultFormFollows a rotation when…
Google Secret Manager projects/<project>/secrets/<name>[/versions/<version>] the version is omitted, or is latest
AWS Secrets Manager arn:aws:secretsmanager:<region>:<account>:secret:<name>[:<json-key>[:<stage>[:<id>]]] stage and id are omitted (AWSCURRENT)
Azure Key Vault https://<vault>.vault.azure.net/secrets/<name>[/<version>] the version is omitted

The trailing AWS segments are the format ECS uses in valueFrom, adopted as-is. Empty slots count as placeholders, so …:secret:name::AWSPREVIOUS names a stage and no JSON key.

The full ARN is required. AWS itself accepts a bare name in SecretId; this does not. A bare name is indistinguishable from any other string — and reference recognition usually decides what an application encrypts at rest. Same reason the Azure host is pinned exactly: without that anchor the shape would be "any https URL with /secrets/ in it".

Collecting credentials

credentials takes either the blob the owner supplied — the file they downloaded, the JSON their CLI printed — or the same fields as an object. Each vault exports the type of that object, which is what tells you exactly what to ask each customer for and what to store against them.

VaultTypeFields
Google Secret Manager GoogleCredentials client_email, private_key, project_id?, token_uri?
AWS Secrets Manager AwsCredentials accessKeyId, secretAccessKey, region, sessionToken?
Azure Key Vault AzureCredentials tenantId, clientId, clientSecret, vaultUrl

Google's are snake_case because they are the downloaded key file's own field names: JSON.parse it and it already satisfies the type, with nothing transcribed by hand — which is where wrong values come from.

typescriptimport { awsVault } from '@devicai/vaultsdk/aws';
import type { AwsCredentials } from '@devicai/vaultsdk/aws';

// your own column, typed — one row per customer
interface Customer { id: string; vault: AwsCredentials }

const vault = awsVault({ credentials: customer.vault });
The two paths are deliberately not equally forgiving. A pasted string is whatever the operator had in front of them, so it is read tolerantly: accessKeyId or AWS_ACCESS_KEY_ID, clientId or appId. A typed object is what a developer writes, and there the type states one spelling and the compiler holds you to it — down to naming the field you misspelled. Reading is where you accept what people give you; writing is where you should not have to guess.

Validation is the same either way. An object loaded back out of a database was never seen by the compiler, so a missing region still raises unauthenticated at construction rather than at the first read.

Vault: Google Secret Manager

The credential blob is the service account JSON file, unmodified.

typescriptimport { googleVault } from '@devicai/vaultsdk/google';

// the project comes from the file's project_id, so listing just works
const vault = googleVault({ credentials: serviceAccountJson });

await vault.read('projects/acme/secrets/stripe/versions/latest');

Vault: AWS Secrets Manager

The blob accepts both the camelCase of the SDKs and the SCREAMING_SNAKE of the CLI environment, so operators can paste whichever they have in front of them.

typescriptimport { awsVault } from '@devicai/vaultsdk/aws';

const vault = awsVault({
  credentials: JSON.stringify({
    accessKeyId: 'AKIA…',
    secretAccessKey: '…',
    region: 'eu-west-1',      // only a default — each ARN carries its own
    // sessionToken: '…'    ← temporary credentials work too
  }),
});
The console stores secrets as key/value by default, so most real secrets are a JSON object. Without :<json-key> on the reference, what comes back is the whole {"api_key":"…"} envelope — a string that passes every type check and is rejected by whatever you send it to, reported as an authentication problem somewhere else entirely.

Cloud-side gotchas

Vault: Azure Key Vault

The blob accepts the literal output of az ad sp create-for-rbacappId, password, tenant — because that is what most operators have on screen, and retyping GUIDs into other field names is an invitation to paste the wrong value.

bashaz ad sp create-for-rbac --name devic-vault-reader
# { "appId": "…", "password": "…", "tenant": "…" }
# add "vaultUrl": "https://acme-kv.vault.azure.net" and pass the object as credentials
Creating the vault does not give you access to its contents. Under RBAC, being Owner covers the control plane; reading or writing secrets is data plane and needs its own role. It surfaces as The operation is not allowed by RBAC on a vault you just created yourself, which disconcerts everyone.
RoleWhoWhat it unlocks
Key Vault Secrets Officerthe person setting it upcreate and view secrets in the portal
Key Vault Secrets Useryour app registrationread the value — the only one strictly required
Key Vault Readeryour app registrationoptional: list names, which is what makes the picker appear

Reading, listing, testing

typescript// read — cached for ttlMs, and routed by the reference itself
const { value, version } = await vault.read(reference);

// who are we? Straight off the credentials, no network call
vault.provider; vault.identity; vault.scope;

// let an operator pick — null means "may not enumerate", not "broken"
const options = await vault.list();
// [{ name: 'stripe-key', reference: 'projects/acme/secrets/stripe-key/versions/latest' }]

// prove the identity without touching any secret
await vault.verify();

// prove the secret without leaking it — bypasses the cache on purpose
const probe = await vault.probe(reference);
// { version: '7', elapsedMs: 214, length: 24, preview: 'sk-••••••cdef' }

vault.invalidate(reference);  // forget one
vault.invalidate();           // forget all
Never compose a reference yourself. Each option from list() carries the one its own adapter built — only the adapter knows its native form, including the six random characters AWS appends to a name.

Cache

A warm round-trip to a hosted secret manager is several hundred milliseconds, which rules out reading on every use. Values are cached for ttlMs (5 minutes by default), which keeps the vault effectively idle while bounding how long a rotation takes to be noticed — and a rotation is noticed immediately anyway when the credential gets rejected by whatever it was sent to.

typescriptconst vault = awsVault({
  credentials: customer.vaultCredentials,
  ttlMs: 5 * 60_000,   // 0 disables caching entirely
});

await vault.read(reference);  // goes to the vault
await vault.read(reference);  // does not

vault.invalidate(reference);
await vault.read(reference);  // goes to the vault again
Nothing is kept beyond the TTL. Surviving a vault outage by holding on to a copy of someone's credential is a policy with real consequences — what happens the day they revoke you? — so it belongs to the application that can answer for it, not to an SDK. If you build one, the rule worth keeping is that only unavailable may serve a stored value: a 401, a 403 or a 404 are answers, and falling back on those would mean revoking your access changes nothing.

Deciding what to encrypt at rest

Most applications adopting this pattern need to answer one question per stored field: is this a secret to be encrypted, or an address to be kept legible? looksLikeReference is how you answer it.

typescriptimport { looksLikeReference } from '@devicai/vaultsdk';

// no setup, no adapter imported: it knows every shape the SDK supports
const stored = looksLikeReference(input)
  ? input                 // an address: keep it legible
  : encrypt(input);       // a secret: encrypt it
Widening the parser changes what gets encrypted. A new shape that also matches a credential you already hold means that credential silently stops being encrypted — no error, no log, no symptom. Before adding one, sweep your database for values the new shape would match, reporting shape, collection and field and never values.

This is also why all the shapes live in one file in the core rather than one per adapter package: recognition must not depend on which adapters you happened to import. Forgetting an import would otherwise turn stored references back into things that look like secrets.

Errors

Every vault fails in its own dialect — one returns 403, another has a private error code that contradicts its status, another just times out. All of it is translated at the adapter boundary into five words, so your code branches once instead of per vendor.

kindMeansWho fixes it
unauthenticatedthe identity was rejectedreconnect with credentials that work
forbiddenidentity accepted, not allowed herethey grant the role
not_foundthe reference points nowherefix the typo, or it was deleted
unavailable5xx, timeout, throttling, networknobody — retry; retryable is true
invalid_referenceit isn't an address at allwhoever typed it

Some of those translations are less obvious than they look, and each one is pinned by a test: AWS distinguishes a wrong credential from a missing permission by the error name, not the status code — both arrive as 400. A secret scheduled for deletion answers InvalidRequestException, which is a real answer about a secret on its way out, so it maps to not_found and must not open the fallback. Key Vault's SecretDisabled is a 403 that is really about that one secret rather than about the identity, so it maps to not_found too.

Testing against it

An in-memory vault ships with the package, so your own resilience behaviour can be tested without a cloud account and without mocking fetch.

typescriptimport { memoryVault } from '@devicai/vaultsdk';

const vault = memoryVault({ secrets: { 'stripe-key': 'sk-test-123' } });

await vault.read('mem://stripe-key');

vault.rotate('stripe-key', 'sk-test-456');   // a rotation
vault.setFailure('forbidden');                // a revocation → throws
expect(vault.reads).toBe(1);                  // and the cache really cached

Adding a vault

One file, plus its shape in core/src/reference.ts. The vault, the routing and the cache are already vault-agnostic.

typescriptinterface VaultAdapter<Ref, Credentials> {
  id; name;
  // declaring Credentials is what types `credentials` on your config
  credentialsFrom(raw: Credentials | string): VaultCredentials;
  verify(source, scope?): Promise<VaultIdentity>;
  read(ref, source): Promise<VaultReadResult>;
  list(source, scope?): Promise<VaultSecretOption[] | null>;
}

export const myVault = (config) => createVault(new MyAdapter(config), config);

Credentials answer with headers for a specific request rather than with a token — authorize(request) => headers. Google and Azure hand out a bearer token that is the same for every call; AWS signs each request individually. It is the only question both can answer, and it is what let Azure be added in an afternoon.

Helpers in the core so an adapter stays small: authorizedFetch, bearerCredentials, headerTokenCredentials, signingCredentials, cachedToken, sha256, hmacSha256, signRsaSha256 and base64 helpers that avoid Buffer.

HashiCorp Vault, when someone gets to it

It is the interesting case, because HashiCorp publishes no canonical identifier to adopt. People write secret/data/app/config, which is a shape any configuration value could wear — so a form has to be invented, and that is exactly where a parser gets dangerous.

proposed formhcv://<host>/<mount>/<path>#<field>