stable

cover

Owner-curated plaintext metadata (name, description, icon) that stays readable before vault unlock or bundle decryption.
Subpath@noy-db/hub (root export)
TypeCore feature — enabled with the cover option on NoydbOptions.
Cross-cuts@noy-db/hub core (vault storage + .noydb bundle header format), every storage adapter (reads go through the plain get/put contract, no adapter-specific code)

What it is

noy-db's core guarantee is that storage backends only ever see ciphertext: every record — and by default every label on a vault (noy-db's unit of encrypted storage; see vaults and collections) — is encrypted under a key the owner must first supply. That is great for confidentiality, but it means a .noydb bundle dropped on a USB stick, or a list of vaults in a multi-tenant picker, has nothing readable at all: no name, no icon, not even a timestamp. See Enclave for the key model this sits below.

cover closes that specific gap with a small, deliberately narrow plaintext label. The developer opts a vault into the feature via NoydbOptions; the vault's owner then sets a handful of fields — name, description, icon, locale — that stay readable without unlocking the vault or decrypting a .noydb bundle (see bundle for the container format). Everything else about the vault stays exactly as encrypted as before: the envelope is a label, not a window into the data.

Every published field is one the owner explicitly chose to publish. The envelope deliberately excludes anything an attacker could use to infer activity over time — record counts, user counts, last-accessed timestamps — so opting in never turns into an unintended audit surface.

What's in scope

Owner-curated, static fields:

FieldTypeNotes
namestring | Record<string, string>Vault display name. Pass a plain string for a single-locale label, or a locale → string map ({ en: '…', th: '…' }) for multi-locale.
descriptionstring | Record<string, string>Free-form one or two sentences. Same single-string / locale-map shape as name.
iconstringInline data: URL (data:image/png;base64,… or data:image/svg+xml;base64,…). External URLs are not accepted. Capped at 256 KB of encoded data-URL string length by default. PNG and SVG only.
createdAtISO-8601 stringSet on the first setCover write; immutable thereafter — the developer/owner cannot set it directly.
updatedAtISO-8601 stringRefreshed on every setCover call.
versionnumberMonotonic counter starting at 1; increments on every successful write. Useful as a cheap cache-invalidation signal.
defaultLocaleBCP-47 stringFallback locale a reader falls back to when the requested locale isn't in a name/description map.

createdAt, updatedAt, and version are hub-managed — the owner cannot set them through setCover; including them in a schema's fields list is a no-op.

What's out of scope

These are deliberately absent from the envelope — they would make it an audit surface rather than a label:

  • Record counts, per-collection record counts, user counts, user lists.
  • Any field that updates on every read or write (last-accessed-at) — that pattern leaks activity over time to anyone who can list the bundle or vault.
  • A signature or HMAC over the envelope (see Limits & gotchas below — v1 is a plaintext, untrusted hint, not a signed claim).

Setup

import { createNoydb } from '@noy-db/hub'
import { toMemory } from '@noy-db/to-memory'

const db = await createNoydb({
  store: toMemory(),
  user: 'alice',
  secret: 'a-strong-secret',
  // shorthand — every field above, default caps:
  cover: true,
  // or narrow what the owner is allowed to set:
  // cover: { fields: ['name', 'icon'], maxIconBytes: 64 * 1024 },
})

Off by default. A vault written by a hub without cover set carries no envelope: getCover/readCover on it return undefined, and calling setCover throws ValidationError (importable from @noy-db/hub).

Usage

Owner sets the label, a caller reads it back

await db.openVault('acme')

const envelope = await db.setCover('acme', {
  name: { en: 'Acme 2026 Tax Records', th: 'บันทึกภาษี Acme 2026' },
  description: 'Q1–Q4 invoices, audit-ready.',
  icon: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0i…',
  defaultLocale: 'en',
})
// → { _noydb_public: 1, version: 1, name: { en: '…', th: '…' },
//     description: '…', icon: '…', defaultLocale: 'en',
//     createdAt: '2026-07-08T12:00:00.000Z', updatedAt: '2026-07-08T12:00:00.000Z' }

const label = await db.getCover('acme', { locale: 'th' })
// → { _noydb_public: 1, version: 1, name: 'บันทึกภาษี Acme 2026',
//     description: 'Q1–Q4 invoices, audit-ready.', icon: '…',
//     defaultLocale: 'en', createdAt: '…', updatedAt: '…' }

