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
@@ -1,512 +1,512 @@
1
- /**
2
- * HTTP surface for the settings page.
3
- *
4
- * The page is plain browser JavaScript, so it reaches the Host over the
5
- * authenticated `/api` Fetch channel that `ctx.connection.fetch` mounts, rather
6
- * than a raw route on the bare web server: the channel runs the same Host,
7
- * Origin and browser-credential checks every other `/api` call gets, so the
8
- * form cannot become an unauthenticated write path.
9
- *
10
- * The write routes add one more guard: the page must send an explicit
11
- * `x-d365-meta-write` header (and, when an `Origin` survives the channel, match
12
- * this server's origin). Browser pages cannot send a custom header across
13
- * origins without a CORS preflight the routes never answer, so "a person
14
- * confirmed this in the settings page" stays true for metadata changes.
15
- *
16
- * Every handler funnels into the same modules the tools use, so the UI and the
17
- * agent cannot disagree about what was saved.
18
- */
19
- import { appendFile } from 'node:fs/promises'
20
- import { join } from 'node:path'
21
- import { readSettings, saveGeneral, saveGitlab, saveSettings, checkEnvironment } from './settings-store.js'
22
- import { dataDirectory, loadConfig } from './config.js'
23
- import { checkGitlab } from './gitlab.js'
24
- import { D365Error } from './errors.js'
25
- import { CAPABILITIES, allows, readPolicy, savePolicy } from './write-policy.js'
26
- import { deletePlan, listChanges, listPlans, listPublishReports, readPlan, writePublishReport } from './write-store.js'
27
- import { executePlan, publishNow, publishTargets } from './write-actions.js'
28
- import { applyCleanup, compactAllSnapshots, formatBytes, planCleanup, storageSummary } from './retention.js'
29
-
30
- const BASE = '/api/d365-meta'
31
-
32
- /** Header the settings page sends on every route that can change an environment. */
33
- const WRITE_HEADER = 'x-d365-meta-write'
34
- const WRITE_HEADER_VALUE = '1'
35
-
36
- /** JSON response helper. */
37
- function json(status, value) {
38
- return new Response(JSON.stringify(value), {
39
- status,
40
- headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
41
- })
42
- }
43
-
44
- /** Map a thrown error to the response the page can show. */
45
- function failure(error) {
46
- if (error instanceof D365Error) return json(400, { error: error.message, hint: error.hint })
47
- return json(500, { error: error?.message ?? String(error) })
48
- }
49
-
50
- /**
51
- * Register the settings and write routes.
52
- * @param ctx - `{ connection, origin }`; `origin` is the web server's own origin.
53
- * @param state - holds the raw patch config.
54
- * @returns disposer.
55
- */
56
- export function registerRoutes(ctx, state) {
57
- const fetch = ctx.connection.fetch
58
- const serverOrigin = ctx.origin
59
- const disposers = []
60
-
61
- /**
62
- * Is this `Origin` the page's own origin?
63
- *
64
- * Comparing against `webServer.getOrigin()` alone is fragile: that call throws
65
- * before the listener binds and its spelling (bind host vs request host) can
66
- * differ from what the browser sends. A browser request carries other
67
- * trustworthy hints for the same fact — the request URL and the `Host`
68
- * header — so any of them matching counts as same-origin. A non-browser client
69
- * sends no `Origin` at all, which is the property the guard actually needs.
70
- */
71
- function isSameOrigin(origin, request) {
72
- let parsed
73
- try {
74
- parsed = new URL(origin)
75
- } catch {
76
- return false
77
- }
78
-
79
- const candidates = []
80
- if (serverOrigin) candidates.push(serverOrigin)
81
- if (request.url) candidates.push(request.url)
82
- const host = request.headers.get('host')
83
- if (host) candidates.push(`${parsed.protocol}//${host}`)
84
-
85
- return candidates.some((candidate) => {
86
- try {
87
- return new URL(candidate).origin === parsed.origin
88
- } catch {
89
- return false
90
- }
91
- })
92
- }
93
-
94
- /** Record a rejection, so a wrong guard is diagnosable instead of silent. */
95
- function logRejection(reason, request) {
96
- try {
97
- const line = `${JSON.stringify({
98
- at: new Date().toISOString(),
99
- reason,
100
- origin: request.headers.get('origin'),
101
- host: request.headers.get('host'),
102
- url: request.url,
103
- serverOrigin,
104
- headers: [...request.headers.keys()].sort().join(','),
105
- })}\n`
106
- appendFile(join(dataDirectory(state.rawConfig), '.d365', 'write-guard.log'), line).catch(() => {})
107
- } catch {
108
- // Diagnostics are best-effort.
109
- }
110
- }
111
-
112
- /**
113
- * Browser-only guard for anything that changes an environment.
114
- *
115
- * Requiring `Origin` alone does not work: the authenticated `/api` channel
116
- * rewrites the request URL to `http://dsh.internal` and strips `Origin`, so
117
- * every real browser call arrived without one (see `.d365/write-guard.log`).
118
- * The page therefore proves itself with an explicit `x-d365-meta-write` header.
119
- * A cross-site page cannot send that header without a CORS preflight, and these
120
- * routes never answer a preflight, so the confirmation still cannot be forged
121
- * from another origin. An `Origin`, when the channel does pass one through,
122
- * must additionally be same-origin.
123
- */
124
- function requiresBrowserRequest(request) {
125
- const origin = request.headers.get('origin')
126
- if (origin && !isSameOrigin(origin, request)) {
127
- logRejection('origin-mismatch', request)
128
- return json(403, {
129
- error: '写入只能从设置页发起',
130
- hint: '请求来自其他站点;请在设置页确认计划。',
131
- })
132
- }
133
- if (request.headers.get(WRITE_HEADER) === WRITE_HEADER_VALUE) return undefined
134
- logRejection('missing-write-header', request)
135
- return json(403, {
136
- error: '写入只能从设置页发起',
137
- hint: '设置页会带上专用的确认头;命令行与工具无法伪造这次确认。',
138
- })
139
- }
140
-
141
- disposers.push(fetch.register({
142
- path: `${BASE}/config`,
143
- methods: ['GET', 'POST'],
144
- requestBody: 'buffered',
145
- async fetch(request) {
146
- try {
147
- if (request.method === 'GET') {
148
- return json(200, { settings: await readSettings(state.rawConfig) })
149
- }
150
- // This route writes the environment list and credentials: it needs the
151
- // same same-origin guard as every other write route here.
152
- const guard = requiresBrowserRequest(request)
153
- if (guard) return guard
154
- const body = await request.json().catch(() => {
155
- throw new D365Error('请求体不是合法 JSON', { code: 'bad-json' })
156
- })
157
- const result = await saveSettings(state.rawConfig, body)
158
- return json(200, { settings: result.settings, secretsTouched: result.secretsTouched })
159
- } catch (error) {
160
- return failure(error)
161
- }
162
- },
163
- }))
164
-
165
- disposers.push(fetch.register({
166
- path: `${BASE}/settings`,
167
- methods: ['POST'],
168
- requestBody: 'buffered',
169
- async fetch(request) {
170
- try {
171
- const guard = requiresBrowserRequest(request)
172
- if (guard) return guard
173
- const body = await request.json().catch(() => ({}))
174
- // Two independent forms share this route so the page can save one
175
- // section without resending the other.
176
- if (body?.gitlab) {
177
- const settings = await saveGitlab(state.rawConfig, body.gitlab)
178
- return json(200, { settings })
179
- }
180
- const settings = await saveGeneral(state.rawConfig, body?.general ?? body ?? {})
181
- return json(200, { settings })
182
- } catch (error) {
183
- return failure(error)
184
- }
185
- },
186
- }))
187
-
188
- disposers.push(fetch.register({
189
- path: `${BASE}/gitlab/check`,
190
- methods: ['POST'],
191
- requestBody: 'buffered',
192
- async fetch(request) {
193
- try {
194
- const guard = requiresBrowserRequest(request)
195
- if (guard) return guard
196
- const body = await request.json().catch(() => null)
197
- const config = await loadConfig(state.rawConfig)
198
- // Let the form test values before they are saved.
199
- const candidate = body
200
- ? { ...config, gitlab: { ...config.gitlab, ...(body.url ? { url: String(body.url).replace(/\/+$/, '') } : {}) } }
201
- : config
202
- return json(200, await checkGitlab(candidate))
203
- } catch (error) {
204
- return failure(error)
205
- }
206
- },
207
- }))
208
-
209
- disposers.push(fetch.register({
210
- path: `${BASE}/storage`,
211
- methods: ['GET'],
212
- // Every exact route must declare a body mode. Without it the channel falls
213
- // back to streaming and constructs a GET request with a body, which throws
214
- // and surfaces as a bare HTTP 400 in the page.
215
- requestBody: 'buffered',
216
- async fetch() {
217
- try {
218
- const config = await loadConfig(state.rawConfig)
219
- return json(200, { storage: await storageSummary(config) })
220
- } catch (error) {
221
- return failure(error)
222
- }
223
- },
224
- }))
225
-
226
- disposers.push(fetch.register({
227
- path: `${BASE}/storage/cleanup`,
228
- methods: ['POST'],
229
- requestBody: 'buffered',
230
- async fetch(request) {
231
- try {
232
- const guard = requiresBrowserRequest(request)
233
- if (guard) return guard
234
- const body = await request.json().catch(() => ({}))
235
- const config = await loadConfig(state.rawConfig)
236
- const target = ['snapshots', 'reports', 'diffs', 'query', 'changes', 'all'].includes(body?.target)
237
- ? body.target
238
- : 'all'
239
- const plan = await planCleanup(config, { target })
240
- if (body?.dryRun !== false) {
241
- return json(200, {
242
- dryRun: true,
243
- plan: {
244
- target,
245
- totalFiles: plan.totalFiles,
246
- totalBytes: plan.totalBytes,
247
- totalText: formatBytes(plan.totalBytes),
248
- classes: plan.classes.map((row) => ({
249
- id: row.id,
250
- label: row.label,
251
- count: row.count,
252
- files: row.remove.length,
253
- bytes: row.bytes,
254
- bytesText: formatBytes(row.bytes),
255
- keep: row.keep,
256
- maxAgeDays: row.maxAgeDays,
257
- })),
258
- },
259
- storage: await storageSummary(config),
260
- })
261
- }
262
- const applied = await applyCleanup(config, plan)
263
- return json(200, {
264
- dryRun: false,
265
- deleted: applied.deleted.length,
266
- bytes: applied.bytes,
267
- bytesText: formatBytes(applied.bytes),
268
- failures: applied.failures,
269
- storage: await storageSummary(config),
270
- })
271
- } catch (error) {
272
- return failure(error)
273
- }
274
- },
275
- }))
276
-
277
- disposers.push(fetch.register({
278
- path: `${BASE}/storage/compact`,
279
- methods: ['POST'],
280
- requestBody: 'buffered',
281
- async fetch(request) {
282
- try {
283
- const guard = requiresBrowserRequest(request)
284
- if (guard) return guard
285
- const config = await loadConfig(state.rawConfig)
286
- const result = await compactAllSnapshots(config)
287
- return json(200, {
288
- snapshots: result.snapshots,
289
- files: result.files,
290
- savedBytes: result.savedBytes,
291
- savedText: formatBytes(result.savedBytes),
292
- storage: await storageSummary(config),
293
- })
294
- } catch (error) {
295
- return failure(error)
296
- }
297
- },
298
- }))
299
-
300
- disposers.push(fetch.register({
301
- path: `${BASE}/check`,
302
- methods: ['POST'],
303
- requestBody: 'buffered',
304
- async fetch(request) {
305
- try {
306
- const body = await request.json().catch(() => ({}))
307
- const key = String(body?.key ?? '').trim()
308
- if (!key) return json(400, { error: '缺少环境 key' })
309
- return json(200, await checkEnvironment(state.rawConfig, key))
310
- } catch (error) {
311
- return failure(error)
312
- }
313
- },
314
- }))
315
-
316
- disposers.push(fetch.register({
317
- path: `${BASE}/write/policy`,
318
- methods: ['GET', 'POST'],
319
- requestBody: 'buffered',
320
- async fetch(request) {
321
- try {
322
- const config = await loadConfig(state.rawConfig)
323
- if (request.method === 'GET') {
324
- const policy = await readPolicy(config.configFile)
325
- return json(200, {
326
- policy,
327
- capabilities: CAPABILITIES,
328
- environments: Object.keys(config.environments),
329
- })
330
- }
331
- const guard = requiresBrowserRequest(request)
332
- if (guard) return guard
333
- const body = await request.json().catch(() => ({}))
334
- const policy = await savePolicy(config.configFile, body ?? {})
335
- return json(200, { policy, capabilities: CAPABILITIES, environments: Object.keys(config.environments) })
336
- } catch (error) {
337
- return failure(error)
338
- }
339
- },
340
- }))
341
-
342
- disposers.push(fetch.register({
343
- path: `${BASE}/write/plans`,
344
- methods: ['GET'],
345
- requestBody: 'buffered',
346
- async fetch() {
347
- try {
348
- const config = await loadConfig(state.rawConfig)
349
- return json(200, {
350
- plans: await listPlans(config),
351
- changes: await listChanges(config, 10),
352
- publishes: await listPublishReports(config),
353
- })
354
- } catch (error) {
355
- return failure(error)
356
- }
357
- },
358
- }))
359
-
360
- disposers.push(fetch.register({
361
- path: `${BASE}/write/execute`,
362
- methods: ['POST'],
363
- requestBody: 'buffered',
364
- async fetch(request) {
365
- try {
366
- const guard = requiresBrowserRequest(request)
367
- if (guard) return guard
368
- const body = await request.json().catch(() => ({}))
369
- const planId = String(body?.planId ?? '').trim()
370
- if (!planId) return json(400, { error: '缺少 planId' })
371
-
372
- const config = await loadConfig(state.rawConfig)
373
- const plans = await listPlans(config)
374
- const plan = plans.find((item) => item.id === planId)
375
- if (!plan) {
376
- // Executing deletes the plan, so "not found" usually means "already
377
- // done, look at the page that ran it". The ledger is the only evidence
378
- // left; consult it before claiming the plan was cancelled.
379
- const done = (await listChanges(config, 50)).find((entry) => entry.planId === planId)
380
- if (done) {
381
- const ok = done.applied?.length ?? 0
382
- const failed = done.failed?.length ?? 0
383
- return json(409, {
384
- error: `该计划已经执行过:成功 ${ok} 个,失败 ${failed} 个`,
385
- executed: true,
386
- ok,
387
- failed,
388
- publishState: done.publishState,
389
- })
390
- }
391
- return json(404, { error: '计划不存在或已被取消' })
392
- }
393
- if (plan.status !== 'pending') {
394
- const label = plan.status === 'running'
395
- ? '正在执行,或上次执行中途中断'
396
- : plan.status === 'failed' ? '上次执行失败' : '已经执行过'
397
- return json(409, {
398
- error: `计划${label}`,
399
- hint: '先核对 .d365/changes 里的执行记录;要再做一次,请取消该计划并重新生成。',
400
- status: plan.status,
401
- })
402
- }
403
-
404
- // The page is a second front end, not a second policy: the capability is
405
- // re-checked here so revoking it in the meantime blocks the execution.
406
- const policy = await readPolicy(config.configFile)
407
- if (plan.capability && !allows(policy, plan.envKey, plan.capability)) {
408
- const label = CAPABILITIES[plan.capability]?.label ?? plan.capability
409
- return json(403, {
410
- error: `环境 ${plan.envKey} 未授权「${label}」,不能执行这个计划`,
411
- hint: '在设置页「权限」里勾选该能力后再试。',
412
- })
413
- }
414
-
415
- const result = await executePlan(config, plan, undefined, { force: body?.force === true })
416
- await deletePlan(config, planId)
417
- return json(200, result)
418
- } catch (error) {
419
- return failure(error)
420
- }
421
- },
422
- }))
423
-
424
- disposers.push(fetch.register({
425
- path: `${BASE}/write/publish`,
426
- methods: ['POST'],
427
- requestBody: 'buffered',
428
- async fetch(request) {
429
- try {
430
- const guard = requiresBrowserRequest(request)
431
- if (guard) return guard
432
- const body = await request.json().catch(() => ({}))
433
- const planId = String(body?.planId ?? '').trim()
434
- if (!planId) return json(400, { error: '缺少 planId' })
435
-
436
- const config = await loadConfig(state.rawConfig)
437
- const entry = (await listChanges(config, 100)).find((item) => item.planId === planId)
438
- if (!entry) return json(404, { error: '没有找到这个计划的执行记录' })
439
- if (!(entry.applied?.length > 0)) return json(400, { error: '这个计划没有成功的写入,不需要发布' })
440
-
441
- // Publishing the result of a write is still part of that write: if the
442
- // permission was revoked after the plan ran, the person must not be able
443
- // to push the change live from the page.
444
- const policyPath = config.configFile
445
- const policy = policyPath ? await readPolicy(policyPath) : { environments: {} }
446
- if (entry.capability && !allows(policy, entry.envKey, entry.capability)) {
447
- return json(403, {
448
- error: `环境 ${entry.envKey} 未授权 ${entry.capability},不能发布这次改动`,
449
- hint: '先到「权限」里重新授权,或在环境里手工发布。',
450
- })
451
- }
452
-
453
- const scope = publishTargets(entry.applied)
454
- const startedAt = new Date().toISOString()
455
- const outcome = await publishNow(config, entry.envKey, scope, { force: body?.force === true })
456
- const publish = {
457
- planId,
458
- envKey: entry.envKey,
459
- startedAt,
460
- finishedAt: new Date().toISOString(),
461
- targets: scope,
462
- republished: true,
463
- ok: outcome.ok,
464
- mode: outcome.mode,
465
- ...(outcome.error ? { error: outcome.error } : {}),
466
- }
467
- await writePublishReport(config, planId, publish)
468
- return json(200, { publish })
469
- } catch (error) {
470
- return failure(error)
471
- }
472
- },
473
- }))
474
-
475
- disposers.push(fetch.register({
476
- path: `${BASE}/write/cancel`,
477
- methods: ['POST'],
478
- requestBody: 'buffered',
479
- async fetch(request) {
480
- try {
481
- const guard = requiresBrowserRequest(request)
482
- if (guard) return guard
483
- const body = await request.json().catch(() => ({}))
484
- const planId = String(body?.planId ?? '').trim()
485
- if (!planId) return json(400, { error: '缺少 planId' })
486
- const config = await loadConfig(state.rawConfig)
487
- const plan = await readPlan(config, planId)
488
- if (!plan) return json(404, { error: '计划不存在或已被取消' })
489
- // Cancelling a running plan would hide writes that are still landing: the
490
- // execution loop has already read the operations it is applying.
491
- if (plan.status && plan.status !== 'pending') {
492
- const label = plan.status === 'running'
493
- ? '正在执行,或上次执行中途中断'
494
- : plan.status === 'failed' ? '上次执行失败' : '已经执行过'
495
- return json(409, {
496
- error: `计划${label},不能取消`,
497
- hint: '先到 .d365/changes 核对执行记录。',
498
- status: plan.status,
499
- })
500
- }
501
- const removed = await deletePlan(config, planId)
502
- return json(200, { removed })
503
- } catch (error) {
504
- return failure(error)
505
- }
506
- },
507
- }))
508
-
509
- return () => {
510
- for (const dispose of disposers) Promise.resolve(dispose()).catch(() => {})
511
- }
512
- }
1
+ /**
2
+ * HTTP surface for the settings page.
3
+ *
4
+ * The page is plain browser JavaScript, so it reaches the Host over the
5
+ * authenticated `/api` Fetch channel that `ctx.connection.fetch` mounts, rather
6
+ * than a raw route on the bare web server: the channel runs the same Host,
7
+ * Origin and browser-credential checks every other `/api` call gets, so the
8
+ * form cannot become an unauthenticated write path.
9
+ *
10
+ * The write routes add one more guard: the page must send an explicit
11
+ * `x-d365-meta-write` header (and, when an `Origin` survives the channel, match
12
+ * this server's origin). Browser pages cannot send a custom header across
13
+ * origins without a CORS preflight the routes never answer, so "a person
14
+ * confirmed this in the settings page" stays true for metadata changes.
15
+ *
16
+ * Every handler funnels into the same modules the tools use, so the UI and the
17
+ * agent cannot disagree about what was saved.
18
+ */
19
+ import { appendFile } from 'node:fs/promises'
20
+ import { join } from 'node:path'
21
+ import { readSettings, saveGeneral, saveGitlab, saveSettings, checkEnvironment } from './settings-store.js'
22
+ import { dataDirectory, loadConfig } from './config.js'
23
+ import { checkGitlab } from './gitlab.js'
24
+ import { D365Error } from './errors.js'
25
+ import { CAPABILITIES, allows, readPolicy, savePolicy } from './write-policy.js'
26
+ import { deletePlan, listChanges, listPlans, listPublishReports, readPlan, writePublishReport } from './write-store.js'
27
+ import { executePlan, publishNow, publishTargets } from './write-actions.js'
28
+ import { applyCleanup, compactAllSnapshots, formatBytes, planCleanup, storageSummary } from './retention.js'
29
+
30
+ const BASE = '/api/d365-meta'
31
+
32
+ /** Header the settings page sends on every route that can change an environment. */
33
+ const WRITE_HEADER = 'x-d365-meta-write'
34
+ const WRITE_HEADER_VALUE = '1'
35
+
36
+ /** JSON response helper. */
37
+ function json(status, value) {
38
+ return new Response(JSON.stringify(value), {
39
+ status,
40
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
41
+ })
42
+ }
43
+
44
+ /** Map a thrown error to the response the page can show. */
45
+ function failure(error) {
46
+ if (error instanceof D365Error) return json(400, { error: error.message, hint: error.hint })
47
+ return json(500, { error: error?.message ?? String(error) })
48
+ }
49
+
50
+ /**
51
+ * Register the settings and write routes.
52
+ * @param ctx - `{ connection, origin }`; `origin` is the web server's own origin.
53
+ * @param state - holds the raw patch config.
54
+ * @returns disposer.
55
+ */
56
+ export function registerRoutes(ctx, state) {
57
+ const fetch = ctx.connection.fetch
58
+ const serverOrigin = ctx.origin
59
+ const disposers = []
60
+
61
+ /**
62
+ * Is this `Origin` the page's own origin?
63
+ *
64
+ * Comparing against `webServer.getOrigin()` alone is fragile: that call throws
65
+ * before the listener binds and its spelling (bind host vs request host) can
66
+ * differ from what the browser sends. A browser request carries other
67
+ * trustworthy hints for the same fact — the request URL and the `Host`
68
+ * header — so any of them matching counts as same-origin. A non-browser client
69
+ * sends no `Origin` at all, which is the property the guard actually needs.
70
+ */
71
+ function isSameOrigin(origin, request) {
72
+ let parsed
73
+ try {
74
+ parsed = new URL(origin)
75
+ } catch {
76
+ return false
77
+ }
78
+
79
+ const candidates = []
80
+ if (serverOrigin) candidates.push(serverOrigin)
81
+ if (request.url) candidates.push(request.url)
82
+ const host = request.headers.get('host')
83
+ if (host) candidates.push(`${parsed.protocol}//${host}`)
84
+
85
+ return candidates.some((candidate) => {
86
+ try {
87
+ return new URL(candidate).origin === parsed.origin
88
+ } catch {
89
+ return false
90
+ }
91
+ })
92
+ }
93
+
94
+ /** Record a rejection, so a wrong guard is diagnosable instead of silent. */
95
+ function logRejection(reason, request) {
96
+ try {
97
+ const line = `${JSON.stringify({
98
+ at: new Date().toISOString(),
99
+ reason,
100
+ origin: request.headers.get('origin'),
101
+ host: request.headers.get('host'),
102
+ url: request.url,
103
+ serverOrigin,
104
+ headers: [...request.headers.keys()].sort().join(','),
105
+ })}\n`
106
+ appendFile(join(dataDirectory(state.rawConfig), '.d365', 'write-guard.log'), line).catch(() => {})
107
+ } catch {
108
+ // Diagnostics are best-effort.
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Browser-only guard for anything that changes an environment.
114
+ *
115
+ * Requiring `Origin` alone does not work: the authenticated `/api` channel
116
+ * rewrites the request URL to `http://dsh.internal` and strips `Origin`, so
117
+ * every real browser call arrived without one (see `.d365/write-guard.log`).
118
+ * The page therefore proves itself with an explicit `x-d365-meta-write` header.
119
+ * A cross-site page cannot send that header without a CORS preflight, and these
120
+ * routes never answer a preflight, so the confirmation still cannot be forged
121
+ * from another origin. An `Origin`, when the channel does pass one through,
122
+ * must additionally be same-origin.
123
+ */
124
+ function requiresBrowserRequest(request) {
125
+ const origin = request.headers.get('origin')
126
+ if (origin && !isSameOrigin(origin, request)) {
127
+ logRejection('origin-mismatch', request)
128
+ return json(403, {
129
+ error: '写入只能从设置页发起',
130
+ hint: '请求来自其他站点;请在设置页确认计划。',
131
+ })
132
+ }
133
+ if (request.headers.get(WRITE_HEADER) === WRITE_HEADER_VALUE) return undefined
134
+ logRejection('missing-write-header', request)
135
+ return json(403, {
136
+ error: '写入只能从设置页发起',
137
+ hint: '设置页会带上专用的确认头;命令行与工具无法伪造这次确认。',
138
+ })
139
+ }
140
+
141
+ disposers.push(fetch.register({
142
+ path: `${BASE}/config`,
143
+ methods: ['GET', 'POST'],
144
+ requestBody: 'buffered',
145
+ async fetch(request) {
146
+ try {
147
+ if (request.method === 'GET') {
148
+ return json(200, { settings: await readSettings(state.rawConfig) })
149
+ }
150
+ // This route writes the environment list and credentials: it needs the
151
+ // same same-origin guard as every other write route here.
152
+ const guard = requiresBrowserRequest(request)
153
+ if (guard) return guard
154
+ const body = await request.json().catch(() => {
155
+ throw new D365Error('请求体不是合法 JSON', { code: 'bad-json' })
156
+ })
157
+ const result = await saveSettings(state.rawConfig, body)
158
+ return json(200, { settings: result.settings, secretsTouched: result.secretsTouched })
159
+ } catch (error) {
160
+ return failure(error)
161
+ }
162
+ },
163
+ }))
164
+
165
+ disposers.push(fetch.register({
166
+ path: `${BASE}/settings`,
167
+ methods: ['POST'],
168
+ requestBody: 'buffered',
169
+ async fetch(request) {
170
+ try {
171
+ const guard = requiresBrowserRequest(request)
172
+ if (guard) return guard
173
+ const body = await request.json().catch(() => ({}))
174
+ // Two independent forms share this route so the page can save one
175
+ // section without resending the other.
176
+ if (body?.gitlab) {
177
+ const settings = await saveGitlab(state.rawConfig, body.gitlab)
178
+ return json(200, { settings })
179
+ }
180
+ const settings = await saveGeneral(state.rawConfig, body?.general ?? body ?? {})
181
+ return json(200, { settings })
182
+ } catch (error) {
183
+ return failure(error)
184
+ }
185
+ },
186
+ }))
187
+
188
+ disposers.push(fetch.register({
189
+ path: `${BASE}/gitlab/check`,
190
+ methods: ['POST'],
191
+ requestBody: 'buffered',
192
+ async fetch(request) {
193
+ try {
194
+ const guard = requiresBrowserRequest(request)
195
+ if (guard) return guard
196
+ const body = await request.json().catch(() => null)
197
+ const config = await loadConfig(state.rawConfig)
198
+ // Let the form test values before they are saved.
199
+ const candidate = body
200
+ ? { ...config, gitlab: { ...config.gitlab, ...(body.url ? { url: String(body.url).replace(/\/+$/, '') } : {}) } }
201
+ : config
202
+ return json(200, await checkGitlab(candidate))
203
+ } catch (error) {
204
+ return failure(error)
205
+ }
206
+ },
207
+ }))
208
+
209
+ disposers.push(fetch.register({
210
+ path: `${BASE}/storage`,
211
+ methods: ['GET'],
212
+ // Every exact route must declare a body mode. Without it the channel falls
213
+ // back to streaming and constructs a GET request with a body, which throws
214
+ // and surfaces as a bare HTTP 400 in the page.
215
+ requestBody: 'buffered',
216
+ async fetch() {
217
+ try {
218
+ const config = await loadConfig(state.rawConfig)
219
+ return json(200, { storage: await storageSummary(config) })
220
+ } catch (error) {
221
+ return failure(error)
222
+ }
223
+ },
224
+ }))
225
+
226
+ disposers.push(fetch.register({
227
+ path: `${BASE}/storage/cleanup`,
228
+ methods: ['POST'],
229
+ requestBody: 'buffered',
230
+ async fetch(request) {
231
+ try {
232
+ const guard = requiresBrowserRequest(request)
233
+ if (guard) return guard
234
+ const body = await request.json().catch(() => ({}))
235
+ const config = await loadConfig(state.rawConfig)
236
+ const target = ['snapshots', 'reports', 'diffs', 'query', 'changes', 'all'].includes(body?.target)
237
+ ? body.target
238
+ : 'all'
239
+ const plan = await planCleanup(config, { target })
240
+ if (body?.dryRun !== false) {
241
+ return json(200, {
242
+ dryRun: true,
243
+ plan: {
244
+ target,
245
+ totalFiles: plan.totalFiles,
246
+ totalBytes: plan.totalBytes,
247
+ totalText: formatBytes(plan.totalBytes),
248
+ classes: plan.classes.map((row) => ({
249
+ id: row.id,
250
+ label: row.label,
251
+ count: row.count,
252
+ files: row.remove.length,
253
+ bytes: row.bytes,
254
+ bytesText: formatBytes(row.bytes),
255
+ keep: row.keep,
256
+ maxAgeDays: row.maxAgeDays,
257
+ })),
258
+ },
259
+ storage: await storageSummary(config),
260
+ })
261
+ }
262
+ const applied = await applyCleanup(config, plan)
263
+ return json(200, {
264
+ dryRun: false,
265
+ deleted: applied.deleted.length,
266
+ bytes: applied.bytes,
267
+ bytesText: formatBytes(applied.bytes),
268
+ failures: applied.failures,
269
+ storage: await storageSummary(config),
270
+ })
271
+ } catch (error) {
272
+ return failure(error)
273
+ }
274
+ },
275
+ }))
276
+
277
+ disposers.push(fetch.register({
278
+ path: `${BASE}/storage/compact`,
279
+ methods: ['POST'],
280
+ requestBody: 'buffered',
281
+ async fetch(request) {
282
+ try {
283
+ const guard = requiresBrowserRequest(request)
284
+ if (guard) return guard
285
+ const config = await loadConfig(state.rawConfig)
286
+ const result = await compactAllSnapshots(config)
287
+ return json(200, {
288
+ snapshots: result.snapshots,
289
+ files: result.files,
290
+ savedBytes: result.savedBytes,
291
+ savedText: formatBytes(result.savedBytes),
292
+ storage: await storageSummary(config),
293
+ })
294
+ } catch (error) {
295
+ return failure(error)
296
+ }
297
+ },
298
+ }))
299
+
300
+ disposers.push(fetch.register({
301
+ path: `${BASE}/check`,
302
+ methods: ['POST'],
303
+ requestBody: 'buffered',
304
+ async fetch(request) {
305
+ try {
306
+ const body = await request.json().catch(() => ({}))
307
+ const key = String(body?.key ?? '').trim()
308
+ if (!key) return json(400, { error: '缺少环境 key' })
309
+ return json(200, await checkEnvironment(state.rawConfig, key))
310
+ } catch (error) {
311
+ return failure(error)
312
+ }
313
+ },
314
+ }))
315
+
316
+ disposers.push(fetch.register({
317
+ path: `${BASE}/write/policy`,
318
+ methods: ['GET', 'POST'],
319
+ requestBody: 'buffered',
320
+ async fetch(request) {
321
+ try {
322
+ const config = await loadConfig(state.rawConfig)
323
+ if (request.method === 'GET') {
324
+ const policy = await readPolicy(config.configFile)
325
+ return json(200, {
326
+ policy,
327
+ capabilities: CAPABILITIES,
328
+ environments: Object.keys(config.environments),
329
+ })
330
+ }
331
+ const guard = requiresBrowserRequest(request)
332
+ if (guard) return guard
333
+ const body = await request.json().catch(() => ({}))
334
+ const policy = await savePolicy(config.configFile, body ?? {})
335
+ return json(200, { policy, capabilities: CAPABILITIES, environments: Object.keys(config.environments) })
336
+ } catch (error) {
337
+ return failure(error)
338
+ }
339
+ },
340
+ }))
341
+
342
+ disposers.push(fetch.register({
343
+ path: `${BASE}/write/plans`,
344
+ methods: ['GET'],
345
+ requestBody: 'buffered',
346
+ async fetch() {
347
+ try {
348
+ const config = await loadConfig(state.rawConfig)
349
+ return json(200, {
350
+ plans: await listPlans(config),
351
+ changes: await listChanges(config, 10),
352
+ publishes: await listPublishReports(config),
353
+ })
354
+ } catch (error) {
355
+ return failure(error)
356
+ }
357
+ },
358
+ }))
359
+
360
+ disposers.push(fetch.register({
361
+ path: `${BASE}/write/execute`,
362
+ methods: ['POST'],
363
+ requestBody: 'buffered',
364
+ async fetch(request) {
365
+ try {
366
+ const guard = requiresBrowserRequest(request)
367
+ if (guard) return guard
368
+ const body = await request.json().catch(() => ({}))
369
+ const planId = String(body?.planId ?? '').trim()
370
+ if (!planId) return json(400, { error: '缺少 planId' })
371
+
372
+ const config = await loadConfig(state.rawConfig)
373
+ const plans = await listPlans(config)
374
+ const plan = plans.find((item) => item.id === planId)
375
+ if (!plan) {
376
+ // Executing deletes the plan, so "not found" usually means "already
377
+ // done, look at the page that ran it". The ledger is the only evidence
378
+ // left; consult it before claiming the plan was cancelled.
379
+ const done = (await listChanges(config, 50)).find((entry) => entry.planId === planId)
380
+ if (done) {
381
+ const ok = done.applied?.length ?? 0
382
+ const failed = done.failed?.length ?? 0
383
+ return json(409, {
384
+ error: `该计划已经执行过:成功 ${ok} 个,失败 ${failed} 个`,
385
+ executed: true,
386
+ ok,
387
+ failed,
388
+ publishState: done.publishState,
389
+ })
390
+ }
391
+ return json(404, { error: '计划不存在或已被取消' })
392
+ }
393
+ if (plan.status !== 'pending') {
394
+ const label = plan.status === 'running'
395
+ ? '正在执行,或上次执行中途中断'
396
+ : plan.status === 'failed' ? '上次执行失败' : '已经执行过'
397
+ return json(409, {
398
+ error: `计划${label}`,
399
+ hint: '先核对 .d365/changes 里的执行记录;要再做一次,请取消该计划并重新生成。',
400
+ status: plan.status,
401
+ })
402
+ }
403
+
404
+ // The page is a second front end, not a second policy: the capability is
405
+ // re-checked here so revoking it in the meantime blocks the execution.
406
+ const policy = await readPolicy(config.configFile)
407
+ if (plan.capability && !allows(policy, plan.envKey, plan.capability)) {
408
+ const label = CAPABILITIES[plan.capability]?.label ?? plan.capability
409
+ return json(403, {
410
+ error: `环境 ${plan.envKey} 未授权「${label}」,不能执行这个计划`,
411
+ hint: '在设置页「权限」里勾选该能力后再试。',
412
+ })
413
+ }
414
+
415
+ const result = await executePlan(config, plan, undefined, { force: body?.force === true })
416
+ await deletePlan(config, planId)
417
+ return json(200, result)
418
+ } catch (error) {
419
+ return failure(error)
420
+ }
421
+ },
422
+ }))
423
+
424
+ disposers.push(fetch.register({
425
+ path: `${BASE}/write/publish`,
426
+ methods: ['POST'],
427
+ requestBody: 'buffered',
428
+ async fetch(request) {
429
+ try {
430
+ const guard = requiresBrowserRequest(request)
431
+ if (guard) return guard
432
+ const body = await request.json().catch(() => ({}))
433
+ const planId = String(body?.planId ?? '').trim()
434
+ if (!planId) return json(400, { error: '缺少 planId' })
435
+
436
+ const config = await loadConfig(state.rawConfig)
437
+ const entry = (await listChanges(config, 100)).find((item) => item.planId === planId)
438
+ if (!entry) return json(404, { error: '没有找到这个计划的执行记录' })
439
+ if (!(entry.applied?.length > 0)) return json(400, { error: '这个计划没有成功的写入,不需要发布' })
440
+
441
+ // Publishing the result of a write is still part of that write: if the
442
+ // permission was revoked after the plan ran, the person must not be able
443
+ // to push the change live from the page.
444
+ const policyPath = config.configFile
445
+ const policy = policyPath ? await readPolicy(policyPath) : { environments: {} }
446
+ if (entry.capability && !allows(policy, entry.envKey, entry.capability)) {
447
+ return json(403, {
448
+ error: `环境 ${entry.envKey} 未授权 ${entry.capability},不能发布这次改动`,
449
+ hint: '先到「权限」里重新授权,或在环境里手工发布。',
450
+ })
451
+ }
452
+
453
+ const scope = publishTargets(entry.applied)
454
+ const startedAt = new Date().toISOString()
455
+ const outcome = await publishNow(config, entry.envKey, scope, { force: body?.force === true })
456
+ const publish = {
457
+ planId,
458
+ envKey: entry.envKey,
459
+ startedAt,
460
+ finishedAt: new Date().toISOString(),
461
+ targets: scope,
462
+ republished: true,
463
+ ok: outcome.ok,
464
+ mode: outcome.mode,
465
+ ...(outcome.error ? { error: outcome.error } : {}),
466
+ }
467
+ await writePublishReport(config, planId, publish)
468
+ return json(200, { publish })
469
+ } catch (error) {
470
+ return failure(error)
471
+ }
472
+ },
473
+ }))
474
+
475
+ disposers.push(fetch.register({
476
+ path: `${BASE}/write/cancel`,
477
+ methods: ['POST'],
478
+ requestBody: 'buffered',
479
+ async fetch(request) {
480
+ try {
481
+ const guard = requiresBrowserRequest(request)
482
+ if (guard) return guard
483
+ const body = await request.json().catch(() => ({}))
484
+ const planId = String(body?.planId ?? '').trim()
485
+ if (!planId) return json(400, { error: '缺少 planId' })
486
+ const config = await loadConfig(state.rawConfig)
487
+ const plan = await readPlan(config, planId)
488
+ if (!plan) return json(404, { error: '计划不存在或已被取消' })
489
+ // Cancelling a running plan would hide writes that are still landing: the
490
+ // execution loop has already read the operations it is applying.
491
+ if (plan.status && plan.status !== 'pending') {
492
+ const label = plan.status === 'running'
493
+ ? '正在执行,或上次执行中途中断'
494
+ : plan.status === 'failed' ? '上次执行失败' : '已经执行过'
495
+ return json(409, {
496
+ error: `计划${label},不能取消`,
497
+ hint: '先到 .d365/changes 核对执行记录。',
498
+ status: plan.status,
499
+ })
500
+ }
501
+ const removed = await deletePlan(config, planId)
502
+ return json(200, { removed })
503
+ } catch (error) {
504
+ return failure(error)
505
+ }
506
+ },
507
+ }))
508
+
509
+ return () => {
510
+ for (const dispose of disposers) Promise.resolve(dispose()).catch(() => {})
511
+ }
512
+ }