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 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
+ }