configuration-management 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +199 -0
- package/actions/configurations/commerce/index.js +55 -0
- package/actions/configurations/export-config/index.js +259 -0
- package/actions/configurations/ext.config.yaml +151 -0
- package/actions/configurations/import-config/index.js +544 -0
- package/actions/configurations/registration/index.js +37 -0
- package/actions/configurations/sync-store-mappings-from-commerce/index.js +199 -0
- package/actions/configurations/system-config-list/index.js +127 -0
- package/actions/configurations/system-config-save/index.js +160 -0
- package/actions/configurations/system-config-schema/index.js +327 -0
- package/actions/utils.js +73 -0
- package/package.json +74 -0
- package/scripts/setup-app-config.js +114 -0
- package/src/abdb-config.js +241 -0
- package/src/abdb-helper.js +476 -0
- package/src/index.js +20 -0
- package/src/oauth1a.js +135 -0
- package/src/system-config-crypto.js +113 -0
- package/src/system-config-shared.js +89 -0
- package/web/src/components/App.js +47 -0
- package/web/src/components/AppSectionNav.js +49 -0
- package/web/src/components/ExtensionRegistration.js +33 -0
- package/web/src/components/MainPage.js +46 -0
- package/web/src/components/SystemConfig.js +1464 -0
- package/web/src/components/SystemConfigSchemaEditor.js +459 -0
- package/web/src/hooks/useConfirm.js +355 -0
- package/web/src/hooks/useSystemConfig.js +238 -0
- package/web/src/hooks/useSystemConfigSchema.js +102 -0
- package/web/src/index.js +41 -0
- package/web/src/schema/systemConfigSchema.js +82 -0
- package/web/src/settings.js +57 -0
- package/web/src/styles/index.css +326 -0
- package/web/src/theme.js +104 -0
- package/web/src/utils/storeMappingsFromCommerceRest.js +73 -0
- package/web/src/utils.js +52 -0
|
@@ -0,0 +1,199 @@
|
|
|
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
|
+
// Rebuild the legacy `store_mappings` blob from Commerce REST and upsert it
|
|
9
|
+
// into system_config_data at general/settings/store_mappings (default scope).
|
|
10
|
+
//
|
|
11
|
+
// Output shape (matches the original middleware-config.json):
|
|
12
|
+
// {
|
|
13
|
+
// "0": { "code": "admin", "language_code": "en", "website_code": "admin", "website_id": "0" },
|
|
14
|
+
// "1": { "code": "default", "language_code": "en", "website_code": "base", "website_id": "1" },
|
|
15
|
+
// "2": { "code": "en_ch", "language_code": "en", "website_code": "ch", "website_id": "2" },
|
|
16
|
+
// ...
|
|
17
|
+
// }
|
|
18
|
+
//
|
|
19
|
+
// Inputs (action params):
|
|
20
|
+
// dryRun — true → preview only, no write
|
|
21
|
+
// includeAdmin — true → include website id=0 ("admin"). Default false.
|
|
22
|
+
//
|
|
23
|
+
// Trigger from `POST .../sync-store-mappings-from-commerce` or wire to a UI
|
|
24
|
+
// button. Stored as a JSON string in a textarea field, so existing
|
|
25
|
+
// `getConfig('general/settings/store_mappings', params)` callers get the
|
|
26
|
+
// parsed object back unchanged.
|
|
27
|
+
|
|
28
|
+
const { Core } = require('@adobe/aio-sdk')
|
|
29
|
+
const { errorResponse } = require('../../utils')
|
|
30
|
+
const { getClient } = require('configuration-management/abdb')
|
|
31
|
+
const { getCommerceOauthClient } = require('configuration-management/oauth1a')
|
|
32
|
+
const { toStateKey } = require('configuration-management/shared')
|
|
33
|
+
|
|
34
|
+
const DATA_COLLECTION = 'system_config_data'
|
|
35
|
+
const PATH = 'general/settings/store_mappings'
|
|
36
|
+
const SCOPE = 'default'
|
|
37
|
+
const SCOPE_ID = '0'
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Derive language_code from a store-view code following the conventional
|
|
41
|
+
* `<lang>_<region>` pattern (e.g. en_ch → 'en', fr_ch → 'fr'). Codes without
|
|
42
|
+
* an underscore fall back to 'en' to match the legacy middleware shape.
|
|
43
|
+
*/
|
|
44
|
+
function deriveLanguageCode (code) {
|
|
45
|
+
const m = String(code || '').toLowerCase().match(/^([a-z]{2})_/)
|
|
46
|
+
return m ? m[1] : 'en'
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function ensureCollection (client, name) {
|
|
50
|
+
try {
|
|
51
|
+
await client.createCollection(name)
|
|
52
|
+
} catch (err) {
|
|
53
|
+
const msg = (err && err.message) ? String(err.message) : String(err)
|
|
54
|
+
if (!/exist|already|duplicate/i.test(msg)) throw err
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function tryFindOne (collection, query) {
|
|
59
|
+
try {
|
|
60
|
+
const arr = await collection.find(query).limit(1).toArray()
|
|
61
|
+
return arr && arr.length ? arr[0] : null
|
|
62
|
+
} catch (err) {
|
|
63
|
+
const msg = err && err.message ? String(err.message) : String(err)
|
|
64
|
+
if (/not found/i.test(msg)) return null
|
|
65
|
+
throw err
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function fetchCommerceData (params, logger) {
|
|
70
|
+
if (!params.COMMERCE_BASE_URL) {
|
|
71
|
+
throw new Error('COMMERCE_BASE_URL is not configured')
|
|
72
|
+
}
|
|
73
|
+
const oauth = getCommerceOauthClient(
|
|
74
|
+
{
|
|
75
|
+
url: params.COMMERCE_BASE_URL,
|
|
76
|
+
consumerKey: params.COMMERCE_CONSUMER_KEY,
|
|
77
|
+
consumerSecret: params.COMMERCE_CONSUMER_SECRET,
|
|
78
|
+
accessToken: params.COMMERCE_ACCESS_TOKEN,
|
|
79
|
+
accessTokenSecret: params.COMMERCE_ACCESS_TOKEN_SECRET
|
|
80
|
+
},
|
|
81
|
+
logger
|
|
82
|
+
)
|
|
83
|
+
const [storeViews, websites] = await Promise.all([
|
|
84
|
+
oauth.get('store/storeViews'),
|
|
85
|
+
oauth.get('store/websites')
|
|
86
|
+
])
|
|
87
|
+
return {
|
|
88
|
+
storeViews: Array.isArray(storeViews) ? storeViews : [],
|
|
89
|
+
websites: Array.isArray(websites) ? websites : []
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function buildStoreMappings (storeViews, websites, { includeAdmin }) {
|
|
94
|
+
const websiteById = new Map()
|
|
95
|
+
for (const w of websites) {
|
|
96
|
+
if (w && w.id != null) websiteById.set(String(w.id), w)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const mapping = {}
|
|
100
|
+
for (const sv of storeViews) {
|
|
101
|
+
if (!sv || sv.id == null) continue
|
|
102
|
+
const storeId = String(sv.id)
|
|
103
|
+
if (!includeAdmin && (storeId === '0' || sv.code === 'admin')) continue
|
|
104
|
+
const websiteId = sv.website_id != null ? String(sv.website_id) : ''
|
|
105
|
+
const website = websiteById.get(websiteId)
|
|
106
|
+
mapping[storeId] = {
|
|
107
|
+
code: String(sv.code || ''),
|
|
108
|
+
language_code: deriveLanguageCode(sv.code),
|
|
109
|
+
website_code: website ? String(website.code || '') : '',
|
|
110
|
+
website_id: websiteId
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return mapping
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function main (params) {
|
|
117
|
+
const logger = Core.Logger('sync-store-mappings', { level: params.LOG_LEVEL || 'info' })
|
|
118
|
+
const dryRun = params.dryRun === true || params.dryRun === 'true'
|
|
119
|
+
const includeAdmin = params.includeAdmin === true || params.includeAdmin === 'true'
|
|
120
|
+
|
|
121
|
+
// 1. Fetch from Commerce.
|
|
122
|
+
let commerce
|
|
123
|
+
try {
|
|
124
|
+
commerce = await fetchCommerceData(params, logger)
|
|
125
|
+
} catch (e) {
|
|
126
|
+
logger.error(`Commerce REST failed: ${e.message}`)
|
|
127
|
+
return errorResponse(500, `Commerce REST failed: ${e.message}`, logger)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 2. Build the mapping.
|
|
131
|
+
const mapping = buildStoreMappings(commerce.storeViews, commerce.websites, { includeAdmin })
|
|
132
|
+
const count = Object.keys(mapping).length
|
|
133
|
+
if (count === 0) {
|
|
134
|
+
return errorResponse(500, 'No store views returned by Commerce — refusing to overwrite with empty mapping', logger)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (dryRun) {
|
|
138
|
+
return {
|
|
139
|
+
statusCode: 200,
|
|
140
|
+
body: { ok: true, dryRun: true, count, mapping }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 3. Upsert into ABDB at general/settings/store_mappings (default scope).
|
|
145
|
+
let dbHandle
|
|
146
|
+
try {
|
|
147
|
+
dbHandle = await getClient(params)
|
|
148
|
+
} catch (e) {
|
|
149
|
+
logger.error(`ABDB connect failed: ${e.message}`)
|
|
150
|
+
return errorResponse(500, `ABDB connect failed: ${e.message}`, logger)
|
|
151
|
+
}
|
|
152
|
+
const { client, close } = dbHandle
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
await ensureCollection(client, DATA_COLLECTION)
|
|
156
|
+
const collection = await client.collection(DATA_COLLECTION)
|
|
157
|
+
const _id = toStateKey(SCOPE, SCOPE_ID, PATH)
|
|
158
|
+
const now = new Date().toISOString()
|
|
159
|
+
// Stored as a JSON STRING (textarea field) so getConfig's maybeParseJson
|
|
160
|
+
// returns the object on read.
|
|
161
|
+
const value = JSON.stringify(mapping, null, 2)
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
await collection.updateOne(
|
|
165
|
+
{ _id },
|
|
166
|
+
{
|
|
167
|
+
$set: { value, updatedAt: now, scope: SCOPE, scope_id: SCOPE_ID, path: PATH },
|
|
168
|
+
$setOnInsert: { _id, createdAt: now }
|
|
169
|
+
},
|
|
170
|
+
{ upsert: true }
|
|
171
|
+
)
|
|
172
|
+
} catch (err) {
|
|
173
|
+
const msg = (err && err.message) ? String(err.message) : String(err)
|
|
174
|
+
if (!/upsert|unsupported|not implemented/i.test(msg)) throw err
|
|
175
|
+
// Fallback: find-then-write.
|
|
176
|
+
const existing = await tryFindOne(collection, { _id })
|
|
177
|
+
if (existing) {
|
|
178
|
+
await collection.updateOne({ _id }, { $set: { value, updatedAt: now } })
|
|
179
|
+
} else {
|
|
180
|
+
await collection.insertOne({
|
|
181
|
+
_id, scope: SCOPE, scope_id: SCOPE_ID, path: PATH, value, createdAt: now, updatedAt: now
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
logger.info(`store_mappings synced: ${count} entries → ${PATH}`)
|
|
187
|
+
return {
|
|
188
|
+
statusCode: 200,
|
|
189
|
+
body: { ok: true, count, mapping, path: PATH, scope: SCOPE, scope_id: SCOPE_ID }
|
|
190
|
+
}
|
|
191
|
+
} catch (error) {
|
|
192
|
+
logger.error(error)
|
|
193
|
+
return errorResponse(500, error.message || 'sync failed', logger)
|
|
194
|
+
} finally {
|
|
195
|
+
try { await close() } catch (_) {}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
exports.main = main
|
|
@@ -0,0 +1,127 @@
|
|
|
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 { getClient } = require('configuration-management/abdb')
|
|
11
|
+
const {
|
|
12
|
+
SENSITIVE_PLACEHOLDER,
|
|
13
|
+
toStateKey,
|
|
14
|
+
normalizeScope,
|
|
15
|
+
normalizeScopeId,
|
|
16
|
+
buildInheritanceChain
|
|
17
|
+
} = require('configuration-management/shared')
|
|
18
|
+
const { decrypt, isEncrypted } = require('configuration-management/crypto')
|
|
19
|
+
|
|
20
|
+
const COLLECTION = 'system_config_data'
|
|
21
|
+
|
|
22
|
+
async function ensureCollection (client) {
|
|
23
|
+
try {
|
|
24
|
+
await client.createCollection(COLLECTION)
|
|
25
|
+
} catch (err) {
|
|
26
|
+
const msg = (err && err.message) ? String(err.message) : String(err)
|
|
27
|
+
if (!/exist|already|duplicate/i.test(msg)) throw err
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Returns values for the requested paths from the ABDB system_config_data
|
|
33
|
+
* collection, applying Magento-style scope inheritance.
|
|
34
|
+
*
|
|
35
|
+
* Document shape (mirrors core_config_data):
|
|
36
|
+
* { _id, scope, scope_id, path, value, updatedAt }
|
|
37
|
+
*
|
|
38
|
+
* Response:
|
|
39
|
+
* { scope, scopeId, items: { "<path>": { value, origin, sensitive } } }
|
|
40
|
+
*/
|
|
41
|
+
async function main (params) {
|
|
42
|
+
const logger = Core.Logger('system-config-list', { level: params.LOG_LEVEL || 'info' })
|
|
43
|
+
|
|
44
|
+
const errorMessage = checkMissingRequestInputs(params, ['paths'], [])
|
|
45
|
+
if (errorMessage) return errorResponse(400, errorMessage, logger)
|
|
46
|
+
|
|
47
|
+
const { paths, sensitivePaths = [], scope: rawScope = 'default', scopeId: rawScopeId = '0', parentWebsiteId } = params
|
|
48
|
+
if (!Array.isArray(paths)) {
|
|
49
|
+
return errorResponse(400, 'paths must be an array of "section/group/field" strings', logger)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let scope, scopeId
|
|
53
|
+
try {
|
|
54
|
+
scope = normalizeScope(rawScope)
|
|
55
|
+
scopeId = normalizeScopeId(scope, rawScopeId)
|
|
56
|
+
} catch (e) {
|
|
57
|
+
return errorResponse(400, e.message, logger)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const sensitiveSet = new Set(sensitivePaths)
|
|
61
|
+
const chain = buildInheritanceChain(scope, scopeId, parentWebsiteId)
|
|
62
|
+
|
|
63
|
+
let dbHandle
|
|
64
|
+
try {
|
|
65
|
+
dbHandle = await getClient(params)
|
|
66
|
+
} catch (e) {
|
|
67
|
+
logger.error(`ABDB connect failed: ${e.message}`)
|
|
68
|
+
return errorResponse(500, `ABDB connect failed: ${e.message}`, logger)
|
|
69
|
+
}
|
|
70
|
+
const { client, close } = dbHandle
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
await ensureCollection(client)
|
|
74
|
+
const collection = await client.collection(COLLECTION)
|
|
75
|
+
|
|
76
|
+
// Batch fetch every (scope, path) combination we might need in one go.
|
|
77
|
+
const ids = []
|
|
78
|
+
for (const path of paths) {
|
|
79
|
+
for (const link of chain) {
|
|
80
|
+
ids.push(toStateKey(link.scope, link.scopeId, path))
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const docs = ids.length
|
|
84
|
+
? await collection.find({ _id: { $in: ids } }).toArray()
|
|
85
|
+
: []
|
|
86
|
+
const byId = new Map(docs.map((d) => [d._id, d]))
|
|
87
|
+
|
|
88
|
+
const items = {}
|
|
89
|
+
for (const path of paths) {
|
|
90
|
+
let resolved = null
|
|
91
|
+
for (const link of chain) {
|
|
92
|
+
const id = toStateKey(link.scope, link.scopeId, path)
|
|
93
|
+
const doc = byId.get(id)
|
|
94
|
+
if (!doc || doc.value === undefined) continue
|
|
95
|
+
|
|
96
|
+
let value = doc.value
|
|
97
|
+
if (isEncrypted(value)) {
|
|
98
|
+
try {
|
|
99
|
+
value = decrypt(value, params)
|
|
100
|
+
} catch (e) {
|
|
101
|
+
logger.error(`Failed to decrypt ${path} @ ${link.scope}:${link.scopeId}: ${e.message}`)
|
|
102
|
+
value = ''
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (sensitiveSet.has(path) && value !== '' && value != null) {
|
|
106
|
+
value = SENSITIVE_PLACEHOLDER
|
|
107
|
+
}
|
|
108
|
+
resolved = { value, origin: { scope: link.scope, scopeId: link.scopeId } }
|
|
109
|
+
break
|
|
110
|
+
}
|
|
111
|
+
items[path] = resolved || { value: undefined, origin: null }
|
|
112
|
+
items[path].sensitive = sensitiveSet.has(path)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
statusCode: 200,
|
|
117
|
+
body: { message: 'System config fetched', scope, scopeId, items }
|
|
118
|
+
}
|
|
119
|
+
} catch (error) {
|
|
120
|
+
logger.error(error)
|
|
121
|
+
return errorResponse(500, error.message || 'Failed to read system config', logger)
|
|
122
|
+
} finally {
|
|
123
|
+
try { await close() } catch (_) {}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
exports.main = main
|
|
@@ -0,0 +1,160 @@
|
|
|
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, logDetails } = require('../../utils')
|
|
10
|
+
const { getClient } = require('configuration-management/abdb')
|
|
11
|
+
const {
|
|
12
|
+
SENSITIVE_PLACEHOLDER,
|
|
13
|
+
USE_DEFAULT_SENTINEL,
|
|
14
|
+
isValidPath,
|
|
15
|
+
toStateKey,
|
|
16
|
+
normalizeScope,
|
|
17
|
+
normalizeScopeId
|
|
18
|
+
} = require('configuration-management/shared')
|
|
19
|
+
const { encrypt } = require('configuration-management/crypto')
|
|
20
|
+
|
|
21
|
+
const COLLECTION = 'system_config_data'
|
|
22
|
+
|
|
23
|
+
async function ensureCollection (client) {
|
|
24
|
+
try {
|
|
25
|
+
await client.createCollection(COLLECTION)
|
|
26
|
+
} catch (err) {
|
|
27
|
+
const msg = (err && err.message) ? String(err.message) : String(err)
|
|
28
|
+
if (!/exist|already|duplicate/i.test(msg)) throw err
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* ABDB's `findOne` throws "Document not found" on a miss in this driver
|
|
34
|
+
* version. `find().limit(1).toArray()` always returns an array, so use that
|
|
35
|
+
* to mean "fetch one or null". Belt-and-braces try/catch in case `find`
|
|
36
|
+
* itself starts throwing in a future version.
|
|
37
|
+
*/
|
|
38
|
+
async function tryFindOne (collection, query) {
|
|
39
|
+
try {
|
|
40
|
+
const arr = await collection.find(query).limit(1).toArray()
|
|
41
|
+
return arr && arr.length ? arr[0] : null
|
|
42
|
+
} catch (err) {
|
|
43
|
+
const msg = (err && err.message) ? String(err.message) : String(err)
|
|
44
|
+
if (/not found/i.test(msg)) return null
|
|
45
|
+
throw err
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Upsert/delete values in the ABDB `system_config_data` collection.
|
|
51
|
+
*
|
|
52
|
+
* Per-value behavior (unchanged):
|
|
53
|
+
* - value === SENSITIVE_PLACEHOLDER → no-op (UI sent back a masked value)
|
|
54
|
+
* - value === USE_DEFAULT_SENTINEL → delete the scope override (inherit)
|
|
55
|
+
* - sensitive=true → encrypt with AES-256-GCM before writing
|
|
56
|
+
*
|
|
57
|
+
* Document shape (mirrors core_config_data):
|
|
58
|
+
* { _id, scope, scope_id, path, value, createdAt, updatedAt }
|
|
59
|
+
*/
|
|
60
|
+
async function main (params) {
|
|
61
|
+
const logger = Core.Logger('system-config-save', { level: params.LOG_LEVEL || 'info' })
|
|
62
|
+
|
|
63
|
+
const errorMessage = checkMissingRequestInputs(params, ['values'], [])
|
|
64
|
+
if (errorMessage) return errorResponse(400, errorMessage, logger)
|
|
65
|
+
|
|
66
|
+
const { values, sensitivePaths = [], scope: rawScope = 'default', scopeId: rawScopeId = '0' } = params
|
|
67
|
+
if (!values || typeof values !== 'object' || Array.isArray(values)) {
|
|
68
|
+
return errorResponse(400, 'values must be an object of { "section/group/field": value }', logger)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let scope, scopeId
|
|
72
|
+
try {
|
|
73
|
+
scope = normalizeScope(rawScope)
|
|
74
|
+
scopeId = normalizeScopeId(scope, rawScopeId)
|
|
75
|
+
} catch (e) {
|
|
76
|
+
return errorResponse(400, e.message, logger)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const sensitiveSet = new Set(sensitivePaths)
|
|
80
|
+
|
|
81
|
+
let dbHandle
|
|
82
|
+
try {
|
|
83
|
+
dbHandle = await getClient(params)
|
|
84
|
+
} catch (e) {
|
|
85
|
+
logger.error(`ABDB connect failed: ${e.message}`)
|
|
86
|
+
return errorResponse(500, `ABDB connect failed: ${e.message}`, logger)
|
|
87
|
+
}
|
|
88
|
+
const { client, close } = dbHandle
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
await ensureCollection(client)
|
|
92
|
+
const collection = await client.collection(COLLECTION)
|
|
93
|
+
const now = new Date().toISOString()
|
|
94
|
+
const saved = []
|
|
95
|
+
const deleted = []
|
|
96
|
+
const skipped = []
|
|
97
|
+
|
|
98
|
+
for (const [path, value] of Object.entries(values)) {
|
|
99
|
+
if (!isValidPath(path)) {
|
|
100
|
+
skipped.push({ path, reason: 'invalid path format' })
|
|
101
|
+
continue
|
|
102
|
+
}
|
|
103
|
+
const id = toStateKey(scope, scopeId, path)
|
|
104
|
+
|
|
105
|
+
if (value === USE_DEFAULT_SENTINEL) {
|
|
106
|
+
await collection.deleteOne({ _id: id })
|
|
107
|
+
deleted.push(path)
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
if (sensitiveSet.has(path) && value === SENSITIVE_PLACEHOLDER) {
|
|
111
|
+
skipped.push({ path, reason: 'masked placeholder, kept existing' })
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let stored = value
|
|
116
|
+
if (sensitiveSet.has(path) && stored !== '' && stored != null) {
|
|
117
|
+
stored = encrypt(String(stored), params)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const existing = await tryFindOne(collection, { _id: id })
|
|
121
|
+
if (existing) {
|
|
122
|
+
await collection.updateOne(
|
|
123
|
+
{ _id: id },
|
|
124
|
+
{ $set: { value: stored, updatedAt: now } }
|
|
125
|
+
)
|
|
126
|
+
} else {
|
|
127
|
+
await collection.insertOne({
|
|
128
|
+
_id: id,
|
|
129
|
+
scope,
|
|
130
|
+
scope_id: scopeId,
|
|
131
|
+
path,
|
|
132
|
+
value: stored,
|
|
133
|
+
createdAt: now,
|
|
134
|
+
updatedAt: now
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
saved.push(path)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const redactedForLog = Object.fromEntries(
|
|
141
|
+
Object.entries(values).map(([p, v]) => [p, sensitiveSet.has(p) ? '[REDACTED]' : v])
|
|
142
|
+
)
|
|
143
|
+
logDetails(
|
|
144
|
+
'system-config-save',
|
|
145
|
+
`scope=${scope}:${scopeId} payload=${JSON.stringify(redactedForLog)}`
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
statusCode: 200,
|
|
150
|
+
body: { message: 'System config saved', scope, scopeId, saved, deleted, skipped }
|
|
151
|
+
}
|
|
152
|
+
} catch (error) {
|
|
153
|
+
logger.error(error)
|
|
154
|
+
return errorResponse(500, error.message || 'Failed to save system config', logger)
|
|
155
|
+
} finally {
|
|
156
|
+
try { await close() } catch (_) {}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
exports.main = main
|