dsh-d365-kit 0.34.1 → 0.34.4

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 +319 -308
  2. package/CHANGELOG.zh-CN.md +318 -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/index.js CHANGED
@@ -1,142 +1,142 @@
1
- /**
2
- * `dsh-d365-kit` — D365 CE / Dataverse toolkit for DSH.
3
- *
4
- * Tools, one data path:
5
- * d365_envs which environments exist and whether they authenticate
6
- * d365_metadata the generic metadata query over a fixed resource catalogue
7
- * d365_layers the solution-layer stack behind any component
8
- * d365_sync snapshot an environment's metadata to local files
9
- * d365_diff compare two snapshots
10
- * d365_configure read/write the environment list (same storage as the UI)
11
- * d365_write prepare a metadata change, then confirm and apply it
12
- * d365_migrate assess what happens if a solution is imported into another environment
13
- * d365_code_check what a merge request's changed scripts break in a target environment
14
- * d365_refs who references a field, script or plugin assembly
15
- * d365_data count / group / sample business records (needs `data.read`)
16
- * d365_data_write create or patch one business record (needs `data.write`)
17
- * d365_data_sync move business records between environments (plan, then confirm)
18
- * d365_invoke call one action or function and return status + body (needs `message.invoke`)
1
+ /**
2
+ * `dsh-d365-kit` — D365 CE / Dataverse toolkit for DSH.
3
+ *
4
+ * Tools, one data path:
5
+ * d365_envs which environments exist and whether they authenticate
6
+ * d365_metadata the generic metadata query over a fixed resource catalogue
7
+ * d365_layers the solution-layer stack behind any component
8
+ * d365_sync snapshot an environment's metadata to local files
9
+ * d365_diff compare two snapshots
10
+ * d365_configure read/write the environment list (same storage as the UI)
11
+ * d365_write prepare a metadata change, then confirm and apply it
12
+ * d365_migrate assess what happens if a solution is imported into another environment
13
+ * d365_code_check what a merge request's changed scripts break in a target environment
14
+ * d365_refs who references a field, script or plugin assembly
15
+ * d365_data count / group / sample business records (needs `data.read`)
16
+ * d365_data_write create or patch one business record (needs `data.write`)
17
+ * d365_data_sync move business records between environments (plan, then confirm)
18
+ * d365_invoke call one action or function and return status + body (needs `message.invoke`)
19
19
  * d365_trace read plug-in trace logs to explain a failure (needs `data.read`)
20
- * d365_flows Power Automate cloud flows: list, one flow's skeleton, who touches a table
21
- * d365_contract compare plugin registration / option values across environments
22
- * d365_health stuck jobs, running imports, recently installed solutions
23
- * d365_reports what this plugin wrote to disk, and how much space it takes
24
- *
25
- * Reads go through `lib/client.js`, which sends GET only and splits reads in
26
- * two: metadata (always allowed) and business records (needs `data.read` for
27
- * that environment, resolved by `lib/data-policy.js`). The single metadata
28
- * write path lives in `lib/write-actions.js` and always runs behind a plan that
29
- * a human approves; business record writes live in `lib/tool-data-write.js`,
30
- * are gated by `data.write`, and always ask for confirmation first.
31
- *
32
- * The Web UI half contributes a settings page ("D365 工具箱") that saves through
33
- * the same `lib/settings-store.js` functions as `d365_configure`, and reaches
34
- * the Host through the routes in `lib/settings-http.js`.
35
- */
36
- import { register as registerEnvironments } from './lib/tool-environments.js'
37
- import { register as registerMetadata } from './lib/tool-metadata.js'
38
- import { register as registerLayers } from './lib/tool-layers.js'
39
- import { register as registerSync } from './lib/tool-sync.js'
40
- import { register as registerDiff } from './lib/tool-diff.js'
41
- import { register as registerConfigure } from './lib/tool-configure.js'
42
- import { register as registerWrite } from './lib/tool-write.js'
43
- import { register as registerMigrate } from './lib/tool-migrate.js'
44
- import { register as registerCodeCheck } from './lib/tool-code-check.js'
45
- import { register as registerRefs } from './lib/tool-refs.js'
46
- import { register as registerData } from './lib/tool-data.js'
47
- import { register as registerDataWrite } from './lib/tool-data-write.js'
48
- import { register as registerContract } from './lib/tool-contract.js'
49
- import { register as registerHealth } from './lib/tool-health.js'
50
- import { register as registerReports } from './lib/tool-reports.js'
51
- import { register as registerDataSync } from './lib/tool-data-sync.js'
52
- import { register as registerInvoke } from './lib/tool-invoke.js'
20
+ * d365_flows Power Automate cloud flows: list, one flow's skeleton, who touches a table
21
+ * d365_contract compare plugin registration / option values across environments
22
+ * d365_health stuck jobs, running imports, recently installed solutions
23
+ * d365_reports what this plugin wrote to disk, and how much space it takes
24
+ *
25
+ * Reads go through `lib/client.js`, which sends GET only and splits reads in
26
+ * two: metadata (always allowed) and business records (needs `data.read` for
27
+ * that environment, resolved by `lib/data-policy.js`). The single metadata
28
+ * write path lives in `lib/write-actions.js` and always runs behind a plan that
29
+ * a human approves; business record writes live in `lib/tool-data-write.js`,
30
+ * are gated by `data.write`, and always ask for confirmation first.
31
+ *
32
+ * The Web UI half contributes a settings page ("D365 工具箱") that saves through
33
+ * the same `lib/settings-store.js` functions as `d365_configure`, and reaches
34
+ * the Host through the routes in `lib/settings-http.js`.
35
+ */
36
+ import { register as registerEnvironments } from './lib/tool-environments.js'
37
+ import { register as registerMetadata } from './lib/tool-metadata.js'
38
+ import { register as registerLayers } from './lib/tool-layers.js'
39
+ import { register as registerSync } from './lib/tool-sync.js'
40
+ import { register as registerDiff } from './lib/tool-diff.js'
41
+ import { register as registerConfigure } from './lib/tool-configure.js'
42
+ import { register as registerWrite } from './lib/tool-write.js'
43
+ import { register as registerMigrate } from './lib/tool-migrate.js'
44
+ import { register as registerCodeCheck } from './lib/tool-code-check.js'
45
+ import { register as registerRefs } from './lib/tool-refs.js'
46
+ import { register as registerData } from './lib/tool-data.js'
47
+ import { register as registerDataWrite } from './lib/tool-data-write.js'
48
+ import { register as registerContract } from './lib/tool-contract.js'
49
+ import { register as registerHealth } from './lib/tool-health.js'
50
+ import { register as registerReports } from './lib/tool-reports.js'
51
+ import { register as registerDataSync } from './lib/tool-data-sync.js'
52
+ import { register as registerInvoke } from './lib/tool-invoke.js'
53
53
  import { register as registerTrace } from './lib/tool-trace.js'
