qdadm 0.17.0 → 0.18.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qdadm",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Vue 3 framework for admin dashboards with PrimeVue",
5
5
  "author": "quazardous",
6
6
  "license": "MIT",
@@ -328,6 +328,51 @@ export class EntityManager {
328
328
  return this._authAdapter
329
329
  }
330
330
 
331
+ /**
332
+ * Build permission string for an action based on entity_permissions config
333
+ *
334
+ * The permission format depends on the security config:
335
+ * - entity_permissions: false → 'entity:read'
336
+ * - entity_permissions: true → 'books:read'
337
+ * - entity_permissions: ['books'] → 'books:read' for books, 'entity:read' for others
338
+ *
339
+ * @param {string} action - Action name (read, create, update, delete, list)
340
+ * @returns {string} - Permission string
341
+ * @private
342
+ */
343
+ _getPermissionString(action) {
344
+ const checker = this.authAdapter._securityChecker
345
+ if (!checker) return `entity:${action}`
346
+
347
+ const config = checker.entityPermissions
348
+ if (config === false) return `entity:${action}`
349
+ if (config === true) return `${this.name}:${action}`
350
+ if (Array.isArray(config) && config.includes(this.name)) {
351
+ return `${this.name}:${action}`
352
+ }
353
+ return `entity:${action}`
354
+ }
355
+
356
+ /**
357
+ * Check permission using isGranted() if security is configured
358
+ *
359
+ * Falls back to traditional canPerform()/canAccessRecord() if no SecurityChecker.
360
+ * This method respects the entity_permissions config for granular permissions.
361
+ *
362
+ * @param {string} action - Action to check (read, create, update, delete, list)
363
+ * @param {object} [subject] - Optional subject for context-aware checks
364
+ * @returns {boolean}
365
+ */
366
+ checkPermission(action, subject = null) {
367
+ // If isGranted is available, use it
368
+ if (this.authAdapter.isGranted && this.authAdapter._securityChecker) {
369
+ const perm = this._getPermissionString(action)
370
+ return this.authAdapter.isGranted(perm, subject)
371
+ }
372
+ // Fallback to traditional method
373
+ return this.canAccess(action, subject)
374
+ }
375
+
331
376
  /**
332
377
  * Set the auth adapter
333
378
  * @param {AuthAdapter|null} adapter
@@ -33,6 +33,65 @@
33
33
  * @interface
34
34
  */
