@lbstack/accessx 0.1.0 → 0.3.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.
@@ -1,23 +1,42 @@
1
- import { ConditionFn, Resource } from "../types";
1
+ import * as React from "react";
2
+ import { Resource } from "../types";
2
3
  export declare function createAccess<const R extends readonly string[], const A extends readonly string[], const Res extends readonly Resource[]>(config: {
3
4
  roles: R;
4
5
  actions: A;
5
6
  resources: Res;
6
7
  }): {
8
+ useCan: (role: R[number] | R[number][], permission: string, context?: any) => boolean;
9
+ Can: ({ role, permission, context, children, }: {
10
+ role: R[number] | R[number][];
11
+ permission: string;
12
+ context?: any;
13
+ children: React.ReactNode;
14
+ }) => import("react/jsx-runtime").JSX.Element | null;
15
+ usePermissions: (source: R[number] | string[] | (() => Promise<string[]>)) => {
16
+ permissions: string[];
17
+ loading: boolean;
18
+ refresh: () => Promise<void>;
19
+ };
20
+ refresh: (role?: R[number] | undefined) => Promise<void>;
21
+ canDo: (granted: any[], permission: string, context?: any) => boolean;
7
22
  roles: R;
8
23
  actions: A;
9
24
  resources: Res;
10
25
  permissions: {
11
- key: "*" | `${Res[number]["key"]}:${A[number]}` | `${Res[number]["key"]}:*`;
26
+ key: import("./engine").PermissionKey<Res, A>;
12
27
  resource: Resource;
13
28
  action: string;
14
29
  }[];
15
- permissionKeys: ("*" | `${Res[number]["key"]}:${A[number]}` | `${Res[number]["key"]}:*`)[];
16
- allow: (role: R[number], permission: ("*" | `${Res[number]["key"]}:${A[number]}` | `${Res[number]["key"]}:*`) | ("*" | `${Res[number]["key"]}:${A[number]}` | `${Res[number]["key"]}:*`)[], condition?: ConditionFn) => void;
17
- can: (role: R[number], permission: "*" | `${Res[number]["key"]}:${A[number]}` | `${Res[number]["key"]}:*`, context?: any) => boolean;
18
- hasPermission: (granted: ("*" | `${Res[number]["key"]}:${A[number]}` | `${Res[number]["key"]}:*`)[], required: "*" | `${Res[number]["key"]}:${A[number]}` | `${Res[number]["key"]}:*`, context?: any) => boolean;
19
- resolvePermissions: (role: R[number]) => ("*" | `${Res[number]["key"]}:${A[number]}` | `${Res[number]["key"]}:*`)[];
20
- normalizePermissions: (raw: string[]) => ("*" | `${Res[number]["key"]}:${A[number]}` | `${Res[number]["key"]}:*`)[];
21
- assignPermissions: (role: R[number], perms: ("*" | `${Res[number]["key"]}:${A[number]}` | `${Res[number]["key"]}:*`) | ("*" | `${Res[number]["key"]}:${A[number]}` | `${Res[number]["key"]}:*`)[], condition?: ConditionFn) => void;
22
- getRolePermissions: (role: R[number]) => ("*" | `${Res[number]["key"]}:${A[number]}` | `${Res[number]["key"]}:*`)[];
30
+ permissionKeys: import("./engine").PermissionKey<Res, A>[];
31
+ allow: (role: R[number], permission: import("./engine").PermissionKey<Res, A> | import("./engine").PermissionKey<Res, A>[], condition?: import("../types").ConditionFn) => void;
32
+ can: (role: R[number] | R[number][], permission: import("./engine").PermissionKey<Res, A>, context?: any) => boolean;
33
+ resolvePermissions: (role: R[number]) => import("./engine").PermissionKey<Res, A>[];
34
+ normalizePermissions: (raw: string[]) => import("./engine").PermissionKey<Res, A>[];
35
+ getRolePermissions: (role: R[number]) => import("./engine").PermissionKey<Res, A>[];
36
+ assignPermissions: (role: R[number], perms: import("./engine").PermissionKey<Res, A> | import("./engine").PermissionKey<Res, A>[] | (() => Promise<import("./engine").PermissionKey<Res, A> | import("./engine").PermissionKey<Res, A>[]>), options?: import("../types").ConditionFn | {
37
+ condition?: import("../types").ConditionFn;
38
+ invalidateKey?: string | (() => Promise<string>) | undefined;
39
+ interval?: number;
40
+ } | undefined) => Promise<void>;
41
+ subscribe: (cb: () => void) => () => void;
23
42
  };
