qdadm 0.36.0 → 0.38.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.
Files changed (43) hide show
  1. package/README.md +27 -174
  2. package/package.json +2 -1
  3. package/src/auth/SessionAuthAdapter.js +114 -3
  4. package/src/components/editors/PermissionEditor.vue +535 -0
  5. package/src/components/forms/FormField.vue +1 -11
  6. package/src/components/index.js +1 -0
  7. package/src/components/layout/AppLayout.vue +20 -8
  8. package/src/components/layout/defaults/DefaultToaster.vue +3 -3
  9. package/src/components/pages/LoginPage.vue +26 -5
  10. package/src/composables/useCurrentEntity.js +26 -17
  11. package/src/composables/useForm.js +7 -0
  12. package/src/composables/useFormPageBuilder.js +7 -0
  13. package/src/composables/useNavContext.js +30 -16
  14. package/src/core/index.js +0 -3
  15. package/src/debug/AuthCollector.js +175 -33
  16. package/src/debug/Collector.js +24 -2
  17. package/src/debug/EntitiesCollector.js +8 -0
  18. package/src/debug/SignalCollector.js +60 -2
  19. package/src/debug/components/panels/AuthPanel.vue +157 -27
  20. package/src/debug/components/panels/EntitiesPanel.vue +17 -1
  21. package/src/entity/EntityManager.js +183 -34
  22. package/src/entity/auth/EntityAuthAdapter.js +54 -46
  23. package/src/entity/auth/SecurityChecker.js +110 -42
  24. package/src/entity/auth/factory.js +11 -2
  25. package/src/entity/auth/factory.test.js +29 -0
  26. package/src/entity/storage/factory.test.js +6 -5
  27. package/src/index.js +3 -0
  28. package/src/kernel/Kernel.js +132 -21
  29. package/src/kernel/KernelContext.js +158 -0
  30. package/src/security/EntityRoleGranterAdapter.js +350 -0
  31. package/src/security/PermissionMatcher.js +148 -0
  32. package/src/security/PermissionRegistry.js +263 -0
  33. package/src/security/PersistableRoleGranterAdapter.js +618 -0
  34. package/src/security/RoleGranterAdapter.js +123 -0
  35. package/src/security/RoleGranterStorage.js +161 -0
  36. package/src/security/RolesManager.js +81 -0
  37. package/src/security/SecurityModule.js +73 -0
  38. package/src/security/StaticRoleGranterAdapter.js +114 -0
  39. package/src/security/UsersManager.js +122 -0
  40. package/src/security/index.js +45 -0
  41. package/src/security/pages/RoleForm.vue +212 -0
  42. package/src/security/pages/RoleList.vue +106 -0
  43. package/src/styles/main.css +62 -2
