pyreon

@pyreon/permissions provides reactive, universal permissions. A permission is either a boolean or a predicate function — check it with can(key, context?), which reads like a signal: it re-evaluates inside effects, computeds, and JSX. One primitive maps to any authorization model — RBAC, ABAC, feature flags, subscription tiers — because every model collapses to string keys.

@pyreon/permissionsstable

Installation

npm install @pyreon/permissions
bun add @pyreon/permissions
pnpm add @pyreon/permissions
yarn add @pyreon/permissions

Peer dependencies: @pyreon/reactivity, @pyreon/core (plus the optional @pyreon/native-compiler, for multiplatform builds).

Quick Start

// @check
import { createPermissions } from '@pyreon/permissions'

interface Post {
  authorId: string
}

const currentUserId = () => 'u1'
const myPost: Post = { authorId: 'u1' }

const can = createPermissions({
  'posts.read': true,
  'posts.create': true,
  'posts.update': (post: Post) => post.authorId === currentUserId(),
  'users.manage': false,
})

// Check — reactive in effects/computeds/JSX
can('posts.read') // true
can('posts.update', myPost) // evaluates the predicate
can('users.manage') // false
Permissions — reactive can()

Why a Permissions Primitive?

Most apps scatter authorization across the codebase: inline user.role === 'admin' checks, ad-hoc helper functions, booleans threaded through props. That drifts — a check changes in one place but not another, and there is no single source of truth for "what can this user do?".

@pyreon/permissions centralizes the answer into one reactive object:

// ❌ Scattered, non-reactive, drifts over time
if (user.role === 'admin' || user.role === 'editor') showEditButton()
if (post.authorId === user.id) showDeleteButton()

// ✅ One reactive source of truth — every check goes through `can`
const can = createPermissions({
  'posts.edit': () => ['admin', 'editor'].includes(role()),
  'posts.delete': (post: Post) => post.authorId === userId(),
})

{() => can('posts.edit') && <EditButton />}
{() => can('posts.delete', post) && <DeleteButton />}

Because the check is a signal read, the UI updates the moment permissions change — a role switch, a subscription upgrade, a logout — with no manual re-wiring.

Core Concepts

Permission Values

Every key maps to a PermissionValue, which is one of two things:

  • true / false — a static grant or denial.

  • (context?) => boolean — a predicate, evaluated on each check with optional context. This is how you express instance-level (ABAC) rules.

const can = createPermissions({
  // Static grant / denial
  'posts.read': true,
  'billing.export': false,

  // Predicate — instance-level check, receives the context you pass to can()
  'posts.update': (post: Post) => post.authorId === userId(),

  // Predicate — derived from reactive state, ignores its context arg
  'users.manage': () => currentUser()?.role === 'admin',
})

A predicate that throws is treated as denied rather than crashing the check — so can('posts.update') (no context) against a predicate that reads post.authorId returns false instead of throwing. This mirrors the throw-safe guard behavior in @pyreon/machine.

const can = createPermissions({
  'posts.update': (post: Post) => post.authorId === userId(),
})

can('posts.update') // false — predicate threw on `undefined.authorId`, caught → denied
can('posts.update', somePost) // evaluated normally

Checking Permissions

can(key, context?) returns a boolean. It is reactive when read inside a reactive scope — an effect(), a computed(), or a JSX {() => ...} thunk. The check subscribes to an internal version signal, so every can() read re-runs automatically when permissions change via set() / patch() / clear().

// Static check
can('posts.read') // true

// Instance check — the second argument is passed to the predicate
can('posts.update', somePost) // evaluates (post) => post.authorId === userId()

// In JSX — reactive, re-renders when permissions change
{() => can('posts.read') && <PostList />}
{() => can('posts.update', post) && <EditButton />}

// In an effect — reactive
effect(() => {
  if (can('users.manage')) showAdminTools()
})

// In a computed — reactive
const isAdmin = computed(() => can('users.manage'))

Inverse and Multi-Checks

// Inverse — true when the permission is DENIED
can.not('billing.export') // true if the user cannot export

// AND — true only if every key is granted
can.all('posts.read', 'posts.create') // true if both granted

// OR — true if at least one key is granted
can.any('posts.update', 'posts.delete') // true if either granted

Reactive Gating in JSX

The idiomatic way to show or hide UI by permission is a {() => ...} thunk, or <Show> for an explicit fallback:

