configuration-management 0.1.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 (35) hide show
  1. package/README.md +199 -0
  2. package/actions/configurations/commerce/index.js +55 -0
  3. package/actions/configurations/export-config/index.js +259 -0
  4. package/actions/configurations/ext.config.yaml +151 -0
  5. package/actions/configurations/import-config/index.js +544 -0
  6. package/actions/configurations/registration/index.js +37 -0
  7. package/actions/configurations/sync-store-mappings-from-commerce/index.js +199 -0
  8. package/actions/configurations/system-config-list/index.js +127 -0
  9. package/actions/configurations/system-config-save/index.js +160 -0
  10. package/actions/configurations/system-config-schema/index.js +327 -0
  11. package/actions/utils.js +73 -0
  12. package/package.json +74 -0
  13. package/scripts/setup-app-config.js +114 -0
  14. package/src/abdb-config.js +241 -0
  15. package/src/abdb-helper.js +476 -0
  16. package/src/index.js +20 -0
  17. package/src/oauth1a.js +135 -0
  18. package/src/system-config-crypto.js +113 -0
  19. package/src/system-config-shared.js +89 -0
  20. package/web/src/components/App.js +47 -0
  21. package/web/src/components/AppSectionNav.js +49 -0
  22. package/web/src/components/ExtensionRegistration.js +33 -0
  23. package/web/src/components/MainPage.js +46 -0
  24. package/web/src/components/SystemConfig.js +1464 -0
  25. package/web/src/components/SystemConfigSchemaEditor.js +459 -0
  26. package/web/src/hooks/useConfirm.js +355 -0
  27. package/web/src/hooks/useSystemConfig.js +238 -0
  28. package/web/src/hooks/useSystemConfigSchema.js +102 -0
  29. package/web/src/index.js +41 -0
  30. package/web/src/schema/systemConfigSchema.js +82 -0
  31. package/web/src/settings.js +57 -0
  32. package/web/src/styles/index.css +326 -0
  33. package/web/src/theme.js +104 -0
  34. package/web/src/utils/storeMappingsFromCommerceRest.js +73 -0
  35. package/web/src/utils.js +52 -0
