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