team
| Subpath | @noy-db/hub/team |
| Factory | withTeam() |
| Layer | with-party |
| Cluster | G — 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 enablesgrant/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 asteamStrategytocreateNoydbto makegrant/revoke/rotatelive.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. ThrowsPermissionDeniedErrorfor acustodiancaller — rotation is owner/admin-only.db.changeSecret(vault: string, newSecret: string, options?): Promise<void>— change the caller's own secret; not gated bywithTeam().- 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:
| Field | Type | Notes |
|---|---|---|
userId | string | required |
displayName | string | required |
role | Role | 'owner' | 'admin' | 'operator' | 'viewer' | 'client' |
secret | string | the new user's own secret; not checked against a strength policy by default — validate it yourself before calling grant |
permissions | Permissions? | { collectionName: 'rw' | 'ro' }; defaults from role when omitted |
allowWeakSecret | boolean? | reserved for an internal strength check that db.grant does not currently enable — has no effect through the public API today |
RevokeOptions:
| Field | Type | Notes |
|---|---|---|
userId | string | required |
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. Acustodianrole — normally minted via the custody service'sgrantCustodian— operates every collection but can never grant, revoke, or rotate, even if held by an admin-equivalent caller. db.rotatere-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 throwsAlreadyElevatedError. - The export gates (
vault.assertCanExport('plaintext', fmt)andvault.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 — otherwiseelevatethrowsTierNotGrantedError. - Since
0.3.0-pre.13, a record's blob attachments follow its tier: onelevate()they re-home under the tier DEK and become invisible to a tier-0 caller (readable viablob(id).atTier()by a cleared caller), anddemote()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
custodianrole and grant/revoke-custodian API - Proven by showcase
06-multi-user.