stable

user-envelope

Per-principal profile storage: every keyring gets a small encrypted JSON object at `_users/<keyringId>` for its own display name, theme, and preferences.
Subpath@noy-db/hub (root export)
TypeCore feature — exposed as vault.user.*, always available.

What it does

Every noy-db vault can hold more than one user. Each user unlocks the vault with their own keyring — the per-principal set of wrapped keys that grants them access — and this service gives every keyring a small JSON object to store its own profile and preferences (display name, theme, locale, whatever the app needs), living alongside it in the vault. That object is called a user envelope: it's stored at _users/<keyringId>, encrypted under a DEK (data-encryption key) shared by every keyring in the vault, and only ever written by the user it belongs to — hub commits only to userId === keyringId and where the envelope lives; the shape of the data inside is entirely up to the app. Three method families on vault.user.*:

  • Write-self — me() / updateMe(patch) / setMe(payload). Always target the writer's own keyringId; there is no API method to write someone else's envelope.
  • Read-anyone — get(keyringId) / list(). Gated by view-team-profiles (default minTier: 2).
  • Reactive — subscribe(id, cb) / live(id). In-process event emission on local writes.

When you need it

  • A multi-user vault where each user wants their own UI preferences (theme, locale) that follow them across devices via the team/sync engine.
  • A team-aware app rendering "who's online" or "who edited this record" with display names + avatars sourced consistently from one place.
  • An accounting-style app where each user has a profile (signature, default currency map, per-client overrides) that travels with the vault.
  • Admin onboarding flows that pre-fill a teammate's display name and locale at invite time.

Setup

None — vault.user is always available on every Vault. The first write lazily provisions any state the underlying primitive needs:

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

// No store → the kernel's built-in in-memory default (0.3).
const db = await createNoydb({ user: 'alice', secret: 'pass-2026' })
const vault = await db.openVault('demo')

await vault.user.updateMe({
  profile: { displayName: 'Alice' },
  preferences: { theme: 'dark' },
})
// → {
//     keyringId: 'alice',
//     data: { profile: { displayName: 'Alice' }, preferences: { theme: 'dark' } },
//     _v: 1,
//     _ts: '2026-05-05T10:00:00.000Z',
//   }

Usage

Read your own envelope

me() returns the writer's own envelope, or null if they've never written one — it's never gated, since reading your own data needs no permission check:

const mine = await vault.user.me()
// → {
//     keyringId: 'alice',
//     data: { profile: { displayName: 'Alice' }, preferences: { theme: 'dark' } },
//     _v: 1,
//     _ts: '2026-05-05T10:00:00.000Z',
//   }

Read a teammate's envelope

get(keyringId) reads any principal's envelope by id. Reading someone else's envelope is gated by view-team-profiles (default minTier: 2) — reading your own is not:

const bobs = await vault.user.get('bob')
// → { keyringId: 'bob', data: { profile: { displayName: 'Bob' } }, _v: 1, _ts: '2026-05-05T10:05:00.000Z' }
// throws PolicyDeniedError if the caller's session tier is below view-team-profiles' minTier

React to local writes

subscribe(keyringId, cb) fires synchronously whenever that principal writes their own envelope from this process:

const unsubscribe = vault.user.subscribe('alice', (env) => {
  console.log(env?.data.preferences?.theme)
})

await vault.user.updateMe({ preferences: { theme: 'light' } })
// → the subscriber above fires synchronously: logs 'light'

unsubscribe()

API

Write-self (own envelope only)

vault.user.me<T>(): Promise<UserEnvelope<T> | null>
vault.user.updateMe<T>(patch: DeepPartial<T>, presented?): Promise<UserEnvelope<T>>
vault.user.setMe<T>(payload: T, presented?): Promise<UserEnvelope<T>>

Each call goes through the edit-own-profile policy gate (default minTier: 3). Pass presented: { factors, sharedDevice } to satisfy tightened policies (e.g. STRICT_POLICY requires a TOTP or email-OTP proof for profile edits).

Read-anyone

vault.user.get<T>(keyringId: string, presented?): Promise<UserEnvelope<T> | null>
vault.user.list<T>(presented?): Promise<UserEnvelope<T>[]>

get(keyringId) is gated by view-team-profiles only when keyringId !== self — reading your own envelope is never gated.

const bob = await vault.user.get('bob')
// → { keyringId: 'bob', data: { profile: { displayName: 'Bob' } }, _v: 1, _ts: '2026-05-05T10:05:00.000Z' }

list() reads every persisted envelope in the vault (store-defined order):

const all = await vault.user.list()
// → [
//     { keyringId: 'alice', data: { profile: { displayName: 'Alice' } }, _v: 1, _ts: '2026-05-05T10:00:00.000Z' },
//     { keyringId: 'bob',   data: { profile: { displayName: 'Bob' } },   _v: 1, _ts: '2026-05-05T10:05:00.000Z' },
//   ]

list() has a deliberate asymmetry on denial:

