qdadm 0.31.0 → 0.34.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.
@@ -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 { EntityAuthAdapter } from './EntityAuthAdapter.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 EntityAuthAdapter>}
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 {EntityAuthAdapter} 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 {EntityAuthAdapter | string | object} config - Auth config
113
+ * @param {object} [context={}] - Context with authTypes, authResolver
114
+ * @returns {EntityAuthAdapter} 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 EntityAuthAdapter instance → return directly (backward compatible)
142
+ if (config instanceof EntityAuthAdapter) {
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
+ }
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Tests for auth factory and CompositeAuthAdapter
3
+ */
4
+ import { describe, it, expect } from 'vitest'
5
+ import { EntityAuthAdapter } from './EntityAuthAdapter.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 EntityAuthAdapter {
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 EntityAuthAdapter {
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 EntityAuthAdapter 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,10 +3,14 @@
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
9
- export { AuthAdapter, AuthActions } from './AuthAdapter.js'
13
+ export { EntityAuthAdapter, AuthActions } from './EntityAuthAdapter.js'
10
14
 
11
15
  // Role Hierarchy (topological resolution)
12
16
  export { RoleHierarchy, createRoleHierarchy } from './RoleHierarchy.js'
@@ -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'
@@ -65,13 +65,26 @@ export class MockApiStorage extends IStorage {
65
65
  return MockApiStorage.capabilities.supportsCaching
66
66
  }
67
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
+
68
80
  constructor(options = {}) {
69
81
  super()
70
82
  const {
71
83
  entityName,
72
84
  idField = 'id',
73
85
  generateId = () => Date.now().toString(36) + Math.random().toString(36).substr(2),
74
- initialData = null
86
+ initialData = null,
87
+ authCheck = null // Optional: () => boolean - throws 401 if returns false
75
88
  } = options
76
89
 
77
90
  if (!entityName) {
@@ -83,11 +96,24 @@ export class MockApiStorage extends IStorage {
83
96
  this.generateId = generateId
84
97
  this._storageKey = `mockapi_${entityName}_data`
85
98
  this._data = new Map()
99
+ this._authCheck = authCheck
86
100
 
87
101
  // Load from localStorage or seed with initialData
88
102
  this._loadFromStorage(initialData)
89
103
  }
90
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
+
91
117
  /**
92
118
  * Load data from localStorage, seed with initialData if empty
93
119
  * @param {Array|null} initialData - Data to seed if storage is empty
@@ -158,6 +184,7 @@ export class MockApiStorage extends IStorage {
158
184
  * @returns {Promise<{ items: Array, total: number }>}
159
185
  */
160
186
  async list(params = {}) {
187
+ this._checkAuth()
161
188
  const { page = 1, page_size = 20, sort_by, sort_order = 'asc', filters = {}, search } = params
162
189
 
163
190
  let items = this._getAll()
@@ -213,6 +240,7 @@ export class MockApiStorage extends IStorage {
213
240
  * @returns {Promise<object>}
214
241
  */
215
242
  async get(id) {
243
+ this._checkAuth()
216
244
  const item = this._data.get(String(id))
217
245
  if (!item) {
218
246
  const error = new Error(`Entity not found: ${id}`)
@@ -228,6 +256,7 @@ export class MockApiStorage extends IStorage {
228
256
  * @returns {Promise<Array<object>>}
229
257
  */
230
258
  async getMany(ids) {
259
+ this._checkAuth()
231
260
  if (!ids || ids.length === 0) return []
232
261
  const results = []
233
262
  for (const id of ids) {
@@ -282,6 +311,7 @@ export class MockApiStorage extends IStorage {
282
311
  * @returns {Promise<object>}
283
312
  */
284
313
  async create(data) {
314
+ this._checkAuth()
285
315
  const id = data[this.idField] || this.generateId()
286
316
  const newItem = {
287
317
  ...data,
@@ -300,6 +330,7 @@ export class MockApiStorage extends IStorage {
300
330
  * @returns {Promise<object>}
301
331
  */
302
332
  async update(id, data) {
333
+ this._checkAuth()
303
334
  if (!this._data.has(String(id))) {
304
335
  const error = new Error(`Entity not found: ${id}`)
305
336
  error.status = 404
@@ -322,6 +353,7 @@ export class MockApiStorage extends IStorage {
322
353
  * @returns {Promise<object>}
323
354
  */
324
355
  async patch(id, data) {
356
+ this._checkAuth()
325
357
  const existing = this._data.get(String(id))
326
358
  if (!existing) {
327
359
  const error = new Error(`Entity not found: ${id}`)
@@ -344,6 +376,7 @@ export class MockApiStorage extends IStorage {
344
376
  * @returns {Promise<void>}
345
377
  */
346
378
  async delete(id) {
379
+ this._checkAuth()
347
380
  if (!this._data.has(String(id))) {
348
381
  const error = new Error(`Entity not found: ${id}`)
349
382
  error.status = 404
package/src/index.js CHANGED
@@ -17,6 +17,9 @@ export { createQdadm } from './plugin.js'
17
17
  // Entity system
18
18
  export * from './entity/index.js'
19
19
 
20
+ // Session auth (user authentication)
21
+ export * from './auth/index.js'
22
+
20
23
  // Orchestrator
21
24
  export * from './orchestrator/index.js'
22
25