configuration-get-config 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 +43 -0
- package/package.json +47 -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/README.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# configuration-get-config
|
|
2
|
+
|
|
3
|
+
Read scoped system configuration from **Adobe App Builder Database (ABDB)**.
|
|
4
|
+
|
|
5
|
+
Provides `getConfig()` with Magento-style scope inheritance (`default` → `websites` → `stores`), AES-256-GCM decryption for sensitive values, and Commerce REST helpers for resolving website/store codes to numeric IDs.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install configuration-get-config
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Peer dependencies (App Builder runtime):
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install @adobe/aio-lib-core-auth @adobe/aio-lib-db @adobe/aio-lib-ims dotenv
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```js
|
|
22
|
+
const { getConfig } = require('configuration-get-config')
|
|
23
|
+
|
|
24
|
+
async function main (params) {
|
|
25
|
+
const apiUrl = await getConfig('sync_general/api/url', params, {
|
|
26
|
+
scope: 'websites',
|
|
27
|
+
scopeCode: 'base'
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## API
|
|
33
|
+
|
|
34
|
+
| Export | Description |
|
|
35
|
+
|--------|-------------|
|
|
36
|
+
| `getConfig(path, params, options)` | Read a config value with scope inheritance |
|
|
37
|
+
| `clearAbdbConfigCache()` | Clear the in-process lookup cache |
|
|
38
|
+
|
|
39
|
+
Subpath exports: `./abdb`, `./config`, `./crypto`, `./shared`, `./oauth1a`
|
|
40
|
+
|
|
41
|
+
## License
|
|
42
|
+
|
|
43
|
+
Apache-2.0
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "configuration-get-config",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Read scoped system configuration from Adobe App Builder Database (ABDB) with Magento-style inheritance, encryption, and Commerce REST scope resolution.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"author": "Adobe Inc.",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"adobe-io",
|
|
9
|
+
"aio",
|
|
10
|
+
"app-builder",
|
|
11
|
+
"adobe-commerce",
|
|
12
|
+
"configuration",
|
|
13
|
+
"abdb",
|
|
14
|
+
"get-config"
|
|
15
|
+
],
|
|
16
|
+
"main": "./src/index.js",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": "./src/index.js",
|
|
19
|
+
"./abdb": "./src/abdb-helper.js",
|
|
20
|
+
"./config": "./src/abdb-config.js",
|
|
21
|
+
"./crypto": "./src/system-config-crypto.js",
|
|
22
|
+
"./shared": "./src/system-config-shared.js",
|
|
23
|
+
"./oauth1a": "./src/oauth1a.js"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"src",
|
|
27
|
+
"README.md"
|
|
28
|
+
],
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=18"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"@adobe/aio-lib-core-auth": "^1.1.0",
|
|
34
|
+
"@adobe/aio-lib-db": "^1.0.1",
|
|
35
|
+
"@adobe/aio-lib-ims": "^8.0.0",
|
|
36
|
+
"dotenv": "^16.4.5"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"got": "^11.8.5",
|
|
40
|
+
"oauth-1.0a": "^2.2.6"
|
|
41
|
+
},
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "https://github.com/adobe/config-management-poc.git",
|
|
45
|
+
"directory": "packages/configuration-get-config"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
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 { getCommerceOauthClient } = require('./oauth1a')
|
|
9
|
+
const {
|
|
10
|
+
isValidPath,
|
|
11
|
+
toStateKey,
|
|
12
|
+
buildInheritanceChain,
|
|
13
|
+
normalizeScope,
|
|
14
|
+
normalizeScopeId
|
|
15
|
+
} = require('./system-config-shared')
|
|
16
|
+
const { getClient } = require('./abdb-helper')
|
|
17
|
+
const { isEncrypted, decrypt } = require('./system-config-crypto')
|
|
18
|
+
|
|
19
|
+
const COLLECTION = 'system_config_data'
|
|
20
|
+
const CACHE_TTL_MS = 5 * 60 * 1000
|
|
21
|
+
|
|
22
|
+
// Per-lookup result cache.
|
|
23
|
+
const cache = new Map() // key: `${scope}:${scopeId}:${path}` → { value, expiresAt }
|
|
24
|
+
|
|
25
|
+
// Commerce code → numeric id maps. Refreshed at most every CACHE_TTL_MS.
|
|
26
|
+
let websiteCodeToId = null // Map<code, id>
|
|
27
|
+
let websiteCodeToIdAt = 0
|
|
28
|
+
let storeCodeToId = null // Map<code, id> + parentWebsiteId
|
|
29
|
+
let storeCodeToIdAt = 0
|
|
30
|
+
|
|
31
|
+
function maybeParseJson (value) {
|
|
32
|
+
if (typeof value !== 'string') return value
|
|
33
|
+
const trimmed = value.trim()
|
|
34
|
+
if (!trimmed) return value
|
|
35
|
+
if (!(trimmed.startsWith('{') || trimmed.startsWith('['))) return value
|
|
36
|
+
try { return JSON.parse(trimmed) } catch { return value }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function tryFindOne (collection, query) {
|
|
40
|
+
try {
|
|
41
|
+
const arr = await collection.find(query).limit(1).toArray()
|
|
42
|
+
return arr && arr.length ? arr[0] : null
|
|
43
|
+
} catch (err) {
|
|
44
|
+
const msg = err && err.message ? String(err.message) : String(err)
|
|
45
|
+
if (/not found/i.test(msg)) return null
|
|
46
|
+
throw err
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function pickCommerceCreds (params) {
|
|
51
|
+
return {
|
|
52
|
+
url: params.COMMERCE_BASE_URL || process.env.COMMERCE_BASE_URL,
|
|
53
|
+
consumerKey: params.COMMERCE_CONSUMER_KEY || process.env.COMMERCE_CONSUMER_KEY,
|
|
54
|
+
consumerSecret: params.COMMERCE_CONSUMER_SECRET || process.env.COMMERCE_CONSUMER_SECRET,
|
|
55
|
+
accessToken: params.COMMERCE_ACCESS_TOKEN || process.env.COMMERCE_ACCESS_TOKEN,
|
|
56
|
+
accessTokenSecret: params.COMMERCE_ACCESS_TOKEN_SECRET || process.env.COMMERCE_ACCESS_TOKEN_SECRET
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function loadWebsiteCodeMap (params) {
|
|
61
|
+
const now = Date.now()
|
|
62
|
+
if (websiteCodeToId && (now - websiteCodeToIdAt) < CACHE_TTL_MS) return websiteCodeToId
|
|
63
|
+
|
|
64
|
+
const creds = pickCommerceCreds(params)
|
|
65
|
+
if (!creds.url) return websiteCodeToId || new Map()
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const oauth = getCommerceOauthClient(creds, { error: () => {}, info: () => {} })
|
|
69
|
+
const websites = await oauth.get('store/websites')
|
|
70
|
+
const map = new Map()
|
|
71
|
+
if (Array.isArray(websites)) {
|
|
72
|
+
for (const w of websites) {
|
|
73
|
+
if (w && w.code != null && w.id != null) {
|
|
74
|
+
map.set(String(w.code), String(w.id))
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
websiteCodeToId = map
|
|
79
|
+
websiteCodeToIdAt = now
|
|
80
|
+
return map
|
|
81
|
+
} catch (_) {
|
|
82
|
+
return websiteCodeToId || new Map()
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function loadStoreViewCodeMap (params) {
|
|
87
|
+
const now = Date.now()
|
|
88
|
+
if (storeCodeToId && (now - storeCodeToIdAt) < CACHE_TTL_MS) return storeCodeToId
|
|
89
|
+
|
|
90
|
+
const creds = pickCommerceCreds(params)
|
|
91
|
+
if (!creds.url) return storeCodeToId || new Map()
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const oauth = getCommerceOauthClient(creds, { error: () => {}, info: () => {} })
|
|
95
|
+
const stores = await oauth.get('store/storeViews')
|
|
96
|
+
const map = new Map()
|
|
97
|
+
if (Array.isArray(stores)) {
|
|
98
|
+
for (const s of stores) {
|
|
99
|
+
if (s && s.code != null && s.id != null) {
|
|
100
|
+
map.set(String(s.code), { id: String(s.id), websiteId: s.website_id != null ? String(s.website_id) : null })
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
storeCodeToId = map
|
|
105
|
+
storeCodeToIdAt = now
|
|
106
|
+
return map
|
|
107
|
+
} catch (_) {
|
|
108
|
+
return storeCodeToId || new Map()
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Resolve a scope code (e.g. website 'ch', store view 'en_ch') to its numeric
|
|
114
|
+
* id via Commerce REST. Returns null when the code can't be resolved AND no
|
|
115
|
+
* verbatim fallback is wanted.
|
|
116
|
+
*/
|
|
117
|
+
async function resolveScopeId (scope, code, params) {
|
|
118
|
+
if (!code) return null
|
|
119
|
+
if (scope === 'websites') {
|
|
120
|
+
const map = await loadWebsiteCodeMap(params)
|
|
121
|
+
return map.get(String(code)) || null
|
|
122
|
+
}
|
|
123
|
+
if (scope === 'stores') {
|
|
124
|
+
const map = await loadStoreViewCodeMap(params)
|
|
125
|
+
return map.get(String(code))?.id || null
|
|
126
|
+
}
|
|
127
|
+
return null
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Look up a single config value from ABDB.
|
|
132
|
+
*
|
|
133
|
+
* @param {string} path `<section>/<group>/<field>` (e.g. 'campaign_general/url/url')
|
|
134
|
+
* @param {object} [params] action params containing OAuth + crypto + Commerce creds.
|
|
135
|
+
* Falls back to process.env when omitted.
|
|
136
|
+
* @param {object} [options]
|
|
137
|
+
* @param {string} [options.scope='default'] 'default' | 'websites' | 'stores'
|
|
138
|
+
* @param {string} [options.scopeId] website / store id (numeric string); takes precedence over scopeCode
|
|
139
|
+
* @param {string} [options.scopeCode] website / store-view CODE — resolved to numeric id via Commerce REST
|
|
140
|
+
* @param {string|number} [options.parentWebsiteId] used when scope='stores' to fall back to the parent website
|
|
141
|
+
* @param {string} [options.parentWebsiteCode] same as parentWebsiteId but resolved from a website code
|
|
142
|
+
* @param {boolean} [options.fresh] bypass the cache
|
|
143
|
+
* @returns {Promise<*|null>}
|
|
144
|
+
*/
|
|
145
|
+
async function getConfig (path, params = {}, options = {}) {
|
|
146
|
+
if (!isValidPath(path)) return null
|
|
147
|
+
|
|
148
|
+
let scope
|
|
149
|
+
try {
|
|
150
|
+
scope = normalizeScope(options.scope)
|
|
151
|
+
} catch (_) {
|
|
152
|
+
return null
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 1. Resolve the active scope id (numeric).
|
|
156
|
+
let resolvedScopeId
|
|
157
|
+
if (scope === 'default') {
|
|
158
|
+
resolvedScopeId = '0'
|
|
159
|
+
} else if (options.scopeId != null && options.scopeId !== '') {
|
|
160
|
+
resolvedScopeId = String(options.scopeId)
|
|
161
|
+
} else if (options.scopeCode) {
|
|
162
|
+
const fromCommerce = await resolveScopeId(scope, options.scopeCode, params)
|
|
163
|
+
// If Commerce isn't reachable, fall back to using the code verbatim — the
|
|
164
|
+
// value still gets queried, and the legacy `getSystemConfig` shim writes
|
|
165
|
+
// under the literal code so this keeps working.
|
|
166
|
+
resolvedScopeId = fromCommerce || String(options.scopeCode)
|
|
167
|
+
} else {
|
|
168
|
+
return null
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 2. Resolve the parent website id for store-scope inheritance.
|
|
172
|
+
let parentWebsiteId = options.parentWebsiteId
|
|
173
|
+
if (parentWebsiteId == null && options.parentWebsiteCode) {
|
|
174
|
+
parentWebsiteId =
|
|
175
|
+
await resolveScopeId('websites', options.parentWebsiteCode, params) ||
|
|
176
|
+
String(options.parentWebsiteCode)
|
|
177
|
+
}
|
|
178
|
+
// Auto-derive parentWebsiteId from the store view itself when not given.
|
|
179
|
+
if (parentWebsiteId == null && scope === 'stores' && options.scopeCode) {
|
|
180
|
+
const sMap = await loadStoreViewCodeMap(params)
|
|
181
|
+
parentWebsiteId = sMap.get(String(options.scopeCode))?.websiteId || undefined
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
let normalizedScopeId
|
|
185
|
+
try {
|
|
186
|
+
normalizedScopeId = normalizeScopeId(scope, resolvedScopeId)
|
|
187
|
+
} catch (_) {
|
|
188
|
+
return null
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const cacheKey = `${scope}:${normalizedScopeId}:${path}`
|
|
192
|
+
const now = Date.now()
|
|
193
|
+
if (!options.fresh) {
|
|
194
|
+
const c = cache.get(cacheKey)
|
|
195
|
+
if (c && c.expiresAt > now) return c.value
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
let handle
|
|
199
|
+
try {
|
|
200
|
+
handle = await getClient(params)
|
|
201
|
+
} catch (_) {
|
|
202
|
+
return null
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
const collection = await handle.client.collection(COLLECTION)
|
|
207
|
+
const chain = buildInheritanceChain(scope, normalizedScopeId, parentWebsiteId)
|
|
208
|
+
|
|
209
|
+
let resolved = null
|
|
210
|
+
for (const link of chain) {
|
|
211
|
+
const id = toStateKey(link.scope, link.scopeId, path)
|
|
212
|
+
const doc = await tryFindOne(collection, { _id: id })
|
|
213
|
+
if (!doc || doc.value === undefined) continue
|
|
214
|
+
let value = doc.value
|
|
215
|
+
if (isEncrypted(value)) {
|
|
216
|
+
try { value = decrypt(value, params) } catch (_) { /* keep raw */ }
|
|
217
|
+
}
|
|
218
|
+
value = maybeParseJson(value)
|
|
219
|
+
resolved = value
|
|
220
|
+
break
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
cache.set(cacheKey, { value: resolved, expiresAt: now + CACHE_TTL_MS })
|
|
224
|
+
return resolved
|
|
225
|
+
} finally {
|
|
226
|
+
try { await handle.close() } catch (_) { /* noop */ }
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Clear the entire in-process cache (e.g. after a re-migration). */
|
|
231
|
+
function clearAbdbConfigCache () {
|
|
232
|
+
cache.clear()
|
|
233
|
+
websiteCodeToId = null
|
|
234
|
+
storeCodeToId = null
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
module.exports = {
|
|
238
|
+
COLLECTION,
|
|
239
|
+
getConfig,
|
|
240
|
+
clearAbdbConfigCache
|
|
241
|
+
}
|
|
@@ -0,0 +1,476 @@
|
|
|
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
|
+
Unless required by applicable law or agreed to in writing, software distributed under
|
|
8
|
+
the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
|
9
|
+
OF ANY KIND, either express or implied. See the License for the specific language
|
|
10
|
+
governing permissions and limitations under the License.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const { generateAccessToken } = require('@adobe/aio-lib-core-auth')
|
|
14
|
+
const libDb = require('@adobe/aio-lib-db')
|
|
15
|
+
|
|
16
|
+
const COLLECTION_IMPORT_QUEUE = 'import_queue'
|
|
17
|
+
const IMPORT_PIPELINE_COLLECTIONS = [COLLECTION_IMPORT_QUEUE]
|
|
18
|
+
const ABDB_SCOPES = ['adobeio.abdata.read', 'adobeio.abdata.write', 'adobeio.abdata.manage']
|
|
19
|
+
|
|
20
|
+
function isUnsetOauthInput (value) {
|
|
21
|
+
if (value == null || value === '') return true
|
|
22
|
+
const s = String(value).trim()
|
|
23
|
+
return s === '' || s.startsWith('$')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function normalizeScopesToArray (scopes) {
|
|
27
|
+
if (!scopes) return []
|
|
28
|
+
if (Array.isArray(scopes)) return scopes.filter(Boolean).map(String)
|
|
29
|
+
const s = String(scopes).trim()
|
|
30
|
+
if (!s) return []
|
|
31
|
+
return s.split(/[\s,]+/).filter(Boolean)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Get ABDB client. Requires action to have include-ims-credentials: true.
|
|
36
|
+
*
|
|
37
|
+
* Region resolution (first non-empty wins):
|
|
38
|
+
* 1. options.region — explicit override at the call site
|
|
39
|
+
* 2. params.AIO_DB_REGION — action input from ext.config.yaml
|
|
40
|
+
* 3. process.env.AIO_DB_REGION — local .env when running aio app run
|
|
41
|
+
*
|
|
42
|
+
* Throws if none is configured — we never silently pick a region for you.
|
|
43
|
+
*
|
|
44
|
+
* @param {object} params - Action params (must contain OAuth credentials for generateAccessToken)
|
|
45
|
+
* @param {object} [options]
|
|
46
|
+
* @param {string} [options.region] - explicit region override
|
|
47
|
+
* @returns {Promise<{client: object, close: function}>}
|
|
48
|
+
*/
|
|
49
|
+
async function getClient (params, options = {}) {
|
|
50
|
+
const tokenResponse = await generateAccessToken(params)
|
|
51
|
+
const token = tokenResponse.access_token || tokenResponse
|
|
52
|
+
const region = options.region || params?.AIO_DB_REGION || process.env.AIO_DB_REGION
|
|
53
|
+
if (!region || typeof region !== 'string' || !region.trim()) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
'ABDB region not configured: set AIO_DB_REGION in .env and pass it through ext.config.yaml inputs'
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const db = await libDb.init({ token, region: region.trim() })
|
|
60
|
+
const client = await db.connect()
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
client,
|
|
64
|
+
close: () => client.close()
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Get collection by name
|
|
70
|
+
* @param {object} client - ABDB client from getClient()
|
|
71
|
+
* @param {string} collectionName - Collection name
|
|
72
|
+
* @returns {Promise<object>} MongoDB-style collection
|
|
73
|
+
*/
|
|
74
|
+
async function getCollection (client, collectionName) {
|
|
75
|
+
return client.collection(collectionName)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Get collection by name
|
|
80
|
+
* @param {object} client - ABDB client from getClient()
|
|
81
|
+
* @param {string} collectionName - Collection name
|
|
82
|
+
* @returns {Promise<object>} MongoDB-style collection
|
|
83
|
+
*/
|
|
84
|
+
function getCollectionByName (client,collectionName) {
|
|
85
|
+
return client.collection(collectionName)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Normalize listCollections API response to a Set of collection names.
|
|
90
|
+
* @param {*} raw
|
|
91
|
+
* @returns {Set<string>}
|
|
92
|
+
*/
|
|
93
|
+
function collectionNamesFromListResponse (raw) {
|
|
94
|
+
const out = new Set()
|
|
95
|
+
if (!raw) return out
|
|
96
|
+
const visit = (item) => {
|
|
97
|
+
if (typeof item === 'string') {
|
|
98
|
+
out.add(item)
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
if (item && typeof item === 'object') {
|
|
102
|
+
const n = item.name ?? item.collectionName
|
|
103
|
+
if (typeof n === 'string') out.add(n)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (Array.isArray(raw)) {
|
|
107
|
+
raw.forEach(visit)
|
|
108
|
+
return out
|
|
109
|
+
}
|
|
110
|
+
if (typeof raw === 'object') {
|
|
111
|
+
const nested = raw.collections ?? raw.cursor?.firstBatch ?? raw.data
|
|
112
|
+
if (Array.isArray(nested)) {
|
|
113
|
+
nested.forEach(visit)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return out
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Ensure import pipeline collections exist in ABDB (recreate if dropped in console).
|
|
121
|
+
* Uses listCollections when possible; falls back to createCollection with duplicate tolerance.
|
|
122
|
+
*
|
|
123
|
+
* @param {object} client - DbClient from getClient().client
|
|
124
|
+
* @param {object} [options]
|
|
125
|
+
* @param {{ info?: function, warn?: function }} [options.logger] - optional aio logger
|
|
126
|
+
* @returns {Promise<void>}
|
|
127
|
+
*/
|
|
128
|
+
async function ensureImportCollectionsExist (client, options = {}) {
|
|
129
|
+
const log = options.logger || { info: () => {}, warn: () => {} }
|
|
130
|
+
let existing = new Set()
|
|
131
|
+
try {
|
|
132
|
+
const raw = await client.listCollections({})
|
|
133
|
+
existing = collectionNamesFromListResponse(raw)
|
|
134
|
+
} catch (e) {
|
|
135
|
+
log.warn(`ensureImportCollectionsExist: listCollections failed (${e.message}); attempting createCollection for each pipeline collection`)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const created = []
|
|
139
|
+
for (const name of IMPORT_PIPELINE_COLLECTIONS) {
|
|
140
|
+
if (existing.has(name)) continue
|
|
141
|
+
try {
|
|
142
|
+
await client.createCollection(name)
|
|
143
|
+
created.push(name)
|
|
144
|
+
} catch (err) {
|
|
145
|
+
const m = (err && err.message) ? String(err.message) : String(err)
|
|
146
|
+
if (/exist|already|duplicate/i.test(m)) {
|
|
147
|
+
continue
|
|
148
|
+
}
|
|
149
|
+
throw err
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (created.length) {
|
|
153
|
+
log.info(`ensureImportCollectionsExist: created ABDB collections: ${created.join(', ')}`)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* OAuth Server-to-Server (client_credentials) via @adobe/aio-lib-ims — same pattern as workspace .env OAUTH_*.
|
|
159
|
+
*
|
|
160
|
+
* @param {object} params - Must include OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OAUTH_ORG_ID; OAUTH_SCOPES merged with ABDB scopes
|
|
161
|
+
* @returns {Promise<string|null>} token or null if required params are missing
|
|
162
|
+
*/
|
|
163
|
+
async function fetchImsTokenFromClientCredentials (params = {}) {
|
|
164
|
+
const clientId = params.OAUTH_CLIENT_ID
|
|
165
|
+
const clientSecret = params.OAUTH_CLIENT_SECRET
|
|
166
|
+
const orgId = params.OAUTH_ORG_ID
|
|
167
|
+
if (isUnsetOauthInput(clientId) || isUnsetOauthInput(clientSecret) || isUnsetOauthInput(orgId)) {
|
|
168
|
+
return null
|
|
169
|
+
}
|
|
170
|
+
const extra = normalizeScopesToArray(params.OAUTH_SCOPES)
|
|
171
|
+
const scopes = [...new Set([...ABDB_SCOPES, ...extra])]
|
|
172
|
+
if (scopes.length === 0) {
|
|
173
|
+
throw new Error('No IMS scopes resolved for ABDB; set OAUTH_SCOPES or rely on default ABDB scopes')
|
|
174
|
+
}
|
|
175
|
+
const { Ims } = require('@adobe/aio-lib-ims')
|
|
176
|
+
const ims = new Ims()
|
|
177
|
+
const tokenResult = await ims.getAccessTokenByClientCredentials(clientId, clientSecret, orgId, scopes)
|
|
178
|
+
const token =
|
|
179
|
+
tokenResult?.access_token?.token ||
|
|
180
|
+
(typeof tokenResult?.payload?.access_token === 'string' ? tokenResult.payload.access_token : null)
|
|
181
|
+
if (!token) {
|
|
182
|
+
throw new Error(
|
|
183
|
+
`IMS client_credentials failed or returned no token: ${JSON.stringify(tokenResult?.payload || tokenResult)}`
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
return token
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Resolve IMS bearer token for ABDB.
|
|
191
|
+
* Order: options.token → params access_token → AIO_DB_IMS_TOKEN → OAUTH_* client_credentials (@adobe/aio-lib-ims) → @adobe/aio-lib-core-auth
|
|
192
|
+
*
|
|
193
|
+
* @param {object} params - Runtime action params
|
|
194
|
+
* @param {object} options - { token?: string }
|
|
195
|
+
* @returns {Promise<string>}
|
|
196
|
+
*/
|
|
197
|
+
async function resolveImsToken (params = {}, options = {}) {
|
|
198
|
+
if (options.token != null && String(options.token).trim() !== '') {
|
|
199
|
+
return String(options.token).trim()
|
|
200
|
+
}
|
|
201
|
+
const fromParams = params.access_token || params.ACCESS_TOKEN || params.__oauth?.access_token
|
|
202
|
+
if (fromParams != null && String(fromParams).trim() !== '') {
|
|
203
|
+
return String(fromParams).trim()
|
|
204
|
+
}
|
|
205
|
+
if (process.env.AIO_DB_IMS_TOKEN != null && String(process.env.AIO_DB_IMS_TOKEN).trim() !== '') {
|
|
206
|
+
return String(process.env.AIO_DB_IMS_TOKEN).trim()
|
|
207
|
+
}
|
|
208
|
+
const fromOAuth = await fetchImsTokenFromClientCredentials(params)
|
|
209
|
+
if (fromOAuth) {
|
|
210
|
+
return fromOAuth
|
|
211
|
+
}
|
|
212
|
+
let generateAccessTokenFn
|
|
213
|
+
try {
|
|
214
|
+
({ generateAccessToken: generateAccessTokenFn } = require('@adobe/aio-lib-core-auth'))
|
|
215
|
+
} catch {
|
|
216
|
+
throw new Error(
|
|
217
|
+
'ABDB IMS token missing: set OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OAUTH_ORG_ID, OAUTH_SCOPES on the action; ' +
|
|
218
|
+
'or pass access_token / options.token; or set AIO_DB_IMS_TOKEN; ' +
|
|
219
|
+
'or install @adobe/aio-lib-core-auth.'
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
const tokenResponse = await generateAccessTokenFn(params)
|
|
223
|
+
return tokenResponse.access_token?.token || tokenResponse.access_token || tokenResponse
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Get ABDB client. Requires a valid IMS access token (see resolveImsToken).
|
|
228
|
+
*
|
|
229
|
+
* @param {object} params - Action params (for generateAccessToken when package is used)
|
|
230
|
+
* @param {object} options - { token?: string, region?: string, ow?: { namespace?: string } }
|
|
231
|
+
* @returns {Promise<{ db: object, client: object, close: () => Promise<void> }>}
|
|
232
|
+
*/
|
|
233
|
+
async function getClientAbdb (params = {}, options = {}) {
|
|
234
|
+
const token = await resolveImsToken(params, options)
|
|
235
|
+
const region = options.region || process.env.AIO_DB_REGION || 'amer'
|
|
236
|
+
const ow = options.ow
|
|
237
|
+
|
|
238
|
+
const db = await libDb.init({ token, region, ...(ow ? { ow } : {}) })
|
|
239
|
+
const client = await db.connect()
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
db,
|
|
243
|
+
client,
|
|
244
|
+
close: async () => client.close()
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Run work with a connected client; always closes the client in finally.
|
|
250
|
+
*
|
|
251
|
+
* @param {object} params
|
|
252
|
+
* @param {(client: object) => Promise<*>} fn
|
|
253
|
+
* @param {object} [options]
|
|
254
|
+
* @returns {Promise<*>}
|
|
255
|
+
*/
|
|
256
|
+
async function withDbClient (params, fn, options = {}) {
|
|
257
|
+
const { client, close } = await getClientAbdb(params, options)
|
|
258
|
+
try {
|
|
259
|
+
return await fn(client)
|
|
260
|
+
} finally {
|
|
261
|
+
await close()
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
// ——— DbClient (database-level) ———
|
|
265
|
+
|
|
266
|
+
async function dbStats (client, options = {}) {
|
|
267
|
+
return client.dbStats(options)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function orgStats (client, options = {}) {
|
|
271
|
+
return client.orgStats(options)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function listCollections (client, filter = {}, options = {}) {
|
|
275
|
+
return client.listCollections(filter, options)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function createCollection (client, name, options = {}) {
|
|
279
|
+
return client.createCollection(name, options)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ——— DbCollection: writes ———
|
|
283
|
+
|
|
284
|
+
async function insertOne (client, collectionName, document, options = {}) {
|
|
285
|
+
return getCollectionByName(client, collectionName).insertOne(document, options)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function insertMany (client, collectionName, documents, options = {}) {
|
|
289
|
+
return getCollectionByName(client, collectionName).insertMany(documents, options)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function updateOne (client, collectionName, filter, update, options = {}) {
|
|
293
|
+
return getCollectionByName(client, collectionName).updateOne(filter, update, options)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function updateMany (client, collectionName, filter, update, options = {}) {
|
|
297
|
+
return getCollectionByName(client, collectionName).updateMany(filter, update, options)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function replaceOne (client, collectionName, filter, replacement, options = {}) {
|
|
301
|
+
return getCollectionByName(client, collectionName).replaceOne(filter, replacement, options)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function deleteOne (client, collectionName, filter, options = {}) {
|
|
305
|
+
return getCollectionByName(client, collectionName).deleteOne(filter, options)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function deleteMany (client, collectionName, filter, options = {}) {
|
|
309
|
+
return getCollectionByName(client, collectionName).deleteMany(filter, options)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function bulkWrite (client, collectionName, operations, options = {}) {
|
|
313
|
+
return getCollectionByName(client, collectionName).bulkWrite(operations, options)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function findOneAndUpdate (client, collectionName, filter, update, options = {}) {
|
|
317
|
+
return getCollectionByName(client, collectionName).findOneAndUpdate(filter, update, options)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function findOneAndReplace (client, collectionName, filter, replacement, options = {}) {
|
|
321
|
+
return getCollectionByName(client, collectionName).findOneAndReplace(filter, replacement, options)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function findOneAndDelete (client, collectionName, filter, options = {}) {
|
|
325
|
+
return getCollectionByName(client, collectionName).findOneAndDelete(filter, options)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ——— DbCollection: reads ———
|
|
329
|
+
|
|
330
|
+
async function findOne (client, collectionName, filter, options = {}) {
|
|
331
|
+
return getCollectionByName(client, collectionName).findOne(filter, options)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** ABDB findOne throws DbError with message "Document not found" when no match — unlike MongoDB null. */
|
|
335
|
+
function isDocumentNotFoundDbError (err) {
|
|
336
|
+
const msg = err != null && typeof err.message === 'string' ? err.message : ''
|
|
337
|
+
return err?.name === 'DbError' && msg.includes('Document not found')
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Like findOne, but returns null when no document matches (ABDB error → null).
|
|
342
|
+
*
|
|
343
|
+
* @returns {Promise<object|null>}
|
|
344
|
+
*/
|
|
345
|
+
async function findOneOrNull (client, collectionName, filter, options = {}) {
|
|
346
|
+
try {
|
|
347
|
+
return await findOne(client, collectionName, filter, options)
|
|
348
|
+
} catch (err) {
|
|
349
|
+
if (isDocumentNotFoundDbError(err)) {
|
|
350
|
+
return null
|
|
351
|
+
}
|
|
352
|
+
throw err
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* @returns {object} FindCursor — await cursor.close() when done (or call client.close()).
|
|
358
|
+
*/
|
|
359
|
+
function find (client, collectionName, filter = {}, options = {}) {
|
|
360
|
+
return getCollectionByName(client, collectionName).find(filter, options)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function findArray (client, collectionName, filter = {}, options = {}) {
|
|
364
|
+
return getCollectionByName(client, collectionName).findArray(filter, options)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** find() + toArray() with cursor closed after use. */
|
|
368
|
+
async function findToArray (client, collectionName, filter = {}, options = {}) {
|
|
369
|
+
const cursor = find(client, collectionName, filter, options)
|
|
370
|
+
try {
|
|
371
|
+
return await cursor.toArray()
|
|
372
|
+
} finally {
|
|
373
|
+
await cursor.close()
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async function countDocuments (client, collectionName, filter = {}, options = {}) {
|
|
378
|
+
return getCollectionByName(client, collectionName).countDocuments(filter, options)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function estimatedDocumentCount (client, collectionName, options = {}) {
|
|
382
|
+
return getCollectionByName(client, collectionName).estimatedDocumentCount(options)
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function distinct (client, collectionName, field, filter = {}, options = {}) {
|
|
386
|
+
return getCollectionByName(client, collectionName).distinct(field, filter, options)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ——— aggregate ———
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* @returns {object} AggregateCursor — close when done.
|
|
393
|
+
*/
|
|
394
|
+
function aggregate (client, collectionName, pipeline = [], options = {}) {
|
|
395
|
+
return getCollectionByName(client, collectionName).aggregate(pipeline, options)
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async function aggregateToArray (client, collectionName, pipeline = [], options = {}) {
|
|
399
|
+
const cursor = aggregate(client, collectionName, pipeline, options)
|
|
400
|
+
try {
|
|
401
|
+
return await cursor.toArray()
|
|
402
|
+
} finally {
|
|
403
|
+
await cursor.close()
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ——— indexes & collection admin ———
|
|
408
|
+
|
|
409
|
+
async function getIndexes (client, collectionName) {
|
|
410
|
+
return getCollectionByName(client, collectionName).getIndexes()
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async function createIndex (client, collectionName, specification, options = {}) {
|
|
414
|
+
return getCollectionByName(client, collectionName).createIndex(specification, options)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async function dropIndex (client, collectionName, index, options = {}) {
|
|
418
|
+
return getCollectionByName(client, collectionName).dropIndex(index, options)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async function collectionStats (client, collectionName, options = {}) {
|
|
422
|
+
return getCollectionByName(client, collectionName).stats(options)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async function dropCollection (client, collectionName, options = {}) {
|
|
426
|
+
return getCollectionByName(client, collectionName).drop(options)
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function renameCollection (client, collectionName, newCollectionName, options = {}) {
|
|
430
|
+
const col = getCollectionByName(client, collectionName)
|
|
431
|
+
await col.renameCollection(newCollectionName, options)
|
|
432
|
+
return col
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
module.exports = {
|
|
436
|
+
COLLECTION_IMPORT_QUEUE,
|
|
437
|
+
IMPORT_PIPELINE_COLLECTIONS,
|
|
438
|
+
getClient,
|
|
439
|
+
getCollection,
|
|
440
|
+
ensureImportCollectionsExist,
|
|
441
|
+
resolveImsToken,
|
|
442
|
+
getCollectionByName,
|
|
443
|
+
getClientAbdb,
|
|
444
|
+
withDbClient,
|
|
445
|
+
dbStats,
|
|
446
|
+
orgStats,
|
|
447
|
+
listCollections,
|
|
448
|
+
createCollection,
|
|
449
|
+
insertOne,
|
|
450
|
+
insertMany,
|
|
451
|
+
updateOne,
|
|
452
|
+
updateMany,
|
|
453
|
+
replaceOne,
|
|
454
|
+
deleteOne,
|
|
455
|
+
deleteMany,
|
|
456
|
+
bulkWrite,
|
|
457
|
+
findOneAndUpdate,
|
|
458
|
+
findOneAndReplace,
|
|
459
|
+
findOneAndDelete,
|
|
460
|
+
findOne,
|
|
461
|
+
findOneOrNull,
|
|
462
|
+
find,
|
|
463
|
+
findArray,
|
|
464
|
+
findToArray,
|
|
465
|
+
countDocuments,
|
|
466
|
+
estimatedDocumentCount,
|
|
467
|
+
distinct,
|
|
468
|
+
aggregate,
|
|
469
|
+
aggregateToArray,
|
|
470
|
+
getIndexes,
|
|
471
|
+
createIndex,
|
|
472
|
+
dropIndex,
|
|
473
|
+
collectionStats,
|
|
474
|
+
dropCollection,
|
|
475
|
+
renameCollection
|
|
476
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
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 abdbHelper = require('./abdb-helper')
|
|
9
|
+
const abdbConfig = require('./abdb-config')
|
|
10
|
+
const systemConfigShared = require('./system-config-shared')
|
|
11
|
+
const systemConfigCrypto = require('./system-config-crypto')
|
|
12
|
+
const oauth1a = require('./oauth1a')
|
|
13
|
+
|
|
14
|
+
module.exports = {
|
|
15
|
+
...abdbHelper,
|
|
16
|
+
...abdbConfig,
|
|
17
|
+
...systemConfigShared,
|
|
18
|
+
...systemConfigCrypto,
|
|
19
|
+
...oauth1a
|
|
20
|
+
}
|
package/src/oauth1a.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
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
|
+
Unless required by applicable law or agreed to in writing, software distributed under
|
|
8
|
+
the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
|
9
|
+
OF ANY KIND, either express or implied. See the License for the specific language
|
|
10
|
+
governing permissions and limitations under the License.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const Oauth1a = require('oauth-1.0a')
|
|
14
|
+
const crypto = require('crypto')
|
|
15
|
+
const got = require('got')
|
|
16
|
+
|
|
17
|
+
function getOauthClient(options, logger) {
|
|
18
|
+
const instance = {}
|
|
19
|
+
|
|
20
|
+
// Remove trailing slash if any
|
|
21
|
+
const serverUrl = options.url
|
|
22
|
+
const apiVersion = options.version
|
|
23
|
+
const oauth = Oauth1a({
|
|
24
|
+
consumer: {
|
|
25
|
+
key: options.consumerKey,
|
|
26
|
+
secret: options.consumerSecret
|
|
27
|
+
},
|
|
28
|
+
signature_method: 'HMAC-SHA256',
|
|
29
|
+
hash_function: hashFunctionSha256
|
|
30
|
+
})
|
|
31
|
+
const token = {
|
|
32
|
+
key: options.accessToken,
|
|
33
|
+
secret: options.accessTokenSecret
|
|
34
|
+
}
|
|
35
|
+
const storeCode = options.storeCode || 'default'
|
|
36
|
+
|
|
37
|
+
function hashFunctionSha256(baseString, key) {
|
|
38
|
+
return crypto.createHmac('sha256', key).update(baseString).digest('base64')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function apiCall(requestData, requestToken = '', customHeaders = {}) {
|
|
42
|
+
try {
|
|
43
|
+
const headers = {
|
|
44
|
+
...(requestToken
|
|
45
|
+
? { Authorization: 'Bearer ' + requestToken }
|
|
46
|
+
: oauth.toHeader(oauth.authorize(requestData, token))),
|
|
47
|
+
...customHeaders
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Configure HTTPS options based on environment
|
|
51
|
+
const httpsOptions = {
|
|
52
|
+
method: requestData.method,
|
|
53
|
+
headers,
|
|
54
|
+
body: requestData.body,
|
|
55
|
+
responseType: 'json',
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return await got(requestData.url, httpsOptions).json()
|
|
59
|
+
} catch (error) {
|
|
60
|
+
logger.error(error)
|
|
61
|
+
|
|
62
|
+
throw error
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
instance.consumerToken = async function (loginData) {
|
|
67
|
+
return apiCall({
|
|
68
|
+
url: createUrl('integration/customer/token', storeCode),
|
|
69
|
+
method: 'POST',
|
|
70
|
+
body: loginData
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function createUrl (resourceUrl, store) {
|
|
75
|
+
const s = (store != null && String(store).trim()) ? String(store).trim() : storeCode
|
|
76
|
+
return serverUrl + s + '/' + apiVersion + '/' + resourceUrl
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
instance.get = async function (resourceUrl, requestToken = '', customHeaders = {}) {
|
|
80
|
+
const requestData = {
|
|
81
|
+
url: createUrl(resourceUrl, storeCode),
|
|
82
|
+
method: 'GET'
|
|
83
|
+
}
|
|
84
|
+
return apiCall(requestData, requestToken, customHeaders)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
instance.post = async function (resourceUrl, data, requestToken = '', customHeaders = {}) {
|
|
88
|
+
const requestData = {
|
|
89
|
+
url: createUrl(resourceUrl, storeCode),
|
|
90
|
+
method: 'POST',
|
|
91
|
+
body: data
|
|
92
|
+
}
|
|
93
|
+
return apiCall(requestData, requestToken, customHeaders)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
instance.put = async function (resourceUrl, data, requestToken = '', customHeaders = {}) {
|
|
97
|
+
const requestData = {
|
|
98
|
+
url: createUrl(resourceUrl, storeCode),
|
|
99
|
+
method: 'PUT',
|
|
100
|
+
body: data
|
|
101
|
+
}
|
|
102
|
+
return apiCall(requestData, requestToken, customHeaders)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
instance.delete = async function (resourceUrl, requestToken = '', customHeaders = {}) {
|
|
106
|
+
const requestData = {
|
|
107
|
+
url: createUrl(resourceUrl, storeCode),
|
|
108
|
+
method: 'DELETE'
|
|
109
|
+
}
|
|
110
|
+
return apiCall(requestData, requestToken, customHeaders)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
instance.deleteWithBody = async function (resourceUrl, data, requestToken = '', customHeaders = {}) {
|
|
114
|
+
const requestData = {
|
|
115
|
+
url: createUrl(resourceUrl, storeCode),
|
|
116
|
+
method: 'DELETE',
|
|
117
|
+
body: data
|
|
118
|
+
}
|
|
119
|
+
return apiCall(requestData, requestToken, customHeaders)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return instance
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function getCommerceOauthClient(options, logger) {
|
|
126
|
+
options.version = 'V1'
|
|
127
|
+
const storePrefix = options.storeCode ? `${options.storeCode}/` : ''
|
|
128
|
+
options.url = options.url + 'rest/' + storePrefix
|
|
129
|
+
return getOauthClient(options, logger)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = {
|
|
133
|
+
getOauthClient,
|
|
134
|
+
getCommerceOauthClient
|
|
135
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
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
|
+
// Equivalent of Magento's env.php `crypt.key`: a project-wide secret used to
|
|
9
|
+
// derive an AES-256-GCM key that protects sensitive system_config values at
|
|
10
|
+
// rest in App Builder DB (aio-lib-state).
|
|
11
|
+
//
|
|
12
|
+
// Key material is taken from action params/env (never from request payload):
|
|
13
|
+
// SYSTEM_CONFIG_CRYPT_KEY – preferred, dedicated secret
|
|
14
|
+
// OAUTH_CLIENT_SECRET – fallback, the workspace's client secret
|
|
15
|
+
//
|
|
16
|
+
// Wire format for ciphertext (string):
|
|
17
|
+
// enc:v1:<base64url(salt)>:<base64url(iv)>:<base64url(tag)>:<base64url(ct)>
|
|
18
|
+
// `v1` lets us rotate the algorithm later without breaking previously stored
|
|
19
|
+
// values. `salt` is per-record so the derived key changes even if the same
|
|
20
|
+
// master secret is reused across records.
|
|
21
|
+
|
|
22
|
+
const crypto = require('crypto')
|
|
23
|
+
|
|
24
|
+
const ENC_PREFIX = 'enc:v1:'
|
|
25
|
+
const KEY_BYTES = 32
|
|
26
|
+
const IV_BYTES = 12
|
|
27
|
+
const SALT_BYTES = 16
|
|
28
|
+
const SCRYPT_PARAMS = { N: 16384, r: 8, p: 1 }
|
|
29
|
+
|
|
30
|
+
function b64uEncode (buf) {
|
|
31
|
+
return Buffer.from(buf).toString('base64url')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function b64uDecode (str) {
|
|
35
|
+
return Buffer.from(str, 'base64url')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function resolveMasterSecret (params = {}) {
|
|
39
|
+
const secret =
|
|
40
|
+
params.SYSTEM_CONFIG_CRYPT_KEY ||
|
|
41
|
+
params.OAUTH_CLIENT_SECRET ||
|
|
42
|
+
process.env.SYSTEM_CONFIG_CRYPT_KEY ||
|
|
43
|
+
process.env.OAUTH_CLIENT_SECRET ||
|
|
44
|
+
''
|
|
45
|
+
if (!secret || typeof secret !== 'string' || secret.length < 8) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
'Encryption key not configured: set SYSTEM_CONFIG_CRYPT_KEY or OAUTH_CLIENT_SECRET'
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
return secret
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function deriveKey (masterSecret, salt) {
|
|
54
|
+
return crypto.scryptSync(masterSecret, salt, KEY_BYTES, SCRYPT_PARAMS)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isEncrypted (value) {
|
|
58
|
+
return typeof value === 'string' && value.startsWith(ENC_PREFIX)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function encrypt (plaintext, params) {
|
|
62
|
+
if (plaintext == null || plaintext === '') {
|
|
63
|
+
return plaintext
|
|
64
|
+
}
|
|
65
|
+
const text = typeof plaintext === 'string' ? plaintext : String(plaintext)
|
|
66
|
+
const secret = resolveMasterSecret(params)
|
|
67
|
+
const salt = crypto.randomBytes(SALT_BYTES)
|
|
68
|
+
const iv = crypto.randomBytes(IV_BYTES)
|
|
69
|
+
const key = deriveKey(secret, salt)
|
|
70
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv)
|
|
71
|
+
const ct = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()])
|
|
72
|
+
const tag = cipher.getAuthTag()
|
|
73
|
+
return [
|
|
74
|
+
ENC_PREFIX,
|
|
75
|
+
b64uEncode(salt),
|
|
76
|
+
':',
|
|
77
|
+
b64uEncode(iv),
|
|
78
|
+
':',
|
|
79
|
+
b64uEncode(tag),
|
|
80
|
+
':',
|
|
81
|
+
b64uEncode(ct)
|
|
82
|
+
].join('')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function decrypt (encrypted, params) {
|
|
86
|
+
if (!isEncrypted(encrypted)) {
|
|
87
|
+
return encrypted
|
|
88
|
+
}
|
|
89
|
+
const body = encrypted.slice(ENC_PREFIX.length)
|
|
90
|
+
const parts = body.split(':')
|
|
91
|
+
if (parts.length !== 4) {
|
|
92
|
+
throw new Error('Malformed encrypted value')
|
|
93
|
+
}
|
|
94
|
+
const [saltB64, ivB64, tagB64, ctB64] = parts
|
|
95
|
+
const secret = resolveMasterSecret(params)
|
|
96
|
+
const salt = b64uDecode(saltB64)
|
|
97
|
+
const iv = b64uDecode(ivB64)
|
|
98
|
+
const tag = b64uDecode(tagB64)
|
|
99
|
+
const ct = b64uDecode(ctB64)
|
|
100
|
+
const key = deriveKey(secret, salt)
|
|
101
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv)
|
|
102
|
+
decipher.setAuthTag(tag)
|
|
103
|
+
const pt = Buffer.concat([decipher.update(ct), decipher.final()])
|
|
104
|
+
return pt.toString('utf8')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = {
|
|
108
|
+
ENC_PREFIX,
|
|
109
|
+
isEncrypted,
|
|
110
|
+
encrypt,
|
|
111
|
+
decrypt,
|
|
112
|
+
resolveMasterSecret
|
|
113
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
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
|
+
// Mirrors Magento's core_config_data: (scope, scope_id, path, value).
|
|
9
|
+
// aio-lib-state keys allow [a-zA-Z0-9_-] only, so we encode
|
|
10
|
+
// scope=`default` scopeId=0 path=`web/secure/base_url`
|
|
11
|
+
// as the state key
|
|
12
|
+
// sysconfig__default__0__web__secure__base_url
|
|
13
|
+
|
|
14
|
+
const STATE_KEY_PREFIX = 'sysconfig__'
|
|
15
|
+
const SCOPES = ['default', 'websites', 'stores']
|
|
16
|
+
const PATH_SEGMENT = /^[a-zA-Z0-9_-]+$/
|
|
17
|
+
const SCOPE_ID_RE = /^[a-zA-Z0-9_-]+$/
|
|
18
|
+
const SENSITIVE_PLACEHOLDER = '__SENSITIVE_UNCHANGED__'
|
|
19
|
+
const USE_DEFAULT_SENTINEL = '__USE_DEFAULT__'
|
|
20
|
+
|
|
21
|
+
function isValidPath (path) {
|
|
22
|
+
if (typeof path !== 'string') return false
|
|
23
|
+
const parts = path.split('/')
|
|
24
|
+
if (parts.length !== 3) return false
|
|
25
|
+
return parts.every((p) => PATH_SEGMENT.test(p))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizeScope (scope) {
|
|
29
|
+
if (!scope) return 'default'
|
|
30
|
+
if (!SCOPES.includes(scope)) {
|
|
31
|
+
throw new Error(`Invalid scope "${scope}". Expected one of: ${SCOPES.join(', ')}`)
|
|
32
|
+
}
|
|
33
|
+
return scope
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function normalizeScopeId (scope, scopeId) {
|
|
37
|
+
if (scope === 'default') return '0'
|
|
38
|
+
const id = String(scopeId ?? '').trim()
|
|
39
|
+
if (!id || !SCOPE_ID_RE.test(id)) {
|
|
40
|
+
throw new Error(`Invalid scopeId "${scopeId}" for scope "${scope}"`)
|
|
41
|
+
}
|
|
42
|
+
return id
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function toStateKey (scope, scopeId, path) {
|
|
46
|
+
if (!isValidPath(path)) {
|
|
47
|
+
throw new Error(`Invalid config path: ${path}`)
|
|
48
|
+
}
|
|
49
|
+
const s = normalizeScope(scope)
|
|
50
|
+
const sid = normalizeScopeId(s, scopeId)
|
|
51
|
+
return [STATE_KEY_PREFIX, s, '__', sid, '__', path.split('/').join('__')].join('')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Magento-style fallback chain. When reading at store scope we look up:
|
|
56
|
+
* stores:<storeId> → websites:<websiteId> → default:0
|
|
57
|
+
* `parentWebsiteId` is supplied by the caller (resolved from /rest/V1/store/storeViews).
|
|
58
|
+
*/
|
|
59
|
+
function buildInheritanceChain (scope, scopeId, parentWebsiteId) {
|
|
60
|
+
const s = normalizeScope(scope)
|
|
61
|
+
if (s === 'default') {
|
|
62
|
+
return [{ scope: 'default', scopeId: '0' }]
|
|
63
|
+
}
|
|
64
|
+
if (s === 'websites') {
|
|
65
|
+
return [
|
|
66
|
+
{ scope: 'websites', scopeId: normalizeScopeId('websites', scopeId) },
|
|
67
|
+
{ scope: 'default', scopeId: '0' }
|
|
68
|
+
]
|
|
69
|
+
}
|
|
70
|
+
// stores
|
|
71
|
+
const chain = [{ scope: 'stores', scopeId: normalizeScopeId('stores', scopeId) }]
|
|
72
|
+
if (parentWebsiteId !== undefined && parentWebsiteId !== null && String(parentWebsiteId) !== '') {
|
|
73
|
+
chain.push({ scope: 'websites', scopeId: normalizeScopeId('websites', parentWebsiteId) })
|
|
74
|
+
}
|
|
75
|
+
chain.push({ scope: 'default', scopeId: '0' })
|
|
76
|
+
return chain
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = {
|
|
80
|
+
STATE_KEY_PREFIX,
|
|
81
|
+
SCOPES,
|
|
82
|
+
SENSITIVE_PLACEHOLDER,
|
|
83
|
+
USE_DEFAULT_SENTINEL,
|
|
84
|
+
isValidPath,
|
|
85
|
+
normalizeScope,
|
|
86
|
+
normalizeScopeId,
|
|
87
|
+
toStateKey,
|
|
88
|
+
buildInheritanceChain
|
|
89
|
+
}
|