Denial reasonBehavior
disabledSilent self-fallback — returns [me] only.
insufficient-tierThrows PolicyDeniedError (caller is expected to elevate)
missing-factorThrows PolicyDeniedError
stale-proofThrows PolicyDeniedError

The enabled: false path is the privacy-strict opt-out ("nobody sees teammate profiles in this app"); the others are authentication concerns. An insufficient-tier denial here asks you to authenticate further up the session ladder — it is not the per-record elevation documented on tiers.

Reactive

vault.user.subscribe<T>(keyringId: string, cb: (env: UserEnvelope<T> | null) => void): Unsubscribe
vault.user.live<T>(keyringId: string): LiveUserEnvelope<T>

Subscribers fire synchronously after every successful local updateMe/setMe on the matching keyringId. Pass '*' to fire on every change. Cross-instance changes (a teammate edits their profile on their device, the sync engine pulls onto this device) flow through the team/sync engine and will fire subscribers when sync replays the write through the API. In v1, raw store-level changes do not fire subscribers — wire your sync layer to call back through vault.user.setMe/updateMe if you need that behavior.

Lifecycle hooks

db.grant(vault, { ..., initialProfile?: T })

Admin pre-fill at grant time. Hub seeds the new principal's first envelope under the caller's _users DEK so the new user can decrypt it on first open. Once the user activates and writes their own envelope, the own-only write rule prevents further admin edits — bootstrap-only by construction.

await db.grant('acme', {
  userId: 'bob',
  displayName: 'Bob',
  role: 'operator',
  secret: 'glasses cabinet bicycle umbrella thunder velvet',
  permissions: { invoices: 'rw' },
  initialProfile: { profile: { displayName: 'Bob' }, preferences: { theme: 'light' } },
})
// → bob's `_users/bob` envelope exists, pre-filled, before bob ever opens the vault

listUsersWithEnvelopes(store, vault, dek, callerRole)

import { listUsersWithEnvelopes } from '@noy-db/hub'

const rows = await listUsersWithEnvelopes<UserShape>(store, vault, dek, callerRole)
// rows: Array<{ user: UserInfo; envelope: UserEnvelope<UserShape> | null }>
// → [
//     { user: { userId: 'alice', displayName: 'Alice', role: 'owner', ... },
//       envelope: { keyringId: 'alice', data: { profile: { displayName: 'Alice' } }, _v: 1, _ts: '...' } },
//     { user: { userId: 'bob', displayName: 'Bob', role: 'operator', ... },
//       envelope: null },
//   ]

Convenience for admin UIs that want to render team-member lists with profile data in a single pass — store/vault are the same values passed to createNoydb/openVault, callerRole is the caller's own role, and it drives the directory-visibility rules below. Principals without a persisted envelope (legacy keyrings predating this feature) come back with envelope: null.

Availability

vault.user is always present — there is no opt-in to skip. Vaults that never call vault.user.* work identically to today; the _users collection is reserved on grant but envelopes are empty until first updateMe().

Storage shape

Each principal has one envelope at the store key (_users, <keyringId>). The envelope uses the standard noy-db form:

{ "_noydb": 1, "_v": 7, "_ts": "2026-05-05T10:00:00.000Z", "_iv": "<base64>", "_data": "<base64 ciphertext>" }

_data decrypts (under the _users DEK) to an opaque JSON object. Hub does not reserve any keys inside the payload. The reference shape { profile, preferences, app } lives in the recipe-user-preferences recipe as copy-paste material — apps adopt, modify, or replace it.

The _users DEK is eager-provisioned in createOwnerKeyring() for new vaults, and propagated to every granted keyring via the existing system-collection branch (collName.startsWith('_')) in team/keyring.ts. Pre-existing vaults (provisioned before this feature) get the DEK lazily on first vault.user.* access — see "Edge cases & limits" below.

Permission model

Two new built-in policy gates, registered alongside the existing tier-2/tier-3 gates:

GateDefault (PERSONAL)Default (STRICT)Purpose
edit-own-profile{ minTier: 3 }{ minTier: 2, factors: [{ anyOf: ['totp', 'email-otp'] }] }Authorize a write to one's own envelope.
view-team-profiles{ minTier: 2 }{ minTier: 2 }Authorize reading other principals' envelopes.

Structural rules (cannot be relaxed by policy):

  1. vault.user.updateMe / setMe always target writerKeyringId. Runtime check throws PermissionDeniedError if the resolved target keyringId does not equal the writer's.
  2. The API exposes no method that accepts a target keyringId for writes. There is no path by which an admin or owner can write a teammate's envelope.

Tightening (allowed):

  • A strict app sets edit-own-profile to require a fresh TOTP for any profile change.
  • A privacy-strict app sets view-team-profiles.enabled: false → list() returns only self; get(other) throws PolicyDeniedError.

Relaxing (disallowed):

  • Setting edit-own-profile to permit cross-principal writes is impossible. Policy controls authentication, not authorization. The own-only rule is structural.

