@pyreon/permissions 0.5.0 → 0.7.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/lib/index.js ADDED
@@ -0,0 +1,132 @@
1
+ import { computed, signal } from "@pyreon/reactivity";
2
+ import { createContext, provide, useContext } from "@pyreon/core";
3
+
4
+ //#region src/permissions.ts
5
+ /**
6
+ * Resolve a permission key against the map.
7
+ * Resolution order: exact match → wildcard (e.g., 'posts.*') → global wildcard ('*') → false.
8
+ */
9
+ function resolve(map, key, context) {
10
+ const exact = map.get(key);
11
+ if (exact !== void 0) return typeof exact === "function" ? exact(context) : exact;
12
+ const dotIndex = key.lastIndexOf(".");
13
+ if (dotIndex !== -1) {
14
+ const prefix = key.slice(0, dotIndex);
15
+ const wildcard = map.get(`${prefix}.*`);
16
+ if (wildcard !== void 0) return typeof wildcard === "function" ? wildcard(context) : wildcard;
17
+ }
18
+ const global = map.get("*");
19
+ if (global !== void 0) return typeof global === "function" ? global(context) : global;
20
+ return false;
21
+ }
22
+ /**
23
+ * Create a reactive permissions instance.
24
+ *
25
+ * The returned `can` function checks permissions reactively —
26
+ * reads update automatically when permissions change via `set()` or `patch()`.
27
+ *
28
+ * @param initial - Optional initial permission map
29
+ * @returns A callable `Permissions` instance
30
+ *
31
+ * @example
32
+ * ```tsx
33
+ * const can = createPermissions({
34
+ * 'posts.read': true,
35
+ * 'posts.update': (post: Post) => post.authorId === userId(),
36
+ * 'users.manage': false,
37
+ * })
38
+ *
39
+ * // Check (reactive in effects/computeds/JSX)
40
+ * can('posts.read') // true
41
+ * can('posts.update', myPost) // evaluates predicate
42
+ *
43
+ * // JSX
44
+ * {() => can('posts.delete') && <DeleteButton />}
45
+ *
46
+ * // Update
47
+ * can.set({ 'posts.read': true, 'admin': true })
48
+ * can.patch({ 'users.manage': true })
49
+ * ```
50
+ */
51
+ function createPermissions(initial) {
52
+ const store = signal(toMap(initial));
53
+ const version = signal(0);
54
+ function toMap(obj) {
55
+ if (!obj) return /* @__PURE__ */ new Map();
56
+ return new Map(Object.entries(obj));
57
+ }
58
+ function can(key, context) {
59
+ version();
60
+ return resolve(store.peek(), key, context);
61
+ }
62
+ can.not = (key, context) => {
63
+ return !can(key, context);
64
+ };
65
+ can.all = (...keys) => {
66
+ return keys.every((key) => can(key));
67
+ };
68
+ can.any = (...keys) => {
69
+ return keys.some((key) => can(key));
70
+ };
71
+ can.set = (permissions) => {
72
+ store.set(toMap(permissions));
73
+ version.update((v) => v + 1);
74
+ };
75
+ can.patch = (permissions) => {
76
+ const current = store.peek();
77
+ for (const [key, value] of Object.entries(permissions)) current.set(key, value);
78
+ store.set(current);
79
+ version.update((v) => v + 1);
80
+ };
81
+ can.granted = computed(() => {
82
+ version();
83
+ const keys = [];
84
+ for (const [key, value] of store.peek()) if (value === true || typeof value === "function") keys.push(key);
85
+ return keys;
86
+ });
87
+ can.entries = computed(() => {
88
+ version();
89
+ return [...store.peek().entries()];
90
+ });
91
+ return can;
92
+ }
93
+
94
+ //#endregion
95
+ //#region src/context.ts
96
+ const PermissionsContext = createContext(null);
97
+ /**
98
+ * Provide a permissions instance to descendant components.
99
+ * Use this for SSR isolation or testing — each request/test gets its own instance.
100
+ *
101
+ * @example
102
+ * ```tsx
103
+ * const can = createPermissions({ ... })
104
+ *
105
+ * <PermissionsProvider instance={can}>
106
+ * <App />
107
+ * </PermissionsProvider>
108
+ * ```
109
+ */
110
+ function PermissionsProvider(props) {
111
+ provide(PermissionsContext, props.instance);
112
+ return props.children ?? null;
113
+ }
114
+ /**
115
+ * Access the nearest permissions instance from context.
116
+ * Must be used within a `<PermissionsProvider>`.
117
+ *
118
+ * @example
119
+ * ```tsx
120
+ * const can = usePermissions()
121
+ * {() => can('posts.read') && <PostList />}
122
+ * ```
123
+ */
124
+ function usePermissions() {
125
+ const instance = useContext(PermissionsContext);
126
+ if (!instance) throw new Error("[@pyreon/permissions] usePermissions() must be used within <PermissionsProvider>.");
127
+ return instance;
128
+ }
129
+
130
+ //#endregion
131
+ export { PermissionsProvider, createPermissions, usePermissions };
132
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":[],"sources":["../src/permissions.ts","../src/context.ts"],"sourcesContent":["import { computed, signal } from '@pyreon/reactivity'\nimport type { PermissionMap, PermissionValue, Permissions } from './types'\n\n/**\n * Resolve a permission key against the map.\n * Resolution order: exact match → wildcard (e.g., 'posts.*') → global wildcard ('*') → false.\n */\nfunction resolve(\n map: Map<string, PermissionValue>,\n key: string,\n context?: unknown,\n): boolean {\n // 1. Exact match\n const exact = map.get(key)\n if (exact !== undefined) {\n return typeof exact === 'function' ? exact(context) : exact\n }\n\n // 2. Wildcard match — 'posts.read' matches 'posts.*'\n const dotIndex = key.lastIndexOf('.')\n if (dotIndex !== -1) {\n const prefix = key.slice(0, dotIndex)\n const wildcard = map.get(`${prefix}.*`)\n if (wildcard !== undefined) {\n return typeof wildcard === 'function' ? wildcard(context) : wildcard\n }\n }\n\n // 3. Global wildcard\n const global = map.get('*')\n if (global !== undefined) {\n return typeof global === 'function' ? global(context) : global\n }\n\n // 4. No match → denied\n return false\n}\n\n/**\n * Create a reactive permissions instance.\n *\n * The returned `can` function checks permissions reactively —\n * reads update automatically when permissions change via `set()` or `patch()`.\n *\n * @param initial - Optional initial permission map\n * @returns A callable `Permissions` instance\n *\n * @example\n * ```tsx\n * const can = createPermissions({\n * 'posts.read': true,\n * 'posts.update': (post: Post) => post.authorId === userId(),\n * 'users.manage': false,\n * })\n *\n * // Check (reactive in effects/computeds/JSX)\n * can('posts.read') // true\n * can('posts.update', myPost) // evaluates predicate\n *\n * // JSX\n * {() => can('posts.delete') && <DeleteButton />}\n *\n * // Update\n * can.set({ 'posts.read': true, 'admin': true })\n * can.patch({ 'users.manage': true })\n * ```\n */\nexport function createPermissions(initial?: PermissionMap): Permissions {\n // Internal reactive state — a signal holding the permission map\n const store = signal(toMap(initial))\n // Version counter — incremented on every set/patch to trigger reactive updates\n const version = signal(0)\n\n function toMap(obj?: PermissionMap): Map<string, PermissionValue> {\n if (!obj) return new Map()\n return new Map(Object.entries(obj))\n }\n\n // The main check function — reads `version` to subscribe in reactive contexts\n function can(key: string, context?: unknown): boolean {\n // Reading version subscribes this call to reactive updates\n version()\n return resolve(store.peek(), key, context)\n }\n\n can.not = (key: string, context?: unknown): boolean => {\n return !can(key, context)\n }\n\n can.all = (...keys: string[]): boolean => {\n return keys.every((key) => can(key))\n }\n\n can.any = (...keys: string[]): boolean => {\n return keys.some((key) => can(key))\n }\n\n can.set = (permissions: PermissionMap): void => {\n store.set(toMap(permissions))\n version.update((v) => v + 1)\n }\n\n can.patch = (permissions: PermissionMap): void => {\n const current = store.peek()\n for (const [key, value] of Object.entries(permissions)) {\n current.set(key, value)\n }\n store.set(current)\n version.update((v) => v + 1)\n }\n\n can.granted = computed(() => {\n version()\n const keys: string[] = []\n for (const [key, value] of store.peek()) {\n // Static true or predicate (capability exists)\n if (value === true || typeof value === 'function') {\n keys.push(key)\n }\n }\n return keys\n })\n\n can.entries = computed(() => {\n version()\n return [...store.peek().entries()]\n })\n\n return can as Permissions\n}\n","import { createContext, provide, useContext } from '@pyreon/core'\nimport type { VNodeChild } from '@pyreon/core'\nimport type { Permissions } from './types'\n\nconst PermissionsContext = createContext<Permissions | null>(null)\n\n/**\n * Provide a permissions instance to descendant components.\n * Use this for SSR isolation or testing — each request/test gets its own instance.\n *\n * @example\n * ```tsx\n * const can = createPermissions({ ... })\n *\n * <PermissionsProvider instance={can}>\n * <App />\n * </PermissionsProvider>\n * ```\n */\nexport function PermissionsProvider(props: {\n instance: Permissions\n children?: VNodeChild\n}): VNodeChild {\n provide(PermissionsContext, props.instance)\n\n return props.children ?? null\n}\n\n/**\n * Access the nearest permissions instance from context.\n * Must be used within a `<PermissionsProvider>`.\n *\n * @example\n * ```tsx\n * const can = usePermissions()\n * {() => can('posts.read') && <PostList />}\n * ```\n */\nexport function usePermissions(): Permissions {\n const instance = useContext(PermissionsContext)\n if (!instance) {\n throw new Error(\n '[@pyreon/permissions] usePermissions() must be used within <PermissionsProvider>.',\n )\n }\n return instance\n}\n"],"mappings":";;;;;;;;AAOA,SAAS,QACP,KACA,KACA,SACS;CAET,MAAM,QAAQ,IAAI,IAAI,IAAI;AAC1B,KAAI,UAAU,OACZ,QAAO,OAAO,UAAU,aAAa,MAAM,QAAQ,GAAG;CAIxD,MAAM,WAAW,IAAI,YAAY,IAAI;AACrC,KAAI,aAAa,IAAI;EACnB,MAAM,SAAS,IAAI,MAAM,GAAG,SAAS;EACrC,MAAM,WAAW,IAAI,IAAI,GAAG,OAAO,IAAI;AACvC,MAAI,aAAa,OACf,QAAO,OAAO,aAAa,aAAa,SAAS,QAAQ,GAAG;;CAKhE,MAAM,SAAS,IAAI,IAAI,IAAI;AAC3B,KAAI,WAAW,OACb,QAAO,OAAO,WAAW,aAAa,OAAO,QAAQ,GAAG;AAI1D,QAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgCT,SAAgB,kBAAkB,SAAsC;CAEtE,MAAM,QAAQ,OAAO,MAAM,QAAQ,CAAC;CAEpC,MAAM,UAAU,OAAO,EAAE;CAEzB,SAAS,MAAM,KAAmD;AAChE,MAAI,CAAC,IAAK,wBAAO,IAAI,KAAK;AAC1B,SAAO,IAAI,IAAI,OAAO,QAAQ,IAAI,CAAC;;CAIrC,SAAS,IAAI,KAAa,SAA4B;AAEpD,WAAS;AACT,SAAO,QAAQ,MAAM,MAAM,EAAE,KAAK,QAAQ;;AAG5C,KAAI,OAAO,KAAa,YAA+B;AACrD,SAAO,CAAC,IAAI,KAAK,QAAQ;;AAG3B,KAAI,OAAO,GAAG,SAA4B;AACxC,SAAO,KAAK,OAAO,QAAQ,IAAI,IAAI,CAAC;;AAGtC,KAAI,OAAO,GAAG,SAA4B;AACxC,SAAO,KAAK,MAAM,QAAQ,IAAI,IAAI,CAAC;;AAGrC,KAAI,OAAO,gBAAqC;AAC9C,QAAM,IAAI,MAAM,YAAY,CAAC;AAC7B,UAAQ,QAAQ,MAAM,IAAI,EAAE;;AAG9B,KAAI,SAAS,gBAAqC;EAChD,MAAM,UAAU,MAAM,MAAM;AAC5B,OAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,YAAY,CACpD,SAAQ,IAAI,KAAK,MAAM;AAEzB,QAAM,IAAI,QAAQ;AAClB,UAAQ,QAAQ,MAAM,IAAI,EAAE;;AAG9B,KAAI,UAAU,eAAe;AAC3B,WAAS;EACT,MAAM,OAAiB,EAAE;AACzB,OAAK,MAAM,CAAC,KAAK,UAAU,MAAM,MAAM,CAErC,KAAI,UAAU,QAAQ,OAAO,UAAU,WACrC,MAAK,KAAK,IAAI;AAGlB,SAAO;GACP;AAEF,KAAI,UAAU,eAAe;AAC3B,WAAS;AACT,SAAO,CAAC,GAAG,MAAM,MAAM,CAAC,SAAS,CAAC;GAClC;AAEF,QAAO;;;;;AC5HT,MAAM,qBAAqB,cAAkC,KAAK;;;;;;;;;;;;;;AAelE,SAAgB,oBAAoB,OAGrB;AACb,SAAQ,oBAAoB,MAAM,SAAS;AAE3C,QAAO,MAAM,YAAY;;;;;;;;;;;;AAa3B,SAAgB,iBAA8B;CAC5C,MAAM,WAAW,WAAW,mBAAmB;AAC/C,KAAI,CAAC,SACH,OAAM,IAAI,MACR,oFACD;AAEH,QAAO"}
@@ -0,0 +1,169 @@
1
+ import { Computed } from "@pyreon/reactivity";
2
+ import { VNodeChild } from "@pyreon/core";
3
+
4
+ //#region src/types.d.ts
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
+ type PermissionPredicate<TContext = unknown> = (context?: TContext) => boolean;
10
+ /**
11
+ * A permission value is either a static boolean or a predicate function.
12
+ * - `true` / `false` — static grant or denial
13
+ * - `(context?) => boolean` — dynamic, evaluated per-check
14
+ */
15
+ type PermissionValue<TContext = unknown> = boolean | PermissionPredicate<TContext>;
16
+ /**
17
+ * A map of permission keys to their values.
18
+ * Keys are dot-separated strings (e.g., 'posts.read', 'users.manage').
19
+ * Wildcards are supported: 'posts.*' matches any 'posts.X' key.
20
+ */
21
+ type PermissionMap = Record<string, PermissionValue>;
22
+ /**
23
+ * The permissions instance returned by `createPermissions()`.
24
+ * Callable — `can('posts.read')` returns a boolean, reactive in effects/computeds/JSX.
25
+ */
26
+ interface Permissions {
27
+ /**
28
+ * Check if a permission is granted.
29
+ * Returns a boolean — reactive when read inside effects, computeds, or JSX `{() => ...}`.
30
+ *
31
+ * @param key - Permission key (e.g., 'posts.read')
32
+ * @param context - Optional context for predicate evaluation (e.g., a post instance)
33
+ *
34
+ * @example
35
+ * ```tsx
36
+ * can('posts.read') // static check
37
+ * can('posts.update', post) // instance check
38
+ * {() => can('posts.delete') && <DeleteButton />}
39
+ * ```
40
+ */
41
+ (key: string, context?: unknown): boolean;
42
+ /**
43
+ * Inverse check — returns true when the permission is denied.
44
+ *
45
+ * @example
46
+ * ```tsx
47
+ * can.not('billing.export') // true if user cannot export
48
+ * ```
49
+ */
50
+ not: (key: string, context?: unknown) => boolean;
51
+ /**
52
+ * Check if ALL listed permissions are granted.
53
+ *
54
+ * @example
55
+ * ```tsx
56
+ * can.all('posts.read', 'posts.create')
57
+ * ```
58
+ */
59
+ all: (...keys: string[]) => boolean;
60
+ /**
61
+ * Check if ANY of the listed permissions is granted.
62
+ *
63
+ * @example
64
+ * ```tsx
65
+ * can.any('posts.update', 'posts.delete')
66
+ * ```
67
+ */
68
+ any: (...keys: string[]) => boolean;
69
+ /**
70
+ * Replace all permissions. All reactive reads update automatically.
71
+ *
72
+ * @example
73
+ * ```tsx
74
+ * can.set({ 'posts.read': true, 'users.manage': false })
75
+ * ```
76
+ */
77
+ set: (permissions: PermissionMap) => void;
78
+ /**
79
+ * Merge permissions into the current map.
80
+ * Existing keys are overwritten, new keys are added.
81
+ *
82
+ * @example
83
+ * ```tsx
84
+ * can.patch({ 'billing.export': true })
85
+ * ```
86
+ */
87
+ patch: (permissions: PermissionMap) => void;
88
+ /**
89
+ * All currently granted permission keys (static true + predicates that exist).
90
+ * Reactive signal — updates when permissions change.
91
+ *
92
+ * @example
93
+ * ```tsx
94
+ * // For help dialogs or admin dashboards
95
+ * can.granted() // ['posts.read', 'posts.create', 'users.manage']
96
+ * ```
97
+ */
98
+ granted: Computed<string[]>;
99
+ /**
100
+ * All permission entries as [key, value] pairs.
101
+ * Reactive signal — updates when permissions change.
102
+ */
103
+ entries: Computed<[string, PermissionValue][]>;
104
+ }
105
+ //#endregion
106
+ //#region src/permissions.d.ts
107
+ /**
108
+ * Create a reactive permissions instance.
109
+ *
110
+ * The returned `can` function checks permissions reactively —
111
+ * reads update automatically when permissions change via `set()` or `patch()`.
112
+ *
113
+ * @param initial - Optional initial permission map
114
+ * @returns A callable `Permissions` instance
115
+ *
116
+ * @example
117
+ * ```tsx
118
+ * const can = createPermissions({
119
+ * 'posts.read': true,
120
+ * 'posts.update': (post: Post) => post.authorId === userId(),
121
+ * 'users.manage': false,
122
+ * })
123
+ *
124
+ * // Check (reactive in effects/computeds/JSX)
125
+ * can('posts.read') // true
126
+ * can('posts.update', myPost) // evaluates predicate
127
+ *
128
+ * // JSX
129
+ * {() => can('posts.delete') && <DeleteButton />}
130
+ *
131
+ * // Update
132
+ * can.set({ 'posts.read': true, 'admin': true })
133
+ * can.patch({ 'users.manage': true })
134
+ * ```
135
+ */
136
+ declare function createPermissions(initial?: PermissionMap): Permissions;
137
+ //#endregion
138
+ //#region src/context.d.ts
139
+ /**
140
+ * Provide a permissions instance to descendant components.
141
+ * Use this for SSR isolation or testing — each request/test gets its own instance.
142
+ *
143
+ * @example
144
+ * ```tsx
145
+ * const can = createPermissions({ ... })
146
+ *
147
+ * <PermissionsProvider instance={can}>
148
+ * <App />
149
+ * </PermissionsProvider>
150
+ * ```
151
+ */
152
+ declare function PermissionsProvider(props: {
153
+ instance: Permissions;
154
+ children?: VNodeChild;
155
+ }): VNodeChild;
156
+ /**
157
+ * Access the nearest permissions instance from context.
158
+ * Must be used within a `<PermissionsProvider>`.
159
+ *
160
+ * @example
161
+ * ```tsx
162
+ * const can = usePermissions()
163
+ * {() => can('posts.read') && <PostList />}
164
+ * ```
165
+ */
166
+ declare function usePermissions(): Permissions;
167
+ //#endregion
168
+ export { type PermissionMap, type PermissionPredicate, type PermissionValue, type Permissions, PermissionsProvider, createPermissions, usePermissions };
169
+ //# sourceMappingURL=index2.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index2.d.ts","names":[],"sources":["../../../src/types.ts","../../../src/permissions.ts","../../../src/context.ts"],"mappings":";;;;;;;AAQA;KAAY,mBAAA,wBACV,OAAA,GAAU,QAAA;;;;;;KAQA,eAAA,iCAER,mBAAA,CAAoB,QAAA;;AAFxB;;;;KAWY,aAAA,GAAgB,MAAA,SAAe,eAAA;;;;;UAQ1B,WAAA;EARQ;;;;AAQzB;;;;;;;;;;EARyB,CAuBtB,GAAA,UAAa,OAAA;EAAA;;;;;;;;EAUd,GAAA,GAAM,GAAA,UAAa,OAAA;EA8BA;;;;;;;;EApBnB,GAAA,MAAS,IAAA;EAiDkB;;;;;;ACrD7B;;EDcE,GAAA,MAAS,IAAA;ECd4D;;;;;;;;EDwBrE,GAAA,GAAM,WAAA,EAAa,aAAA;EExEL;;;;;;;;;EFmFd,KAAA,GAAQ,WAAA,EAAa,aAAA;EEjFrB;;;;;;AAiBF;;;;EF4EE,OAAA,EAAS,QAAA;;;;;EAMT,OAAA,EAAS,QAAA,UAAkB,eAAA;AAAA;;;;;;AAhH7B;;;;;;;;;AASA;;;;;;;;;AAWA;;;;;AAQA;;;iBC+BgB,iBAAA,CAAkB,OAAA,GAAU,aAAA,GAAgB,WAAA;;;;;AD3D5D;;;;;;;;;AASA;;iBEEgB,mBAAA,CAAoB,KAAA;EAClC,QAAA,EAAU,WAAA;EACV,QAAA,GAAW,UAAA;AAAA,IACT,UAAA;;;;AFMJ;;;;;AAQA;;iBEEgB,cAAA,CAAA,GAAkB,WAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/permissions",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Reactive permissions for Pyreon — type-safe, signal-driven, universal",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -42,11 +42,5 @@
42
42
  "peerDependencies": {
43
43
  "@pyreon/core": ">=0.5.0 <1.0.0",
44
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
45
  }
52
46
  }