Passing locale resolves name/description to plain strings for that locale; omitting locale returns the raw map so a multilingual picker can render every translation itself.

Reading without unlocking the vault

The whole point of the envelope is that it works without a secret or an open Noydb session — pass the same store adapter directly:

import { readCover } from '@noy-db/hub/cover'

const label = await readCover(store, 'acme', { locale: 'en' })
// → { name: 'Acme 2026 Tax Records', description: 'Q1–Q4 invoices, audit-ready.', … }
// or `undefined` if the vault has no envelope written

Snapshotting into a .noydb bundle

writePod() (see bundle) automatically snapshots the source vault's current envelope into the bundle's plaintext header, so a recipient can read the label before decrypting anything:

import { writePod, readPod, readPodHeader } from '@noy-db/hub/pod'
import { readPodCover } from '@noy-db/hub'

const bytes = await writePod(vault)

// Pre-decompression, pre-decryption read — cheap, synchronous:
const header = readPodHeader(bytes)
header.publicEnvelope
// → { name: '…', description: '…', … } or undefined
// The field keeps its frozen wire name `publicEnvelope` (#799), not `cover`.

// Equivalent helper when you only care about the label:
readPodCover(bytes, { locale: 'en' })
// → { name: 'Acme 2026 Tax Records', description: 'Q1–Q4 invoices, audit-ready.', … }

Whether _meta/cover survives vault.load(dumpJson) on the destination vault is store-dependent — vault.dump() reads through adapter.loadAll(name), and individual store adapters choose whether to include underscore-prefixed internal collections in that bulk read. The bundle header snapshot is the portable path: after loading, re-attach the label explicitly from the header (the destination vault needs cover enabled in its own NoydbOptions for this to succeed):

const result = await readPod(bytes)
await vault.load(result.dumpJson)

if (result.header.publicEnvelope) {
  const { name, description, icon, defaultLocale } = result.header.publicEnvelope
  await db.setCover(vault.name, {
    ...(name !== undefined ? { name } : {}),
    ...(description !== undefined ? { description } : {}),
    ...(icon !== undefined ? { icon } : {}),
    ...(defaultLocale !== undefined ? { defaultLocale } : {}),
  })
}

i18n integration

name and description accept either a plain string (single-locale) or a locale → string map:

// Single-locale
{ name: 'Acme 2026' }

// Multi-locale
{ name: { en: 'Acme 2026', th: 'Acme 2026 (TH)', de: 'Acme 2026 (DE)' } }

On the read path, readCover/getCover/readPodCover all accept a locale option and resolve through the envelope's own fallback chain:

  1. Exact locale match.
  2. The envelope's defaultLocale (when set).
  3. First non-empty translation in the map.
  4. Empty string (only if every translation is empty).

This is a narrower, purpose-built resolver — not the hub's general-purpose resolveI18nText used for record fields. resolveI18nText throws LocaleNotSpecifiedError when no translation matches, because a missing record value is treated as a programming error worth surfacing. Label semantics are looser: a vault picker prefers showing something identifiable over a thrown error, so the envelope's resolver never throws.

The schema's maxStringChars cap (default 200) applies per resolved locale — each translation in a map is bounded independently, not the JSON-stringified union.

API reference

All names below are exported from @noy-db/hub.

Noydb methods

  • db.setCover(vault, input) → Promise<Cover> — validate input against the vault's resolved schema and persist it, merging onto any existing envelope (createdAt preserved, updatedAt/version bumped). Throws ValidationError if cover isn't enabled in NoydbOptions, or if input violates the schema (unknown field, oversize icon/string, disallowed MIME type).
  • db.getCover(vault, opts?) → Promise<Cover | undefined> — read the envelope. Works even when cover is not enabled on this hub instance (reads are passive); returns undefined when nothing has been written. opts.locale resolves locale-map fields to plain strings.

vault.getCover(opts?)

  • vault.getCover(opts?) → Promise<Cover | undefined> — same read as db.getCover(vault.name, opts), scoped to an already-open Vault instance. This is what writePod() calls internally to snapshot the envelope into a bundle header.

