stable

directory

Vault-level directory toggle and per-user hide flag that filter who shows up when you enumerate a vault's team — always-on, no with*() opt-in required.
Subpath@noy-db/hub (root export)
TypeCore feature — listUsers and listUsersWithEnvelopes are always available.

What it does

Any multi-user vault eventually needs a "who's on this team" screen. Directory is the pair of visibility flags that shape that listing: a vault-level toggle that turns enumeration off entirely for non-admins, and a per-user flag that lets an individual opt out of showing up in it. Both are checked by listUsersWithEnvelopes, the join helper from the team collaboration family that combines each keyring (a role-carrying access record) with its decrypted user envelope (the per-principal profile record described in user-envelope) — documented in full below, since it is where both directory flags are enforced.

Neither flag is a security boundary. Both are plain JSON sidecars stored next to the vault's other metadata — see Limits & gotchas — so treat them as admin-UI polish, not as a way to hide a principal's existence from someone with direct store access.

Setup

There is nothing to opt into — both flags are part of the always-on core, no with*() strategy required. The directory toggle is reachable from any Noydb instance (db.getDirectoryEnabled/setDirectoryEnabled); the per-user flag is reachable from any open Vault (vault.user.getMyVisibility/setMyVisibility) — there is no vault.setDirectoryEnabled or db.user.setMyVisibility shortcut on the other object. Enumerating more than one principal does need a second user, which requires opting into multi-user grants with withTeam():

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

const store = toMemory() // shared by every session below; swap for to-file / to-postgres in production

const owner = await createNoydb({
  store,
  teamStrategy: withTeam(),
  user: 'alice',
  secret: 'alice-pass-2026-strong',
})
const ownerVault = await owner.openVault('demo')

await owner.grant('demo', {
  userId: 'bob',
  displayName: 'Bob',
  role: 'viewer',
  secret: 'bob-pass-2026-strong',
})

await owner.getDirectoryEnabled('demo')
// → true — enabled by default, no _meta/directory document required

Usage

Opt yourself out of the listing

Own-only — there is no method that takes another user's keyringId, so no caller can hide or unhide a different principal:

const bobSession = await createNoydb({
  store,
  teamStrategy: withTeam(),
  user: 'bob',
  secret: 'bob-pass-2026-strong',
})
const bobVault = await bobSession.openVault('demo')

await bobVault.user.setMyVisibility({ hidden: true })
await bobVault.user.getMyVisibility()
// → { hidden: true }

Toggle the vault-level directory

Owner-only:

await owner.setDirectoryEnabled('demo', false)
await owner.getDirectoryEnabled('demo')
// → false

What the flags do to a listing

listUsersWithEnvelopes(store, vault, userEnvelopeDek, callerRole, options?) is the read path that enforces both flags together. It joins every granted keyring with its decrypted user envelope and applies, in order: the vault-level toggle, then the per-user hide flag.

It takes the vault's _users collection key as a plain argument rather than an open Vault session, and today there is no public Vault method that returns that key — see Limits & gotchas. The snippet below is illustrative of the call shape, not something you can copy-paste and run as-is until a public accessor exists:

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

await owner.setDirectoryEnabled('demo', true) // re-enable after the toggle example above

// userEnvelopeDek: no public accessor yet — see Limits & gotchas below.
// This is the vault's `_users` collection DEK; server-side / admin tooling
// that already manages store and key access supplies it directly.
declare const userEnvelopeDek: CryptoKey

const rows = await listUsersWithEnvelopes(store, 'demo', userEnvelopeDek, 'owner')
// rows: [{ user: { userId: 'alice', role: 'owner', ... }, envelope: {...} }]
//       (bob is filtered out — hidden: true, caller did not ask for includeHidden)

const all = await listUsersWithEnvelopes(store, 'demo', userEnvelopeDek, 'owner', {
  includeHidden: true,
})
// all: [{ user: { userId: 'alice', ... }, envelope: {...} },
//       { user: { userId: 'bob', ... },   envelope: {...} }]

includeHidden: true itself requires owner or admin; any other role passing it throws PermissionDeniedError. With the directory disabled and callerRole: 'viewer', the same call throws DirectoryDisabledError instead:

Vault "demo" has its user directory disabled. Only owners and admins
can call listUsersWithEnvelopes() here. Use db.setDirectoryEnabled(vault, true) to re-enable.

API reference

Types

ExportShapePurpose
DirectoryConfig{ enabled: boolean }Vault-level directory toggle, persisted at _meta/directory.
UserVisibility{ hidden: boolean }Per-user hide flag, persisted at _meta/visibility/<keyringId>.

