cover
| Subpath | @noy-db/hub (root export) |
| Type | Core 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:
| Field | Type | Notes |
|---|---|---|
name | string | 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. |
description | string | Record<string, string> | Free-form one or two sentences. Same single-string / locale-map shape as name. |
icon | string | Inline 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. |
createdAt | ISO-8601 string | Set on the first setCover write; immutable thereafter — the developer/owner cannot set it directly. |
updatedAt | ISO-8601 string | Refreshed on every setCover call. |
version | number | Monotonic counter starting at 1; increments on every successful write. Useful as a cheap cache-invalidation signal. |
defaultLocale | BCP-47 string | Fallback 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:
- Exact
localematch. - The envelope's
defaultLocale(when set). - First non-empty translation in the map.
- 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>— validateinputagainst the vault's resolved schema and persist it, merging onto any existing envelope (createdAtpreserved,updatedAt/versionbumped). ThrowsValidationErrorifcoverisn't enabled inNoydbOptions, or ifinputviolates the schema (unknown field, oversize icon/string, disallowed MIME type).db.getCover(vault, opts?)→Promise<Cover | undefined>— read the envelope. Works even whencoveris not enabled on this hub instance (reads are passive); returnsundefinedwhen nothing has been written.opts.localeresolves locale-map fields to plain strings.
vault.getCover(opts?)
vault.getCover(opts?)→Promise<Cover | undefined>— same read asdb.getCover(vault.name, opts), scoped to an already-openVaultinstance. This is whatwritePod()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, noNoydb/Vaultinstance needed. Resolvesopts.localewhen given; built on top ofloadCover.loadCover(store, vault)→Promise<Cover | undefined>— the lower-level readreadCoveris built on: fetches_meta/coverfrom the store and parses it, with no locale resolution. Returnsundefinedfor a fresh vault or a corrupted document.saveCover(store, vault, envelope)→Promise<void>— the lower-level writedb.setCover/vault.getCover's write path is built on: persists a fullCoverrecord to_meta/coverdirectly, with no schema validation or field merging.readPodCover(bytes, opts?)→Cover | undefined— read the envelope out of raw.noydbbundle bytes, without parsing the body.opts.localebehaves the same as above.isCover(x)→boolean— runtime shape check (_noydb_public === 1andversionis a number).
Schema helpers
resolveCoverSchema(schema)→ResolvedCoverSchema | undefined— merge a developer-suppliedCoverSchema(or thetrueshorthand) ontoDEFAULT_COVER_SCHEMA. Returnsundefinedwhen the feature is off.validateCoverInput(input, schema)→void— the validatorsetCoverruns internally; throwsValidationErroron 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 ofname/description.CoverSchema/ResolvedCoverSchema— the developer-supplied and resolved-with-defaults schema shapes.SetCoverInput— the subset ofCoveran owner can pass tosetCover(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 ofCoverSchema.fields.
Storage layout
| Layer | Path | Format |
|---|---|---|
| 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 header | header.cover | Inline 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
coverenabled carry no envelope at all. - Schema-bounded. If a developer's schema only lists
nameandicon, an owner callingsetCoverwithdescriptiongets aValidationError— 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
.noydbbundle in cloud storage already exposes its size andbodySha256to anyone who can list the file.coveradds owner-curated label fields to that same, already-visible surface — the encrypted body itself remains the source of truth.
See also
- bundle — the
.noydbcontainer format whose header carries the envelope snapshot. - i18n — the record-level
i18nText/resolveI18nTextmachinery 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).