stable

User preferences

Give each user of a shared vault an encrypted profile-and-preferences document — own-only writes, admin pre-fill, cross-user reads, live bindings.

Audience: multi-user vaults that want each user to get their own encrypted profile-and-preferences document (display name, avatar, locale, theme) that follows them across devices and that only they can write. Bundle: core (the built-in user envelope, always available) + withTeam() for multi-user grants — optionally withSync() for cross-device propagation and presence.

Verified by: showcases/src/recipe-user-preferences.recipe.test.ts — the runnable form of this recipe.

noy-db stores documents encrypted inside a vault, a container that can be shared by several users. For each user, the hub (@noy-db/hub, the core library) reserves one document called the user envelope: it lives at the storage key _users/<keyringId>, where the keyringId is the user's id in the vault's key registry — one envelope per user. The hub guarantees exactly two things: where the envelope is stored, and that each user can write only their own. What goes inside the envelope is entirely up to your app. This recipe supplies a reference shape and the read/write patterns around it; the full API is documented in user-envelope.

When to reach for this

You have a multi-user vault and want each user to get their own profile + preferences (display name, avatar, locale, theme) that follow them across devices via the team/sync engine. You want admin pre-fill at invite time, you want clean cross-user reads ("Bob's display name in the team list"), and you want the hub to enforce that only Bob can write Bob's profile — even an owner cannot.

Setup

import { createNoydb } from '@noy-db/hub'
import { withTeam } from '@noy-db/hub/team'

// withTeam() enables multi-user vaults (grant/revoke). vault.user itself
// is always available, even in single-user vaults.
const db = await createNoydb({
  teamStrategy: withTeam(),
  user: 'vicio',
  secret: 'vicio-pass-2026-strong',
  // No `store` → the built-in in-memory store. For a persistent browser
  // vault, pass e.g. toBrowserIdb() from @noy-db/to-browser-idb.
})

const vault = await db.openVault('demo')

Reference shape

interface UserShape {
  profile?: {
    displayName?: string
    avatarRef?: string  // ref to a file stored via the blobs service; not a URL
    locale?: string     // BCP 47
    timeZone?: string   // IANA
  }
  preferences?: {
    theme?: 'light' | 'dark' | 'system'
    locale?: string     // UI override; falls back to profile.locale
  }
  app?: {
    // Per-app extensions. Opaque to hub.
    signature?: string
    perClientCurrency?: Record<string, string>
  }
}

This interface is app-defined. The hub never imports it — it JSON-serializes and encrypts whatever object you hand it. Copy this shape, extend it, contract it, or replace it; the API is generic over UserEnvelope<T>, so your own shape flows through every method.

One practical bound: the JSON-serialized payload is soft-capped at 64 KiB — a larger write throws UserEnvelopeOversizedError. Keep bulky data (avatar images, documents) in the blobs service and store only refs here, as avatarRef above does.

Reading and writing your own envelope

vault.user.* always targets your envelope on writes — there is no method that accepts another user's id on the write path.

// First write — full shape. Creates the envelope.
await vault.user.updateMe<UserShape>({
  profile: { displayName: 'Vicio', locale: 'it-IT' },
  preferences: { theme: 'dark' },
})

// updateMe deep-merges: partial patches preserve unmentioned fields.
// (Pass null to delete a field; omitted keys are preserved.)
await vault.user.updateMe<UserShape>({
  preferences: { theme: 'light' }, // profile.* untouched
})

// Read your own envelope (never policy-gated).
const me = await vault.user.me<UserShape>()
console.log(me?.data.profile?.displayName) // → 'Vicio'
console.log(me?.data.preferences?.theme)   // → 'light' (merged, profile kept)

The full envelope returned by me():

{
  "keyringId": "vicio",
  "data": {
    "profile": { "displayName": "Vicio", "locale": "it-IT" },
    "preferences": { "theme": "light" }
  },
  "_v": 2,
  "_ts": "2026-07-08T09:00:00.000Z"
}

setMe replaces instead of merging — use it sparingly; updateMe is the canonical mutation:

await vault.user.setMe<UserShape>({
  profile: { displayName: 'Vicio2' },
})

const replaced = await vault.user.me<UserShape>()
console.log(replaced?.data.preferences) // → undefined (setMe replaced everything)