Directory toggle

ExportSignaturePurpose
readDirectoryConfig(store, vault) => Promise<DirectoryConfig | undefined>Read _meta/directory. undefined means no document exists yet — callers treat that as { enabled: true }. A corrupted document also reads back as undefined rather than throwing.
persistDirectoryConfig(store, vault, config: DirectoryConfig) => Promise<void>Write _meta/directory. No role check — the role gate lives in the wrapper below.
db.getDirectoryEnabled(vault)Promise<boolean>Kernel wrapper over readDirectoryConfig; no role gate — anyone who can open the vault can read the toggle.
db.setDirectoryEnabled(vault, enabled)Promise<void>Kernel wrapper over persistDirectoryConfig; owner-only, throws PermissionDeniedError otherwise.
DIRECTORY_RECORD_ID'directory'The _meta record id the toggle lives at.

Per-user visibility

ExportSignaturePurpose
readUserVisibility(store, vault, keyringId) => Promise<UserVisibility | undefined>Read _meta/visibility/<keyringId>. undefined means no document exists yet — callers treat that as { hidden: false }.
persistUserVisibility(store, vault, keyringId, visibility: UserVisibility) => Promise<void>Write _meta/visibility/<keyringId>. No ownership check — the own-only rule lives in the wrapper below.
deleteUserVisibility(store, vault, keyringId) => Promise<void>Delete the sidecar. Called by revoke() so a hidden flag never leaks onto a re-granted userId. Idempotent.
vault.user.getMyVisibility()Promise<UserVisibility>Read the caller's own flag.
vault.user.setMyVisibility({ hidden })Promise<void>Set the caller's own flag. Own-only by construction — no keyringId parameter exists on this method.
visibilityRecordId(keyringId)(keyringId: string) => stringCompose the _meta record id: `visibility/${keyringId}`.
VISIBILITY_RECORD_PREFIX'visibility/'The prefix visibilityRecordId uses.

Shared

ExportPurpose
META_COLLECTION'_meta' — the reserved collection both records live in.
DirectoryDisabledErrorThrown by listUsersWithEnvelopes when the directory is disabled and the caller is neither owner nor admin. Carries .vault.

listUsersWithEnvelopes(store, vault, userEnvelopeDek, callerRole, options?)

Exported from @noy-db/hub root; defined in the team collaboration family and documented here because this is where both directory flags are actually enforced.

ExportSignaturePurpose
listUsersWithEnvelopes<T>(store: NoydbStore, vault: string, userEnvelopeDek: CryptoKey, callerRole: Role, options?: { includeHidden?: boolean }) => Promise<Array<{ user: UserInfo; envelope: UserEnvelope<T> | null }>>Joins every granted keyring with its decrypted user envelope, applying the vault-level toggle then the per-user hide flag. options.includeHidden requires callerRole to be owner or admin; passing it as any other role throws PermissionDeniedError. A principal with no persisted envelope comes back with envelope: null.

Limits & gotchas

  • listUsersWithEnvelopes takes a raw collection key, not a Vault. It reads directly against a NoydbStore, so it needs the vault's _users collection DEK and the caller's role as plain arguments rather than deriving them from an open Vault session — there is currently no public Vault method that returns that key. It is meant for server-side / admin tooling that already manages store and key access itself, not a one-line call from arbitrary app code holding only a Vault.
  • UX flag, not a privacy guarantee. Both records are plaintext JSON sidecars (the envelope's _iv is left empty — see plaintext-bypass), and only gate the listUsersWithEnvelopes join. The keyring file at _keyring/<userId> and the encrypted envelope at _users/<keyringId> are both still present and listable to anyone with direct store read access. These flags stop an admin UI from accidentally rendering a hidden teammate — they do not hide that the principal exists from a determined attacker.
  • A missing document is not "off." Both flags default to the visible/enabled state when nothing has been persisted ({ enabled: true }, { hidden: false }) — there is no explicit "unset" state to branch on.
  • includeHidden: true is role-gated, not caller-gated. It checks the role you pass into listUsersWithEnvelopes, so whatever wires callerRole from your session is the actual security boundary — pass it from the unlocked keyring's role, never from user input.
  • Revoke cleans up; recovery does not touch it. revoke() deletes the visibility sidecar so it can't leak onto a re-granted userId. Peer-recovery (recoverUser) only rewrites the keyring file, so an existing hidden flag survives a secret reset for the same principal.

See also