Lifecycle

  • Created on grant. When team/grant.ts provisions a new keyring, it also creates a _users/<keyringId> envelope, seeded with initialProfile?: T if present, else empty ({}).
  • Magic-link grants. The existing team/magic-link-grant.ts is for tier delegation (auditor gets 48h access to a collection), not user creation; it does not produce a persistent keyring. Bootstrap initialProfile flows through db.grant() only.
  • Deleted on revoke. Cascade-revoke removes the envelope alongside the keyring file. Idempotent: no error if the envelope is already absent.
  • Rotated on tier-1 rotation. The DEK rotation path re-encrypts every _users/* envelope under the fresh DEK — the user-envelope feature inherits this for free since _users is in the affected collections set.
  • Untouched on tier-2 add/remove. Authenticator slot changes do not touch the envelope.

Pairs well with

  • team — joined listUsersWithEnvelopes() for admin UIs; cascade-revoke deletes envelopes alongside keyrings.
  • tiers — the edit-own-profile and view-team-profiles gates plug into the same policy-gate DSL as every other built-in gate.
  • sync — own-write changes propagate to teammates' clients on the next sync round-trip; subscribe() fires when the sync layer replays the write through the API.
  • i18n — apps storing profile.locale use it to drive vault.t() resolution.

Edge cases & limits

  • Soft size cap: 64 KiB per envelope. Larger payloads throw UserEnvelopeOversizedError. UTF-8 byte-aware (multi-byte characters are counted via TextEncoder, not String.length).
  • Pre-existing vaults — single-principal lazy DEK. Vaults provisioned before this feature do not have a _users DEK in any keyring. The first call to vault.user.* lazily creates the DEK in that user's keyring only. Other principals on a different machine that open the vault later will then create their own (different) DEK — and cross-principal reads will fail until the vault rotates the keys. Workaround: call db.rotate(vault, ['_users']) after the first user calls vault.user.* to propagate the DEK to all keyrings. New vaults (post-feature) avoid this entirely via eager provisioning in createOwnerKeyring().
  • Subscribe / live in v1 fire only on local writes. Cross-instance changes flow via team/sync; subscribers there see the change when sync replays through the API. Wiring raw store-level changes into subscribers is a follow-up.
  • No managed-mode identity-provider integration yet. A future UserProfileProvider interface for syncing profile data from an external IdP is planned but not implemented — there is no such export, constructor option, or runtime hook in the current API.
  • listUsersWithEnvelopes requires the _users DEK, which has no public accessor yet. The function's dek parameter must currently be sourced from vault internals; there is no supported public method that returns it. Until one ships, the joined enumeration is reachable from code with direct access to those internals (e.g. within the hub itself), not from ordinary app code.

Directory visibility

Two complementary opt-outs on top of listUsersWithEnvelopes:

  • vault.user.setMyVisibility({ hidden: true }) — per-user opt-out. Persisted as a plaintext sidecar at _meta/visibility/<keyringId>. Hidden users are filtered out of the default listing; owner/admin callers can pass { includeHidden: true } to see them. Own-only by construction (no method to hide another principal). Read it back with vault.user.getMyVisibility(), which returns { hidden: false } when nothing has been persisted yet.
    await vault.user.setMyVisibility({ hidden: true })
    const mine = await vault.user.getMyVisibility()
    // → { hidden: true }
    
  • db.setDirectoryEnabled(vault, false) — vault-level toggle. Owner-only. Persisted at _meta/directory. When disabled, listUsersWithEnvelopes throws DirectoryDisabledError for any caller whose role is neither owner nor admin.
    await db.setDirectoryEnabled('acme', false)
    // → any admin/non-owner call to listUsersWithEnvelopes now throws DirectoryDisabledError
    

Both flags live in _meta rather than inside the encrypted UserEnvelope<T>.data payload — data is opaque-to-hub by contract, and the directory filter must work even when an envelope decryption fails (legacy keyrings predating the envelope feature, or a corrupted envelope). Storing them as plaintext sidecars matches the _meta/policy pattern documented in plaintext-bypass.md.

Honest caveat — this is a UX flag, not a privacy guarantee. The keyring file at _keyring/<userId> is still listed (an attacker with direct store read access can count keyrings and read role + permissions metadata). The envelope ciphertext at _users/<keyringId> is still present in the store. The visibility + directory flags only gate the hub-level enumeration helper — they prevent admin-UIs from accidentally rendering a hidden user, not a determined adversary from learning who has access. Apps that need real principal anonymity should use a dedicated identity store external to the vault.

Peer-recovery preserves both flags: db.recoverUser only rewrites the keyring file, so the user's visibility doc and the vault's directory doc both survive the rotation.

See also

  • 70-user-envelope.showcase.test.ts — Hub API end-to-end
  • recipe-user-preferences.recipe.test.ts — reference shape pattern + device-local pattern
  • docs/services/session-tiers.md — policy gate DSL the new gates plug into
  • docs/services/team.md — multi-user grant/revoke flows the lifecycle binds to
  • docs/services/plaintext-bypass.md — _meta/directory and _meta/visibility/<keyringId> are documented bypasses