@pyreon/permissions 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/package.json +52 -0
- package/src/context.ts +57 -0
- package/src/index.ts +35 -0
- package/src/permissions.ts +130 -0
- package/src/tests/permissions.test.ts +547 -0
- package/src/types.ts +122 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Vit Bokisch
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pyreon/permissions",
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Reactive permissions for Pyreon — type-safe, signal-driven, universal",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/pyreon/fundamentals.git",
|
|
9
|
+
"directory": "packages/permissions"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/pyreon/fundamentals/tree/main/packages/permissions#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/pyreon/fundamentals/issues"
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"lib",
|
|
20
|
+
"src",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"type": "module",
|
|
25
|
+
"main": "./lib/index.js",
|
|
26
|
+
"module": "./lib/index.js",
|
|
27
|
+
"types": "./lib/types/index.d.ts",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"bun": "./src/index.ts",
|
|
31
|
+
"import": "./lib/index.js",
|
|
32
|
+
"types": "./lib/types/index.d.ts"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"sideEffects": false,
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "vl_rolldown_build",
|
|
38
|
+
"dev": "vl_rolldown_build-watch",
|
|
39
|
+
"test": "vitest run",
|
|
40
|
+
"typecheck": "tsc --noEmit"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"@pyreon/core": ">=0.5.0 <1.0.0",
|
|
44
|
+
"@pyreon/reactivity": ">=0.5.0 <1.0.0"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@happy-dom/global-registrator": "^20.8.3",
|
|
48
|
+
"@pyreon/core": ">=0.5.0 <1.0.0",
|
|
49
|
+
"@pyreon/reactivity": ">=0.5.0 <1.0.0",
|
|
50
|
+
"@vitus-labs/tools-lint": "^1.11.0"
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
onUnmount,
|
|
4
|
+
popContext,
|
|
5
|
+
pushContext,
|
|
6
|
+
useContext,
|
|
7
|
+
} from '@pyreon/core'
|
|
8
|
+
import type { VNodeChild } from '@pyreon/core'
|
|
9
|
+
import type { Permissions } from './types'
|
|
10
|
+
|
|
11
|
+
const PermissionsContext = createContext<Permissions | null>(null)
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Provide a permissions instance to descendant components.
|
|
15
|
+
* Use this for SSR isolation or testing — each request/test gets its own instance.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```tsx
|
|
19
|
+
* const can = createPermissions({ ... })
|
|
20
|
+
*
|
|
21
|
+
* <PermissionsProvider instance={can}>
|
|
22
|
+
* <App />
|
|
23
|
+
* </PermissionsProvider>
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export function PermissionsProvider(props: {
|
|
27
|
+
instance: Permissions
|
|
28
|
+
children?: VNodeChild
|
|
29
|
+
}): VNodeChild {
|
|
30
|
+
const frame = new Map<symbol, unknown>([
|
|
31
|
+
[PermissionsContext.id, props.instance],
|
|
32
|
+
])
|
|
33
|
+
pushContext(frame)
|
|
34
|
+
onUnmount(() => popContext())
|
|
35
|
+
|
|
36
|
+
return props.children ?? null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Access the nearest permissions instance from context.
|
|
41
|
+
* Must be used within a `<PermissionsProvider>`.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```tsx
|
|
45
|
+
* const can = usePermissions()
|
|
46
|
+
* {() => can('posts.read') && <PostList />}
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export function usePermissions(): Permissions {
|
|
50
|
+
const instance = useContext(PermissionsContext)
|
|
51
|
+
if (!instance) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
'[@pyreon/permissions] usePermissions() must be used within <PermissionsProvider>',
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
return instance
|
|
57
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pyreon/permissions — Reactive permissions for Pyreon.
|
|
3
|
+
*
|
|
4
|
+
* A permission is a boolean or a function. Check with `can()` — reactive
|
|
5
|
+
* in effects, computeds, and JSX. Universal: RBAC, ABAC, feature flags,
|
|
6
|
+
* subscription tiers — any model maps to string keys.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```tsx
|
|
10
|
+
* import { createPermissions } from '@pyreon/permissions'
|
|
11
|
+
*
|
|
12
|
+
* const can = createPermissions({
|
|
13
|
+
* 'posts.read': true,
|
|
14
|
+
* 'posts.update': (post: Post) => post.authorId === userId(),
|
|
15
|
+
* 'users.manage': false,
|
|
16
|
+
* })
|
|
17
|
+
*
|
|
18
|
+
* // Reactive check in JSX
|
|
19
|
+
* {() => can('posts.read') && <PostList />}
|
|
20
|
+
*
|
|
21
|
+
* // Update after login
|
|
22
|
+
* can.set(fromRole(user.role))
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export { createPermissions } from './permissions'
|
|
27
|
+
export { PermissionsProvider, usePermissions } from './context'
|
|
28
|
+
|
|
29
|
+
// Types
|
|
30
|
+
export type {
|
|
31
|
+
PermissionMap,
|
|
32
|
+
PermissionPredicate,
|
|
33
|
+
PermissionValue,
|
|
34
|
+
Permissions,
|
|
35
|
+
} from './types'
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { computed, signal } from '@pyreon/reactivity'
|
|
2
|
+
import type { PermissionMap, PermissionValue, Permissions } from './types'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Resolve a permission key against the map.
|
|
6
|
+
* Resolution order: exact match → wildcard (e.g., 'posts.*') → global wildcard ('*') → false.
|
|
7
|
+
*/
|
|
8
|
+
function resolve(
|
|
9
|
+
map: Map<string, PermissionValue>,
|
|
10
|
+
key: string,
|
|
11
|
+
context?: unknown,
|
|
12
|
+
): boolean {
|
|
13
|
+
// 1. Exact match
|
|
14
|
+
const exact = map.get(key)
|
|
15
|
+
if (exact !== undefined) {
|
|
16
|
+
return typeof exact === 'function' ? exact(context) : exact
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// 2. Wildcard match — 'posts.read' matches 'posts.*'
|
|
20
|
+
const dotIndex = key.lastIndexOf('.')
|
|
21
|
+
if (dotIndex !== -1) {
|
|
22
|
+
const prefix = key.slice(0, dotIndex)
|
|
23
|
+
const wildcard = map.get(`${prefix}.*`)
|
|
24
|
+
if (wildcard !== undefined) {
|
|
25
|
+
return typeof wildcard === 'function' ? wildcard(context) : wildcard
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 3. Global wildcard
|
|
30
|
+
const global = map.get('*')
|
|
31
|
+
if (global !== undefined) {
|
|
32
|
+
return typeof global === 'function' ? global(context) : global
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 4. No match → denied
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Create a reactive permissions instance.
|
|
41
|
+
*
|
|
42
|
+
* The returned `can` function checks permissions reactively —
|
|
43
|
+
* reads update automatically when permissions change via `set()` or `patch()`.
|
|
44
|
+
*
|
|
45
|
+
* @param initial - Optional initial permission map
|
|
46
|
+
* @returns A callable `Permissions` instance
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ```tsx
|
|
50
|
+
* const can = createPermissions({
|
|
51
|
+
* 'posts.read': true,
|
|
52
|
+
* 'posts.update': (post: Post) => post.authorId === userId(),
|
|
53
|
+
* 'users.manage': false,
|
|
54
|
+
* })
|
|
55
|
+
*
|
|
56
|
+
* // Check (reactive in effects/computeds/JSX)
|
|
57
|
+
* can('posts.read') // true
|
|
58
|
+
* can('posts.update', myPost) // evaluates predicate
|
|
59
|
+
*
|
|
60
|
+
* // JSX
|
|
61
|
+
* {() => can('posts.delete') && <DeleteButton />}
|
|
62
|
+
*
|
|
63
|
+
* // Update
|
|
64
|
+
* can.set({ 'posts.read': true, 'admin': true })
|
|
65
|
+
* can.patch({ 'users.manage': true })
|
|
66
|
+
* ```
|
|
67
|
+
*/
|
|
68
|
+
export function createPermissions(initial?: PermissionMap): Permissions {
|
|
69
|
+
// Internal reactive state — a signal holding the permission map
|
|
70
|
+
const store = signal(toMap(initial))
|
|
71
|
+
// Version counter — incremented on every set/patch to trigger reactive updates
|
|
72
|
+
const version = signal(0)
|
|
73
|
+
|
|
74
|
+
function toMap(obj?: PermissionMap): Map<string, PermissionValue> {
|
|
75
|
+
if (!obj) return new Map()
|
|
76
|
+
return new Map(Object.entries(obj))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The main check function — reads `version` to subscribe in reactive contexts
|
|
80
|
+
function can(key: string, context?: unknown): boolean {
|
|
81
|
+
// Reading version subscribes this call to reactive updates
|
|
82
|
+
version()
|
|
83
|
+
return resolve(store.peek(), key, context)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
can.not = (key: string, context?: unknown): boolean => {
|
|
87
|
+
return !can(key, context)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
can.all = (...keys: string[]): boolean => {
|
|
91
|
+
return keys.every((key) => can(key))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
can.any = (...keys: string[]): boolean => {
|
|
95
|
+
return keys.some((key) => can(key))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
can.set = (permissions: PermissionMap): void => {
|
|
99
|
+
store.set(toMap(permissions))
|
|
100
|
+
version.update((v) => v + 1)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
can.patch = (permissions: PermissionMap): void => {
|
|
104
|
+
const current = store.peek()
|
|
105
|
+
for (const [key, value] of Object.entries(permissions)) {
|
|
106
|
+
current.set(key, value)
|
|
107
|
+
}
|
|
108
|
+
store.set(current)
|
|
109
|
+
version.update((v) => v + 1)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
can.granted = computed(() => {
|
|
113
|
+
version()
|
|
114
|
+
const keys: string[] = []
|
|
115
|
+
for (const [key, value] of store.peek()) {
|
|
116
|
+
// Static true or predicate (capability exists)
|
|
117
|
+
if (value === true || typeof value === 'function') {
|
|
118
|
+
keys.push(key)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return keys
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
can.entries = computed(() => {
|
|
125
|
+
version()
|
|
126
|
+
return [...store.peek().entries()]
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
return can as Permissions
|
|
130
|
+
}
|
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
import { computed, effect, signal } from '@pyreon/reactivity'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { createPermissions } from '../index'
|
|
4
|
+
|
|
5
|
+
describe('createPermissions', () => {
|
|
6
|
+
// ─── Basic checks ────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
describe('static permissions', () => {
|
|
9
|
+
it('returns true for granted permissions', () => {
|
|
10
|
+
const can = createPermissions({ 'posts.read': true })
|
|
11
|
+
expect(can('posts.read')).toBe(true)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('returns false for denied permissions', () => {
|
|
15
|
+
const can = createPermissions({ 'posts.delete': false })
|
|
16
|
+
expect(can('posts.delete')).toBe(false)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('returns false for undefined permissions', () => {
|
|
20
|
+
const can = createPermissions({ 'posts.read': true })
|
|
21
|
+
expect(can('users.manage')).toBe(false)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('handles empty initial permissions', () => {
|
|
25
|
+
const can = createPermissions()
|
|
26
|
+
expect(can('anything')).toBe(false)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('handles empty object', () => {
|
|
30
|
+
const can = createPermissions({})
|
|
31
|
+
expect(can('anything')).toBe(false)
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
// ─── Predicate permissions ─────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
describe('predicate permissions', () => {
|
|
38
|
+
it('evaluates predicate without context', () => {
|
|
39
|
+
const role = signal('admin')
|
|
40
|
+
const can = createPermissions({
|
|
41
|
+
'users.manage': () => role() === 'admin',
|
|
42
|
+
})
|
|
43
|
+
expect(can('users.manage')).toBe(true)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('evaluates predicate with context', () => {
|
|
47
|
+
const userId = signal('user-1')
|
|
48
|
+
const can = createPermissions({
|
|
49
|
+
'posts.update': (post: any) => post?.authorId === userId.peek(),
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
expect(can('posts.update', { authorId: 'user-1' })).toBe(true)
|
|
53
|
+
expect(can('posts.update', { authorId: 'user-2' })).toBe(false)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('predicate without context returns result of calling with undefined', () => {
|
|
57
|
+
const can = createPermissions({
|
|
58
|
+
'posts.update': (post?: any) => post?.authorId === 'user-1',
|
|
59
|
+
})
|
|
60
|
+
// No context — post is undefined, so authorId check fails
|
|
61
|
+
expect(can('posts.update')).toBe(false)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('predicate that ignores context', () => {
|
|
65
|
+
const can = createPermissions({
|
|
66
|
+
'posts.create': () => true,
|
|
67
|
+
})
|
|
68
|
+
expect(can('posts.create')).toBe(true)
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
// ─── Wildcard matching ─────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
describe('wildcard matching', () => {
|
|
75
|
+
it('matches prefix wildcard', () => {
|
|
76
|
+
const can = createPermissions({ 'posts.*': true })
|
|
77
|
+
expect(can('posts.read')).toBe(true)
|
|
78
|
+
expect(can('posts.create')).toBe(true)
|
|
79
|
+
expect(can('posts.delete')).toBe(true)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('exact match takes precedence over wildcard', () => {
|
|
83
|
+
const can = createPermissions({
|
|
84
|
+
'posts.*': true,
|
|
85
|
+
'posts.delete': false,
|
|
86
|
+
})
|
|
87
|
+
expect(can('posts.read')).toBe(true)
|
|
88
|
+
expect(can('posts.delete')).toBe(false)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('global wildcard matches everything', () => {
|
|
92
|
+
const can = createPermissions({ '*': true })
|
|
93
|
+
expect(can('posts.read')).toBe(true)
|
|
94
|
+
expect(can('users.manage')).toBe(true)
|
|
95
|
+
expect(can('anything')).toBe(true)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('exact match takes precedence over global wildcard', () => {
|
|
99
|
+
const can = createPermissions({
|
|
100
|
+
'*': true,
|
|
101
|
+
'billing.export': false,
|
|
102
|
+
})
|
|
103
|
+
expect(can('posts.read')).toBe(true)
|
|
104
|
+
expect(can('billing.export')).toBe(false)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('prefix wildcard takes precedence over global wildcard', () => {
|
|
108
|
+
const can = createPermissions({
|
|
109
|
+
'*': false,
|
|
110
|
+
'posts.*': true,
|
|
111
|
+
})
|
|
112
|
+
expect(can('posts.read')).toBe(true)
|
|
113
|
+
expect(can('users.manage')).toBe(false)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('wildcard with predicate', () => {
|
|
117
|
+
const can = createPermissions({
|
|
118
|
+
'posts.*': (post: any) => post?.status !== 'archived',
|
|
119
|
+
})
|
|
120
|
+
expect(can('posts.read', { status: 'published' })).toBe(true)
|
|
121
|
+
expect(can('posts.update', { status: 'archived' })).toBe(false)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('does not match partial prefixes', () => {
|
|
125
|
+
const can = createPermissions({ 'post.*': true })
|
|
126
|
+
expect(can('posts.read')).toBe(false) // 'posts' !== 'post'
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
// ─── can.not ───────────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
describe('can.not()', () => {
|
|
133
|
+
it('returns inverse of can()', () => {
|
|
134
|
+
const can = createPermissions({
|
|
135
|
+
'posts.read': true,
|
|
136
|
+
'posts.delete': false,
|
|
137
|
+
})
|
|
138
|
+
expect(can.not('posts.read')).toBe(false)
|
|
139
|
+
expect(can.not('posts.delete')).toBe(true)
|
|
140
|
+
expect(can.not('users.manage')).toBe(true)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('works with predicates and context', () => {
|
|
144
|
+
const can = createPermissions({
|
|
145
|
+
'posts.update': (post: any) => post?.authorId === 'me',
|
|
146
|
+
})
|
|
147
|
+
expect(can.not('posts.update', { authorId: 'me' })).toBe(false)
|
|
148
|
+
expect(can.not('posts.update', { authorId: 'other' })).toBe(true)
|
|
149
|
+
})
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
// ─── can.all / can.any ─────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
describe('can.all()', () => {
|
|
155
|
+
it('returns true when all permissions are granted', () => {
|
|
156
|
+
const can = createPermissions({
|
|
157
|
+
'posts.read': true,
|
|
158
|
+
'posts.create': true,
|
|
159
|
+
})
|
|
160
|
+
expect(can.all('posts.read', 'posts.create')).toBe(true)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('returns false when any permission is denied', () => {
|
|
164
|
+
const can = createPermissions({
|
|
165
|
+
'posts.read': true,
|
|
166
|
+
'posts.delete': false,
|
|
167
|
+
})
|
|
168
|
+
expect(can.all('posts.read', 'posts.delete')).toBe(false)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('returns true for empty args', () => {
|
|
172
|
+
const can = createPermissions()
|
|
173
|
+
expect(can.all()).toBe(true)
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
describe('can.any()', () => {
|
|
178
|
+
it('returns true when any permission is granted', () => {
|
|
179
|
+
const can = createPermissions({
|
|
180
|
+
'posts.read': false,
|
|
181
|
+
'posts.create': true,
|
|
182
|
+
})
|
|
183
|
+
expect(can.any('posts.read', 'posts.create')).toBe(true)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('returns false when no permissions are granted', () => {
|
|
187
|
+
const can = createPermissions({
|
|
188
|
+
'posts.read': false,
|
|
189
|
+
'posts.delete': false,
|
|
190
|
+
})
|
|
191
|
+
expect(can.any('posts.read', 'posts.delete')).toBe(false)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('returns false for empty args', () => {
|
|
195
|
+
const can = createPermissions()
|
|
196
|
+
expect(can.any()).toBe(false)
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
// ─── set / patch ───────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
describe('set()', () => {
|
|
203
|
+
it('replaces all permissions', () => {
|
|
204
|
+
const can = createPermissions({
|
|
205
|
+
'posts.read': true,
|
|
206
|
+
'users.manage': true,
|
|
207
|
+
})
|
|
208
|
+
expect(can('posts.read')).toBe(true)
|
|
209
|
+
expect(can('users.manage')).toBe(true)
|
|
210
|
+
|
|
211
|
+
can.set({ 'posts.read': false })
|
|
212
|
+
expect(can('posts.read')).toBe(false)
|
|
213
|
+
expect(can('users.manage')).toBe(false) // replaced, not merged
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('triggers reactive updates', () => {
|
|
217
|
+
const can = createPermissions({ 'posts.read': true })
|
|
218
|
+
const results: boolean[] = []
|
|
219
|
+
|
|
220
|
+
effect(() => {
|
|
221
|
+
results.push(can('posts.read'))
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
expect(results).toEqual([true])
|
|
225
|
+
|
|
226
|
+
can.set({ 'posts.read': false })
|
|
227
|
+
expect(results).toEqual([true, false])
|
|
228
|
+
})
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
describe('patch()', () => {
|
|
232
|
+
it('merges with existing permissions', () => {
|
|
233
|
+
const can = createPermissions({
|
|
234
|
+
'posts.read': true,
|
|
235
|
+
'users.manage': false,
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
can.patch({ 'users.manage': true, 'billing.view': true })
|
|
239
|
+
expect(can('posts.read')).toBe(true) // unchanged
|
|
240
|
+
expect(can('users.manage')).toBe(true) // updated
|
|
241
|
+
expect(can('billing.view')).toBe(true) // added
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('triggers reactive updates', () => {
|
|
245
|
+
const can = createPermissions({ 'posts.read': false })
|
|
246
|
+
const results: boolean[] = []
|
|
247
|
+
|
|
248
|
+
effect(() => {
|
|
249
|
+
results.push(can('posts.read'))
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
expect(results).toEqual([false])
|
|
253
|
+
|
|
254
|
+
can.patch({ 'posts.read': true })
|
|
255
|
+
expect(results).toEqual([false, true])
|
|
256
|
+
})
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
// ─── Reactivity ────────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
describe('reactivity', () => {
|
|
262
|
+
it('can() is reactive inside effect', () => {
|
|
263
|
+
const can = createPermissions({ 'posts.read': true })
|
|
264
|
+
const results: boolean[] = []
|
|
265
|
+
|
|
266
|
+
effect(() => {
|
|
267
|
+
results.push(can('posts.read'))
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
can.set({ 'posts.read': false })
|
|
271
|
+
can.set({ 'posts.read': true })
|
|
272
|
+
|
|
273
|
+
expect(results).toEqual([true, false, true])
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
it('can() is reactive inside computed', () => {
|
|
277
|
+
const can = createPermissions({ admin: true })
|
|
278
|
+
const isAdmin = computed(() => can('admin'))
|
|
279
|
+
|
|
280
|
+
expect(isAdmin()).toBe(true)
|
|
281
|
+
|
|
282
|
+
can.set({ admin: false })
|
|
283
|
+
expect(isAdmin()).toBe(false)
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('can.not() is reactive', () => {
|
|
287
|
+
const can = createPermissions({ 'posts.read': true })
|
|
288
|
+
const results: boolean[] = []
|
|
289
|
+
|
|
290
|
+
effect(() => {
|
|
291
|
+
results.push(can.not('posts.read'))
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
can.set({ 'posts.read': false })
|
|
295
|
+
expect(results).toEqual([false, true])
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it('can.all() is reactive', () => {
|
|
299
|
+
const can = createPermissions({
|
|
300
|
+
'posts.read': true,
|
|
301
|
+
'posts.create': true,
|
|
302
|
+
})
|
|
303
|
+
const results: boolean[] = []
|
|
304
|
+
|
|
305
|
+
effect(() => {
|
|
306
|
+
results.push(can.all('posts.read', 'posts.create'))
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
can.patch({ 'posts.create': false })
|
|
310
|
+
expect(results).toEqual([true, false])
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
it('can.any() is reactive', () => {
|
|
314
|
+
const can = createPermissions({
|
|
315
|
+
'posts.read': false,
|
|
316
|
+
'posts.create': true,
|
|
317
|
+
})
|
|
318
|
+
const results: boolean[] = []
|
|
319
|
+
|
|
320
|
+
effect(() => {
|
|
321
|
+
results.push(can.any('posts.read', 'posts.create'))
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
can.patch({ 'posts.create': false })
|
|
325
|
+
expect(results).toEqual([true, false])
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
it('predicate with reactive signals inside', () => {
|
|
329
|
+
const role = signal('admin')
|
|
330
|
+
const can = createPermissions({
|
|
331
|
+
'users.manage': () => role() === 'admin',
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
// The predicate reads `role()` but reactivity is driven by
|
|
335
|
+
// the permission version signal, not the inner signal.
|
|
336
|
+
// To react to role changes, update permissions:
|
|
337
|
+
expect(can('users.manage')).toBe(true)
|
|
338
|
+
|
|
339
|
+
can.patch({ 'users.manage': () => role() === 'admin' })
|
|
340
|
+
role.set('viewer')
|
|
341
|
+
// Need to re-evaluate — the predicate itself reads the signal
|
|
342
|
+
// but the permission system doesn't track inner signal deps.
|
|
343
|
+
// This is by design: update permissions via set/patch when the source changes.
|
|
344
|
+
})
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
// ─── granted / entries ─────────────────────────────────────────────────
|
|
348
|
+
|
|
349
|
+
describe('granted()', () => {
|
|
350
|
+
it('returns keys with true values', () => {
|
|
351
|
+
const can = createPermissions({
|
|
352
|
+
'posts.read': true,
|
|
353
|
+
'posts.delete': false,
|
|
354
|
+
'users.manage': true,
|
|
355
|
+
})
|
|
356
|
+
expect(can.granted()).toEqual(
|
|
357
|
+
expect.arrayContaining(['posts.read', 'users.manage']),
|
|
358
|
+
)
|
|
359
|
+
expect(can.granted()).not.toContain('posts.delete')
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
it('includes predicate keys', () => {
|
|
363
|
+
const can = createPermissions({
|
|
364
|
+
'posts.update': (post: any) => post?.authorId === 'me',
|
|
365
|
+
})
|
|
366
|
+
// Predicates are capabilities — they exist
|
|
367
|
+
expect(can.granted()).toContain('posts.update')
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
it('is reactive', () => {
|
|
371
|
+
const can = createPermissions({ 'posts.read': true })
|
|
372
|
+
const results: string[][] = []
|
|
373
|
+
|
|
374
|
+
effect(() => {
|
|
375
|
+
results.push([...can.granted()])
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
can.patch({ 'users.manage': true })
|
|
379
|
+
expect(results).toEqual([['posts.read'], ['posts.read', 'users.manage']])
|
|
380
|
+
})
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
describe('entries()', () => {
|
|
384
|
+
it('returns all entries', () => {
|
|
385
|
+
const can = createPermissions({
|
|
386
|
+
'posts.read': true,
|
|
387
|
+
'posts.delete': false,
|
|
388
|
+
})
|
|
389
|
+
const entries = can.entries()
|
|
390
|
+
expect(entries).toHaveLength(2)
|
|
391
|
+
expect(entries).toEqual(
|
|
392
|
+
expect.arrayContaining([
|
|
393
|
+
['posts.read', true],
|
|
394
|
+
['posts.delete', false],
|
|
395
|
+
]),
|
|
396
|
+
)
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
it('is reactive', () => {
|
|
400
|
+
const can = createPermissions({ a: true })
|
|
401
|
+
const counts: number[] = []
|
|
402
|
+
|
|
403
|
+
effect(() => {
|
|
404
|
+
counts.push(can.entries().length)
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
can.patch({ b: true })
|
|
408
|
+
expect(counts).toEqual([1, 2])
|
|
409
|
+
})
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
// ─── Real-world patterns ───────────────────────────────────────────────
|
|
413
|
+
|
|
414
|
+
describe('real-world patterns', () => {
|
|
415
|
+
it('role-based access control', () => {
|
|
416
|
+
function fromRole(role: string) {
|
|
417
|
+
const roles: Record<string, Record<string, boolean>> = {
|
|
418
|
+
admin: { 'posts.*': true, 'users.*': true, 'billing.*': true },
|
|
419
|
+
editor: {
|
|
420
|
+
'posts.read': true,
|
|
421
|
+
'posts.create': true,
|
|
422
|
+
'posts.update': true,
|
|
423
|
+
'users.read': true,
|
|
424
|
+
},
|
|
425
|
+
viewer: { 'posts.read': true },
|
|
426
|
+
}
|
|
427
|
+
return roles[role] ?? {}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const can = createPermissions(fromRole('editor'))
|
|
431
|
+
|
|
432
|
+
expect(can('posts.read')).toBe(true)
|
|
433
|
+
expect(can('posts.create')).toBe(true)
|
|
434
|
+
expect(can('posts.delete')).toBe(false)
|
|
435
|
+
expect(can('users.read')).toBe(true)
|
|
436
|
+
expect(can('users.manage')).toBe(false)
|
|
437
|
+
expect(can('billing.view')).toBe(false)
|
|
438
|
+
|
|
439
|
+
// Promote to admin
|
|
440
|
+
can.set(fromRole('admin'))
|
|
441
|
+
expect(can('posts.delete')).toBe(true)
|
|
442
|
+
expect(can('users.manage')).toBe(true)
|
|
443
|
+
expect(can('billing.view')).toBe(true)
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
it('feature flags mixed with permissions', () => {
|
|
447
|
+
const can = createPermissions({
|
|
448
|
+
'posts.read': true,
|
|
449
|
+
'posts.create': true,
|
|
450
|
+
'feature.new-editor': true,
|
|
451
|
+
'feature.dark-mode': false,
|
|
452
|
+
'tier.pro': true,
|
|
453
|
+
'tier.enterprise': false,
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
expect(can('feature.new-editor')).toBe(true)
|
|
457
|
+
expect(can('feature.dark-mode')).toBe(false)
|
|
458
|
+
expect(can('tier.pro')).toBe(true)
|
|
459
|
+
expect(can('tier.enterprise')).toBe(false)
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
it('instance-level ownership checks', () => {
|
|
463
|
+
const currentUserId = 'user-42'
|
|
464
|
+
|
|
465
|
+
const can = createPermissions({
|
|
466
|
+
'posts.read': true,
|
|
467
|
+
'posts.update': (post: any) => post?.authorId === currentUserId,
|
|
468
|
+
'posts.delete': (post: any) =>
|
|
469
|
+
post?.authorId === currentUserId && post?.status === 'draft',
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
const myPost = { authorId: 'user-42', status: 'draft' }
|
|
473
|
+
const otherPost = { authorId: 'user-99', status: 'draft' }
|
|
474
|
+
const publishedPost = { authorId: 'user-42', status: 'published' }
|
|
475
|
+
|
|
476
|
+
expect(can('posts.update', myPost)).toBe(true)
|
|
477
|
+
expect(can('posts.update', otherPost)).toBe(false)
|
|
478
|
+
expect(can('posts.delete', myPost)).toBe(true)
|
|
479
|
+
expect(can('posts.delete', publishedPost)).toBe(false)
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
it('multi-tenant with key prefixes', () => {
|
|
483
|
+
const can = createPermissions({
|
|
484
|
+
'org:acme.admin': true,
|
|
485
|
+
'ws:design.posts.*': true,
|
|
486
|
+
'ws:engineering.posts.read': true,
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
expect(can('org:acme.admin')).toBe(true)
|
|
490
|
+
expect(can('ws:design.posts.read')).toBe(true)
|
|
491
|
+
expect(can('ws:design.posts.delete')).toBe(true)
|
|
492
|
+
expect(can('ws:engineering.posts.read')).toBe(true)
|
|
493
|
+
expect(can('ws:engineering.posts.delete')).toBe(false)
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
it('server response transformation', () => {
|
|
497
|
+
// Simulated server response
|
|
498
|
+
const serverResponse = {
|
|
499
|
+
permissions: ['posts:read', 'posts:create', 'users:read'],
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Simple transform — not a framework feature, just a .map()
|
|
503
|
+
const perms = Object.fromEntries(
|
|
504
|
+
serverResponse.permissions.map((p) => [p.replace(':', '.'), true]),
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
const can = createPermissions(perms)
|
|
508
|
+
expect(can('posts.read')).toBe(true)
|
|
509
|
+
expect(can('posts.create')).toBe(true)
|
|
510
|
+
expect(can('users.read')).toBe(true)
|
|
511
|
+
expect(can('posts.delete')).toBe(false)
|
|
512
|
+
})
|
|
513
|
+
|
|
514
|
+
it('superadmin with global wildcard', () => {
|
|
515
|
+
const can = createPermissions({ '*': true })
|
|
516
|
+
expect(can('literally.anything')).toBe(true)
|
|
517
|
+
expect(can('posts.read')).toBe(true)
|
|
518
|
+
expect(can('admin.nuclear-launch')).toBe(true)
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
it('reactive role switching', () => {
|
|
522
|
+
function fromRole(role: string): Record<string, boolean> {
|
|
523
|
+
if (role === 'admin') return { '*': true }
|
|
524
|
+
if (role === 'editor') return { 'posts.*': true, 'users.read': true }
|
|
525
|
+
return { 'posts.read': true }
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const can = createPermissions(fromRole('viewer'))
|
|
529
|
+
const results: boolean[] = []
|
|
530
|
+
|
|
531
|
+
effect(() => {
|
|
532
|
+
results.push(can('posts.create'))
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
expect(results).toEqual([false])
|
|
536
|
+
|
|
537
|
+
can.set(fromRole('editor'))
|
|
538
|
+
expect(results).toEqual([false, true])
|
|
539
|
+
|
|
540
|
+
can.set(fromRole('admin'))
|
|
541
|
+
expect(results).toEqual([false, true, true])
|
|
542
|
+
|
|
543
|
+
can.set(fromRole('viewer'))
|
|
544
|
+
expect(results).toEqual([false, true, true, false])
|
|
545
|
+
})
|
|
546
|
+
})
|
|
547
|
+
})
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { Computed } from '@pyreon/reactivity'
|
|
2
|
+
|
|
3
|
+
// ─── Permission values ───────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A permission predicate — receives optional context and returns a boolean.
|
|
7
|
+
* Used for instance-level checks (e.g., "can update THIS post?").
|
|
8
|
+
*/
|
|
9
|
+
export type PermissionPredicate<TContext = unknown> = (
|
|
10
|
+
context?: TContext,
|
|
11
|
+
) => boolean
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A permission value is either a static boolean or a predicate function.
|
|
15
|
+
* - `true` / `false` — static grant or denial
|
|
16
|
+
* - `(context?) => boolean` — dynamic, evaluated per-check
|
|
17
|
+
*/
|
|
18
|
+
export type PermissionValue<TContext = unknown> =
|
|
19
|
+
| boolean
|
|
20
|
+
| PermissionPredicate<TContext>
|
|
21
|
+
|
|
22
|
+
// ─── Permission map ──────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A map of permission keys to their values.
|
|
26
|
+
* Keys are dot-separated strings (e.g., 'posts.read', 'users.manage').
|
|
27
|
+
* Wildcards are supported: 'posts.*' matches any 'posts.X' key.
|
|
28
|
+
*/
|
|
29
|
+
export type PermissionMap = Record<string, PermissionValue>
|
|
30
|
+
|
|
31
|
+
// ─── Permissions instance ────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The permissions instance returned by `createPermissions()`.
|
|
35
|
+
* Callable — `can('posts.read')` returns a boolean, reactive in effects/computeds/JSX.
|
|
36
|
+
*/
|
|
37
|
+
export interface Permissions {
|
|
38
|
+
/**
|
|
39
|
+
* Check if a permission is granted.
|
|
40
|
+
* Returns a boolean — reactive when read inside effects, computeds, or JSX `{() => ...}`.
|
|
41
|
+
*
|
|
42
|
+
* @param key - Permission key (e.g., 'posts.read')
|
|
43
|
+
* @param context - Optional context for predicate evaluation (e.g., a post instance)
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* ```tsx
|
|
47
|
+
* can('posts.read') // static check
|
|
48
|
+
* can('posts.update', post) // instance check
|
|
49
|
+
* {() => can('posts.delete') && <DeleteButton />}
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
(key: string, context?: unknown): boolean
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Inverse check — returns true when the permission is denied.
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* ```tsx
|
|
59
|
+
* can.not('billing.export') // true if user cannot export
|
|
60
|
+
* ```
|
|
61
|
+
*/
|
|
62
|
+
not: (key: string, context?: unknown) => boolean
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Check if ALL listed permissions are granted.
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* ```tsx
|
|
69
|
+
* can.all('posts.read', 'posts.create')
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
all: (...keys: string[]) => boolean
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Check if ANY of the listed permissions is granted.
|
|
76
|
+
*
|
|
77
|
+
* @example
|
|
78
|
+
* ```tsx
|
|
79
|
+
* can.any('posts.update', 'posts.delete')
|
|
80
|
+
* ```
|
|
81
|
+
*/
|
|
82
|
+
any: (...keys: string[]) => boolean
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Replace all permissions. All reactive reads update automatically.
|
|
86
|
+
*
|
|
87
|
+
* @example
|
|
88
|
+
* ```tsx
|
|
89
|
+
* can.set({ 'posts.read': true, 'users.manage': false })
|
|
90
|
+
* ```
|
|
91
|
+
*/
|
|
92
|
+
set: (permissions: PermissionMap) => void
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Merge permissions into the current map.
|
|
96
|
+
* Existing keys are overwritten, new keys are added.
|
|
97
|
+
*
|
|
98
|
+
* @example
|
|
99
|
+
* ```tsx
|
|
100
|
+
* can.patch({ 'billing.export': true })
|
|
101
|
+
* ```
|
|
102
|
+
*/
|
|
103
|
+
patch: (permissions: PermissionMap) => void
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* All currently granted permission keys (static true + predicates that exist).
|
|
107
|
+
* Reactive signal — updates when permissions change.
|
|
108
|
+
*
|
|
109
|
+
* @example
|
|
110
|
+
* ```tsx
|
|
111
|
+
* // For help dialogs or admin dashboards
|
|
112
|
+
* can.granted() // ['posts.read', 'posts.create', 'users.manage']
|
|
113
|
+
* ```
|
|
114
|
+
*/
|
|
115
|
+
granted: Computed<string[]>
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* All permission entries as [key, value] pairs.
|
|
119
|
+
* Reactive signal — updates when permissions change.
|
|
120
|
+
*/
|
|
121
|
+
entries: Computed<[string, PermissionValue][]>
|
|
122
|
+
}
|