One TypeScript interface for secret managers. Read a customer's credentials from the vault they already run, and store only an address.
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.
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);
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.
| Vault | Form | Follows 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.
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".
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.
| Vault | Type | Fields |
|---|---|---|
| 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 });
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.
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');
list() answers null without it.BEGIN PRIVATE KEY). A PKCS#1 key raises an error naming the openssl pkcs8 -topk8 command that converts it.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 }), });
:<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.
…:secret:my-secret-*.test-2 is not covered by devic/*.verify() goes through STS GetCallerIdentity, which needs no IAM policy at all — so a failure there is unambiguously a bad credential and never a missing permission.The blob accepts the literal output of az ad sp create-for-rbac — appId, 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
The operation is not allowed by RBAC on a vault you just created
yourself, which disconcerts everyone.
| Role | Who | What it unlocks |
|---|---|---|
| Key Vault Secrets Officer | the person setting it up | create and view secrets in the portal |
| Key Vault Secrets User | your app registration | read the value — the only one strictly required |
| Key Vault Reader | your app registration | optional: list names, which is what makes the picker appear |
az keyvault secret set …), which both diagnoses and unblocks. Or sign out.vault.azure.cn, vault.usgovcloudapi.net) need a different login authority and token audience.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
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.
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
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.
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
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.
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.
| kind | Means | Who fixes it |
|---|---|---|
| unauthenticated | the identity was rejected | reconnect with credentials that work |
| forbidden | identity accepted, not allowed here | they grant the role |
| not_found | the reference points nowhere | fix the typo, or it was deleted |
| unavailable | 5xx, timeout, throttling, network | nobody — retry; retryable is true |
| invalid_reference | it isn't an address at all | whoever 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.
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
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.
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>
X-Vault-Token, not Bearer — that is what headerTokenCredentials is for.GET {host}/v1/{mount}/data/{path} → {data:{data:{…},metadata:{version}}}. The metadata.version is how a rotation is noticed.forbidden, 404 → not_found, and 503 means sealed → unavailable.