@@ -1,37 +1,31 @@
1
1
  /**
2
- * EntityAuthAdapter - Interface for entity-level permission checks
2
+ * EntityAuthAdapter - Thin layer for entity-level permission checks
3
3
  *
4
- * Applications implement this interface to plug their authorization
5
- * system into qdadm's EntityManager. The adapter provides two levels of permission checks:
4
+ * Provides entity-level permission checking by delegating to SecurityChecker.
5
+ * All methods have sensible defaults - subclass only if you need custom behavior.
6
6
  *
7
- * 1. **Scopes** (action-level): Can the user perform this action on this entity type?
8
- * Example: Can user create invoices? Can user delete users?
7
+ * Default behavior (when SecurityChecker is configured):
8
+ * - canPerform() isGranted('entity:{entity}:{action}')
9
+ * - canAccessRecord() → isGranted('entity:{entity}:read', record)
10
+ * - getCurrentUser() → uses callback if provided, null otherwise
9
11
  *
10
- * 2. **Silos** (record-level): Can the user access this specific record?
11
- * Example: Can user see invoice #123? (ownership, team membership, etc.)
12
- *
13
- * Note: For user session authentication (login/logout), see SessionAuthAdapter.
14
- *
15
- * Usage:
12
+ * Usage patterns:
16
13
  * ```js
17
- * class MyEntityAuthAdapter extends EntityAuthAdapter {
18
- * canPerform(entity, action) {
19
- * const user = this.getCurrentUser()
20
- * return user?.permissions?.includes(`${entity}:${action}`)
21
- * }
22
- *
23
- * canAccessRecord(entity, record) {
24
- * const user = this.getCurrentUser()
25
- * return record.owner_id === user?.id || record.team_id === user?.team_id
26
- * }
14
+ * // 1. Callback-based (simplest - no subclass needed)
15
+ * const adapter = new EntityAuthAdapter({
16
+ * getCurrentUser: () => myAuthStore.user
17
+ * })
27
18
  *
28
- * getCurrentUser() {
29
- * return this._userStore.currentUser
19
+ * // 2. Subclass-based (for custom permission logic)
20
+ * class MyAdapter extends EntityAuthAdapter {
21
+ * canPerform(entity, action) {
22
+ * if (['orders', 'invoices'].includes(entity) && !this.getCurrentUser()) {
23
+ * return false
24
+ * }
25
+ * return super.canPerform(entity, action)
30
26
  * }
31
27
  * }
32
28
  * ```
33
- *
34
- * @interface
35
29
  */
