qdadm 0.30.0 → 0.32.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 (47) hide show
  1. package/package.json +2 -1
  2. package/src/components/forms/FormPage.vue +1 -1
  3. package/src/components/layout/AppLayout.vue +13 -1
  4. package/src/components/layout/Zone.vue +40 -23
  5. package/src/composables/index.js +1 -0
  6. package/src/composables/useAuth.js +44 -4
  7. package/src/composables/useCurrentEntity.js +44 -0
  8. package/src/composables/useFormPageBuilder.js +3 -3
  9. package/src/composables/useNavContext.js +24 -8
  10. package/src/debug/AuthCollector.js +340 -0
  11. package/src/debug/Collector.js +235 -0
  12. package/src/debug/DebugBridge.js +163 -0
  13. package/src/debug/DebugModule.js +215 -0
  14. package/src/debug/EntitiesCollector.js +403 -0
  15. package/src/debug/ErrorCollector.js +66 -0
  16. package/src/debug/LocalStorageAdapter.js +150 -0
  17. package/src/debug/SignalCollector.js +87 -0
  18. package/src/debug/ToastCollector.js +82 -0
  19. package/src/debug/ZonesCollector.js +300 -0
  20. package/src/debug/components/DebugBar.vue +1232 -0
  21. package/src/debug/components/ObjectTree.vue +194 -0
  22. package/src/debug/components/index.js +8 -0
  23. package/src/debug/components/panels/AuthPanel.vue +174 -0
  24. package/src/debug/components/panels/EntitiesPanel.vue +712 -0
  25. package/src/debug/components/panels/EntriesPanel.vue +188 -0
  26. package/src/debug/components/panels/ToastsPanel.vue +112 -0
  27. package/src/debug/components/panels/ZonesPanel.vue +232 -0
  28. package/src/debug/components/panels/index.js +8 -0
  29. package/src/debug/index.js +31 -0
  30. package/src/entity/EntityManager.js +142 -20
  31. package/src/entity/auth/CompositeAuthAdapter.js +212 -0
  32. package/src/entity/auth/factory.js +207 -0
  33. package/src/entity/auth/factory.test.js +257 -0
  34. package/src/entity/auth/index.js +14 -0
  35. package/src/entity/storage/MockApiStorage.js +51 -2
  36. package/src/entity/storage/index.js +9 -2
  37. package/src/index.js +7 -0
  38. package/src/kernel/Kernel.js +468 -48
  39. package/src/kernel/KernelContext.js +385 -0
  40. package/src/kernel/Module.js +111 -0
  41. package/src/kernel/ModuleLoader.js +573 -0
  42. package/src/kernel/SignalBus.js +2 -7
  43. package/src/kernel/index.js +14 -0
  44. package/src/toast/ToastBridgeModule.js +70 -0
  45. package/src/toast/ToastListener.vue +47 -0
  46. package/src/toast/index.js +15 -0
  47. package/src/toast/useSignalToast.js +113 -0
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Tests for auth factory and CompositeAuthAdapter
3
+ */
4
+ import { describe, it, expect } from 'vitest'
5
+ import { AuthAdapter } from './AuthAdapter.js'
6
+ import { PermissiveAuthAdapter } from './PermissiveAdapter.js'
7
+ import { CompositeAuthAdapter } from './CompositeAuthAdapter.js'
8
+ import { authFactory, parseAuthPattern, authTypes } from './factory.js'
9
+
10
+ // Test adapter for custom types
11
+ class TestAuthAdapter extends AuthAdapter {
12
+ constructor(options = {}) {
13
+ super()
14
+ this.options = options
15
+ }
16
+ canPerform() { return true }
17
+ canAccessRecord() { return true }
18
+ getCurrentUser() { return { id: 'test' } }
19
+ }
20
+
21
+ // Adapter that tracks which entity was checked
22
+ class TrackingAdapter extends AuthAdapter {
23
+ constructor(name) {
24
+ super()
25
+ this.name = name
26
+ this.checkedEntities = []
27
+ }
28
+ canPerform(entity, action) {
29
+ this.checkedEntities.push({ entity, action })
30
+ return true
31
+ }
32
+ canAccessRecord() { return true }
33
+ getCurrentUser() { return { name: this.name } }
34
+ }
35
+
36
+ describe('parseAuthPattern', () => {
37
+ it('parses simple type', () => {
38
+ expect(parseAuthPattern('jwt')).toEqual({ type: 'jwt' })
39
+ expect(parseAuthPattern('permissive')).toEqual({ type: 'permissive' })
40
+ })
41
+
42
+ it('parses type with scope', () => {
43
+ expect(parseAuthPattern('jwt:internal')).toEqual({ type: 'jwt', scope: 'internal' })
44
+ expect(parseAuthPattern('apikey:external')).toEqual({ type: 'apikey', scope: 'external' })
45
+ })
46
+
47
+ it('returns null for invalid input', () => {
48
+ expect(parseAuthPattern(null)).toBeNull()
49
+ expect(parseAuthPattern(123)).toBeNull()
50
+ expect(parseAuthPattern('')).toBeNull()
51
+ })
52
+ })
53
+
54
+ describe('authFactory', () => {
55
+ describe('instance passthrough', () => {
56
+ it('returns AuthAdapter instance as-is', () => {
57
+ const adapter = new PermissiveAuthAdapter()
58
+ const result = authFactory(adapter)
59
+ expect(result).toBe(adapter)
60
+ })
61
+
62
+ it('returns duck-typed adapter as-is', () => {
63
+ const duckAdapter = {
64
+ canPerform: () => true,
65
+ canAccessRecord: () => true,
66
+ getCurrentUser: () => null
67
+ }
68
+ const result = authFactory(duckAdapter)
69
+ expect(result).toBe(duckAdapter)
70
+ })
71
+ })
72
+
73
+ describe('null/undefined', () => {
74
+ it('returns PermissiveAuthAdapter for null', () => {
75
+ const result = authFactory(null)
76
+ expect(result).toBeInstanceOf(PermissiveAuthAdapter)
77
+ })
78
+
79
+ it('returns PermissiveAuthAdapter for undefined', () => {
80
+ const result = authFactory(undefined)
81
+ expect(result).toBeInstanceOf(PermissiveAuthAdapter)
82
+ })
83
+ })
84
+
85
+ describe('string patterns', () => {
86
+ it('resolves built-in types', () => {
87
+ const result = authFactory('permissive')
88
+ expect(result).toBeInstanceOf(PermissiveAuthAdapter)
89
+ })
90
+
91
+ it('resolves custom types from context', () => {
92
+ const result = authFactory('test', {
93
+ authTypes: { test: TestAuthAdapter }
94
+ })
95
+ expect(result).toBeInstanceOf(TestAuthAdapter)
96
+ })
97
+
98
+ it('throws for unknown type', () => {
99
+ expect(() => authFactory('unknown')).toThrow(/Unknown auth type/)
100
+ })
101
+ })
102
+
103
+ describe('config objects', () => {
104
+ it('resolves config with type', () => {
105
+ const result = authFactory(
106
+ { type: 'test', foo: 'bar' },
107
+ { authTypes: { test: TestAuthAdapter } }
108
+ )
109
+ expect(result).toBeInstanceOf(TestAuthAdapter)
110
+ expect(result.options.foo).toBe('bar')
111
+ })
112
+
113
+ it('throws for config without type or default', () => {
114
+ expect(() => authFactory({ foo: 'bar' })).toThrow(/requires either "type" or "default"/)
115
+ })
116
+ })
117
+
118
+ describe('composite config', () => {
119
+ it('creates CompositeAuthAdapter from config', () => {
120
+ const defaultAdapter = new PermissiveAuthAdapter()
121
+ const result = authFactory(
122
+ { default: defaultAdapter },
123
+ { CompositeAuthAdapter }
124
+ )
125
+ expect(result).toBeInstanceOf(CompositeAuthAdapter)
126
+ })
127
+
128
+ it('throws without CompositeAuthAdapter in context', () => {
129
+ const defaultAdapter = new PermissiveAuthAdapter()
130
+ expect(() => authFactory({ default: defaultAdapter })).toThrow(/CompositeAuthAdapter/)
131
+ })
132
+ })
133
+ })
134
+
135
+ describe('CompositeAuthAdapter', () => {
136
+ describe('routing', () => {
137
+ it('uses default adapter for unmatched entities', () => {
138
+ const defaultAdapter = new TrackingAdapter('default')
139
+ const composite = new CompositeAuthAdapter({ default: defaultAdapter })
140
+
141
+ composite.canPerform('books', 'read')
142
+ composite.canPerform('users', 'create')
143
+
144
+ expect(defaultAdapter.checkedEntities).toEqual([
145
+ { entity: 'books', action: 'read' },
146
+ { entity: 'users', action: 'create' }
147
+ ])
148
+ })
149
+
150
+ it('routes exact matches to mapped adapter', () => {
151
+ const defaultAdapter = new TrackingAdapter('default')
152
+ const productsAdapter = new TrackingAdapter('products')
153
+
154
+ const composite = new CompositeAuthAdapter({
155
+ default: defaultAdapter,
156
+ mapping: { products: productsAdapter }
157
+ })
158
+
159
+ composite.canPerform('books', 'read')
160
+ composite.canPerform('products', 'read')
161
+
162
+ expect(defaultAdapter.checkedEntities).toHaveLength(1)
163
+ expect(defaultAdapter.checkedEntities[0].entity).toBe('books')
164
+
165
+ expect(productsAdapter.checkedEntities).toHaveLength(1)
166
+ expect(productsAdapter.checkedEntities[0].entity).toBe('products')
167
+ })
168
+
169
+ it('routes glob patterns to mapped adapter', () => {
170
+ const defaultAdapter = new TrackingAdapter('default')
171
+ const externalAdapter = new TrackingAdapter('external')
172
+
173
+ const composite = new CompositeAuthAdapter({
174
+ default: defaultAdapter,
175
+ mapping: { 'external-*': externalAdapter }
176
+ })
177
+
178
+ composite.canPerform('books', 'read')
179
+ composite.canPerform('external-products', 'read')
180
+ composite.canPerform('external-orders', 'list')
181
+
182
+ expect(defaultAdapter.checkedEntities).toHaveLength(1)
183
+ expect(externalAdapter.checkedEntities).toHaveLength(2)
184
+ })
185
+
186
+ it('supports suffix glob patterns', () => {
187
+ const defaultAdapter = new TrackingAdapter('default')
188
+ const readonlyAdapter = new TrackingAdapter('readonly')
189
+
190
+ const composite = new CompositeAuthAdapter({
191
+ default: defaultAdapter,
192
+ mapping: { '*-readonly': readonlyAdapter }
193
+ })
194
+
195
+ composite.canPerform('products-readonly', 'read')
196
+ composite.canPerform('products', 'read')
197
+
198
+ expect(readonlyAdapter.checkedEntities).toHaveLength(1)
199
+ expect(defaultAdapter.checkedEntities).toHaveLength(1)
200
+ })
201
+ })
202
+
203
+ describe('getCurrentUser', () => {
204
+ it('returns user from default adapter', () => {
205
+ const defaultAdapter = new TrackingAdapter('admin')
206
+ const externalAdapter = new TrackingAdapter('service')
207
+
208
+ const composite = new CompositeAuthAdapter({
209
+ default: defaultAdapter,
210
+ mapping: { 'external-*': externalAdapter }
211
+ })
212
+
213
+ expect(composite.getCurrentUser()).toEqual({ name: 'admin' })
214
+ })
215
+ })
216
+
217
+ describe('factory integration', () => {
218
+ it('resolves adapters in mapping via factory', () => {
219
+ const defaultAdapter = new PermissiveAuthAdapter()
220
+
221
+ const composite = new CompositeAuthAdapter(
222
+ {
223
+ default: defaultAdapter,
224
+ mapping: {
225
+ products: { type: 'test' }
226
+ }
227
+ },
228
+ { authTypes: { test: TestAuthAdapter } }
229
+ )
230
+
231
+ // Products should use TestAuthAdapter
232
+ expect(composite._getAdapter('products')).toBeInstanceOf(TestAuthAdapter)
233
+ // Others use default
234
+ expect(composite._getAdapter('books')).toBe(defaultAdapter)
235
+ })
236
+ })
237
+
238
+ describe('getAdapterInfo', () => {
239
+ it('returns debug info about adapters', () => {
240
+ const composite = new CompositeAuthAdapter({
241
+ default: new PermissiveAuthAdapter(),
242
+ mapping: {
243
+ products: new TestAuthAdapter(),
244
+ 'external-*': new TrackingAdapter('ext')
245
+ }
246
+ })
247
+
248
+ const info = composite.getAdapterInfo()
249
+
250
+ expect(info.default).toBe('PermissiveAuthAdapter')
251
+ expect(info.exactMatches.products).toBe('TestAuthAdapter')
252
+ expect(info.patterns).toHaveLength(1)
253
+ expect(info.patterns[0].pattern).toBe('external-*')
254
+ expect(info.patterns[0].adapter).toBe('TrackingAdapter')
255
+ })
256
+ })
257
+ })
@@ -3,6 +3,10 @@
3
3
  *