@@ -0,0 +1,199 @@
1
+ /*
2
+ Copyright 2025 Adobe. All rights reserved.
3
+ This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License. You may obtain a copy
5
+ of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ */
7
+
8
+ // Rebuild the legacy `store_mappings` blob from Commerce REST and upsert it
9
+ // into system_config_data at general/settings/store_mappings (default scope).
10
+ //
11
+ // Output shape (matches the original middleware-config.json):
12
+ // {
13
+ // "0": { "code": "admin", "language_code": "en", "website_code": "admin", "website_id": "0" },
14
+ // "1": { "code": "default", "language_code": "en", "website_code": "base", "website_id": "1" },
15
+ // "2": { "code": "en_ch", "language_code": "en", "website_code": "ch", "website_id": "2" },
16
+ // ...
17
+ // }
18
+ //
19
+ // Inputs (action params):
20
+ // dryRun — true → preview only, no write
21
+ // includeAdmin — true → include website id=0 ("admin"). Default false.
22
+ //
23
+ // Trigger from `POST .../sync-store-mappings-from-commerce` or wire to a UI
24
+ // button. Stored as a JSON string in a textarea field, so existing
25
+ // `getConfig('general/settings/store_mappings', params)` callers get the
26
+ // parsed object back unchanged.
27
+
28
+ const { Core } = require('@adobe/aio-sdk')
29
+ const { errorResponse } = require('../../utils')
30
+ const { getClient } = require('configuration-management/abdb')
31
+ const { getCommerceOauthClient } = require('configuration-management/oauth1a')
32
+ const { toStateKey } = require('configuration-management/shared')
33
+
34
+ const DATA_COLLECTION = 'system_config_data'
35
+ const PATH = 'general/settings/store_mappings'
36
+ const SCOPE = 'default'
37
+ const SCOPE_ID = '0'
38
+
39
+ /**
40
+ * Derive language_code from a store-view code following the conventional
41
+ * `<lang>_<region>` pattern (e.g. en_ch → 'en', fr_ch → 'fr'). Codes without
42
+ * an underscore fall back to 'en' to match the legacy middleware shape.
43
+ */
44
+ function deriveLanguageCode (code) {
45
+ const m = String(code || '').toLowerCase().match(/^([a-z]{2})_/)
46
+ return m ? m[1] : 'en'
47
+ }
48
+
49
+ async function ensureCollection (client, name) {
50
+ try {
51
+ await client.createCollection(name)
52
+ } catch (err) {
53
+ const msg = (err && err.message) ? String(err.message) : String(err)
54
+ if (!/exist|already|duplicate/i.test(msg)) throw err
55
+ }
56
+ }
57
+
58
+ async function tryFindOne (collection, query) {
59
+ try {
60
+ const arr = await collection.find(query).limit(1).toArray()
61
+ return arr && arr.length ? arr[0] : null
62
+ } catch (err) {
63
+ const msg = err && err.message ? String(err.message) : String(err)
64
+ if (/not found/i.test(msg)) return null
65
+ throw err
66
+ }
67
+ }
68
+
69
+ async function fetchCommerceData (params, logger) {
70
+ if (!params.COMMERCE_BASE_URL) {
71
+ throw new Error('COMMERCE_BASE_URL is not configured')
72
+ }
73
+ const oauth = getCommerceOauthClient(
74
+ {
75
+ url: params.COMMERCE_BASE_URL,
76
+ consumerKey: params.COMMERCE_CONSUMER_KEY,
77
+ consumerSecret: params.COMMERCE_CONSUMER_SECRET,
78
+ accessToken: params.COMMERCE_ACCESS_TOKEN,
79
+ accessTokenSecret: params.COMMERCE_ACCESS_TOKEN_SECRET
80
+ },
81
+ logger
82
+ )
83
+ const [storeViews, websites] = await Promise.all([
84
+ oauth.get('store/storeViews'),
85
+ oauth.get('store/websites')
86
+ ])
87
+ return {
88
+ storeViews: Array.isArray(storeViews) ? storeViews : [],
89
+ websites: Array.isArray(websites) ? websites : []
90
+ }
91
+ }
92
+
93
+ function buildStoreMappings (storeViews, websites, { includeAdmin }) {
94
+ const websiteById = new Map()
95
+ for (const w of websites) {
96
+ if (w && w.id != null) websiteById.set(String(w.id), w)
97
+ }
98
+
99
+ const mapping = {}
100
+ for (const sv of storeViews) {
101
+ if (!sv || sv.id == null) continue
102
+ const storeId = String(sv.id)
103
+ if (!includeAdmin && (storeId === '0' || sv.code === 'admin')) continue
104
+ const websiteId = sv.website_id != null ? String(sv.website_id) : ''
105
+ const website = websiteById.get(websiteId)
106
+ mapping[storeId] = {
107
+ code: String(sv.code || ''),
108
+ language_code: deriveLanguageCode(sv.code),
109
+ website_code: website ? String(website.code || '') : '',
110
+ website_id: websiteId
111
+ }
112
+ }
113
+ return mapping
114
+ }
115
+
116
+ async function main (params) {
117
+ const logger = Core.Logger('sync-store-mappings', { level: params.LOG_LEVEL || 'info' })
118
+ const dryRun = params.dryRun === true || params.dryRun === 'true'
119
+ const includeAdmin = params.includeAdmin === true || params.includeAdmin === 'true'
120
+
121
+ // 1. Fetch from Commerce.
122
+ let commerce
123
+ try {
124
+ commerce = await fetchCommerceData(params, logger)
125
+ } catch (e) {
126
+ logger.error(`Commerce REST failed: ${e.message}`)
127
+ return errorResponse(500, `Commerce REST failed: ${e.message}`, logger)
128
+ }
129
+
130
+ // 2. Build the mapping.
131
+ const mapping = buildStoreMappings(commerce.storeViews, commerce.websites, { includeAdmin })
132
+ const count = Object.keys(mapping).length
133
+ if (count === 0) {
134
+ return errorResponse(500, 'No store views returned by Commerce — refusing to overwrite with empty mapping', logger)
135
+ }
136
+
137
+ if (dryRun) {
138
+ return {
139
+ statusCode: 200,
140
+ body: { ok: true, dryRun: true, count, mapping }
141
+ }
142
+ }
143
+
144
+ // 3. Upsert into ABDB at general/settings/store_mappings (default scope).
145
+ let dbHandle
146
+ try {
147
+ dbHandle = await getClient(params)
148
+ } catch (e) {
149
+ logger.error(`ABDB connect failed: ${e.message}`)
150
+ return errorResponse(500, `ABDB connect failed: ${e.message}`, logger)
151
+ }
152
+ const { client, close } = dbHandle
153
+
154
+ try {
155
+ await ensureCollection(client, DATA_COLLECTION)
156
+ const collection = await client.collection(DATA_COLLECTION)
157
+ const _id = toStateKey(SCOPE, SCOPE_ID, PATH)
158
+ const now = new Date().toISOString()
159
+ // Stored as a JSON STRING (textarea field) so getConfig's maybeParseJson
160
+ // returns the object on read.
161
+ const value = JSON.stringify(mapping, null, 2)
162
+
163
+ try {
164
+ await collection.updateOne(
165
+ { _id },
166
+ {
167
+ $set: { value, updatedAt: now, scope: SCOPE, scope_id: SCOPE_ID, path: PATH },
168
+ $setOnInsert: { _id, createdAt: now }
169
+ },
170
+ { upsert: true }
171
+ )
172
+ } catch (err) {
173
+ const msg = (err && err.message) ? String(err.message) : String(err)
174
+ if (!/upsert|unsupported|not implemented/i.test(msg)) throw err
175
+ // Fallback: find-then-write.
176
+ const existing = await tryFindOne(collection, { _id })
177
+ if (existing) {
178
+ await collection.updateOne({ _id }, { $set: { value, updatedAt: now } })
179
+ } else {
180
+ await collection.insertOne({
181
+ _id, scope: SCOPE, scope_id: SCOPE_ID, path: PATH, value, createdAt: now, updatedAt: now
182
+ })
183
+ }
184
+ }
185
+
186
+ logger.info(`store_mappings synced: ${count} entries → ${PATH}`)
187
+ return {
188
+ statusCode: 200,
189
+ body: { ok: true, count, mapping, path: PATH, scope: SCOPE, scope_id: SCOPE_ID }
190
+ }
191
+ } catch (error) {
192
+ logger.error(error)
193
+ return errorResponse(500, error.message || 'sync failed', logger)
194
+ } finally {
195
+ try { await close() } catch (_) {}
196
+ }
197
+ }
198
+
199
+ exports.main = main
@@ -0,0 +1,127 @@
1
+ /*
2
+ Copyright 2025 Adobe. All rights reserved.
3
+ This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License. You may obtain a copy
5
+ of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ */
7
+
8
+ const { Core } = require('@adobe/aio-sdk')
9
+ const { errorResponse, checkMissingRequestInputs } = require('../../utils')
10
+ const { getClient } = require('configuration-management/abdb')
11
+ const {
12
+ SENSITIVE_PLACEHOLDER,
13
+ toStateKey,
14
+ normalizeScope,
15
+ normalizeScopeId,
16
+ buildInheritanceChain
17
+ } = require('configuration-management/shared')
18
+ const { decrypt, isEncrypted } = require('configuration-management/crypto')
19
+
20
+ const COLLECTION = 'system_config_data'
21
+
22
+ async function ensureCollection (client) {
23
+ try {
24
+ await client.createCollection(COLLECTION)
25
+ } catch (err) {
26
+ const msg = (err && err.message) ? String(err.message) : String(err)
27
+ if (!/exist|already|duplicate/i.test(msg)) throw err
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Returns values for the requested paths from the ABDB system_config_data
33
+ * collection, applying Magento-style scope inheritance.
34
+ *
35
+ * Document shape (mirrors core_config_data):
36
+ * { _id, scope, scope_id, path, value, updatedAt }
37
+ *
38
+ * Response:
39
+ * { scope, scopeId, items: { "<path>": { value, origin, sensitive } } }
40
+ */
41
+ async function main (params) {
42
+ const logger = Core.Logger('system-config-list', { level: params.LOG_LEVEL || 'info' })
43
+
44
+ const errorMessage = checkMissingRequestInputs(params, ['paths'], [])
45
+ if (errorMessage) return errorResponse(400, errorMessage, logger)
46
+
47
+ const { paths, sensitivePaths = [], scope: rawScope = 'default', scopeId: rawScopeId = '0', parentWebsiteId } = params
48
+ if (!Array.isArray(paths)) {
49
+ return errorResponse(400, 'paths must be an array of "section/group/field" strings', logger)
50
+ }
51
+
52
+ let scope, scopeId
53
+ try {
54
+ scope = normalizeScope(rawScope)
55
+ scopeId = normalizeScopeId(scope, rawScopeId)
56
+ } catch (e) {
57
+ return errorResponse(400, e.message, logger)
58
+ }
59
+
60
+ const sensitiveSet = new Set(sensitivePaths)
61
+ const chain = buildInheritanceChain(scope, scopeId, parentWebsiteId)
62
+
63
+ let dbHandle
64
+ try {
65
+ dbHandle = await getClient(params)
66
+ } catch (e) {
67
+ logger.error(`ABDB connect failed: ${e.message}`)
68
+ return errorResponse(500, `ABDB connect failed: ${e.message}`, logger)
69
+ }
70
+ const { client, close } = dbHandle
71
+
72
+ try {
73
+ await ensureCollection(client)
74
+ const collection = await client.collection(COLLECTION)
75
+
76
+ // Batch fetch every (scope, path) combination we might need in one go.
77
+ const ids = []
78
+ for (const path of paths) {
79
+ for (const link of chain) {
80
+ ids.push(toStateKey(link.scope, link.scopeId, path))
81
+ }
82
+ }
83
+ const docs = ids.length
84
+ ? await collection.find({ _id: { $in: ids } }).toArray()
85
+ : []
86
+ const byId = new Map(docs.map((d) => [d._id, d]))
87
+
88
+ const items = {}
89
+ for (const path of paths) {
90
+ let resolved = null
91
+ for (const link of chain) {
92
+ const id = toStateKey(link.scope, link.scopeId, path)
93
+ const doc = byId.get(id)
94
+ if (!doc || doc.value === undefined) continue
95
+
96
+ let value = doc.value
97
+ if (isEncrypted(value)) {
98
+ try {
99
+ value = decrypt(value, params)
100
+ } catch (e) {
101
+ logger.error(`Failed to decrypt ${path} @ ${link.scope}:${link.scopeId}: ${e.message}`)
102
+ value = ''
103
+ }
104
+ }
105
+ if (sensitiveSet.has(path) && value !== '' && value != null) {
106
+ value = SENSITIVE_PLACEHOLDER
107
+ }
108
+ resolved = { value, origin: { scope: link.scope, scopeId: link.scopeId } }
109
+ break
110
+ }
111
+ items[path] = resolved || { value: undefined, origin: null }
112
+ items[path].sensitive = sensitiveSet.has(path)
113
+ }
114
+
115
+ return {
116
+ statusCode: 200,
117
+ body: { message: 'System config fetched', scope, scopeId, items }
118
+ }
119
+ } catch (error) {
120
+ logger.error(error)
121
+ return errorResponse(500, error.message || 'Failed to read system config', logger)
122
+ } finally {
123
+ try { await close() } catch (_) {}
124
+ }
125
+ }
126
+
127
+ exports.main = main
@@ -0,0 +1,160 @@
1
+ /*
2
+ Copyright 2025 Adobe. All rights reserved.
3
+ This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License. You may obtain a copy
5
+ of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ */
7
+
8
+ const { Core } = require('@adobe/aio-sdk')
9
+ const { errorResponse, checkMissingRequestInputs, logDetails } = require('../../utils')
10
+ const { getClient } = require('configuration-management/abdb')
11
+ const {
12
+ SENSITIVE_PLACEHOLDER,
13
+ USE_DEFAULT_SENTINEL,
14
+ isValidPath,
15
+ toStateKey,
16
+ normalizeScope,
17
+ normalizeScopeId
18
+ } = require('configuration-management/shared')
19
+ const { encrypt } = require('configuration-management/crypto')
20
+
21
+ const COLLECTION = 'system_config_data'
22
+
23
+ async function ensureCollection (client) {
24
+ try {
25
+ await client.createCollection(COLLECTION)
26
+ } catch (err) {
27
+ const msg = (err && err.message) ? String(err.message) : String(err)
28
+ if (!/exist|already|duplicate/i.test(msg)) throw err
29
+ }
30
+ }
31
+
32
+ /**
33
+ * ABDB's `findOne` throws "Document not found" on a miss in this driver
34
+ * version. `find().limit(1).toArray()` always returns an array, so use that
35
+ * to mean "fetch one or null". Belt-and-braces try/catch in case `find`
36
+ * itself starts throwing in a future version.
37
+ */
38
+ async function tryFindOne (collection, query) {
39
+ try {
40
+ const arr = await collection.find(query).limit(1).toArray()
41
+ return arr && arr.length ? arr[0] : null
42
+ } catch (err) {
43
+ const msg = (err && err.message) ? String(err.message) : String(err)
44
+ if (/not found/i.test(msg)) return null
45
+ throw err
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Upsert/delete values in the ABDB `system_config_data` collection.
51
+ *
52
+ * Per-value behavior (unchanged):
53
+ * - value === SENSITIVE_PLACEHOLDER → no-op (UI sent back a masked value)
54
+ * - value === USE_DEFAULT_SENTINEL → delete the scope override (inherit)
55
+ * - sensitive=true → encrypt with AES-256-GCM before writing
56
+ *
57
+ * Document shape (mirrors core_config_data):
58
+ * { _id, scope, scope_id, path, value, createdAt, updatedAt }
59
+ */
60
+ async function main (params) {
61
+ const logger = Core.Logger('system-config-save', { level: params.LOG_LEVEL || 'info' })
62
+
63
+ const errorMessage = checkMissingRequestInputs(params, ['values'], [])
64
+ if (errorMessage) return errorResponse(400, errorMessage, logger)
65
+
66
+ const { values, sensitivePaths = [], scope: rawScope = 'default', scopeId: rawScopeId = '0' } = params
67
+ if (!values || typeof values !== 'object' || Array.isArray(values)) {
68
+ return errorResponse(400, 'values must be an object of { "section/group/field": value }', logger)
69
+ }
70
+
71
+ let scope, scopeId
72
+ try {
73
+ scope = normalizeScope(rawScope)
74
+ scopeId = normalizeScopeId(scope, rawScopeId)
75
+ } catch (e) {
76
+ return errorResponse(400, e.message, logger)
77
+ }
78
+
79
+ const sensitiveSet = new Set(sensitivePaths)
80
+
81
+ let dbHandle
82
+ try {
83
+ dbHandle = await getClient(params)
84
+ } catch (e) {
85
+ logger.error(`ABDB connect failed: ${e.message}`)
86
+ return errorResponse(500, `ABDB connect failed: ${e.message}`, logger)
87
+ }
88
+ const { client, close } = dbHandle
89
+
90
+ try {
91
+ await ensureCollection(client)
92
+ const collection = await client.collection(COLLECTION)
93
+ const now = new Date().toISOString()
94
+ const saved = []
95
+ const deleted = []
96
+ const skipped = []
97
+
98
+ for (const [path, value] of Object.entries(values)) {
99
+ if (!isValidPath(path)) {
100
+ skipped.push({ path, reason: 'invalid path format' })
101
+ continue
102
+ }
103
+ const id = toStateKey(scope, scopeId, path)
104
+
105
+ if (value === USE_DEFAULT_SENTINEL) {
106
+ await collection.deleteOne({ _id: id })
107
+ deleted.push(path)
108
+ continue
109
+ }
110
+ if (sensitiveSet.has(path) && value === SENSITIVE_PLACEHOLDER) {
111
+ skipped.push({ path, reason: 'masked placeholder, kept existing' })
112
+ continue
113
+ }
114
+
115
+ let stored = value
116
+ if (sensitiveSet.has(path) && stored !== '' && stored != null) {
117
+ stored = encrypt(String(stored), params)
118
+ }
119
+
120
+ const existing = await tryFindOne(collection, { _id: id })
121
+ if (existing) {
122
+ await collection.updateOne(
123
+ { _id: id },
124
+ { $set: { value: stored, updatedAt: now } }
125
+ )
126
+ } else {
127
+ await collection.insertOne({
128
+ _id: id,
129
+ scope,
130
+ scope_id: scopeId,
131
+ path,
132
+ value: stored,
133
+ createdAt: now,
134
+ updatedAt: now
135
+ })
136
+ }
137
+ saved.push(path)
138
+ }
139
+
140
+ const redactedForLog = Object.fromEntries(
141
+ Object.entries(values).map(([p, v]) => [p, sensitiveSet.has(p) ? '[REDACTED]' : v])
142
+ )
143
+ logDetails(
144
+ 'system-config-save',
145
+ `scope=${scope}:${scopeId} payload=${JSON.stringify(redactedForLog)}`
146
+ )
147
+
148
+ return {
149
+ statusCode: 200,
150
+ body: { message: 'System config saved', scope, scopeId, saved, deleted, skipped }
151
+ }
152
+ } catch (error) {
153
+ logger.error(error)
154
+ return errorResponse(500, error.message || 'Failed to save system config', logger)
155
+ } finally {
156
+ try { await close() } catch (_) {}
157
+ }
158
+ }
159
+
160
+ exports.main = main