36
30
  export class EntityAuthAdapter {
37
31
  /**
@@ -41,6 +35,23 @@ export class EntityAuthAdapter {
41
35
  */
42
36
  _securityChecker = null
43
37
 
38
+ /**
39
+ * Callback to get current user (alternative to subclassing)
40
+ * @type {Function|null}
41
+ * @private
42
+ */
43
+ _getCurrentUserCallback = null
44
+
45
+ /**
46
+ * @param {object} [options]
47
+ * @param {Function} [options.getCurrentUser] - Callback that returns current user or null
48
+ */
49
+ constructor(options = {}) {
50
+ if (options.getCurrentUser) {
51
+ this._getCurrentUserCallback = options.getCurrentUser
52
+ }
53
+ }
54
+
44
55
  /**
45
56
  * Set the SecurityChecker instance for isGranted() delegation
46
57
  *
@@ -62,7 +73,7 @@ export class EntityAuthAdapter {
62
73
  *
63
74
  * @example
64
75
  * adapter.isGranted('ROLE_ADMIN') // Check role
65
- * adapter.isGranted('entity:delete') // Check permission
76
+ * adapter.isGranted('entity:books:delete') // Check permission
66
77
  * adapter.isGranted('books:delete', book) // Check with subject
67
78
  */
68
79
  isGranted(attribute, subject = null) {
@@ -73,8 +84,6 @@ export class EntityAuthAdapter {
73
84
  /**
74
85
  * Check if current user can assign a specific role
75
86
  *
76
- * Uses SecurityChecker's canAssignRole if available.
77
- *
78
87
  * @param {string} targetRole - Role to assign
79
88
  * @returns {boolean} True if user can assign this role
80
89
  */
@@ -96,50 +105,49 @@ export class EntityAuthAdapter {
96
105
  /**
97
106
  * Check if the current user can perform an action on an entity type (scope check)
98
107
  *
99
- * This is the coarse-grained permission check. It determines if the user has
100
- * the right to perform a category of actions, regardless of which specific
101
- * record they want to act on.
108
+ * Default: delegates to isGranted('entity:{entity}:{action}')
109
+ * Override for custom authentication requirements or business rules.
102
110
  *
103
111
  * @param {string} entity - Entity name (e.g., 'users', 'invoices', 'products')
104
112
  * @param {string} action - Action to check: 'read', 'create', 'update', 'delete', 'list'
105
113
  * @returns {boolean} True if user can perform the action on this entity type
106
- *
107
- * @example
108
- * adapter.canPerform('invoices', 'create') // true/false
109
- * adapter.canPerform('users', 'delete') // true/false
110
114
  */
111
115
  canPerform(entity, action) {
112
- throw new Error('[EntityAuthAdapter] canPerform() must be implemented by subclass')
116
+ return this.isGranted(`entity:${entity}:${action}`)
113
117
  }
114
118
 
115
119
  /**
116
120
  * Check if the current user can access a specific record (silo check)
117
121
  *
118
- * This is the fine-grained permission check. After the scope check passes,
119
- * this determines if the user can access a particular record based on
120
- * ownership, team membership, or other business rules.
122
+ * Default: delegates to isGranted('entity:{entity}:read', record)
123
+ * Note: Ownership checks are typically handled via EntityManager's isOwn callback.
121
124
  *
122
125
  * @param {string} entity - Entity name (e.g., 'users', 'invoices')
123
126
  * @param {object} record - The full entity record to check access for
124
127
  * @returns {boolean} True if user can access this specific record
125
- *
126
- * @example
127
- * adapter.canAccessRecord('invoices', { id: 123, owner_id: 456, ... })
128
128
  */
129
129
  canAccessRecord(entity, record) {
130
- throw new Error('[EntityAuthAdapter] canAccessRecord() must be implemented by subclass')
130
+ return this.isGranted(`entity:${entity}:read`, record)
131
131
  }
132
132
 
133
133
  /**
134
134
  * Get the current authenticated user
135
135
  *
136
- * Returns the user object or null if not authenticated. The user object
137
- * should contain whatever information is needed for permission checks.
136
+ * Returns user from:
137
+ * 1. _getCurrentUserCallback if provided in constructor
138
+ * 2. Subclass override if using class extension pattern
139
+ * 3. null if neither is configured
140
+ *
141
+ * Note: SecurityChecker calls this method, so it cannot delegate back to SecurityChecker.
138
142
  *
139
143
  * @returns {object|null} Current user object or null if not authenticated
140
144
  */
141
145
  getCurrentUser() {
142
- throw new Error('[EntityAuthAdapter] getCurrentUser() must be implemented by subclass')
146
+ if (this._getCurrentUserCallback) {
147
+ return this._getCurrentUserCallback()
148
+ }
149
+ // Subclasses can override this method
150
+ return null
143
151
  }
144
152
  }
145
153
 
@@ -1,47 +1,107 @@
1
1
  import { RoleHierarchy } from './RoleHierarchy.js'
2
+ import { PermissionMatcher } from '../../security/PermissionMatcher.js'
3
+ import { StaticRoleGranterAdapter } from '../../security/StaticRoleGranterAdapter.js'
2
4
 
3
5
  /**
4
6
  * SecurityChecker - Symfony-inspired permission checking
5
7
  *
6
8
  * Provides the `isGranted(attribute, subject?)` contract for checking permissions.
7
- * Supports both role checks (ROLE_*) and permission checks (entity:action).
9
+ * Supports both role checks (ROLE_*) and permission checks with wildcards.
8
10
  *
9
- * Note: Symfony's full security system includes "voters" for custom business logic
10
- * (e.g., "owner can edit their own posts"). This implementation focuses on
11
- * role hierarchy + declarative permissions. For complex rules, override
12
- * EntityManager.canRead/canWrite methods directly.
11
+ * Permission format: namespace:target:action
12
+ * - entity:books:read - Entity CRUD
13
+ * - auth:impersonate - System feature
14
+ * - admin:config:edit - Admin feature
15
+ *
16
+ * Wildcard patterns (like signals):
17
+ * - `*` matches exactly one segment
18
+ * - `**` matches zero or more segments (greedy)
13
19
  *
14
20
  * @example
15
- * ```js
16
21
  * const checker = new SecurityChecker({
17
- * roleHierarchy: new RoleHierarchy({ ROLE_ADMIN: ['ROLE_USER'] }),
18
- * rolePermissions: {
19
- * ROLE_USER: ['entity:read', 'entity:list'],
20
- * ROLE_ADMIN: ['entity:create', 'entity:update', 'entity:delete'],
21
- * },
22
+ * roleGranter: new StaticRoleGranterAdapter({
23
+ * role_hierarchy: { ROLE_ADMIN: ['ROLE_USER'] },
24
+ * role_permissions: {
25
+ * ROLE_USER: ['entity:*:read', 'entity:*:list'],
26
+ * ROLE_ADMIN: ['entity:**', 'admin:**']
27
+ * }
28
+ * }),
22
29
  * getCurrentUser: () => authStore.user
23
30
  * })
24
31
  *
25
- * checker.isGranted('ROLE_ADMIN') // Check role
26
- * checker.isGranted('entity:delete') // Check permission
27
- * checker.isGranted('books:delete', book) // Check with subject
28
- * ```
32
+ * checker.isGranted('ROLE_ADMIN') // Check role
33
+ * checker.isGranted('entity:books:read') // Check permission
34
+ * checker.isGranted('entity:books:delete') // Matches 'entity:**' for ADMIN
29
35
  */
30
36
  export class SecurityChecker {
31
37
  /**
32
38
  * @param {Object} options
33
- * @param {RoleHierarchy} options.roleHierarchy - Role hierarchy instance
34
- * @param {Object<string, string[]>} options.rolePermissions - Permissions per role
39
+ * @param {RoleGranterAdapter} [options.roleGranter] - Role granter adapter
40
+ * @param {RoleHierarchy} [options.roleHierarchy] - Role hierarchy (legacy, prefer roleGranter)
41
+ * @param {Object<string, string[]>} [options.rolePermissions] - Permissions per role (legacy)
35
42
  * @param {Function} options.getCurrentUser - Function returning current user or null
36
43
  */
37
- constructor({ roleHierarchy, rolePermissions = {}, getCurrentUser }) {
38
- this.roleHierarchy = roleHierarchy instanceof RoleHierarchy
39
- ? roleHierarchy
40
- : new RoleHierarchy(roleHierarchy || {})
41
- this.rolePermissions = rolePermissions
44
+ constructor({ roleGranter, roleHierarchy, rolePermissions, getCurrentUser }) {
45
+ // Support both new roleGranter and legacy rolePermissions
46
+ if (roleGranter) {
47
+ this._roleGranter = roleGranter
48
+ // Note: roleHierarchy is now a getter that reads dynamically from roleGranter
49
+ // This ensures hierarchy changes after async load() are reflected
50
+ this._legacyRoleHierarchy = null
51
+ } else {
52
+ // Legacy: create static granter from rolePermissions
53
+ this._roleGranter = new StaticRoleGranterAdapter({
54
+ role_hierarchy: roleHierarchy instanceof RoleHierarchy
55
+ ? {} // Can't extract map from RoleHierarchy, use empty
56
+ : (roleHierarchy || {}),
57
+ role_permissions: rolePermissions || {}
58
+ })
59
+ this._legacyRoleHierarchy = roleHierarchy instanceof RoleHierarchy
60
+ ? roleHierarchy
61
+ : new RoleHierarchy(roleHierarchy || {})
62
+ }
63
+
42
64
  this.getCurrentUser = getCurrentUser
43
65
  }
44
66
 
67
+ /**
68
+ * Get role hierarchy (dynamically resolved from roleGranter)
69
+ *
70
+ * This is a getter instead of a cached property to ensure that
71
+ * hierarchy changes after async load() are reflected immediately.
72
+ *
73
+ * @returns {RoleHierarchy}
74
+ */
75
+ get roleHierarchy() {
76
+ // Legacy mode: use cached hierarchy
77
+ if (this._legacyRoleHierarchy) {
78
+ return this._legacyRoleHierarchy
79
+ }
80
+ // Dynamic mode: create fresh RoleHierarchy from current granter state
81
+ return new RoleHierarchy(this._roleGranter.getHierarchy())
82
+ }
83
+
84
+ /**
85
+ * Get role granter adapter
86
+ * @returns {RoleGranterAdapter}
87
+ */
88
+ get roleGranter() {
89
+ return this._roleGranter
90
+ }
91
+
92
+ /**
93
+ * Get role permissions (for backward compatibility / debug panel)
94
+ * @returns {Object<string, string[]>}
95
+ */
96
+ get rolePermissions() {
97
+ const roles = this._roleGranter.getRoles()
98
+ const perms = {}
99
+ for (const role of roles) {
100
+ perms[role] = this._roleGranter.getPermissions(role)
101
+ }
102
+ return perms
103
+ }
104
+
45
105
  /**
46
106
  * Check if current user is granted an attribute (role or permission)
47
107
  *
@@ -49,16 +109,16 @@ export class SecurityChecker {
49
109
  *
50
110
  * Checking flow:
51
111
  * 1. If attribute starts with 'ROLE_' → check role hierarchy
52
- * 2. Check if user has the permission (from role or direct)
112
+ * 2. Check if user has the permission (with wildcard support)
53
113
  *
54
- * @param {string} attribute - Role (ROLE_*) or permission (entity:action)
114
+ * @param {string} attribute - Role (ROLE_*) or permission (namespace:target:action)
55
115
  * @param {object} [subject] - Optional subject for context-aware checks (reserved for future use)
56
116
  * @returns {boolean} True if user is granted the attribute
57
117
  *
58
118
  * @example
59
- * checker.isGranted('ROLE_ADMIN') // true/false
60
- * checker.isGranted('entity:delete') // true/false
61
- * checker.isGranted('books:delete', book) // true/false (with subject)
119
+ * checker.isGranted('ROLE_ADMIN') // true/false
120
+ * checker.isGranted('entity:books:read') // true/false
121
+ * checker.isGranted('entity:books:delete', book) // true/false (with subject)
62
122
  */
63
123
  isGranted(attribute, subject = null) {
64
124
  const user = this.getCurrentUser()
@@ -72,12 +132,9 @@ export class SecurityChecker {
72
132
  )
73
133
  }
74
134
 
75
- // 2. Check if it's a permission
135
+ // 2. Check if it's a permission (with wildcard support)
76
136
  const userPerms = this.getUserPermissions(user)
77
- if (userPerms.includes('*')) return true
78
- if (userPerms.includes(attribute)) return true
79
-
80
- return false
137
+ return PermissionMatcher.any(userPerms, attribute)
81
138
  }
82
139
 
83
140
  /**
@@ -85,11 +142,11 @@ export class SecurityChecker {
85
142
  *
86
143
  * Resolves permissions by:
87
144
  * 1. Getting all reachable roles from role hierarchy
88
- * 2. Collecting permissions from each role
145
+ * 2. Collecting permissions from each role via roleGranter
89
146
  * 3. Adding any user-specific permission overrides
90
147
  *
91
148
  * @param {object} user - User object with role/roles and optional permissions
92
- * @returns {string[]} Array of all permissions
149
+ * @returns {string[]} Array of all permissions (may include wildcards)
93
150
  */
94
151
  getUserPermissions(user) {
95
152
  const roles = user.roles || [user.role]
@@ -99,7 +156,7 @@ export class SecurityChecker {
99
156
  if (!role) continue
100
157
  const reachable = this.roleHierarchy.getReachableRoles(role)
101
158
  for (const r of reachable) {
102
- const rolePerms = this.rolePermissions[r] || []
159
+ const rolePerms = this._roleGranter.getPermissions(r)
103
160
  rolePerms.forEach(p => perms.add(p))
104
161
  }
105
162
  }
@@ -115,14 +172,14 @@ export class SecurityChecker {
115
172
  /**
116
173
  * Check if user can assign a role to another user
117
174
  *
118
- * Rule: Can only assign roles if user has 'role:assign' permission
175
+ * Rule: Can only assign roles if user has 'security:roles:assign' permission
119
176
  * AND has the target role (or higher) themselves.
120
177
  *
121
178
  * @param {string} targetRole - Role to assign
122
179
  * @returns {boolean} True if user can assign this role
123
180
  */
124
181
  canAssignRole(targetRole) {
125
- return this.isGranted('role:assign') && this.isGranted(targetRole)
182
+ return this.isGranted('security:roles:assign') && this.isGranted(targetRole)
126
183
  }
127
184
 
128
185
  /**
@@ -131,7 +188,7 @@ export class SecurityChecker {
131
188
  * @returns {string[]} Array of assignable role names
132
189
  */
133
190
  getAssignableRoles() {
134
- if (!this.isGranted('role:assign')) return []
191
+ if (!this.isGranted('security:roles:assign')) return []
135
192
 
136
193
  const user = this.getCurrentUser()
137
194
  if (!user) return []
@@ -153,15 +210,26 @@ export class SecurityChecker {
153
210
  * Create a SecurityChecker instance from config
154
211
  *
155
212
  * @param {Object} config
156
- * @param {Object<string, string[]>} config.role_hierarchy - Role hierarchy config
157
- * @param {Object<string, string[]>} config.role_permissions - Permissions per role
213
+ * @param {RoleGranterAdapter} [config.roleGranter] - Role granter adapter
214
+ * @param {Object<string, string[]>} [config.role_hierarchy] - Role hierarchy config (legacy)
215
+ * @param {Object<string, string[]>} [config.role_permissions] - Permissions per role (legacy)
158
216
  * @param {Function} config.getCurrentUser - User getter function
159
217
  * @returns {SecurityChecker}
160
218
  */
161
219
  export function createSecurityChecker(config) {
220
+ if (config.roleGranter) {
221
+ return new SecurityChecker({
222
+ roleGranter: config.roleGranter,
223
+ getCurrentUser: config.getCurrentUser
224
+ })
225
+ }
226
+
227
+ // Legacy config: auto-create static granter
162
228
  return new SecurityChecker({
163
- roleHierarchy: new RoleHierarchy(config.role_hierarchy || {}),
164
- rolePermissions: config.role_permissions || {},
229
+ roleGranter: new StaticRoleGranterAdapter({
230
+ role_hierarchy: config.role_hierarchy || {},
231
+ role_permissions: config.role_permissions || {}
232
+ }),
165
233
  getCurrentUser: config.getCurrentUser
166
234
  })
167
235
  }
@@ -105,16 +105,20 @@ function isCompositeConfig(config) {
105
105
  *
106
106
  * Handles:
107
107
  * - AuthAdapter instance → return directly (backward compatible)
108
+ * - Function → wrap as EntityAuthAdapter with getCurrentUser callback
108
109
  * - String pattern 'type' → parse and resolve
109
110
  * - Config object with 'type' → resolve via registry
110
111
  * - Config object with 'default' → create CompositeAuthAdapter
111
112
  *
112
- * @param {EntityAuthAdapter | string | object} config - Auth config
113
+ * @param {EntityAuthAdapter | Function | string | object} config - Auth config
113
114
  * @param {object} [context={}] - Context with authTypes, authResolver
114
115
  * @returns {EntityAuthAdapter} Adapter instance
115
116
  *
116
117
  * @example
117
- * // Instance passthrough (most common, backward compatible)
118
+ * // Function (simplest - for getCurrentUser callback)
119
+ * authFactory(() => authStore.user) // → EntityAuthAdapter
120
+ *
121
+ * // Instance passthrough (backward compatible)
118
122
  * authFactory(myAdapter) // → myAdapter
119
123
  *
120
124
  * // String patterns
@@ -138,6 +142,11 @@ export function authFactory(config, context = {}) {
138
142
  return new PermissiveAuthAdapter()
139
143
  }
140
144
 
145
+ // Function → wrap in EntityAuthAdapter with getCurrentUser callback
146
+ if (typeof config === 'function') {
147
+ return new EntityAuthAdapter({ getCurrentUser: config })
148
+ }
149
+
141
150
  // Already an EntityAuthAdapter instance → return directly (backward compatible)
142
151
  if (config instanceof EntityAuthAdapter) {
143
152
  return config
@@ -82,6 +82,35 @@ describe('authFactory', () => {
82
82
  })
83
83
  })
84
84
 
85
+ describe('function callback', () => {
86
+ it('wraps function in EntityAuthAdapter with getCurrentUser', () => {
87
+ const user = { id: 1, name: 'Test' }
88
+ const result = authFactory(() => user)
89
+
90
+ expect(result).toBeInstanceOf(EntityAuthAdapter)
91
+ expect(result.getCurrentUser()).toBe(user)
92
+ })
93
+
94
+ it('function can return null', () => {
95
+ const result = authFactory(() => null)
96
+
97
+ expect(result).toBeInstanceOf(EntityAuthAdapter)
98
+ expect(result.getCurrentUser()).toBeNull()
99
+ })
100
+
101
+ it('function is called each time getCurrentUser is called', () => {
102
+ let callCount = 0
103
+ const result = authFactory(() => {
104
+ callCount++
105
+ return { id: callCount }
106
+ })
107
+
108
+ expect(result.getCurrentUser()).toEqual({ id: 1 })
109
+ expect(result.getCurrentUser()).toEqual({ id: 2 })
110
+ expect(callCount).toBe(2)
111
+ })
112
+ })
113
+
85
114
  describe('string patterns', () => {
86
115
  it('resolves built-in types', () => {
87
116
  const result = authFactory('permissive')
@@ -59,7 +59,7 @@ describe('defaultStorageResolver', () => {
59
59
  it('creates LocalStorage for local type', () => {
60
60
  const storage = defaultStorageResolver({ type: 'local', key: 'myKey' }, 'items')
61
61
  expect(storage).toBeInstanceOf(LocalStorage)
62
- expect(storage.storageKey).toBe('myKey')
62
+ expect(storage.key).toBe('myKey')
63
63
  })
64
64
 
65
65
  it('creates MemoryStorage for memory type', () => {
@@ -74,7 +74,7 @@ describe('defaultStorageResolver', () => {
74
74
 
75
75
  it('throws for unknown type', () => {
76
76
  expect(() => defaultStorageResolver({ type: 'unknown' }, 'items'))
77
- .toThrow('Unknown storage type: unknown')
77
+ .toThrow('Unknown storage type: "unknown"')
78
78
  })
79
79
  })
80
80
 
@@ -111,9 +111,10 @@ describe('storageFactory', () => {
111
111
  expect(result).toBeInstanceOf(MemoryStorage)
112
112
  })
113
113
 
114
- it('handles config object with string storage', () => {
115
- const result = storageFactory({ storage: 'api:/api/items' }, 'items')
114
+ it('handles config object with endpoint (infers api type)', () => {
115
+ const result = storageFactory({ endpoint: '/api/items' }, 'items')
116
116
  expect(result).toBeInstanceOf(ApiStorage)
117
+ expect(result.endpoint).toBe('/api/items')
117
118
  })
118
119
 
119
120
  it('uses custom resolver when provided', () => {
@@ -133,7 +134,7 @@ describe('storageFactory', () => {
133
134
 
134
135
  it('throws for unparseable string', () => {
135
136
  expect(() => storageFactory('invalid', 'test'))
136
- .toThrow('Cannot parse storage pattern')
137
+ .toThrow('Invalid storage pattern')
137
138
  })
138
139
  })
139
140
 
package/src/index.js CHANGED
@@ -20,6 +20,9 @@ export * from './entity/index.js'
20
20
  // Session auth (user authentication)
21
21
  export * from './auth/index.js'
22
22
 
23
+ // Security (permissions, roles)
24
+ export * from './security/index.js'
25
+
23
26
  // Orchestrator
24
27
  export * from './orchestrator/index.js'
25
28