directory
| Subpath | @noy-db/hub (root export) |
| Type | Core 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
| Export | Shape | Purpose |
|---|---|---|
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
| Export | Signature | Purpose |
|---|---|---|
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
| Export | Signature | Purpose |
|---|---|---|
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) => string | Compose the _meta record id: `visibility/${keyringId}`. |
VISIBILITY_RECORD_PREFIX | 'visibility/' | The prefix visibilityRecordId uses. |
Shared
| Export | Purpose |
|---|---|
META_COLLECTION | '_meta' — the reserved collection both records live in. |
DirectoryDisabledError | Thrown 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.
| Export | Signature | Purpose |
|---|---|---|
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
listUsersWithEnvelopestakes a raw collection key, not aVault. It reads directly against aNoydbStore, so it needs the vault's_userscollection DEK and the caller's role as plain arguments rather than deriving them from an openVaultsession — there is currently no publicVaultmethod 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 aVault.- UX flag, not a privacy guarantee. Both records are plaintext JSON sidecars (the
envelope's
_ivis left empty — see plaintext-bypass), and only gate thelistUsersWithEnvelopesjoin. 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: trueis role-gated, not caller-gated. It checks the role you pass intolistUsersWithEnvelopes, so whatever wirescallerRolefrom your session is the actual security boundary — pass it from the unlocked keyring'srole, 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-granteduserId. Peer-recovery (recoverUser) only rewrites the keyring file, so an existing hidden flag survives a secret reset for the same principal.
See also
- team — grant/revoke/rotate access and the multi-user collaboration
family
listUsersWithEnvelopesbelongs to - auth-introspection — the read-only per-user auth enrollment view, gated the same admin-facing way as the directory listing
- user-envelope — the per-principal profile record this service filters
- Permissions & keyring — roles referenced above
- plaintext-bypass — the
_iv: ''sidecar pattern both flags use - Services overview · Orientation map