54
- import { register as registerFlows } from './lib/tool-flows.js'
55
- import { registerRoutes } from './lib/settings-http.js'
56
- import { registerSkills } from './lib/skill.js'
57
- import { setCapabilityResolver, setDataReadResolver, setDataWriteResolver, setMetadataReadResolver } from './lib/client.js'
58
- import { makeAnyCapabilityResolver, makeDataReadResolver, makeDataWriteResolver, makeMetadataReadResolver } from './lib/data-policy.js'
59
-
60
- export const name = 'dsh-d365-kit'
61
-
62
- /** Tools are required; the HTTP carrier is looked up optionally via `ctx.get`. */
63
- export const inject = ['tools']
64
-
65
- /** Guards the route registration against the two attach paths colliding. */
66
- let routesAttached = false
67
-
68
- /**
69
- * Install the tools and, when a web server is present, the settings routes.
70
- * @param ctx - host context.
71
- * @param config - the `config` block from cordis.patch.yml.
72
- */
73
- export function apply(ctx, config = {}) {
74
- const state = { rawConfig: config ?? {} }
75
-
76
- // Every request consults the policy file: metadata reads, business-record
77
- // reads and writes are separate capabilities granted per environment.
78
- setMetadataReadResolver(makeMetadataReadResolver(() => state.rawConfig))
79
- setDataReadResolver(makeDataReadResolver(() => state.rawConfig))
80
- setDataWriteResolver(makeDataWriteResolver(() => state.rawConfig))
81
- setCapabilityResolver(makeAnyCapabilityResolver(() => state.rawConfig))
82
-
83
- const disposers = [
84
- registerEnvironments(ctx, state),
85
- registerMetadata(ctx, state),
86
- registerLayers(ctx, state),
87
- registerSync(ctx, state),
88
- registerDiff(ctx, state),
89
- registerConfigure(ctx, state),
90
- registerWrite(ctx, state),
91
- registerMigrate(ctx, state),
92
- registerCodeCheck(ctx, state),
93
- registerRefs(ctx, state),
94
- registerData(ctx, state),
95
- registerDataWrite(ctx, state),
96
- registerDataSync(ctx, state),
97
- registerInvoke(ctx, state),
54
+ import { register as registerFlows } from './lib/tool-flows.js'
55
+ import { registerRoutes } from './lib/settings-http.js'
56
+ import { registerSkills } from './lib/skill.js'
57
+ import { setCapabilityResolver, setDataReadResolver, setDataWriteResolver, setMetadataReadResolver } from './lib/client.js'
58
+ import { makeAnyCapabilityResolver, makeDataReadResolver, makeDataWriteResolver, makeMetadataReadResolver } from './lib/data-policy.js'
59
+
60
+ export const name = 'dsh-d365-kit'
61
+
62
+ /** Tools are required; the HTTP carrier is looked up optionally via `ctx.get`. */
63
+ export const inject = ['tools']
64
+
65
+ /** Guards the route registration against the two attach paths colliding. */
66
+ let routesAttached = false
67
+
68
+ /**
69
+ * Install the tools and, when a web server is present, the settings routes.
70
+ * @param ctx - host context.
71
+ * @param config - the `config` block from cordis.patch.yml.
72
+ */
73
+ export function apply(ctx, config = {}) {
74
+ const state = { rawConfig: config ?? {} }
75
+
76
+ // Every request consults the policy file: metadata reads, business-record
77
+ // reads and writes are separate capabilities granted per environment.
78
+ setMetadataReadResolver(makeMetadataReadResolver(() => state.rawConfig))
79
+ setDataReadResolver(makeDataReadResolver(() => state.rawConfig))
80
+ setDataWriteResolver(makeDataWriteResolver(() => state.rawConfig))
81
+ setCapabilityResolver(makeAnyCapabilityResolver(() => state.rawConfig))
82
+
83
+ const disposers = [
84
+ registerEnvironments(ctx, state),
85
+ registerMetadata(ctx, state),
86
+ registerLayers(ctx, state),
87
+ registerSync(ctx, state),
88
+ registerDiff(ctx, state),
89
+ registerConfigure(ctx, state),
90
+ registerWrite(ctx, state),
91
+ registerMigrate(ctx, state),
92
+ registerCodeCheck(ctx, state),
93
+ registerRefs(ctx, state),
94
+ registerData(ctx, state),
95
+ registerDataWrite(ctx, state),
96
+ registerDataSync(ctx, state),
97
+ registerInvoke(ctx, state),
98
98
  registerTrace(ctx, state),
99
- registerFlows(ctx, state),
100
- registerContract(ctx, state),
101
- registerHealth(ctx, state),
102
- registerReports(ctx, state),
103
- ]
104
-
105
- ctx.effect(() => () => {
106
- for (const dispose of disposers) {
107
- try {
108
- dispose?.()
109
- } catch {
110
- // A failed unregister must not block the remaining ones.
111
- }
112
- }
113
- })
114
-
115
- // The troubleshooting skill travels with the package: registering it here is
116
- // what makes installing the bundle enough, with no file to copy by hand.
117
- registerSkills(ctx)
118
-
119
- // Looked up rather than declared: a headless profile has no HTTP carrier and
120
- // must not keep the plugin inactive. The settings routes live on the
121
- // authenticated `/api` channel that `ctx.connection` owns.
122
- const attachRoutes = (host) => {
123
- if (routesAttached) return
124
- const connection = typeof host.get === 'function' ? host.get('connection') : undefined
125
- if (!connection) return
126
- // The write routes compare the browser Origin against this origin; without
127
- // it they refuse, which is the intended failure mode.
128
- let origin
129
- try {
130
- origin = typeof host.get === 'function' ? host.get('webServer')?.getOrigin?.() : undefined
131
- } catch {
132
- origin = undefined
133
- }
134
- routesAttached = true
135
- host.effect(() => {
136
- routesAttached = false
137
- return registerRoutes({ connection, origin }, state)
138
- })
139
- }
140
- if (typeof ctx.inject === 'function') ctx.inject(['connection'], attachRoutes)
141
- attachRoutes(ctx)
142
- }
99
+ registerFlows(ctx, state),
100
+ registerContract(ctx, state),
101
+ registerHealth(ctx, state),
102
+ registerReports(ctx, state),
103
+ ]
104
+
105
+ ctx.effect(() => () => {
106
+ for (const dispose of disposers) {
107
+ try {
108
+ dispose?.()
109
+ } catch {
110
+ // A failed unregister must not block the remaining ones.
111
+ }
112
+ }
113
+ })
114
+
115
+ // The troubleshooting skill travels with the package: registering it here is
116
+ // what makes installing the bundle enough, with no file to copy by hand.
117
+ registerSkills(ctx)
118
+
119
+ // Looked up rather than declared: a headless profile has no HTTP carrier and
120
+ // must not keep the plugin inactive. The settings routes live on the
121
+ // authenticated `/api` channel that `ctx.connection` owns.
122
+ const attachRoutes = (host) => {
123
+ if (routesAttached) return
124
+ const connection = typeof host.get === 'function' ? host.get('connection') : undefined
125
+ if (!connection) return
126
+ // The write routes compare the browser Origin against this origin; without
127
+ // it they refuse, which is the intended failure mode.
128
+ let origin
129
+ try {
130
+ origin = typeof host.get === 'function' ? host.get('webServer')?.getOrigin?.() : undefined
131
+ } catch {
132
+ origin = undefined
133
+ }
134
+ routesAttached = true
135
+ host.effect(() => {
136
+ routesAttached = false
137
+ return registerRoutes({ connection, origin }, state)
138
+ })
139
+ }
140
+ if (typeof ctx.inject === 'function') ctx.inject(['connection'], attachRoutes)
141
+ attachRoutes(ctx)
142
+ }
package/lib/auth.js CHANGED
@@ -1,181 +1,181 @@
1
- /**
2
- * OAuth2 client-credentials tokens for Dataverse.
3
- *
4
- * One token per environment, cached until shortly before expiry. The token is
5
- * requested against the environment's own URL, which is what Dataverse
6
- * requires (a token for another environment is rejected with 401).
7
- */
8
- import { createHash } from 'node:crypto'
9
- import { resolveCredential } from './config.js'
10
- import { D365Error } from './errors.js'
11
-
12
- /** Cloud-specific Entra ID authorities. The resource scope is always the env URL. */
13
- const AUTHORITY = {
14
- public: 'https://login.microsoftonline.com',
15
- usgovernment: 'https://login.microsoftonline.us',
16
- dod: 'https://login.microsoftonline.us',
17
- china: 'https://login.partner.microsoftonline.cn',
18
- }
19
-
20
- export const KNOWN_CLOUDS = Object.keys(AUTHORITY)
21
-
22
- /** @type {Map<string, { token: string, expiresAt: number, fingerprint: string }>} */
23
- const tokenCache = new Map()
24
-
25
- /**
26
- * Token requests that are already in flight, per environment.
27
- *
28
- * Without this, N requests issued at the same moment (a business-data sync
29
- * writes with a concurrency of 4, a multi-environment query fans out per
30
- * environment) each missed the cache and each POSTed to Entra — six concurrent
31
- * requests used to mean six token requests. They now await one POST.
32
- * @type {Map<string, { promise: Promise<string>, fingerprint: string | undefined }>}
33
- */
34
- const tokenPending = new Map()
35
-
36
- /** Renew this long before `expires_in` runs out, so an in-flight request never races the expiry. */
37
- const REFRESH_SKEW_MS = 120_000
38
-
39
- /**
40
- * Fingerprint of the values a token was minted from.
41
- *
42
- * Changing the application user in the settings page must take effect on the
43
- * next call, but the token cache outlives a settings save (and a plugin reload
44
- * reuses the module). Comparing this fingerprint makes a cached token
45
- * unusable as soon as the url, tenant, client id or secret changes.
46
- */
47
- function credentialFingerprint(env) {
48
- const parts = ['tenantId', 'clientId', 'clientSecret'].map((kind) => {
49
- const resolved = resolveCredential(env, kind)
50
- return resolved.ok ? resolved.value : ''
51
- })
52
- if (parts.some((part) => !part)) return undefined
53
- return createHash('sha256').update([env.url, ...parts].join('\u0000')).digest('hex')
54
- }
55
-
56
- /**
57
- * Drop a cached token so the next call re-authenticates.
58
- * @param envKey - environment key.
59
- */
60
- export function forgetToken(envKey) {
61
- tokenCache.delete(envKey)
62
- }
63
-
64
- function requireCredential(env, kind) {
65
- const resolved = resolveCredential(env, kind)
66
- if (resolved.ok) return resolved.value
67
- throw new D365Error(
68
- `环境 ${env.key} 缺少 ${kind}(找不到 ${resolved.missing.join(' 或 ')})`,
69
- {
70
- code: 'missing-credential',
71
- hint: `在配置里写 ${kind}Env: '${resolved.missing[0]}',并在启动 harness 的环境中设置该变量。`,
72
- },
73
- )
74
- }
75
-
76
- /**
77
- * Acquire (or reuse) an access token for one environment.
78
- *
79
- * Three layers keep authentication off the per-request path: the cache, the
80
- * in-flight map (so concurrent callers share one POST), and the 401 handler in
81
- * the client, which only forces a refresh when the server actually rejected a
82
- * token.
83
- * @param env - normalized environment.
84
- * @param options - `force` bypasses the cache.
85
- * @returns a bearer token string.
86
- */
87
- export async function getAccessToken(env, options = {}) {
88
- const fingerprint = credentialFingerprint(env)
89
- const cached = tokenCache.get(env.key)
90
- if (
91
- !options.force
92
- && cached
93
- && fingerprint
94
- && cached.fingerprint === fingerprint
95
- && cached.expiresAt - REFRESH_SKEW_MS > Date.now()
96
- ) {
97
- return cached.token
98
- }
99
-
100
- if (!options.force) {
101
- const inFlight = tokenPending.get(env.key)
102
- if (inFlight && inFlight.fingerprint === fingerprint) return inFlight.promise
103
- }
104
-
105
- const promise = requestToken(env, fingerprint)
106
- tokenPending.set(env.key, { promise, fingerprint })
107
- try {
108
- return await promise
109
- } finally {
110
- // Only the caller that installed this promise may clear it: a forced
111
- // refresh can replace it while the first POST is still running.
112
- if (tokenPending.get(env.key)?.promise === promise) tokenPending.delete(env.key)
113
- }
114
- }
115
-
116
- /** The actual client-credentials POST. Callers go through `getAccessToken`. */
117
- async function requestToken(env, fingerprint) {
118
- const tenant = requireCredential(env, 'tenantId')
119
- const clientId = requireCredential(env, 'clientId')
120
- const clientSecret = requireCredential(env, 'clientSecret')
121
-
122
- const authority = (env.authorityHost ?? AUTHORITY[env.cloud] ?? AUTHORITY.public).replace(/\/+$/, '')
123
- const url = `${authority}/${encodeURIComponent(tenant)}/oauth2/v2.0/token`
124
- const body = new URLSearchParams({
125
- grant_type: 'client_credentials',
126
- client_id: clientId,
127
- client_secret: clientSecret,
128
- scope: `${env.url}/.default`,
129
- })
130
-
131
- const controller = new AbortController()
132
- const timer = setTimeout(() => controller.abort(), 30_000)
133
- let response
134
- try {
135
- response = await fetch(url, {
136
- method: 'POST',
137
- headers: { 'content-type': 'application/x-www-form-urlencoded' },
138
- body,
139
- signal: controller.signal,
140
- })
141
- } catch (error) {
142
- throw new D365Error(`环境 ${env.key} 获取令牌失败:${error.message}`, {
143
- code: 'token-network',
144
- hint: `确认能访问 ${authority};企业代理需要放行登录与 *.dynamics.com。`,
145
- })
146
- } finally {
147
- clearTimeout(timer)
148
- }
149
-
150
- const text = await response.text()
151
- let payload
152
- try {
153
- payload = JSON.parse(text)
154
- } catch {
155
- throw new D365Error(`环境 ${env.key} 获取令牌失败:HTTP ${response.status},响应不是 JSON`, {
156
- code: 'token-http',
157
- })
158
- }
159
-
160
- if (!response.ok) {
161
- const detail = payload?.error_description ?? payload?.error ?? text.slice(0, 400)
162
- throw new D365Error(`环境 ${env.key} 获取令牌失败:HTTP ${response.status} — ${detail}`, {
163
- code: 'token-http',
164
- status: response.status,
165
- hint: '常见原因:密钥过期、应用用户未在目标环境启用、或该环境 URL 与租户不匹配。',
166
- })
167
- }
168
-
169
- const token = payload?.access_token
170
- if (!token) {
171
- throw new D365Error(`环境 ${env.key} 获取令牌失败:响应中没有 access_token`, { code: 'token-shape' })
172
- }
173
-
174
- const lifetimeMs = Number(payload.expires_in) > 0 ? Number(payload.expires_in) * 1000 : 3600_000
175
- tokenCache.set(env.key, {
176
- token,
177
- expiresAt: Date.now() + lifetimeMs,
178
- fingerprint: fingerprint ?? '',
179
- })
180
- return token
181
- }
1
+ /**
2
+ * OAuth2 client-credentials tokens for Dataverse.
3
+ *
4
+ * One token per environment, cached until shortly before expiry. The token is
5
+ * requested against the environment's own URL, which is what Dataverse
6
+ * requires (a token for another environment is rejected with 401).
7
+ */
8
+ import { createHash } from 'node:crypto'
9
+ import { resolveCredential } from './config.js'
10
+ import { D365Error } from './errors.js'
11
+
12
+ /** Cloud-specific Entra ID authorities. The resource scope is always the env URL. */
13
+ const AUTHORITY = {
14
+ public: 'https://login.microsoftonline.com',
15
+ usgovernment: 'https://login.microsoftonline.us',
16
+ dod: 'https://login.microsoftonline.us',
17
+ china: 'https://login.partner.microsoftonline.cn',
18
+ }
19
+
20
+ export const KNOWN_CLOUDS = Object.keys(AUTHORITY)
21
+
22
+ /** @type {Map<string, { token: string, expiresAt: number, fingerprint: string }>} */
23
+ const tokenCache = new Map()
24
+
25
+ /**
26
+ * Token requests that are already in flight, per environment.
27
+ *
28
+ * Without this, N requests issued at the same moment (a business-data sync
29
+ * writes with a concurrency of 4, a multi-environment query fans out per
30
+ * environment) each missed the cache and each POSTed to Entra — six concurrent
31
+ * requests used to mean six token requests. They now await one POST.
32
+ * @type {Map<string, { promise: Promise<string>, fingerprint: string | undefined }>}
33
+ */
34
+ const tokenPending = new Map()
35
+
36
+ /** Renew this long before `expires_in` runs out, so an in-flight request never races the expiry. */
37
+ const REFRESH_SKEW_MS = 120_000
38
+
39
+ /**
40
+ * Fingerprint of the values a token was minted from.
41
+ *
42
+ * Changing the application user in the settings page must take effect on the
43
+ * next call, but the token cache outlives a settings save (and a plugin reload
44
+ * reuses the module). Comparing this fingerprint makes a cached token
45
+ * unusable as soon as the url, tenant, client id or secret changes.
46
+ */
47
+ function credentialFingerprint(env) {
48
+ const parts = ['tenantId', 'clientId', 'clientSecret'].map((kind) => {
49
+ const resolved = resolveCredential(env, kind)
50
+ return resolved.ok ? resolved.value : ''
51
+ })
52
+ if (parts.some((part) => !part)) return undefined
53
+ return createHash('sha256').update([env.url, ...parts].join('\u0000')).digest('hex')
54
+ }
55
+
56
+ /**
57
+ * Drop a cached token so the next call re-authenticates.
58
+ * @param envKey - environment key.
59
+ */
60
+ export function forgetToken(envKey) {
61
+ tokenCache.delete(envKey)
62
+ }
63
+
64
+ function requireCredential(env, kind) {
65
+ const resolved = resolveCredential(env, kind)
66
+ if (resolved.ok) return resolved.value
67
+ throw new D365Error(
68
+ `环境 ${env.key} 缺少 ${kind}(找不到 ${resolved.missing.join(' 或 ')})`,
69
+ {
70
+ code: 'missing-credential',
71
+ hint: `在配置里写 ${kind}Env: '${resolved.missing[0]}',并在启动 harness 的环境中设置该变量。`,
72
+ },
73
+ )
74
+ }
75
+
76
+ /**
77
+ * Acquire (or reuse) an access token for one environment.
78
+ *
79
+ * Three layers keep authentication off the per-request path: the cache, the
80
+ * in-flight map (so concurrent callers share one POST), and the 401 handler in
81
+ * the client, which only forces a refresh when the server actually rejected a
82
+ * token.
83
+ * @param env - normalized environment.
84
+ * @param options - `force` bypasses the cache.
85
+ * @returns a bearer token string.
86
+ */
87
+ export async function getAccessToken(env, options = {}) {
88
+ const fingerprint = credentialFingerprint(env)
89
+ const cached = tokenCache.get(env.key)
90
+ if (
91
+ !options.force
92
+ && cached
93
+ && fingerprint
94
+ && cached.fingerprint === fingerprint
95
+ && cached.expiresAt - REFRESH_SKEW_MS > Date.now()
96
+ ) {
97
+ return cached.token
98
+ }
99
+
100
+ if (!options.force) {
101
+ const inFlight = tokenPending.get(env.key)
102
+ if (inFlight && inFlight.fingerprint === fingerprint) return inFlight.promise
103
+ }
104
+
105
+ const promise = requestToken(env, fingerprint)
106
+ tokenPending.set(env.key, { promise, fingerprint })
107
+ try {
108
+ return await promise
109
+ } finally {
110
+ // Only the caller that installed this promise may clear it: a forced
111
+ // refresh can replace it while the first POST is still running.
112
+ if (tokenPending.get(env.key)?.promise === promise) tokenPending.delete(env.key)
113
+ }
114
+ }
115
+
116
+ /** The actual client-credentials POST. Callers go through `getAccessToken`. */
117
+ async function requestToken(env, fingerprint) {
118
+ const tenant = requireCredential(env, 'tenantId')
119
+ const clientId = requireCredential(env, 'clientId')
120
+ const clientSecret = requireCredential(env, 'clientSecret')
121
+
122
+ const authority = (env.authorityHost ?? AUTHORITY[env.cloud] ?? AUTHORITY.public).replace(/\/+$/, '')
123
+ const url = `${authority}/${encodeURIComponent(tenant)}/oauth2/v2.0/token`
124
+ const body = new URLSearchParams({
125
+ grant_type: 'client_credentials',
126
+ client_id: clientId,
127
+ client_secret: clientSecret,
128
+ scope: `${env.url}/.default`,
129
+ })
130
+
131
+ const controller = new AbortController()
132
+ const timer = setTimeout(() => controller.abort(), 30_000)
133
+ let response
134
+ try {
135
+ response = await fetch(url, {
136
+ method: 'POST',
137
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
138
+ body,
139
+ signal: controller.signal,
140
+ })
141
+ } catch (error) {
142
+ throw new D365Error(`环境 ${env.key} 获取令牌失败:${error.message}`, {
143
+ code: 'token-network',
144
+ hint: `确认能访问 ${authority};企业代理需要放行登录与 *.dynamics.com。`,
145
+ })
146
+ } finally {
147
+ clearTimeout(timer)
148
+ }
149
+
150
+ const text = await response.text()
151
+ let payload
152
+ try {
153
+ payload = JSON.parse(text)
154
+ } catch {
155
+ throw new D365Error(`环境 ${env.key} 获取令牌失败:HTTP ${response.status},响应不是 JSON`, {
156
+ code: 'token-http',
157
+ })
158
+ }
159
+
160
+ if (!response.ok) {
161
+ const detail = payload?.error_description ?? payload?.error ?? text.slice(0, 400)
162
+ throw new D365Error(`环境 ${env.key} 获取令牌失败:HTTP ${response.status} — ${detail}`, {
163
+ code: 'token-http',
164
+ status: response.status,
165
+ hint: '常见原因:密钥过期、应用用户未在目标环境启用、或该环境 URL 与租户不匹配。',
166
+ })
167
+ }
168
+
169
+ const token = payload?.access_token
170
+ if (!token) {
171
+ throw new D365Error(`环境 ${env.key} 获取令牌失败:响应中没有 access_token`, { code: 'token-shape' })
172
+ }
173
+
174
+ const lifetimeMs = Number(payload.expires_in) > 0 ? Number(payload.expires_in) * 1000 : 3600_000
175
+ tokenCache.set(env.key, {
176
+ token,
177
+ expiresAt: Date.now() + lifetimeMs,
178
+ fingerprint: fingerprint ?? '',
179
+ })
180
+ return token
181
+ }