stable

policy

Gate sensitive vault operations behind a minimum session tier and optional extra-factor proofs, via the VaultPolicy / GatePolicy config types.
Subpath@noy-db/hub (root export)
TypeCore 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
}
FieldPurpose
minTierLower number is more privileged (1 = secret unlock). The caller's active tier must be <= minTier.
factorsExtra freshness-bound proofs required on top of the tier check — see FactorRequirement.
warnSoft 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.
enabledSet 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

KindSourceOff-device?
totpRFC 6238 authenticator app (Google Authenticator, 1Password)yes
email-otpone-time code mailed to the useryes
recoveryprintable recovery code (@noy-db/on-recovery)yes (paper)
shamirk-of-n threshold share (@noy-db/on-shamir)yes
webauthn-roaminghardware key (YubiKey, SoloKey, Titan)yes (key is portable)
webauthn-platformplatform passkey (Touch ID, Face ID, Windows Hello)no (device-bound)
passwordtier-2 password (@noy-db/on-password)no
pintier-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 flagged sharedDevice.
  • mergePolicy(base, override?) — merges a partial override onto a preset. Unspecified gates inherit from base; gates present in override fully replace the base entry for that gate.

Errors

  • PolicyDeniedError — thrown by checkGate (and any gated method) on denial. Carries gate, reason (a PolicyDenyReason), and required (the GatePolicy that was not met).
  • PolicyDenyReason — 'insufficient-tier' | 'missing-factor' | 'stale-proof' | 'disabled' | 'shared-device-blocked'.

Limits & gotchas

  • The on-disk policy wins. policy on createNoydb() is only honored the first time a vault is created; every later createNoydb() call for the same vault reads the persisted document from _meta/policy. Use db.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 gates behaves 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 a GatePolicy for it if you want enforcement.
  • minTier is "at least as privileged", not "exactly". Tier 1 (secret) satisfies a gate requiring minTier: 3; a session at tier 3 (PIN-unlocked) does not satisfy a gate requiring minTier: 1.
  • updatePolicy replaces a gate wholesale. Overriding one field of a preset's gate means re-specifying the whole GatePolicy for that gate, not patching a single field.
  • warn never blocks by itself unless explicitly set to 'block' — sharedDevice: 'warn' is purely informational and never raises PolicyDeniedError.

See also