35
35
  export class AuthAdapter {
36
+ /**
37
+ * SecurityChecker instance for isGranted() delegation
38
+ * @type {import('./SecurityChecker.js').SecurityChecker|null}
39
+ * @private
40
+ */
41
+ _securityChecker = null
42
+
43
+ /**
44
+ * Set the SecurityChecker instance for isGranted() delegation
45
+ *
46
+ * @param {import('./SecurityChecker.js').SecurityChecker} checker
47
+ */
48
+ setSecurityChecker(checker) {
49
+ this._securityChecker = checker
50
+ }
51
+
52
+ /**
53
+ * Check if current user is granted an attribute (Symfony-like contract)
54
+ *
55
+ * This is the unified permission check method. It delegates to SecurityChecker
56
+ * if one is configured, otherwise returns true (permissive fallback).
57
+ *
58
+ * @param {string} attribute - Role (ROLE_*) or permission (entity:action)
59
+ * @param {object} [subject] - Optional subject for context-aware checks
60
+ * @returns {boolean} True if user is granted the attribute
61
+ *
62
+ * @example
63
+ * adapter.isGranted('ROLE_ADMIN') // Check role
64
+ * adapter.isGranted('entity:delete') // Check permission
65
+ * adapter.isGranted('books:delete', book) // Check with subject
66
+ */
67
+ isGranted(attribute, subject = null) {
68
+ if (!this._securityChecker) return true // Permissive if not configured
69
+ return this._securityChecker.isGranted(attribute, subject)
70
+ }
71
+
72
+ /**
73
+ * Check if current user can assign a specific role
74
+ *
75
+ * Uses SecurityChecker's canAssignRole if available.
76
+ *
77
+ * @param {string} targetRole - Role to assign
78
+ * @returns {boolean} True if user can assign this role
79
+ */
80
+ canAssignRole(targetRole) {
81
+ if (!this._securityChecker) return true
82
+ return this._securityChecker.canAssignRole(targetRole)
83
+ }
84
+
85
+ /**
86
+ * Get all roles that current user can assign
87
+ *
88
+ * @returns {string[]} Array of assignable role names
89
+ */
90
+ getAssignableRoles() {
91
+ if (!this._securityChecker) return []
92
+ return this._securityChecker.getAssignableRoles()
93
+ }
94
+
36
95
  /**
37
96
  * Check if the current user can perform an action on an entity type (scope check)
38
97
  *
@@ -0,0 +1,153 @@
1
+ /**
2
+ * RoleHierarchy - Topological role resolution for Symfony-like permission system
3
+ *
4
+ * Roles form a Directed Acyclic Graph (DAG) where higher roles inherit
5
+ * permissions from lower roles. This class resolves the complete set of
6
+ * roles that a user effectively has based on their assigned roles.
7
+ *
8
+ * @example
9
+ * ```js
10
+ * const hierarchy = new RoleHierarchy({
11
+ * ROLE_ADMIN: ['ROLE_USER'], // Admin inherits from User
12
+ * ROLE_SUPER_ADMIN: ['ROLE_ADMIN'], // Super admin inherits from Admin
13
+ * ROLE_MANAGER: ['ROLE_USER'], // Manager also inherits from User
14
+ * })
15
+ *
16
+ * hierarchy.getReachableRoles('ROLE_ADMIN')
17
+ * // Returns: ['ROLE_ADMIN', 'ROLE_USER']
18
+ *
19
+ * hierarchy.isGrantedRole(['ROLE_ADMIN'], 'ROLE_USER')
20
+ * // Returns: true (admin has user permissions)
21
+ * ```
22
+ */
23
+ export class RoleHierarchy {
24
+ /**
25
+ * @param {Object<string, string[]>} hierarchy - Role inheritance map
26
+ * Keys are role names, values are arrays of parent roles they inherit from
27
+ */
28
+ constructor(hierarchy = {}) {
29
+ this.map = hierarchy
30
+ }
31
+
32
+ /**
33
+ * Resolve all roles reachable from a given role (topological traversal)
34
+ *
35
+ * Performs BFS traversal of the role graph to find all inherited roles.
36
+ * Handles cycles gracefully by tracking visited nodes.
37
+ *
38
+ * @param {string} role - Starting role to resolve
39
+ * @returns {string[]} All roles including the starting role and all inherited roles
40
+ *
41
+ * @example
42
+ * // With hierarchy: { ROLE_ADMIN: ['ROLE_USER'] }
43
+ * hierarchy.getReachableRoles('ROLE_ADMIN')
44
+ * // Returns: ['ROLE_ADMIN', 'ROLE_USER']
45
+ */
46
+ getReachableRoles(role) {
47
+ const visited = new Set()
48
+ const queue = [role]
49
+
50
+ while (queue.length > 0) {
51
+ const current = queue.shift()
52
+ if (visited.has(current)) continue
53
+ visited.add(current)
54
+
55
+ const parents = this.map[current] || []
56
+ queue.push(...parents)
57
+ }
58
+
59
+ return [...visited]
60
+ }
61
+
62
+ /**
63
+ * Check if user with given roles has the required role
64
+ *
65
+ * A user has a role if:
66
+ * 1. They are directly assigned that role, OR
67
+ * 2. They have a role that inherits from the required role
68
+ *
69
+ * @param {string|string[]} userRoles - Role(s) assigned to the user
70
+ * @param {string} requiredRole - The role to check for
71
+ * @returns {boolean} True if user has the required role (directly or inherited)
72
+ *
73
+ * @example
74
+ * // With hierarchy: { ROLE_ADMIN: ['ROLE_USER'] }
75
+ * hierarchy.isGrantedRole(['ROLE_ADMIN'], 'ROLE_USER') // true
76
+ * hierarchy.isGrantedRole(['ROLE_USER'], 'ROLE_ADMIN') // false
77
+ */
78
+ isGrantedRole(userRoles, requiredRole) {
79
+ const roles = Array.isArray(userRoles) ? userRoles : [userRoles]
80
+
81
+ for (const role of roles) {
82
+ const reachable = this.getReachableRoles(role)
83
+ if (reachable.includes(requiredRole)) return true
84
+ }
85
+
86
+ return false
87
+ }
88
+
89
+ /**
90
+ * Get all roles that can reach a given role (reverse lookup)
91
+ *
92
+ * Useful for finding which roles would grant a specific permission.
93
+ *
94
+ * @param {string} targetRole - The role to find grantors for
95
+ * @returns {string[]} All roles that have the target role in their reachable set
96
+ */
97
+ getRolesGranting(targetRole) {
98
+ const grantors = []
99
+
100
+ for (const role of Object.keys(this.map)) {
101
+ if (this.isGrantedRole([role], targetRole)) {
102
+ grantors.push(role)
103
+ }
104
+ }
105
+
106
+ // Also check the target role itself
107
+ if (!grantors.includes(targetRole)) {
108
+ grantors.push(targetRole)
109
+ }
110
+
111
+ return grantors
112
+ }
113
+
114
+ /**
115
+ * Validate the hierarchy for cycles (optional sanity check)
116
+ *
117
+ * @returns {boolean} True if hierarchy is valid (no cycles)
118
+ */
119
+ validate() {
120
+ const visiting = new Set()
121
+ const visited = new Set()
122
+
123
+ const hasCycle = (role) => {
124
+ if (visited.has(role)) return false
125
+ if (visiting.has(role)) return true
126
+
127
+ visiting.add(role)
128
+ const parents = this.map[role] || []
129
+ for (const parent of parents) {
130
+ if (hasCycle(parent)) return true
131
+ }
132
+ visiting.delete(role)
133
+ visited.add(role)
134
+ return false
135
+ }
136
+
137
+ for (const role of Object.keys(this.map)) {
138
+ if (hasCycle(role)) return false
139
+ }
140
+
141
+ return true
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Create a RoleHierarchy instance from config
147
+ *
148
+ * @param {Object<string, string[]>} config - Role hierarchy configuration
149
+ * @returns {RoleHierarchy}
150
+ */
151
+ export function createRoleHierarchy(config = {}) {
152
+ return new RoleHierarchy(config)
153
+ }
@@ -0,0 +1,167 @@
1
+ import { RoleHierarchy } from './RoleHierarchy.js'
2
+
3
+ /**
4
+ * SecurityChecker - Symfony-inspired permission checking
5
+ *
6
+ * Provides the `isGranted(attribute, subject?)` contract for checking permissions.
7
+ * Supports both role checks (ROLE_*) and permission checks (entity:action).
8
+ *
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.
13
+ *
14
+ * @example
15
+ * ```js
16
+ * 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
+ * getCurrentUser: () => authStore.user
23
+ * })
24
+ *
25
+ * checker.isGranted('ROLE_ADMIN') // Check role
26
+ * checker.isGranted('entity:delete') // Check permission
27
+ * checker.isGranted('books:delete', book) // Check with subject
28
+ * ```
29
+ */
30
+ export class SecurityChecker {
31
+ /**
32
+ * @param {Object} options
33
+ * @param {RoleHierarchy} options.roleHierarchy - Role hierarchy instance
34
+ * @param {Object<string, string[]>} options.rolePermissions - Permissions per role
35
+ * @param {Function} options.getCurrentUser - Function returning current user or null
36
+ */
37
+ constructor({ roleHierarchy, rolePermissions = {}, getCurrentUser }) {
38
+ this.roleHierarchy = roleHierarchy instanceof RoleHierarchy
39
+ ? roleHierarchy
40
+ : new RoleHierarchy(roleHierarchy || {})
41
+ this.rolePermissions = rolePermissions
42
+ this.getCurrentUser = getCurrentUser
43
+ }
44
+
45
+ /**
46
+ * Check if current user is granted an attribute (role or permission)
47
+ *
48
+ * This is the main contract method, similar to Symfony's isGranted().
49
+ *
50
+ * Checking flow:
51
+ * 1. If attribute starts with 'ROLE_' → check role hierarchy
52
+ * 2. Check if user has the permission (from role or direct)
53
+ *
54
+ * @param {string} attribute - Role (ROLE_*) or permission (entity:action)
55
+ * @param {object} [subject] - Optional subject for context-aware checks (reserved for future use)
56
+ * @returns {boolean} True if user is granted the attribute
57
+ *
58
+ * @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)
62
+ */
63
+ isGranted(attribute, subject = null) {
64
+ const user = this.getCurrentUser()
65
+ if (!user) return false
66
+
67
+ // 1. Check if it's a role (ROLE_*)
68
+ if (attribute.startsWith('ROLE_')) {
69
+ return this.roleHierarchy.isGrantedRole(
70
+ user.roles || [user.role],
71
+ attribute
72
+ )
73
+ }
74
+
75
+ // 2. Check if it's a permission
76
+ const userPerms = this.getUserPermissions(user)
77
+ if (userPerms.includes('*')) return true
78
+ if (userPerms.includes(attribute)) return true
79
+
80
+ return false
81
+ }
82
+
83
+ /**
84
+ * Get all permissions for a user (resolved from roles + user overrides)
85
+ *
86
+ * Resolves permissions by:
87
+ * 1. Getting all reachable roles from role hierarchy
88
+ * 2. Collecting permissions from each role
89
+ * 3. Adding any user-specific permission overrides
90
+ *
91
+ * @param {object} user - User object with role/roles and optional permissions
92
+ * @returns {string[]} Array of all permissions
93
+ */
94
+ getUserPermissions(user) {
95
+ const roles = user.roles || [user.role]
96
+ const perms = new Set()
97
+
98
+ for (const role of roles) {
99
+ if (!role) continue
100
+ const reachable = this.roleHierarchy.getReachableRoles(role)
101
+ for (const r of reachable) {
102
+ const rolePerms = this.rolePermissions[r] || []
103
+ rolePerms.forEach(p => perms.add(p))
104
+ }
105
+ }
106
+
107
+ // User-specific permission overrides
108
+ if (user.permissions && Array.isArray(user.permissions)) {
109
+ user.permissions.forEach(p => perms.add(p))
110
+ }
111
+
112
+ return [...perms]
113
+ }
114
+
115
+ /**
116
+ * Check if user can assign a role to another user
117
+ *
118
+ * Rule: Can only assign roles if user has 'role:assign' permission
119
+ * AND has the target role (or higher) themselves.
120
+ *
121
+ * @param {string} targetRole - Role to assign
122
+ * @returns {boolean} True if user can assign this role
123
+ */
124
+ canAssignRole(targetRole) {
125
+ return this.isGranted('role:assign') && this.isGranted(targetRole)
126
+ }
127
+
128
+ /**
129
+ * Get all roles that current user can assign
130
+ *
131
+ * @returns {string[]} Array of assignable role names
132
+ */
133
+ getAssignableRoles() {
134
+ if (!this.isGranted('role:assign')) return []
135
+
136
+ const user = this.getCurrentUser()
137
+ if (!user) return []
138
+
139
+ const userRoles = user.roles || [user.role]
140
+ const assignable = new Set()
141
+
142
+ for (const role of userRoles) {
143
+ if (!role) continue
144
+ const reachable = this.roleHierarchy.getReachableRoles(role)
145
+ reachable.forEach(r => assignable.add(r))
146
+ }
147
+
148
+ return [...assignable]
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Create a SecurityChecker instance from config
154
+ *
155
+ * @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
158
+ * @param {Function} config.getCurrentUser - User getter function
159
+ * @returns {SecurityChecker}
160
+ */
161
+ export function createSecurityChecker(config) {
162
+ return new SecurityChecker({
163
+ roleHierarchy: new RoleHierarchy(config.role_hierarchy || {}),
164
+ rolePermissions: config.role_permissions || {},
165
+ getCurrentUser: config.getCurrentUser
166
+ })
167
+ }
@@ -2,10 +2,17 @@
2
2
  * Auth Module
3
3
  *
4
4
  * AuthAdapter interface and implementations for scope/silo permission checks.
5
+ * Includes Symfony-inspired role hierarchy and security checker.
5
6
  */
6
7
 
7
8
  // Interface
8
9
  export { AuthAdapter, AuthActions } from './AuthAdapter.js'
9
10
 
11
+ // Role Hierarchy (topological resolution)
12
+ export { RoleHierarchy, createRoleHierarchy } from './RoleHierarchy.js'
13
+
14
+ // Security Checker (isGranted contract)
15
+ export { SecurityChecker, createSecurityChecker } from './SecurityChecker.js'
16
+
10
17
  // Implementations
11
18
  export { PermissiveAuthAdapter, createPermissiveAdapter } from './PermissiveAdapter.js'
package/src/index.js CHANGED
@@ -4,6 +4,10 @@
4
4
  * A framework for building admin dashboards with Vue 3, PrimeVue, and Vue Router.
5
5
  */
6
6
 
7
+ // Version (from package.json)
8
+ import pkg from '../package.json'
9
+ export const version = pkg.version
10
+
7
11
  // Kernel (simplified bootstrap)
8
12
  export * from './kernel/index.js'
9
13
 
@@ -51,6 +51,7 @@ import { createSignalBus } from './SignalBus.js'
51
51
  import { createZoneRegistry } from '../zones/ZoneRegistry.js'
52
52
  import { registerStandardZones } from '../zones/zones.js'
53
53
  import { createHookRegistry } from '../hooks/HookRegistry.js'
54
+ import { createSecurityChecker } from '../entity/auth/SecurityChecker.js'
54
55
 
55
56
  export class Kernel {
56
57
  /**
@@ -71,6 +72,7 @@ export class Kernel {
71
72
  * @param {object} options.features - Feature toggles { auth, poweredBy }
72
73
  * @param {object} options.primevue - PrimeVue config { plugin, theme, options }
73
74
  * @param {object} options.layouts - Layout components { list, form, dashboard, base }
75
+ * @param {object} options.security - Security config { role_hierarchy, role_permissions, entity_permissions }
74
76
  */
75
77
  constructor(options) {
76
78
  this.options = options
@@ -81,6 +83,7 @@ export class Kernel {
81
83
  this.zoneRegistry = null
82
84
  this.hookRegistry = null
83
85
  this.layoutComponents = null
86
+ this.securityChecker = null
84
87
  }
85
88
 
86
89
  /**
@@ -88,12 +91,17 @@ export class Kernel {
88
91
  * @returns {App} Vue app instance ready to mount
89
92
  */
90
93
  createApp() {
91
- this._initModules()
92
- this._createRouter()
94
+ // 1. Create services first (modules need them)
93
95
  this._createSignalBus()
94
96
  this._createHookRegistry()
95
- this._createOrchestrator()
96
97
  this._createZoneRegistry()
98
+ // 2. Initialize modules (can use all services, registers routes)
99
+ this._initModules()
100
+ // 3. Create router (needs routes from modules)
101
+ this._createRouter()
102
+ // 4. Create orchestrator and remaining components
103
+ this._createOrchestrator()
104
+ this._setupSecurity()
97
105
  this._createLayoutComponents()
98
106
  this._createVueApp()
99
107
  this._installPlugins()
@@ -102,13 +110,19 @@ export class Kernel {
102
110
 
103
111
  /**
104
112
  * Initialize modules from glob import
113
+ * Passes services to modules for zone/signal/hook registration
105
114
  */
106
115
  _initModules() {
107
116
  if (this.options.sectionOrder) {
108
117
  setSectionOrder(this.options.sectionOrder)
109
118
  }
110
119
  if (this.options.modules) {
111
- initModules(this.options.modules, this.options.modulesOptions || {})
120
+ initModules(this.options.modules, {
121
+ ...this.options.modulesOptions,
122
+ zones: this.zoneRegistry,
123
+ signals: this.signals,
124
+ hooks: this.hookRegistry
125
+ })
112
126
  }
113
127
  }
114
128
 
@@ -211,6 +225,44 @@ export class Kernel {
211
225
  })
212
226
  }
213
227
 
228
+ /**
229
+ * Setup security layer (role hierarchy, permissions)
230
+ *
231
+ * If security config is provided, creates a SecurityChecker and wires it
232
+ * into the entityAuthAdapter for isGranted() support.
233
+ *
234
+ * Security config:
235
+ * ```js
236
+ * security: {
237
+ * role_hierarchy: { ROLE_ADMIN: ['ROLE_USER'] },
238
+ * role_permissions: {
239
+ * ROLE_USER: ['entity:read', 'entity:list'],
240
+ * ROLE_ADMIN: ['entity:create', 'entity:update', 'entity:delete'],
241
+ * },
242
+ * entity_permissions: false // false | true | ['books', 'loans']
243
+ * }
244
+ * ```
245
+ */
246
+ _setupSecurity() {
247
+ const { security, entityAuthAdapter } = this.options
248
+ if (!security) return
249
+
250
+ // Create SecurityChecker with role hierarchy and permissions
251
+ this.securityChecker = createSecurityChecker({
252
+ role_hierarchy: security.role_hierarchy || {},
253
+ role_permissions: security.role_permissions || {},
254
+ getCurrentUser: () => entityAuthAdapter?.getCurrentUser?.() || null
255
+ })
256
+
257
+ // Store entity_permissions config for EntityManager to use
258
+ this.securityChecker.entityPermissions = security.entity_permissions ?? false
259
+
260
+ // Wire SecurityChecker into entityAuthAdapter
261
+ if (entityAuthAdapter?.setSecurityChecker) {
262
+ entityAuthAdapter.setSecurityChecker(this.securityChecker)
263
+ }
264
+ }
265
+
214
266
  /**
215
267
  * Create zone registry for extensible UI composition
216
268
  * Registers standard zones during bootstrap.
@@ -386,4 +438,21 @@ export class Kernel {
386
438
  get layouts() {
387
439
  return this.layoutComponents
388
440
  }
441
+
442
+ /**
443
+ * Get the SecurityChecker instance
444
+ * @returns {import('../entity/auth/SecurityChecker.js').SecurityChecker|null}
445
+ */
446
+ getSecurityChecker() {
447
+ return this.securityChecker
448
+ }
449
+
450
+ /**
451
+ * Shorthand accessor for security checker
452
+ * Allows `kernel.security.isGranted(...)` syntax
453
+ * @returns {import('../entity/auth/SecurityChecker.js').SecurityChecker|null}
454
+ */
455
+ get security() {
456
+ return this.securityChecker
457
+ }
389
458
  }
@@ -1,28 +1,28 @@
1
1
  /**
2
2
  * Module Registry - Auto-discovery and registration system
3
3
  *
4
- * Each module can provide an init.js that registers:
5
- * - Routes
6
- * - Navigation items
7
- * - Route families (for active state detection)
4
+ * Each module provides an init.js that registers:
5
+ * - Routes & Navigation (via registry)
6
+ * - Zone blocks (via zones)
7
+ * - Signal handlers (via signals)
8
+ * - Hooks (via hooks)
8
9
  *
9
10
  * Usage in module (modules/agents/init.js):
10
11
  *
11
- * export function init(registry) {
12
- * registry.addRoutes('agents', [
13
- * { path: '', name: 'agents', component: () => import('./pages/AgentList.vue') },
14
- * { path: 'create', name: 'agent-create', component: () => import('./pages/AgentForm.vue') },
15
- * { path: ':id/edit', name: 'agent-edit', component: () => import('./pages/AgentForm.vue') }
16
- * ])
12
+ * export function init({ registry, zones, signals, hooks }) {
13
+ * // Routes & Navigation
14
+ * registry.addRoutes('agents', [...])
15
+ * registry.addNavItem({ section: 'Simulation', route: 'agents', ... })
16
+ * registry.addRouteFamily('agents', ['agent-'])
17
17
  *
18
- * registry.addNavItem({
19
- * section: 'Simulation',
20
- * route: 'agents',
21
- * icon: 'pi pi-user',
22
- * label: 'Agents'
23
- * })
18
+ * // Zone blocks
19
+ * zones.registerBlock('agents-list-header', { id: 'agents-header', component: Header })
24
20
  *
25
- * registry.addRouteFamily('agents', ['agent-'])
21
+ * // Signal handlers
22
+ * signals.on('agents:created', handleCreated)
23
+ *
24
+ * // Hooks
25
+ * hooks.register('agents:presave', validateAgent)
26
26
  * }
27
27
  */
28
28
 
@@ -134,24 +134,33 @@ export function setSectionOrder(order) {
134
134
  *
135
135
  * Usage in app:
136
136
  * const moduleInits = import.meta.glob('./modules/* /init.js', { eager: true })
137
- * initModules(moduleInits)
137
+ * initModules(moduleInits, { zones, signals, hooks })
138
138
  *
139
139
  * @param {object} moduleInits - Result of import.meta.glob
140
- * @param {object} options - { coreNavItems: [] } - Core items not in modules
140
+ * @param {object} options - { coreNavItems, zones, signals, hooks }
141
+ * @param {Array} options.coreNavItems - Core nav items not in modules
142
+ * @param {ZoneRegistry} options.zones - Zone registry for block registration
143
+ * @param {SignalBus} options.signals - Signal bus for event handlers
144
+ * @param {HookRegistry} options.hooks - Hook registry for lifecycle hooks
141
145
  */
142
146
  export function initModules(moduleInits, options = {}) {
147
+ const { coreNavItems, zones, signals, hooks } = options
148
+
143
149
  // Add core nav items (pages that aren't in modules)
144
- if (options.coreNavItems) {
145
- for (const item of options.coreNavItems) {
150
+ if (coreNavItems) {
151
+ for (const item of coreNavItems) {
146
152
  registry.addNavItem(item)
147
153
  }
148
154
  }
149
155
 
156
+ // Context passed to module init functions
157
+ const context = { registry, zones, signals, hooks }
158
+
150
159
  // Initialize all discovered modules
151
160
  for (const path in moduleInits) {
152
161
  const module = moduleInits[path]
153
162
  if (typeof module.init === 'function') {
154
- module.init(registry)
163
+ module.init(context)
155
164
  }
156
165
  }
157
166
  }
@@ -294,11 +294,18 @@ export class ZoneRegistry {
294
294
  this._wrapGraph.get(zoneName).set(block.id, block.wraps)
295
295
  }
296
296
 
297
- // Check for duplicate ID (for 'add' operations only)
298
- if (block.id && operation === 'add') {
297
+ // Check for duplicate ID
298
+ if (block.id) {
299
299
  const existingIndex = zone.blocks.findIndex(b => b.id === block.id)
300
300
  if (existingIndex !== -1) {
301
- // Replace existing block with same ID
301
+ // Warn in debug mode - duplicates shouldn't happen with proper module init
302
+ if (this._debug) {
303
+ console.warn(
304
+ `[qdadm:zones] Duplicate block ID "${block.id}" in zone "${zoneName}". ` +
305
+ `This may indicate a module is being initialized multiple times.`
306
+ )
307
+ }
308
+ // Replace existing block (backward compatibility)
302
309
  zone.blocks[existingIndex] = block
303
310
  } else {
304
311
  zone.blocks.push(block)