@@ -1,85 +1,70 @@
1
+ import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
2
+ import * as React from "react";
3
+ import { createEngine } from "./engine";
4
+ import { createAccessUI } from "./ui/access-control-ui";
1
5
  export function createAccess(config) {
2
- const permissions = config.resources.flatMap((resource) => config.actions.map((action) => ({
3
- key: `${resource.key}:${action}`,
4
- resource,
5
- action,
6
- })));
7
- const permissionKeys = permissions.map((p) => p.key);
8
- // Store perms with optional conditions
9
- const rolePermissions = new Map();
10
- function assignPermissions(role, perms, condition) {
11
- const list = Array.isArray(perms) ? perms : [perms];
12
- if (!rolePermissions.has(role))
13
- rolePermissions.set(role, new Map());
14
- const permsMap = rolePermissions.get(role);
15
- list.forEach((p) => permsMap.set(p, condition || (() => true)));
6
+ const engine = createEngine(config);
7
+ const { canDo } = createAccessUI();
8
+ /**
9
+ * Hook to manage permissions from various sources (static, dynamic, or engine-bound)
10
+ */
11
+ function usePermissions(source) {
12
+ const [perms, setPerms] = React.useState([]);
13
+ const [loading, setLoading] = React.useState(true);
14
+ const fetch = React.useCallback(async () => {
15
+ setLoading(true);
16
+ try {
17
+ if (typeof source === "function") {
18
+ const res = await source();
19
+ setPerms(res);
20
+ }
21
+ else if (Array.isArray(source)) {
22
+ setPerms(source);
23
+ }
24
+ else {
25
+ // Engine-bound role
26
+ setPerms(engine.getRolePermissions(source));
27
+ }
28
+ }
29
+ finally {
30
+ setLoading(false);
31
+ }
32
+ }, [source]);
33
+ React.useEffect(() => {
34
+ fetch();
35
+ // If it's a role (string provided but not an array), subscribe to global refreshes
36
+ if (typeof source === "string" && !Array.isArray(source)) {
37
+ return engine.subscribe(() => {
38
+ setPerms(engine.getRolePermissions(source));
39
+ });
40
+ }
41
+ }, [fetch, source]);
42
+ return { permissions: perms, loading, refresh: fetch };
16
43
  }
17
- function getRolePermissions(role) {
18
- const permsMap = rolePermissions.get(role);
19
- return permsMap ? Array.from(permsMap.keys()) : [];
44
+ /**
45
+ * Pre-bound UI components
46
+ */
47
+ function useCan(role, permission, context) {
48
+ const [allowed, setAllowed] = React.useState(() => engine.can(role, permission, context));
49
+ React.useEffect(() => {
50
+ setAllowed(engine.can(role, permission, context));
51
+ return engine.subscribe(() => {
52
+ setAllowed(engine.can(role, permission, context));
53
+ });
54
+ }, [role, permission, context]);
55
+ return allowed;
20
56
  }
21
- function allow(role, permission, condition) {
22
- assignPermissions(role, permission, condition);
23
- }
24
- function resolvePermissions(role) {
25
- return getRolePermissions(role);
26
- }
27
- function hasPermission(granted, required, context) {
28
- // This is a static check for local lists.
29
- // For ABAC, we need the actual definition with conditions.
30
- // If 'granted' is just string[], we can't check conditions unless we have the role definition.
31
- if (granted.includes("*"))
32
- return true;
33
- if (granted.includes(required))
34
- return true;
35
- const [resource] = required.split(":");
36
- if (granted.includes(`${resource}:*`))
37
- return true;
38
- return false;
39
- }
40
- // New: Role-aware permission check (supports ABAC)
41
- function can(role, permission, context) {
42
- const permsMap = rolePermissions.get(role);
43
- if (!permsMap)
44
- return false;
45
- // 1. Global wildcard
46
- if (permsMap.has("*")) {
47
- const condition = permsMap.get("*");
48
- if (condition(context))
49
- return true;
50
- }
51
- // 2. Direct match
52
- if (permsMap.has(permission)) {
53
- const condition = permsMap.get(permission);
54
- if (condition(context))
55
- return true;
56
- }
57
- // 3. Resource wildcard
58
- const [resource] = permission.split(":");
59
- const resourceWildcard = `${resource}:*`;
60
- if (permsMap.has(resourceWildcard)) {
61
- const condition = permsMap.get(resourceWildcard);
62
- if (condition(context))
63
- return true;
64
- }
65
- return false;
66
- }
67
- function normalizePermissions(raw) {
68
- const valid = new Set(permissionKeys);
69
- return raw.filter((p) => valid.has(p));
57
+ function Can({ role, permission, context, children, }) {
58
+ const allowed = useCan(role, permission, context);
59
+ return allowed ? _jsx(_Fragment, { children: children }) : null;
70
60
  }
71
61
  return {
72
- roles: config.roles,
73
- actions: config.actions,
74
- resources: config.resources,
75
- permissions,
76
- permissionKeys,
77
- allow,
78
- can, // Role-based check (ABAC supported)
79
- hasPermission, // Static list check (Legacy/Simple)
80
- resolvePermissions,
81
- normalizePermissions,
82
- assignPermissions,
83
- getRolePermissions,
62
+ ...engine,
63
+ useCan,
64
+ Can,
65
+ usePermissions,
66
+ refresh: engine.refresh,
67
+ // Keep legacy check just in case
68
+ canDo,
84
69
  };
85
70
  }
@@ -0,0 +1,30 @@
1
+ import { ConditionFn, Resource } from "../types";
2
+ export type PermissionKey<Res extends readonly Resource[], A extends readonly string[]> = `${Res[number]["key"]}:${A[number]}` | `${Res[number]["key"]}:*` | "*";
3
+ export declare function createEngine<const R extends readonly string[], const A extends readonly string[], const Res extends readonly Resource[]>(config: {
4
+ roles: R;
5
+ actions: A;
6
+ resources: Res;
7
+ }): {
8
+ roles: R;
9
+ actions: A;
10
+ resources: Res;
11
+ permissions: {
12
+ key: PermissionKey<Res, A>;
13
+ resource: Resource;
14
+ action: string;
15
+ }[];
16
+ permissionKeys: PermissionKey<Res, A>[];
17
+ allow: (role: R[number], permission: PermissionKey<Res, A> | PermissionKey<Res, A>[], condition?: ConditionFn) => void;
18
+ can: (role: R[number] | R[number][], permission: PermissionKey<Res, A>, context?: any) => boolean;
19
+ resolvePermissions: (role: R[number]) => PermissionKey<Res, A>[];
20
+ normalizePermissions: (raw: string[]) => PermissionKey<Res, A>[];
21
+ getRolePermissions: (role: R[number]) => PermissionKey<Res, A>[];
22
+ assignPermissions: (role: R[number], perms: PermissionKey<Res, A> | PermissionKey<Res, A>[] | (() => Promise<PermissionKey<Res, A> | PermissionKey<Res, A>[]>), options?: ConditionFn | {
23
+ condition?: ConditionFn;
24
+ invalidateKey?: string | (() => Promise<string>);
25
+ interval?: number;
26
+ }) => Promise<void>;
27
+ refresh: (role?: R[number]) => Promise<void>;
28
+ subscribe: (cb: () => void) => () => void;
29
+ };
30
+ export type Engine = ReturnType<typeof createEngine>;
@@ -0,0 +1,176 @@
1
+ export function createEngine(config) {
2
+ const roleAssignments = new Map();
3
+ const listeners = new Set();
4
+ function subscribe(cb) {
5
+ listeners.add(cb);
6
+ return () => {
7
+ listeners.delete(cb);
8
+ };
9
+ }
10
+ function notify() {
11
+ listeners.forEach((cb) => cb());
12
+ }
13
+ const permissions = config.resources.flatMap((resource) => config.actions.map((action) => ({
14
+ key: `${resource.key}:${action}`,
15
+ resource,
16
+ action,
17
+ })));
18
+ const permissionKeys = permissions.map((p) => p.key);
19
+ async function assignPermissions(role, perms, options) {
20
+ const condition = typeof options === "function" ? options : options?.condition;
21
+ const invalidateKey = typeof options === "object" ? options?.invalidateKey : undefined;
22
+ const interval = typeof options === "object" ? options?.interval : undefined;
23
+ const assignment = {
24
+ permissions: new Map(),
25
+ permsFetcher: typeof perms === "function" ? perms : undefined,
26
+ invalidateKeyFetcher: invalidateKey,
27
+ interval: interval,
28
+ condition: condition,
29
+ };
30
+ const updateFn = async (newKey) => {
31
+ let list = [];
32
+ if (typeof perms === "function") {
33
+ const _permissions = await perms();
34
+ list = Array.isArray(_permissions)
35
+ ? _permissions
36
+ : [_permissions];
37
+ }
38
+ else {
39
+ list = Array.isArray(perms) ? perms : [perms];
40
+ }
41
+ assignment.permissions.clear();
42
+ list.forEach((p) => assignment.permissions.set(p, assignment.condition || (() => true)));
43
+ if (newKey !== undefined) {
44
+ assignment.lastInvalidateKey = newKey;
45
+ }
46
+ else if (assignment.invalidateKeyFetcher) {
47
+ assignment.lastInvalidateKey =
48
+ typeof assignment.invalidateKeyFetcher === "function"
49
+ ? await assignment.invalidateKeyFetcher()
50
+ : assignment.invalidateKeyFetcher;
51
+ }
52
+ notify();
53
+ };
54
+ await updateFn();
55
+ if (!roleAssignments.has(role))
56
+ roleAssignments.set(role, []);
57
+ roleAssignments.get(role).push(assignment);
58
+ if (interval && interval > 0) {
59
+ assignment.timer = setInterval(async () => {
60
+ const currentKey = assignment.invalidateKeyFetcher
61
+ ? typeof assignment.invalidateKeyFetcher === "function"
62
+ ? await assignment.invalidateKeyFetcher()
63
+ : assignment.invalidateKeyFetcher
64
+ : undefined;
65
+ if (!assignment.invalidateKeyFetcher ||
66
+ currentKey !== assignment.lastInvalidateKey) {
67
+ await updateFn(currentKey);
68
+ }
69
+ }, interval);
70
+ }
71
+ }
72
+ async function refresh(role) {
73
+ const roles = role ? [role] : Array.from(roleAssignments.keys());
74
+ for (const r of roles) {
75
+ const assignments = roleAssignments.get(r) || [];
76
+ for (const a of assignments) {
77
+ if (!a.permsFetcher)
78
+ continue;
79
+ const localUpdate = async (newKey) => {
80
+ const _permissions = await a.permsFetcher();
81
+ const list = Array.isArray(_permissions)
82
+ ? _permissions
83
+ : [_permissions];
84
+ a.permissions.clear();
85
+ list.forEach((p) => a.permissions.set(p, a.condition || (() => true)));
86
+ if (newKey !== undefined) {
87
+ a.lastInvalidateKey = newKey;
88
+ }
89
+ else if (a.invalidateKeyFetcher) {
90
+ a.lastInvalidateKey =
91
+ typeof a.invalidateKeyFetcher === "function"
92
+ ? await a.invalidateKeyFetcher()
93
+ : a.invalidateKeyFetcher;
94
+ }
95
+ notify();
96
+ };
97
+ if (a.invalidateKeyFetcher) {
98
+ const currentKey = typeof a.invalidateKeyFetcher === "function"
99
+ ? await a.invalidateKeyFetcher()
100
+ : a.invalidateKeyFetcher;
101
+ if (currentKey !== a.lastInvalidateKey) {
102
+ await localUpdate(currentKey);
103
+ }
104
+ }
105
+ else {
106
+ await localUpdate();
107
+ }
108
+ }
109
+ }
110
+ }
111
+ function getRolePermissions(role) {
112
+ const assignments = roleAssignments.get(role) || [];
113
+ const allPerms = new Set();
114
+ assignments.forEach((a) => {
115
+ Array.from(a.permissions.keys()).forEach((p) => allPerms.add(p));
116
+ });
117
+ return Array.from(allPerms);
118
+ }
119
+ function allow(role, permission, condition) {
120
+ assignPermissions(role, permission, condition);
121
+ }
122
+ function resolvePermissions(role) {
123
+ return getRolePermissions(role);
124
+ }
125
+ function can(role, permission, context) {
126
+ const roles = Array.isArray(role) ? role : [role];
127
+ return roles.some((r) => {
128
+ const assignments = roleAssignments.get(r);
129
+ if (!assignments)
130
+ return false;
131
+ return assignments.some((assignment) => {
132
+ const permsMap = assignment.permissions;
133
+ // 1. Global wildcard
134
+ if (permsMap.has("*")) {
135
+ const condition = permsMap.get("*");
136
+ if (condition(context))
137
+ return true;
138
+ }
139
+ // 2. Direct match
140
+ if (permsMap.has(permission)) {
141
+ const condition = permsMap.get(permission);
142
+ if (condition(context))
143
+ return true;
144
+ }
145
+ // 3. Resource wildcard
146
+ const [resource] = permission.split(":");
147
+ const resourceWildcard = `${resource}:*`;
148
+ if (permsMap.has(resourceWildcard)) {
149
+ const condition = permsMap.get(resourceWildcard);
150
+ if (condition(context))
151
+ return true;
152
+ }
153
+ return false;
154
+ });
155
+ });
156
+ }
157
+ function normalizePermissions(raw) {
158
+ const valid = new Set(permissionKeys);
159
+ return raw.filter((p) => valid.has(p));
160
+ }
161
+ return {
162
+ roles: config.roles,
163
+ actions: config.actions,
164
+ resources: config.resources,
165
+ permissions,
166
+ permissionKeys,
167
+ allow,
168
+ can,
169
+ resolvePermissions,
170
+ normalizePermissions,
171
+ getRolePermissions,
172
+ assignPermissions,
173
+ refresh,
174
+ subscribe,
175
+ };
176
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./access-control";
2
+ export * from "./engine";
@@ -0,0 +1,2 @@
1
+ export * from "./access-control";
2
+ export * from "./engine";
@@ -1,12 +1,18 @@
1
1
  import * as React from "react";
2
- import { PermissionType } from "../../types";
3
- export declare function createAccessUI(): {
4
- useCan: (granted: PermissionType[], permission: PermissionType, context?: any) => boolean;
2
+ export interface CanProps {
3
+ permission: string;
4
+ context?: any;
5
+ children: React.ReactNode;
6
+ }
7
+ export declare function createAccessUI(engine?: {
8
+ can: (role: any, permission: any, context?: any) => boolean;
9
+ }): {
10
+ useCan: (grantedOrPermission: any[] | string, permissionOrContext?: string | any, maybeContext?: any) => any;
5
11
  Can: ({ permissions, permission, context, children, }: {
6
- permissions: PermissionType[];
7
- permission: PermissionType;
12
+ permissions?: any[];
13
+ permission: string;
8
14
  context?: any;
9
15
  children: React.ReactNode;
10
- }) => React.ReactNode;
11
- canDo: (granted: PermissionType[], permission: PermissionType, context?: any) => boolean;
16
+ }) => import("react/jsx-runtime").JSX.Element | null;
17
+ canDo: (granted: any[], permission: string, context?: any) => boolean;
12
18
  };
@@ -1,14 +1,30 @@
1
+ import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
1
2
  import * as React from "react";
2
- export function createAccessUI() {
3
- function useCan(granted, permission, context) {
4
- return React.useMemo(() => canDo(granted, permission, context), [granted, permission, context]);
3
+ export function createAccessUI(engine) {
4
+ /**
5
+ * Hook to check permission.
6
+ * If engine is provided, it uses the engine (pre-bound).
7
+ * Otherwise, it requires granted permissions as first argument (legacy).
8
+ */
9
+ function useCan(grantedOrPermission, permissionOrContext, maybeContext) {
10
+ return React.useMemo(() => {
11
+ if (engine) {
12
+ // Pre-bound: useCan(permission, context)
13
+ // We don't have the role here, so we assume the engine instance
14
+ // is already bound to a role or we need to handle it differently.
15
+ // Actually, createAccess will wrap this.
16
+ return engine.can(grantedOrPermission, permissionOrContext);
17
+ }
18
+ // Legacy: useCan(granted, permission, context)
19
+ return canDo(grantedOrPermission, permissionOrContext, maybeContext);
20
+ }, [grantedOrPermission, permissionOrContext, maybeContext]);
5
21
  }
6
22
  function Can({ permissions, permission, context, children, }) {
7
- return canDo(permissions, permission, context) ? children : null;
23
+ const allowed = engine
24
+ ? engine.can(permission, context)
25
+ : canDo(permissions || [], permission, context); // Fallback for legacy
26
+ return allowed ? _jsx(_Fragment, { children: children }) : null;
8
27
  }
9
- // ✅ Function-level permission checker
10
- // Note: This is a static list check. If conditions are needed on frontend,
11
- // the frontend list should contain the logic or be checked against a role-aware engine.
12
28
  function canDo(granted, permission, context) {
13
29
  if (granted.includes("*"))
14
30
  return true;
package/package.json CHANGED
@@ -1,30 +1,33 @@
1
- {
2
- "name": "@lbstack/accessx",
3
- "version": "0.1.0",
4
- "description": "A role & resource based access control system with end to end type safety.",
5
- "license": "ISC",
6
- "author": "",
7
- "type": "module",
8
- "main": "./dist/index.js",
9
- "module": "./dist/index.js",
10
- "types": "./dist/index.d.ts",
11
- "files": [
12
- "dist",
13
- "README.md"
14
- ],
15
- "scripts": {
16
- "build": "tsc",
17
- "test": "jest",
18
- "prepublishOnly": "npm run build"
19
- },
20
- "devDependencies": {
21
- "@types/jest": "^29.5.11",
22
- "@types/react": "^18.2.48",
23
- "jest": "^29.7.0",
24
- "ts-jest": "^29.1.1",
25
- "typescript": "^5.3.3"
26
- },
27
- "peerDependencies": {
28
- "react": ">=16.8.0"
29
- }
30
- }
1
+ {
2
+ "name": "@lbstack/accessx",
3
+ "version": "0.3.0",
4
+ "description": "A role & resource based access control system with end to end type safety.",
5
+ "license": "ISC",
6
+ "author": "@lbstack",
7
+ "type": "module",
8
+ "main": "./dist/index.js",
9
+ "module": "./dist/index.js",
10
+ "types": "./dist/index.d.ts",
11
+ "files": [
12
+ "dist",
13
+ "README.md"
14
+ ],
15
+ "scripts": {
16
+ "build": "tsc",
17
+ "test": "jest",
18
+ "prepublishOnly": "npm run build",
19
+ "release:patch": "npm version patch && npm publish",
20
+ "release:minor": "npm version minor && npm publish",
21
+ "release:major": "npm version major && npm publish"
22
+ },
23
+ "devDependencies": {
24
+ "@types/jest": "^29.5.11",
25
+ "@types/react": "^18.2.48",
26
+ "jest": "^29.7.0",
27
+ "ts-jest": "^29.1.1",
28
+ "typescript": "^5.3.3"
29
+ },
30
+ "peerDependencies": {
31
+ "react": ">=16.8.0"
32
+ }
33
+ }
package/readme.md CHANGED
@@ -133,32 +133,114 @@ const permissions = access.normalizePermissions(permissionsFromDb);
133
133
 
134
134
  Ensures only valid generated permissions are used.
135
135
 
136
- Login Response
137
136
  res.json({
138
137
  user,
139
138
  permissions: access.resolvePermissions(user.role),
140
139
  });
140
+ ```
141
+
142
+ ## 🔑 Assigning Permissions
143
+
144
+ There are two ways to assign permissions to roles: **Manual (Static)** and **Dynamic (Database-linked)**.
145
+
146
+ ### 1. Manual Assignment (Static)
147
+ Best for hardcoded defaults or simple applications. This is the standard, **non-mandatory** approach.
141
148
 
142
- 🎨 Frontend Usage (React)
149
+ ```typescript
150
+ // Single permission
151
+ access.allow("ADMIN", "USER:DELETE");
143
152
 
144
- Frontend never uses roles.
145
- It only receives resolved permissions.
153
+ // Multiple permissions
154
+ access.allow("EDITOR", ["BLOGS:CREATE", "BLOGS:READ", "BLOGS:UPDATE"]);
146
155
 
147
- useCan Hook
148
- const canEdit = access.useCan(
149
- auth.permissions,
150
- "BLOGS:UPDATE"
156
+ // With custom ABAC conditions
157
+ access.allow("USER", "BLOG:UPDATE", (context) => {
158
+ return context.post.authorId === context.user.id;
159
+ });
160
+ ```
161
+
162
+ ### 2. Dynamic Assignment (Database + Cache)
163
+ Best for production apps where permissions are managed in a DB or Admin Panel. This is **optional** but provides powerful caching and auto-sync benefits.
164
+
165
+ ```typescript
166
+ await access.assignPermissions("ADMIN",
167
+ // 1. Fetcher: Returns the list of valid permissions from your DB
168
+ async () => {
169
+ const permissions = await db.query("SELECT key FROM permissions WHERE role = 'ADMIN'");
170
+ return permissions.map(p => p.key);
171
+ },
172
+ {
173
+ // OPTIONAL: A fast key-check (e.g., Redis version or DB timestamp)
174
+ // The engine only re-runs the Fetcher if this key changes.
175
+ invalidateKey: async () => await redis.get("perms:admin:version"),
176
+
177
+ // OPTIONAL: Auto-check for updates every 60 seconds in the background
178
+ interval: 60000
179
+ }
151
180
  );
181
+ ```
182
+
183
+ > [!TIP]
184
+ > **Extra Benefits**: By using `invalidateKey`, you avoid hitting your database for every permission check. The engine keeps permissions in an in-memory cache and only refetches when your "version" key in Redis/DB changes.
185
+
186
+ ## 🔄 Manual Refresh & Sync
187
+
188
+ If you don't use the `interval` option, or if you need to force a sync after an admin update, use the `refresh` method.
189
+
190
+ ```typescript
191
+ // Forces the engine to check invalidateKeys and re-fetch if they changed
192
+ await access.refresh();
193
+
194
+ // Refresh only a specific role
195
+ await access.refresh("ADMIN");
196
+ ```
197
+
198
+ ## 🎨 Frontend Usage (React)
199
+
200
+ Frontend components are reactive. When you call `access.refresh()` or when an `interval` triggers an update, all components using the hooks will automatically re-render.
201
+
202
+ ### 1. `useCan` Hook (Engine Bound)
203
+ Automatically re-renders when the engine's permissions for the given role change.
204
+
205
+ ```tsx
206
+ const canEdit = access.useCan("EDITOR", "BLOGS:UPDATE");
207
+ ```
208
+
209
+ ### 2. `usePermissions` Hook (Flexible Source)
210
+ Manage permissions from any source (Static, Async, or Role). Provides `loading` state and a manual `refresh` trigger.
211
+
212
+ ```tsx
213
+ const { permissions, loading, refresh } = access.usePermissions(async () => {
214
+ const res = await api.get("/my-permissions");
215
+ return res.data;
216
+ });
152
217
 
153
- <button disabled={!canEdit}>Edit</button>
218
+ if (loading) return <Spinner />;
154
219
 
155
- <Can /> Component
156
- <access.Can
157
- permissions={auth.permissions}
158
- permission="USER:DELETE"
159
- >
160
- <DeleteUserButton />
220
+ return (
221
+ <div>
222
+ <button onClick={() => refresh()}>Sync Permissions</button>
223
+ <Can permissions={permissions} permission="BLOG:CREATE">
224
+ <CreatePost />
225
+ </Can>
226
+ </div>
227
+ );
228
+ ```
229
+
230
+ ### 3. `<Can />` Component
231
+ Works with both roles (engine-bound) and explicit permission arrays.
232
+
233
+ ```tsx
234
+ // Role-based (Reactive)
235
+ <access.Can role="ADMIN" permission="USER:DELETE">
236
+ <DeleteButton />
237
+ </access.Can>
238
+
239
+ // Permission-based
240
+ <access.Can permissions={userPerms} permission="BLOGS:READ">
241
+ <PostList />
161
242
  </access.Can>
243
+ ```
162
244
 
163
245
  🧠 Type Safety & Autocomplete
164
246
 
@@ -184,62 +266,23 @@ access.permissionKeys
184
266
 
185
267
  Backend
186
268
  access.allow(role, permission)
269
+ access.assignPermissions(role, perms, options) // Async: Supports fetchers & invalidation
187
270
  access.can(role, permission)
188
271
  access.resolvePermissions(role)
189
272
  access.normalizePermissions(raw)
273
+ access.refresh(role?) // Async: Trigger key check and conditional refetch
190
274
 
191
275
  Frontend
192
276
  access.useCan(permissions, permission)
193
277
  <access.Can />
194
278
 
195
- 🆚 Comparison with Keycloak
196
-
197
- This package is an application-level authorization engine, not a full IAM system.
198
-
199
- Area @accessx/core Keycloak
200
- Identity ❌ ✅
201
- RBAC Core ✅ ✅
202
- Type Safety ✅ ❌
203
- Frontend Hooks ✅ ❌
204
- Performance ✅ ❌
205
- Admin UI ❌ ✅
206
- Multi-App SSO ❌ ✅
207
- Runtime Policy Change ❌ ✅
208
- Enterprise Compliance ❌ ✅
209
- Ops Overhead Low High
210
- Strengths
211
-
212
- Excellent developer experience (TypeScript-first)
213
-
214
- Frontend hooks and React components
215
-
216
- High performance and low latency
217
-
218
- Embedded, framework-agnostic, zero ops
219
-
220
- Weaknesses Compared to Keycloak
221
-
222
- No identity or login management
223
-
224
- No multi-application SSO
225
-
226
- No admin UI or delegation
227
-
228
- Permissions mostly static (redeploy needed for new resources/actions)
229
-
230
- No ABAC / advanced policy language
231
-
232
- No enterprise audit / compliance tooling
233
-
234
- Ideal Usage
235
- Keycloak (Authentication + Identity)
236
-
237
- JWT / Claims
238
-
239
- @accessx/core (Authorization + UI permissions)
279
+ 🔐 Multi-Permission Assignment
280
+ Assign multiple permissions to a role at once:
281
+ ```typescript
282
+ access.allow("EDITOR", ["BLOGS:CREATE", "BLOGS:READ", "BLOGS:UPDATE"]);
283
+ ```
240
284
 
241
285
 
242
- This is complementary, not a replacement for Keycloak.
243
286
 
244
287
  🏆 Why Use @accessx/core?
245
288
 
@@ -288,17 +331,10 @@ npm test
288
331
 
289
332
  ## 🛣️ Roadmap
290
333
 
291
- Wildcard permissions (BLOGS:*)
292
-
293
- Multi-role users
294
-
295
- Attribute-based access control (ABAC)
296
-
297
- Permission groups
298
-
299
- JWT permission compression
300
-
301
- CLI generator
334
+ - [ ] Multi-role users
335
+ - [ ] Permission groups
336
+ - [ ] JWT permission compression
337
+ - [ ] CLI generator
302
338
 
303
339
  📄 License
304
340