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.
- package/package.json +2 -1
- package/src/components/forms/FormPage.vue +1 -1
- package/src/components/layout/AppLayout.vue +13 -1
- package/src/components/layout/Zone.vue +40 -23
- package/src/composables/index.js +1 -0
- package/src/composables/useAuth.js +44 -4
- package/src/composables/useCurrentEntity.js +44 -0
- package/src/composables/useFormPageBuilder.js +3 -3
- package/src/composables/useNavContext.js +24 -8
- package/src/debug/AuthCollector.js +340 -0
- package/src/debug/Collector.js +235 -0
- package/src/debug/DebugBridge.js +163 -0
- package/src/debug/DebugModule.js +215 -0
- package/src/debug/EntitiesCollector.js +403 -0
- package/src/debug/ErrorCollector.js +66 -0
- package/src/debug/LocalStorageAdapter.js +150 -0
- package/src/debug/SignalCollector.js +87 -0
- package/src/debug/ToastCollector.js +82 -0
- package/src/debug/ZonesCollector.js +300 -0
- package/src/debug/components/DebugBar.vue +1232 -0
- package/src/debug/components/ObjectTree.vue +194 -0
- package/src/debug/components/index.js +8 -0
- package/src/debug/components/panels/AuthPanel.vue +174 -0
- package/src/debug/components/panels/EntitiesPanel.vue +712 -0
- package/src/debug/components/panels/EntriesPanel.vue +188 -0
- package/src/debug/components/panels/ToastsPanel.vue +112 -0
- package/src/debug/components/panels/ZonesPanel.vue +232 -0
- package/src/debug/components/panels/index.js +8 -0
- package/src/debug/index.js +31 -0
- package/src/entity/EntityManager.js +142 -20
- package/src/entity/auth/CompositeAuthAdapter.js +212 -0
- package/src/entity/auth/factory.js +207 -0
- package/src/entity/auth/factory.test.js +257 -0
- package/src/entity/auth/index.js +14 -0
- package/src/entity/storage/MockApiStorage.js +51 -2
- package/src/entity/storage/index.js +9 -2
- package/src/index.js +7 -0
- package/src/kernel/Kernel.js +468 -48
- package/src/kernel/KernelContext.js +385 -0
- package/src/kernel/Module.js +111 -0
- package/src/kernel/ModuleLoader.js +573 -0
- package/src/kernel/SignalBus.js +2 -7
- package/src/kernel/index.js +14 -0
- package/src/toast/ToastBridgeModule.js +70 -0
- package/src/toast/ToastListener.vue +47 -0
- package/src/toast/index.js +15 -0
- 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
|
|
170
|
-
*
|
|
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
|
|
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
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
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
|
-
|
|
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
|
+
}
|