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.
Files changed (75) hide show
  1. package/CHANGELOG.md +318 -308
  2. package/CHANGELOG.zh-CN.md +317 -307
  3. package/INSTALL.md +94 -94
  4. package/INSTALL.zh-CN.md +93 -93
  5. package/LICENSE +21 -21
  6. package/README.md +336 -327
  7. package/README.zh-CN.md +336 -327
  8. package/client.js +1818 -1818
  9. package/cordis.patch.yml +44 -44
  10. package/docs/en/architecture.md +172 -172
  11. package/docs/en/checks.md +184 -184
  12. package/docs/en/installation.md +151 -151
  13. package/docs/en/permissions.md +161 -161
  14. package/docs/en/scenarios.md +202 -202
  15. package/docs/en/tools.md +665 -665
  16. package/docs/en/troubleshooting.md +131 -131
  17. package/docs/zh/architecture.md +172 -172
  18. package/docs/zh/checks.md +184 -184
  19. package/docs/zh/installation.md +151 -151
  20. package/docs/zh/permissions.md +161 -161
  21. package/docs/zh/scenarios.md +202 -202
  22. package/docs/zh/tools.md +665 -665
  23. package/docs/zh/troubleshooting.md +131 -131
  24. package/examples/prompts.en.md +206 -206
  25. package/examples/prompts.zh-CN.md +206 -206
  26. package/examples/waivers.example.json +20 -20
  27. package/icon.svg +6 -6
  28. package/index.js +139 -139
  29. package/lib/auth.js +181 -181
  30. package/lib/client.js +496 -496
  31. package/lib/code-check.js +549 -549
  32. package/lib/config.js +323 -323
  33. package/lib/contract-history.js +49 -49
  34. package/lib/contract.js +583 -583
  35. package/lib/csharp-check.js +493 -493
  36. package/lib/data-migrate.js +1318 -1318
  37. package/lib/data-policy.js +78 -78
  38. package/lib/error-codes.js +80 -80
  39. package/lib/gitlab.js +161 -161
  40. package/lib/health.js +205 -205
  41. package/lib/migrate.js +732 -732
  42. package/lib/preflight.js +116 -116
  43. package/lib/refs.js +191 -191
  44. package/lib/resources.js +320 -320
  45. package/lib/retention-defaults.js +17 -17
  46. package/lib/retention.js +384 -384
  47. package/lib/runtime.js +232 -232
  48. package/lib/settings-http.js +512 -512
  49. package/lib/settings-store.js +371 -371
  50. package/lib/snapshot.js +149 -149
  51. package/lib/tool-code-check.js +133 -133
  52. package/lib/tool-configure.js +169 -169
  53. package/lib/tool-contract.js +376 -376
  54. package/lib/tool-data-sync.js +559 -559
  55. package/lib/tool-data-write.js +203 -203
  56. package/lib/tool-data.js +282 -282
  57. package/lib/tool-diff.js +484 -484
  58. package/lib/tool-environments.js +106 -106
  59. package/lib/tool-flows.js +540 -540
  60. package/lib/tool-health.js +127 -127
  61. package/lib/tool-layers.js +497 -497
  62. package/lib/tool-metadata.js +417 -417
  63. package/lib/tool-migrate.js +102 -102
  64. package/lib/tool-refs.js +206 -206
  65. package/lib/tool-reports.js +299 -299
  66. package/lib/tool-sync.js +430 -430
  67. package/lib/tool-write.js +563 -563
  68. package/lib/waivers.js +83 -83
  69. package/lib/write-actions.js +1374 -1374
  70. package/lib/write-policy.js +199 -199
  71. package/lib/write-store.js +167 -167
  72. package/locale/en.json +68 -68
  73. package/locale/zh.json +68 -68
  74. package/package.json +2 -2
  75. package/skills/d365-troubleshooting/SKILL.md +115 -115