Writes go through the edit-own-profile policy gate; reads of your own envelope are never gated. See user-envelope for the gate defaults.

Admin pre-fill at grant time

When you invite a teammate with db.grant(), you can seed their envelope via initialProfile so their first sign-in already shows a name and sensible defaults:

await db.grant('demo', {
  userId: 'bob',
  displayName: 'Bob',
  role: 'viewer',
  secret: 'bob-pass-2026-strong',
  initialProfile: {
    profile: { displayName: 'Bob the Auditor', locale: 'fr-FR' },
    preferences: { theme: 'dark' },
  } satisfies UserShape,
})

This is bootstrap-only: once Bob activates and writes his own envelope, the own-only write rule prevents further admin edits. Admins cannot edit teammate profiles after activation — that's structural (no such method exists), not policy-controlled.

Reading teammates' profiles

Cross-user reads go through the view-team-profiles policy gate (any authenticated session passes by default):

// Read one teammate by keyringId.
const bob = await vault.user.get<UserShape>('bob')
console.log(bob?.data.profile?.displayName) // → 'Bob the Auditor'

// List every persisted envelope in the vault (order is store-defined).
const everyone = await vault.user.list<UserShape>()
console.log(everyone.map((e) => e.keyringId)) // → ['vicio', 'bob']

Two privacy behaviors worth knowing: with the gate configured as enabled: false, list() silently returns only your own envelope ([me]), while an insufficient session tier throws PolicyDeniedError — the first is a deliberate "nobody sees teammate profiles in this app" choice, the second means "authenticate further".

Device-local state — keep it OUT of the vault

Anything stored in the vault syncs across devices. Device-local state (last-opened-collection id, table column widths, scroll position, "don't show this onboarding tip again") is by definition not cross-device — keep it in localStorage at the app layer, not in the user envelope.

// Cross-device state → vault.user.*
await vault.user.updateMe<UserShape>({ preferences: { theme: 'dark' } })

// This-device state → localStorage
window.localStorage.setItem('lastOpenedCollectionId', 'invoices')

Mixing the two in one envelope creates surprises ("why did my column widths sync to my phone?").

Reactive bindings (Vue / React / Svelte)

vault.user.live(keyringId) returns a reactive handle that fires on every local write; wire it into your framework's state primitive:

// Vue 3 composable (sketch)
import { ref, onUnmounted } from 'vue'
import { vault } from './vault'          // your app's vault module
import type { UserShape } from './user-shape'

export function useUserEnvelope() {
  const me = ref<UserShape | null>(null)
  const live = vault.user.live<UserShape>(vault.userId)
  // Initial fetch
  vault.user.me<UserShape>().then((env) => { me.value = env?.data ?? null })
  // Live updates on local writes
  const unsub = live.subscribe((env) => { me.value = env?.data ?? null })
  onUnmounted(() => { live.stop(); unsub() })
  return { me }
}

Subscribers fire on local writes; changes made on another device arrive through the team/sync engine and surface when the sync layer replays the write through this API.

Presence integration

When announcing presence on a collection (who is online, who is editing what), the app sources displayName from its own user envelope and includes it in the presence payload. Presence requires the sync strategy: createNoydb({ syncStrategy: withSync(), … }) with withSync from @noy-db/hub/sync.

const invoices = vault.collection('invoices')
const presence = invoices.presence<{ displayName?: string; editingRecordId?: string }>()

const me = await vault.user.me<UserShape>()
await presence.update({
  displayName: me?.data.profile?.displayName,
  editingRecordId: 'invoice-42',
})

// Subscribers receive every *other* active peer — your own session is
// excluded, and stale entries (no update for 30 s by default) are filtered.
presence.subscribe((peers) => {
  // peers: Array<{ userId, payload, lastSeen }>
  console.log(peers.map((p) => p.payload.displayName))
  // → ['Vicio2']  (as seen from Bob's session; Vicio's own session sees [])
})

The hub stays payload-agnostic — it does not introspect the user envelope to populate presence; your app decides what to broadcast.

See also

  • user-envelope — full service reference (API, policy gates, lifecycle, storage shape)
  • team — grant/revoke flows the envelope lifecycle binds to
  • sync — cross-device propagation and presence