stable

bundle

Write and read .noydb files — a portable single-file vault container with a minimum-disclosure header and optional re-keyed export to recipients.
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.
Factorynone — direct named imports
ClusterF — 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 if exportSecret and recipients are 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. Throws PodIntegrityError (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-method NoydbStore, 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 via autoFlush: false + store.flush() / store.batch(). Types: WrapPodStoreOptions (the autoFlush bag) and WrappedPodNoydbStore (a NoydbStore plus flush() / 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 the CompressionAlgo type.
  • 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/handle document 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 primitive writePod() uses for its re-keying path; the produced files exist only in the bundle bytes.

WritePodOptions

OptionTypeDefaultEffect
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.
collectionsreadonly string[]allAllowlist of user-collection names. Applies to user collections only — keyrings and ledger stay so the bundle remains unlockable.
sinceDate | 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.
tierAtMostnumber—Drop records whose envelope _tier is strictly greater. Envelope-level; no decryption needed.
exportSecretstring—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.
recipientsreadonly 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.
sealedCredentialsself-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

FieldTypeDefaultMeaning
idstringrequiredUser id stamped onto the keyring file in the bundle.
secretstringrequiredWhat the recipient will type to unlock.
displayNamestringidOptional display name.
roleRole'viewer'Role on the destination vault.
permissionsPermissionsrole defaultsPer-collection permissions; also bounds which DEKs are wrapped into the slot.
exportCapabilityExportCapabilitynoneOptional as-* export grants on the destination vault.
importCapabilityImportCapabilitynoneOptional as-* import grants. Default-closed.
expiresAtstring (ISO 8601)—Past this cutoff the slot's keyring refuses to load with KeyringExpiredError — time-boxed audit access.

ReadPodOptions

OptionTypeDefaultEffect
sealingProvidersreadonly 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).
attemptUnsealAcrossProvidersbooleanfalseOpt-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

FieldTypeMeaning
headerNoydbPodHeaderThe validated header.
dumpJsonstringThe 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)

FieldTypeMeaning
formatVersionnumberBumped on layout changes (currently 1).
handlestringThe vault's opaque ULID (26-char Crockford base32).
bodyBytesnumberCompressed body length — completeness check without decompressing.
bodySha256stringSHA-256 of the compressed body (lowercase hex) — integrity check without decompressing.
cover?CoverOwner-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

  1. vault.dump() produces the canonical backup JSON (records + source keyring + ledger).
  2. Records in satellite collections whose base row is dead are dropped unconditionally, before any other filter.
  3. When recipients (or exportSecret) is set, the source keyring is replaced with one freshly derived KeyringFile per 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.
  4. Plaintext filters (where) run, then metadata-only slice filters (collections, since, tierAtMost).
  5. 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.
  • sealedCredentials with mode: 'self-target' — each credential is sealed under a NoydbSealer you also control on the reading side (same Keychain entry, same KMS account, …). The reader supplies sealingProviders to readPod(); 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 cover with 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.
  • exportSecret and recipients are mutually exclusive, as are autoCredentials, 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, not vault.load() under the source session.

See also