@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,541 @@
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
+ // Import a previously-exported system_config dump into ABDB.
9
+ //
10
+ // Inputs (POST body):
11
+ // dump : the JSON object produced by export-config, OR
12
+ // schema / values : provide them inline instead of nesting under `dump`
13
+ //
14
+ // schemaOnly : true → ignore `values`
15
+ // valuesOnly : true → ignore `schema`
16
+ // overwrite : false (default) → only insert rows that don't exist;
17
+ // true → upsert every row (existing values get replaced)
18
+ //
19
+ // Sensitive fields are imported AS-IS (ciphertext). They will only decrypt
20
+ // against the same SYSTEM_CONFIG_CRYPT_KEY that produced them.
21
+ //
22
+ // website_id / store_id remap
23
+ // ───────────────────────────
24
+ // The source env's website_id numbers don't necessarily match the target's.
25
+ // To keep config aligned, we translate scope_id by matching website_code
26
+ // (scope='websites') and store code (scope='stores') between the source's
27
+ // store_mappings (carried in the dump) and the TARGET env's store_mappings.
28
+ // The target side is resolved live from Commerce REST
29
+ // (store/storeViews + store/websites) on every import, with a fallback to
30
+ // the previously-synced blob in ABDB at general/settings/store_mappings if
31
+ // Commerce credentials aren't configured. Rows with no code match are
32
+ // skipped unless `allowUnmapped: true` is passed.
33
+
34
+ const { Core } = require('@adobe/aio-sdk')
35
+ const { errorResponse } = require('../../utils')
36
+ const { getClient } = require('@adobedjangir/commerce-admin-management/abdb')
37
+ const { isValidPath, toStateKey, normalizeScope, normalizeScopeId } = require('@adobedjangir/commerce-admin-management/shared')
38
+ const { getCommerceOauthClient } = require('@adobedjangir/commerce-admin-management/oauth1a')
39
+ const { isEncrypted, decrypt, encrypt } = require('@adobedjangir/commerce-admin-management/crypto')
40
+ const { readCommerceCreds, toClientShape } = require('../../commerce-creds')
41
+
42
+ const SCHEMA_COLLECTION = 'system_config_schema'
43
+ const SCHEMA_DOC_ID = 'v1'
44
+ const DATA_COLLECTION = 'system_config_data'
45
+ const STORE_MAPPINGS_PATH = 'general/settings/store_mappings'
46
+
47
+ async function ensureCollection (client, name) {
48
+ try {
49
+ await client.createCollection(name)
50
+ } catch (err) {
51
+ const msg = (err && err.message) ? String(err.message) : String(err)
52
+ if (!/exist|already|duplicate/i.test(msg)) throw err
53
+ }
54
+ }
55
+
56
+ async function tryFindOne (collection, query) {
57
+ try {
58
+ const arr = await collection.find(query).limit(1).toArray()
59
+ return arr && arr.length ? arr[0] : null
60
+ } catch (err) {
61
+ const msg = err && err.message ? String(err.message) : String(err)
62
+ if (/not found/i.test(msg)) return null
63
+ throw err
64
+ }
65
+ }
66
+
67
+ function parseMaybeJson (v) {
68
+ if (v == null) return null
69
+ if (typeof v === 'object') return v
70
+ if (typeof v !== 'string') return null
71
+ const t = v.trim()
72
+ if (!(t.startsWith('{') || t.startsWith('['))) return null
73
+ try { return JSON.parse(t) } catch (_) { return null }
74
+ }
75
+
76
+ /**
77
+ * Pull a store_mappings blob ({ storeId: { code, website_code, website_id, … } })
78
+ * out of an array of value rows. Returns null if not present.
79
+ */
80
+ function extractStoreMappings (rows) {
81
+ if (!Array.isArray(rows)) return null
82
+ const row = rows.find(r => r && r.path === STORE_MAPPINGS_PATH && r.scope === 'default')
83
+ if (!row) return null
84
+ const obj = parseMaybeJson(row.value)
85
+ return obj && typeof obj === 'object' ? obj : null
86
+ }
87
+
88
+ async function readTargetStoreMappingsFromAbdb (client) {
89
+ try {
90
+ const dataCol = await client.collection(DATA_COLLECTION)
91
+ const arr = await dataCol.find({
92
+ _id: toStateKey('default', '0', STORE_MAPPINGS_PATH)
93
+ }).limit(1).toArray()
94
+ if (!arr || !arr.length) return null
95
+ return parseMaybeJson(arr[0].value)
96
+ } catch (_) {
97
+ return null
98
+ }
99
+ }
100
+
101
+ function deriveLanguageCode (code) {
102
+ const m = String(code || '').toLowerCase().match(/^([a-z]{2})_/)
103
+ return m ? m[1] : 'en'
104
+ }
105
+
106
+ /**
107
+ * Fetch storeViews + websites from Commerce REST and build a store_mappings
108
+ * blob in the same shape used everywhere else:
109
+ * { storeId: { code, language_code, website_code, website_id } }
110
+ * Returns null if Commerce credentials are missing or the call fails.
111
+ */
112
+ async function fetchTargetStoreMappingsFromCommerce (params, logger) {
113
+ const creds = await readCommerceCreds(params).catch(() => null)
114
+ const shape = toClientShape(creds)
115
+ if (!shape || !shape.url || !shape.consumerKey) return null
116
+ try {
117
+ const oauth = getCommerceOauthClient(shape, logger)
118
+ const [storeViews, websites] = await Promise.all([
119
+ oauth.get('store/storeViews'),
120
+ oauth.get('store/websites')
121
+ ])
122
+ const websiteById = new Map()
123
+ for (const w of websites || []) {
124
+ if (w && w.id != null) websiteById.set(String(w.id), w)
125
+ }
126
+ const mapping = {}
127
+ for (const sv of storeViews || []) {
128
+ if (!sv || sv.id == null) continue
129
+ const storeId = String(sv.id)
130
+ if (storeId === '0' || sv.code === 'admin') continue
131
+ const websiteId = sv.website_id != null ? String(sv.website_id) : ''
132
+ const website = websiteById.get(websiteId)
133
+ mapping[storeId] = {
134
+ code: String(sv.code || ''),
135
+ language_code: deriveLanguageCode(sv.code),
136
+ website_code: website ? String(website.code || '') : '',
137
+ website_id: websiteId
138
+ }
139
+ }
140
+ return Object.keys(mapping).length ? mapping : null
141
+ } catch (err) {
142
+ if (logger) logger.warn(`Commerce REST lookup failed during import remap: ${err.message}`)
143
+ return null
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Resolve the target env's store_mappings, preferring live Commerce REST
149
+ * (always current) and falling back to whatever is in ABDB at
150
+ * general/settings/store_mappings (handy for offline imports). Returns
151
+ * { mapping, source } where source ∈ 'commerce' | 'abdb' | null.
152
+ */
153
+ async function resolveTargetMappings (params, client, logger) {
154
+ const fromCommerce = await fetchTargetStoreMappingsFromCommerce(params, logger)
155
+ if (fromCommerce) return { mapping: fromCommerce, source: 'commerce' }
156
+ const fromAbdb = await readTargetStoreMappingsFromAbdb(client)
157
+ if (fromAbdb) return { mapping: fromAbdb, source: 'abdb' }
158
+ return { mapping: null, source: null }
159
+ }
160
+
161
+ /**
162
+ * Build translation tables from source → target store_mappings.
163
+ * websites: source website_id (string) → target website_id (string), matched by website_code
164
+ * stores : source store id (string) → target store id (string), matched by store code
165
+ * Also returns inverse human-readable maps for diagnostics.
166
+ */
167
+ function buildIdMap (source, target) {
168
+ const websiteSrcByCode = new Map()
169
+ const websiteTgtByCode = new Map()
170
+ const storeSrcByCode = new Map()
171
+ const storeTgtByCode = new Map()
172
+
173
+ const indexSide = (mapping, websiteByCode, storeByCode) => {
174
+ if (!mapping || typeof mapping !== 'object') return
175
+ for (const [storeId, m] of Object.entries(mapping)) {
176
+ if (!m || typeof m !== 'object') continue
177
+ if (m.website_code && m.website_id != null) {
178
+ // Only record the first occurrence so we keep a deterministic mapping.
179
+ if (!websiteByCode.has(m.website_code)) websiteByCode.set(m.website_code, String(m.website_id))
180
+ }
181
+ if (m.code) storeByCode.set(m.code, String(storeId))
182
+ }
183
+ }
184
+ indexSide(source, websiteSrcByCode, storeSrcByCode)
185
+ indexSide(target, websiteTgtByCode, storeTgtByCode)
186
+
187
+ const websites = {} // sourceWebsiteId → targetWebsiteId
188
+ const websiteCodes = {} // sourceWebsiteId → website_code (for diagnostics)
189
+ for (const [code, srcId] of websiteSrcByCode.entries()) {
190
+ websiteCodes[srcId] = code
191
+ if (websiteTgtByCode.has(code)) websites[srcId] = websiteTgtByCode.get(code)
192
+ }
193
+ const stores = {} // sourceStoreId → targetStoreId
194
+ const storeCodes = {}
195
+ for (const [code, srcId] of storeSrcByCode.entries()) {
196
+ storeCodes[srcId] = code
197
+ if (storeTgtByCode.has(code)) stores[srcId] = storeTgtByCode.get(code)
198
+ }
199
+ return { websites, stores, websiteCodes, storeCodes }
200
+ }
201
+
202
+ async function upsertOne (collection, doc, { overwrite }) {
203
+ // Single-roundtrip upsert (mirrors system-config-save).
204
+ try {
205
+ if (overwrite) {
206
+ await collection.updateOne(
207
+ { _id: doc._id },
208
+ {
209
+ $set: {
210
+ scope: doc.scope,
211
+ scope_id: doc.scope_id,
212
+ path: doc.path,
213
+ value: doc.value,
214
+ updatedAt: doc.updatedAt
215
+ },
216
+ $setOnInsert: { _id: doc._id, createdAt: doc.createdAt }
217
+ },
218
+ { upsert: true }
219
+ )
220
+ return 'upserted'
221
+ }
222
+ // Insert-only: skip if exists.
223
+ const existing = await tryFindOne(collection, { _id: doc._id })
224
+ if (existing) return 'skipped'
225
+ await collection.insertOne(doc)
226
+ return 'inserted'
227
+ } catch (err) {
228
+ const msg = err && err.message ? String(err.message) : String(err)
229
+ if (!/upsert|unsupported|not implemented/i.test(msg)) throw err
230
+ // Fallback: find-then-write.
231
+ const existing = await tryFindOne(collection, { _id: doc._id })
232
+ if (existing) {
233
+ if (!overwrite) return 'skipped'
234
+ await collection.updateOne({ _id: doc._id }, { $set: doc })
235
+ return 'updated'
236
+ }
237
+ await collection.insertOne(doc)
238
+ return 'inserted'
239
+ }
240
+ }
241
+
242
+ async function main (params) {
243
+ const logger = Core.Logger('import-config', { level: params.LOG_LEVEL || 'info' })
244
+
245
+ // Accept `dump: {schema, values, storeMappings, …}` AND/OR top-level
246
+ // schema/values. The client uses the side-channel `dump.storeMappings` to
247
+ // carry the source store_mappings on every chunk for id remap, so we must
248
+ // merge instead of letting `dump` override top-level fields.
249
+ const dump = params.dump && typeof params.dump === 'object' ? params.dump : null
250
+ const schemaIn = params.schema || (dump ? dump.schema : undefined)
251
+ const valuesIn = params.values || (dump ? dump.values : undefined)
252
+
253
+ if (!schemaIn && !valuesIn) {
254
+ return errorResponse(400, 'Body must include `dump`, `schema`, or `values`', logger)
255
+ }
256
+
257
+ const schemaOnly = params.schemaOnly === true || params.schemaOnly === 'true'
258
+ const valuesOnly = params.valuesOnly === true || params.valuesOnly === 'true'
259
+ const overwrite = params.overwrite === true || params.overwrite === 'true'
260
+ // When true, rows with no website_code/store_code match keep their original
261
+ // numeric scope_id. Default false — they are skipped and reported.
262
+ const allowUnmapped = params.allowUnmapped === true || params.allowUnmapped === 'true'
263
+ // Optional: the source env's SYSTEM_CONFIG_CRYPT_KEY. When provided we
264
+ // decrypt sensitive ciphertext with it and re-encrypt with the target env's
265
+ // key, so values survive cross-env imports. When omitted (or equal to the
266
+ // target's key) ciphertext is stored verbatim.
267
+ const sourceCryptKey = typeof params.sourceCryptKey === 'string' && params.sourceCryptKey.length >= 8
268
+ ? params.sourceCryptKey
269
+ : null
270
+
271
+ let dbHandle
272
+ try {
273
+ dbHandle = await getClient(params)
274
+ } catch (e) {
275
+ logger.error(`ABDB connect failed: ${e.message}`)
276
+ return errorResponse(500, `ABDB connect failed: ${e.message}`, logger)
277
+ }
278
+ const { client, close } = dbHandle
279
+
280
+ const summary = {
281
+ schemaImported: false,
282
+ schemaSkipped: false,
283
+ valuesInserted: 0,
284
+ valuesUpserted: 0,
285
+ valuesSkipped: 0,
286
+ unmappedSkipped: 0,
287
+ invalid: [],
288
+ unmapped: [],
289
+ overwrite,
290
+ idMap: null,
291
+ sensitiveReencrypted: 0,
292
+ sensitiveDecryptFailed: 0
293
+ }
294
+
295
+ try {
296
+ // ── Schema ──
297
+ if (!valuesOnly && schemaIn && typeof schemaIn === 'object' && Array.isArray(schemaIn.sections)) {
298
+ await ensureCollection(client, SCHEMA_COLLECTION)
299
+ const schemaCol = await client.collection(SCHEMA_COLLECTION)
300
+ const existing = await tryFindOne(schemaCol, { _id: SCHEMA_DOC_ID })
301
+ const now = new Date().toISOString()
302
+ if (existing && !overwrite) {
303
+ summary.schemaSkipped = true
304
+ logger.info('Schema exists and overwrite=false — skipped')
305
+ } else {
306
+ try {
307
+ await schemaCol.updateOne(
308
+ { _id: SCHEMA_DOC_ID },
309
+ {
310
+ $set: { schema: schemaIn, updatedAt: now },
311
+ $setOnInsert: { _id: SCHEMA_DOC_ID, createdAt: now }
312
+ },
313
+ { upsert: true }
314
+ )
315
+ } catch (e) {
316
+ // Fallback for drivers without upsert support.
317
+ if (existing) {
318
+ await schemaCol.updateOne({ _id: SCHEMA_DOC_ID }, { $set: { schema: schemaIn, updatedAt: now } })
319
+ } else {
320
+ await schemaCol.insertOne({ _id: SCHEMA_DOC_ID, schema: schemaIn, createdAt: now, updatedAt: now })
321
+ }
322
+ }
323
+ summary.schemaImported = true
324
+ }
325
+ }
326
+
327
+ // ── Values ──
328
+ if (!schemaOnly && Array.isArray(valuesIn)) {
329
+ await ensureCollection(client, DATA_COLLECTION)
330
+ const dataCol = await client.collection(DATA_COLLECTION)
331
+ const now = new Date().toISOString()
332
+
333
+ // Determine which paths the schema marks as sensitive. Preference:
334
+ // 1. Dump's own `sensitivePaths` array (export-config v2+)
335
+ // 2. Schema sections walk (either inline schema, or what's already in ABDB)
336
+ let sensitivePathSet = null
337
+ if (dump && Array.isArray(dump.sensitivePaths) && dump.sensitivePaths.length) {
338
+ sensitivePathSet = new Set(dump.sensitivePaths)
339
+ } else {
340
+ let schemaForFlags = schemaIn && typeof schemaIn === 'object' ? schemaIn : null
341
+ if (!schemaForFlags) {
342
+ try {
343
+ const schemaCol = await client.collection(SCHEMA_COLLECTION)
344
+ const existingSchema = await tryFindOne(schemaCol, { _id: SCHEMA_DOC_ID })
345
+ schemaForFlags = existingSchema && existingSchema.schema ? existingSchema.schema : null
346
+ } catch (_) { /* ok */ }
347
+ }
348
+ sensitivePathSet = new Set()
349
+ if (schemaForFlags && Array.isArray(schemaForFlags.sections)) {
350
+ for (const s of schemaForFlags.sections) {
351
+ for (const g of (s.groups || [])) {
352
+ for (const f of (g.fields || [])) {
353
+ if (f && f.sensitive) sensitivePathSet.add(`${s.id}/${g.id}/${f.id}`)
354
+ }
355
+ }
356
+ }
357
+ }
358
+ }
359
+
360
+ // Resolve the TARGET env's store_mappings live from Commerce (with an
361
+ // ABDB fallback). No source mapping is fetched — each row carries its
362
+ // own scope_code from export, which is matched against the target.
363
+ const { mapping: targetMappings, source: targetSource } =
364
+ await resolveTargetMappings(params, client, logger)
365
+ // Build per-code → target id lookup tables once.
366
+ const targetWebsiteIdByCode = new Map()
367
+ const targetStoreIdByCode = new Map()
368
+ if (targetMappings) {
369
+ for (const [storeId, m] of Object.entries(targetMappings)) {
370
+ if (!m) continue
371
+ if (m.website_code && m.website_id != null && !targetWebsiteIdByCode.has(m.website_code)) {
372
+ targetWebsiteIdByCode.set(String(m.website_code), String(m.website_id))
373
+ }
374
+ if (m.code) targetStoreIdByCode.set(String(m.code), String(storeId))
375
+ }
376
+ }
377
+ summary.idMap = {
378
+ targetSource,
379
+ targetWebsiteCount: targetWebsiteIdByCode.size,
380
+ targetStoreCount: targetStoreIdByCode.size,
381
+ hasTarget: !!targetMappings,
382
+ matchedByCode: 0,
383
+ matchedById: 0
384
+ }
385
+ logger.info(
386
+ `target Commerce (${targetSource}): ` +
387
+ `websites=${targetWebsiteIdByCode.size}, stores=${targetStoreIdByCode.size}`
388
+ )
389
+
390
+ // translateScopeId(scope, scopeId, scopeCode)
391
+ // - prefers scope_code from the row (set by export-config v2+)
392
+ // - falls back to scope_id pass-through if the target already has
393
+ // that numeric id (handles same-env or legacy dumps)
394
+ const translateScopeId = (scope, srcId, scopeCode) => {
395
+ const s = String(srcId)
396
+ if (scope === 'websites') {
397
+ if (scopeCode) {
398
+ const tgt = targetWebsiteIdByCode.get(String(scopeCode))
399
+ if (tgt) {
400
+ summary.idMap.matchedByCode++
401
+ return { id: tgt, mapped: true, code: String(scopeCode) }
402
+ }
403
+ return { id: s, mapped: false, code: String(scopeCode) }
404
+ }
405
+ // No scope_code from export. Maybe scope_id is already the code
406
+ // (legacy migrate-legacy-config path), try that.
407
+ if (targetWebsiteIdByCode.has(s)) {
408
+ summary.idMap.matchedByCode++
409
+ return { id: targetWebsiteIdByCode.get(s), mapped: true, code: s }
410
+ }
411
+ // Or scope_id may already match a target website (same env).
412
+ if (targetMappings && Object.values(targetMappings).some(m => m && String(m.website_id) === s)) {
413
+ summary.idMap.matchedById++
414
+ return { id: s, mapped: true }
415
+ }
416
+ return { id: s, mapped: false }
417
+ }
418
+ if (scope === 'stores') {
419
+ if (scopeCode) {
420
+ const tgt = targetStoreIdByCode.get(String(scopeCode))
421
+ if (tgt) {
422
+ summary.idMap.matchedByCode++
423
+ return { id: tgt, mapped: true, code: String(scopeCode) }
424
+ }
425
+ return { id: s, mapped: false, code: String(scopeCode) }
426
+ }
427
+ if (targetStoreIdByCode.has(s)) {
428
+ summary.idMap.matchedByCode++
429
+ return { id: targetStoreIdByCode.get(s), mapped: true, code: s }
430
+ }
431
+ if (targetMappings && targetMappings[s]) {
432
+ summary.idMap.matchedById++
433
+ return { id: s, mapped: true }
434
+ }
435
+ return { id: s, mapped: false }
436
+ }
437
+ return { id: s, mapped: true }
438
+ }
439
+
440
+ for (const row of valuesIn) {
441
+ if (!row || !row.path || row.scope == null) {
442
+ summary.invalid.push({ row, reason: 'missing scope or path' })
443
+ continue
444
+ }
445
+ if (!isValidPath(row.path)) {
446
+ summary.invalid.push({ path: row.path, reason: 'invalid path' })
447
+ continue
448
+ }
449
+ let scope, scopeId
450
+ try {
451
+ scope = normalizeScope(row.scope)
452
+ scopeId = normalizeScopeId(scope, row.scope_id)
453
+ } catch (e) {
454
+ summary.invalid.push({ path: row.path, reason: e.message })
455
+ continue
456
+ }
457
+ // Translate scope_id from source env → target env using the row's
458
+ // scope_code (stamped at export) against the target's live Commerce.
459
+ if (scope === 'websites' || scope === 'stores') {
460
+ const t = translateScopeId(scope, scopeId, row.scope_code)
461
+ if (!t.mapped) {
462
+ if (!allowUnmapped) {
463
+ summary.unmappedSkipped++
464
+ summary.unmapped.push({
465
+ scope,
466
+ source_scope_id: scopeId,
467
+ code: t.code || row.scope_code || null,
468
+ path: row.path
469
+ })
470
+ continue
471
+ }
472
+ } else {
473
+ scopeId = t.id
474
+ }
475
+ }
476
+ // Encrypt sensitive values with the TARGET env's key.
477
+ //
478
+ // Three input shapes a sensitive value may arrive in:
479
+ // (a) plaintext — produced by export-config v2+ (decrypted at
480
+ // export). We just encrypt with local key.
481
+ // (b) enc:v1:... ciphertext from the SAME env — already protected
482
+ // by the local key; pass through. (Fast path —
483
+ // same workspace re-import.)
484
+ // (c) enc:v1:... ciphertext from a DIFFERENT env — needs the
485
+ // sourceCryptKey to decode. Falls back to (b)
486
+ // verbatim when no source key is provided.
487
+ let writeValue = row.value
488
+ const isSensitivePath = sensitivePathSet.has(row.path)
489
+ if (isSensitivePath && typeof writeValue === 'string') {
490
+ if (isEncrypted(writeValue)) {
491
+ if (sourceCryptKey) {
492
+ try {
493
+ const plaintext = decrypt(writeValue, { SYSTEM_CONFIG_CRYPT_KEY: sourceCryptKey })
494
+ writeValue = encrypt(plaintext, params)
495
+ summary.sensitiveReencrypted++
496
+ } catch (err) {
497
+ summary.sensitiveDecryptFailed++
498
+ logger.warn(`Re-encrypt with sourceCryptKey failed for ${row.path}: ${err.message}`)
499
+ }
500
+ }
501
+ // else: leave ciphertext as-is; will only decrypt if target's
502
+ // key happens to match source's.
503
+ } else if (writeValue !== '' && writeValue != null) {
504
+ // Plaintext sensitive value (v2 dump or fresh value) — encrypt
505
+ // with the local key.
506
+ try {
507
+ writeValue = encrypt(String(writeValue), params)
508
+ summary.sensitiveReencrypted++
509
+ } catch (err) {
510
+ summary.sensitiveDecryptFailed++
511
+ logger.warn(`Encrypt failed for ${row.path}: ${err.message}`)
512
+ }
513
+ }
514
+ }
515
+ const doc = {
516
+ _id: toStateKey(scope, scopeId, row.path),
517
+ scope,
518
+ scope_id: scopeId,
519
+ path: row.path,
520
+ value: writeValue,
521
+ createdAt: now,
522
+ updatedAt: now
523
+ }
524
+ const r = await upsertOne(dataCol, doc, { overwrite })
525
+ if (r === 'inserted') summary.valuesInserted++
526
+ else if (r === 'skipped') summary.valuesSkipped++
527
+ else summary.valuesUpserted++
528
+ }
529
+ }
530
+
531
+ logger.info(`Import done: ${JSON.stringify(summary)}`)
532
+ return { statusCode: 200, body: { ok: true, summary } }
533
+ } catch (error) {
534
+ logger.error(error)
535
+ return errorResponse(500, error.message || 'Import failed', logger)
536
+ } finally {
537
+ try { await close() } catch (_) {}
538
+ }
539
+ }
540
+
541
+ exports.main = main
@@ -0,0 +1,37 @@
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
+ async function main () {
9
+ const extensionId = 'CommerceAdminManagement'
10
+
11
+ return {
12
+ statusCode: 200,
13
+ body: {
14
+ registration: {
15
+ menuItems: [
16
+ {
17
+ id: `${extensionId}::apps`,
18
+ title: 'Apps',
19
+ isSection: true,
20
+ sortOrder: 1
21
+ },
22
+ {
23
+ id: `${extensionId}::configuration_management`,
24
+ title: 'Configuration Management',
25
+ parent: `${extensionId}::apps`,
26
+ sortOrder: 10
27
+ }
28
+ ],
29
+ page: {
30
+ title: 'Configuration Management - Adobe Commerce → Third-party APIs'
31
+ }
32
+ }
33
+ }
34
+ }
35
+ }
36
+
37
+ exports.main = main