package/src/context.ts CHANGED
@@ -1,10 +1,4 @@
1
- import {
2
- createContext,
3
- onUnmount,
4
- popContext,
5
- pushContext,
6
- useContext,
7
- } from '@pyreon/core'
1
+ import { createContext, provide, useContext } from '@pyreon/core'
8
2
  import type { VNodeChild } from '@pyreon/core'
9
3
  import type { Permissions } from './types'
10
4
 
@@ -27,11 +21,7 @@ export function PermissionsProvider(props: {
27
21
  instance: Permissions
28
22
  children?: VNodeChild
29
23
  }): VNodeChild {
30
- const frame = new Map<symbol, unknown>([
31
- [PermissionsContext.id, props.instance],
32
- ])
33
- pushContext(frame)
34
- onUnmount(() => popContext())
24
+ provide(PermissionsContext, props.instance)
35
25
 
36
26
  return props.children ?? null
37
27
  }
@@ -50,7 +40,7 @@ export function usePermissions(): Permissions {
50
40
  const instance = useContext(PermissionsContext)
51
41
  if (!instance) {
52
42
  throw new Error(
53
- '[@pyreon/permissions] usePermissions() must be used within <PermissionsProvider>',
43
+ '[@pyreon/permissions] usePermissions() must be used within <PermissionsProvider>.',
54
44
  )
55
45
  }
56
46
  return instance