package/lib/client.js CHANGED
@@ -1,496 +1,496 @@
1
- /**
2
- * Dataverse Web API client.
3
- *
4
- * Every request is a GET or write to one environment. Reads are split in two:
5
- * metadata and definition tables need the `metadata.read` capability, business
6
- * records need `data.read`. The check lives here, at the single choke point, so
7
- * a new tool cannot accidentally bypass it.
8
- */
9
- import { getAccessToken, forgetToken } from './auth.js'
10
- import { D365Error, describeDataverseError, httpErrorHint } from './errors.js'
11
- import { errorCodeFromBody } from './error-codes.js'
12
-
13
- const API_VERSION = 'v9.2'
14
- const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504])
15
- /** Methods the server treats as read-only, so repeating them cannot write twice. */
16
- const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
17
-
18
- /**
19
- * Entity sets and endpoints that only expose metadata or deployment state.
20
- * Anything not listed here is treated as business data.
21
- */
22
- const METADATA_READ_PREFIXES = [
23
- 'entitydefinitions',
24
- 'relationshipdefinitions',
25
- 'globaloptionsets',
26
- 'globaloptionsetdefinitions',
27
- 'solutions',
28
- 'solutioncomponents',
29
- 'publishers',
30
- 'sdkmessageprocessingsteps',
31
- 'sdkmessageprocessingstepimages',
32
- 'sdkmessages',
33
- 'pluginassemblies',
34
- 'plugintypes',
35
- 'webresourceset',
36
- 'systemforms',
37
- 'savedqueries',
38
- 'workflows',
39
- 'customapis',
40
- 'sitemaps',
41
- 'organizations',
42
- 'msdyn_componentlayers',
43
- 'msdyn_solutionhistories',
44
- 'retrieveversion',
45
- '$metadata',
46
- ]
47
-
48
- /**
49
- * Action endpoints that change metadata or deployment state rather than
50
- * business records. They are allowed through the `data.write` guard; the
51
- * metadata capabilities are enforced separately by `d365_write`.
52
- */
53
- const METADATA_WRITE_SEGMENTS = [
54
- 'publishxml',
55
- 'publishallxml',
56
- 'insertoptionvalue',
57
- 'updateoptionvalue',
58
- 'deleteoptionvalue',
59
- 'createattribute',
60
- 'updateattribute',
61
- 'deleteattribute',
62
- 'removeactivecustomizations',
63
- 'updateentity',
64
- 'createentity',
65
- 'deleteentity',
66
- 'addsolutioncomponent',
67
- 'removesolutioncomponent',
68
- ]
69
-
70
- let dataReadResolver = async () => false
71
- let dataWriteResolver = async () => false
72
- let metadataReadResolver = async () => true
73
- let capabilityResolver = async () => false
74
-
75
- /**
76
- * Install the generic `(environment, capability) => boolean` decision function.
77
- *
78
- * Only `d365_invoke` uses it, and only for the capability it names explicitly on
79
- * the request: everything else keeps the three narrow resolvers above, so this
80
- * does not widen any existing gate.
81
- */
82
- export function setCapabilityResolver(resolver) {
83
- capabilityResolver = typeof resolver === 'function' ? resolver : async () => false
84
- }
85
-
86
- /** Install the `data.read` decision function; index.js points it at the policy file. */
87
- export function setDataReadResolver(resolver) {
88
- dataReadResolver = typeof resolver === 'function' ? resolver : async () => false
89
- }
90
-
91
- /** Install the `data.write` decision function; index.js points it at the policy file. */
92
- export function setDataWriteResolver(resolver) {
93
- dataWriteResolver = typeof resolver === 'function' ? resolver : async () => false
94
- }
95
-
96
- /** Install the `metadata.read` decision function; index.js points it at the policy file. */
97
- export function setMetadataReadResolver(resolver) {
98
- metadataReadResolver = typeof resolver === 'function' ? resolver : async () => true
99
- }
100
-
101
- function urlSegment(url) {
102
- const text = String(url?.pathname ?? url ?? '')
103
- const marker = `/api/data/${API_VERSION}/`
104
- const at = text.indexOf(marker)
105
- const rest = at >= 0 ? text.slice(at + marker.length) : text.replace(/^\/+/, '')
106
- return rest.split(/[/?(]/)[0].toLowerCase()
107
- }
108
-
109
- /** @returns true when the URL only touches metadata or deployment state. */
110
- export function isMetadataRead(url) {
111
- const segment = urlSegment(url)
112
- return METADATA_READ_PREFIXES.some((prefix) => segment === prefix || segment.startsWith(`${prefix}(`))
113
- }
114
-
115
- /** @returns true when the URL changes metadata or deployment state, not business records. */
116
- export function isMetadataWrite(url) {
117
- const segment = urlSegment(url)
118
- if (isMetadataRead(url)) return true
119
- return METADATA_WRITE_SEGMENTS.some((name) => segment === name)
120
- }
121
-
122
- const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
123
-
124
- /**
125
- * Run `fetch` with a timeout, while still honoring a caller-provided signal.
126
- *
127
- * The timer stays armed after the response headers arrive: a server that sends
128
- * headers and then stalls the body would otherwise hang forever, because
129
- * `response.text()` is not covered by the `fetch` promise. The caller must call
130
- * `release()` once the body has been consumed (or the request abandoned).
131
- * @returns `{ response, release }`.
132
- */
133
- async function timedFetch(url, init, timeoutMs, externalSignal) {
134
- const controller = new AbortController()
135
- const timer = setTimeout(() => controller.abort(new Error(`请求超时(${timeoutMs}ms)`)), timeoutMs)
136
- let listener
137
- if (externalSignal) {
138
- if (externalSignal.aborted) controller.abort(externalSignal.reason)
139
- else {
140
- listener = () => controller.abort(externalSignal.reason)
141
- externalSignal.addEventListener('abort', listener, { once: true })
142
- }
143
- }
144
- const release = () => {
145
- clearTimeout(timer)
146
- if (listener) externalSignal.removeEventListener('abort', listener)
147
- }
148
- try {
149
- const response = await fetch(url, { ...init, signal: controller.signal })
150
- return { response, release }
151
- } catch (error) {
152
- release()
153
- throw error
154
- }
155
- }
156
-
157
- /** Seconds to wait from a `Retry-After` header, falling back to exponential backoff. */
158
- function retryDelay(response, attempt) {
159
- const header = response.headers.get('retry-after')
160
- const seconds = Number(header)
161
- if (Number.isFinite(seconds) && seconds >= 0) return Math.min(seconds, 120) * 1000
162
- return Math.min(1000 * 2 ** attempt, 20_000)
163
- }
164
-
165
- export class DataverseClient {
166
- /**
167
- * @param env - normalized environment.
168
- * @param options - timeout, retry budget, and request counters shared by a tool call.
169
- */
170
- constructor(env, options = {}) {
171
- this.env = env
172
- this.timeoutMs = options.timeoutMs ?? 60_000
173
- this.maxRetries = options.maxRetries ?? 4
174
- this.counters = options.counters ?? { requests: 0 }
175
- }
176
-
177
- get apiBase() {
178
- return `${this.env.url}/api/data/${API_VERSION}/`
179
- }
180
-
181
- /**
182
- * Build one absolute Web API URL.
183
- * @param path - path relative to the Web API root; may contain OData segments.
184
- * @param params - query options such as `$select`. Empty values are dropped.
185
- */
186
- buildUrl(path, params = {}) {
187
- const url = new URL(this.apiBase + path)
188
- for (const [key, value] of Object.entries(params)) {
189
- if (value === undefined || value === null || value === '') continue
190
- url.searchParams.set(key, String(value))
191
- }
192
- return url
193
- }
194
-
195
- /**
196
- * GET one URL and decode it as JSON, retrying throttling and refreshing the
197
- * token once on 401.
198
- * @returns the parsed JSON value.
199
- */
200
- async requestJson(url, state = {}) {
201
- const attempt = state.attempt ?? 0
202
- const refreshed = state.refreshed ?? false
203
- const method = state.method ?? 'GET'
204
-
205
- if (method === 'GET') {
206
- // Metadata and business records are gated separately: an environment can
207
- // allow reading table definitions while business rows stay closed.
208
- const metadata = isMetadataRead(url)
209
- const allowed = metadata
210
- ? await metadataReadResolver(this.env.key)
211
- : await dataReadResolver(this.env.key)
212
- if (!allowed) {
213
- throw new D365Error(
214
- metadata
215
- ? `环境 ${this.env.key} 未授权读取元数据`
216
- : `环境 ${this.env.key} 未授权读取业务数据`,
217
- {
218
- code: metadata ? 'metadata-read-forbidden' : 'data-read-forbidden',
219
- hint: metadata
220
- ? `在设置页「D365 工具箱 → 权限」里给 ${this.env.key} 勾上「读取元数据」后再试。`
221
- : `在设置页「D365 工具箱 → 权限」里给 ${this.env.key} 勾上「读取业务数据」后再试。`,
222
- },
223
- )
224
- }
225
- }
226
-
227
- if (method !== 'GET' && !isMetadataWrite(url)) {
228
- // A caller may name a different capability for one request (only
229
- // `d365_invoke` does, after checking it itself). Everything else still
230
- // walks the `data.write` gate.
231
- const capability = state.capability === 'message.invoke' ? 'message.invoke' : 'data.write'
232
- const allowed = capability === 'message.invoke'
233
- ? await capabilityResolver(this.env.key, 'message.invoke')
234
- : await dataWriteResolver(this.env.key)
235
- if (!allowed) {
236
- throw new D365Error(
237
- capability === 'message.invoke'
238
- ? `环境 ${this.env.key} 未授权调用消息`
239
- : `环境 ${this.env.key} 未授权新增或修改业务数据`,
240
- {
241
- code: capability === 'message.invoke' ? 'message-invoke-forbidden' : 'data-write-forbidden',
242
- hint: capability === 'message.invoke'
243
- ? `在设置页「D365 工具箱 → 权限」里给 ${this.env.key} 勾上「调用消息」后再试。`
244
- : `在设置页「D365 工具箱 → 权限」里给 ${this.env.key} 勾上「新增/修改业务数据」后再试。元数据的修改走 d365_write,不受这个开关影响。`,
245
- },
246
- )
247
- }
248
- }
249
-
250
- const token = await getAccessToken(this.env, { force: refreshed })
251
- this.counters.requests += 1
252
-
253
- const headers = {
254
- authorization: `Bearer ${token}`,
255
- accept: 'application/json',
256
- 'odata-maxversion': '4.0',
257
- 'odata-version': '4.0',
258
- }
259
- if (state.body !== undefined) headers['content-type'] = 'application/json; charset=utf-8'
260
- if (state.headers) Object.assign(headers, state.headers)
261
-
262
- let response
263
- let release = () => {}
264
- try {
265
- const timed = await timedFetch(
266
- url,
267
- {
268
- method,
269
- headers,
270
- body: state.body === undefined ? undefined : JSON.stringify(state.body),
271
- },
272
- this.timeoutMs,
273
- state.signal,
274
- )
275
- response = timed.response
276
- release = timed.release
277
- } catch (error) {
278
- // A failed request is only safe to repeat when the server provably did
279
- // not act on it. A timeout or a dropped connection after the bytes went
280
- // out tells us nothing: retrying a POST/PATCH/DELETE there duplicates the
281
- // write (a second CreateAttribute, a second InsertOptionValue).
282
- if (attempt < this.maxRetries && SAFE_METHODS.has(method)) {
283
- await wait(500 * (attempt + 1))
284
- return this.requestJson(url, { ...state, attempt: attempt + 1 })
285
- }
286
- throw new D365Error(`请求环境 ${this.env.key} 失败:${error.message}`, {
287
- code: 'network',
288
- hint: SAFE_METHODS.has(method)
289
- ? '检查网络与该环境 URL 是否可达。'
290
- : '写入请求可能已在环境里生效,也可能没生效——先到环境里核对结果,再决定是否重试。',
291
- })
292
- }
293
-
294
- if (response.status === 401 && !refreshed) {
295
- release()
296
- forgetToken(this.env.key)
297
- return this.requestJson(url, { ...state, refreshed: true })
298
- }
299
-
300
- // 429 means the request was rejected before running, so repeating it is
301
- // safe for any method. 5xx is different: the server may have half-applied a
302
- // write, so only idempotent methods are retried.
303
- const retryable = response.status === 429
304
- || (RETRYABLE_STATUS.has(response.status) && SAFE_METHODS.has(method))
305
- if (retryable && attempt < this.maxRetries) {
306
- release()
307
- await wait(retryDelay(response, attempt))
308
- return this.requestJson(url, { ...state, attempt: attempt + 1 })
309
- }
310
-
311
- let text
312
- try {
313
- text = await response.text()
314
- } catch (error) {
315
- throw new D365Error(`读取环境 ${this.env.key} 的响应失败:${error.message}`, {
316
- code: 'network',
317
- hint: '响应头已经到了但正文没有在超时内读完;写入是否生效以环境里的实际状态为准。',
318
- })
319
- } finally {
320
- release()
321
- }
322
- if (!response.ok) {
323
- let body
324
- try {
325
- body = JSON.parse(text)
326
- } catch {
327
- body = text
328
- }
329
- throw new D365Error(`环境 ${this.env.key} 返回 HTTP ${response.status}:${describeDataverseError(body)}`, {
330
- code: 'http',
331
- status: response.status,
332
- errorCode: errorCodeFromBody(body),
333
- // Keep the body: the message above is a summary, and when a write fails
334
- // on one property the original payload text is what a person needs.
335
- body: text.length > 8000 ? `${text.slice(0, 8000)}…(共 ${text.length} 字符)` : text,
336
- hint: httpErrorHint({ status: response.status, body }),
337
- })
338
- }
339
-
340
- if (!text) return null
341
- try {
342
- return JSON.parse(text)
343
- } catch {
344
- throw new D365Error(`环境 ${this.env.key} 返回的不是 JSON`, { code: 'decode' })
345
- }
346
- }
347
-
348
- /**
349
- * GET a collection, following `@odata.nextLink` until `limit` rows are
350
- * collected. Returns the rows and whether more exist.
351
- *
352
- * `$top` is sent with the first request: without it the server picks its own
353
- * page size (thousands of rows), which on a real organization made one
354
- * `systemforms` page take ~28 seconds when three rows were all that was
355
- * wanted.
356
- * @param limit - hard cap on returned rows.
357
- */
358
- async getPaged(path, params, limit, signal) {
359
- const records = []
360
- const first = { ...params }
361
- const unlimited = !Number.isFinite(limit)
362
- // `__noTop` is the fallback path's opt-out: some endpoints (the metadata
363
- // ones) reject `$top` outright, and retrying must not re-add it.
364
- const noTop = first.__noTop === true || unlimited
365
- delete first.__noTop
366
- // Ask for one row more than the cap: a server that fills `$top` exactly
367
- // sends no `@odata.nextLink`, so without the probe row a capped response
368
- // is indistinguishable from a complete one.
369
- if (!noTop && first.$top === undefined && Number.isFinite(limit)) first.$top = limit + 1
370
- let next = this.buildUrl(path, first)
371
- let pages = 0
372
- const maxPages = unlimited ? 20_000 : 500
373
-
374
- while (next && (unlimited || records.length < limit) && pages < maxPages) {
375
- const page = await this.requestJson(next, { signal })
376
- const value = Array.isArray(page?.value) ? page.value : []
377
- records.push(...value)
378
- next = page?.['@odata.nextLink']
379
- pages += 1
380
- }
381
-
382
- const overflow = !unlimited && records.length > limit
383
- return {
384
- records: overflow ? records.slice(0, limit) : records,
385
- hasMore: Boolean(next) || overflow,
386
- pages,
387
- }
388
- }
389
-
390
- /** GET a single record (collection query with `$top=1`). */
391
- async getOne(path, params, signal) {
392
- const url = this.buildUrl(path, { ...params, $top: 1 })
393
- const page = await this.requestJson(url, { signal })
394
- return Array.isArray(page?.value) ? page.value[0] : page
395
- }
396
-
397
- /**
398
- * POST a JSON body (an unbound action such as `InsertOptionValue`) and decode
399
- * the response. Writes are reachable only through the audited write pipeline.
400
- */
401
- async postJson(path, body, signal) {
402
- return this.requestJson(this.buildUrl(path), { method: 'POST', body, signal })
403
- }
404
-
405
- /**
406
- * Send one JSON body with any method and extra headers.
407
- *
408
- * Metadata creation and update need more than POST: creating a column is a
409
- * POST to the entity's `Attributes` collection, updating it is a PUT that
410
- * carries `MSCRM.MergeLabels`, and deleting it is a DELETE. None of those are
411
- * actions, so the option-value helpers do not cover them.
412
- * @param path - Web API path.
413
- * @param options - `{ method, body, headers }`.
414
- */
415
- async sendJson(path, options = {}, signal) {
416
- const { method = 'POST', body, headers } = options
417
- return this.requestJson(this.buildUrl(path), { method, body, headers, signal })
418
- }
419
-
420
- /**
421
- * Read the organization's platform version.
422
- *
423
- * The `organization` table does not expose a `version` property in every
424
- * deployment (a real one rejected `$select=version` with 0x80060888), so the
425
- * supported `RetrieveVersion` action is the only portable source. It is
426
- * best-effort: a missing version must not fail a connection check.
427
- */
428
- async readVersion(signal) {
429
- try {
430
- const payload = await this.requestJson(this.buildUrl('RetrieveVersion'), { signal })
431
- return payload?.Version ?? payload?.version ?? undefined
432
- } catch {
433
- return undefined
434
- }
435
- }
436
- }
437
-
438
- /**
439
- * GET with progressive fallbacks for query options real deployments reject.
440
- *
441
- * Dataverse does not accept the same `$select`/`$orderby` vocabulary on every
442
- * endpoint or version: a metadata endpoint answers `$orderby` with
443
- * 0x80060888 "The query parameter ... is not supported", and a table may lack a
444
- * property this plugin asked for ("Could not find a property named 'modifiedon'
445
- * on type 'Microsoft.Dynamics.CRM.systemform'"). Each rejection narrows the
446
- * request — one field at a time — instead of failing the whole resource.
447
- * @returns `{ page, selectDropped, orderbyDropped, droppedFields }`.
448
- */
449
- export async function getPagedWithSelectFallback(client, path, params, limit, signal) {
450
- const dropped = { selectDropped: false, orderbyDropped: false, topDropped: false, droppedFields: [] }
451
- const current = { ...params }
452
-
453
- for (let attempt = 0; attempt < 10; attempt += 1) {
454
- try {
455
- return { page: await client.getPaged(path, current, limit, signal), ...dropped }
456
- } catch (error) {
457
- if (error?.status !== 400) throw error
458
- const text = String(error.message ?? '')
459
-
460
- const missing = /property named '([^']+)'/.exec(text)
461
- if (missing && current.$select) {
462
- const fields = current.$select.split(',').map((item) => item.trim()).filter(Boolean)
463
- const kept = fields.filter((field) => field.toLowerCase() !== missing[1].toLowerCase())
464
- if (kept.length && kept.length < fields.length) {
465
- current.$select = kept.join(',')
466
- dropped.droppedFields.push(missing[1])
467
- continue
468
- }
469
- }
470
-
471
- // "The query parameter '...' is not supported": the name is often
472
- // redacted before it reaches us, so narrow the request by dropping the
473
- // options in turn, cheapest first. `$top` is added inside getPaged, so
474
- // the check is on the marker, not on the params object.
475
- if (/query parameter/i.test(text) && !dropped.topDropped) {
476
- current.__noTop = true
477
- delete current.$top
478
- dropped.topDropped = true
479
- continue
480
- }
481
- if (current.$orderby) {
482
- delete current.$orderby
483
- dropped.orderbyDropped = true
484
- continue
485
- }
486
- if (current.$select) {
487
- delete current.$select
488
- dropped.selectDropped = true
489
- continue
490
- }
491
- throw error
492
- }
493
- }
494
-
495
- return { page: await client.getPaged(path, current, limit, signal), ...dropped }
496
- }
1
+ /**
2
+ * Dataverse Web API client.
3
+ *
4
+ * Every request is a GET or write to one environment. Reads are split in two:
5
+ * metadata and definition tables need the `metadata.read` capability, business
6
+ * records need `data.read`. The check lives here, at the single choke point, so
7
+ * a new tool cannot accidentally bypass it.
8
+ */
9
+ import { getAccessToken, forgetToken } from './auth.js'
10
+ import { D365Error, describeDataverseError, httpErrorHint } from './errors.js'
11
+ import { errorCodeFromBody } from './error-codes.js'
12
+
13
+ const API_VERSION = 'v9.2'
14
+ const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504])
15
+ /** Methods the server treats as read-only, so repeating them cannot write twice. */
16
+ const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
17
+
18
+ /**
19
+ * Entity sets and endpoints that only expose metadata or deployment state.
20
+ * Anything not listed here is treated as business data.
21
+ */
22
+ const METADATA_READ_PREFIXES = [
23
+ 'entitydefinitions',
24
+ 'relationshipdefinitions',
25
+ 'globaloptionsets',
26
+ 'globaloptionsetdefinitions',
27
+ 'solutions',
28
+ 'solutioncomponents',
29
+ 'publishers',
30
+ 'sdkmessageprocessingsteps',
31
+ 'sdkmessageprocessingstepimages',
32
+ 'sdkmessages',
33
+ 'pluginassemblies',
34
+ 'plugintypes',
35
+ 'webresourceset',
36
+ 'systemforms',
37
+ 'savedqueries',
38
+ 'workflows',
39
+ 'customapis',
40
+ 'sitemaps',
41
+ 'organizations',
42
+ 'msdyn_componentlayers',
43
+ 'msdyn_solutionhistories',
44
+ 'retrieveversion',
45
+ '$metadata',
46
+ ]
47
+
48
+ /**
49
+ * Action endpoints that change metadata or deployment state rather than
50
+ * business records. They are allowed through the `data.write` guard; the
51
+ * metadata capabilities are enforced separately by `d365_write`.
52
+ */
53
+ const METADATA_WRITE_SEGMENTS = [
54
+ 'publishxml',
55
+ 'publishallxml',
56
+ 'insertoptionvalue',
57
+ 'updateoptionvalue',
58
+ 'deleteoptionvalue',
59
+ 'createattribute',
60
+ 'updateattribute',
61
+ 'deleteattribute',
62
+ 'removeactivecustomizations',
63
+ 'updateentity',
64
+ 'createentity',
65
+ 'deleteentity',
66
+ 'addsolutioncomponent',
67
+ 'removesolutioncomponent',
68
+ ]
69
+
70
+ let dataReadResolver = async () => false
71
+ let dataWriteResolver = async () => false
72
+ let metadataReadResolver = async () => true
73
+ let capabilityResolver = async () => false
74
+
75
+ /**
76
+ * Install the generic `(environment, capability) => boolean` decision function.
77
+ *
78
+ * Only `d365_invoke` uses it, and only for the capability it names explicitly on
79
+ * the request: everything else keeps the three narrow resolvers above, so this
80
+ * does not widen any existing gate.
81
+ */
82
+ export function setCapabilityResolver(resolver) {
83
+ capabilityResolver = typeof resolver === 'function' ? resolver : async () => false
84
+ }
85
+
86
+ /** Install the `data.read` decision function; index.js points it at the policy file. */
87
+ export function setDataReadResolver(resolver) {
88
+ dataReadResolver = typeof resolver === 'function' ? resolver : async () => false
89
+ }
90
+
91
+ /** Install the `data.write` decision function; index.js points it at the policy file. */
92
+ export function setDataWriteResolver(resolver) {
93
+ dataWriteResolver = typeof resolver === 'function' ? resolver : async () => false
94
+ }
95
+
96
+ /** Install the `metadata.read` decision function; index.js points it at the policy file. */
97
+ export function setMetadataReadResolver(resolver) {
98
+ metadataReadResolver = typeof resolver === 'function' ? resolver : async () => true
99
+ }
100
+
101
+ function urlSegment(url) {
102
+ const text = String(url?.pathname ?? url ?? '')
103
+ const marker = `/api/data/${API_VERSION}/`
104
+ const at = text.indexOf(marker)
105
+ const rest = at >= 0 ? text.slice(at + marker.length) : text.replace(/^\/+/, '')
106
+ return rest.split(/[/?(]/)[0].toLowerCase()
107
+ }
108
+
109
+ /** @returns true when the URL only touches metadata or deployment state. */
110
+ export function isMetadataRead(url) {
111
+ const segment = urlSegment(url)
112
+ return METADATA_READ_PREFIXES.some((prefix) => segment === prefix || segment.startsWith(`${prefix}(`))
113
+ }
114
+
115
+ /** @returns true when the URL changes metadata or deployment state, not business records. */
116
+ export function isMetadataWrite(url) {
117
+ const segment = urlSegment(url)
118
+ if (isMetadataRead(url)) return true
119
+ return METADATA_WRITE_SEGMENTS.some((name) => segment === name)
120
+ }
121
+
122
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
123
+
124
+ /**
125
+ * Run `fetch` with a timeout, while still honoring a caller-provided signal.
126
+ *
127
+ * The timer stays armed after the response headers arrive: a server that sends
128
+ * headers and then stalls the body would otherwise hang forever, because
129
+ * `response.text()` is not covered by the `fetch` promise. The caller must call
130
+ * `release()` once the body has been consumed (or the request abandoned).
131
+ * @returns `{ response, release }`.
132
+ */
133
+ async function timedFetch(url, init, timeoutMs, externalSignal) {
134
+ const controller = new AbortController()
135
+ const timer = setTimeout(() => controller.abort(new Error(`请求超时(${timeoutMs}ms)`)), timeoutMs)
136
+ let listener
137
+ if (externalSignal) {
138
+ if (externalSignal.aborted) controller.abort(externalSignal.reason)
139
+ else {
140
+ listener = () => controller.abort(externalSignal.reason)
141
+ externalSignal.addEventListener('abort', listener, { once: true })
142
+ }
143
+ }
144
+ const release = () => {
145
+ clearTimeout(timer)
146
+ if (listener) externalSignal.removeEventListener('abort', listener)
147
+ }
148
+ try {
149
+ const response = await fetch(url, { ...init, signal: controller.signal })
150
+ return { response, release }
151
+ } catch (error) {
152
+ release()
153
+ throw error
154
+ }
155
+ }
156
+
157
+ /** Seconds to wait from a `Retry-After` header, falling back to exponential backoff. */
158
+ function retryDelay(response, attempt) {
159
+ const header = response.headers.get('retry-after')
160
+ const seconds = Number(header)
161
+ if (Number.isFinite(seconds) && seconds >= 0) return Math.min(seconds, 120) * 1000
162
+ return Math.min(1000 * 2 ** attempt, 20_000)
163
+ }
164
+
165
+ export class DataverseClient {
166
+ /**
167
+ * @param env - normalized environment.
168
+ * @param options - timeout, retry budget, and request counters shared by a tool call.
169
+ */
170
+ constructor(env, options = {}) {
171
+ this.env = env
172
+ this.timeoutMs = options.timeoutMs ?? 60_000
173
+ this.maxRetries = options.maxRetries ?? 4
174
+ this.counters = options.counters ?? { requests: 0 }
175
+ }
176
+
177
+ get apiBase() {
178
+ return `${this.env.url}/api/data/${API_VERSION}/`
179
+ }
180
+
181
+ /**
182
+ * Build one absolute Web API URL.
183
+ * @param path - path relative to the Web API root; may contain OData segments.
184
+ * @param params - query options such as `$select`. Empty values are dropped.
185
+ */
186
+ buildUrl(path, params = {}) {
187
+ const url = new URL(this.apiBase + path)
188
+ for (const [key, value] of Object.entries(params)) {
189
+ if (value === undefined || value === null || value === '') continue
190
+ url.searchParams.set(key, String(value))
191
+ }
192
+ return url
193
+ }
194
+
195
+ /**
196
+ * GET one URL and decode it as JSON, retrying throttling and refreshing the
197
+ * token once on 401.
198
+ * @returns the parsed JSON value.
199
+ */
200
+ async requestJson(url, state = {}) {
201
+ const attempt = state.attempt ?? 0
202
+ const refreshed = state.refreshed ?? false
203
+ const method = state.method ?? 'GET'
204
+
205
+ if (method === 'GET') {
206
+ // Metadata and business records are gated separately: an environment can
207
+ // allow reading table definitions while business rows stay closed.
208
+ const metadata = isMetadataRead(url)
209
+ const allowed = metadata
210
+ ? await metadataReadResolver(this.env.key)
211
+ : await dataReadResolver(this.env.key)
212
+ if (!allowed) {
213
+ throw new D365Error(
214
+ metadata
215
+ ? `环境 ${this.env.key} 未授权读取元数据`
216
+ : `环境 ${this.env.key} 未授权读取业务数据`,
217
+ {
218
+ code: metadata ? 'metadata-read-forbidden' : 'data-read-forbidden',
219
+ hint: metadata
220
+ ? `在设置页「D365 工具箱 → 权限」里给 ${this.env.key} 勾上「读取元数据」后再试。`
221
+ : `在设置页「D365 工具箱 → 权限」里给 ${this.env.key} 勾上「读取业务数据」后再试。`,
222
+ },
223
+ )
224
+ }
225
+ }
226
+
227
+ if (method !== 'GET' && !isMetadataWrite(url)) {
228
+ // A caller may name a different capability for one request (only
229
+ // `d365_invoke` does, after checking it itself). Everything else still
230
+ // walks the `data.write` gate.
231
+ const capability = state.capability === 'message.invoke' ? 'message.invoke' : 'data.write'
232
+ const allowed = capability === 'message.invoke'
233
+ ? await capabilityResolver(this.env.key, 'message.invoke')
234
+ : await dataWriteResolver(this.env.key)
235
+ if (!allowed) {
236
+ throw new D365Error(
237
+ capability === 'message.invoke'
238
+ ? `环境 ${this.env.key} 未授权调用消息`
239
+ : `环境 ${this.env.key} 未授权新增或修改业务数据`,
240
+ {
241
+ code: capability === 'message.invoke' ? 'message-invoke-forbidden' : 'data-write-forbidden',
242
+ hint: capability === 'message.invoke'
243
+ ? `在设置页「D365 工具箱 → 权限」里给 ${this.env.key} 勾上「调用消息」后再试。`
244
+ : `在设置页「D365 工具箱 → 权限」里给 ${this.env.key} 勾上「新增/修改业务数据」后再试。元数据的修改走 d365_write,不受这个开关影响。`,
245
+ },
246
+ )
247
+ }
248
+ }
249
+
250
+ const token = await getAccessToken(this.env, { force: refreshed })
251
+ this.counters.requests += 1
252
+
253
+ const headers = {
254
+ authorization: `Bearer ${token}`,
255
+ accept: 'application/json',
256
+ 'odata-maxversion': '4.0',
257
+ 'odata-version': '4.0',
258
+ }
259
+ if (state.body !== undefined) headers['content-type'] = 'application/json; charset=utf-8'
260
+ if (state.headers) Object.assign(headers, state.headers)
261
+
262
+ let response
263
+ let release = () => {}
264
+ try {
265
+ const timed = await timedFetch(
266
+ url,
267
+ {
268
+ method,
269
+ headers,
270
+ body: state.body === undefined ? undefined : JSON.stringify(state.body),
271
+ },
272
+ this.timeoutMs,
273
+ state.signal,
274
+ )
275
+ response = timed.response
276
+ release = timed.release
277
+ } catch (error) {
278
+ // A failed request is only safe to repeat when the server provably did
279
+ // not act on it. A timeout or a dropped connection after the bytes went
280
+ // out tells us nothing: retrying a POST/PATCH/DELETE there duplicates the
281
+ // write (a second CreateAttribute, a second InsertOptionValue).
282
+ if (attempt < this.maxRetries && SAFE_METHODS.has(method)) {
283
+ await wait(500 * (attempt + 1))
284
+ return this.requestJson(url, { ...state, attempt: attempt + 1 })
285
+ }
286
+ throw new D365Error(`请求环境 ${this.env.key} 失败:${error.message}`, {
287
+ code: 'network',
288
+ hint: SAFE_METHODS.has(method)
289
+ ? '检查网络与该环境 URL 是否可达。'
290
+ : '写入请求可能已在环境里生效,也可能没生效——先到环境里核对结果,再决定是否重试。',
291
+ })
292
+ }
293
+
294
+ if (response.status === 401 && !refreshed) {
295
+ release()
296
+ forgetToken(this.env.key)
297
+ return this.requestJson(url, { ...state, refreshed: true })
298
+ }
299
+
300
+ // 429 means the request was rejected before running, so repeating it is
301
+ // safe for any method. 5xx is different: the server may have half-applied a
302
+ // write, so only idempotent methods are retried.
303
+ const retryable = response.status === 429
304
+ || (RETRYABLE_STATUS.has(response.status) && SAFE_METHODS.has(method))
305
+ if (retryable && attempt < this.maxRetries) {
306
+ release()
307
+ await wait(retryDelay(response, attempt))
308
+ return this.requestJson(url, { ...state, attempt: attempt + 1 })
309
+ }
310
+
311
+ let text
312
+ try {
313
+ text = await response.text()
314
+ } catch (error) {
315
+ throw new D365Error(`读取环境 ${this.env.key} 的响应失败:${error.message}`, {
316
+ code: 'network',
317
+ hint: '响应头已经到了但正文没有在超时内读完;写入是否生效以环境里的实际状态为准。',
318
+ })
319
+ } finally {
320
+ release()
321
+ }
322
+ if (!response.ok) {
323
+ let body
324
+ try {
325
+ body = JSON.parse(text)
326
+ } catch {
327
+ body = text
328
+ }
329
+ throw new D365Error(`环境 ${this.env.key} 返回 HTTP ${response.status}:${describeDataverseError(body)}`, {
330
+ code: 'http',
331
+ status: response.status,
332
+ errorCode: errorCodeFromBody(body),
333
+ // Keep the body: the message above is a summary, and when a write fails
334
+ // on one property the original payload text is what a person needs.
335
+ body: text.length > 8000 ? `${text.slice(0, 8000)}…(共 ${text.length} 字符)` : text,
336
+ hint: httpErrorHint({ status: response.status, body }),
337
+ })
338
+ }
339
+
340
+ if (!text) return null
341
+ try {
342
+ return JSON.parse(text)
343
+ } catch {
344
+ throw new D365Error(`环境 ${this.env.key} 返回的不是 JSON`, { code: 'decode' })
345
+ }
346
+ }
347
+
348
+ /**
349
+ * GET a collection, following `@odata.nextLink` until `limit` rows are
350
+ * collected. Returns the rows and whether more exist.
351
+ *
352
+ * `$top` is sent with the first request: without it the server picks its own
353
+ * page size (thousands of rows), which on a real organization made one
354
+ * `systemforms` page take ~28 seconds when three rows were all that was
355
+ * wanted.
356
+ * @param limit - hard cap on returned rows.
357
+ */
358
+ async getPaged(path, params, limit, signal) {
359
+ const records = []
360
+ const first = { ...params }
361
+ const unlimited = !Number.isFinite(limit)
362
+ // `__noTop` is the fallback path's opt-out: some endpoints (the metadata
363
+ // ones) reject `$top` outright, and retrying must not re-add it.
364
+ const noTop = first.__noTop === true || unlimited
365
+ delete first.__noTop
366
+ // Ask for one row more than the cap: a server that fills `$top` exactly
367
+ // sends no `@odata.nextLink`, so without the probe row a capped response
368
+ // is indistinguishable from a complete one.
369
+ if (!noTop && first.$top === undefined && Number.isFinite(limit)) first.$top = limit + 1
370
+ let next = this.buildUrl(path, first)
371
+ let pages = 0
372
+ const maxPages = unlimited ? 20_000 : 500
373
+
374
+ while (next && (unlimited || records.length < limit) && pages < maxPages) {
375
+ const page = await this.requestJson(next, { signal })
376
+ const value = Array.isArray(page?.value) ? page.value : []
377
+ records.push(...value)
378
+ next = page?.['@odata.nextLink']
379
+ pages += 1
380
+ }
381
+
382
+ const overflow = !unlimited && records.length > limit
383
+ return {
384
+ records: overflow ? records.slice(0, limit) : records,
385
+ hasMore: Boolean(next) || overflow,
386
+ pages,
387
+ }
388
+ }
389
+
390
+ /** GET a single record (collection query with `$top=1`). */
391
+ async getOne(path, params, signal) {
392
+ const url = this.buildUrl(path, { ...params, $top: 1 })
393
+ const page = await this.requestJson(url, { signal })
394
+ return Array.isArray(page?.value) ? page.value[0] : page
395
+ }
396
+
397
+ /**
398
+ * POST a JSON body (an unbound action such as `InsertOptionValue`) and decode
399
+ * the response. Writes are reachable only through the audited write pipeline.
400
+ */
401
+ async postJson(path, body, signal) {
402
+ return this.requestJson(this.buildUrl(path), { method: 'POST', body, signal })
403
+ }
404
+
405
+ /**
406
+ * Send one JSON body with any method and extra headers.
407
+ *
408
+ * Metadata creation and update need more than POST: creating a column is a
409
+ * POST to the entity's `Attributes` collection, updating it is a PUT that
410
+ * carries `MSCRM.MergeLabels`, and deleting it is a DELETE. None of those are
411
+ * actions, so the option-value helpers do not cover them.
412
+ * @param path - Web API path.
413
+ * @param options - `{ method, body, headers }`.
414
+ */
415
+ async sendJson(path, options = {}, signal) {
416
+ const { method = 'POST', body, headers } = options
417
+ return this.requestJson(this.buildUrl(path), { method, body, headers, signal })
418
+ }
419
+
420
+ /**
421
+ * Read the organization's platform version.
422
+ *
423
+ * The `organization` table does not expose a `version` property in every
424
+ * deployment (a real one rejected `$select=version` with 0x80060888), so the
425
+ * supported `RetrieveVersion` action is the only portable source. It is
426
+ * best-effort: a missing version must not fail a connection check.
427
+ */
428
+ async readVersion(signal) {
429
+ try {
430
+ const payload = await this.requestJson(this.buildUrl('RetrieveVersion'), { signal })
431
+ return payload?.Version ?? payload?.version ?? undefined
432
+ } catch {
433
+ return undefined
434
+ }
435
+ }
436
+ }
437
+
438
+ /**
439
+ * GET with progressive fallbacks for query options real deployments reject.
440
+ *
441
+ * Dataverse does not accept the same `$select`/`$orderby` vocabulary on every
442
+ * endpoint or version: a metadata endpoint answers `$orderby` with
443
+ * 0x80060888 "The query parameter ... is not supported", and a table may lack a
444
+ * property this plugin asked for ("Could not find a property named 'modifiedon'
445
+ * on type 'Microsoft.Dynamics.CRM.systemform'"). Each rejection narrows the
446
+ * request — one field at a time — instead of failing the whole resource.
447
+ * @returns `{ page, selectDropped, orderbyDropped, droppedFields }`.
448
+ */
449
+ export async function getPagedWithSelectFallback(client, path, params, limit, signal) {
450
+ const dropped = { selectDropped: false, orderbyDropped: false, topDropped: false, droppedFields: [] }
451
+ const current = { ...params }
452
+
453
+ for (let attempt = 0; attempt < 10; attempt += 1) {
454
+ try {
455
+ return { page: await client.getPaged(path, current, limit, signal), ...dropped }
456
+ } catch (error) {
457
+ if (error?.status !== 400) throw error
458
+ const text = String(error.message ?? '')
459
+
460
+ const missing = /property named '([^']+)'/.exec(text)
461
+ if (missing && current.$select) {
462
+ const fields = current.$select.split(',').map((item) => item.trim()).filter(Boolean)
463
+ const kept = fields.filter((field) => field.toLowerCase() !== missing[1].toLowerCase())
464
+ if (kept.length && kept.length < fields.length) {
465
+ current.$select = kept.join(',')
466
+ dropped.droppedFields.push(missing[1])
467
+ continue
468
+ }
469
+ }
470
+
471
+ // "The query parameter '...' is not supported": the name is often
472
+ // redacted before it reaches us, so narrow the request by dropping the
473
+ // options in turn, cheapest first. `$top` is added inside getPaged, so
474
+ // the check is on the marker, not on the params object.
475
+ if (/query parameter/i.test(text) && !dropped.topDropped) {
476
+ current.__noTop = true
477
+ delete current.$top
478
+ dropped.topDropped = true
479
+ continue
480
+ }
481
+ if (current.$orderby) {
482
+ delete current.$orderby
483
+ dropped.orderbyDropped = true
484
+ continue
485
+ }
486
+ if (current.$select) {
487
+ delete current.$select
488
+ dropped.selectDropped = true
489
+ continue
490
+ }
491
+ throw error
492
+ }
493
+ }
494
+
495
+ return { page: await client.getPaged(path, current, limit, signal), ...dropped }
496
+ }