qdadm 0.17.0 → 0.25.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.
@@ -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
  }
@@ -0,0 +1,277 @@
1
+ /**
2
+ * FilterQuery - Resolves dropdown options for filters
3
+ *
4
+ * Abstracts option resolution from two sources:
5
+ * - 'entity': fetch options from a related EntityManager
6
+ * - 'field': extract distinct values from parent manager's cached items
7
+ *
8
+ * Usage:
9
+ * ```js
10
+ * // Entity source - fetch from genres EntityManager
11
+ * const query = new FilterQuery({
12
+ * source: 'entity',
13
+ * entity: 'genres',
14
+ * label: 'name',
15
+ * value: 'id'
16
+ * })
17
+ *
18
+ * // Field source - extract unique status values
19
+ * const query = new FilterQuery({
20
+ * source: 'field',
21
+ * field: 'status'
22
+ * })
23
+ *
24
+ * // Get options (async)
25
+ * const options = await query.getOptions(orchestrator)
26
+ * // → [{ label: 'Rock', value: 1 }, { label: 'Jazz', value: 2 }]
27
+ * ```
28
+ */
29
+ import { getNestedValue } from './QueryExecutor.js'
30
+
31
+ const VALID_SOURCES = ['entity', 'field']
32
+
33
+ /**
34
+ * Resolve a value from an item using a path or function
35
+ *
36
+ * @param {object} item - The item to extract value from
37
+ * @param {string|Function} resolver - Path string ('name', 'author.country') or function (item => value)
38
+ * @returns {*} The resolved value
39
+ */
40
+ function resolveValue(item, resolver) {
41
+ if (typeof resolver === 'function') {
42
+ return resolver(item)
43
+ }
44
+
45
+ // Use shared getNestedValue for path resolution
46
+ return getNestedValue(resolver, item)
47
+ }
48
+
49
+ export class FilterQuery {
50
+ /**
51
+ * @param {object} options
52
+ * @param {'entity'|'field'} options.source - Source type for options
53
+ * @param {string} [options.entity] - Entity name (required if source='entity')
54
+ * @param {string} [options.field] - Field name to extract values from (required if source='field')
55
+ * @param {object} [options.parentManager] - Parent EntityManager (for field source, set by builder)
56
+ * @param {string|Function} [options.label='name'] - Label resolver (path or function)
57
+ * @param {string|Function} [options.value='id'] - Value resolver (path or function)
58
+ * @param {Function} [options.transform] - Post-processing function for options
59
+ * @param {Function} [options.toQuery] - Transform filter value to query object
60
+ */
61
+ constructor(options = {}) {
62
+ const {
63
+ source,
64
+ entity = null,
65
+ field = null,
66
+ parentManager = null,
67
+ label = 'name',
68
+ value = 'id',
69
+ transform = null,
70
+ toQuery = null
71
+ } = options
72
+
73
+ // Validate source type
74
+ if (!source || !VALID_SOURCES.includes(source)) {
75
+ throw new Error(
76
+ `FilterQuery: source must be one of: ${VALID_SOURCES.join(', ')}. Got: ${source}`
77
+ )
78
+ }
79
+
80
+ // Validate source-specific requirements
81
+ if (source === 'entity' && !entity) {
82
+ throw new Error('FilterQuery: entity is required when source is "entity"')
83
+ }
84
+
85
+ if (source === 'field' && !field) {
86
+ throw new Error('FilterQuery: field is required when source is "field"')
87
+ }
88
+
89
+ this.source = source
90
+ this.entity = entity
91
+ this.field = field
92
+ this.parentManager = parentManager
93
+ this.label = label
94
+ this.value = value
95
+ this.transform = transform
96
+ this.toQuery = toQuery
97
+
98
+ // Internal cache
99
+ this._options = null
100
+ this._signals = null
101
+ this._subscriptions = [] // Signal unsubscribe functions for cleanup
102
+ }
103
+
104
+ /**
105
+ * Set the parent manager (called by useListPageBuilder for field source)
106
+ *
107
+ * @param {EntityManager} manager
108
+ * @returns {FilterQuery} this for chaining
109
+ */
110
+ setParentManager(manager) {
111
+ this.parentManager = manager
112
+ return this
113
+ }
114
+
115
+ /**
116
+ * Set the SignalBus for cache invalidation
117
+ *
118
+ * For entity sources, subscribes to CRUD signals ({entity}:created, {entity}:updated,
119
+ * {entity}:deleted) to automatically invalidate cached options when the source
120
+ * entity changes.
121
+ *
122
+ * @param {SignalBus} signals
123
+ * @returns {FilterQuery} this for chaining
124
+ */
125
+ setSignals(signals) {
126
+ // Clean up existing subscriptions if any
127
+ this._cleanupSubscriptions()
128
+
129
+ this._signals = signals
130
+
131
+ // Subscribe to entity CRUD signals for cache invalidation
132
+ if (this.source === 'entity' && this.entity && signals) {
133
+ const actions = ['created', 'updated', 'deleted']
134
+ for (const action of actions) {
135
+ const signalName = `${this.entity}:${action}`
136
+ const unbind = signals.on(signalName, () => this.invalidate())
137
+ this._subscriptions.push(unbind)
138
+ }
139
+ }
140
+
141
+ return this
142
+ }
143
+
144
+ /**
145
+ * Cleanup signal subscriptions
146
+ * @private
147
+ */
148
+ _cleanupSubscriptions() {
149
+ for (const unbind of this._subscriptions) {
150
+ if (typeof unbind === 'function') {
151
+ unbind()
152
+ }
153
+ }
154
+ this._subscriptions = []
155
+ }
156
+
157
+ /**
158
+ * Dispose the FilterQuery, cleaning up signal subscriptions
159
+ *
160
+ * Call this when the FilterQuery is no longer needed to prevent memory leaks.
161
+ */
162
+ dispose() {
163
+ this._cleanupSubscriptions()
164
+ this._signals = null
165
+ this._options = null
166
+ }
167
+
168
+ /**
169
+ * Invalidate the cached options (forces refresh on next getOptions)
170
+ */
171
+ invalidate() {
172
+ this._options = null
173
+ }
174
+
175
+ /**
176
+ * Get options for dropdown
177
+ *
178
+ * @param {Orchestrator} [orchestrator] - Required for entity source
179
+ * @returns {Promise<Array<{label: string, value: *}>>}
180
+ */
181
+ async getOptions(orchestrator = null) {
182
+ // Return cached options if available
183
+ if (this._options !== null) {
184
+ return this._options
185
+ }
186
+
187
+ let items = []
188
+
189
+ if (this.source === 'entity') {
190
+ items = await this._fetchFromEntity(orchestrator)
191
+ } else if (this.source === 'field') {
192
+ items = this._extractFromField()
193
+ }
194
+
195
+ // Map items to { label, value } format
196
+ let options = items.map(item => ({
197
+ label: resolveValue(item, this.label),
198
+ value: resolveValue(item, this.value)
199
+ }))
200
+
201
+ // Apply transform if provided
202
+ if (typeof this.transform === 'function') {
203
+ options = this.transform(options)
204
+ }
205
+
206
+ // Cache the result
207
+ this._options = options
208
+
209
+ return options
210
+ }
211
+
212
+ /**
213
+ * Fetch items from entity manager
214
+ *
215
+ * @param {Orchestrator} orchestrator
216
+ * @returns {Promise<Array>}
217
+ * @private
218
+ */
219
+ async _fetchFromEntity(orchestrator) {
220
+ if (!orchestrator) {
221
+ throw new Error('FilterQuery: orchestrator is required for entity source')
222
+ }
223
+
224
+ const manager = orchestrator.get(this.entity)
225
+ if (!manager) {
226
+ throw new Error(`FilterQuery: entity "${this.entity}" not found in orchestrator`)
227
+ }
228
+
229
+ // Fetch all items (large page_size to get everything)
230
+ const result = await manager.list({ page_size: 1000 })
231
+ return result.items || []
232
+ }
233
+
234
+ /**
235
+ * Extract unique values from parent manager's cached items
236
+ *
237
+ * @returns {Array}
238
+ * @private
239
+ */
240
+ _extractFromField() {
241
+ if (!this.parentManager) {
242
+ throw new Error('FilterQuery: parentManager is required for field source')
243
+ }
244
+
245
+ // Get cached items from parent manager
246
+ const cachedItems = this.parentManager._cache || []
247
+
248
+ if (cachedItems.length === 0) {
249
+ return []
250
+ }
251
+
252
+ // Extract unique values from the field
253
+ const seen = new Set()
254
+ const items = []
255
+
256
+ for (const item of cachedItems) {
257
+ const fieldValue = resolveValue(item, this.field)
258
+ if (fieldValue == null) continue
259
+
260
+ // Create a unique key for deduplication
261
+ const key = typeof fieldValue === 'object' ? JSON.stringify(fieldValue) : fieldValue
262
+
263
+ if (!seen.has(key)) {
264
+ seen.add(key)
265
+ // For field source, the item IS the value (create a simple object)
266
+ items.push({
267
+ [this.field]: fieldValue,
268
+ // Also set 'name' and 'id' defaults for simple resolution
269
+ name: fieldValue,
270
+ id: fieldValue
271
+ })
272
+ }
273
+ }
274
+
275
+ return items
276
+ }
277
+ }