import { Show } from '@pyreon/core'

function Toolbar() {
  return (
    <div>
      {/* Inline conditional — render nothing when denied */}
      {() => can('posts.create') && <NewPostButton />}

      {/* <Show> — render a fallback when denied */}
      <Show when={() => can('billing.export')} fallback={<UpgradePrompt />}>
        <ExportButton />
      </Show>
    </div>
  )
}

Both forms re-evaluate automatically when permissions change — switch roles and the toolbar updates without a manual refresh.

Updating Permissions

Permissions are reactive end-to-end — every can() read updates automatically when the source map changes. Three methods mutate it.

can.set(map) — Replace All

set discards the entire current map and installs a new one. Use it on login, logout, or any wholesale role change.

// After login — install permissions from the server response
can.set({
  'posts.read': true,
  'posts.create': true,
  'users.manage': true,
})

// Role change — replace everything
can.set(fromRole('viewer'))
RBAC — a role switch lights the permitted tiles

can.patch(map) — Merge

patch merges the given keys into the existing map: keys present in the argument are overwritten or added, keys not in the argument are preserved.

// Subscription upgrade — add a capability without dropping the rest
can.patch({ 'billing.export': true })

// Feature flag toggle — flip one key, keep everything else
can.patch({ 'feature.new-editor': false })

can.clear() — Deny Everything

clear empties the map. With no entries, every key falls through to "denied". Use it on logout.

can.clear() // user logged out — every can() check is now false

It is equivalent to can.set({}).

Wildcard Matching

Wildcards group permissions under a prefix so you don't have to enumerate every key. There are three forms, resolved most-specific-first.

prefix.* — One Segment

posts.* matches keys that are exactly one segment below posts — posts.read, posts.create, posts.delete — but not posts.read.title (two segments deep).

const can = createPermissions({
  'posts.*': true, // matches posts.read, posts.create, posts.delete, ...
  'posts.delete': false, // exact key wins over the wildcard
})

can('posts.read') // true  — matched by 'posts.*'
can('posts.create') // true  — matched by 'posts.*'
can('posts.delete') // false — exact match takes precedence
can('posts.read.title') // false — 'posts.*' is one segment only

prefix.** — Any Depth Below

posts.** matches any depth strictly below posts — posts.read, posts.admin.delete, posts.a.b.c. It does not match the bare posts node itself (use posts.* or an exact posts key for that).

const can = createPermissions({
  'billing.**': true, // any depth: billing.read, billing.invoices.export, ...
  'billing.refunds.**': false, // deny the refunds subtree specifically
})

can('billing.read') // true  — matched by 'billing.**'
can('billing.invoices.export') // true  — matched by 'billing.**'
can('billing.refunds.issue') // false — the more-specific 'billing.refunds.**' deny wins

This is the CASL cannot-over-can shape expressed in flat keys: a broad subtree grant plus a specific deny, with the deny winning because it is more specific.

Wildcard — one grant lights the whole namespace

* — Everything

The global * matches any key at any depth. Use it for a superadmin.

const can = createPermissions({ '*': true })
can('literally.anything') // true
can('billing.refunds.issue.now') // true

Resolution Order

For a key like posts.admin.delete, can() walks this order and returns the first match:

  1. Exact match — posts.admin.delete.

  2. Single-segment wildcard — posts.admin.* (the immediate parent + .*).

  3. Recursive subtree wildcards — posts.admin.**, then posts.**, walking ancestors most-specific-first.

  4. Global wildcard — *.

  5. No match → false (denied by default).

Because resolution is most-specific-first, an exact key or a deeper ** always overrides a broader grant — so you can grant a whole subtree and carve out exceptions:

const can = createPermissions({
  'admin.**': true, // grant the whole admin tree
  'admin.users.delete': false, // ...except this one capability
})

can('admin.settings.update') // true  — 'admin.**'
can('admin.users.list') // true  — 'admin.**'
can('admin.users.delete') // false — exact deny wins over 'admin.**'

Imperative Guards with can.assert

can() is the reactive boolean for rendering. can.assert() is its imperative companion: it throws when a permission is denied, so a denial halts execution. Use it in route loaders, navigation guards, and server actions.

// In a route loader / server action
can.assert('posts.delete', post) // throws "[Pyreon] permission denied: 'posts.delete'"
await deletePost(post)

