Skip to content
This repository was archived by the owner on Mar 26, 2026. It is now read-only.

Commit 88e2f02

Browse files
authored
Merge pull request #10 from pyreon/docs/add-storage-hotkeys
docs: add permissions documentation
2 parents ab7e715 + 6c33c5a commit 88e2f02

1 file changed

Lines changed: 328 additions & 0 deletions

File tree

content/docs/permissions/index.mdx

Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
---
2+
title: Permissions
3+
description: Reactive type-safe permissions for Pyreon — universal, signal-driven, works with any authorization model
4+
---
5+
6+
# @pyreon/permissions
7+
8+
Reactive permissions primitive for Pyreon. A permission is either a boolean or a function — check with `can()`, reactive in effects, computeds, and JSX. Works with any authorization model: RBAC, ABAC, feature flags, subscription tiers.
9+
10+
## Installation
11+
12+
```bash
13+
bun add @pyreon/permissions
14+
```
15+
16+
Peer dependencies: `@pyreon/reactivity`, `@pyreon/core`
17+
18+
## Quick Start
19+
20+
```tsx
21+
import { createPermissions } from '@pyreon/permissions'
22+
23+
const can = createPermissions({
24+
'posts.read': true,
25+
'posts.create': true,
26+
'posts.update': (post: Post) => post.authorId === currentUserId(),
27+
'users.manage': false,
28+
})
29+
30+
// Check — reactive in effects/computeds/JSX
31+
can('posts.read') // true
32+
can('posts.update', myPost) // evaluates predicate
33+
can('users.manage') // false
34+
```
35+
36+
## Core Concepts
37+
38+
### Permission Values
39+
40+
A permission value is either:
41+
- **`true` / `false`** — static grant or denial
42+
- **`(context?) => boolean`** — predicate, evaluated per-check with optional context
43+
44+
```tsx
45+
const can = createPermissions({
46+
// Static
47+
'posts.read': true,
48+
'billing.export': false,
49+
50+
// Predicate — instance-level check
51+
'posts.update': (post: Post) => post.authorId === userId(),
52+
53+
// Predicate — derived from reactive state
54+
'users.manage': () => currentUser()?.role === 'admin',
55+
})
56+
```
57+
58+
### Checking Permissions
59+
60+
`can()` returns a boolean. It's reactive when called inside effects, computeds, or JSX `{() => ...}` wrappers.
61+
62+
```tsx
63+
// Static check
64+
can('posts.read') // true
65+
66+
// Instance check — passes context to predicate
67+
can('posts.update', somePost) // evaluates (post) => post.authorId === userId()
68+
69+
// In JSX — reactive, updates when permissions change
70+
{() => can('posts.read') && <PostList />}
71+
{() => can('posts.update', post) && <EditButton />}
72+
73+
// In effects — reactive
74+
effect(() => {
75+
if (can('users.manage')) showAdminTools()
76+
})
77+
78+
// In computeds — reactive
79+
const isAdmin = computed(() => can('users.manage'))
80+
```
81+
82+
### Inverse and Multi-Checks
83+
84+
```tsx
85+
// Inverse
86+
can.not('billing.export') // true if denied
87+
88+
// All must be true
89+
can.all('posts.read', 'posts.create') // true if both granted
90+
91+
// At least one must be true
92+
can.any('posts.update', 'posts.delete') // true if either granted
93+
```
94+
95+
## Updating Permissions
96+
97+
Permissions are reactive — all `can()` reads update automatically when the source changes.
98+
99+
### `can.set(map)` — Replace All
100+
101+
```tsx
102+
// After login — set permissions from server response
103+
can.set({
104+
'posts.read': true,
105+
'posts.create': true,
106+
'users.manage': true,
107+
})
108+
109+
// Role change — replace everything
110+
can.set(fromRole('viewer'))
111+
```
112+
113+
### `can.patch(map)` — Merge
114+
115+
```tsx
116+
// Subscription upgrade — add new permissions
117+
can.patch({ 'billing.export': true })
118+
119+
// Feature flag toggle
120+
can.patch({ 'feature.new-editor': false })
121+
```
122+
123+
## Wildcard Matching
124+
125+
Wildcards allow grouping permissions under a prefix.
126+
127+
```tsx
128+
const can = createPermissions({
129+
'posts.*': true, // matches posts.read, posts.create, posts.delete, etc.
130+
'posts.delete': false, // exact match overrides wildcard
131+
})
132+
133+
can('posts.read') // true — matched by 'posts.*'
134+
can('posts.create') // true — matched by 'posts.*'
135+
can('posts.delete') // false — exact match takes precedence
136+
```
137+
138+
### Resolution Order
139+
140+
1. **Exact match**`'posts.update'` → use it
141+
2. **Prefix wildcard**`'posts.*'` → use it
142+
3. **Global wildcard**`'*'` → use it (superadmin)
143+
4. **No match**`false` (denied)
144+
145+
```tsx
146+
// Superadmin — global wildcard grants everything
147+
const can = createPermissions({ '*': true })
148+
can('literally.anything') // true
149+
```
150+
151+
## Introspection
152+
153+
For help dialogs, admin dashboards, or debugging.
154+
155+
```tsx
156+
// All granted permission keys — reactive Computed<string[]>
157+
can.granted() // ['posts.read', 'posts.create', 'users.manage']
158+
159+
// All entries as [key, value] pairs — reactive Computed
160+
can.entries() // [['posts.read', true], ['users.manage', false], ...]
161+
```
162+
163+
## Context Pattern (SSR / Testing)
164+
165+
For SSR isolation or testing, use the provider to scope a permissions instance.
166+
167+
```tsx
168+
import { PermissionsProvider, usePermissions } from '@pyreon/permissions'
169+
170+
// Provide
171+
<PermissionsProvider instance={can}>
172+
<App />
173+
</PermissionsProvider>
174+
175+
// Consume
176+
function AdminPanel() {
177+
const can = usePermissions()
178+
return () => can('admin') && <AdminDashboard />
179+
}
180+
```
181+
182+
## Real-World Patterns
183+
184+
### Role-Based Access Control (RBAC)
185+
186+
```tsx
187+
function fromRole(role: string): Record<string, boolean> {
188+
const roles: Record<string, Record<string, boolean>> = {
189+
admin: { '*': true },
190+
editor: {
191+
'posts.read': true,
192+
'posts.create': true,
193+
'posts.update': true,
194+
'users.read': true,
195+
},
196+
viewer: { 'posts.read': true, 'users.read': true },
197+
}
198+
return roles[role] ?? {}
199+
}
200+
201+
const can = createPermissions(fromRole('editor'))
202+
203+
// After role change
204+
can.set(fromRole('admin'))
205+
```
206+
207+
### Server Response / JWT Claims
208+
209+
```tsx
210+
// Server returns permission strings
211+
const response = await fetch('/api/me')
212+
const { permissions } = await response.json()
213+
// permissions: ['posts:read', 'posts:create', 'users:manage']
214+
215+
// Transform to permission map
216+
can.set(
217+
Object.fromEntries(
218+
permissions.map((p: string) => [p.replace(':', '.'), true])
219+
)
220+
)
221+
```
222+
223+
### Feature Flags
224+
225+
```tsx
226+
const can = createPermissions({
227+
// Access control
228+
'posts.read': true,
229+
'posts.create': true,
230+
231+
// Feature flags
232+
'feature.new-editor': true,
233+
'feature.dark-mode': false,
234+
235+
// Subscription tier
236+
'tier.pro': true,
237+
'tier.enterprise': false,
238+
})
239+
240+
{() => can('feature.new-editor') && <NewEditor />}
241+
{() => can('tier.pro') && <ExportButton />}
242+
```
243+
244+
### Instance-Level Ownership
245+
246+
```tsx
247+
const can = createPermissions({
248+
'posts.read': true,
249+
'posts.update': (post: Post) => post.authorId === currentUserId(),
250+
'posts.delete': (post: Post) =>
251+
post.authorId === currentUserId() && post.status === 'draft',
252+
})
253+
254+
function PostRow({ post }: { post: Post }) {
255+
return (
256+
<tr>
257+
<td>{post.title}</td>
258+
<td>
259+
{() => can('posts.update', post) && <EditButton post={post} />}
260+
{() => can('posts.delete', post) && <DeleteButton post={post} />}
261+
</td>
262+
</tr>
263+
)
264+
}
265+
```
266+
267+
### Multi-Tenant with Key Prefixes
268+
269+
```tsx
270+
const can = createPermissions({
271+
'org:acme.admin': true,
272+
'ws:design.posts.*': true,
273+
'ws:engineering.posts.read': true,
274+
})
275+
276+
can('ws:design.posts.delete') // true — wildcard match
277+
can('ws:engineering.posts.delete') // false — only read granted
278+
```
279+
280+
### Reactive Role Switching
281+
282+
```tsx
283+
const can = createPermissions(fromRole('viewer'))
284+
285+
// Permissions automatically update in all components
286+
effect(() => {
287+
can.set(fromRole(currentRole()))
288+
})
289+
290+
// Every can() check in the app reacts to role changes
291+
```
292+
293+
### With useQuery — Conditional Fetching
294+
295+
```tsx
296+
const { data } = useQuery(() => ({
297+
queryKey: ['users'],
298+
queryFn: fetchUsers,
299+
enabled: can('users.read'),
300+
}))
301+
```
302+
303+
## Type Exports
304+
305+
```tsx
306+
import type {
307+
Permissions, // The callable permissions instance
308+
PermissionMap, // Record<string, PermissionValue>
309+
PermissionValue, // boolean | (context?) => boolean
310+
PermissionPredicate, // (context?) => boolean
311+
} from '@pyreon/permissions'
312+
```
313+
314+
## API Reference
315+
316+
| API | Description |
317+
|---|---|
318+
| `createPermissions(initial?)` | Create a reactive permissions instance |
319+
| `can(key, context?)` | Check permission — reactive in effects/computeds/JSX |
320+
| `can.not(key, context?)` | Inverse check |
321+
| `can.all(...keys)` | True if all permissions granted |
322+
| `can.any(...keys)` | True if any permission granted |
323+
| `can.set(map)` | Replace all permissions |
324+
| `can.patch(map)` | Merge into existing permissions |
325+
| `can.granted()` | `Computed<string[]>` — all granted keys |
326+
| `can.entries()` | `Computed<[string, PermissionValue][]>` — all entries |
327+
| `PermissionsProvider` | Context provider for SSR/testing |
328+
| `usePermissions()` | Access permissions from context |

0 commit comments

Comments
 (0)