usePermissions

The scope set useCan reads, plus the reactive per-resource gates scopes cannot express.

useCan is only a matcher — it needs a scope set to match against. <PermissionProvider> supplies it and usePermissions reads it, so the whole app gates on one reactive source instead of each component fetching the session again.

PermissionProvider / usePermissions

import { PermissionProvider, usePermissions, canCall, ADMIN_SCOPE } from '@voltro/client'

<PermissionProvider scopes={session.scopes}>…</PermissionProvider>

const { scopes } = usePermissions()   // ReadonlyArray<string>

<PermissionProvider> takes exactly scopes and children. Mount it once high in the tree, fed by your session subscription — scopes are ordinary reactive data, so a role change re-renders every gate.

With no provider above it, usePermissions() returns { scopes: [] }, which makes useCan deny. That is the deliberate default: a missing provider hides affordances rather than revealing them.

canCall(subjectScopes, required) is the same matcher as a plain function, for loaders and route guards that are not components. required as an array means ALL of them (AND); ADMIN_SCOPE ('admin:full') satisfies anything; an empty requirement always passes.

useCanAny / canCallAny

useCan with an array demands ALL of the scopes. useCanAny is the OR variant — true when the subject holds AT LEAST ONE. Use it for "this section is visible to editors OR reviewers" affordances.

import { useCanAny, canCallAny } from '@voltro/client'

const canReview = useCanAny(['notes:edit', 'notes:review'])

canCallAny(subjectScopes, required) is the same OR check as a plain function, matching canCall. ADMIN_SCOPE satisfies it, and an empty requirement passes — nothing is being demanded.

@voltro/client is the one place these live. Scopes are a framework concept — @voltro/protocol owns ScopeError, guards: [{ scope }] and ctx.access — and rbac is only ONE way to produce them; an app can register its own setResourceScopeResolver over its own tables instead. These hooks CONSUME scopes, so they must not drag in a plugin that PRODUCES them.

useResourceCan / useResourceCans

Scopes answer "may this subject delete todos". They cannot answer "may this subject delete todo 42" — that depends on relation tuples only the server holds. These two hooks ask the server, reactively.

import { useResourceCan, useResourceCans } from '@voltro/client'

const { allowed, pending } = useResourceCan('app', 'docs.can', {
  action: 'write', resourceType: 'doc', resourceId: id,
})

const { allowedIds } = useResourceCans('app', 'docs.canMany', {
  action: 'delete', resourceType: 'doc', resourceIds,
})
<DataTable rowActions={(r) => allowedIds.has(r.id) ? <DeleteBtn id={r.id} /> : null} />

Both take (apiName, rpcTag, input, options?), where options is the ordinary SubscriptionOptions (skip, fallback) — they are thin projections over useSubscription. You bind them to a server query whose source is the ReBAC tuple table, returning { allowed } or { allowedIds }; because that source is reactive, a revoke re-runs can() server-side and the button disables with no refetch.

useResourceCan returns { allowed: boolean, pending: boolean }allowed is a real boolean that starts false and stays false while pending, never undefined. useResourceCans returns { allowedIds: ReadonlySet<string>, pending: boolean }; an id absent from the set is denied. Both fail closed.

All of this hides affordances. The server still enforces every call through the same can() engine — see Authorization.