// Custom message
can.assert('billing.export', undefined, 'Upgrade your plan to export')
// throws "[Pyreon] Upgrade your plan to export"

The thrown error is always [Pyreon]-prefixed — your custom message if you pass one, otherwise the default permission denied: '<key>'. It evaluates predicates and wildcards exactly like can(), and returns void when the permission is granted.

Introspection

For help dialogs, admin dashboards, or debugging, the instance exposes two reactive computeds.

// All granted keys — reactive Computed<string[]>.
// Includes keys whose value is `true` OR a predicate (the capability exists).
can.granted() // ['posts.read', 'posts.create', 'users.manage']

// All entries as [key, value] pairs — reactive Computed<[string, PermissionValue][]>
can.entries() // [['posts.read', true], ['posts.update', fn], ['billing.export', false], ...]

Context Pattern (SSR / Testing)

For SSR isolation (per-request permissions) or test isolation (override permissions per test), wrap your tree in <PermissionsProvider> and read the instance with usePermissions() instead of importing a module-level singleton.

import { PermissionsProvider, usePermissions } from '@pyreon/permissions'

// Provide — the prop is `value`
function App({ can }: { can: Permissions }) {
  return (
    <PermissionsProvider value={can}>
      <Router />
    </PermissionsProvider>
  )
}

// Consume in any descendant
function AdminPanel() {
  const can = usePermissions()
  return () => (can('admin.dashboard') ? <Dashboard /> : <AccessDenied />)
}

