policy
| Subpath | @noy-db/hub (root export) |
| Type | Core feature — every vault has a policy from creation; configured via the policy option on createNoydb(). |
What it does
Some operations on a vault are more dangerous than others — rotating the secret, exporting
plaintext, enrolling a new authenticator, granting or revoking a user. policy lets you declare,
per operation, the minimum authentication strength required before that operation is allowed to
run: a minimum session tier (how the caller unlocked the vault — secret, routine
login, or quick-resume PIN; see session-tiers) and, optionally, extra
factor proofs (a fresh TOTP code, an emailed one-time code, a recovery code, …) presented at
the moment of the call.
Each guarded operation is called a gate. A gate has a name ('rotate-secret',
'export-plaintext', 'enroll-user', …) and a GatePolicy — the rule that decides whether a
given call is allowed. The full set of gates for a vault is a VaultPolicy, persisted once at
_meta/policy on first vault creation. session, tiers, and team build their
re-authentication prompts and permission checks on top of this same gate-check enforcement.
There is nothing to opt into — every vault has a policy from the moment it's created. If you pass
none, noy-db uses a sensible default (PERSONAL_POLICY); you only need to reach for the API below
when you want to change the defaults or check a gate yourself.
Setup
policy has no with*() strategy — pass a VaultPolicy directly to createNoydb(). Start from
one of the two built-in presets and override only the gates you need to change:
import { createNoydb, STRICT_POLICY, mergePolicy } from '@noy-db/hub'
const db = await createNoydb({
store,
user: 'u1',
// STRICT_POLICY, with one extra app-defined gate layered on top.
policy: mergePolicy(STRICT_POLICY, {
gates: {
'app:approve-large-payment': {
minTier: 2,
factors: [{ anyOf: ['totp'] }],
},
},
}),
})
If you omit policy entirely, noy-db persists PERSONAL_POLICY on first vault creation. The
on-disk document is the source of truth after that: the policy option only takes effect the
first time a vault is created, not on every createNoydb() call. Change it later with
db.updatePolicy().
Usage
1. Check a gate before calling a guarded method
db.checkGate(vault, gate, factors?) evaluates a gate against the vault's current session tier
and the factor proofs you present, without performing any other action. Use it to pre-flight a UI
("do I need to ask for a TOTP code before showing the export button?") or to guard your own
app:* actions.
import { PolicyDeniedError } from '@noy-db/hub'
try {
await db.checkGate('books', 'export-plaintext', {
factors: [{ kind: 'totp', mintedAt: new Date().toISOString() }],
})
// → resolves with undefined: allowed
} catch (err) {
if (err instanceof PolicyDeniedError) {
// → err.reason: 'insufficient-tier' | 'missing-factor' | 'stale-proof'
// | 'disabled' | 'shared-device-blocked'
// → err.required: the GatePolicy the caller failed to satisfy
console.log(`denied: ${err.reason}`, err.required)
}
}
Built-in gated methods (db.rotateSecret, db.enrollAuthenticator, db.updateUser, …) take
the same FactorProofBundle as their last argument and run through checkGate internally, so you
usually don't call checkGate directly for those — just pass factors:
await db.team.rotateSecret(
'books',
{ newSecret: 'glasses cabinet bicycle umbrella thunder velvet' },
{ factors: [{ kind: 'totp' }] },
)
2. Read and update the vault's policy
const current = await db.getPolicy('books')
// → { secret: { minWords: 6, ... }, gates: { 'rotate-secret': { minTier: 1, ... }, ... } }
const updated = await db.updatePolicy('books', {
gates: {
'view-team-profiles': { minTier: 1, enabled: false }, // privacy-strict opt-out
},
})
// → merged VaultPolicy: every other gate untouched, 'view-team-profiles' replaced
updatePolicy fully replaces the entry for any gate you specify (it does not deep-merge a
single gate's fields) and leaves every other gate as-is. It is itself gated by 'enroll-user' —
changing the policy is a privilege-management action.
3. Preview a verdict without throwing
describeGate runs the same check as checkGate but returns a structured result instead of
throwing — useful for building an error UI ("you'll need a TOTP code plus a recovery code to do
that") ahead of the user attempting the action:
import { describeGate } from '@noy-db/hub'
const verdict = await describeGate(await db.getPolicy('books'), 'rotate-secret', {
activeTier: 1,
factors: [],
})
// → { ok: false, reason: 'missing-factor', required: { minTier: 1, factors: [...] } }
API reference
VaultPolicy
interface VaultPolicy {
readonly secret?: SecretPolicy // strength rules applied at secret ingress
readonly gates: Partial<Record<GateName, GatePolicy>>
}
Persisted once at _meta/policy when a vault is first created; db.updatePolicy() is the only
supported way to change it afterward.
GatePolicy
interface GatePolicy {
readonly minTier: 1 | 2 | 3 // minimum session tier the caller must hold
readonly factors?: ReadonlyArray<FactorRequirement>
readonly warn?: WarningRules
readonly enabled?: boolean // false disables the action entirely
}
| Field | Purpose |
|---|---|
minTier | Lower number is more privileged (1 = secret unlock). The caller's active tier must be <= minTier. |
factors | Extra freshness-bound proofs required on top of the tier check — see FactorRequirement. |
warn | Soft signals layered on the verdict. Today only warn.sharedDevice: 'block' is enforced — it turns a shared-device call into a denial. warn.weakAuthenticator is declared on the WarningRules type but not yet read or enforced by the engine. |
enabled | Set false to disable the action outright (e.g. rotate-secret is impossible by construction in managed-secret mode). |
Built-in gates without a configured policy fail closed (treated as { minTier: 1, enabled: false }). App-defined gates (app:*) without a configured policy are no-ops — the engine
only enforces gates you've explicitly registered.
FactorRequirement
interface FactorRequirement {
readonly anyOf: ReadonlyArray<FactorKind>
readonly count?: number // distinct fresh proofs required, default 1
readonly freshnessMs?: number // acceptance window per proof, default 5 minutes
}
FactorKind
| Kind | Source | Off-device? |
|---|---|---|
totp | RFC 6238 authenticator app (Google Authenticator, 1Password) | yes |
email-otp | one-time code mailed to the user | yes |
recovery | printable recovery code (@noy-db/on-recovery) | yes (paper) |
shamir | k-of-n threshold share (@noy-db/on-shamir) | yes |
webauthn-roaming | hardware key (YubiKey, SoloKey, Titan) | yes (key is portable) |
webauthn-platform | platform passkey (Touch ID, Face ID, Windows Hello) | no (device-bound) |
password | tier-2 password (@noy-db/on-password) | no |
pin | tier-3 quick-resume PIN (@noy-db/on-pin) | no |
Off-device kinds are the strongest proofs because they require something separate from the device the user just unlocked; platform/password/PIN prove "this is still the same user" without binding across devices.
FactorProof / FactorProofBundle
interface FactorProof {
readonly kind: FactorKind
readonly mintedAt?: string // ISO-8601 timestamp, compared against freshnessMs
readonly payload?: unknown // method-specific, opaque to the engine
}
interface FactorProofBundle {
readonly factors?: ReadonlyArray<FactorProof>
readonly sharedDevice?: boolean
}
FactorProofBundle is the optional last argument of every gated Noydb method (db.grant,
db.revoke, db.updateUser, db.enrollAuthenticator, db.removeAuthenticator,
db.updateAuthenticator, db.rotateSecret, db.recoverSecret, db.recoverUser,
db.enrollUnlock, …).
GateName / BuiltInGateName
GateName is either a BuiltInGateName or an app-defined name of the form `app:${string}`.
Built-in gates: rotate-secret, recover-secret, enroll-authenticator,
remove-authenticator, update-authenticator, rotate-recovery, rotate-unlock, enroll-user,
revoke-user, peer-recover-user, update-user, export-bundle, export-plaintext,
view-user-auth, edit-own-profile, view-team-profiles, client-unilateral-withdraw,
user-request-withdrawal, approve-user-withdrawal, grant-custodian, liberate-vault.
checkGate / describeGate
function checkGate(policy: VaultPolicy, gate: GateName, context: CheckGateContext): Promise<void>
function describeGate(
policy: VaultPolicy,
gate: GateName,
context: CheckGateContext,
): Promise<{ ok: true } | { ok: false; reason: PolicyDenyReason; required: GatePolicy }>
interface CheckGateContext {
readonly activeTier: ActiveTier // 1 | 2 | 3
readonly factors?: ReadonlyArray<FactorProof>
readonly sharedDevice?: boolean
readonly now?: number // override Date.now() for tests
}
db.checkGate(vault, gate, factors?) is the convenience wrapper — it loads the vault's policy and
active tier for you and calls checkGate underneath.
Presets and merging
PERSONAL_POLICY— the default for personal vaults and small deployments. Off-device factors (TOTP / email-OTP / recovery) are required only where they matter most (rotate-secret,export-plaintext); most gates are satisfied by a plain tier-1 unlock.STRICT_POLICY— for regulated deployments and shared workstations. Raises the secret floor to 8 words, requires two distinct off-device factors for sensitive exports, and blocks exports on a device flaggedsharedDevice.mergePolicy(base, override?)— merges a partial override onto a preset. Unspecified gates inherit frombase; gates present inoverridefully replace the base entry for that gate.
Errors
PolicyDeniedError— thrown bycheckGate(and any gated method) on denial. Carriesgate,reason(aPolicyDenyReason), andrequired(theGatePolicythat was not met).PolicyDenyReason—'insufficient-tier' | 'missing-factor' | 'stale-proof' | 'disabled' | 'shared-device-blocked'.
Limits & gotchas
- The on-disk policy wins.
policyoncreateNoydb()is only honored the first time a vault is created; every latercreateNoydb()call for the same vault reads the persisted document from_meta/policy. Usedb.updatePolicy()to change it deliberately — a developer override can never silently weaken what the vault already committed to. - Built-in gates fail closed. A built-in gate with no entry in
gatesbehaves as{ minTier: 1, enabled: false }— it denies every call until you configure it. - App gates fail open. An
app:*gate with no entry is a no-op (always allowed) — register aGatePolicyfor it if you want enforcement. minTieris "at least as privileged", not "exactly". Tier1(secret) satisfies a gate requiringminTier: 3; a session at tier3(PIN-unlocked) does not satisfy a gate requiringminTier: 1.updatePolicyreplaces a gate wholesale. Overriding one field of a preset's gate means re-specifying the wholeGatePolicyfor that gate, not patching a single field.warnnever blocks by itself unless explicitly set to'block'—sharedDevice: 'warn'is purely informational and never raisesPolicyDeniedError.
See also
- session-tiers — the tier model (
1/2/3) thatminTierchecks against. - session — session lifecycle and re-authentication built on top of gate checks.
- team — grant/revoke/update-user flows gated by this policy DSL.
- Services overview · Orientation map