dsh-d365-kit 0.34.1 → 0.34.3
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/CHANGELOG.md +318 -308
- package/CHANGELOG.zh-CN.md +317 -307
- package/INSTALL.md +94 -94
- package/INSTALL.zh-CN.md +93 -93
- package/LICENSE +21 -21
- package/README.md +336 -327
- package/README.zh-CN.md +336 -327
- package/client.js +1818 -1818
- package/cordis.patch.yml +44 -44
- package/docs/en/architecture.md +172 -172
- package/docs/en/checks.md +184 -184
- package/docs/en/installation.md +151 -151
- package/docs/en/permissions.md +161 -161
- package/docs/en/scenarios.md +202 -202
- package/docs/en/tools.md +665 -665
- package/docs/en/troubleshooting.md +131 -131
- package/docs/zh/architecture.md +172 -172
- package/docs/zh/checks.md +184 -184
- package/docs/zh/installation.md +151 -151
- package/docs/zh/permissions.md +161 -161
- package/docs/zh/scenarios.md +202 -202
- package/docs/zh/tools.md +665 -665
- package/docs/zh/troubleshooting.md +131 -131
- package/examples/prompts.en.md +206 -206
- package/examples/prompts.zh-CN.md +206 -206
- package/examples/waivers.example.json +20 -20
- package/icon.svg +6 -6
- package/index.js +139 -139
- package/lib/auth.js +181 -181
- package/lib/client.js +496 -496
- package/lib/code-check.js +549 -549
- package/lib/config.js +323 -323
- package/lib/contract-history.js +49 -49
- package/lib/contract.js +583 -583
- package/lib/csharp-check.js +493 -493
- package/lib/data-migrate.js +1318 -1318
- package/lib/data-policy.js +78 -78
- package/lib/error-codes.js +80 -80
- package/lib/gitlab.js +161 -161
- package/lib/health.js +205 -205
- package/lib/migrate.js +732 -732
- package/lib/preflight.js +116 -116
- package/lib/refs.js +191 -191
- package/lib/resources.js +320 -320
- package/lib/retention-defaults.js +17 -17
- package/lib/retention.js +384 -384
- package/lib/runtime.js +232 -232
- package/lib/settings-http.js +512 -512
- package/lib/settings-store.js +371 -371
- package/lib/snapshot.js +149 -149
- package/lib/tool-code-check.js +133 -133
- package/lib/tool-configure.js +169 -169
- package/lib/tool-contract.js +376 -376
- package/lib/tool-data-sync.js +559 -559
- package/lib/tool-data-write.js +203 -203
- package/lib/tool-data.js +282 -282
- package/lib/tool-diff.js +484 -484
- package/lib/tool-environments.js +106 -106
- package/lib/tool-flows.js +540 -540
- package/lib/tool-health.js +127 -127
- package/lib/tool-layers.js +497 -497
- package/lib/tool-metadata.js +417 -417
- package/lib/tool-migrate.js +102 -102
- package/lib/tool-refs.js +206 -206
- package/lib/tool-reports.js +299 -299
- package/lib/tool-sync.js +430 -430
- package/lib/tool-write.js +563 -563
- package/lib/waivers.js +83 -83
- package/lib/write-actions.js +1374 -1374
- package/lib/write-policy.js +199 -199
- package/lib/write-store.js +167 -167
- package/locale/en.json +68 -68
- package/locale/zh.json +68 -68
- package/package.json +2 -2
- package/skills/d365-troubleshooting/SKILL.md +115 -115
package/lib/write-policy.js
CHANGED
|
@@ -1,199 +1,199 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Write policy: who may change what, in which environment.
|
|
3
|
-
*
|
|
4
|
-
* The plugin stays read-only unless a person grants a capability here. The
|
|
5
|
-
* grant lives in the same `d365.config.json` humans already edit, so it is
|
|
6
|
-
* per-machine by default: the file is not shared, and a teammate's install
|
|
7
|
-
* has its own policy.
|
|
8
|
-
*
|
|
9
|
-
* Every request consults this file: metadata reads, business-record reads and
|
|
10
|
-
* every write. Metadata reads are the one capability granted by default, so an
|
|
11
|
-
* install that never opened this page still behaves like a read-only tool.
|
|
12
|
-
*
|
|
13
|
-
* Capabilities are deliberately narrow. Every one of them maps to one official
|
|
14
|
-
* Dataverse action, which keeps the blast radius of a mistake to the thing the
|
|
15
|
-
* person asked for.
|
|
16
|
-
*/
|
|
17
|
-
import { readFile, writeFile } from 'node:fs/promises'
|
|
18
|
-
|
|
19
|
-
export const CAPABILITIES = {
|
|
20
|
-
'metadata.read': {
|
|
21
|
-
group: '元数据',
|
|
22
|
-
label: '读取元数据',
|
|
23
|
-
action: 'GET',
|
|
24
|
-
description:
|
|
25
|
-
'允许读取表、字段、表单、视图、插件、解决方案层等元数据定义。未在配置里显式列出的环境默认开启;关掉后连元数据工具也不可用。',
|
|
26
|
-
},
|
|
27
|
-
'optionValue.add': {
|
|
28
|
-
group: '选项集',
|
|
29
|
-
label: '新增选项值',
|
|
30
|
-
action: 'InsertOptionValue',
|
|
31
|
-
description: '向全局选项集追加新选项(不修改已有值)。',
|
|
32
|
-
},
|
|
33
|
-
'optionValue.updateLabel': {
|
|
34
|
-
group: '选项集',
|
|
35
|
-
label: '修改选项标签',
|
|
36
|
-
action: 'UpdateOptionValue',
|
|
37
|
-
description: '只改选项的显示标签,不改值、不改绑定关系。',
|
|
38
|
-
},
|
|
39
|
-
'optionValue.delete': {
|
|
40
|
-
group: '选项集',
|
|
41
|
-
label: '删除选项值',
|
|
42
|
-
action: 'DeleteOptionValue',
|
|
43
|
-
risk: 'high',
|
|
44
|
-
description: '从选项集删除一个值;已有记录若正在使用该值,删除会失败,标签也无法找回。',
|
|
45
|
-
},
|
|
46
|
-
'attribute.add': {
|
|
47
|
-
group: '字段',
|
|
48
|
-
label: '新增字段',
|
|
49
|
-
action: 'CreateAttribute',
|
|
50
|
-
description: '在指定表上新建列(文本、整数、小数、货币、布尔、日期时间)。',
|
|
51
|
-
},
|
|
52
|
-
'attribute.update': {
|
|
53
|
-
group: '字段',
|
|
54
|
-
label: '修改字段显示名',
|
|
55
|
-
action: 'UpdateAttribute',
|
|
56
|
-
description: '改列的显示名、描述、必填级别;不改类型、不改数据。',
|
|
57
|
-
},
|
|
58
|
-
'attribute.delete': {
|
|
59
|
-
group: '字段',
|
|
60
|
-
label: '删除字段',
|
|
61
|
-
action: 'DeleteAttribute',
|
|
62
|
-
risk: 'high',
|
|
63
|
-
description: '永久删除列及其数据,不可恢复;有依赖(视图、表单、流程)时会失败。',
|
|
64
|
-
},
|
|
65
|
-
'entity.update': {
|
|
66
|
-
group: '表',
|
|
67
|
-
label: '修改表显示名',
|
|
68
|
-
action: 'UpdateEntity',
|
|
69
|
-
description: '改表的显示名与描述;不改列、不改数据。',
|
|
70
|
-
},
|
|
71
|
-
'view.columns': {
|
|
72
|
-
group: '视图',
|
|
73
|
-
label: '修改视图列',
|
|
74
|
-
action: 'UpdateSavedQuery',
|
|
75
|
-
description: '在系统视图里增删列(直接改 layoutxml);不动筛选条件,也不动个人视图。',
|
|
76
|
-
},
|
|
77
|
-
'layer.remove': {
|
|
78
|
-
group: '组件层',
|
|
79
|
-
label: '移除自定义层',
|
|
80
|
-
action: 'RemoveActiveCustomizations',
|
|
81
|
-
risk: 'high',
|
|
82
|
-
description:
|
|
83
|
-
'删掉组件最上面的未受管自定义层(解决方案层里显示为 Active 的那一层),让下面的受管层重新生效。不可恢复;支持表、字段、全局选项集、表单、视图、流程、Web 资源、插件程序集/类型/步骤、自定义 API。',
|
|
84
|
-
},
|
|
85
|
-
'message.invoke': {
|
|
86
|
-
group: '消息',
|
|
87
|
-
label: '调用消息',
|
|
88
|
-
action: 'Custom Action / Function',
|
|
89
|
-
risk: 'high',
|
|
90
|
-
description:
|
|
91
|
-
'允许调用表外的自定义消息(action 或 function,例如 WinOpportunity、contoso_DoSomething)。消息的行为由插件决定,可能写数据、发通知;每次调用前都会在对话里弹出确认卡片并显示请求体。默认关闭。',
|
|
92
|
-
},
|
|
93
|
-
'data.read': {
|
|
94
|
-
group: '数据',
|
|
95
|
-
label: '读取业务数据',
|
|
96
|
-
action: 'GET',
|
|
97
|
-
description:
|
|
98
|
-
'允许读取表中的业务记录(客户、订单、配置数据等),而不只是元数据定义。默认关闭;关闭时任何非元数据表都读不到。',
|
|
99
|
-
},
|
|
100
|
-
'data.write': {
|
|
101
|
-
group: '数据',
|
|
102
|
-
label: '新增/修改业务数据',
|
|
103
|
-
action: 'POST/PATCH',
|
|
104
|
-
risk: 'high',
|
|
105
|
-
description:
|
|
106
|
-
'允许新建或修改表中的业务记录(例如补一条配置数据)。不含删除;每次写入前都会在对话里弹出确认卡片。默认关闭。',
|
|
107
|
-
},
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
export const CAPABILITY_KEYS = Object.keys(CAPABILITIES)
|
|
111
|
-
|
|
112
|
-
/** Read the raw config file; `write` is not part of the normalized config. */
|
|
113
|
-
async function readRaw(configPath) {
|
|
114
|
-
try {
|
|
115
|
-
const text = (await readFile(configPath, 'utf8')).replace(/^\uFEFF/, '')
|
|
116
|
-
const parsed = JSON.parse(text)
|
|
117
|
-
return parsed && typeof parsed === 'object' ? parsed : {}
|
|
118
|
-
} catch {
|
|
119
|
-
return {}
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* Bumped when the meaning of an entry changes. Version 2 added `metadata.read`:
|
|
125
|
-
* files written before it list only the old capabilities, so those entries get
|
|
126
|
-
* the (previously implicit) metadata read added back rather than losing it.
|
|
127
|
-
*/
|
|
128
|
-
const POLICY_VERSION = 2
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* @returns `{ environments: { [envKey]: capability[] }, solutionHint }` —
|
|
132
|
-
* an environment missing here has never been configured and keeps the
|
|
133
|
-
* default metadata read; an empty array means every capability is revoked.
|
|
134
|
-
*/
|
|
135
|
-
export async function readPolicy(configPath) {
|
|
136
|
-
if (!configPath) return { environments: {} }
|
|
137
|
-
const raw = await readRaw(configPath)
|
|
138
|
-
const version = Number(raw.write?.capabilityVersion ?? 1)
|
|
139
|
-
const environments = {}
|
|
140
|
-
for (const [key, value] of Object.entries(raw.write?.environments ?? {})) {
|
|
141
|
-
if (!Array.isArray(value)) continue
|
|
142
|
-
// An empty list is meaningful: it is how a page "revokes everything" for an
|
|
143
|
-
// environment, including the default-on metadata read. Dropping it here
|
|
144
|
-
// would silently turn the default back on.
|
|
145
|
-
const allowed = value.filter((item) => CAPABILITY_KEYS.includes(item))
|
|
146
|
-
if (version < POLICY_VERSION && !allowed.includes('metadata.read')) allowed.push('metadata.read')
|
|
147
|
-
environments[key] = allowed
|
|
148
|
-
}
|
|
149
|
-
return {
|
|
150
|
-
environments,
|
|
151
|
-
solutionHint: raw.write?.solutionUniqueName ?? undefined,
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/** Replace the policy section, leaving the rest of the config untouched. */
|
|
156
|
-
export async function savePolicy(configPath, policy) {
|
|
157
|
-
const raw = await readRaw(configPath)
|
|
158
|
-
const environments = {}
|
|
159
|
-
for (const [key, value] of Object.entries(policy?.environments ?? {})) {
|
|
160
|
-
// Keep an explicit empty list: absence means "not configured yet" (metadata
|
|
161
|
-
// read on), an empty list means "everything off".
|
|
162
|
-
environments[key] = Array.isArray(value) ? value.filter((item) => CAPABILITY_KEYS.includes(item)) : []
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
const write = { ...(raw.write ?? {}), environments, capabilityVersion: POLICY_VERSION }
|
|
166
|
-
if (policy?.solutionUniqueName) write.solutionUniqueName = String(policy.solutionUniqueName).trim()
|
|
167
|
-
else delete write.solutionUniqueName
|
|
168
|
-
|
|
169
|
-
if (!Object.keys(environments).length && !write.solutionUniqueName) delete raw.write
|
|
170
|
-
else raw.write = write
|
|
171
|
-
|
|
172
|
-
await writeFile(configPath, `${JSON.stringify(raw, null, 2)}\n`, 'utf8')
|
|
173
|
-
return readPolicy(configPath)
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/** Capabilities granted to an environment that has no entry yet. */
|
|
177
|
-
export const DEFAULT_GRANTED = ['metadata.read']
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* Whether `envKey` may run `capability`.
|
|
181
|
-
*
|
|
182
|
-
* An environment with no entry has never been configured, so it keeps the
|
|
183
|
-
* metadata default. Once an entry exists it is the whole truth — an empty
|
|
184
|
-
* array turns even that default off.
|
|
185
|
-
*/
|
|
186
|
-
export function allows(policy, envKey, capability) {
|
|
187
|
-
const granted = policy?.environments?.[envKey]
|
|
188
|
-
if (Array.isArray(granted)) return granted.includes(capability)
|
|
189
|
-
return DEFAULT_GRANTED.includes(capability)
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/** Human-readable matrix for the tool and the page. */
|
|
193
|
-
export function describePolicy(policy, environmentKeys) {
|
|
194
|
-
const rows = environmentKeys.map((key) => ({
|
|
195
|
-
environment: key,
|
|
196
|
-
capabilities: CAPABILITY_KEYS.filter((capability) => allows(policy, key, capability)),
|
|
197
|
-
}))
|
|
198
|
-
return { capabilities: CAPABILITIES, rows }
|
|
199
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Write policy: who may change what, in which environment.
|
|
3
|
+
*
|
|
4
|
+
* The plugin stays read-only unless a person grants a capability here. The
|
|
5
|
+
* grant lives in the same `d365.config.json` humans already edit, so it is
|
|
6
|
+
* per-machine by default: the file is not shared, and a teammate's install
|
|
7
|
+
* has its own policy.
|
|
8
|
+
*
|
|
9
|
+
* Every request consults this file: metadata reads, business-record reads and
|
|
10
|
+
* every write. Metadata reads are the one capability granted by default, so an
|
|
11
|
+
* install that never opened this page still behaves like a read-only tool.
|
|
12
|
+
*
|
|
13
|
+
* Capabilities are deliberately narrow. Every one of them maps to one official
|
|
14
|
+
* Dataverse action, which keeps the blast radius of a mistake to the thing the
|
|
15
|
+
* person asked for.
|
|
16
|
+
*/
|
|
17
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
18
|
+
|
|
19
|
+
export const CAPABILITIES = {
|
|
20
|
+
'metadata.read': {
|
|
21
|
+
group: '元数据',
|
|
22
|
+
label: '读取元数据',
|
|
23
|
+
action: 'GET',
|
|
24
|
+
description:
|
|
25
|
+
'允许读取表、字段、表单、视图、插件、解决方案层等元数据定义。未在配置里显式列出的环境默认开启;关掉后连元数据工具也不可用。',
|
|
26
|
+
},
|
|
27
|
+
'optionValue.add': {
|
|
28
|
+
group: '选项集',
|
|
29
|
+
label: '新增选项值',
|
|
30
|
+
action: 'InsertOptionValue',
|
|
31
|
+
description: '向全局选项集追加新选项(不修改已有值)。',
|
|
32
|
+
},
|
|
33
|
+
'optionValue.updateLabel': {
|
|
34
|
+
group: '选项集',
|
|
35
|
+
label: '修改选项标签',
|
|
36
|
+
action: 'UpdateOptionValue',
|
|
37
|
+
description: '只改选项的显示标签,不改值、不改绑定关系。',
|
|
38
|
+
},
|
|
39
|
+
'optionValue.delete': {
|
|
40
|
+
group: '选项集',
|
|
41
|
+
label: '删除选项值',
|
|
42
|
+
action: 'DeleteOptionValue',
|
|
43
|
+
risk: 'high',
|
|
44
|
+
description: '从选项集删除一个值;已有记录若正在使用该值,删除会失败,标签也无法找回。',
|
|
45
|
+
},
|
|
46
|
+
'attribute.add': {
|
|
47
|
+
group: '字段',
|
|
48
|
+
label: '新增字段',
|
|
49
|
+
action: 'CreateAttribute',
|
|
50
|
+
description: '在指定表上新建列(文本、整数、小数、货币、布尔、日期时间)。',
|
|
51
|
+
},
|
|
52
|
+
'attribute.update': {
|
|
53
|
+
group: '字段',
|
|
54
|
+
label: '修改字段显示名',
|
|
55
|
+
action: 'UpdateAttribute',
|
|
56
|
+
description: '改列的显示名、描述、必填级别;不改类型、不改数据。',
|
|
57
|
+
},
|
|
58
|
+
'attribute.delete': {
|
|
59
|
+
group: '字段',
|
|
60
|
+
label: '删除字段',
|
|
61
|
+
action: 'DeleteAttribute',
|
|
62
|
+
risk: 'high',
|
|
63
|
+
description: '永久删除列及其数据,不可恢复;有依赖(视图、表单、流程)时会失败。',
|
|
64
|
+
},
|
|
65
|
+
'entity.update': {
|
|
66
|
+
group: '表',
|
|
67
|
+
label: '修改表显示名',
|
|
68
|
+
action: 'UpdateEntity',
|
|
69
|
+
description: '改表的显示名与描述;不改列、不改数据。',
|
|
70
|
+
},
|
|
71
|
+
'view.columns': {
|
|
72
|
+
group: '视图',
|
|
73
|
+
label: '修改视图列',
|
|
74
|
+
action: 'UpdateSavedQuery',
|
|
75
|
+
description: '在系统视图里增删列(直接改 layoutxml);不动筛选条件,也不动个人视图。',
|
|
76
|
+
},
|
|
77
|
+
'layer.remove': {
|
|
78
|
+
group: '组件层',
|
|
79
|
+
label: '移除自定义层',
|
|
80
|
+
action: 'RemoveActiveCustomizations',
|
|
81
|
+
risk: 'high',
|
|
82
|
+
description:
|
|
83
|
+
'删掉组件最上面的未受管自定义层(解决方案层里显示为 Active 的那一层),让下面的受管层重新生效。不可恢复;支持表、字段、全局选项集、表单、视图、流程、Web 资源、插件程序集/类型/步骤、自定义 API。',
|
|
84
|
+
},
|
|
85
|
+
'message.invoke': {
|
|
86
|
+
group: '消息',
|
|
87
|
+
label: '调用消息',
|
|
88
|
+
action: 'Custom Action / Function',
|
|
89
|
+
risk: 'high',
|
|
90
|
+
description:
|
|
91
|
+
'允许调用表外的自定义消息(action 或 function,例如 WinOpportunity、contoso_DoSomething)。消息的行为由插件决定,可能写数据、发通知;每次调用前都会在对话里弹出确认卡片并显示请求体。默认关闭。',
|
|
92
|
+
},
|
|
93
|
+
'data.read': {
|
|
94
|
+
group: '数据',
|
|
95
|
+
label: '读取业务数据',
|
|
96
|
+
action: 'GET',
|
|
97
|
+
description:
|
|
98
|
+
'允许读取表中的业务记录(客户、订单、配置数据等),而不只是元数据定义。默认关闭;关闭时任何非元数据表都读不到。',
|
|
99
|
+
},
|
|
100
|
+
'data.write': {
|
|
101
|
+
group: '数据',
|
|
102
|
+
label: '新增/修改业务数据',
|
|
103
|
+
action: 'POST/PATCH',
|
|
104
|
+
risk: 'high',
|
|
105
|
+
description:
|
|
106
|
+
'允许新建或修改表中的业务记录(例如补一条配置数据)。不含删除;每次写入前都会在对话里弹出确认卡片。默认关闭。',
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const CAPABILITY_KEYS = Object.keys(CAPABILITIES)
|
|
111
|
+
|
|
112
|
+
/** Read the raw config file; `write` is not part of the normalized config. */
|
|
113
|
+
async function readRaw(configPath) {
|
|
114
|
+
try {
|
|
115
|
+
const text = (await readFile(configPath, 'utf8')).replace(/^\uFEFF/, '')
|
|
116
|
+
const parsed = JSON.parse(text)
|
|
117
|
+
return parsed && typeof parsed === 'object' ? parsed : {}
|
|
118
|
+
} catch {
|
|
119
|
+
return {}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Bumped when the meaning of an entry changes. Version 2 added `metadata.read`:
|
|
125
|
+
* files written before it list only the old capabilities, so those entries get
|
|
126
|
+
* the (previously implicit) metadata read added back rather than losing it.
|
|
127
|
+
*/
|
|
128
|
+
const POLICY_VERSION = 2
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* @returns `{ environments: { [envKey]: capability[] }, solutionHint }` —
|
|
132
|
+
* an environment missing here has never been configured and keeps the
|
|
133
|
+
* default metadata read; an empty array means every capability is revoked.
|
|
134
|
+
*/
|
|
135
|
+
export async function readPolicy(configPath) {
|
|
136
|
+
if (!configPath) return { environments: {} }
|
|
137
|
+
const raw = await readRaw(configPath)
|
|
138
|
+
const version = Number(raw.write?.capabilityVersion ?? 1)
|
|
139
|
+
const environments = {}
|
|
140
|
+
for (const [key, value] of Object.entries(raw.write?.environments ?? {})) {
|
|
141
|
+
if (!Array.isArray(value)) continue
|
|
142
|
+
// An empty list is meaningful: it is how a page "revokes everything" for an
|
|
143
|
+
// environment, including the default-on metadata read. Dropping it here
|
|
144
|
+
// would silently turn the default back on.
|
|
145
|
+
const allowed = value.filter((item) => CAPABILITY_KEYS.includes(item))
|
|
146
|
+
if (version < POLICY_VERSION && !allowed.includes('metadata.read')) allowed.push('metadata.read')
|
|
147
|
+
environments[key] = allowed
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
environments,
|
|
151
|
+
solutionHint: raw.write?.solutionUniqueName ?? undefined,
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Replace the policy section, leaving the rest of the config untouched. */
|
|
156
|
+
export async function savePolicy(configPath, policy) {
|
|
157
|
+
const raw = await readRaw(configPath)
|
|
158
|
+
const environments = {}
|
|
159
|
+
for (const [key, value] of Object.entries(policy?.environments ?? {})) {
|
|
160
|
+
// Keep an explicit empty list: absence means "not configured yet" (metadata
|
|
161
|
+
// read on), an empty list means "everything off".
|
|
162
|
+
environments[key] = Array.isArray(value) ? value.filter((item) => CAPABILITY_KEYS.includes(item)) : []
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const write = { ...(raw.write ?? {}), environments, capabilityVersion: POLICY_VERSION }
|
|
166
|
+
if (policy?.solutionUniqueName) write.solutionUniqueName = String(policy.solutionUniqueName).trim()
|
|
167
|
+
else delete write.solutionUniqueName
|
|
168
|
+
|
|
169
|
+
if (!Object.keys(environments).length && !write.solutionUniqueName) delete raw.write
|
|
170
|
+
else raw.write = write
|
|
171
|
+
|
|
172
|
+
await writeFile(configPath, `${JSON.stringify(raw, null, 2)}\n`, 'utf8')
|
|
173
|
+
return readPolicy(configPath)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Capabilities granted to an environment that has no entry yet. */
|
|
177
|
+
export const DEFAULT_GRANTED = ['metadata.read']
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Whether `envKey` may run `capability`.
|
|
181
|
+
*
|
|
182
|
+
* An environment with no entry has never been configured, so it keeps the
|
|
183
|
+
* metadata default. Once an entry exists it is the whole truth — an empty
|
|
184
|
+
* array turns even that default off.
|
|
185
|
+
*/
|
|
186
|
+
export function allows(policy, envKey, capability) {
|
|
187
|
+
const granted = policy?.environments?.[envKey]
|
|
188
|
+
if (Array.isArray(granted)) return granted.includes(capability)
|
|
189
|
+
return DEFAULT_GRANTED.includes(capability)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Human-readable matrix for the tool and the page. */
|
|
193
|
+
export function describePolicy(policy, environmentKeys) {
|
|
194
|
+
const rows = environmentKeys.map((key) => ({
|
|
195
|
+
environment: key,
|
|
196
|
+
capabilities: CAPABILITY_KEYS.filter((capability) => allows(policy, key, capability)),
|
|
197
|
+
}))
|
|
198
|
+
return { capabilities: CAPABILITIES, rows }
|
|
199
|
+
}
|