usePermissions() throws [Pyreon] usePermissions() must be used within <PermissionsProvider>. if no provider is mounted above it — so a missing provider fails loudly rather than silently denying everything. On the native targets (iOS/Android) the same mistake cannot throw — a bare usePermissions() reads the environment/CompositionLocal default, which is a deny-all set — so that default is a distinguished instance: the first check against it prints a once-per-process [Pyreon] usePermissions() was read with no <PermissionsProvider> above it warning (Swift: DEBUG builds only; Kotlin: the runtime library cannot see the app's BuildConfig, so it writes once to System.err/logcat). An explicit usePermissions([]) never warns — it states an intent.

Real-World Patterns

Role-Based Access Control (RBAC)

Map each role to a permission map, then set it when the role changes.

function fromRole(role: string): Record<string, boolean> {
  const roles: Record<string, Record<string, boolean>> = {
    admin: { '*': true },
    editor: {
      'posts.read': true,
      'posts.create': true,
      'posts.update': true,
      'users.read': true,
    },
    viewer: { 'posts.read': true, 'users.read': true },
  }
  return roles[role] ?? {}
}

const can = createPermissions(fromRole('editor'))

// On role change — replace the whole map
can.set(fromRole('admin'))

Attribute-Based Access Control (ABAC)

ABAC rules depend on the resource being checked. Express them as predicates and pass the resource as the context argument.

const can = createPermissions({
  'posts.read': true,
  'posts.update': (post: Post) => post.authorId === currentUserId(),
  'posts.delete': (post: Post) =>
    post.authorId === currentUserId() && post.status === 'draft',
})

function PostRow({ post }: { post: Post }) {
  return (
    <tr>
      <td>{post.title}</td>
      <td>
        {() => can('posts.update', post) && <EditButton post={post} />}
        {() => can('posts.delete', post) && <DeleteButton post={post} />}
      </td>
    </tr>
  )
}

Because the predicate reads currentUserId() (a signal), the check also re-evaluates when the current user changes — combining ABAC with reactivity.

Feature Flags

Feature flags are just permission keys. Group them under a feature. prefix and gate UI on them like any other check.

const can = createPermissions({
  // Access control
  'posts.read': true,
  'posts.create': true,

  // Feature flags
  'feature.new-editor': true,
  'feature.dark-mode': false,
})

{() => can('feature.new-editor') && <NewEditor />}

// Toggle a flag at runtime without touching the rest
can.patch({ 'feature.dark-mode': true })

Subscription Tiers

Model tiers as keys and gate premium features on them. A subscription upgrade is a patch.

const can = createPermissions({
  'tier.pro': true,
  'tier.enterprise': false,
})

{() => can('tier.pro') && <ExportButton />}
{() => can('tier.enterprise') && <SsoSettings />}

// User upgrades — flip the tier reactively
can.patch({ 'tier.enterprise': true })

Server Response / JWT Claims

Servers often return permission strings. Transform them into a map and set it.

const response = await fetch('/api/me')
const { permissions } = await response.json()
// permissions: ['posts:read', 'posts:create', 'users:manage']

can.set(
  Object.fromEntries(permissions.map((p: string) => [p.replace(':', '.'), true])),
)

Reactive Role Switching

A predicate that reads a role() signal means the entire app re-evaluates the moment the role changes — no set call needed.

const role = signal('viewer')

const can = createPermissions({
  'posts.read': true,
  'posts.edit': () => ['admin', 'editor'].includes(role()),
  'users.manage': () => role() === 'admin',
})

role.set('admin') // every can('posts.edit') / can('users.manage') re-evaluates

Multi-Tenant with Key Prefixes

Namespace permissions per org or workspace by prefixing keys. Wildcards still apply within a prefix.

const can = createPermissions({
  'org:acme.admin': true,
  'ws:design.posts.*': true,
  'ws:engineering.posts.read': true,
})

can('ws:design.posts.delete') // true  — matched by 'ws:design.posts.*'
can('ws:engineering.posts.delete') // false — only read was granted

Route Guards with assert

In a router loader, assert halts navigation when the user lacks access.

const route = {
  path: '/admin',
  loader: () => {
    can.assert('admin.dashboard', undefined, 'Admins only')
    return loadAdminData()
  },
}

Conditional Data Fetching with @pyreon/query

Gate a query on a permission so it never runs for users who can't see the data.

const { data } = useQuery(() => ({
  queryKey: ['users'],
  queryFn: fetchUsers,
  enabled: can('users.read'),
}))

Type Exports

import type {
  Permissions, // the callable permissions instance
  PermissionMap, // Record<string, PermissionValue>
  PermissionValue, // boolean | ((context?) => boolean)
  PermissionPredicate, // (context?) => boolean
} from '@pyreon/permissions'
TypeDefinitionNotes
Permissionscallable (key, context?) => booleanthe instance returned by createPermissions, with methods
PermissionMapRecord<string, PermissionValue>the shape passed to createPermissions / set / patch
PermissionValueboolean | PermissionPredicatea static grant/denial or a predicate
PermissionPredicate(context?: TContext) => booleanevaluated per-check; throwing → denied

API Reference

createPermissions(initial?)

ParameterTypeDescription
initialPermissionMapOptional initial permission map. Omitted → empty (deny all).

Returns a callable Permissions instance.

Permissions instance

MemberReturnsDescription
can(key, context?)booleanCheck a permission. Reactive in effects/computeds/JSX. Passes context to a predicate; a throwing predicate → denied.
can.not(key, context?)booleanInverse — true when the permission is denied.
can.all(...keys) / can.all(keys[], context?)booleantrue only if every key is granted. The array form passes context to every predicate.
can.any(...keys) / can.any(keys[], context?)booleantrue if at least one key is granted. The array form passes context to every predicate.
can.set(map)voidReplace the entire permission map. Reactive reads update.
can.patch(map)voidMerge map into the current permissions; unlisted keys are preserved. Reactive reads update.
can.clear()voidRemove all permissions (deny everything). Equivalent to can.set({}).
can.assert(key, context?, message?)voidThrow a [Pyreon]-prefixed error if denied (custom message, or permission denied: '<key>'); else returns void.
can.grantedComputed<string[]>Reactive list of all keys whose value is true or a predicate (the capability exists). Read as can.granted().
can.entriesComputed<[string, PermissionValue][]>Reactive list of all [key, value] pairs. Read as can.entries().

Wildcard keys

FormMatchesExample
prefix.*exactly one segment below prefixposts.* → posts.read ✓, posts.read.title ✗
prefix.**any depth strictly below prefix (not the bare prefix node)posts.** → posts.read ✓, posts.admin.delete ✓
*everything, any depth* → literally.anything ✓

Resolution is most-specific-first: exact → prefix.* → prefix.** (nearest ancestor first) → * → false. A more-specific exact/** deny overrides a broader grant.

Context

APIReturnsDescription
<PermissionsProvider value={can}>VNodeChildProvide a Permissions instance to descendants. The prop is value.
usePermissions()PermissionsRead the nearest provided instance. Throws if no <PermissionsProvider> is mounted.
usePermissions(grants)PermissionsSelf-contained instance seeded with grants (readonly string[]); needs no provider. Works on all three targets.
Permissions