@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.
- package/README.md +637 -0
- package/actions/commerce-creds.js +262 -0
- package/actions/configurations/commerce/index.js +41 -0
- package/actions/configurations/commerce-connection-save/index.js +53 -0
- package/actions/configurations/commerce-connection-status/index.js +27 -0
- package/actions/configurations/commerce-connection-test/index.js +47 -0
- package/actions/configurations/export-config/index.js +256 -0
- package/actions/configurations/ext.config.yaml +192 -0
- package/actions/configurations/import-config/index.js +541 -0
- package/actions/configurations/registration/index.js +37 -0
- package/actions/configurations/sync-store-mappings-from-commerce/index.js +190 -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 +80 -0
- package/scripts/build-web.js +58 -0
- package/scripts/setup.js +445 -0
- package/src/abdb-config.js +8 -0
- package/src/abdb-helper.js +8 -0
- package/src/index.js +22 -0
- package/src/oauth1a.js +8 -0
- package/src/system-config-crypto.js +8 -0
- package/src/system-config-shared.js +8 -0
- package/web/dist/index.css +305 -0
- package/web/dist/index.js +2986 -0
- package/web/index.js +7 -0
- package/web/src/components/App.js +54 -0
- package/web/src/components/AppSectionNav.js +49 -0
- package/web/src/components/CommerceSetupWizard.js +160 -0
- package/web/src/components/ExtensionRegistration.js +33 -0
- package/web/src/components/MainPage.js +147 -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 +46 -0
- package/web/src/nav-icons.js +30 -0
- package/web/src/nav.json +10 -0
- package/web/src/pages/index.js +17 -0
- package/web/src/schema/systemConfigSchema.js +82 -0
- package/web/src/settings.js +101 -0
- package/web/src/styles/index.css +337 -0
- package/web/src/theme.js +104 -0
- package/web/src/utils/storeMappingsFromCommerceRest.js +73 -0
- package/web/src/utils.js +52 -0
- 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
|