user-envelope
| Subpath | @noy-db/hub (root export) |
| Type | Core 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 byview-team-profiles(defaultminTier: 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 reason | Behavior |
|---|---|
disabled | Silent self-fallback — returns [me] only. |
insufficient-tier | Throws PolicyDeniedError (caller is expected to elevate) |
missing-factor | Throws PolicyDeniedError |
stale-proof | Throws 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:
| Gate | Default (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):
vault.user.updateMe/setMealways targetwriterKeyringId. Runtime check throwsPermissionDeniedErrorif the resolved target keyringId does not equal the writer's.- 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-profileto require a fresh TOTP for any profile change. - A privacy-strict app sets
view-team-profiles.enabled: false→list()returns only self;get(other)throwsPolicyDeniedError.
Relaxing (disallowed):
- Setting
edit-own-profileto permit cross-principal writes is impossible. Policy controls authentication, not authorization. The own-only rule is structural.
Lifecycle
- Created on grant. When
team/grant.tsprovisions a new keyring, it also creates a_users/<keyringId>envelope, seeded withinitialProfile?: Tif present, else empty ({}). - Magic-link grants. The existing
team/magic-link-grant.tsis for tier delegation (auditor gets 48h access to a collection), not user creation; it does not produce a persistent keyring. BootstrapinitialProfileflows throughdb.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_usersis in the affected collections set. - Untouched on tier-2 add/remove. Authenticator slot changes do not touch the envelope.
Pairs well with
team— joinedlistUsersWithEnvelopes()for admin UIs; cascade-revoke deletes envelopes alongside keyrings.tiers— theedit-own-profileandview-team-profilesgates 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 storingprofile.localeuse it to drivevault.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, notString.length). - Pre-existing vaults — single-principal lazy DEK. Vaults provisioned before this feature do not have a
_usersDEK in any keyring. The first call tovault.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: calldb.rotate(vault, ['_users'])after the first user callsvault.user.*to propagate the DEK to all keyrings. New vaults (post-feature) avoid this entirely via eager provisioning increateOwnerKeyring(). - 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
UserProfileProviderinterface 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. listUsersWithEnvelopesrequires the_usersDEK, which has no public accessor yet. The function'sdekparameter 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/admincallers can pass{ includeHidden: true }to see them. Own-only by construction (no method to hide another principal). Read it back withvault.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,listUsersWithEnvelopesthrowsDirectoryDisabledErrorfor any caller whose role is neitherownernoradmin.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-endrecipe-user-preferences.recipe.test.ts— reference shape pattern + device-local patterndocs/services/session-tiers.md— policy gate DSL the new gates plug intodocs/services/team.md— multi-user grant/revoke flows the lifecycle binds todocs/services/plaintext-bypass.md—_meta/directoryand_meta/visibility/<keyringId>are documented bypasses