@mstone6969/vault
v2.4.0
Published
A write-only credential vault: envelope encryption, key rotation, expiry, history and an encrypted single-file store.
Maintainers
Readme
@mstone6969/vault
A write-only credential vault. Values go in encrypted; the only way one comes
back out is open() or resolve(), so a vault can sit behind an API without a
reveal endpoint.
bun add @mstone6969/vaultUse
import { generateKey, MemoryStore, Vault } from "@mstone6969/vault"
const vault = new Vault({
key: process.env.VAULT_KEY ?? generateKey(), // 32 bytes, base64
store: new MemoryStore(),
})
await vault.put("alice", "stripe_key", "sk_live_…")
await vault.list("alice")
// [{ owner: "alice", name: "stripe_key", metadata: {}, createdAt: …, updatedAt: … }]
// — no value, ever
await vault.open("alice", "stripe_key") // "sk_live_…"Everything is scoped by an owner, so one vault serves many accounts and two
people can both keep a token without seeing each other's.
How a value is sealed
Every value gets its own data key. The value is sealed under that, and only the data key is sealed under your master key:
value ──sealed under──▶ data key ──sealed under──▶ master keyTwo things follow. Changing the master key re-seals a handful of bytes per
entry rather than every value, so rekey is cheap whatever you keep in there.
And a data key that leaks opens one value, not all of them.
Values written by earlier versions are sealed under the master key directly;
they still open, and rekey gives them an envelope on the way past.
Metadata
A value is sealed, but the facts about it usually should not be. put takes
a map of non-secret strings that list returns as-is:
await vault.put("alice", "deploy", privateKey, {
metadata: { kind: "ssh", publicKey: "ssh-ed25519 AAAA…" },
})
await vault.list("alice")
// [{ name: "deploy", metadata: { kind: "ssh", publicKey: "ssh-ed25519 AAAA…" }, … }]That is what lets a listing say what something is — which login a password belongs to, which public key pairs with a sealed private one — without opening anything. Replacing a value replaces its metadata too.
A listing can be narrowed to entries whose metadata matches:
await vault.list("alice", { kind: "ssh" })Only metadata, because it is the only part kept in the clear. Filtering on a value would mean opening every secret in the vault to answer a listing.
It is stored in the clear. Put nothing in it you would not show.
References
Configuration can name a secret instead of holding one. resolve swaps
@vault:<name> for the stored value and leaves everything else alone:
await vault.resolve("alice", {
NODE_ENV: "production",
API_KEY: "@vault:stripe_key",
})
// { NODE_ENV: "production", API_KEY: "sk_live_…" }A reference can also sit inside a longer string, which is what connection strings need:
await vault.resolve("alice", {
DSN: "postgres://app:@vault:[email protected]:5432/app",
})
// { DSN: "postgres://app:[email protected]:5432/app" }References work anywhere in a structure, not only at the top:
await vault.resolve("alice", {
database: { host: "db.internal", password: "@vault:db" },
webhooks: [{ url: "@vault:hook" }],
})Objects and arrays are walked; anything else — a number, a Date, a class instance — is passed through as itself. A branch containing no references at all is handed back rather than copied.
A reference to a secret that isn't there throws. Running a job with a blank
credential is worse than not running it. Change the prefix with
new Vault({ …, prefix: "secret://" }).
Bytes
A certificate, a keyfile, a kubeconfig — anything that is not text:
await vault.putBytes("alice", "tls-key", await Bun.file("tls.key").bytes())
const key = await vault.openBytes("alice", "tls-key")Values are text underneath, so this base64s on the way in and decodes on the
way out — one encoding rather than one per caller. The entry is marked as bytes
in its metadata, so openBytes refuses an entry holding text instead of
handing back decoded noise. Everything else works the same: metadata, expiry,
rotation, sharing, history.
Bytes cannot be stored with sealed: false. They would sit in the database as
base64 and read back as text, which is a trap rather than a feature.
As a data URL
To hand a stored thing straight to something that takes one — an <img src>, a
config field wanting an inline certificate:
await vault.putBytes("alice", "logo", png, { contentType: "image/png" })
await vault.openDataUrl("alice", "logo")
// "data:image/png;base64,iVBORw0KGgo…"putDataUrl is the way back in, keeping the media type from the URL, so what
goes in comes back out identical. A text entry becomes
text/plain;charset=utf-8; bytes stored without a contentType are called
application/octet-stream rather than being claimed as something they might
not be.
The URL contains the secret. Data URLs have a way of ending up in logs, DOM
dumps and browser history — treat what comes back the way you would treat
open().
Credentials for other services
A token for npm, GitLab, anything else. The token is the sealed value; everything else about it is metadata, in the clear:
await vault.putCredential("alice", "npm-publish", process.env.NPM_TOKEN!, {
service: "npm",
username: "mstone6969",
scopes: ["publish"],
}, { expiresAt: new Date("2026-11-21") })
await vault.credential("alice", "npm-publish")
// { service: "npm", env: "NPM_TOKEN", username: "mstone6969", scopes: ["publish"] }Expiry is the entry's own expiresAt, so an expired credential stops opening
and stops being injected by the same rule as everything else.
npm, github, gitlab, cargo, docker and pypi have known conventions
for which variable holds the token. Anything else has to say, via env — a
guessed variable name either does nothing or puts a live token somewhere that
was not asking for it.
Running commands through the vault
await vault.run("alice", ["npm", "publish"], {
credentials: ["npm-publish"],
device: "laptop",
})The token exists in that child process's environment and nowhere else. No
.npmrc, no ~/.netrc, nothing left behind if the command fails, and nothing
in shell history. The command is an array, not a shell string, so nothing is
word-split or expanded.
Be clear about what this does and does not buy. An environment is readable by
anything running as the same user — /proc/<pid>/environ on Linux. It keeps a
token off disk; it does not hide it from you or from anything else you are
running.
Devices
const device = await vault.enrolDevice("alice", "laptop")
device.fingerprint // "SHA256:…", how the audit trail names it
device.publicKey // an ordinary authorized_keys lineAn Ed25519 keypair stored like any other secret. Two things it is good for:
audit entries can say which machine did something, and the public key works as
an authorized_keys line, so an enrolled device can be given access to a host
without a second keypair.
What it is not: anyone who can open the vault can enrol a device. This is attribution and convenience, not a boundary against somebody who holds the master key. It tells you which machine was used, not that the machine was allowed.
SSH keys
The vault can make them, keep the private half, and publish the public one:
await vault.putSshKey("alice", "deploy", { comment: "deploy@ci" })
const { publicKey, fingerprint } = await vault.sshPublicKey("alice", "deploy")
// "ssh-ed25519 AAAAC3… deploy@ci", "SHA256:…"Ed25519, with no choice offered: no key size to get wrong, and every OpenSSH
since 6.5 takes them. The public key and fingerprint live in the metadata, in
the clear, so a listing can say which key is on which host without opening
anything — and sshPublicKey costs one read and no unsealing.
The private key comes out in OpenSSH's own format, ready to use:
await Bun.write("id_ed25519", await vault.openSshKey("alice", "deploy"))
// then chmod 0600, or ssh will refuse itIt is stored unencrypted inside its envelope. A key file on disk needs a passphrase; one in a vault already has the master key in front of it, and a second passphrase would just be another secret to keep.
rotateSshKey replaces a key and keeps the old one openable through
versions(), so a host that has not had the new public key installed yet is not
locked out the moment it runs.
Storage
Four stores ship with the package. If more than one process writes the same
vault, use PostgresStore — see More than one writer.
FileStore keeps everything in one encrypted file. The other stores seal
values and leave the rest in the open — SQLite has an owner column and a
name column, so anyone who can read the file learns what you keep even if
they cannot read it. Here the whole index is inside a single envelope, and what
leaks at rest is the file's size:
import { FileStore } from "@mstone6969/vault/stores/file"
const store = new FileStore("./secrets.vault", fileKey("/etc/vault.key"))Give the file a key of its own, or hand it the vault's — sharing means one key opens both layers. It is loaded and written whole, so it suits hundreds of secrets and one writer, not millions and many.
Writes go to a temporary file, are flushed to disk, and are renamed into place,
so neither a crash nor a power loss leaves a half-written index — which matters
more here than elsewhere, because the whole index is one envelope and a torn
file would lose every record rather than one. Writes also take a lock file
beside the store and re-read the file while holding it, so two processes on the
same path queue up instead of overwriting each other. Unlike SqliteStore, it
uses only node:fs, so it runs on Node as well as Bun.
MemoryStore ships in the main entry. SqliteStore is Bun-only — it
imports bun:sqlite, so it lives behind a subpath and never loads unless you
ask for it:
import { SqliteStore } from "@mstone6969/vault/stores/sqlite"
const store = new SqliteStore("./vault.sqlite") // or ":memory:", or a DatabasePostgresStore is the one to reach for when several processes share a
vault. It is Bun-only too, since it uses Bun's built-in SQL:
import { PostgresStore } from "@mstone6969/vault/stores/postgres"
const store = new PostgresStore(process.env.DATABASE_URL!)
await store.migrate() // once, before first usemigrate is not run for you, because a library should not create tables behind
your back the first time you read from it. It is safe to call on every start,
and from several processes at once.
Everything else runs on Node 18+ as well as Bun.
To keep secrets in a database you already run, implement VaultStore — four
methods, all scoped by owner, all dealing in sealed strings and never plaintext:
type VaultStore = {
get(owner: string, name: string): Promise<SecretRecord | null>
list(owner: string): Promise<SecretRecord[]>
all(): Promise<SecretRecord[]>
put(record: {
owner: string
name: string
sealed: string
metadata: Record<string, string>
}): Promise<SecretRecord>
remove(owner: string, name: string): Promise<boolean>
// Optional. Without it you get a weaker guarantee, not an error.
putIf?(record: SecretRecord, expectedRevision: number | null): Promise<SecretRecord | null>
}Encryption
AES-256-GCM, a fresh 12-byte IV per write, stored as iv:payload in base64.
GCM's authentication tag means an altered value fails to open rather than
decrypting to something wrong — both cases are covered by tests.
Each entry's data key is sealed as belonging to that entry: its owner and name go into the authentication tag. Sealed bytes copied from one row to another therefore will not open in their new home, so somebody who can write to the database cannot hand themselves another owner's secret by moving it into their own row. It also means a backup restored across rows fails loudly instead of quietly serving the wrong credential.
Entries written before 1.4 have no such tie and still open; rekey or reseal
gives them one on the way past.
The key never leaves your process, and the package never writes it anywhere.
Using something else
The algorithm is a seam. cipher sets what new writes use; ciphers lists
what the vault can still read:
import { aesGcm } from "@mstone6969/vault"
const vault = new Vault({ key, store, cipher: aesGcm(128) })Values record which algorithm sealed them, so old and new coexist and
reseal() is the migration — it rewrites each value under whatever the vault
currently writes with. ciphersInUse() tells you how far that has got:
await vault.ciphersInUse() // { A256GCM: 412 }
await vault.reseal()
await vault.ciphersInUse() // { A128GCM: 412 }Values sealed with the default carry no marker at all, so nothing already written changes shape and a vault that never touches this is byte-for-byte what it was.
Implement Cipher to use an algorithm that does not ship here — a
FIPS-validated module, ChaCha20-Poly1305 through node:crypto on Node, a
post-quantum scheme. A cipher never sees the entry it belongs to and does not
need to: the tie that stops a value being moved between entries is on the data
key, not the value, so a plugged-in algorithm cannot weaken it by leaving
something out.
Two warnings worth heeding. Check the algorithm exists on your runtime before
committing a vault to it — Bun has no chacha20-poly1305, and a vault that
cannot open its own values is not recoverable. And nothing about AES-256 is
known to be weak: if you have no specific reason to move, do not.
Lifecycle
An entry can be more than a value:
await vault.put("alice", "region", "eu-west-1", { sealed: false }) // readable
await vault.put("alice", "root_ca", pem, { final: true }) // written once
await vault.put("alice", "token", value, { expiresAt: tomorrow }) // stops working
await vault.rotate("alice", "deploy", next) // keeps the old oneopenstores the value in the clear, andread()gives it back. For configuration rather than credentials; a sealed entry answers 403.finalrefuses every future replacement — delete it or live with it.expiresAtstops the entry resolving once it passes.purgeExpired()clears them out when you are ready.rotatekeeps what it replaced, up tohistoryLimit(5 by default), andversions()opens them. A job that read the credential moments before a rotation can still finish on what it was given.
Rotating without knowing the value
An entry can carry a rotation policy: how to make its next value. That is a recipe, never a value, so it is stored in the open beside the metadata — and whatever runs the rotation is told how to make the next password without being told the current one.
await vault.put("alice", "db", firstPassword, {
metadata: { username: "ada" },
rotation: { kind: "random", length: 24, every: 86_400 },
})
await vault.rotate("alice", "db") // no value: the policy makes oneFor a credential only the far end can mint, name a generator instead. The vault stores the name; the function stays in your process:
const vault = new Vault({
key,
store,
generators: {
provider: async ({ arguments: args }) => api.mintKey(args.account),
},
})
await vault.put("alice", "api", currentKey, {
rotation: { kind: "generator", generator: "provider", arguments: { account: "acct_123" } },
})A generator is told which entry is being rotated and its policy's arguments — deliberately not the value it is replacing. One that needs the old value can ask the vault for it.
every says how often, in seconds. rotationDue() reports what is overdue and
rotateDue() acts on it, so a scheduled job is two lines:
const { rotated, failed } = await vault.rotateDue()
for (const { name, reason } of failed) console.warn(name, reason)One credential that will not rotate — a provider that is down, a generator that
throws — is named in failed and the rest still go. A pass that abandoned the
remaining credentials because one endpoint was unreachable would be worse than
not running.
rotationDue(now?) reports entries whose every has elapsed since they were
last rotated. Nothing rotates them for you; schedule it and act on the list.
Rotating keeps everything the entry already had — its metadata, its policy, its
expiry — and records rotatedAt. Only the value changes.
Anything you leave out of put stays as it was, so rotating a credential does
not quietly forget what kind it is or when it expires.
reseal() re-seals values under fresh data keys without changing the master
key — cheap hygiene, so the ciphertext of an unchanged secret stops being
comparable between two copies of the database.
Where the key comes from
new Vault({ key: envKey("VAULT_KEY"), store }) // an environment variable
new Vault({ key: fileKey("/etc/vault.key"), store }) // a file
new Vault({ key: staticKey(material), store }) // one you already have
new Vault({ key: passphraseKey(phrase, salt), store }) // something rememberedpassphraseKey stretches a passphrase into a key with PBKDF2-HMAC-SHA256,
600,000 iterations by default — deliberately slow, so that guessing at the
passphrase costs the guesser real time. The salt is not a secret and does not
have to be hidden, but it does have to be the same one every time, or the key
comes out different and nothing opens. Keep it beside the vault.
A passphrase is only ever as good as the passphrase. Prefer a generated key where you have somewhere to keep one.
A provider is one method — write your own for anything that can hand over key material. It is called the first time a key is actually needed, not when the vault is built, so a vault nobody uses never reaches for one.
Or a key this process never sees
A provider hands the vault key material, which means the key is in memory and a
heap dump has it. A KeyWrapper instead does the two things the vault
actually needs — wrap a data key, unwrap it again — somewhere the process
cannot reach: AWS KMS, Cloud KMS, Vault's transit engine, an HSM.
const wrapper: KeyWrapper = {
async wrap(material, binding) {
const out = await kms.send(new EncryptCommand({
KeyId: "alias/vault",
Plaintext: Buffer.from(material, "base64"),
EncryptionContext: { binding },
}))
return Buffer.from(out.CiphertextBlob!).toString("base64")
},
async unwrap(wrapped, binding) {
const out = await kms.send(new DecryptCommand({
CiphertextBlob: Buffer.from(wrapped, "base64"),
EncryptionContext: { binding },
}))
return Buffer.from(out.Plaintext!).toString("base64")
},
}
const vault = new Vault({ key: wrapper, store })This works because of the envelope: only the small data key is ever wrapped,
never the value, so it is one short round trip per entry rather than sending
secrets over the wire. The binding handed to a wrapper is the entry's owner
and name, so passing it as the service's encryption context has the KMS enforce
the same tie the local path enforces.
A wrapper is accepted anywhere a key is — as key, in previousKeys, and as
the argument to rekey. Which means moving an existing vault onto a KMS is not
a migration:
await vault.rekey(wrapper) // read with the old key, wrap with the KMSand moving back off it is rekey(material).
Watching what happens
new Vault({
key,
store,
onAccess: (event) => log(event), // put, open, read, remove, rotate, rekey, denied
})Every refusal is reported too, with the reason — final, sealed, expired.
The hook is never awaited and its failures are swallowed: an audit trail that
throws must not take the vault with it.
Changing the key
rekey opens every value with the current key and re-seals it under a new one:
const report = await vault.rekey(nextKey)
// { rekeyed: 128, failed: [] }The old key stays readable for the life of that vault, so a run that stops halfway leaves a mix that still opens. Construct the next one with both until you are sure:
new Vault({ key: nextKey, previousKeys: [oldKey], store })A value that will not open under any key it holds is left exactly as it was
and named in failed — re-sealing what cannot be read would only destroy it.
[!WARNING] Losing every key loses every value sealed under them. Rekey before you retire a key, and back the current one up where you would back up a password.
Sharing
An entry belongs to one owner. To let somebody else read it, grant it:
await vault.share("alice", "deploy-key", { with: "bob" })
await vault.open("bob", "deploy-key", { from: "alice" })The reader names whose entry they want. That is deliberate — a grant can never
quietly shadow something the reader already keeps under the same name, and
open("bob", "deploy-key") still means Bob's own.
Grants are read-only, always. Writing, rotating and deleting stay with the owner however widely an entry is shared; a grant that could overwrite the credential would make "shared with" mean "owned by". They can be temporary:
await vault.share("alice", "db", { with: "carol", expiresAt: friday })shares(owner, name) says who can read one of yours — lapsed grants included,
because "who could have seen this" is the question it exists to answer.
sharedWith(reader) is the other direction, and leaves lapsed grants out
because it answers what you can open right now.
Withdrawing takes effect immediately:
await vault.unshare("alice", "deploy-key", { with: "bob" })It says nothing about what Bob already read and kept, which is why a withdrawn grant is also a reason to rotate the value.
References reach shared secrets by naming the owner, and a name cannot contain a slash, so the two forms never collide:
await vault.resolve("bob", {
OURS: "@vault:own-token",
THEIRS: "@vault:alice/db",
})Keeping a record
onAccess is a callback the vault does not wait for. For a trail that outlives
the process, give it a log:
import { Vault } from "@mstone6969/vault"
import { SqliteAuditLog } from "@mstone6969/vault/stores/sqlite"
const audit = new SqliteAuditLog("./audit.sqlite")
const vault = new Vault({ key, store, audit })
await vault.open("alice", "db")
// Read the trail from the log, not from the vault.
await audit.entries({ owner: "alice", action: "open", since: monday })Every action is recorded, including the refusals — usually the interesting
ones. A read through a grant records both sides: owner is whose secret it
was, by is who read it.
A log that cannot be written fails the operation. That is the default and it is deliberate: an audit trail with silent gaps is worse than none, because it looks like evidence. If you would rather the vault carry on:
new Vault({ key, store, audit: { log, required: false } })The entry is written after the action and before the call returns, so a failed append reports something that did in fact happen. That is the cost of recording outcomes rather than intentions, and it fails in the safe direction — the caller is told something went wrong.
Three logs ship: MemoryAuditLog for tests and development, SqliteAuditLog
and PostgresAuditLog for a record that lasts. None of them has a method that
deletes a line. Retention belongs to whoever owns the database, not to the
library writing to it.
Entries are hash-chained, so an edited or deleted line can be spotted:
import { verifyChain } from "@mstone6969/vault"
const report = await verifyChain(await audit.entries())
if (!report.intact) console.error("trail broken at", report.brokenAt)Pass it an unfiltered listing — the chain runs through every entry, so a
filtered slice looks broken when nothing is wrong. And be clear about what it
buys: it proves the middle of a log has not been touched. It does not stop
somebody who can write to the database from re-hashing the whole thing or
lopping off the end. For that the log has to live somewhere the vault's writer
cannot reach; verifyChain is then what tells you whether what came back from
there adds up.
More than one writer
Every entry carries a revision that goes up by one on each write. Read it,
and hand it back to refuse a write that would land on top of someone else's:
const [entry] = await vault.list("alice")
await vault.put("alice", "token", next, { expectedRevision: entry.revision })
// throws 409 if anything wrote to it in between{ expectedRevision: null } means "only if it does not exist yet", which is how
to claim a name without racing another writer for it.
To get that protection on every write without passing it each time, build the
vault with strictWrites:
const vault = new Vault({ key, store, strictWrites: true })It is off by default because it turns a write that used to succeed into a 409, and a vault written against 1.1 should keep behaving the way it did. Turn it on wherever more than one process writes the same store. Without it, two writers racing on one entry silently lose one of the two values — and the loser is told the write succeeded.
How airtight it is depends on the store:
| Store | Contested write |
| --- | --- |
| PostgresStore | Settled by the database, in one statement |
| SqliteStore | Settled by SQLite, in one statement |
| FileStore | Settled under a lock file, one writer at a time |
| MemoryStore | Settled — there is only one process to contend |
| Your own | Checked, then written: narrower, not closed |
A custom store gets the weaker guarantee unless it implements putIf, which is
one method and optional. Nothing breaks without it; the window between the
check and the write just stays open.
remove takes expectedRevision too. That one is read-then-delete rather than
a single step, because a delete you can see is easier to live with than a write
you cannot.
When there are a lot of them
list hands back everything an owner has, which is right until it isn't.
page walks instead:
let after: string | null = null
do {
const { entries, cursor } = await vault.page("alice", { after, limit: 100 })
for (const entry of entries) console.log(entry.name)
after = cursor
} while (after !== null)Keyset paging, not an offset, so a write during the walk cannot make a page skip or repeat an entry.
The vault's own whole-store walks — rekey, reseal, purgeExpired,
rotationDue, sharedWith, exportAll — go through pages too, wherever the
store implements page. All four shipped stores do. A custom store without it
still works; those operations just load everything at once, as they did before
1.6. Set the page size with new Vault({ …, pageSize: 500 }).
Moving a vault
exportAll packs everything into one sealed document, and importAll unpacks
it somewhere else:
const carried = generateKey()
await Bun.write("backup.txt", await vault.exportAll(carried))
// on the other machine, in a vault with a master key of its own
const report = await elsewhere.importAll(await Bun.file("backup.txt").text(), carried)
// { imported: 42, skipped: [] }Values are opened and re-sealed under the key you pass, rather than copied across as they are — which is what lets the far end have a different master key. Metadata, expiry, rotation policies, finality and history come too.
Two things follow from that. It is the one operation that holds every secret in memory at once, so give it a key you would give the vault itself and treat the document as the vault in a single string. And it refuses rather than skipping when a value will not open: an export that quietly dropped what it could not read would look like a backup right up until you needed it.
For a vault that will not fit in memory, exportStream seals one entry per
line and importStream reads them as they arrive:
const file = Bun.file("backup.txt").writer()
for await (const line of vault.exportStream(carried)) file.write(`${line}\n`)
await file.end()
const text = await Bun.file("backup.txt").text()
await elsewhere.importStream(text.split("\n"), carried)The trade is real and worth stating: a single blob hides how many entries there
are and how big each one is, and a line-per-entry document does not.
importAll reads either format, so you only have to choose on the way out.
An import leaves entries that already exist alone and names them in skipped,
unless you pass { overwrite: true }. Restoring a backup over a vault that has
moved on should not silently undo the newer values.
API reference
The reference is generated from the source and ships inside the package, so
node_modules/@mstone6969/vault/docs is the same documentation you get here —
every function, parameter, type and thrown error, with examples.
| Start at | For |
| --- | --- |
| Vault | Everything you do with secrets |
| VaultOptions | Building one: keys, store, history, generators, audit hook |
| PutOptions | open, final, expiresAt, rotation, keepHistory |
| RotationPolicy | How the next value is made |
| FileStore · MemoryStore · SqliteStore | The stores that ship |
| VaultStore | Writing your own |
| KeyProvider | Where the master key comes from |
| VaultError · VaultKeyError | What is thrown, and the status each suggests |
Names are up to 64 characters of letters, numbers, dot, dash or underscore.
Bad input throws VaultError, which carries a suggested HTTP status so the
vault can sit behind an API without the caller knowing its internals; key and
ciphertext problems throw VaultKeyError.
Regenerate the reference with bun run docs. bun run docs:check fails if any
exported member is undocumented, and prepublishOnly runs it — so the
reference cannot fall behind the code the way a table in this file can.
Versions
Every published version, and what changed, is in CHANGELOG.md.
Releasing
Releases are cut by CI, not from a laptop. Bump the version, commit, and push a matching tag:
npm version patch # or minor
git push --follow-tagsThe tag triggers the release workflow, which refuses a tag that disagrees with
package.json, runs the same gate as prepublishOnly — types, documentation,
tests at 100%, build, reference, and an import under Node — and only then
publishes with the NPM_TOKEN repository secret.
workflow_dispatch runs everything except the publish, for checking the
pipeline without spending a version number.
Upgrading from 1.x
MIGRATING.md has the detail. In short:
- Entries written before 1.4 no longer open by default. Run
reseal()on 1.7 first, or start 2.0 withallowUnbound: trueand run it there.unbound()tells you what is left. strictWritesis on by default, so a write that would clobber a concurrent change throws 409.strictWrites: falserestores the old behaviour.- A custom store must implement
putIf, andSecretRecord.revisionand.sharesare required. The four stores that ship already comply.
Nothing else moved.
Stability
2.0 means the surface below is settled, and a breaking change to it needs a 3.0:
- The
Vaultclass and its methods. VaultStore, so a store written today keeps working.KeyProviderandKeyWrapper, and the providers that ship.SecretRecord,SecretSummary,PutOptions,RotationPolicy,VaultEvent,Share,AuditLog, and the errors.- The sealed format: a data key per value, wrapped under the master key and tied to the entry's owner and name. A version that could not open what an earlier one wrote would be a 3.0.
Adding an optional field to an options object, a new store, or a new provider is a minor version. Anything that changes what an existing call does is a major one.
Security
What the vault protects against, what it does not, and how to choose a key: SECURITY.md.
Development
bun test # the suite
bun run test:coverage # the suite, and fail if anything in src is untested
bun run typecheck
bun run docs # generate docs/ from the TSDoc comments
bun run docs:check # fail if any exported member is undocumentedEvery line and function in src is covered, and every exported member carries
TSDoc; test:coverage and docs:check enforce both, and prepublishOnly runs
them. Bun accepts coverageThreshold in bunfig.toml but does not act on it, so
the coverage check reads the lcov report itself and exits non-zero on a gap.
The generated reference lives in docs/ and ships with the package.
