stable

team

Multi-user collaboration — grant/revoke/rotate wrapped DEKs via withTeam(), plus magic-link sessions; delegation and tier elevation moved to /services/tiers.
Subpath@noy-db/hub/team
FactorywithTeam()
Layerwith-party
ClusterG — Collaboration & Auth
LOC cost~1,000 (off-bundle when not opted in)

What it does

Every vault encrypts each collection with its own DEK (data-encryption key) — see Permissions & Keyring for the full key hierarchy. Wrapping that DEK for a single owner is the minimum every vault ships with, always on, no opt-in required. The team service is what you add when a vault has more than one user: granting a second person a wrapped copy of the DEK, revoking it, rotating it, and everything that follows from more than one keyring existing in the same vault — magic-link viewer sessions, sub-permission delegation, and hierarchical permission tiers.

When you need it

  • Vaults shared between two or more users with role-based access
  • Time-boxed read-only viewer sessions (auditors, reviewers)
  • Operators with a sub-permission delegated by an admin
  • Hierarchical access (tiers 0..N where higher tiers see lower tiers but not vice versa)

Prerequisites

  • None — withTeam() alone enables grant / revoke / rotate.
  • For tier elevation on a handle: the target collection must declare the tier and tiersStrategy: withTiers() (a separate opt-in from @noy-db/hub/tiers) must be passed too.

Opt in

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

const db = await createNoydb({
  store: toMemory(),
  user: 'alice',
  teamStrategy: withTeam(),
})

Without teamStrategy: withTeam(), db.grant, db.revoke, and db.rotate throw TeamNotEnabledError — the floor default rejects every call so a single-user vault never pays the bundle cost of the keyring-grant engine. Single-user primitives stay ungated with or without withTeam(): the owner keyring itself, unlock, db.listUsers, db.updateUser, db.changeSecret, and secret rotate/recover.

Usage

Grant, then revoke

await db.grant('acme', {
  userId: 'bob',
  displayName: 'Bob',
  role: 'operator',
  secret: 'glasses cabinet bicycle umbrella thunder velvet',
  permissions: { invoices: 'rw' },
})
// → resolves once bob's keyring is written with a wrapped DEK for `invoices`

await db.revoke('acme', { userId: 'bob' })
// → resolves once bob's wrapped DEKs are removed. `cascade: 'strict'` (the default) also
//   revokes any admins bob had granted; pass `cascade: 'warn'` to log them instead

Rotate keys

const { needsRegrant, unverified } = await db.rotate('acme', ['invoices', 'payments'])
// → resolves once fresh DEKs are generated for `invoices` and `payments` and every existing
//   record in those collections is decrypted under the old DEK and re-encrypted under the new one

rotate keeps membership unchanged — but every other member loses their keys for the rotated collections. A member's DEKs are wrapped under that member's KEK, and a KEK derives only from that member's secret, so the caller cannot re-wrap a fresh DEK for anyone else. Rotation therefore drops the rotated collections from every other member's keyring; they keep their membership and their other collections.

needsRegrant names each (userId, collection) pair that was dropped — re-run grant() for those pairs or they stay locked out. See Rotation drops every other member's access.

unverified names members skipped because their _keyring file failed roster verification. Skipping gives them less, never more — the file is left untouched and its wrappings for the rotated collections go stale. It is the same locked-out end state as needsRegrant but needs a different remedy: a needsRegrant entry is fixed by grant(), while an unverified file no longer authenticates and cannot be repaired by granting over it.

To remove a member, use db.revoke(vault, { userId }) — revocation always rotates.

Scoped tier elevation

Record-tier elevation — vault.elevate(), demote(), delegate(), tier DEKs — has its own service page: tiers. Grants and tiers compose: the tier DEK a delegate receives is scoped by the same keyring this page describes.

API

  • withTeam() — factory; pass as teamStrategy to createNoydb to make grant/revoke/rotate live.
  • db.grant(vault: string, options: GrantOptions, factors?: FactorProofBundle): Promise<void> — wrap a DEK for a new user.
  • db.revoke(vault: string, options: RevokeOptions, factors?: FactorProofBundle): Promise<void> — remove a user's access; cascades to admins they granted by default.
  • db.rotate(vault: string, collections: string[]): Promise<void> — regenerate DEKs for the given collections, re-encrypt their records, and re-wrap for remaining members. Throws PermissionDeniedError for a custodian caller — rotation is owner/admin-only.
  • db.changeSecret(vault: string, newSecret: string, options?): Promise<void> — change the caller's own secret; not gated by withTeam().
  • Magic-link grant helpers, imported from @noy-db/hub/team — writeMagicLinkGrant, readMagicLinkGrantRecord, listMagicLinkGrants, unwrapMagicLinkGrant, revokeMagicLinkGrant, isMagicLinkGrantExpired — one-shot, secret-less read-only viewer sessions bound to a magic-link token.
  • Record-tier elevation and cross-tier delegation (vault.elevate / demote / delegate / refreshDelegations / revokeDelegation) moved to tiers.

Options

GrantOptions:

FieldTypeNotes
userIdstringrequired
displayNamestringrequired
roleRole'owner' | 'admin' | 'operator' | 'viewer' | 'client'
secretstringthe new user's own secret; not checked against a strength policy by default — validate it yourself before calling grant
permissionsPermissions?{ collectionName: 'rw' | 'ro' }; defaults from role when omitted
allowWeakSecretboolean?reserved for an internal strength check that db.grant does not currently enable — has no effect through the public API today

RevokeOptions:

FieldTypeNotes
userIdstringrequired
cascade'strict' | 'warn'?when the revoked user is an admin, 'strict' (default) recursively revokes admins they granted; 'warn' logs them instead

Limits & gotchas

  • Roles, most to least privileged: owner > admin > operator > viewer > client. Owner and admin can grant/revoke; viewer and client are read-only. A custodian role — normally minted via the custody service's grantCustodian — operates every collection but can never grant, revoke, or rotate, even if held by an admin-equivalent caller.
  • db.rotate re-encrypts every record in the given collections under a fresh DEK — cost is O(records in those collections), not a metadata-only rewrap.
  • Only one tier elevation can be active per vault at a time; a second vault.elevate(...) call while one is live throws AlreadyElevatedError.
  • The export gates (vault.assertCanExport('plaintext', fmt) and vault.assertCanExport('bundle')) are not bypassed by elevation — it projects a tier, not a privilege escalation.
  • Owners, admins, and custodians can elevate to any tier (auto-minting the DEK at write time); other roles must already hold a *#<tier> DEK on their keyring — via a prior grant or an active delegation — otherwise elevate throws TierNotGrantedError.
  • Since 0.3.0-pre.13, a record's blob attachments follow its tier: on elevate() they re-home under the tier DEK and become invisible to a tier-0 caller (readable via blob(id).atTier() by a cleared caller), and demote() restores them. See blobs → Tiered blobs.

See also

  • Permissions & Keyring — the always-on single-owner keyring this service extends
  • sync — grants flow over sync; rotated DEKs propagate to peers
  • session — token-based sessions enforce grant policies
  • history — every grant / revoke writes a ledger entry
  • custody — the owner-only custodian role and grant/revoke-custodian API
  • Proven by showcase 06-multi-user.