bundle
| Subpath | @noy-db/hub/pod. The old @noy-db/hub/bundle alias is gone from every published version of hub since 0.4.0-pre — it was last present in 0.3.0, so an import of it fails on both rails today. |
| Factory | none — direct named imports |
| Cluster | F — Snapshot & Portability |
| LOC cost | ~846 (off-bundle when not opted in) |
What it does
This service writes and reads .noydb files — a single-file, portable container holding a complete (or filtered) backup of a vault, noy-db's unit of encrypted storage. vault.dump() serialises the vault to a JSON string — records still encrypted, plus the keyring (the wrapped decryption keys) and the audit ledger — and writePod() wraps that JSON in a compact binary container that is safe to park on untrusted storage (S3, Google Drive, a USB stick, an email attachment), because every record inside is already AES-256-GCM ciphertext. See Enclave for the key model.
The container layout is: a 10-byte fixed prefix (NDB1 magic, flags, compression algorithm, header length), then an unencrypted JSON header, then the compressed body (brotli where the runtime supports it, gzip otherwise). The header is minimum-disclosure — it carries only what a cloud-listing UI needs before downloading: the format version, the vault's handle (an opaque 26-character ULID identifier, generated once per vault and stable across re-exports), the body's byte count, and the body's SHA-256. The parser rejects any header key outside that allowlist, so nothing about the tenant, the exporter, or export timing leaks through cloud-storage metadata indexing.
When you need it
- Backups to S3 / R2 / Google Drive / Dropbox where you want a single file per vault
- Offline transfer (USB, email) of a complete vault snapshot
- Handing a re-keyed slice of a vault to a recipient with their own secret
- Bundle-as-store: backends that prefer whole-file I/O over per-record operations (
wrapPodStore)
Opt in
The service does not use a with*() strategy factory — it tree-shakes naturally via direct named imports:
import { writePod, readPod, readPodHeader } from '@noy-db/hub/pod'
The former names — writePod, readPod, readPodHeader, and the WritePodOptions type — remain exported as deprecated aliases of writePod, readPod, readPodHeader, and WritePodOptions. New code should use the Pod names.
Usage
Backup and restore round-trip
import { createNoydb } from '@noy-db/hub'
import { toMemory } from '@noy-db/to-memory'
import { writePod, readPod, readPodHeader } from '@noy-db/hub/pod'
const db = await createNoydb({ store: toMemory(), user: 'alice', secret: 'a-strong-secret' })
const vault = await db.openVault('acme')
await vault.collection('invoices').put('inv-1', { id: 'inv-1', amount: 100 })
// 1. Write the container — one Uint8Array, save it anywhere
const bytes = await writePod(vault)
// 2. Inspect the header without decompressing the body (cheap, synchronous)
const header = readPodHeader(bytes)
{
"formatVersion": 1,
"handle": "01J1QDX9Y3Z4A5B6C7D8E9FGHJ",
"bodyBytes": 1874,
"bodySha256": "9f2ce1b4a7d8036c5e12f9a0b3d47c68e51a2f90c4b7d3e6a8f1049b2c5d7e3a"
}
// 3. Restore — readPod validates the magic, the header, and the body's
// integrity hash, then decompresses. It takes NO secret: it is a
// pure format layer, and the records inside dumpJson stay ciphertext.
const { dumpJson } = await readPod(bytes)
await vault.load(dumpJson)
await vault.collection('invoices').get('inv-1')
// → { id: 'inv-1', amount: 100 }
For local files, @noy-db/to-file ships two thin wrappers over the same primitives:
import { savePod, loadPod } from '@noy-db/to-file'
const handle = await vault.getPodHandle()
await savePod(`./bundles/${handle}.noydb`, vault) // writePod + atomic writeFile
const result = await loadPod(`./bundles/${handle}.noydb`) // readFile + readPod
result.header
// → { formatVersion: 1, handle: '01J1QDX9Y3Z4A5B6C7D8E9FGHJ', bodyBytes: 1874, bodySha256: '9f2ce1b4a7d8036c5e12f9a0b3d47c68e51a2f90c4b7d3e6a8f1049b2c5d7e3a' }
// result.dumpJson is the same vault.dump() JSON ready for vault.load() — see PodReadResult below
Naming files by the handle rather than the vault name avoids leaking metadata at the filesystem layer.
Re-keyed export to recipients
By default a bundle inherits the source vault's keyring — right for personal backup-and-restore. To hand a bundle to someone else, pass recipients (or the single-recipient exportSecret shorthand): the bundle's keyring map is replaced with one freshly derived slot per recipient, each sealed with that recipient's own secret. The source credentials never travel.
const bytes = await writePod(vault, {
collections: ['invoices'], // slice: only this collection
recipients: [
{
id: 'auditor',
secret: 'auditor-pw-2026',
role: 'viewer',
permissions: { invoices: 'ro' },
expiresAt: '2026-08-07T00:00:00Z', // slot refuses to unlock after this
},
],
})
const { dumpJson } = await readPod(bytes)
Object.keys(JSON.parse(dumpJson).keyrings)
// → ['auditor'] — the source keyring is not in the bundle
A recipient secret must be at least 12 characters — exportSecret and every string
recipients[].secret — with no opt-out, refused with WeakSecretError since hub 0.10.0-pre.2. Any
characters qualify: a passphrase, a generated token, a password-manager string. It is deliberately
not the owner-phrase rule, because a .noydb bundle is built to travel.
On the recipient's device, the bundle's keyrings and collections are written to the recipient's own store; openVault() then unlocks with the recipient's secret, exactly like any other vault. A slot's permissions also bound which decryption keys are wrapped into it — a { invoices: 'ro' } slot cannot decrypt other collections even though their ciphertext may sit in the bundle.
API
Functions
writePod(vault, options?)→Promise<Uint8Array>— dump the vault, apply re-keying and slice filters, compress, and assemble the container. Throws ifexportSecretandrecipientsare both set. (deprecated alias:writePod)readPod(bytes, options?)→Promise<PodReadResult>— validate magic + header, verify the body length and SHA-256, decompress, and return the original dump JSON. ThrowsPodIntegrityError(importable from@noy-db/hub) when the body was truncated, padded, or modified — distinct from format errors so you can pattern-match corrupted-in-transit vs malformed-by-producer. (deprecated alias:readPod)readPodHeader(bytes)→NoydbPodHeader— synchronous, header-only read: no body decompression, no integrity verification. Cost is O(prefix + header bytes). (deprecated alias:readPodHeader)wrapPodStore(store, options?)/createPodStore(impl)— convert a whole-file pod store (blob-oriented read/write with optimistic concurrency) into the standard six-methodNoydbStore, for backends like Google Drive, WebDAV, or iCloud Drive that work best with whole-file I/O. Version conflicts re-read, merge last-write-wins per record, and retry (max 3 attempts); flushing is per-mutation by default, or deferred viaautoFlush: false+store.flush()/store.batch(). Types:WrapPodStoreOptions(theautoFlushbag) andWrappedPodNoydbStore(aNoydbStoreplusflush()/batch()). (deprecated aliases:wrapPodStore,createPodStore,WrapPodStoreOptions,WrappedPodNoydbStore)generateULID()/isULID(value)— the handle format helpers.- Format-level exports for tooling:
NOYDB_POD_MAGIC,NOYDB_POD_PREFIX_BYTES,NOYDB_POD_FORMAT_VERSION,FLAG_COMPRESSED,FLAG_HAS_INTEGRITY_HASH,COMPRESSION_NONE/COMPRESSION_GZIP/COMPRESSION_BROTLI,validateBundleHeader(parsed),encodeBundleHeader(header), and theCompressionAlgotype. resetBrotliSupportCache()— test-only: reset the cached brotli feature detection.
Vault methods used with this service (always-on core)
vault.dump()→Promise<string>— the canonical backup JSON (encrypted records + keyrings + ledger).vault.load(dumpJson)→Promise<void>— restore from a dump; verifies the embedded ledger head when the backup carries one.vault.getPodHandle()→Promise<string>— the vault's ULID handle, persisted in the vault's_meta/handledocument so it survives process restarts and repeat exports.vault.buildBundleRecipientKeyrings(recipients)→Promise<Record<string, KeyringFile>>— build per-recipient keyring files without persisting anything to the source vault. This is the primitivewritePod()uses for its re-keying path; the produced files exist only in the bundle bytes.
WritePodOptions
| Option | Type | Default | Effect |
|---|---|---|---|
compression | 'auto' | 'brotli' | 'gzip' | 'none' | 'auto' | 'auto' tries brotli, falls back to gzip. 'brotli' throws if the runtime lacks support. 'none' is for round-trip testing. |
collections | readonly string[] | all | Allowlist of user-collection names. Applies to user collections only — keyrings and ledger stay so the bundle remains unlockable. |
since | Date | string | — | Keep only records whose envelope _ts is on/after this instant. Operates on the unencrypted envelope timestamp — no decryption needed. |
where | (record, { collection, id }) => boolean | Promise<boolean> | — | Plaintext predicate: each record is decrypted with the vault's key to run the test, and survivors keep their original ciphertext (no re-encryption). Async predicates supported. |
tierAtMost | number | — | Drop records whose envelope _tier is strictly greater. Envelope-level; no decryption needed. |
exportSecret | string | — | Single-recipient re-keying shorthand: one fresh keyring slot sealed with this secret, inheriting the source keyring's userId, role, and permissions. At least 12 characters (since hub 0.10.0-pre.2). Mutually exclusive with recipients. |
recipients | readonly PodRecipient[] | — | Multi-recipient re-keying (see below). Mutually exclusive with exportSecret. |
autoCredentials | { policy: 'public-by-design', perUser: Record<string, AutoCredential> } | — | Embed plaintext per-user credentials (secret | password | pin) for auto-unlock. Anyone holding the bytes can read them — for demo data and sample vaults only. The explicit policy discriminant is mandatory; a bare { perUser } is rejected at write time. |
sealedCredentials | self-target or recipient-target shape (see Auto-unlock) | — | Embed per-user credentials sealed under a sealing-key provider. Mutually exclusive with autoCredentials. |
autoSecrets | (deprecated) | — | Use autoCredentials. |
sealedSecrets | (deprecated) | — | Use sealedCredentials. |
collections and since intersect (AND). With no options, the bundle is a whole-vault snapshot.
PodRecipient
| Field | Type | Default | Meaning |
|---|---|---|---|
id | string | required | User id stamped onto the keyring file in the bundle. |
secret | string | required | What the recipient will type to unlock. |
displayName | string | id | Optional display name. |
role | Role | 'viewer' | Role on the destination vault. |
permissions | Permissions | role defaults | Per-collection permissions; also bounds which DEKs are wrapped into the slot. |
exportCapability | ExportCapability | none | Optional as-* export grants on the destination vault. |
importCapability | ImportCapability | none | Optional as-* import grants. Default-closed. |
expiresAt | string (ISO 8601) | — | Past this cutoff the slot's keyring refuses to load with KeyringExpiredError — time-boxed audit access. |
ReadPodOptions
| Option | Type | Default | Effect |
|---|---|---|---|
sealingProviders | readonly NoydbSealer[] | — | Recipient-side providers used to unseal sealed auto-unlock entries; matched by provider .id against each entry's pid. When unset, sealed entries are returned unmodified (base64 sealed bytes). |
attemptUnsealAcrossProviders | boolean | false | Opt-in trial mode: when an entry's pid matches no provider, try each provider with a matching algorithm. Surfaces extra credential prompts; use deliberately. |
PodReadResult
| Field | Type | Meaning |
|---|---|---|
header | NoydbPodHeader | The validated header. |
dumpJson | string | The original vault.dump() JSON, ready for vault.load(). |
autoUnlock? | { kind: 'unsealed' | 'sealed', perUser: Record<string, AutoCredential> } | Present only when the header's autoUnlock flag is set. 'sealed' entries read without matching providers keep the raw base64 sealed bytes in value. |
NoydbPodHeader
(deprecated alias: NoydbPodHeader)
| Field | Type | Meaning |
|---|---|---|
formatVersion | number | Bumped on layout changes (currently 1). |
handle | string | The vault's opaque ULID (26-char Crockford base32). |
bodyBytes | number | Compressed body length — completeness check without decompressing. |
bodySha256 | string | SHA-256 of the compressed body (lowercase hex) — integrity check without decompressing. |
cover? | Cover | Owner-curated label fields (name, icon, …) — see cover. Untrusted hint; the encrypted body stays the source of truth. |
autoUnlock? | 'unsealed' | 'sealed' | Indicator that the body carries auto-unlock credentials — visible pre-download so listing UIs can warn. |
bundleKind? | 'snapshot' | 'extracted-partition' | The bundle's lifecycle role; 'extracted-partition' bundles are re-keyed projections awaiting adoption — see transferable partitions. |
transferSeal? | { v: 1, alg, sealId } | Metadata-only indicator, present iff bundleKind === 'extracted-partition'. |
Any other header key causes validateBundleHeader to throw. Deliberately forbidden: vault/tenant name, exporter identity, export timestamps, and KDF parameters or salts.
Re-keying pipeline
vault.dump()produces the canonical backup JSON (records + source keyring + ledger).- Records in satellite collections whose base row is dead are dropped unconditionally, before any other filter.
- When
recipients(orexportSecret) is set, the source keyring is replaced with one freshly derivedKeyringFileper recipient: each recipient's key-encryption key is derived from their secret + a fresh salt via PBKDF2-SHA256 (600,000 iterations); the per-collection data-encryption keys (DEKs) are unwrapped from the source keyring once and re-wrapped under each recipient's key with AES-KW. - Plaintext filters (
where) run, then metadata-only slice filters (collections,since,tierAtMost). - The pruned, re-keyed JSON is compressed and wrapped in the container.
Record ciphertext is never rewritten — re-keying touches only the small wrapped-DEK blobs (32 bytes each), so re-keying a multi-GB bundle takes milliseconds. The privilege-escalation guard from grant() is reused: every DEK wrapped into a recipient's slot must come from the source's own DEK set.
After re-keying, the recipients array materialises as Record<userId, KeyringFile> in the bundle's keyrings field — structurally identical to a live vault's multi-user keyring map.
Auto-unlock credentials
A bundle can carry per-user credentials so the recipient's app can open it without prompting:
autoCredentials— plaintext,policy: 'public-by-design'required. Anyone holding the file can read them; meant for demo data, sample vaults, and onboarding.sealedCredentialswithmode: 'self-target'— each credential is sealed under aNoydbSealeryou also control on the reading side (same Keychain entry, same KMS account, …). The reader suppliessealingProviderstoreadPod(); entries are matched by provider id (pid) and unsealed transparently.
Recipient-target sealing
sealedCredentials with mode: 'recipient-target' lets a sender seal credentials for a recipient whose sealing provider the sender does not hold. The recipient publishes a RecipientHint — { v: 1, pid, alg: 'rsa-oaep-sha256', material: { publicKeyPem } } — through any out-of-band channel (email, S3, in-app message). The sender passes, per user, { credential, hint }; the hub seals each credential against the hint using a hybrid scheme (a fresh AES-256-GCM content key, RSA-OAEP-SHA256-wrapped to the recipient's public key).
The sender's provider must implement the RecipientSealer interface (publishRecipientHint() + sealForRecipient()). Handover-capable providers implement it (for example @noy-db/at-aws-kms with asymmetric keys); self-only providers such as @noy-db/at-macos-keychain and @noy-db/at-env do not — the type system rejects them at the call site. The reader side is unchanged: pid dispatch finds the matching local provider, which unseals the hybrid envelope inside the opaque sealed bytes. See the at-* sealing-provider family.
Behavior when NOT opted in
Not importing the symbols means they are not in your application bundle — there is nothing to gate.
Pairs well with
- blobs — attached binaries round-trip inside the bundle
- history — the dump embeds the audit-ledger head;
vault.load()verifies the hash chain on restore - snapshots — snapshot bytes are produced by this service's writer
- routing — pod stores as one leg of a multi-store setup
Edge cases & limits
- Brotli needs
CompressionStream('br')(Node 22+, Chrome 124+, Firefox 122+).'auto'falls back to gzip;compression: 'brotli'throws where unsupported. The algorithm is encoded in the prefix byte, so readers handle either transparently. - The handle is persisted on the vault's own storage (
_meta/handle): repeat exports of the same vault share it, but a restored vault generates its own handle on first export — the dump deliberately excludes_meta, because handle stability is per vault instance, not per content. - The header is normally well under 1 KB but can reach roughly 256 KB when a
coverwith an inline icon is present — size range requests accordingly. - The header parser rejects unknown keys — you cannot smuggle custom metadata into the header; put it in a record instead.
exportSecretandrecipientsare mutually exclusive, as areautoCredentials,sealedCredentials, and their deprecated predecessors — violations throw at write time.readPod()never decrypts records; restoring re-keyed bundles requires opening the vault with a recipient credential, notvault.load()under the source session.
See also
- snapshots — checkpoint/restore built on this container
- transferable partitions —
extracted-partitionbundles and adoption - cover — the owner-curated header labels
- Enclave — KEK/DEK model, AES-GCM envelopes
to-*storage family — includes the@noy-db/to-filesavePod/loadPodhelpersat-*sealing-provider family — sealing keys for auto-unlock- Proven by showcase
21-with-bundle.