4
4
  * AuthAdapter interface and implementations for scope/silo permission checks.
5
5
  * Includes Symfony-inspired role hierarchy and security checker.
6
+ *
7
+ * Multi-source auth:
8
+ * - CompositeAuthAdapter routes to different adapters based on entity patterns
9
+ * - authFactory normalizes config (instance, string, or object)
6
10
  */
7
11
 
8
12
  // Interface
@@ -16,3 +20,13 @@ export { SecurityChecker, createSecurityChecker } from './SecurityChecker.js'
16
20
 
17
21
  // Implementations
18
22
  export { PermissiveAuthAdapter, createPermissiveAdapter } from './PermissiveAdapter.js'
23
+ export { CompositeAuthAdapter, createCompositeAuthAdapter } from './CompositeAuthAdapter.js'
24
+
25
+ // Factory/Resolver pattern (like storage/factory.js)
26
+ export {
27
+ authFactory,
28
+ createAuthFactory,
29
+ authTypes,
30
+ parseAuthPattern,
31
+ defaultAuthResolver
32
+ } from './factory.js'
@@ -29,7 +29,23 @@ export class MockApiStorage extends IStorage {
29
29
  * - supportsTotal: true - Returns accurate total from in-memory data
30
30
  * - supportsFilters: true - Filters in-memory via list() params
31
31
  * - supportsPagination: true - Paginates in-memory
32
- * - supportsCaching: false - Already in-memory, no cache benefit
32
+ * - supportsCaching: false - Suggests EntityManager cache is not useful (see below)
33
+ *
34
+ * WHY supportsCaching = false?
35
+ * MockApiStorage is already in-memory, so the primary benefit of caching (avoiding
36
+ * network calls) doesn't apply. However, EntityManager cache CAN still be enabled
37
+ * by subclassing and setting supportsCaching: true. This is useful when you need:
38
+ * - Parent field resolution for searchFields (e.g., 'book.title' in loans)
39
+ * - Warmup at boot for consistent initial state
40
+ * - Local filtering without re-calling list()
41
+ *
42
+ * To enable EntityManager caching on MockApiStorage:
43
+ * ```js
44
+ * class CacheableMockStorage extends MockApiStorage {
45
+ * static capabilities = { ...MockApiStorage.capabilities, supportsCaching: true }
46
+ * get supportsCaching() { return true }
47
+ * }
48
+ * ```
33
49
  *
34
50
  * @type {import('./index.js').StorageCapabilities}
35
51
  */
@@ -49,13 +65,26 @@ export class MockApiStorage extends IStorage {
49
65
  return MockApiStorage.capabilities.supportsCaching
50
66
  }
51
67
 
68
+ /**
69
+ * Instance capabilities getter.
70
+ * Merges static capabilities with instance-specific ones like requiresAuth.
71
+ * @returns {object}
72
+ */
73
+ get capabilities() {
74
+ return {
75
+ ...MockApiStorage.capabilities,
76
+ requiresAuth: !!this._authCheck
77
+ }
78
+ }
79
+
52
80
  constructor(options = {}) {
53
81
  super()
54
82
  const {
55
83
  entityName,
56
84
  idField = 'id',
57
85
  generateId = () => Date.now().toString(36) + Math.random().toString(36).substr(2),
58
- initialData = null
86
+ initialData = null,
87
+ authCheck = null // Optional: () => boolean - throws 401 if returns false
59
88
  } = options
60
89
 
61
90
  if (!entityName) {
@@ -67,11 +96,24 @@ export class MockApiStorage extends IStorage {
67
96
  this.generateId = generateId
68
97
  this._storageKey = `mockapi_${entityName}_data`
69
98
  this._data = new Map()
99
+ this._authCheck = authCheck
70
100
 
71
101
  // Load from localStorage or seed with initialData
72
102
  this._loadFromStorage(initialData)
73
103
  }
74
104
 
105
+ /**
106
+ * Check authentication if authCheck is configured
107
+ * @throws {Error} 401 error if not authenticated
108
+ */
109
+ _checkAuth() {
110
+ if (this._authCheck && !this._authCheck()) {
111
+ const error = new Error(`Unauthorized: Authentication required to access ${this.entityName}`)
112
+ error.status = 401
113
+ throw error
114
+ }
115
+ }
116
+
75
117
  /**
76
118
  * Load data from localStorage, seed with initialData if empty
77
119
  * @param {Array|null} initialData - Data to seed if storage is empty
@@ -142,6 +184,7 @@ export class MockApiStorage extends IStorage {
142
184
  * @returns {Promise<{ items: Array, total: number }>}
143
185
  */
144
186
  async list(params = {}) {
187
+ this._checkAuth()
145
188
  const { page = 1, page_size = 20, sort_by, sort_order = 'asc', filters = {}, search } = params
146
189
 
147
190
  let items = this._getAll()
@@ -197,6 +240,7 @@ export class MockApiStorage extends IStorage {
197
240
  * @returns {Promise<object>}
198
241
  */
199
242
  async get(id) {
243
+ this._checkAuth()
200
244
  const item = this._data.get(String(id))
201
245
  if (!item) {
202
246
  const error = new Error(`Entity not found: ${id}`)
@@ -212,6 +256,7 @@ export class MockApiStorage extends IStorage {
212
256
  * @returns {Promise<Array<object>>}
213
257
  */
214
258
  async getMany(ids) {
259
+ this._checkAuth()
215
260
  if (!ids || ids.length === 0) return []
216
261
  const results = []
217
262
  for (const id of ids) {
@@ -266,6 +311,7 @@ export class MockApiStorage extends IStorage {
266
311
  * @returns {Promise<object>}
267
312
  */
268
313
  async create(data) {
314
+ this._checkAuth()
269
315
  const id = data[this.idField] || this.generateId()
270
316
  const newItem = {
271
317
  ...data,
@@ -284,6 +330,7 @@ export class MockApiStorage extends IStorage {
284
330
  * @returns {Promise<object>}
285
331
  */
286
332
  async update(id, data) {
333
+ this._checkAuth()
287
334
  if (!this._data.has(String(id))) {
288
335
  const error = new Error(`Entity not found: ${id}`)
289
336
  error.status = 404
@@ -306,6 +353,7 @@ export class MockApiStorage extends IStorage {
306
353
  * @returns {Promise<object>}
307
354
  */
308
355
  async patch(id, data) {
356
+ this._checkAuth()
309
357
  const existing = this._data.get(String(id))
310
358
  if (!existing) {
311
359
  const error = new Error(`Entity not found: ${id}`)
@@ -328,6 +376,7 @@ export class MockApiStorage extends IStorage {
328
376
  * @returns {Promise<void>}
329
377
  */
330
378
  async delete(id) {
379
+ this._checkAuth()
331
380
  if (!this._data.has(String(id))) {
332
381
  const error = new Error(`Entity not found: ${id}`)
333
382
  error.status = 404
@@ -19,7 +19,14 @@
19
19
  * @property {boolean} [supportsTotal=false] - Storage `list()` returns `{ items, total }` with accurate total count
20
20
  * @property {boolean} [supportsFilters=false] - Storage `list()` accepts `filters` param and handles filtering
21
21
  * @property {boolean} [supportsPagination=false] - Storage `list()` accepts `page`/`page_size` params
22
- * @property {boolean} [supportsCaching=false] - Storage benefits from EntityManager cache layer (network-based storages)
22
+ * @property {boolean} [supportsCaching=false] - Hint to EntityManager: should it cache this storage's results?
23
+ * This is a RECOMMENDATION, not a capability. The storage suggests whether EntityManager caching is useful:
24
+ * - `true`: Cache is beneficial (remote APIs, slow storages) - enables local filtering, parent field
25
+ * resolution, warmup at boot
26
+ * - `false`: Cache adds no value (already in-memory storages like MockApiStorage) - but still technically
27
+ * possible if overridden. Set to `false` when storage is already fast and in-memory.
28
+ * Use `false` for: in-memory storages, real-time data sources, storages with their own cache layer.
29
+ * Use `true` for: REST APIs, database backends, any storage where avoiding repeat fetches helps.
23
30
  * @property {string[]} [searchFields] - Fields to search when filtering locally. Supports own fields ('title')
24
31
  * and parent entity fields ('book.title') via EntityManager.parents config. When undefined, all string
25
32
  * fields are searched (default behavior). When defined, only listed fields are searched.
@@ -40,7 +47,7 @@
40
47
  * supportsTotal: true,
41
48
  * supportsFilters: true,
42
49
  * supportsPagination: true,
43
- * supportsCaching: true // set to false for in-memory storages
50
+ * supportsCaching: true // false = "not useful" (in-memory), true = "beneficial" (remote API)
44
51
  * }
45
52
  *
46
53
  * // Backward-compat instance getter (optional, for smooth migration)
package/src/index.js CHANGED
@@ -47,5 +47,12 @@ export * from './query/index.js'
47
47
  // Utils
48
48
  export * from './utils/index.js'
49
49
 
50
+ // Toast (signal-based notifications)
51
+ export * from './toast/index.js'
52
+
53
+ // Debug tools are NOT exported here to enable tree-shaking.
54
+ // Import from 'qdadm/debug' separately when needed:
55
+ // import { debugBar, DebugModule } from 'qdadm/debug'
56
+
50
57
  // Assets
51
58
  export { default as qdadmLogo } from './assets/logo.svg'