@adobedjangir/commerce-admin-management 0.0.2

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 (48) hide show
  1. package/README.md +637 -0
  2. package/actions/commerce-creds.js +262 -0
  3. package/actions/configurations/commerce/index.js +41 -0
  4. package/actions/configurations/commerce-connection-save/index.js +53 -0
  5. package/actions/configurations/commerce-connection-status/index.js +27 -0
  6. package/actions/configurations/commerce-connection-test/index.js +47 -0
  7. package/actions/configurations/export-config/index.js +256 -0
  8. package/actions/configurations/ext.config.yaml +192 -0
  9. package/actions/configurations/import-config/index.js +541 -0
  10. package/actions/configurations/registration/index.js +37 -0
  11. package/actions/configurations/sync-store-mappings-from-commerce/index.js +190 -0
  12. package/actions/configurations/system-config-list/index.js +127 -0
  13. package/actions/configurations/system-config-save/index.js +160 -0
  14. package/actions/configurations/system-config-schema/index.js +327 -0
  15. package/actions/utils.js +73 -0
  16. package/package.json +80 -0
  17. package/scripts/build-web.js +58 -0
  18. package/scripts/setup.js +445 -0
  19. package/src/abdb-config.js +8 -0
  20. package/src/abdb-helper.js +8 -0
  21. package/src/index.js +22 -0
  22. package/src/oauth1a.js +8 -0
  23. package/src/system-config-crypto.js +8 -0
  24. package/src/system-config-shared.js +8 -0
  25. package/web/dist/index.css +305 -0
  26. package/web/dist/index.js +2986 -0
  27. package/web/index.js +7 -0
  28. package/web/src/components/App.js +54 -0
  29. package/web/src/components/AppSectionNav.js +49 -0
  30. package/web/src/components/CommerceSetupWizard.js +160 -0
  31. package/web/src/components/ExtensionRegistration.js +33 -0
  32. package/web/src/components/MainPage.js +147 -0
  33. package/web/src/components/SystemConfig.js +1464 -0
  34. package/web/src/components/SystemConfigSchemaEditor.js +459 -0
  35. package/web/src/hooks/useConfirm.js +355 -0
  36. package/web/src/hooks/useSystemConfig.js +238 -0
  37. package/web/src/hooks/useSystemConfigSchema.js +102 -0
  38. package/web/src/index.js +46 -0
  39. package/web/src/nav-icons.js +30 -0
  40. package/web/src/nav.json +10 -0
  41. package/web/src/pages/index.js +17 -0
  42. package/web/src/schema/systemConfigSchema.js +82 -0
  43. package/web/src/settings.js +101 -0
  44. package/web/src/styles/index.css +337 -0
  45. package/web/src/theme.js +104 -0
  46. package/web/src/utils/storeMappingsFromCommerceRest.js +73 -0
  47. package/web/src/utils.js +52 -0
  48. package/web/styles.css +337 -0
