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.
- package/README.md +199 -0
- package/actions/configurations/commerce/index.js +55 -0
- package/actions/configurations/export-config/index.js +259 -0
- package/actions/configurations/ext.config.yaml +151 -0
- package/actions/configurations/import-config/index.js +544 -0
- package/actions/configurations/registration/index.js +37 -0
- package/actions/configurations/sync-store-mappings-from-commerce/index.js +199 -0
- package/actions/configurations/system-config-list/index.js +127 -0
- package/actions/configurations/system-config-save/index.js +160 -0
- package/actions/configurations/system-config-schema/index.js +327 -0
- package/actions/utils.js +73 -0
- package/package.json +74 -0
- package/scripts/setup-app-config.js +114 -0
- package/src/abdb-config.js +241 -0
- package/src/abdb-helper.js +476 -0
- package/src/index.js +20 -0
- package/src/oauth1a.js +135 -0
- package/src/system-config-crypto.js +113 -0
- package/src/system-config-shared.js +89 -0
- package/web/src/components/App.js +47 -0
- package/web/src/components/AppSectionNav.js +49 -0
- package/web/src/components/ExtensionRegistration.js +33 -0
- package/web/src/components/MainPage.js +46 -0
- package/web/src/components/SystemConfig.js +1464 -0
- package/web/src/components/SystemConfigSchemaEditor.js +459 -0
- package/web/src/hooks/useConfirm.js +355 -0
- package/web/src/hooks/useSystemConfig.js +238 -0
- package/web/src/hooks/useSystemConfigSchema.js +102 -0
- package/web/src/index.js +41 -0
- package/web/src/schema/systemConfigSchema.js +82 -0
- package/web/src/settings.js +57 -0
- package/web/src/styles/index.css +326 -0
- package/web/src/theme.js +104 -0
- package/web/src/utils/storeMappingsFromCommerceRest.js +73 -0
- 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
|