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