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.