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,8 +1,9 @@
1
1
  <script setup>
2
2
  /**
3
3
  * AuthPanel - Auth collector display with activity indicator
4
+ * Each event has its own timer and fades out before destruction
4
5
  */
5
- import { onMounted, computed } from 'vue'
6
+ import { onMounted, ref, onUnmounted } from 'vue'
6
7
  import ObjectTree from '../ObjectTree.vue'
7
8
 
8
9
  const props = defineProps({
@@ -10,19 +11,101 @@ const props = defineProps({
10
11
  entries: { type: Array, required: true }
11
12
  })
12
13
 
13
- // Mark events as seen when panel is viewed (badge resets but events stay visible)
14
+ // Local events with fade state
15
+ const localEvents = ref([])
16
+ const timers = new Map()
17
+ let unsubscribe = null
18
+
19
+ // Mark events as seen when panel is viewed
14
20
  onMounted(() => {
15
21
  props.collector.markSeen?.()
22
+ syncEvents()
23
+
24
+ // Subscribe to collector changes
25
+ if (props.collector.onNotify) {
26
+ unsubscribe = props.collector.onNotify(syncEvents)
27
+ }
28
+ })
29
+
30
+ onUnmounted(() => {
31
+ // Unsubscribe from collector
32
+ if (unsubscribe) unsubscribe()
33
+
34
+ // Clear all timers
35
+ for (const timer of timers.values()) {
36
+ clearTimeout(timer.fade)
37
+ clearTimeout(timer.destroy)
38
+ }
39
+ timers.clear()
16
40
  })
17
41
 
18
- // Get all recent events (stacked display)
19
- const recentEvents = computed(() => props.collector.getRecentEvents?.() || [])
42
+ /**
43
+ * Sync local events with collector and setup timers
44
+ */
45
+ function syncEvents() {
46
+ const collectorEvents = props.collector.getRecentEvents?.() || []
47
+ const ttl = props.collector._eventTtl || 60000
48
+ const fadeTime = 3000 // Start fading 3s before destruction
49
+ const now = Date.now()
50
+
51
+ // Add new events
52
+ for (const event of collectorEvents) {
53
+ if (!localEvents.value.find(e => e.id === event.id)) {
54
+ const age = now - event.timestamp.getTime()
55
+ const remaining = ttl - age
56
+
57
+ if (remaining > 0) {
58
+ const localEvent = { ...event, fading: false }
59
+ localEvents.value.unshift(localEvent)
60
+
61
+ // Setup fade timer
62
+ const fadeDelay = Math.max(0, remaining - fadeTime)
63
+ const fadeTimer = setTimeout(() => {
64
+ localEvent.fading = true
65
+ }, fadeDelay)
66
+
67
+ // Setup destroy timer
68
+ const destroyTimer = setTimeout(() => {
69
+ removeEvent(event.id)
70
+ }, remaining)
71
+
72
+ timers.set(event.id, { fade: fadeTimer, destroy: destroyTimer })
73
+ }
74
+ }
75
+ }
76
+
77
+ // Remove events that no longer exist in collector
78
+ const collectorIds = new Set(collectorEvents.map(e => e.id))
79
+ localEvents.value = localEvents.value.filter(e => {
80
+ if (!collectorIds.has(e.id)) {
81
+ clearEventTimers(e.id)
82
+ return false
83
+ }
84
+ return true
85
+ })
86
+ }
87
+
88
+ function removeEvent(id) {
89
+ clearEventTimers(id)
90
+ localEvents.value = localEvents.value.filter(e => e.id !== id)
91
+ }
92
+
93
+ function clearEventTimers(id) {
94
+ const timer = timers.get(id)
95
+ if (timer) {
96
+ clearTimeout(timer.fade)
97
+ clearTimeout(timer.destroy)
98
+ timers.delete(id)
99
+ }
100
+ }
20
101
 
21
102
  function getIcon(type) {
22
103
  const icons = {
23
104
  user: 'pi-user',
105
+ impersonated: 'pi-user-edit',
24
106
  token: 'pi-key',
25
- permissions: 'pi-shield',
107
+ 'user-permissions': 'pi-shield',
108
+ permissions: 'pi-list',
26
109
  hierarchy: 'pi-sitemap',
27
110
  'role-permissions': 'pi-lock',
28
111
  adapter: 'pi-cog'
@@ -39,7 +122,8 @@ function getEventIcon(type) {
39
122
  login: 'pi-sign-in',
40
123
  logout: 'pi-sign-out',
41
124
  impersonate: 'pi-user-edit',
42
- 'impersonate-stop': 'pi-user'
125
+ 'impersonate-stop': 'pi-user',
126
+ 'login-error': 'pi-times-circle'
43
127
  }
44
128
  return icons[type] || 'pi-info-circle'
45
129
  }
@@ -53,8 +137,6 @@ function getEventLabel(event) {
53
137
  }
54
138
  }
55
139
  if (event.type === 'impersonate' && event.data) {
56
- // Payload structure: { target: { username }, original: { username } }
57
- // Or signal object: { data: { target: { username } } }
58
140
  const data = event.data.data || event.data
59
141
  const username = data.target?.username
60
142
  || data.username
@@ -70,11 +152,20 @@ function getEventLabel(event) {
70
152
  return `Back to ${username}`
71
153
  }
72
154
  }
155
+ if (event.type === 'login-error' && event.data) {
156
+ const username = event.data.username
157
+ const error = event.data.error || 'Invalid credentials'
158
+ if (username) {
159
+ return `Login failed: ${username} - ${error}`
160
+ }
161
+ return `Login failed: ${error}`
162
+ }
73
163
  const labels = {
74
164
  login: 'User logged in',
75
165
  logout: 'User logged out',
76
166
  impersonate: 'Impersonating user',
77
- 'impersonate-stop': 'Stopped impersonation'
167
+ 'impersonate-stop': 'Stopped impersonation',
168
+ 'login-error': 'Login failed'
78
169
  }
79
170
  return labels[event.type] || event.type
80
171
  }
@@ -83,8 +174,13 @@ function getEventLabel(event) {
83
174
  <template>
84
175
  <div class="auth-panel">
85
176
  <!-- Invariant entries (user, token, permissions, adapter) -->
86
- <div v-for="(entry, idx) in entries" :key="idx" class="auth-item">
87
- <div class="auth-header">
177
+ <div
178
+ v-for="(entry, idx) in entries"
179
+ :key="idx"
180
+ class="auth-item"
181
+ :class="{ 'auth-item--impersonated': entry.type === 'impersonated' }"
182
+ >
183
+ <div class="auth-header" :class="{ 'auth-header--impersonated': entry.type === 'impersonated' }">
88
184
  <i :class="['pi', getIcon(entry.type)]" />
89
185
  <span class="auth-label">{{ entry.label || entry.type }}</span>
90
186
  </div>
@@ -92,17 +188,19 @@ function getEventLabel(event) {
92
188
  <ObjectTree v-else-if="entry.data" :data="entry.data" :maxDepth="4" />
93
189
  </div>
94
190
 
95
- <!-- Recent auth events (stacked below, newest first) -->
96
- <div
97
- v-for="event in recentEvents"
98
- :key="event.id"
99
- class="auth-activity"
100
- :class="event.type"
101
- >
102
- <i :class="['pi', getEventIcon(event.type)]" />
103
- <span>{{ getEventLabel(event) }}</span>
104
- <span class="auth-time">{{ formatTime(event.timestamp) }}</span>
105
- </div>
191
+ <!-- Recent auth events with individual timers -->
192
+ <TransitionGroup name="event">
193
+ <div
194
+ v-for="event in localEvents"
195
+ :key="event.id"
196
+ class="auth-activity"
197
+ :class="[event.type, { fading: event.fading }]"
198
+ >
199
+ <i :class="['pi', getEventIcon(event.type)]" />
200
+ <span>{{ getEventLabel(event) }}</span>
201
+ <span class="auth-time">{{ formatTime(event.timestamp) }}</span>
202
+ </div>
203
+ </TransitionGroup>
106
204
  </div>
107
205
  </template>
108
206
 
@@ -113,6 +211,7 @@ function getEventLabel(event) {
113
211
  flex-direction: column;
114
212
  gap: 8px;
115
213
  }
214
+
116
215
  /* Activity indicator */
117
216
  .auth-activity {
118
217
  display: flex;
@@ -122,8 +221,29 @@ function getEventLabel(event) {
122
221
  border-radius: 4px;
123
222
  font-size: 12px;
124
223
  font-weight: 600;
125
- animation: pulse 2s ease-in-out infinite;
224
+ transition: opacity 3s ease-out, transform 0.3s ease-out;
225
+ }
226
+
227
+ .auth-activity.fading {
228
+ opacity: 0;
229
+ }
230
+
231
+ /* TransitionGroup animations */
232
+ .event-enter-active {
233
+ transition: all 0.3s ease-out;
234
+ }
235
+ .event-leave-active {
236
+ transition: all 0.5s ease-in;
237
+ }
238
+ .event-enter-from {
239
+ opacity: 0;
240
+ transform: translateY(-10px);
126
241
  }
242
+ .event-leave-to {
243
+ opacity: 0;
244
+ transform: translateY(30px);
245
+ }
246
+
127
247
  .auth-activity.login {
128
248
  background: linear-gradient(90deg, rgba(34, 197, 94, 0.2) 0%, rgba(34, 197, 94, 0.05) 100%);
129
249
  border-left: 3px solid #22c55e;
@@ -144,21 +264,28 @@ function getEventLabel(event) {
144
264
  border-left: 3px solid #a1a1aa;
145
265
  color: #a1a1aa;
146
266
  }
267
+ .auth-activity.login-error {
268
+ background: linear-gradient(90deg, rgba(239, 68, 68, 0.2) 0%, rgba(239, 68, 68, 0.05) 100%);
269
+ border-left: 3px solid #ef4444;
270
+ color: #ef4444;
271
+ }
272
+
147
273
  .auth-time {
148
274
  margin-left: auto;
149
275
  font-size: 10px;
150
276
  opacity: 0.6;
151
277
  font-weight: 400;
152
278
  }
153
- @keyframes pulse {
154
- 0%, 100% { opacity: 1; }
155
- 50% { opacity: 0.7; }
156
- }
279
+
157
280
  .auth-item {
158
281
  background: #27272a;
159
282
  border-radius: 4px;
160
283
  padding: 8px;
161
284
  }
285
+ .auth-item--impersonated {
286
+ background: linear-gradient(90deg, rgba(249, 115, 22, 0.15) 0%, rgba(249, 115, 22, 0.05) 100%);
287
+ border-left: 3px solid #f97316;
288
+ }
162
289
  .auth-header {
163
290
  display: flex;
164
291
  align-items: center;
@@ -166,6 +293,9 @@ function getEventLabel(event) {
166
293
  margin-bottom: 6px;
167
294
  color: #10b981;
168
295
  }
296
+ .auth-header--impersonated {
297
+ color: #f97316;
298
+ }
169
299
  .auth-label {
170
300
  font-weight: 600;
171
301
  }
@@ -110,7 +110,7 @@ function getCapabilityLabel(cap) {
110
110
  <div v-if="entries[0]?.type === 'status'" class="entities-status">
111
111
  {{ entries[0].message }}
112
112
  </div>
113
- <div v-else v-for="entity in entries" :key="entity.name" class="entity-item" :class="{ 'entity-active': entity.hasActivity }">
113
+ <div v-else v-for="entity in entries" :key="entity.name" class="entity-item" :class="{ 'entity-active': entity.hasActivity, 'entity-system': entity.system }">
114
114
  <div class="entity-header" @click="toggleExpand(entity.name)">
115
115
  <button class="entity-expand">
116
116
  <i :class="['pi', isExpanded(entity.name) ? 'pi-chevron-down' : 'pi-chevron-right']" />
@@ -353,6 +353,22 @@ function getCapabilityLabel(cap) {
353
353
  border-left-color: #f59e0b;
354
354
  background: linear-gradient(90deg, rgba(245, 158, 11, 0.1) 0%, #27272a 30%);
355
355
  }
356
+ .entity-system {
357
+ border-left-color: #ef4444;
358
+ background: linear-gradient(90deg, rgba(239, 68, 68, 0.1) 0%, #27272a 30%);
359
+ }
360
+ .entity-system .entity-header {
361
+ color: #ef4444;
362
+ }
363
+ .entity-system .entity-header:hover {
364
+ color: #f87171;
365
+ }
366
+ .entity-system .entity-name::after {
367
+ content: ' (system)';
368
+ font-size: 9px;
369
+ color: #f87171;
370
+ font-weight: normal;
371
+ }
356
372
  .entity-header {
357
373
  display: flex;
358
374
  align-items: center;
@@ -60,8 +60,11 @@ export class EntityManager {
60
60
  readOnly = false, // If true, canCreate/canUpdate/canDelete return false
61
61
  warmup = true, // If true, cache is preloaded at boot via DeferredRegistry
62
62
  authSensitive, // If true, auto-invalidate datalayer on auth events (auto-inferred from storage.requiresAuth if not set)
63
+ system = false, // If true, marks entity as system-provided (roles, users)
63
64
  // Scope control
64
65
  scopeWhitelist = null, // Array of scopes/modules that can bypass restrictions
66
+ // Ownership (for record-level access control)
67
+ isOwn = null, // (record, user) => boolean - check if user owns the record
65
68
  // Relations
66
69
  children = {}, // { roles: { entity: 'roles', endpoint?: ':id/roles' } }
67
70
  parent = null, // { entity: 'users', foreignKey: 'user_id' }
@@ -88,10 +91,14 @@ export class EntityManager {
88
91
  this._warmup = warmup
89
92
  // Auto-infer authSensitive from storage.requiresAuth if not explicitly set
90
93
  this._authSensitive = authSensitive ?? this._getStorageRequiresAuth()
94
+ this._system = system
91
95
 
92
96
  // Scope control
93
97
  this._scopeWhitelist = scopeWhitelist
94
98
 
99
+ // Ownership
100
+ this._isOwn = isOwn
101
+
95
102
  // Relations
96
103
  this._children = children
97
104
  this._parent = parent
@@ -368,48 +375,54 @@ export class EntityManager {
368
375
  }
369
376
 
370
377
  /**
371
- * Build permission string for an action based on entity_permissions config
378
+ * Build permission string for an entity action
379
+ *
380
+ * Format: entity:{name}:{action}
381
+ * Examples: entity:books:read, entity:users:create
372
382
  *
373
- * The permission format depends on the security config:
374
- * - entity_permissions: false 'entity:read'
375
- * - entity_permissions: true 'books:read'
376
- * - entity_permissions: ['books'] 'books:read' for books, 'entity:read' for others
383
+ * Role permissions can use wildcards to match:
384
+ * - entity:*:readread any entity
385
+ * - entity:books:*any action on books
386
+ * - entity:**all entity permissions
377
387
  *
378
388
  * @param {string} action - Action name (read, create, update, delete, list)
379
389
  * @returns {string} - Permission string
380
390
  * @private
381
391
  */
382
392
  _getPermissionString(action) {
383
- const checker = this.authAdapter._securityChecker
384
- if (!checker) return `entity:${action}`
385
-
386
- const config = checker.entityPermissions
387
- if (config === false) return `entity:${action}`
388
- if (config === true) return `${this.name}:${action}`
389
- if (Array.isArray(config) && config.includes(this.name)) {
390
- return `${this.name}:${action}`
391
- }
392
- return `entity:${action}`
393
+ return `entity:${this.name}:${action}`
393
394
  }
394
395
 
395
396
  /**
396
- * Check permission using isGranted() if security is configured
397
+ * Get the current authenticated user
397
398
  *
398
- * Falls back to traditional canPerform()/canAccessRecord() if no SecurityChecker.
399
- * This method respects the entity_permissions config for granular permissions.
399
+ * Tries authAdapter.getCurrentUser() first, then falls back to kernel's authAdapter.
400
400
  *
401
- * @param {string} action - Action to check (read, create, update, delete, list)
402
- * @param {object} [subject] - Optional subject for context-aware checks
403
- * @returns {boolean}
401
+ * @returns {object|null} Current user or null
402
+ * @private
404
403
  */
405
- checkPermission(action, subject = null) {
406
- // If isGranted is available, use it
407
- if (this.authAdapter.isGranted && this.authAdapter._securityChecker) {
408
- const perm = this._getPermissionString(action)
409
- return this.authAdapter.isGranted(perm, subject)
404
+ _getCurrentUser() {
405
+ // Try authAdapter.getCurrentUser() first (EntityAuthAdapter subclass)
406
+ if (typeof this.authAdapter?.getCurrentUser === 'function') {
407
+ try {
408
+ return this.authAdapter.getCurrentUser()
409
+ } catch {
410
+ // Fallback if not implemented
411
+ }
410
412
  }
411
- // Fallback to traditional method
412
- return this.canAccess(action, subject)
413
+ // Fallback to kernel's authAdapter
414
+ return this._orchestrator?.kernel?.options?.authAdapter?.getUser?.() ?? null
415
+ }
416
+
417
+ /**
418
+ * Check if SecurityChecker is configured via authAdapter
419
+ * Only returns true when adapter has _securityChecker set,
420
+ * allowing legacy canPerform() adapters to work correctly.
421
+ * @returns {boolean}
422
+ * @private
423
+ */
424
+ _hasSecurityChecker() {
425
+ return this.authAdapter?._securityChecker != null
413
426
  }
414
427
 
415
428
  /**
@@ -424,9 +437,22 @@ export class EntityManager {
424
437
  * Check if the current user can perform an action, optionally on a specific record
425
438
  *
426
439
  * This is the primary permission check method. It combines:
427
- * 1. Local restrictions (readOnly, scopeWhitelist)
428
- * 2. Scope check via AuthAdapter.canPerform() - can user do this action type?
429
- * 3. Silo check via AuthAdapter.canAccessRecord() - can user access this record?
440
+ * 1. Local restrictions (readOnly)
441
+ * 2. Ownership check via isOwn callback (if configured)
442
+ * 3. Permission check via isGranted() (if SecurityChecker configured)
443
+ * OR legacy canPerform()/canAccessRecord() fallback
444
+ *
445
+ * Permission format: entity:{name}:{action}
446
+ * Wildcard examples:
447
+ * - entity:*:read → can read any entity
448
+ * - entity:books:* → any action on books
449
+ * - entity:** → all entity permissions
450
+ *
451
+ * Ownership pattern:
452
+ * - Configure isOwn callback: (record, user) => boolean
453
+ * - When user owns a record, check entity-own:{entity}:{action} permission
454
+ * - Example: entity-own:loans:update allows owner to update their loans
455
+ * - Use entity-own:{entity}:** to allow all actions on owned records
430
456
  *
431
457
  * @param {string} action - Action to check: 'read', 'create', 'update', 'delete', 'list'
432
458
  * @param {object} [record] - Optional: specific record to check (for silo validation)
@@ -442,6 +468,16 @@ export class EntityManager {
442
468
  * manager.canAccess('read', item) // Can user see this specific item?
443
469
  * manager.canAccess('update', item) // Can user edit this specific item?
444
470
  * manager.canAccess('delete', item) // Can user delete this specific item?
471
+ *
472
+ * @example
473
+ * // Ownership pattern with permissions
474
+ * const loansManager = new EntityManager({
475
+ * name: 'loans',
476
+ * isOwn: (record, user) => record.user_id === user?.id,
477
+ * storage: loansStorage
478
+ * })
479
+ * // Role config: { permissions: ['entity-own:loans:**'] }
480
+ * loansManager.canAccess('update', myLoan) // true if I own the loan
445
481
  */
446
482
  canAccess(action, record = null) {
447
483
  // 1. Check readOnly restriction for write actions
@@ -449,13 +485,31 @@ export class EntityManager {
449
485
  return false
450
486
  }
451
487
 
452
- // 2. Scope check: can user perform this action on this entity type?
488
+ // 2. Ownership check: if user owns the record, check entity-own permission
489
+ if (record && this._isOwn && this._hasSecurityChecker()) {
490
+ const user = this._getCurrentUser()
491
+ if (user && this._isOwn(record, user)) {
492
+ // Owner - check entity-own:{entity}:{action} permission
493
+ const ownPerm = `entity-own:${this.name}:${action}`
494
+ if (this.authAdapter.isGranted(ownPerm, record)) {
495
+ return true
496
+ }
497
+ }
498
+ }
499
+
500
+ // 3. Use isGranted() with entity:name:action format when available
501
+ if (this._hasSecurityChecker()) {
502
+ const perm = this._getPermissionString(action)
503
+ return this.authAdapter.isGranted(perm, record)
504
+ }
505
+
506
+ // 4. Legacy fallback: canPerform() + canAccessRecord()
453
507
  const canPerformAction = this.authAdapter.canPerform(this.name, action)
454
508
  if (!canPerformAction) {
455
509
  return false
456
510
  }
457
511
 
458
- // 3. Silo check: if record provided, can user access this specific record?
512
+ // 5. Silo check: if record provided, can user access this specific record?
459
513
  if (record !== null) {
460
514
  return this.authAdapter.canAccessRecord(this.name, record)
461
515
  }
@@ -485,6 +539,14 @@ export class EntityManager {
485
539
  return this._readOnly
486
540
  }
487
541
 
542
+ /**
543
+ * Check if entity is system-provided (roles, users)
544
+ * @returns {boolean}
545
+ */
546
+ get system() {
547
+ return this._system
548
+ }
549
+
488
550
  /**
489
551
  * Check if user can create new entities
490
552
  * Delegates to canAccess('create')
@@ -643,6 +705,91 @@ export class EntityManager {
643
705
  .map(([name, config]) => ({ name, ...config }))
644
706
  }
645
707
 
708
+ // ============ REFERENCE OPTIONS ============
709
+
710
+ /**
711
+ * Resolve reference options for a field
712
+ *
713
+ * If the field has a `reference` property, fetches data from the referenced
714
+ * entity and returns options array for select/dropdown.
715
+ *
716
+ * @param {string} fieldName - Field name
717
+ * @returns {Promise<Array<{label: string, value: any}>>} - Options array
718
+ *
719
+ * @example
720
+ * // Field config: { type: 'select', reference: { entity: 'roles', labelField: 'label' } }
721
+ * const options = await manager.resolveReferenceOptions('role')
722
+ * // Returns: [{ label: 'Admin', value: 'ROLE_ADMIN' }, { label: 'User', value: 'ROLE_USER' }]
723
+ */
724
+ async resolveReferenceOptions(fieldName) {
725
+ const fieldConfig = this._fields[fieldName]
726
+ if (!fieldConfig) {
727
+ console.warn(`[EntityManager:${this.name}] Unknown field '${fieldName}'`)
728
+ return []
729
+ }
730
+
731
+ // If field has static options, return them
732
+ if (fieldConfig.options && !fieldConfig.reference) {
733
+ return fieldConfig.options
734
+ }
735
+
736
+ // If no reference, return empty
737
+ if (!fieldConfig.reference) {
738
+ return []
739
+ }
740
+
741
+ // Need orchestrator to access other managers
742
+ if (!this._orchestrator) {
743
+ console.warn(`[EntityManager:${this.name}] No orchestrator, cannot resolve reference for '${fieldName}'`)
744
+ return fieldConfig.options || []
745
+ }
746
+
747
+ const { entity, labelField, valueField } = fieldConfig.reference
748
+ const refManager = this._orchestrator.get(entity)
749
+
750
+ if (!refManager) {
751
+ console.warn(`[EntityManager:${this.name}] Referenced entity '${entity}' not found`)
752
+ return fieldConfig.options || []
753
+ }
754
+
755
+ try {
756
+ // Fetch all items from referenced entity
757
+ const { items } = await refManager.list({ limit: 1000 })
758
+
759
+ // Build options array
760
+ const refLabelField = labelField || refManager.labelField || 'label'
761
+ const refValueField = valueField || refManager.idField || 'id'
762
+
763
+ return items.map(item => ({
764
+ label: item[refLabelField] ?? item[refValueField],
765
+ value: item[refValueField]
766
+ }))
767
+ } catch (error) {
768
+ console.error(`[EntityManager:${this.name}] Failed to resolve reference for '${fieldName}':`, error)
769
+ return fieldConfig.options || []
770
+ }
771
+ }
772
+
773
+ /**
774
+ * Resolve all reference options for form fields
775
+ *
776
+ * Returns a map of fieldName -> options for all fields with references.
777
+ *
778
+ * @returns {Promise<Map<string, Array<{label: string, value: any}>>>}
779
+ */
780
+ async resolveAllReferenceOptions() {
781
+ const optionsMap = new Map()
782
+
783
+ for (const [fieldName, fieldConfig] of Object.entries(this._fields)) {
784
+ if (fieldConfig.reference) {
785
+ const options = await this.resolveReferenceOptions(fieldName)
786
+ optionsMap.set(fieldName, options)
787
+ }
788
+ }
789
+
790
+ return optionsMap
791
+ }
792
+
646
793
  // ============ SEVERITY MAPS ============
647
794
 
648
795
  /**
@@ -1263,7 +1410,9 @@ export class EntityManager {
1263
1410
  */
1264
1411
  get isCacheEnabled() {
1265
1412
  if (this.effectiveThreshold <= 0) return false
1266
- if (this.storage?.supportsCaching === false) return false
1413
+ // Check capabilities (instance getter or static)
1414
+ const caps = this.storage?.capabilities || this.storage?.constructor?.capabilities
1415
+ if (caps?.supportsCaching === false) return false
1267
1416
  if (!this.storageSupportsTotal) return false
1268
1417
  return true
1269
1418
  }