qdadm 0.34.0 → 0.36.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.34.0",
3
+ "version": "0.36.0",
4
4
  "description": "Vue 3 framework for admin dashboards with PrimeVue",
5
5
  "author": "quazardous",
6
6
  "license": "MIT",
@@ -36,6 +36,7 @@ export class AuthCollector extends Collector {
36
36
  constructor(options = {}) {
37
37
  super(options)
38
38
  this._authAdapter = null
39
+ this._securityChecker = null
39
40
  this._ctx = null
40
41
  this._signalCleanups = []
41
42
  // Activity tracking for login/logout events
@@ -58,6 +59,7 @@ export class AuthCollector extends Collector {
58
59
  // Try alternate locations
59
60
  this._authAdapter = ctx.authAdapter
60
61
  }
62
+ this._securityChecker = ctx.security
61
63
  this._setupSignals()
62
64
  }
63
65
 
@@ -164,6 +166,7 @@ export class AuthCollector extends Collector {
164
166
  }
165
167
  this._signalCleanups = []
166
168
  this._authAdapter = null
169
+ this._securityChecker = null
167
170
  this._ctx = null
168
171
  }
169
172
 
@@ -292,6 +295,29 @@ export class AuthCollector extends Collector {
292
295
  // Permissions not available
293
296
  }
294
297
 
298
+ // Role hierarchy & permissions (lazy fetch - securityChecker created after module connect)
299
+ try {
300
+ const securityChecker = this._securityChecker || this._ctx?.security
301
+ const hierarchy = securityChecker?.roleHierarchy?.map
302
+ if (hierarchy && Object.keys(hierarchy).length > 0) {
303
+ entries.push({
304
+ type: 'hierarchy',
305
+ label: 'Role Hierarchy',
306
+ data: hierarchy
307
+ })
308
+ }
309
+ const rolePermissions = securityChecker?.rolePermissions
310
+ if (rolePermissions && Object.keys(rolePermissions).length > 0) {
311
+ entries.push({
312
+ type: 'role-permissions',
313
+ label: 'Role Permissions',
314
+ data: rolePermissions
315
+ })
316
+ }
317
+ } catch (e) {
318
+ // Security checker not available
319
+ }
320
+
295
321
  // Adapter info