@@ -0,0 +1,262 @@
1
+ /*
2
+ Copyright 2025 Adobe. All rights reserved.
3
+ Licensed under the Apache License, Version 2.0 (the "License");
4
+ */
5
+
6
+ // Centralized read/write/test for Adobe Commerce connection credentials.
7
+ //
8
+ // Storage: a single document in ABDB `system_config_data` at
9
+ // scope=default, scope_id=0, path=_system/commerce/connection
10
+ // whose `value` is an encrypted JSON blob:
11
+ // { baseUrl, consumerKey, consumerSecret, accessToken, accessTokenSecret }
12
+ //
13
+ // Everything except `baseUrl` is sensitive; the whole blob is encrypted with
14
+ // SYSTEM_CONFIG_CRYPT_KEY via system-config-crypto. We encrypt the JSON string
15
+ // as a unit (one ciphertext) rather than per-field, since the blob is read
16
+ // atomically and stored at a single key.
17
+
18
+ const { getClient } = require('@adobedjangir/commerce-admin-management/abdb')
19
+ const { toStateKey } = require('@adobedjangir/commerce-admin-management/shared')
20
+ const { encrypt, decrypt, isEncrypted } = require('@adobedjangir/commerce-admin-management/crypto')
21
+ const { getCommerceOauthClient } = require('@adobedjangir/commerce-admin-management/oauth1a')
22
+
23
+ const COLLECTION = 'system_config_data'
24
+ const SCOPE = 'default'
25
+ const SCOPE_ID = '0'
26
+ const PATH = '_system/commerce/connection'
27
+ const DOC_ID = toStateKey(SCOPE, SCOPE_ID, PATH)
28
+
29
+ // Per-cold-start cache so each action call doesn't pay ABDB+decrypt cost.
30
+ let credsCache = null
31
+ let credsCacheAt = 0
32
+ const CACHE_TTL_MS = 5 * 60 * 1000
33
+
34
+ function clearCommerceCredsCache () {
35
+ credsCache = null
36
+ credsCacheAt = 0
37
+ }
38
+
39
+ function normalizeBaseUrl (url) {
40
+ if (!url || typeof url !== 'string') return ''
41
+ const trimmed = url.trim()
42
+ if (!trimmed) return ''
43
+ return trimmed.endsWith('/') ? trimmed : trimmed + '/'
44
+ }
45
+
46
+ function toClientShape (creds) {
47
+ if (!creds) return null
48
+ return {
49
+ url: normalizeBaseUrl(creds.baseUrl),
50
+ consumerKey: creds.consumerKey,
51
+ consumerSecret: creds.consumerSecret,
52
+ accessToken: creds.accessToken,
53
+ accessTokenSecret: creds.accessTokenSecret
54
+ }
55
+ }
56
+
57
+ function maskCreds (creds) {
58
+ if (!creds) return null
59
+ const mask = (v) => (v ? '****' + String(v).slice(-4) : '')
60
+ return {
61
+ baseUrl: creds.baseUrl || '',
62
+ consumerKey: mask(creds.consumerKey),
63
+ consumerSecret: mask(creds.consumerSecret),
64
+ accessToken: mask(creds.accessToken),
65
+ accessTokenSecret: mask(creds.accessTokenSecret)
66
+ }
67
+ }
68
+
69
+ async function ensureCollection (client) {
70
+ try {
71
+ await client.createCollection(COLLECTION)
72
+ } catch (err) {
73
+ const msg = (err && err.message) ? String(err.message) : String(err)
74
+ if (!/exist|already|duplicate/i.test(msg)) throw err
75
+ }
76
+ }
77
+
78
+ async function tryFindOne (collection, query) {
79
+ try {
80
+ const arr = await collection.find(query).limit(1).toArray()
81
+ return arr && arr.length ? arr[0] : null
82
+ } catch (err) {
83
+ const msg = err && err.message ? String(err.message) : String(err)
84
+ if (/not found/i.test(msg)) return null
85
+ throw err
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Load the saved commerce creds from ABDB. Returns null if not configured.
91
+ * Returned shape: { baseUrl, consumerKey, consumerSecret, accessToken, accessTokenSecret }
92
+ */
93
+ async function readCommerceCreds (params, { fresh = false } = {}) {
94
+ const now = Date.now()
95
+ if (!fresh && credsCache && (now - credsCacheAt) < CACHE_TTL_MS) {
96
+ return credsCache
97
+ }
98
+
99
+ let handle
100
+ try {
101
+ handle = await getClient(params)
102
+ } catch (e) {
103
+ return null
104
+ }
105
+ try {
106
+ await ensureCollection(handle.client)
107
+ const collection = await handle.client.collection(COLLECTION)
108
+ const doc = await tryFindOne(collection, { _id: DOC_ID })
109
+ if (!doc || doc.value === undefined || doc.value === null || doc.value === '') {
110
+ credsCache = null
111
+ credsCacheAt = now
112
+ return null
113
+ }
114
+ let raw = doc.value
115
+ if (isEncrypted(raw)) {
116
+ raw = decrypt(raw, params)
117
+ }
118
+ let parsed
119
+ try {
120
+ parsed = typeof raw === 'string' ? JSON.parse(raw) : raw
121
+ } catch (_) {
122
+ return null
123
+ }
124
+ if (!parsed || !parsed.baseUrl) return null
125
+ credsCache = parsed
126
+ credsCacheAt = now
127
+ return parsed
128
+ } finally {
129
+ try { await handle.close() } catch (_) {}
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Persist commerce creds (encrypted JSON blob). Caller is responsible for
135
+ * having validated the connection first.
136
+ */
137
+ async function writeCommerceCreds (params, creds) {
138
+ if (!creds || !creds.baseUrl) {
139
+ throw new Error('baseUrl required')
140
+ }
141
+ const payload = JSON.stringify({
142
+ baseUrl: normalizeBaseUrl(creds.baseUrl),
143
+ consumerKey: String(creds.consumerKey || ''),
144
+ consumerSecret: String(creds.consumerSecret || ''),
145
+ accessToken: String(creds.accessToken || ''),
146
+ accessTokenSecret: String(creds.accessTokenSecret || '')
147
+ })
148
+ const encrypted = encrypt(payload, params)
149
+
150
+ const { client, close } = await getClient(params)
151
+ try {
152
+ await ensureCollection(client)
153
+ const collection = await client.collection(COLLECTION)
154
+ const now = new Date().toISOString()
155
+ try {
156
+ await collection.updateOne(
157
+ { _id: DOC_ID },
158
+ {
159
+ $set: { value: encrypted, updatedAt: now, scope: SCOPE, scope_id: SCOPE_ID, path: PATH },
160
+ $setOnInsert: { _id: DOC_ID, createdAt: now }
161
+ },
162
+ { upsert: true }
163
+ )
164
+ } catch (err) {
165
+ const msg = (err && err.message) ? String(err.message) : String(err)
166
+ if (!/upsert|unsupported|not implemented/i.test(msg)) throw err
167
+ const existing = await tryFindOne(collection, { _id: DOC_ID })
168
+ if (existing) {
169
+ await collection.updateOne({ _id: DOC_ID }, { $set: { value: encrypted, updatedAt: now } })
170
+ } else {
171
+ await collection.insertOne({
172
+ _id: DOC_ID, scope: SCOPE, scope_id: SCOPE_ID, path: PATH, value: encrypted, createdAt: now, updatedAt: now
173
+ })
174
+ }
175
+ }
176
+ clearCommerceCredsCache()
177
+ } finally {
178
+ try { await close() } catch (_) {}
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Hit a lightweight authenticated Commerce REST endpoint to verify creds.
184
+ * Returns { ok, storeCount, message } and never throws on auth/network — it
185
+ * normalises the failure into the result envelope.
186
+ */
187
+ async function testCommerceConnection (creds, logger) {
188
+ const errLogger = logger && typeof logger.error === 'function' ? logger : { error: () => {} }
189
+ const shape = toClientShape(creds)
190
+ if (!shape || !shape.url) {
191
+ return { ok: false, message: 'baseUrl is required' }
192
+ }
193
+ if (!shape.consumerKey || !shape.consumerSecret || !shape.accessToken || !shape.accessTokenSecret) {
194
+ return { ok: false, message: 'All OAuth fields are required' }
195
+ }
196
+ try {
197
+ const oauth = getCommerceOauthClient({ ...shape }, errLogger)
198
+ const stores = await oauth.get('store/storeConfigs')
199
+ const count = Array.isArray(stores) ? stores.length : 0
200
+ return { ok: true, storeCount: count, message: `Connected — ${count} store config(s) returned` }
201
+ } catch (err) {
202
+ const status = err && err.response && err.response.statusCode
203
+ const msg = (err && err.message) ? String(err.message) : 'Connection failed'
204
+ return { ok: false, message: status ? `HTTP ${status}: ${msg}` : msg }
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Resolve commerce creds for any action that needs them.
210
+ * Precedence:
211
+ * 1. explicit creds supplied on params (legacy / local dev fallback)
212
+ * 2. ABDB-stored creds
213
+ * 3. process.env (purely a dev escape hatch)
214
+ *
215
+ * Throws when nothing is configured so the caller emits a clear 412.
216
+ */
217
+ async function getCommerceCreds (params, logger) {
218
+ const fromParams = {
219
+ baseUrl: params.COMMERCE_BASE_URL,
220
+ consumerKey: params.COMMERCE_CONSUMER_KEY,
221
+ consumerSecret: params.COMMERCE_CONSUMER_SECRET,
222
+ accessToken: params.COMMERCE_ACCESS_TOKEN,
223
+ accessTokenSecret: params.COMMERCE_ACCESS_TOKEN_SECRET
224
+ }
225
+ if (fromParams.baseUrl && fromParams.consumerKey) {
226
+ return fromParams
227
+ }
228
+ const fromDb = await readCommerceCreds(params)
229
+ if (fromDb && fromDb.baseUrl) return fromDb
230
+ const fromEnv = {
231
+ baseUrl: process.env.COMMERCE_BASE_URL,
232
+ consumerKey: process.env.COMMERCE_CONSUMER_KEY,
233
+ consumerSecret: process.env.COMMERCE_CONSUMER_SECRET,
234
+ accessToken: process.env.COMMERCE_ACCESS_TOKEN,
235
+ accessTokenSecret: process.env.COMMERCE_ACCESS_TOKEN_SECRET
236
+ }
237
+ if (fromEnv.baseUrl && fromEnv.consumerKey) return fromEnv
238
+ const err = new Error('Commerce connection not configured. Complete the Commerce setup wizard first.')
239
+ err.code = 'COMMERCE_NOT_CONFIGURED'
240
+ throw err
241
+ }
242
+
243
+ /**
244
+ * Convenience: returns a ready-to-use OAuth1a client built from stored creds.
245
+ */
246
+ async function getStoredCommerceOauthClient (params, logger) {
247
+ const creds = await getCommerceCreds(params, logger)
248
+ return getCommerceOauthClient(toClientShape(creds), logger || { error: () => {} })
249
+ }
250
+
251
+ module.exports = {
252
+ COMMERCE_CONNECTION_PATH: PATH,
253
+ COMMERCE_CONNECTION_DOC_ID: DOC_ID,
254
+ readCommerceCreds,
255
+ writeCommerceCreds,
256
+ testCommerceConnection,
257
+ getCommerceCreds,
258
+ getStoredCommerceOauthClient,
259
+ toClientShape,
260
+ maskCreds,
261
+ clearCommerceCredsCache
262
+ }
@@ -0,0 +1,41 @@
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 { getStoredCommerceOauthClient } = require('../../commerce-creds')
11
+
12
+ async function main (params) {
13
+ const logger = Core.Logger('main', { level: params.LOG_LEVEL || 'info' })
14
+
15
+ try {
16
+ const requiredParams = ['operation']
17
+ const requiredHeaders = ['Authorization']
18
+ const errorMessage = checkMissingRequestInputs(params, requiredParams, requiredHeaders)
19
+ if (errorMessage) {
20
+ return errorResponse(400, errorMessage, logger)
21
+ }
22
+
23
+ let oauth
24
+ try {
25
+ oauth = await getStoredCommerceOauthClient(params, logger)
26
+ } catch (e) {
27
+ if (e.code === 'COMMERCE_NOT_CONFIGURED') {
28
+ return errorResponse(412, e.message, logger)
29
+ }
30
+ throw e
31
+ }
32
+
33
+ const content = await oauth.get(params.operation)
34
+ return { statusCode: 200, body: content }
35
+ } catch (error) {
36
+ logger.error(error)
37
+ return errorResponse(500, error, logger)
38
+ }
39
+ }
40
+
41
+ exports.main = main
@@ -0,0 +1,53 @@
1
+ /*
2
+ Copyright 2025 Adobe. All rights reserved.
3
+ Licensed under the Apache License, Version 2.0
4
+ */
5
+
6
+ const { Core } = require('@adobe/aio-sdk')
7
+ const { errorResponse, checkMissingRequestInputs } = require('../../utils')
8
+ const {
9
+ testCommerceConnection,
10
+ writeCommerceCreds,
11
+ maskCreds
12
+ } = require('../../commerce-creds')
13
+
14
+ async function main (params) {
15
+ const logger = Core.Logger('commerce-connection-save', { level: params.LOG_LEVEL || 'info' })
16
+ try {
17
+ const missing = checkMissingRequestInputs(params, [
18
+ 'baseUrl', 'consumerKey', 'consumerSecret', 'accessToken', 'accessTokenSecret'
19
+ ])
20
+ if (missing) return errorResponse(400, missing, logger)
21
+
22
+ const creds = {
23
+ baseUrl: params.baseUrl,
24
+ consumerKey: params.consumerKey,
25
+ consumerSecret: params.consumerSecret,
26
+ accessToken: params.accessToken,
27
+ accessTokenSecret: params.accessTokenSecret
28
+ }
29
+
30
+ // Validate before persisting unless the caller explicitly opted out.
31
+ const skipTest = params.skipTest === true || params.skipTest === 'true'
32
+ if (!skipTest) {
33
+ const test = await testCommerceConnection(creds, logger)
34
+ if (!test.ok) {
35
+ return {
36
+ statusCode: 200,
37
+ body: { ok: false, saved: false, message: test.message }
38
+ }
39
+ }
40
+ }
41
+
42
+ await writeCommerceCreds(params, creds)
43
+ return {
44
+ statusCode: 200,
45
+ body: { ok: true, saved: true, creds: maskCreds(creds) }
46
+ }
47
+ } catch (error) {
48
+ logger.error(error)
49
+ return errorResponse(500, error.message || 'save failed', logger)
50
+ }
51
+ }
52
+
53
+ exports.main = main
@@ -0,0 +1,27 @@
1
+ /*
2
+ Copyright 2025 Adobe. All rights reserved.
3
+ Licensed under the Apache License, Version 2.0
4
+ */
5
+
6
+ const { Core } = require('@adobe/aio-sdk')
7
+ const { errorResponse } = require('../../utils')
8
+ const { readCommerceCreds, maskCreds } = require('../../commerce-creds')
9
+
10
+ async function main (params) {
11
+ const logger = Core.Logger('commerce-connection-status', { level: params.LOG_LEVEL || 'info' })
12
+ try {
13
+ const creds = await readCommerceCreds(params, { fresh: params.fresh === true || params.fresh === 'true' })
14
+ return {
15
+ statusCode: 200,
16
+ body: {
17
+ configured: !!(creds && creds.baseUrl),
18
+ creds: creds ? maskCreds(creds) : null
19
+ }
20
+ }
21
+ } catch (error) {
22
+ logger.error(error)
23
+ return errorResponse(500, error.message || 'status failed', logger)
24
+ }
25
+ }
26
+
27
+ exports.main = main
@@ -0,0 +1,47 @@
1
+ /*
2
+ Copyright 2025 Adobe. All rights reserved.
3
+ Licensed under the Apache License, Version 2.0
4
+ */
5
+
6
+ const { Core } = require('@adobe/aio-sdk')
7
+ const { errorResponse, checkMissingRequestInputs } = require('../../utils')
8
+ const { testCommerceConnection, readCommerceCreds } = require('../../commerce-creds')
9
+
10
+ async function main (params) {
11
+ const logger = Core.Logger('commerce-connection-test', { level: params.LOG_LEVEL || 'info' })
12
+ try {
13
+ // Allow testing either the form values being entered OR the currently saved creds.
14
+ let creds = {
15
+ baseUrl: params.baseUrl,
16
+ consumerKey: params.consumerKey,
17
+ consumerSecret: params.consumerSecret,
18
+ accessToken: params.accessToken,
19
+ accessTokenSecret: params.accessTokenSecret
20
+ }
21
+ const allBlank = !creds.baseUrl && !creds.consumerKey && !creds.consumerSecret &&
22
+ !creds.accessToken && !creds.accessTokenSecret
23
+ if (allBlank) {
24
+ const saved = await readCommerceCreds(params, { fresh: true })
25
+ if (!saved) {
26
+ return errorResponse(412, 'No creds supplied and none saved', logger)
27
+ }
28
+ creds = saved
29
+ } else {
30
+ const missing = checkMissingRequestInputs(params, [
31
+ 'baseUrl', 'consumerKey', 'consumerSecret', 'accessToken', 'accessTokenSecret'
32
+ ])
33
+ if (missing) return errorResponse(400, missing, logger)
34
+ }
35
+
36
+ const result = await testCommerceConnection(creds, logger)
37
+ return {
38
+ statusCode: result.ok ? 200 : 200, // surface failures in body, not HTTP
39
+ body: result
40
+ }
41
+ } catch (error) {
42
+ logger.error(error)
43
+ return errorResponse(500, error.message || 'test failed', logger)
44
+ }
45
+ }
46
+
47
+ exports.main = main
@@ -0,0 +1,256 @@
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
+ // Export the entire system_config (schema + values) to a portable JSON
9
+ // document. The result includes:
10
+ // - schema : the document stored under system_config_schema._id = 'v1'
11
+ // - values : every row in system_config_data, as { scope, scope_id, path, value }
12
+ // - meta : timestamp + counts so the operator can sanity-check the dump
13
+ //
14
+ // Sensitive fields are exported as their ENCRYPTED ciphertext so the dump is
15
+ // safe to share, but it can only be re-imported into a workspace whose
16
+ // SYSTEM_CONFIG_CRYPT_KEY matches the one used when these values were saved.
17
+ //
18
+ // Trigger from the UI's "Export → JSON" button, or invoke directly:
19
+ // POST .../system-config-export
20
+ // body: {} → full dump
21
+ // body: { schemaOnly: true } → omit values
22
+ // body: { valuesOnly: true } → omit schema
23
+ // body: { scopes: ['default','websites'] } → filter by scope tuple
24
+ //
25
+ // Response: { ok, dump }, where `dump` is the JSON the caller saves as a file.
26
+
27
+ const { Core } = require('@adobe/aio-sdk')
28
+ const { errorResponse } = require('../../utils')
29
+ const { getClient } = require('@adobedjangir/commerce-admin-management/abdb')
30
+ const { isEncrypted, decrypt } = require('@adobedjangir/commerce-admin-management/crypto')
31
+ const { readCommerceCreds, toClientShape } = require('../../commerce-creds')
32
+ const { getCommerceOauthClient } = require('@adobedjangir/commerce-admin-management/oauth1a')
33
+
34
+ const SCHEMA_COLLECTION = 'system_config_schema'
35
+ const SCHEMA_DOC_ID = 'v1'
36
+ const DATA_COLLECTION = 'system_config_data'
37
+ // Bumped to v2 when we started decrypting sensitive values into plaintext on
38
+ // export. Importers ≥ v2 know to re-encrypt with the target env's key.
39
+ const EXPORT_VERSION = 2
40
+
41
+ /**
42
+ * Walk a schema doc and return the set of "section/group/field" paths whose
43
+ * field is marked `sensitive: true`. Used by both export (decide what to
44
+ * decrypt) and import (decide what to re-encrypt against the target's key).
45
+ */
46
+ function deriveLanguageCode (code) {
47
+ const m = String(code || '').toLowerCase().match(/^([a-z]{2})_/)
48
+ return m ? m[1] : 'en'
49
+ }
50
+
51
+ /**
52
+ * Build a Commerce-style store_mappings blob from live REST data:
53
+ * { storeId: { code, language_code, website_code, website_id } }
54
+ * Returns null if Commerce credentials aren't configured or the call fails.
55
+ * Embedding this in every export lets cross-env imports translate
56
+ * website_id / store_id by matching `website_code` / store `code` against the
57
+ * target env's own Commerce, regardless of whether sync-store-mappings was
58
+ * ever run on the source.
59
+ */
60
+ async function fetchSourceStoreMappingsFromCommerce (params, logger) {
61
+ const creds = await readCommerceCreds(params).catch(() => null)
62
+ const shape = toClientShape(creds)
63
+ if (!shape || !shape.url || !shape.consumerKey) return null
64
+ try {
65
+ const oauth = getCommerceOauthClient(shape, logger)
66
+ const [storeViews, websites] = await Promise.all([
67
+ oauth.get('store/storeViews'),
68
+ oauth.get('store/websites')
69
+ ])
70
+ const websiteById = new Map()
71
+ for (const w of websites || []) {
72
+ if (w && w.id != null) websiteById.set(String(w.id), w)
73
+ }
74
+ const mapping = {}
75
+ for (const sv of storeViews || []) {
76
+ if (!sv || sv.id == null) continue
77
+ const storeId = String(sv.id)
78
+ if (storeId === '0' || sv.code === 'admin') continue
79
+ const websiteId = sv.website_id != null ? String(sv.website_id) : ''
80
+ const website = websiteById.get(websiteId)
81
+ mapping[storeId] = {
82
+ code: String(sv.code || ''),
83
+ language_code: deriveLanguageCode(sv.code),
84
+ website_code: website ? String(website.code || '') : '',
85
+ website_id: websiteId
86
+ }
87
+ }
88
+ return Object.keys(mapping).length ? mapping : null
89
+ } catch (err) {
90
+ if (logger) logger.warn(`Export: Commerce store_mappings lookup failed: ${err.message}`)
91
+ return null
92
+ }
93
+ }
94
+
95
+ function collectSensitivePaths (schema) {
96
+ const out = new Set()
97
+ if (!schema || !Array.isArray(schema.sections)) return out
98
+ for (const s of schema.sections) {
99
+ if (!s || !Array.isArray(s.groups)) continue
100
+ for (const g of s.groups) {
101
+ if (!g || !Array.isArray(g.fields)) continue
102
+ for (const f of g.fields) {
103
+ if (f && f.sensitive) out.add(`${s.id}/${g.id}/${f.id}`)
104
+ }
105
+ }
106
+ }
107
+ return out
108
+ }
109
+
110
+ async function tryFindOne (collection, query) {
111
+ try {
112
+ const arr = await collection.find(query).limit(1).toArray()
113
+ return arr && arr.length ? arr[0] : null
114
+ } catch (err) {
115
+ const msg = err && err.message ? String(err.message) : String(err)
116
+ if (/not found/i.test(msg)) return null
117
+ throw err
118
+ }
119
+ }
120
+
121
+ async function main (params) {
122
+ const logger = Core.Logger('export-config', { level: params.LOG_LEVEL || 'info' })
123
+
124
+ const schemaOnly = params.schemaOnly === true || params.schemaOnly === 'true'
125
+ const valuesOnly = params.valuesOnly === true || params.valuesOnly === 'true'
126
+ const scopeFilter = Array.isArray(params.scopes) && params.scopes.length
127
+ ? new Set(params.scopes.map(String))
128
+ : null
129
+
130
+ let dbHandle
131
+ try {
132
+ dbHandle = await getClient(params)
133
+ } catch (e) {
134
+ logger.error(`ABDB connect failed: ${e.message}`)
135
+ return errorResponse(500, `ABDB connect failed: ${e.message}`, logger)
136
+ }
137
+ const { client, close } = dbHandle
138
+
139
+ try {
140
+ let schema = null
141
+ if (!valuesOnly) {
142
+ const schemaCol = await client.collection(SCHEMA_COLLECTION)
143
+ const doc = await tryFindOne(schemaCol, { _id: SCHEMA_DOC_ID })
144
+ schema = doc && doc.schema ? doc.schema : { sections: [] }
145
+ }
146
+
147
+ // We need the schema to know which paths are sensitive, even when the
148
+ // caller asked for valuesOnly — load it locally without including it in
149
+ // the dump.
150
+ let schemaForFlags = schema
151
+ if (!schemaForFlags) {
152
+ try {
153
+ const schemaCol = await client.collection(SCHEMA_COLLECTION)
154
+ const doc = await tryFindOne(schemaCol, { _id: SCHEMA_DOC_ID })
155
+ schemaForFlags = doc && doc.schema ? doc.schema : null
156
+ } catch (_) { /* ok if missing */ }
157
+ }
158
+ const sensitivePaths = collectSensitivePaths(schemaForFlags)
159
+
160
+ // Always pull the SOURCE env's Commerce mapping so we can stamp every
161
+ // website/store-scoped row with its scope_code (website_code / store
162
+ // view code). The importer then needs only the target's Commerce — no
163
+ // separate storeMappings blob has to be carried between envs.
164
+ const storeMappingsFromCommerce = await fetchSourceStoreMappingsFromCommerce(params, logger)
165
+ // Build quick lookup tables: websiteId → website_code, storeId → store code.
166
+ const websiteCodeById = new Map()
167
+ const storeCodeById = new Map()
168
+ if (storeMappingsFromCommerce) {
169
+ for (const [storeId, m] of Object.entries(storeMappingsFromCommerce)) {
170
+ if (!m) continue
171
+ if (m.website_id != null && m.website_code) {
172
+ websiteCodeById.set(String(m.website_id), String(m.website_code))
173
+ }
174
+ if (m.code) storeCodeById.set(String(storeId), String(m.code))
175
+ }
176
+ }
177
+
178
+ let values = []
179
+ let decryptedCount = 0
180
+ let decryptFailedCount = 0
181
+ if (!schemaOnly) {
182
+ const dataCol = await client.collection(DATA_COLLECTION)
183
+ const docs = await dataCol.find({}).toArray().catch(() => [])
184
+ for (const d of docs) {
185
+ if (!d || typeof d.path !== 'string') continue
186
+ if (scopeFilter && !scopeFilter.has(d.scope)) continue
187
+ let value = d.value
188
+ // Decrypt sensitive ciphertext using THIS env's key so the dump is
189
+ // portable to any other workspace. The recipient will re-encrypt
190
+ // with its own key based on schema.sensitive flags. This means the
191
+ // exported JSON file contains plaintext secrets — treat it as
192
+ // sensitive.
193
+ if (isEncrypted(value)) {
194
+ try {
195
+ value = decrypt(value, params)
196
+ decryptedCount++
197
+ } catch (e) {
198
+ // Couldn't decrypt — keep the ciphertext envelope so a target
199
+ // workspace with the matching key can still pick it up via the
200
+ // legacy sourceCryptKey path.
201
+ decryptFailedCount++
202
+ logger.warn(`Export: failed to decrypt ${d.path} @ ${d.scope}:${d.scope_id}: ${e.message}`)
203
+ }
204
+ }
205
+ // Tag the row with the source env's code (website_code or store
206
+ // view code). At import time the recipient looks up its own Commerce
207
+ // and resolves scope_code → target scope_id directly, with no need
208
+ // to ship a separate storeMappings blob.
209
+ let scopeCode
210
+ if (d.scope === 'websites') scopeCode = websiteCodeById.get(String(d.scope_id))
211
+ else if (d.scope === 'stores') scopeCode = storeCodeById.get(String(d.scope_id))
212
+ values.push({
213
+ scope: d.scope,
214
+ scope_id: d.scope_id,
215
+ ...(scopeCode ? { scope_code: scopeCode } : {}),
216
+ path: d.path,
217
+ value
218
+ })
219
+ }
220
+ // Stable ordering for diffable dumps.
221
+ values.sort((a, b) => {
222
+ if (a.scope !== b.scope) return a.scope.localeCompare(b.scope)
223
+ if (a.scope_id !== b.scope_id) return String(a.scope_id).localeCompare(String(b.scope_id))
224
+ return a.path.localeCompare(b.path)
225
+ })
226
+ }
227
+
228
+ const dump = {
229
+ __format: 'adobe-commerce-app-builder/system-config-export',
230
+ __version: EXPORT_VERSION,
231
+ exportedAt: new Date().toISOString(),
232
+ // List of sensitive paths so the importer can re-encrypt them with the
233
+ // target env's key without needing to re-derive from the schema.
234
+ sensitivePaths: Array.from(sensitivePaths),
235
+ sensitiveDecrypted: decryptedCount,
236
+ sensitiveDecryptFailed: decryptFailedCount,
237
+ counts: {
238
+ sections: schema ? (schema.sections || []).length : 0,
239
+ values: values.length,
240
+ scopeCoded: values.filter(v => v.scope_code).length
241
+ },
242
+ ...(valuesOnly ? {} : { schema }),
243
+ ...(schemaOnly ? {} : { values })
244
+ }
245
+
246
+ logger.info(`Exported: ${dump.counts.sections} section(s), ${dump.counts.values} value(s)`)
247
+ return { statusCode: 200, body: { ok: true, dump } }
248
+ } catch (error) {
249
+ logger.error(error)
250
+ return errorResponse(500, error.message || 'Export failed', logger)
251
+ } finally {
252
+ try { await close() } catch (_) {}
253
+ }
254
+ }
255
+
256
+ exports.main = main