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
@@ -119,6 +119,19 @@ export class EntityManager {
119
119
 
120
120
  // Cleanup function for signal listeners
121
121
  this._signalCleanup = null
122
+
123
+ // Operation stats tracking (for debug panel)
124
+ this._stats = {
125
+ list: 0, // Total list() calls
126
+ get: 0, // Total get() calls
127
+ create: 0, // Total create() calls
128
+ update: 0, // Total update() calls (includes patch)
129
+ delete: 0, // Total delete() calls
130
+ cacheHits: 0, // list() served from local cache
131
+ cacheMisses: 0, // list() fetched from API
132
+ maxItemsSeen: 0, // Max items returned in a single list()
133
+ maxTotal: 0 // Max total count reported by API
134
+ }
122
135
  }
123
136
 
124
137
  // ============ SIGNALS ============
@@ -166,20 +179,15 @@ export class EntityManager {
166
179
  /**
167
180
  * Invoke a lifecycle hook for this entity
168
181
  *
169
- * Invokes both entity-specific hook (e.g., 'books:presave') and
170
- * generic hook (e.g., 'entity:presave'). Entity-specific hooks run first.
182
+ * Invokes generic hook (e.g., 'entity:presave') with entity name in context.
183
+ * Handlers can filter by context.entity if needed.
171
184
  *
172
185
  * @param {string} hookName - Hook name without entity prefix (e.g., 'presave')
173
- * @param {object} context - Hook context passed to handlers
186
+ * @param {object} context - Hook context passed to handlers (includes entity name)
174
187
  * @private
175
188
  */
176
189
  async _invokeHook(hookName, context) {
177
190
  if (!this._hooks) return
178
-
179
- // Invoke entity-specific hook first (e.g., 'books:presave')
180
- await this._hooks.invoke(`${this.name}:${hookName}`, context)
181
-
182
- // Invoke generic hook (e.g., 'entity:presave')
183
191
  await this._hooks.invoke(`entity:${hookName}`, context)
184
192
  }
185
193
 
@@ -688,35 +696,67 @@ export class EntityManager {
688
696
  throw new Error(`[EntityManager:${this.name}] list() not implemented`)
689
697
  }
690
698
 
691
- // Extract cacheSafe flag (for ownership/scope filters that are session-bound)
692
- const { cacheSafe = false, ...queryParams } = params
699
+ // Extract internal flag and cacheSafe flag
700
+ const { _internal = false, cacheSafe = false, ...queryParams } = params
701
+
702
+ // Only count stats for non-internal operations
703
+ if (!_internal) {
704
+ this._stats.list++
705
+ }
693
706
 
694
707
  const hasFilters = queryParams.search || Object.keys(queryParams.filters || {}).length > 0
695
708
  const canUseCache = !hasFilters || cacheSafe
696
709
 
697
710
  // 1. Cache valid + cacheable → use cache with local filtering
698
711
  if (this._cache.valid && canUseCache) {
712
+ if (!_internal) this._stats.cacheHits++
699
713
  console.log('[cache] Using local cache for entity:', this.name)
700
714
  const filtered = this._filterLocally(this._cache.items, queryParams)
715
+ // Update max stats
716
+ if (filtered.items.length > this._stats.maxItemsSeen) {
717
+ this._stats.maxItemsSeen = filtered.items.length
718
+ }
719
+ if (filtered.total > this._stats.maxTotal) {
720
+ this._stats.maxTotal = filtered.total
721
+ }
701
722
  return { ...filtered, fromCache: true }
702
723
  }
703
724
 
725
+ if (!_internal) this._stats.cacheMisses++
726
+
704
727
  // 2. Fetch from API (storage normalizes response to { items, total })
705
728
  const response = await this.storage.list(queryParams)
706
729
  const items = response.items || []
707
730
  const total = response.total ?? items.length
708
731
 
732
+ // Update max stats
733
+ if (items.length > this._stats.maxItemsSeen) {
734
+ this._stats.maxItemsSeen = items.length
735
+ }
736
+ if (total > this._stats.maxTotal) {
737
+ this._stats.maxTotal = total
738
+ }
739
+
709
740
  // 3. Fill cache opportunistically if:
710
741
  // - canUseCache (no filters or cacheSafe filters)
711
742
  // - isCacheEnabled (threshold > 0 and storage supports total)
712
743
  // - total <= threshold (all items fit in cache for complete local filtering)
744
+ // - items.length >= total (we actually received all items)
713
745
  if (canUseCache && this.isCacheEnabled && total <= this.effectiveThreshold) {
714
- this._cache.items = items // Store all items (no slicing - total fits threshold)
715
- this._cache.total = total
716
- this._cache.valid = true
717
- this._cache.loadedAt = Date.now()
718
- // Resolve parent fields for search (book.title, user.username, etc.)
719
- await this._resolveSearchFields(items)
746
+ // Only cache if we received ALL items (not a partial page)
747
+ if (items.length >= total) {
748
+ this._cache.items = items
749
+ this._cache.total = total
750
+ this._cache.valid = true
751
+ this._cache.loadedAt = Date.now()
752
+ // Resolve parent fields for search (book.title, user.username, etc.)
753
+ await this._resolveSearchFields(items)
754
+ }
755
+ // If we got partial results but total fits threshold, load all items for cache
756
+ else if (!this._cacheLoading) {
757
+ // Fire-and-forget: load full cache in background
758
+ this._loadCacheInBackground()
759
+ }
720
760
  }
721
761
 
722
762
  return { items, total, fromCache: false }
@@ -724,10 +764,28 @@ export class EntityManager {
724
764
 
725
765
  /**
726
766
  * Get a single entity by ID
767
+ *
768
+ * If cache is valid and complete (not overflow), serves from cache.
769
+ * Otherwise fetches from storage.
770
+ *
727
771
  * @param {string|number} id
728
772
  * @returns {Promise<object>}
729
773
  */
730
774
  async get(id) {
775
+ this._stats.get++
776
+
777
+ // Try cache first if valid and complete
778
+ if (this._cache.valid && !this.overflow) {
779
+ const idStr = String(id)
780
+ const cached = this._cache.items.find(item => String(item[this.idField]) === idStr)
781
+ if (cached) {
782
+ this._stats.cacheHits++
783
+ return { ...cached } // Return copy to avoid mutation
784
+ }
785
+ }
786
+
787
+ // Fallback to storage
788
+ this._stats.cacheMisses++
731
789
  if (this.storage) {
732
790
  return this.storage.get(id)
733
791
  }
@@ -736,15 +794,38 @@ export class EntityManager {
736
794
 
737
795
  /**
738
796
  * Get multiple entities by IDs (batch fetch)
797
+ *
798
+ * If cache is valid and complete, serves from cache.
799
+ * Otherwise fetches from storage (or parallel get() calls).
800
+ *
739
801
  * @param {Array<string|number>} ids
740
802
  * @returns {Promise<Array<object>>}
741
803
  */
742
804
  async getMany(ids) {
743
805
  if (!ids || ids.length === 0) return []
806
+
807
+ // Try cache first if valid and complete
808
+ if (this._cache.valid && !this.overflow) {
809
+ const idStrs = new Set(ids.map(String))
810
+ const cached = this._cache.items
811
+ .filter(item => idStrs.has(String(item[this.idField])))
812
+ .map(item => ({ ...item })) // Return copies
813
+
814
+ // If we found all items in cache
815
+ if (cached.length === ids.length) {
816
+ this._stats.cacheHits += ids.length
817
+ return cached
818
+ }
819
+ // Partial cache hit - fall through to storage
820
+ }
821
+
822
+ this._stats.cacheMisses += ids.length
823
+
744
824
  if (this.storage?.getMany) {
745
825
  return this.storage.getMany(ids)
746
826
  }
747
- // Fallback: parallel get calls
827
+ // Fallback: parallel get calls (get() handles its own stats)
828
+ this._stats.cacheMisses -= ids.length // Avoid double counting
748
829
  return Promise.all(ids.map(id => this.get(id).catch(() => null)))
749
830
  .then(results => results.filter(Boolean))
750
831
  }
@@ -760,6 +841,7 @@ export class EntityManager {
760
841
  * @returns {Promise<object>} - The created entity
761
842
  */
762
843
  async create(data) {
844
+ this._stats.create++
763
845
  if (this.storage) {
764
846
  // Invoke presave hooks (can modify data or throw to abort)
765
847
  const presaveContext = this._buildPresaveContext(data, true)
@@ -795,6 +877,7 @@ export class EntityManager {
795
877
  * @returns {Promise<object>}
796
878
  */
797
879
  async update(id, data) {
880
+ this._stats.update++
798
881
  if (this.storage) {
799
882
  // Invoke presave hooks (can modify data or throw to abort)
800
883
  const presaveContext = this._buildPresaveContext(data, false, id)
@@ -830,6 +913,7 @@ export class EntityManager {
830
913
  * @returns {Promise<object>}
831
914
  */
832
915
  async patch(id, data) {
916
+ this._stats.update++ // patch counts as update
833
917
  if (this.storage) {
834
918
  // Invoke presave hooks (can modify data or throw to abort)
835
919
  const presaveContext = this._buildPresaveContext(data, false, id)
@@ -863,6 +947,7 @@ export class EntityManager {
863
947
  * @returns {Promise<void>}
864
948
  */
865
949
  async delete(id) {
950
+ this._stats.delete++
866
951
  if (this.storage) {
867
952
  // Invoke predelete hooks (can throw to abort, e.g., for cascade checks)
868
953
  const predeleteContext = this._buildPredeleteContext(id)
@@ -1172,13 +1257,25 @@ export class EntityManager {
1172
1257
  return result
1173
1258
  }
1174
1259
 
1260
+ /**
1261
+ * Internal: load all items into cache in background (fire-and-forget)
1262
+ * Called when a partial list() response indicates caching is possible.
1263
+ */
1264
+ _loadCacheInBackground() {
1265
+ this._cacheLoading = this._loadCache()
1266
+ this._cacheLoading
1267
+ .catch(err => console.error(`[EntityManager:${this.name}] Background cache load failed:`, err))
1268
+ .finally(() => { this._cacheLoading = null })
1269
+ }
1270
+
1175
1271
  /**
1176
1272
  * Internal: load all items into cache
1177
1273
  * @returns {Promise<boolean>} - true if cached, false if too many items
1178
1274
  */
1179
1275
  async _loadCache() {
1180
1276
  // First, check total count with minimal request
1181
- const probe = await this.list({ page_size: 1 })
1277
+ // Use _internal to skip stats counting
1278
+ const probe = await this.list({ page_size: 1, _internal: true })
1182
1279
 
1183
1280
  if (probe.total > this.effectiveThreshold) {
1184
1281
  // Too many items, don't cache
@@ -1186,8 +1283,8 @@ export class EntityManager {
1186
1283
  return false
1187
1284
  }
1188
1285
 
1189
- // Load all items
1190
- const result = await this.list({ page_size: probe.total || this.effectiveThreshold })
1286
+ // Load all items (internal operation, skip stats)
1287
+ const result = await this.list({ page_size: probe.total || this.effectiveThreshold, _internal: true })
1191
1288
  this._cache.items = result.items || []
1192
1289
  this._cache.total = result.total
1193
1290
  this._cache.loadedAt = Date.now()
@@ -1443,6 +1540,31 @@ export class EntityManager {
1443
1540
  }
1444
1541
  }
1445
1542
 
1543
+ /**
1544
+ * Get operation stats (for debug panel)
1545
+ * @returns {object}
1546
+ */
1547
+ getStats() {
1548
+ return { ...this._stats }
1549
+ }
1550
+
1551
+ /**
1552
+ * Reset operation stats
1553
+ */
1554
+ resetStats() {
1555
+ this._stats = {
1556
+ list: 0,
1557
+ get: 0,
1558
+ create: 0,
1559
+ update: 0,
1560
+ delete: 0,
1561
+ cacheHits: 0,
1562
+ cacheMisses: 0,
1563
+ maxItemsSeen: 0,
1564
+ maxTotal: 0
1565
+ }
1566
+ }
1567
+
1446
1568
  // ============ RELATIONS ============
1447
1569
 
1448
1570
  /**
@@ -0,0 +1,212 @@
1
+ /**
2
+ * CompositeAuthAdapter - Routes to sub-adapters based on entity patterns
3
+ *
4
+ * Enables multi-source authentication where different entities may use
5
+ * different auth backends (internal JWT, external API keys, OAuth, etc.)
6
+ *
7
+ * Pattern matching:
8
+ * - Exact match: 'products' matches only 'products'
9
+ * - Prefix glob: 'external-*' matches 'external-products', 'external-orders'
10
+ * - Suffix glob: '*-readonly' matches 'products-readonly'
11
+ *
12
+ * @example
13
+ * ```js
14
+ * const composite = new CompositeAuthAdapter({
15
+ * default: internalAuthAdapter, // JWT for most entities
16
+ * mapping: {
17
+ * 'products': apiKeyAdapter, // API key for products
18
+ * 'external-*': externalAuth, // OAuth for external-* entities
19
+ * 'readonly-*': readonlyAuth // Read-only adapter
20
+ * }
21
+ * })
22
+ *
23
+ * composite.canPerform('books', 'read') // → uses default (internal)
24
+ * composite.canPerform('products', 'read') // → uses apiKeyAdapter
25
+ * composite.canPerform('external-orders', 'read') // → uses externalAuth
26
+ * ```
27
+ */
28
+
29
+ import { AuthAdapter } from './AuthAdapter.js'
30
+ import { authFactory } from './factory.js'
31
+
32
+ export class CompositeAuthAdapter extends AuthAdapter {
33
+ /**
34
+ * @param {object} config - Composite auth configuration
35
+ * @param {AuthAdapter|string|object} config.default - Default adapter (required)
36
+ * @param {Object<string, AuthAdapter|string|object>} [config.mapping={}] - Entity pattern to adapter mapping
37
+ * @param {object} [context={}] - Factory context with authTypes
38
+ */
39
+ constructor(config, context = {}) {
40
+ super()
41
+
42
+ const { default: defaultConfig, mapping = {} } = config
43
+
44
+ if (!defaultConfig) {
45
+ throw new Error('[CompositeAuthAdapter] "default" adapter is required')
46
+ }
47
+
48
+ // Resolve default adapter via factory
49
+ this._default = authFactory(defaultConfig, context)
50
+
51
+ // Resolve mapped adapters
52
+ this._exactMatches = new Map()
53
+ this._patterns = []
54
+
55
+ for (const [pattern, adapterConfig] of Object.entries(mapping)) {
56
+ const adapter = authFactory(adapterConfig, context)
57
+
58
+ if (pattern.includes('*')) {
59
+ // Glob pattern: convert to regex
60
+ const regexPattern = pattern
61
+ .replace(/[.+?^${}()|[\]\\]/g, '\\$&') // Escape regex special chars
62
+ .replace(/\*/g, '.*') // * → .*
63
+ const regex = new RegExp(`^${regexPattern}$`)
64
+ this._patterns.push({ pattern, regex, adapter })
65
+ } else {
66
+ // Exact match
67
+ this._exactMatches.set(pattern, adapter)
68
+ }
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Get the adapter for a specific entity
74
+ *
75
+ * Resolution order:
76
+ * 1. Exact match in mapping
77
+ * 2. First matching glob pattern
78
+ * 3. Default adapter
79
+ *
80
+ * @param {string} entity - Entity name
81
+ * @returns {AuthAdapter}
82
+ */
83
+ _getAdapter(entity) {
84
+ // 1. Exact match
85
+ if (this._exactMatches.has(entity)) {
86
+ return this._exactMatches.get(entity)
87
+ }
88
+
89
+ // 2. Pattern match (first wins)
90
+ for (const { regex, adapter } of this._patterns) {
91
+ if (regex.test(entity)) {
92
+ return adapter
93
+ }
94
+ }
95
+
96
+ // 3. Default
97
+ return this._default
98
+ }
99
+
100
+ /**
101
+ * Check if user can perform action on entity type (scope check)
102
+ * Delegates to the appropriate adapter based on entity name
103
+ *
104
+ * @param {string} entity - Entity name
105
+ * @param {string} action - Action: read, create, update, delete, list
106
+ * @returns {boolean}
107
+ */
108
+ canPerform(entity, action) {
109
+ return this._getAdapter(entity).canPerform(entity, action)
110
+ }
111
+
112
+ /**
113
+ * Check if user can access specific record (silo check)
114
+ * Delegates to the appropriate adapter based on entity name
115
+ *
116
+ * @param {string} entity - Entity name
117
+ * @param {object} record - The record to check
118
+ * @returns {boolean}
119
+ */
120
+ canAccessRecord(entity, record) {
121
+ return this._getAdapter(entity).canAccessRecord(entity, record)
122
+ }
123
+
124
+ /**
125
+ * Get current user from the default adapter
126
+ * The "user" concept comes from the primary auth source
127
+ *
128
+ * @returns {object|null}
129
+ */
130
+ getCurrentUser() {
131
+ return this._default.getCurrentUser()
132
+ }
133
+
134
+ /**
135
+ * Check if user is granted an attribute (role or permission)
136
+ * Delegates to default adapter for global permissions
137
+ *
138
+ * @param {string} attribute - Role or permission to check
139
+ * @param {object} [subject] - Optional subject for context
140
+ * @returns {boolean}
141
+ */
142
+ isGranted(attribute, subject = null) {
143
+ // For entity-specific permissions (entity:action), route to appropriate adapter
144
+ if (attribute.includes(':') && !attribute.startsWith('ROLE_')) {
145
+ const [entity] = attribute.split(':')
146
+ const adapter = this._getAdapter(entity)
147
+ if (adapter.isGranted) {
148
+ return adapter.isGranted(attribute, subject)
149
+ }
150
+ }
151
+
152
+ // Global permissions use default adapter
153
+ if (this._default.isGranted) {
154
+ return this._default.isGranted(attribute, subject)
155
+ }
156
+
157
+ // Fallback for adapters without isGranted
158
+ return true
159
+ }
160
+
161
+ /**
162
+ * Get the default adapter
163
+ * @returns {AuthAdapter}
164
+ */
165
+ get defaultAdapter() {
166
+ return this._default
167
+ }
168
+
169
+ /**
170
+ * Get adapter info for debugging
171
+ * @returns {object}
172
+ */
173
+ getAdapterInfo() {
174
+ const info = {
175
+ default: this._getAdapterName(this._default),
176
+ exactMatches: {},
177
+ patterns: []
178
+ }
179
+
180
+ for (const [entity, adapter] of this._exactMatches) {
181
+ info.exactMatches[entity] = this._getAdapterName(adapter)
182
+ }
183
+
184
+ for (const { pattern, adapter } of this._patterns) {
185
+ info.patterns.push({
186
+ pattern,
187
+ adapter: this._getAdapterName(adapter)
188
+ })
189
+ }
190
+
191
+ return info
192
+ }
193
+
194
+ /**
195
+ * Get adapter name for debugging
196
+ * @private
197
+ */
198
+ _getAdapterName(adapter) {
199
+ return adapter.constructor?.name || 'Unknown'
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Factory function to create CompositeAuthAdapter
205
+ *
206
+ * @param {object} config - { default, mapping }
207
+ * @param {object} [context] - Factory context
208
+ * @returns {CompositeAuthAdapter}
209
+ */
210
+ export function createCompositeAuthAdapter(config, context = {}) {
211
+ return new CompositeAuthAdapter(config, context)
212
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Auth Factory/Resolver Pattern
3
+ *
4
+ * Enables declarative auth adapter configuration with auto-resolution.
5
+ * Follows the same pattern as storage/factory.js for consistency.
6
+ *
7
+ * IMPORTANT: Backward compatible - passing an AuthAdapter instance
8
+ * works exactly as before (simple passthrough).
9
+ *
10
+ * Usage:
11
+ * ```js
12
+ * // Instance passthrough (current behavior, unchanged)
13
+ * authFactory(myAdapter) // → myAdapter
14
+ *
15
+ * // String pattern → factory resolves from registry
16
+ * authFactory('permissive') // → PermissiveAuthAdapter
17
+ * authFactory('jwt:internal') // → JwtAuthAdapter (if registered)
18
+ *
19
+ * // Config object → factory resolves
20
+ * authFactory({ type: 'jwt', tokenKey: 'auth_token' })
21
+ *
22
+ * // Composite config → creates CompositeAuthAdapter
23
+ * authFactory({
24
+ * default: myAdapter,
25
+ * mapping: { 'external-*': externalAdapter }
26
+ * })
27
+ * ```
28
+ */
29
+
30
+ import { AuthAdapter } from './AuthAdapter.js'
31
+ import { PermissiveAuthAdapter } from './PermissiveAdapter.js'
32
+
33
+ /**
34
+ * Built-in auth adapter types
35
+ * Extended via context.authTypes for custom adapters
36
+ * @type {Record<string, typeof AuthAdapter>}
37
+ */
38
+ export const authTypes = {
39
+ permissive: PermissiveAuthAdapter
40
+ }
41
+
42
+ /**
43
+ * Parse auth pattern 'type' or 'type:scope'
44
+ *
45
+ * @param {string} pattern - Auth pattern (e.g., 'jwt', 'apikey:external')
46
+ * @returns {{type: string, scope?: string} | null} Parsed config or null
47
+ *
48
+ * @example
49
+ * parseAuthPattern('jwt') // → { type: 'jwt' }
50
+ * parseAuthPattern('apikey:external') // → { type: 'apikey', scope: 'external' }
51
+ */
52
+ export function parseAuthPattern(pattern) {
53
+ if (typeof pattern !== 'string') return null
54
+
55
+ const match = pattern.match(/^(\w+)(?::(.+))?$/)
56
+ if (match) {
57
+ const [, type, scope] = match
58
+ return scope ? { type, scope } : { type }
59
+ }
60
+ return null
61
+ }
62
+
63
+ /**
64
+ * Default auth resolver - creates adapter instance from config
65
+ *
66
+ * @param {object} config - Normalized auth config with `type` property
67
+ * @param {object} context - Context with authTypes registry
68
+ * @returns {AuthAdapter} Adapter instance
69
+ */
70
+ export function defaultAuthResolver(config, context = {}) {
71
+ const { type, ...rest } = config
72
+
73
+ // Merge built-in types with custom types from context
74
+ const allTypes = { ...authTypes, ...context.authTypes }
75
+ const AdapterClass = allTypes[type]
76
+
77
+ if (!AdapterClass) {
78
+ throw new Error(
79
+ `[authFactory] Unknown auth type: "${type}". ` +
80
+ `Available: ${Object.keys(allTypes).join(', ')}`
81
+ )
82
+ }
83
+
84
+ return new AdapterClass(rest)
85
+ }
86
+
87
+ /**
88
+ * Check if config is a composite auth config
89
+ * Composite config has 'default' + optional 'mapping'
90
+ *
91
+ * @param {any} config
92
+ * @returns {boolean}
93
+ */
94
+ function isCompositeConfig(config) {
95
+ return (
96
+ config &&
97
+ typeof config === 'object' &&
98
+ 'default' in config &&
99
+ !('type' in config)
100
+ )
101
+ }
102
+
103
+ /**
104
+ * Auth factory - normalizes input and resolves to adapter
105
+ *
106
+ * Handles:
107
+ * - AuthAdapter instance → return directly (backward compatible)
108
+ * - String pattern 'type' → parse and resolve
109
+ * - Config object with 'type' → resolve via registry
110
+ * - Config object with 'default' → create CompositeAuthAdapter
111
+ *
112
+ * @param {AuthAdapter | string | object} config - Auth config
113
+ * @param {object} [context={}] - Context with authTypes, authResolver
114
+ * @returns {AuthAdapter} Adapter instance
115
+ *
116
+ * @example
117
+ * // Instance passthrough (most common, backward compatible)
118
+ * authFactory(myAdapter) // → myAdapter
119
+ *
120
+ * // String patterns
121
+ * authFactory('permissive') // → PermissiveAuthAdapter
122
+ * authFactory('jwt') // → JwtAuthAdapter (if registered)
123
+ *
124
+ * // Config objects
125
+ * authFactory({ type: 'jwt', tokenKey: 'token' })
126
+ *
127
+ * // Composite (multi-source)
128
+ * authFactory({
129
+ * default: myAdapter,
130
+ * mapping: { 'products': apiKeyAdapter }
131
+ * })
132
+ */
133
+ export function authFactory(config, context = {}) {
134
+ const { authResolver = defaultAuthResolver } = context
135
+
136
+ // Null/undefined → permissive (safe default)
137
+ if (config == null) {
138
+ return new PermissiveAuthAdapter()
139
+ }
140
+
141
+ // Already an AuthAdapter instance → return directly (backward compatible)
142
+ if (config instanceof AuthAdapter) {
143
+ return config
144
+ }
145
+
146
+ // Duck-typed adapter (has canPerform method)
147
+ if (typeof config.canPerform === 'function') {
148
+ return config
149
+ }
150
+
151
+ // String pattern → parse and resolve
152
+ if (typeof config === 'string') {
153
+ const parsed = parseAuthPattern(config)
154
+ if (!parsed) {
155
+ throw new Error(`[authFactory] Invalid auth pattern: "${config}"`)
156
+ }
157
+ return authResolver(parsed, context)
158
+ }
159
+
160
+ // Config object
161
+ if (typeof config === 'object') {
162
+ // Composite config: { default: ..., mapping: { ... } }
163
+ // Handled separately to avoid circular dependency
164
+ // The CompositeAuthAdapter is resolved via authTypes registry
165
+ if (isCompositeConfig(config)) {
166
+ const CompositeClass = context.authTypes?.composite || context.CompositeAuthAdapter
167
+ if (!CompositeClass) {
168
+ throw new Error(
169
+ '[authFactory] Composite config requires CompositeAuthAdapter. ' +
170
+ 'Pass it via context.CompositeAuthAdapter or register as context.authTypes.composite'
171
+ )
172
+ }
173
+ return new CompositeClass(config, context)
174
+ }
175
+
176
+ // Simple config with type: { type: 'jwt', ... }
177
+ if (config.type) {
178
+ return authResolver(config, context)
179
+ }
180
+
181
+ throw new Error(
182
+ '[authFactory] Config object requires either "type" or "default" property'
183
+ )
184
+ }
185
+
186
+ throw new Error(
187
+ `[authFactory] Invalid auth config: ${typeof config}. ` +
188
+ 'Expected AuthAdapter instance, string, or config object.'
189
+ )
190
+ }
191
+
192
+ /**
193
+ * Create a custom auth factory with bound context
194
+ *
195
+ * @param {object} context - Context with authTypes, authResolver
196
+ * @returns {function} Auth factory with bound context
197
+ *
198
+ * @example
199
+ * const myAuthFactory = createAuthFactory({
200
+ * authTypes: { jwt: JwtAuthAdapter, apikey: ApiKeyAuthAdapter }
201
+ * })
202
+ *
203
+ * const adapter = myAuthFactory('jwt')
204
+ */
205
+ export function createAuthFactory(context) {
206
+ return (config) => authFactory(config, context)
207
+ }