User preferences
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 — optionallywithSync()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