296
322
  entries.push({
297
323
  type: 'adapter',
@@ -77,12 +77,12 @@ export class EntitiesCollector extends Collector {
77
77
  })
78
78
  this._signalCleanups.push(entityCleanup)
79
79
 
80
- // Listen to cache invalidation
81
- const cacheCleanup = signals.on('cache:entity:invalidated', () => {
80
+ // Listen to entity data changes (CRUD operations)
81
+ const dataCleanup = signals.on('entity:data-invalidate', () => {
82
82
  this._lastUpdate = Date.now()
83
83
  this.notifyChange()
84
84
  })
85
- this._signalCleanups.push(cacheCleanup)
85
+ this._signalCleanups.push(dataCleanup)
86
86
  }
87
87
 
88
88
  /**
@@ -248,6 +248,9 @@ export class EntitiesCollector extends Collector {
248
248
  canList: manager.canList?.() ?? true
249
249
  },
250
250
 
251
+ // Auth sensitivity (auto-invalidates on auth events)
252
+ authSensitive: manager._authSensitive ?? false,
253
+
251
254
  // Warmup
252
255
  warmup: {
253
256
  enabled: manager.warmupEnabled ?? false
@@ -18,8 +18,8 @@ import { Collector } from './Collector.js'
18
18
  * Collector for SignalBus events
19
19
  *
20
20
  * Records all signals with their name, data, and source for debugging.
21
- * Uses the `*:*` wildcard pattern to capture all signals following the
22
- * domain:action naming convention.
21
+ * Uses the `**` wildcard pattern to capture all signals including
22
+ * multi-segment names (e.g., entity:data-invalidate, auth:impersonate:start).
23
23
  */
24
24
  export class SignalCollector extends Collector {
25
25
  /**
@@ -43,8 +43,8 @@ export class SignalCollector extends Collector {
43
43
 
44
44
  // Subscribe to all signals using wildcard pattern
45
45
  // QuarKernel supports wildcards with the configured delimiter (:)
46
- // '*:*' matches any domain:action signal
47
- this._unsubscribe = ctx.signals.on('*:*', (event) => {
46
+ // '**' matches all signals including multi-segment (entity:data-invalidate)
47
+ this._unsubscribe = ctx.signals.on('**', (event) => {
48
48
  this.record({
49
49
  name: event.name,
50
50
  data: event.data,
@@ -12,7 +12,7 @@
12
12
  import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
13
13
  import Badge from 'primevue/badge'
14
14
  import Button from 'primevue/button'
15
- import { ZonesPanel, AuthPanel, EntitiesPanel, ToastsPanel, EntriesPanel } from './panels'
15
+ import { ZonesPanel, AuthPanel, EntitiesPanel, ToastsPanel, EntriesPanel, SignalsPanel } from './panels'
16
16
 
17
17
  const props = defineProps({
18
18
  bridge: { type: Object, required: true },
@@ -658,6 +658,13 @@ function getCollectorColor(name) {
658
658
  @update="notifyBridge"
659
659
  />
660
660
 
661
+ <!-- Signals collector -->
662
+ <SignalsPanel
663
+ v-else-if="currentCollector.name === 'signals' || currentCollector.name === 'SignalCollector'"
664
+ :collector="currentCollector.collector"
665
+ :entries="currentCollector.entries"
666
+ />
667
+
661
668
  <div v-else-if="currentCollector.entries.length === 0" class="debug-empty">
662
669
  <i class="pi pi-inbox" />
663
670
  <span>No entries</span>
@@ -23,6 +23,8 @@ function getIcon(type) {
23
23
  user: 'pi-user',
24
24
  token: 'pi-key',
25
25
  permissions: 'pi-shield',
26
+ hierarchy: 'pi-sitemap',
27
+ 'role-permissions': 'pi-lock',
26
28
  adapter: 'pi-cog'
27
29
  }
28
30
  return icons[type] || 'pi-info-circle'
@@ -138,6 +138,11 @@ function getCapabilityLabel(cap) {
138
138
  class="pi pi-eye perm-icon-readonly"
139
139
  title="Read-only entity"
140
140
  />
141
+ <i
142
+ v-if="entity.authSensitive"
143
+ class="pi pi-shield perm-icon-auth-sensitive"
144
+ title="Auth-sensitive"
145
+ />
141
146
  </div>
142
147
  <span class="entity-label">{{ entity.label }}</span>
143
148
  <span v-if="entity.cache.enabled" class="entity-cache" :class="{ 'entity-cache-valid': entity.cache.valid }">
@@ -397,6 +402,10 @@ function getCapabilityLabel(cap) {
397
402
  color: #f59e0b;
398
403
  margin-left: 2px;
399
404
  }
405
+ .perm-icon-auth-sensitive {
406
+ color: #8b5cf6;
407
+ margin-left: 2px;
408
+ }
400
409
  .entity-label {
401
410
  color: #a1a1aa;
402
411
  font-size: 11px;
@@ -46,6 +46,7 @@ async function copyEntry(entry, idx) {
46
46
  :class="{ 'entry-new': entry._isNew }"
47
47
  >
48
48
  <div class="entry-meta">
49
+ <span v-if="entry._isNew" class="entry-new-dot" title="New (unseen)" />
49
50
  <span class="entry-time">{{ formatTime(entry.timestamp) }}</span>
50
51
  <span v-if="entry.name" class="entry-name">{{ entry.name }}</span>
51
52
  </div>
@@ -62,6 +63,7 @@ async function copyEntry(entry, idx) {
62
63
  :class="{ 'entry-new': entry._isNew }"
63
64
  >
64
65
  <div class="entry-header">
66
+ <span v-if="entry._isNew" class="entry-new-dot" title="New (unseen)" />
65
67
  <span class="entry-time">{{ formatTime(entry.timestamp) }}</span>
66
68
  <span v-if="entry.name" class="entry-name">{{ entry.name }}</span>
67
69
  <span v-if="entry.message" class="entry-message">{{ entry.message }}</span>
@@ -98,9 +100,6 @@ async function copyEntry(entry, idx) {
98
100
  .entry-h:last-child {
99
101
  border-right: none;
100
102
  }
101
- .entry-h.entry-new {
102
- border-left: 3px solid #8b5cf6;
103
- }
104
103
 
105
104
  /* Vertical entries (right/fullscreen mode) */
106
105
  .entries-v {
@@ -118,7 +117,7 @@ async function copyEntry(entry, idx) {
118
117
  /* New entry indicator */
119
118
  .entry-new {
120
119
  position: relative;
121
- background: rgba(139, 92, 246, 0.08);
120
+ background: rgba(245, 158, 11, 0.08);
122
121
  }
123
122
  .entry-v.entry-new::before {
124
123
  content: '';
@@ -127,7 +126,20 @@ async function copyEntry(entry, idx) {
127
126
  top: 0;
128
127
  bottom: 0;
129
128
  width: 3px;
130
- background: #8b5cf6;
129
+ background: #f59e0b;
130
+ }
131
+ .entry-h.entry-new {
132
+ border-left: 3px solid #f59e0b;
133
+ }
134
+
135
+ .entry-new-dot {
136
+ display: inline-block;
137
+ width: 6px;
138
+ height: 6px;
139
+ background: #f59e0b;
140
+ border-radius: 50%;
141
+ margin-right: 4px;
142
+ flex-shrink: 0;
131
143
  }
132
144
 
133
145
  /* Entry parts */
@@ -0,0 +1,335 @@
1
+ <script setup>
2
+ /**
3
+ * SignalsPanel - Debug panel for signals with pattern filter
4
+ *
5
+ * Supports QuarKernel wildcard patterns:
6
+ * - auth:** → all auth signals (auth:login, auth:impersonate:start)
7
+ * - cache:** → all cache signals
8
+ * - *:created → all creation signals
9
+ * - ** → all signals (default)
10
+ */
11
+ import { ref, computed, watch } from 'vue'
12
+ import InputText from 'primevue/inputtext'
13
+ import ObjectTree from '../ObjectTree.vue'
14
+
15
+ const props = defineProps({
16
+ collector: { type: Object, required: true },
17
+ entries: { type: Array, required: true }
18
+ })
19
+
20
+ // Filter state - persisted in localStorage
21
+ const STORAGE_KEY = 'qdadm-signals-filter'
22
+ const STORAGE_KEY_MAX = 'qdadm-signals-max'
23
+ const filterPattern = ref(localStorage.getItem(STORAGE_KEY) || '')
24
+ const maxSignals = ref(parseInt(localStorage.getItem(STORAGE_KEY_MAX)) || 50)
25
+
26
+ watch(filterPattern, (val) => {
27
+ localStorage.setItem(STORAGE_KEY, val)
28
+ })
29
+
30
+ watch(maxSignals, (val) => {
31
+ localStorage.setItem(STORAGE_KEY_MAX, String(val))
32
+ })
33
+
34
+ // Convert wildcard pattern to regex
35
+ function wildcardToRegex(pattern) {
36
+ if (!pattern || pattern === '**') return null // No filter
37
+
38
+ // Escape regex special chars except * and :
39
+ let regex = pattern
40
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
41
+ // ** matches anything (including colons)
42
+ .replace(/\*\*/g, '.*')
43
+ // * matches anything except colon
44
+ .replace(/\*/g, '[^:]*')
45
+
46
+ return new RegExp(`^${regex}$`)
47
+ }
48
+
49
+ const filterRegex = computed(() => wildcardToRegex(filterPattern.value.trim()))
50
+
51
+ // Apply filter, max limit, and reverse for top-down (newest first)
52
+ const filteredEntries = computed(() => {
53
+ let result = props.entries
54
+ if (filterRegex.value) {
55
+ result = result.filter(e => filterRegex.value.test(e.name))
56
+ }
57
+ // Apply max limit (slice from end to keep newest)
58
+ if (maxSignals.value > 0 && result.length > maxSignals.value) {
59
+ result = result.slice(-maxSignals.value)
60
+ }
61
+ // Reverse for top-down display (newest first)
62
+ return [...result].reverse()
63
+ })
64
+
65
+ const filterStats = computed(() => {
66
+ const total = props.entries.length
67
+ const shown = filteredEntries.value.length
68
+ return total !== shown ? `${shown}/${total}` : `${total}`
69
+ })
70
+
71
+ // Preset filters
72
+ const presets = [
73
+ { label: 'All', pattern: '' },
74
+ { label: 'data', pattern: 'entity:data-invalidate' },
75
+ { label: 'datalayer', pattern: 'entity:datalayer-invalidate' },
76
+ { label: 'auth', pattern: 'auth:**' },
77
+ { label: 'entity', pattern: 'entity:**' },
78
+ { label: 'toast', pattern: 'toast:**' }
79
+ ]
80
+
81
+ function applyPreset(pattern) {
82
+ filterPattern.value = pattern
83
+ }
84
+
85
+ function formatTime(ts) {
86
+ const d = new Date(ts)
87
+ return d.toLocaleTimeString('en-US', { hour12: false }) + '.' + String(d.getMilliseconds()).padStart(3, '0')
88
+ }
89
+
90
+ // Extract domain from signal name (first segment)
91
+ function getDomain(name) {
92
+ return name.split(':')[0]
93
+ }
94
+
95
+ const domainColors = {
96
+ auth: '#10b981',
97
+ cache: '#f59e0b',
98
+ entity: '#3b82f6',
99
+ toast: '#8b5cf6',
100
+ route: '#06b6d4',
101
+ error: '#ef4444'
102
+ }
103
+
104
+ function getDomainColor(name) {
105
+ const domain = getDomain(name)
106
+ return domainColors[domain] || '#6b7280'
107
+ }
108
+ </script>
109
+
110
+ <template>
111
+ <div class="signals-panel">
112
+ <!-- Filter bar -->
113
+ <div class="signals-filter">
114
+ <div class="signals-filter-input">
115
+ <i class="pi pi-filter" />
116
+ <InputText
117
+ v-model="filterPattern"
118
+ placeholder="Filter: auth:** cache:** *:created"
119
+ class="filter-input"
120
+ />
121
+ <span class="signals-count">{{ filterStats }}</span>
122
+ <span class="signals-max-label">max</span>
123
+ <input
124
+ v-model.number="maxSignals"
125
+ type="number"
126
+ min="10"
127
+ max="500"
128
+ class="max-input"
129
+ />
130
+ </div>
131
+ <div class="signals-presets">
132
+ <button
133
+ v-for="p in presets"
134
+ :key="p.pattern"
135
+ class="preset-btn"
136
+ :class="{ 'preset-active': filterPattern === p.pattern }"
137
+ @click="applyPreset(p.pattern)"
138
+ >
139
+ {{ p.label }}
140
+ </button>
141
+ </div>
142
+ </div>
143
+
144
+ <!-- Entries list -->
145
+ <div class="signals-list">
146
+ <div v-if="filteredEntries.length === 0" class="signals-empty">
147
+ <i class="pi pi-inbox" />
148
+ <span>{{ entries.length === 0 ? 'No signals' : 'No matching signals' }}</span>
149
+ </div>
150
+
151
+ <div
152
+ v-for="(entry, idx) in filteredEntries"
153
+ :key="idx"
154
+ class="signal-entry"
155
+ :class="{ 'signal-new': entry._isNew }"
156
+ >
157
+ <div class="signal-header">
158
+ <span v-if="entry._isNew" class="signal-new-dot" title="New (unseen)" />
159
+ <span class="signal-time">{{ formatTime(entry.timestamp) }}</span>
160
+ <span class="signal-name" :style="{ color: getDomainColor(entry.name) }">
161
+ {{ entry.name }}
162
+ </span>
163
+ </div>
164
+ <div v-if="entry.data && Object.keys(entry.data).length > 0" class="signal-data">
165
+ <ObjectTree :data="entry.data" :expanded="false" />
166
+ </div>
167
+ </div>
168
+ </div>
169
+ </div>
170
+ </template>
171
+
172
+ <style scoped>
173
+ .signals-panel {
174
+ display: flex;
175
+ flex-direction: column;
176
+ height: 100%;
177
+ }
178
+
179
+ .signals-filter {
180
+ padding: 8px 12px;
181
+ background: #27272a;
182
+ border-bottom: 1px solid #3f3f46;
183
+ display: flex;
184
+ flex-direction: column;
185
+ gap: 6px;
186
+ }
187
+
188
+ .signals-filter-input {
189
+ display: flex;
190
+ align-items: center;
191
+ gap: 8px;
192
+ }
193
+
194
+ .signals-filter-input .pi {
195
+ color: #71717a;
196
+ font-size: 12px;
197
+ }
198
+
199
+ .filter-input {
200
+ flex: 1;
201
+ font-size: 12px;
202
+ padding: 4px 8px;
203
+ background: #18181b;
204
+ border: 1px solid #3f3f46;
205
+ border-radius: 4px;
206
+ color: #f4f4f5;
207
+ }
208
+
209
+ .filter-input:focus {
210
+ border-color: #8b5cf6;
211
+ outline: none;
212
+ }
213
+
214
+ .signals-count {
215
+ font-size: 11px;
216
+ color: #71717a;
217
+ min-width: 40px;
218
+ text-align: right;
219
+ }
220
+
221
+ .signals-max-label {
222
+ font-size: 10px;
223
+ color: #52525b;
224
+ margin-left: 8px;
225
+ }
226
+
227
+ .max-input {
228
+ width: 50px;
229
+ font-size: 11px;
230
+ padding: 2px 4px;
231
+ background: #18181b;
232
+ border: 1px solid #3f3f46;
233
+ border-radius: 3px;
234
+ color: #a1a1aa;
235
+ text-align: center;
236
+ }
237
+
238
+ .max-input:focus {
239
+ border-color: #8b5cf6;
240
+ outline: none;
241
+ color: #f4f4f5;
242
+ }
243
+
244
+ .signals-presets {
245
+ display: flex;
246
+ gap: 4px;
247
+ flex-wrap: wrap;
248
+ }
249
+
250
+ .preset-btn {
251
+ padding: 2px 8px;
252
+ font-size: 10px;
253
+ background: #3f3f46;
254
+ border: none;
255
+ border-radius: 3px;
256
+ color: #a1a1aa;
257
+ cursor: pointer;
258
+ }
259
+
260
+ .preset-btn:hover {
261
+ background: #52525b;
262
+ color: #f4f4f5;
263
+ }
264
+
265
+ .preset-active {
266
+ background: #8b5cf6;
267
+ color: white;
268
+ }
269
+
270
+ .signals-list {
271
+ flex: 1;
272
+ overflow-y: auto;
273
+ padding: 4px;
274
+ }
275
+
276
+ .signals-empty {
277
+ display: flex;
278
+ flex-direction: column;
279
+ align-items: center;
280
+ justify-content: center;
281
+ height: 100%;
282
+ color: #71717a;
283
+ gap: 8px;
284
+ }
285
+
286
+ .signal-entry {
287
+ padding: 6px 8px;
288
+ margin-bottom: 2px;
289
+ background: #27272a;
290
+ border-radius: 4px;
291
+ font-size: 12px;
292
+ }
293
+
294
+ .signal-entry:hover {
295
+ background: #3f3f46;
296
+ }
297
+
298
+ .signal-entry.signal-new {
299
+ border-left: 2px solid #f59e0b;
300
+ padding-left: 6px;
301
+ }
302
+
303
+ .signal-new-dot {
304
+ display: inline-block;
305
+ width: 6px;
306
+ height: 6px;
307
+ background: #f59e0b;
308
+ border-radius: 50%;
309
+ margin-right: 4px;
310
+ flex-shrink: 0;
311
+ }
312
+
313
+ .signal-header {
314
+ display: flex;
315
+ align-items: center;
316
+ gap: 8px;
317
+ }
318
+
319
+ .signal-time {
320
+ font-size: 10px;
321
+ color: #71717a;
322
+ font-family: monospace;
323
+ }
324
+
325
+ .signal-name {
326
+ font-weight: 500;
327
+ font-family: monospace;
328
+ }
329
+
330
+ .signal-data {
331
+ margin-top: 4px;
332
+ padding-left: 12px;
333
+ font-size: 11px;
334
+ }
335
+ </style>
@@ -6,3 +6,4 @@ export { default as AuthPanel } from './AuthPanel.vue'
6
6
  export { default as EntitiesPanel } from './EntitiesPanel.vue'
7
7
  export { default as ToastsPanel } from './ToastsPanel.vue'
8
8
  export { default as EntriesPanel } from './EntriesPanel.vue'
9
+ export { default as SignalsPanel } from './SignalsPanel.vue'
@@ -59,6 +59,7 @@ export class EntityManager {
59
59
  localFilterThreshold = null, // Items threshold to switch to local filtering (null = use default)
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
+ authSensitive, // If true, auto-invalidate datalayer on auth events (auto-inferred from storage.requiresAuth if not set)
62
63
  // Scope control
63
64
  scopeWhitelist = null, // Array of scopes/modules that can bypass restrictions
64
65
  // Relations
@@ -85,6 +86,8 @@ export class EntityManager {
85
86
  this.localFilterThreshold = localFilterThreshold
86
87
  this._readOnly = readOnly
87
88
  this._warmup = warmup
89
+ // Auto-infer authSensitive from storage.requiresAuth if not explicitly set
90
+ this._authSensitive = authSensitive ?? this._getStorageRequiresAuth()
88
91
 
89
92
  // Scope control
90
93
  this._scopeWhitelist = scopeWhitelist
@@ -174,6 +177,25 @@ export class EntityManager {
174
177
  this._signals.emitEntity(this.name, action, data)
175
178
  }
176
179
 
180
+ /**
181
+ * Emit entity:data-invalidate signal for client cache invalidation
182
+ *
183
+ * This is a unified signal for clients to know when entity data has changed.
184
+ * Clients can listen to `entity:data-invalidate` to refresh their views.
185
+ *
186
+ * @param {string} action - 'created', 'updated', 'deleted'
187
+ * @param {string|number} id - The affected record ID
188
+ * @private
189
+ */
190
+ _emitDataInvalidate(action, id) {
191
+ if (!this._signals) return
192
+ this._signals.emit('entity:data-invalidate', {
193
+ entity: this.name,
194
+ action,
195
+ id
196
+ })
197
+ }
198
+
177
199
  // ============ LIFECYCLE HOOKS ============
178
200
 
179
201
  /**
@@ -860,6 +882,7 @@ export class EntityManager {
860
882
  manager: this.name,
861
883
  id: result?.[this.idField]
862
884
  })
885
+ this._emitDataInvalidate('created', result?.[this.idField])
863
886
  return result
864
887
  }
865
888
  throw new Error(`[EntityManager:${this.name}] create() not implemented`)
@@ -896,6 +919,7 @@ export class EntityManager {
896
919
  manager: this.name,
897
920
  id
898
921
  })
922
+ this._emitDataInvalidate('updated', id)
899
923
  return result
900
924
  }
901
925
  throw new Error(`[EntityManager:${this.name}] update() not implemented`)
@@ -932,6 +956,7 @@ export class EntityManager {
932
956
  manager: this.name,
933
957
  id
934
958
  })
959
+ this._emitDataInvalidate('updated', id)
935
960
  return result
936
961
  }
937
962
  throw new Error(`[EntityManager:${this.name}] patch() not implemented`)
@@ -959,6 +984,7 @@ export class EntityManager {
959
984
  manager: this.name,
960
985
  id
961
986
  })
987
+ this._emitDataInvalidate('deleted', id)
962
988
  return result
963
989
  }
964
990
  throw new Error(`[EntityManager:${this.name}] delete() not implemented`)
@@ -1009,6 +1035,25 @@ export class EntityManager {
1009
1035
  return caps.supportsTotal ?? false
1010
1036
  }
1011
1037
 
1038
+ /**
1039
+ * Check if storage requires authentication
1040
+ *
1041
+ * Used to auto-infer authSensitive when not explicitly set.
1042
+ * Checks both instance capabilities and static capabilities.
1043
+ *
1044
+ * @returns {boolean} - true if storage requires auth
1045
+ * @private
1046
+ */
1047
+ _getStorageRequiresAuth() {
1048
+ // Check instance capabilities first (may have dynamic requiresAuth)
1049
+ if (this.storage?.capabilities?.requiresAuth !== undefined) {
1050
+ return this.storage.capabilities.requiresAuth
1051
+ }
1052
+ // Fallback to static capabilities
1053
+ const caps = this.storage?.constructor?.capabilities || {}
1054
+ return caps.requiresAuth ?? false
1055
+ }
1056
+
1012
1057
  /**
1013
1058
  * Get searchable fields declared by storage adapter
1014
1059
  *
@@ -1143,11 +1188,12 @@ export class EntityManager {
1143
1188
 
1144
1189
  const cleanups = []
1145
1190
 
1146
- // Listen for parent cache invalidation (if parents defined)
1191
+ // Listen for parent data changes (if parents defined)
1192
+ // When a parent entity's data changes, clear search cache to re-resolve parent fields
1147
1193
  if (this._parents && Object.keys(this._parents).length > 0) {
1148
1194
  const parentEntities = Object.values(this._parents).map(p => p.entity)
1149
1195
  cleanups.push(
1150
- this._signals.on('cache:entity:invalidated', ({ entity }) => {
1196
+ this._signals.on('entity:data-invalidate', ({ entity }) => {
1151
1197
  if (parentEntities.includes(entity)) {
1152
1198
  this._clearSearchCache()
1153
1199
  }
@@ -1155,12 +1201,28 @@ export class EntityManager {
1155
1201
  )
1156
1202
  }
1157
1203
 
1158
- // Listen for targeted cache invalidation (routed by EventRouter)
1159
- // EntityManager only listens to its own signal, staying simple.
1160
- // EventRouter transforms high-level events (auth:impersonate) into targeted signals.
1204
+ // Design choice: EntityManager-centric architecture
1205
+ // Storages stay simple (pure data access), EntityManager owns invalidation logic.
1206
+ //
1207
+ // Signal: entity:datalayer-invalidate { entity, actuator? }
1208
+ // - entity: target entity name, '*' for all, or null/undefined for all
1209
+ // - actuator: source of the signal ('auth' for auth events, undefined for manual)
1210
+ //
1211
+ // Routing is handled by EventRouter (configured at Kernel level):
1212
+ // 'auth:**' → { signal: 'entity:datalayer-invalidate', transform: () => ({ actuator: 'auth' }) }
1213
+ //
1214
+ // Only authSensitive entities react to actuator='auth' signals.
1215
+ // All entities react to manual signals (no actuator).
1216
+
1161
1217
  cleanups.push(
1162
- this._signals.on(`cache:entity:invalidate:${this.name}`, () => {
1163
- this.invalidateCache()
1218
+ this._signals.on('entity:datalayer-invalidate', ({ entity, actuator } = {}) => {
1219
+ // Auth-triggered: only react if authSensitive
1220
+ if (actuator === 'auth' && !this._authSensitive) return
1221
+
1222
+ // Check entity match (global or targeted)
1223
+ if (!entity || entity === '*' || entity === this.name) {
1224
+ this.invalidateDataLayer()
1225
+ }
1164
1226
  })
1165
1227
  )
1166
1228
 
@@ -1216,24 +1278,34 @@ export class EntityManager {
1216
1278
  }
1217
1279
 
1218
1280
  /**
1219
- * Invalidate the cache (call after create/update/delete)
1281
+ * Invalidate the cache, forcing next list() to refetch
1220
1282
  *
1221
- * Emits cache:entity:invalidated signal only when cache was previously valid
1222
- * to avoid duplicate signals on repeated invalidation calls.
1283
+ * Note: entity:data-invalidate signal is emitted by CRUD methods,
1284
+ * not here (to include action and id context).
1223
1285
  */
1224
1286
  invalidateCache() {
1225
- const wasValid = this._cache.valid
1226
-
1227
1287
  this._cache.valid = false
1228
1288
  this._cache.items = []
1229
1289
  this._cache.total = 0
1230
1290
  this._cache.loadedAt = null
1231
1291
  this._cacheLoading = null
1292
+ }
1293
+
1294
+ /**
1295
+ * Invalidate the entire data layer (cache + storage state)
1296
+ *
1297
+ * Called when external context changes (auth, impersonation, etc.)
1298
+ * that may affect what data the storage returns.
1299
+ *
1300
+ * - Clears EntityManager cache
1301
+ * - Calls storage.reset() if available (for storages with internal state)
1302
+ */
1303
+ invalidateDataLayer() {
1304
+ this.invalidateCache()
1232
1305
 
1233
- // Emit cache invalidation signal only when cache was actually valid
1234
- // This prevents noise on repeated invalidation calls
1235
- if (wasValid && this._signals) {
1236
- this._signals.emit('cache:entity:invalidated', { entity: this.name })
1306
+ // Reset storage if it supports it (e.g., clear auth tokens, cached responses)
1307
+ if (typeof this.storage?.reset === 'function') {
1308
+ this.storage.reset()
1237
1309
  }
1238
1310
  }
1239
1311
 
@@ -10,10 +10,13 @@
10
10
  * signals, // SignalBus instance
11
11
  * orchestrator, // Orchestrator instance (optional, for callbacks)
12
12
  * routes: {
13
- * 'auth:impersonate': [
14
- * 'cache:entity:invalidate:loans', // string = emit signal
15
- * { signal: 'notify', transform: (p) => ({ msg: p.user }) }, // object = transform
16
- * (payload, ctx) => { ... } // function = callback
13
+ * // Auth events → datalayer invalidation with actuator (only authSensitive entities react)
14
+ * 'auth:**': [{ signal: 'entity:datalayer-invalidate', transform: () => ({ actuator: 'auth' }) }],
15
+ *
16
+ * // Custom routing with transform
17
+ * 'order:completed': [
18
+ * { signal: 'notify', transform: (p) => ({ msg: `Order ${p.id} done` }) },
19
+ * (payload, ctx) => { ... } // callback
17
20
  * ]
18
21
  * }
19
22
  * })
@@ -39,7 +39,7 @@
39
39
 
40
40
  import { createApp, h } from 'vue'
41
41
  import { createPinia } from 'pinia'
42
- import { createRouter, createWebHistory, createWebHashHistory } from 'vue-router'
42
+ import { createRouter, createWebHistory } from 'vue-router'
43
43
  import ToastService from 'primevue/toastservice'
44
44
  import ConfirmationService from 'primevue/confirmationservice'
45
45
  import Tooltip from 'primevue/tooltip'
@@ -86,7 +86,6 @@ export class Kernel {
86
86
  * @param {string} options.homeRoute - Route name for home redirect (or object { name, component })
87
87
  * @param {Array} options.coreRoutes - Additional routes as layout children (before module routes)
88
88
  * @param {string} options.basePath - Base path for router (e.g., '/dashboard/')
89
- * @param {boolean} options.hashMode - Use hash-based routing (/#/path) for static hosting
90
89
  * @param {object} options.app - App config { name, shortName, version, logo, theme }
91
90
  * @param {object} options.features - Feature toggles { auth, poweredBy }
92
91
  * @param {object} options.primevue - PrimeVue config { plugin, theme, options }
@@ -520,7 +519,7 @@ export class Kernel {
520
519
  * - **Layout-only mode**: Layout at root with all routes as children
521
520
  */
522
521
  _createRouter() {
523
- const { pages, homeRoute, coreRoutes, basePath, hashMode } = this.options
522
+ const { pages, homeRoute, coreRoutes, basePath } = this.options
524
523
 
525
524
  // Layout is required (shell is optional)
526
525
  if (!pages?.layout) {
@@ -598,7 +597,7 @@ export class Kernel {
598
597
  }
599
598
 
600
599
  this.router = createRouter({
601
- history: hashMode ? createWebHashHistory(basePath) : createWebHistory(basePath),
600
+ history: createWebHistory(basePath),
602
601
  routes
603
602
  })
604
603
  }
@@ -121,6 +121,14 @@ export class KernelContext {
121
121
  return this._kernel.options?.authAdapter ?? null
122
122
  }
123
123
 
124
+ /**
125
+ * Get security checker (role hierarchy, permissions)
126
+ * @returns {import('../entity/auth/SecurityChecker.js').SecurityChecker|null}
127
+ */
128
+ get security() {
129
+ return this._kernel.securityChecker
130
+ }
131
+
124
132
  // ─────────────────────────────────────────────────────────────────────────────
125
133
  // Fluent registration methods (return this for chaining)
126
134
  // ─────────────────────────────────────────────────────────────────────────────