Standalone functions (no unlock required)

  • readCover(store, vault, opts?) → Promise<Cover | undefined> — read directly from a store adapter, no Noydb/Vault instance needed. Resolves opts.locale when given; built on top of loadCover.
  • loadCover(store, vault) → Promise<Cover | undefined> — the lower-level read readCover is built on: fetches _meta/cover from the store and parses it, with no locale resolution. Returns undefined for a fresh vault or a corrupted document.
  • saveCover(store, vault, envelope) → Promise<void> — the lower-level write db.setCover/vault.getCover's write path is built on: persists a full Cover record to _meta/cover directly, with no schema validation or field merging.
  • readPodCover(bytes, opts?) → Cover | undefined — read the envelope out of raw .noydb bundle bytes, without parsing the body. opts.locale behaves the same as above.
  • isCover(x) → boolean — runtime shape check (_noydb_public === 1 and version is a number).

Schema helpers

  • resolveCoverSchema(schema) → ResolvedCoverSchema | undefined — merge a developer-supplied CoverSchema (or the true shorthand) onto DEFAULT_COVER_SCHEMA. Returns undefined when the feature is off.
  • validateCoverInput(input, schema) → void — the validator setCover runs internally; throws ValidationError on the first violation.
  • COVER_FIELDS — the full list of settable field names: name, description, icon, createdAt, updatedAt, defaultLocale.
  • DEFAULT_COVER_SCHEMA — { fields: COVER_FIELDS, maxIconBytes: 256 * 1024, iconMimeTypes: ['image/png', 'image/svg+xml'], maxStringChars: 200 }.

Types

  • Cover — the persisted shape (_noydb_public: 1, version, plus the optional fields from the table above).
  • CoverText — string | Record<string, string>, the type of name/description.
  • CoverSchema / ResolvedCoverSchema — the developer-supplied and resolved-with-defaults schema shapes.
  • SetCoverInput — the subset of Cover an owner can pass to setCover (name, description, icon, defaultLocale).
  • CoverField — the union of settable field names (typeof COVER_FIELDS[number]): 'name' | 'description' | 'icon' | 'createdAt' | 'updatedAt' | 'defaultLocale'. This is the element type of CoverSchema.fields.

Storage layout

LayerPathFormat
Vault, on the store_meta/cover (the record id is exported as COVER_RECORD_ID, 'cover')Plaintext envelope (_iv: '', _data is the JSON-serialized Cover) — the same bypass-encryption pattern used by _meta/handle and _meta/policy.
.noydb bundle headerheader.coverInline JSON object inside the bundle's plaintext, minimum-disclosure header.

The bundle header's allowlist normally carries only formatVersion / handle / bodyBytes / bodySha256; cover is one of a small set of additional, individually-validated keys the parser accepts. Every other unknown key is still rejected at parse time — see bundle for the full allowlist.

Limits & gotchas

Threat model — the envelope is a plaintext, untrusted hint, not a signed claim:

  • Plaintext, untrusted hint. A store adapter (or anyone with write access to the underlying storage) can modify the envelope. Treat it like an HTTP Server: header — it helps the user, it is never a security claim. Don't mark the label "verified" or "owner-signed" in a renderer.
  • Off by default. Vaults written by a hub without cover enabled carry no envelope at all.
  • Schema-bounded. If a developer's schema only lists name and icon, an owner calling setCover with description gets a ValidationError — the allowed-field list caps what can leak.
  • Existence does not confirm contents. The envelope can say "Acme Tax Records" while the vault body is empty or holds something else entirely; a first-contact UI should not treat the label as a promise about what's inside.
  • Icon surface. The default 256 KB cap on the encoded data URL (≈ 192 KB decoded) is generous enough for a logo but bounded against smuggling large payloads. The MIME allowlist (image/png, image/svg+xml) is checked on write only — SVGs are not sanitized for embedded scripts at the storage layer, so a renderer must treat the icon as untrusted DOM input, not inline it unsanitized.
  • No new class of leakage. A .noydb bundle in cloud storage already exposes its size and bodySha256 to anyone who can list the file. cover adds owner-curated label fields to that same, already-visible surface — the encrypted body itself remains the source of truth.

See also

  • bundle — the .noydb container format whose header carries the envelope snapshot.
  • i18n — the record-level i18nText/resolveI18nText machinery the envelope's own locale resolver deliberately departs from.
  • session-tiers — the tier-1/2/3 unlock model the envelope sits below (it is readable before tier 1).