@dsh-workbench/shared 0.3.10 → 1.0.0-beta.0

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 (391) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +97 -0
  3. package/dist/api/blocker-table.d.ts +3 -3
  4. package/dist/api/blocker-table.js +47 -23
  5. package/dist/api/dsh.d.ts +19 -1
  6. package/dist/api/host-write.d.ts +0 -15
  7. package/dist/api/host-write.js +1 -35
  8. package/dist/api/workbench-client.js +2 -1
  9. package/dist/components/Activity.css +0 -1
  10. package/dist/components/ApprovalDetail.css +1 -2
  11. package/dist/components/ApprovalDetail.d.ts +1 -1
  12. package/dist/components/ApprovalPlan.css +0 -1
  13. package/dist/components/Card.css +0 -1
  14. package/dist/components/Card.d.ts +1 -1
  15. package/dist/components/CardBoundary.d.ts +1 -1
  16. package/dist/components/CardHead.css +0 -1
  17. package/dist/components/CardHead.d.ts +1 -1
  18. package/dist/components/ChoiceGroup.css +1 -2
  19. package/dist/components/ConfirmBar.d.ts +1 -1
  20. package/dist/components/ConfirmBar.js +1 -1
  21. package/dist/components/Form.css +1 -1
  22. package/dist/components/FormActions.js +1 -1
  23. package/dist/components/LongTaskRow.css +1 -2
  24. package/dist/components/LongTaskRow.d.ts +1 -1
  25. package/dist/components/LongTaskRow.js +1 -1
  26. package/dist/components/Note.css +0 -1
  27. package/dist/components/ResourceStates.css +0 -1
  28. package/dist/components/SettingsSection.css +0 -1
  29. package/dist/components/Spinner.css +0 -1
  30. package/dist/components/Stack.css +0 -1
  31. package/dist/components/StateBlock.css +0 -1
  32. package/dist/components/board-module.d.ts +3 -3
  33. package/dist/components/icons.d.ts +1 -7
  34. package/dist/components/icons.js +11 -16
  35. package/dist/config/constants.d.ts +2 -2
  36. package/dist/config/constants.js +2 -2
  37. package/dist/config/storage.d.ts +0 -4
  38. package/dist/config/storage.js +0 -11
  39. package/dist/contract/adapters.d.ts +10 -0
  40. package/dist/contract/cli.d.ts +3 -0
  41. package/dist/contract/data.d.ts +126 -0
  42. package/dist/{api → contract}/facts.d.ts +12 -3
  43. package/dist/{api → contract}/facts.js +19 -1
  44. package/dist/contract/needs.d.ts +132 -0
  45. package/dist/contract/needs.js +232 -0
  46. package/dist/contract/plugin-source.d.ts +1 -0
  47. package/dist/contract/plugin-source.js +1 -0
  48. package/dist/contract/protocol/bundle-install.d.ts +136 -0
  49. package/dist/contract/protocol/bundle-install.js +12 -0
  50. package/dist/contract/protocol/config-sync.d.ts +55 -0
  51. package/dist/contract/protocol/config-sync.js +2 -0
  52. package/dist/contract/protocol/diy.d.ts +4 -0
  53. package/dist/contract/protocol/diy.js +4 -0
  54. package/dist/contract/protocol/dsh-plugins.d.ts +11 -0
  55. package/dist/contract/protocol/dsh-plugins.js +1 -0
  56. package/dist/contract/protocol/long-tasks.d.ts +13 -0
  57. package/dist/contract/protocol/long-tasks.js +1 -0
  58. package/dist/contract/protocol/market-index.d.ts +195 -0
  59. package/dist/contract/protocol/market-index.js +7 -0
  60. package/dist/contract/protocol/platform.d.ts +44 -0
  61. package/dist/contract/protocol/platform.js +1 -0
  62. package/dist/contract/protocol/preflight-wake.d.ts +7 -0
  63. package/dist/contract/protocol/preflight-wake.js +1 -0
  64. package/dist/contract/protocol/profile-create.d.ts +30 -0
  65. package/dist/contract/protocol/profile-create.js +2 -0
  66. package/dist/contract/protocol/sidebar-edition.d.ts +14 -0
  67. package/dist/contract/protocol/sidebar-edition.js +2 -0
  68. package/dist/contract/protocol/unit-manifests.d.ts +6 -0
  69. package/dist/contract/protocol/unit-manifests.js +1 -0
  70. package/dist/contract/unit-board.d.ts +18 -0
  71. package/dist/contract/unit-board.js +1 -0
  72. package/dist/contract/unit-capability.d.ts +34 -0
  73. package/dist/contract/unit-capability.js +1 -0
  74. package/dist/{api → contract}/unit-declaration.d.ts +7 -5
  75. package/dist/{api → contract}/unit-declaration.js +19 -13
  76. package/dist/contract/unit.d.ts +57 -0
  77. package/dist/contract/unit.js +24 -0
  78. package/dist/contract/workbench-contract.d.ts +15 -0
  79. package/dist/contract/workbench-contract.js +1 -0
  80. package/dist/host/adapter-set.d.ts +26 -0
  81. package/dist/host/adapter-set.js +243 -0
  82. package/dist/host/adapter-types.d.ts +79 -0
  83. package/dist/host/adapter-types.js +23 -0
  84. package/dist/host/adapters.d.ts +22 -0
  85. package/dist/host/adapters.js +9 -0
  86. package/dist/host/browser.d.ts +3 -0
  87. package/dist/host/browser.js +3 -0
  88. package/dist/host/changelog.d.ts +21 -0
  89. package/dist/host/changelog.js +188 -0
  90. package/dist/host/channel-plugin.d.ts +15 -0
  91. package/dist/host/channel-plugin.js +42 -0
  92. package/dist/host/cli.d.ts +4 -0
  93. package/dist/host/cli.js +7 -0
  94. package/dist/host/command-name.d.ts +1 -0
  95. package/dist/host/command-name.js +14 -0
  96. package/dist/host/data-error.d.ts +13 -0
  97. package/dist/host/data-error.js +20 -0
  98. package/dist/host/data-root.js +3 -0
  99. package/dist/host/diy-debug.js +2 -1
  100. package/dist/host/diy-record.js +2 -1
  101. package/dist/host/facts.d.ts +1 -1
  102. package/dist/host/facts.js +3 -2
  103. package/dist/host/git.js +2 -2
  104. package/dist/host/http.js +1 -1
  105. package/dist/host/lib-capabilities.d.ts +22 -0
  106. package/dist/host/lib-capabilities.js +37 -0
  107. package/dist/host/lib-install.d.ts +9 -0
  108. package/dist/host/lib-install.js +10 -0
  109. package/dist/host/long-task.d.ts +1 -1
  110. package/dist/host/long-task.js +4 -3
  111. package/dist/host/market-index.d.ts +22 -0
  112. package/dist/host/market-index.js +420 -0
  113. package/dist/host/needs.d.ts +4 -0
  114. package/dist/host/needs.js +2 -0
  115. package/dist/host/patch.d.ts +9 -0
  116. package/dist/host/patch.js +78 -0
  117. package/dist/host/paths.d.ts +12 -0
  118. package/dist/host/paths.js +28 -0
  119. package/dist/host/platform.d.ts +14 -0
  120. package/dist/host/platform.js +40 -0
  121. package/dist/host/plugin-source.d.ts +8 -6
  122. package/dist/host/plugin-source.js +43 -33
  123. package/dist/host/plugin.d.ts +4 -0
  124. package/dist/host/plugin.js +39 -0
  125. package/dist/host/port-types.d.ts +191 -0
  126. package/dist/host/port-types.js +1 -0
  127. package/dist/host/preflight.d.ts +28 -0
  128. package/dist/host/preflight.js +1 -0
  129. package/dist/host/profile-port.d.ts +4 -0
  130. package/dist/host/profile-port.js +4 -0
  131. package/dist/host/provision.d.ts +2 -2
  132. package/dist/host/provision.js +5 -3
  133. package/dist/host/recipe-rules.d.ts +5 -0
  134. package/dist/host/recipe-rules.js +382 -0
  135. package/dist/host/recipe.d.ts +14 -0
  136. package/dist/host/recipe.js +283 -0
  137. package/dist/host/shell-text.d.ts +9 -2
  138. package/dist/host/shell-text.js +15 -3
  139. package/dist/host/shell.d.ts +2 -0
  140. package/dist/host/shell.js +7 -0
  141. package/dist/host/spec.d.ts +8 -0
  142. package/dist/host/spec.js +24 -0
  143. package/dist/host/supply-index.d.ts +34 -0
  144. package/dist/host/supply-index.js +202 -0
  145. package/dist/host/supply-port.d.ts +5 -0
  146. package/dist/host/supply-port.js +39 -0
  147. package/dist/host/time.d.ts +12 -0
  148. package/dist/host/time.js +19 -0
  149. package/dist/host/tokens.d.ts +26 -0
  150. package/dist/host/tokens.js +154 -0
  151. package/dist/host/tool-approval.d.ts +12 -0
  152. package/dist/host/tool-approval.js +4 -0
  153. package/dist/host/tool-kit.js +1 -1
  154. package/dist/host/unit-face.d.ts +5 -4
  155. package/dist/host/version-range.d.ts +2 -0
  156. package/dist/host/version-range.js +73 -0
  157. package/dist/host/version.d.ts +3 -0
  158. package/dist/host/version.js +29 -0
  159. package/dist/host.d.ts +47 -16
  160. package/dist/host.js +47 -16
  161. package/dist/i18n/en.js +259 -86
  162. package/dist/i18n/index.d.ts +333 -88
  163. package/dist/i18n/merge.js +1 -1
  164. package/dist/i18n/need-text.d.ts +26 -0
  165. package/dist/i18n/need-text.js +26 -0
  166. package/dist/i18n/settings.d.ts +70 -0
  167. package/dist/i18n/settings.js +136 -0
  168. package/dist/i18n/zh.d.ts +332 -87
  169. package/dist/i18n/zh.js +260 -87
  170. package/dist/index.d.ts +24 -8
  171. package/dist/index.js +24 -8
  172. package/dist/lib/blocker-action.d.ts +0 -15
  173. package/dist/lib/blocker-action.js +1 -25
  174. package/dist/{api → lib}/dsh-client.d.ts +12 -1
  175. package/dist/lib/dsh-client.js +1 -0
  176. package/dist/lib/market-index.d.ts +7 -0
  177. package/dist/lib/market-index.js +25 -0
  178. package/dist/lib/react-types.d.ts +5 -0
  179. package/dist/lib/react-types.js +1 -0
  180. package/dist/lib/react.d.ts +1 -40
  181. package/dist/lib/react.js +0 -36
  182. package/dist/lib/ui.js +3 -9
  183. package/dist/testing/data.d.ts +13 -0
  184. package/dist/testing/data.js +46 -0
  185. package/dist/testing/declaration.d.ts +1 -1
  186. package/dist/testing/declaration.js +1 -1
  187. package/dist/testing/host-fakes.d.ts +0 -13
  188. package/dist/testing/host-fakes.js +0 -24
  189. package/dist/testing/needs.d.ts +16 -0
  190. package/dist/testing/needs.js +46 -0
  191. package/dist/testing.d.ts +2 -0
  192. package/dist/testing.js +2 -0
  193. package/package.json +18 -2
  194. package/src/api/__tests__/blocker-table.test.ts +135 -86
  195. package/src/api/blocker-table.ts +52 -26
  196. package/src/api/dsh.ts +17 -1
  197. package/src/api/host-write.ts +0 -43
  198. package/src/api/workbench-client.test.ts +2 -2
  199. package/src/api/workbench-client.ts +2 -1
  200. package/src/components/Activity.css +0 -1
  201. package/src/components/ApprovalDetail.css +1 -2
  202. package/src/components/ApprovalDetail.tsx +1 -1
  203. package/src/components/ApprovalPlan.css +0 -1
  204. package/src/components/Card.css +0 -1
  205. package/src/components/Card.tsx +1 -1
  206. package/src/components/CardAnchor.test.tsx +1 -1
  207. package/src/components/CardBoundary.tsx +1 -1
  208. package/src/components/CardHead.css +0 -1
  209. package/src/components/CardHead.tsx +1 -1
  210. package/src/components/ChoiceGroup.css +1 -2
  211. package/src/components/ConfirmBar.tsx +1 -1
  212. package/src/components/Form.css +1 -1
  213. package/src/components/Form.tsx +1 -1
  214. package/src/components/FormActions.tsx +1 -1
  215. package/src/components/LongTaskRow.css +1 -2
  216. package/src/components/LongTaskRow.tsx +1 -1
  217. package/src/components/Note.css +0 -1
  218. package/src/components/ResourceStates.css +0 -1
  219. package/src/components/SelectFieldNodes.tsx +0 -1
  220. package/src/components/SettingsSection.css +0 -1
  221. package/src/components/Spinner.css +0 -1
  222. package/src/components/Stack.css +0 -1
  223. package/src/components/StateBlock.css +0 -1
  224. package/src/components/__tests__/long-task-row.test.ts +1 -1
  225. package/src/components/board-module.ts +3 -3
  226. package/src/components/icons.tsx +16 -24
  227. package/src/config/constants.ts +2 -2
  228. package/src/config/storage.test.ts +1 -25
  229. package/src/config/storage.ts +0 -12
  230. package/src/{api → contract}/__tests__/facts.test.ts +73 -25
  231. package/src/{api → contract}/__tests__/long-task.test.ts +3 -3
  232. package/src/contract/__tests__/needs.test.ts +74 -0
  233. package/src/{host → contract}/__tests__/unit-declaration.test.ts +1 -1
  234. package/src/contract/__tests__/unit.test.ts +43 -0
  235. package/src/contract/adapters.ts +9 -0
  236. package/src/contract/cli.ts +3 -0
  237. package/src/contract/data.ts +145 -0
  238. package/src/{api → contract}/facts.ts +34 -4
  239. package/src/contract/needs.ts +331 -0
  240. package/src/contract/plugin-source.ts +1 -0
  241. package/src/contract/protocol/bundle-install.ts +198 -0
  242. package/src/contract/protocol/config-sync.ts +65 -0
  243. package/src/contract/protocol/diy.ts +4 -0
  244. package/src/contract/protocol/dsh-plugins.ts +13 -0
  245. package/src/contract/protocol/long-tasks.ts +16 -0
  246. package/src/contract/protocol/market-index.ts +246 -0
  247. package/src/contract/protocol/platform.ts +47 -0
  248. package/src/contract/protocol/preflight-wake.ts +17 -0
  249. package/src/contract/protocol/profile-create.ts +36 -0
  250. package/src/contract/protocol/sidebar-edition.ts +19 -0
  251. package/src/contract/protocol/unit-manifests.ts +8 -0
  252. package/src/contract/unit-board.ts +23 -0
  253. package/src/contract/unit-capability.ts +37 -0
  254. package/src/{host → contract}/unit-declaration.ts +22 -18
  255. package/src/contract/unit.ts +76 -0
  256. package/src/contract/workbench-contract.ts +30 -0
  257. package/src/host/__tests__/adapter-set.test.ts +228 -0
  258. package/src/host/__tests__/adapters.test.ts +27 -0
  259. package/src/host/__tests__/changelog.test.ts +102 -0
  260. package/src/host/__tests__/command-name.test.ts +16 -0
  261. package/src/host/__tests__/diy-record.test.ts +3 -3
  262. package/src/host/__tests__/facts.test.ts +5 -5
  263. package/src/host/__tests__/long-task.test.ts +1 -1
  264. package/src/host/__tests__/market-index.test.ts +179 -0
  265. package/src/host/__tests__/plugin-ledger.test.ts +10 -10
  266. package/src/host/__tests__/provision.test.ts +7 -6
  267. package/src/host/__tests__/shell-text.test.ts +16 -1
  268. package/src/host/__tests__/supply-index.test.ts +137 -0
  269. package/src/host/__tests__/tokens.test.ts +110 -0
  270. package/src/host/__tests__/version-range.test.ts +21 -0
  271. package/src/host/adapter-set.ts +298 -0
  272. package/src/host/adapter-types.ts +105 -0
  273. package/src/host/adapters.ts +44 -0
  274. package/src/host/browser.ts +3 -0
  275. package/src/host/changelog.ts +212 -0
  276. package/src/host/channel-plugin.ts +55 -0
  277. package/src/host/cli.ts +11 -0
  278. package/src/host/command-name.ts +15 -0
  279. package/src/host/data-error.ts +21 -0
  280. package/src/host/data-root.ts +2 -0
  281. package/src/host/diy-debug.ts +2 -1
  282. package/src/host/diy-record.ts +2 -1
  283. package/src/host/facts.ts +4 -3
  284. package/src/host/git.ts +2 -2
  285. package/src/host/http.ts +1 -1
  286. package/src/host/lib-capabilities.ts +52 -0
  287. package/src/host/lib-install.ts +18 -0
  288. package/src/host/long-task.ts +4 -3
  289. package/src/host/market-index.ts +413 -0
  290. package/src/host/needs.ts +6 -0
  291. package/src/host/patch.ts +93 -0
  292. package/src/host/paths.ts +37 -0
  293. package/src/host/platform.ts +48 -0
  294. package/src/host/plugin-source.ts +48 -56
  295. package/src/host/plugin.ts +39 -0
  296. package/src/host/port-types.ts +222 -0
  297. package/src/host/preflight.ts +31 -0
  298. package/src/host/profile-port.ts +9 -0
  299. package/src/host/provision.ts +7 -4
  300. package/src/host/recipe-rules.ts +330 -0
  301. package/src/host/recipe.test.ts +334 -0
  302. package/src/host/recipe.ts +323 -0
  303. package/src/host/shell-text.ts +15 -3
  304. package/src/host/shell.ts +9 -0
  305. package/src/host/spec.ts +32 -0
  306. package/src/host/supply-index.ts +256 -0
  307. package/src/host/supply-port.ts +45 -0
  308. package/src/host/time.ts +22 -0
  309. package/src/host/tokens.ts +186 -0
  310. package/src/host/tool-approval.ts +17 -0
  311. package/src/host/tool-kit.ts +1 -1
  312. package/src/host/unit-face.ts +5 -4
  313. package/src/host/version-range.ts +71 -0
  314. package/src/host/version.ts +30 -0
  315. package/src/host.ts +47 -16
  316. package/src/i18n/en.ts +261 -86
  317. package/src/i18n/merge.test.ts +6 -6
  318. package/src/i18n/merge.ts +1 -1
  319. package/src/i18n/need-text.ts +27 -0
  320. package/src/i18n/settings.ts +141 -0
  321. package/src/i18n/zh.ts +263 -87
  322. package/src/index.ts +24 -8
  323. package/src/lib/blocker-action.ts +0 -28
  324. package/src/{api → lib}/dsh-client.ts +16 -2
  325. package/src/lib/market-index.ts +36 -0
  326. package/src/lib/react-types.ts +5 -0
  327. package/src/lib/react.ts +1 -40
  328. package/src/lib/text.test.ts +2 -2
  329. package/src/lib/ui.test.ts +6 -5
  330. package/src/lib/ui.ts +3 -9
  331. package/src/testing/__tests__/declaration.test.ts +1 -1
  332. package/src/testing/data.ts +66 -0
  333. package/src/testing/declaration.ts +2 -2
  334. package/src/testing/host-fakes.ts +0 -38
  335. package/src/testing/needs.ts +80 -0
  336. package/src/testing.ts +2 -0
  337. package/dist/api/unit-board.d.ts +0 -39
  338. package/dist/api/unit-board.js +0 -45
  339. package/dist/api/unit.d.ts +0 -85
  340. package/dist/api/unit.js +0 -8
  341. package/dist/api/workbench-contract.d.ts +0 -44
  342. package/dist/components/MockStrip.css +0 -39
  343. package/dist/components/MockStrip.d.ts +0 -15
  344. package/dist/components/MockStrip.js +0 -8
  345. package/dist/components/SaveBar.css +0 -7
  346. package/dist/host/browser-find.d.ts +0 -38
  347. package/dist/host/browser-find.js +0 -242
  348. package/dist/host/browser-read.d.ts +0 -28
  349. package/dist/host/browser-read.js +0 -243
  350. package/dist/host/opencli-runtime.d.ts +0 -25
  351. package/dist/host/opencli-runtime.js +0 -56
  352. package/dist/host/opencli-window.d.ts +0 -2
  353. package/dist/host/opencli-window.js +0 -8
  354. package/dist/host/opencli.d.ts +0 -37
  355. package/dist/host/opencli.js +0 -155
  356. package/dist/host/unit-declaration.d.ts +0 -65
  357. package/dist/host/unit-declaration.js +0 -213
  358. package/dist/host/unit-libs.d.ts +0 -37
  359. package/dist/lib/diy-debug.d.ts +0 -80
  360. package/dist/lib/diy-debug.js +0 -88
  361. package/dist/lib/diy-record.d.ts +0 -135
  362. package/dist/lib/diy-record.js +0 -162
  363. package/dist/lib/lint-probe.d.ts +0 -1
  364. package/dist/lib/lint-probe.js +0 -6
  365. package/dist/lib/panel-state.d.ts +0 -120
  366. package/dist/lib/panel-state.js +0 -176
  367. package/src/api/__tests__/host-write.test.ts +0 -75
  368. package/src/api/__tests__/unit-board.test.ts +0 -76
  369. package/src/api/__tests__/unit.test.ts +0 -38
  370. package/src/api/unit-board.ts +0 -69
  371. package/src/api/unit.ts +0 -93
  372. package/src/api/workbench-contract.ts +0 -59
  373. package/src/host/__tests__/browser-find.test.ts +0 -330
  374. package/src/host/__tests__/browser-read.test.ts +0 -228
  375. package/src/host/__tests__/opencli-runtime.test.ts +0 -46
  376. package/src/host/browser-find.ts +0 -311
  377. package/src/host/browser-read.ts +0 -311
  378. package/src/host/opencli-runtime.ts +0 -79
  379. package/src/host/opencli-window.ts +0 -11
  380. package/src/host/opencli.ts +0 -213
  381. package/src/host/unit-libs.ts +0 -41
  382. package/src/lib/blocker-action.test.ts +0 -40
  383. /package/dist/{api/blocker.js → contract/adapters.js} +0 -0
  384. /package/dist/{api → contract}/blocker.d.ts +0 -0
  385. /package/dist/{api/dsh-client.js → contract/blocker.js} +0 -0
  386. /package/dist/{api/workbench-contract.js → contract/cli.js} +0 -0
  387. /package/dist/{host/unit-libs.js → contract/data.js} +0 -0
  388. /package/dist/{api → contract}/long-task.d.ts +0 -0
  389. /package/dist/{api → contract}/long-task.js +0 -0
  390. /package/src/{api → contract}/blocker.ts +0 -0
  391. /package/src/{api → contract}/long-task.ts +0 -0
@@ -0,0 +1,330 @@
1
+ import type { CallOverrides, CredentialSource, RecipeAuth, RequestRecipe } from '../contract/data.js';
2
+
3
+ const METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
4
+ const RESPONSE_KINDS = ['json', 'text'];
5
+ const CONTENT_TYPES = ['json', 'form'];
6
+ const CREDENTIAL_HEADERS = [
7
+ 'authorization',
8
+ 'cookie',
9
+ 'x-api-key',
10
+ 'x-auth-token',
11
+ 'x-csrf-token',
12
+ 'x-xsrf-token',
13
+ ];
14
+ export const DEFAULT_RECIPE_MAX_BYTES = 2 * 1024 * 1024;
15
+ export const MAX_RECIPE_CALLS = 8;
16
+ const MAX_MAX_BYTES = 8 * 1024 * 1024;
17
+
18
+
19
+ function recordOf(value: unknown): Record<string, unknown> | undefined {
20
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
21
+ ? (value as Record<string, unknown>)
22
+ : undefined;
23
+ }
24
+
25
+ function textOf(value: unknown): string {
26
+ return typeof value === 'string' ? value.trim() : '';
27
+ }
28
+
29
+ function httpOriginProblem(value: unknown): string | undefined {
30
+ const text = textOf(value);
31
+ if (text === '') return 'origin 不能为空';
32
+ try {
33
+ const url = new URL(text);
34
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return 'origin 只认 http(s)';
35
+ if (url.origin !== text) return 'origin 只写到域(不带路径、不带尾斜杠)';
36
+ } catch {
37
+ return `origin 不是合法地址:${text}`;
38
+ }
39
+ return undefined;
40
+ }
41
+
42
+ function relativePathProblem(value: unknown, label: string): string | undefined {
43
+ const text = textOf(value);
44
+ if (text === '') return `${label} 不能为空`;
45
+ if (text.includes('\\')) return `${label} 里不许有反斜杠`;
46
+ if (text.startsWith('//')) return `${label} 不许用协议相对地址`;
47
+ if (text.includes('?') || text.includes('#')) return `${label} 不带查询 / 锚点,参数走 query`;
48
+ if (!text.startsWith('/')) return `${label} 要以 / 开头`;
49
+ return undefined;
50
+ }
51
+
52
+ function absoluteUrlProblem(value: unknown, label: string): string | undefined {
53
+ const text = textOf(value);
54
+ if (text === '') return `${label} 不能为空`;
55
+ try {
56
+ const url = new URL(text);
57
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return `${label} 只认 http(s)`;
58
+ } catch {
59
+ return `${label} 不是合法地址:${text}`;
60
+ }
61
+ return undefined;
62
+ }
63
+
64
+ function requestPathProblem(value: unknown, origin: string, label: string): string | undefined {
65
+ const text = textOf(value);
66
+ const relative = relativePathProblem(value, label);
67
+ if (relative === undefined) return undefined;
68
+ if (text.startsWith('/') || text.startsWith('//') || text.includes('\\') || text.includes('?') || text.includes('#')) {
69
+ return relative;
70
+ }
71
+ try {
72
+ const url = new URL(text);
73
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return `${label} 只认 http(s)`;
74
+ if (url.origin !== origin) return `${label} 要和 origin 同源`;
75
+ } catch {
76
+ return `${label} 要以 / 开头,或者是同源的绝对地址`;
77
+ }
78
+ return undefined;
79
+ }
80
+
81
+ function resolvedUrlProblem(value: unknown, origin: string, label: string): string | undefined {
82
+ const text = textOf(value);
83
+ if (text === '') return `${label} 不能为空`;
84
+ if (text.startsWith('//')) return `${label} 不许用协议相对地址`;
85
+ return absoluteUrlProblem(text.startsWith('/') ? `${origin}${text}` : text, label);
86
+ }
87
+
88
+ function queryProblem(value: unknown): string | undefined {
89
+ if (value === undefined) return undefined;
90
+ const map = recordOf(value);
91
+ if (map === undefined) return 'query 要是对象';
92
+ for (const [name, entry] of Object.entries(map)) {
93
+ if (name.trim() === '') return 'query 的键不能为空';
94
+ if (typeof entry !== 'string' && typeof entry !== 'number' && typeof entry !== 'boolean') {
95
+ return `query.${name} 只能是字符串 / 数字 / 布尔`;
96
+ }
97
+ }
98
+ return undefined;
99
+ }
100
+
101
+ function headersProblem(value: unknown): string | undefined {
102
+ if (value === undefined) return undefined;
103
+ const map = recordOf(value);
104
+ if (map === undefined) return 'headers 要是对象';
105
+ for (const [name, entry] of Object.entries(map)) {
106
+ if (name.trim() === '') return 'headers 的键不能为空';
107
+ if (typeof entry !== 'string') return `headers.${name} 要是字符串`;
108
+ if (CREDENTIAL_HEADERS.includes(name.toLowerCase())) {
109
+ return `headers 里不许写凭据头 ${name} —— 凭据走 auth.place`;
110
+ }
111
+ }
112
+ return undefined;
113
+ }
114
+
115
+ function bodyProblem(value: unknown, contentType: string): string | undefined {
116
+ if (value === undefined) return undefined;
117
+ if (contentType === 'form') {
118
+ const map = recordOf(value);
119
+ if (map === undefined) return 'contentType: form 时 body 要是对象';
120
+ for (const [name, entry] of Object.entries(map)) {
121
+ if (typeof entry !== 'string') return `contentType: form 时 body.${name} 要是字符串`;
122
+ }
123
+ return undefined;
124
+ }
125
+ try {
126
+ JSON.stringify(value);
127
+ } catch {
128
+ return 'body 要能 JSON 序列化';
129
+ }
130
+ return undefined;
131
+ }
132
+
133
+ function sourceProblem(value: unknown, origin: string): string | undefined {
134
+ const source = recordOf(value);
135
+ if (source === undefined) return 'auth.source 要是对象';
136
+ const kind = textOf(source['kind']);
137
+ if (kind === 'cookie') return undefined;
138
+ if (kind === 'cookieValue') {
139
+ return textOf(source['name']) === '' ? 'auth.source.name 不能为空' : undefined;
140
+ }
141
+ if (kind === 'meta') {
142
+ return textOf(source['name']) === '' ? 'auth.source.name 不能为空' : undefined;
143
+ }
144
+ if (kind === 'global') {
145
+ return textOf(source['path']) === '' ? 'auth.source.path 不能为空' : undefined;
146
+ }
147
+ if (kind === 'storage') {
148
+ if (textOf(source['area']) !== 'local' && textOf(source['area']) !== 'session') {
149
+ return 'auth.source.area 只认 local / session';
150
+ }
151
+ return textOf(source['key']) === '' ? 'auth.source.key 不能为空' : undefined;
152
+ }
153
+ if (kind === 'endpoint') {
154
+ const urlIssue = resolvedUrlProblem(source['url'], origin, 'auth.source.url');
155
+ if (urlIssue !== undefined) return urlIssue;
156
+ const method = textOf(source['method']);
157
+ if (method !== '' && method !== 'GET' && method !== 'POST') {
158
+ return 'auth.source.method 只认 GET / POST';
159
+ }
160
+ if (textOf(source['path']) === '') return 'auth.source.path(token 在返回里的位置)不能为空';
161
+ return bodyProblem(source['body'], 'json');
162
+ }
163
+ return `不认识的 auth.source.kind:${kind === '' ? '(空)' : kind}`;
164
+ }
165
+
166
+ function placeProblem(value: unknown, source: CredentialSource | undefined): string | undefined {
167
+ const place = recordOf(value);
168
+ if (place === undefined) return 'auth.place 要是对象';
169
+ const where = textOf(place['in']);
170
+ if (where === 'cookie') {
171
+ if (source !== undefined && source.kind !== 'cookie') {
172
+ return '凭据不是 cookie,place.in 不能是 cookie';
173
+ }
174
+ return undefined;
175
+ }
176
+ if (where === 'header' || where === 'query') {
177
+ if (textOf(place['name']) === '') return 'auth.place.name 不能为空';
178
+ if (source !== undefined && source.kind === 'cookie') {
179
+ return 'cookie 来源没有 token 可放,place.in 要写 cookie';
180
+ }
181
+ return undefined;
182
+ }
183
+ if (where === 'body') {
184
+ if (textOf(place['field']) === '') return 'auth.place.field 不能为空';
185
+ if (source !== undefined && source.kind === 'cookie') {
186
+ return 'cookie 来源没有 token 可放,place.in 要写 cookie';
187
+ }
188
+ return undefined;
189
+ }
190
+ return `不认识的 auth.place.in:${where === '' ? '(空)' : where}`;
191
+ }
192
+
193
+ function authProblem(value: unknown, origin: string): string | undefined {
194
+ if (value === undefined) return undefined;
195
+ const auth = recordOf(value);
196
+ if (auth === undefined) return 'auth 要是对象';
197
+ if (textOf(auth['kind']) === 'none') {
198
+ if (auth['source'] !== undefined || auth['place'] !== undefined) {
199
+ return 'auth.kind: none 时不许带 source / place';
200
+ }
201
+ return undefined;
202
+ }
203
+ if (auth['kind'] !== undefined) return 'auth.kind 只认 none';
204
+ const sourceIssue = sourceProblem(auth['source'], origin);
205
+ if (sourceIssue !== undefined) return sourceIssue;
206
+ return placeProblem(auth['place'], auth['source'] as CredentialSource);
207
+ }
208
+
209
+ function maxBytesProblem(value: unknown): string | undefined {
210
+ if (value === undefined) return undefined;
211
+ if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) return 'response.maxBytes 要是正整数';
212
+ if (value > MAX_MAX_BYTES) return `response.maxBytes 最大 ${MAX_MAX_BYTES}`;
213
+ return undefined;
214
+ }
215
+
216
+ function responseProblem(value: unknown): string | undefined {
217
+ if (value === undefined) return undefined;
218
+ const response = recordOf(value);
219
+ if (response === undefined) return 'response 要是对象';
220
+ const kind = textOf(response['kind']);
221
+ if (!RESPONSE_KINDS.includes(kind)) return `response.kind 只认 ${RESPONSE_KINDS.join(' / ')}`;
222
+ if (response['path'] !== undefined && textOf(response['path']) === '') return 'response.path 不能为空';
223
+ return maxBytesProblem(response['maxBytes']);
224
+ }
225
+
226
+ function responseOverrideProblem(value: unknown): string | undefined {
227
+ if (value === undefined) return undefined;
228
+ const response = recordOf(value);
229
+ if (response === undefined) return 'response 要是对象';
230
+ if (response['kind'] !== undefined && !RESPONSE_KINDS.includes(textOf(response['kind']))) {
231
+ return `response.kind 只认 ${RESPONSE_KINDS.join(' / ')}`;
232
+ }
233
+ if (response['path'] !== undefined && textOf(response['path']) === '') return 'response.path 不能为空';
234
+ return maxBytesProblem(response['maxBytes']);
235
+ }
236
+
237
+ function authOf(recipe: RequestRecipe): RecipeAuth {
238
+ return recipe.auth ?? { source: { kind: 'cookie' }, place: { in: 'cookie' } };
239
+ }
240
+
241
+ function overridesProblem(recipe: RequestRecipe, overrides: CallOverrides): string | undefined {
242
+ if (overrides.path !== undefined) {
243
+ const pathIssue = requestPathProblem(overrides.path, recipe.origin, 'path');
244
+ if (pathIssue !== undefined) return pathIssue;
245
+ }
246
+ const queryIssue = queryProblem(overrides.query);
247
+ if (queryIssue !== undefined) return queryIssue;
248
+ const headersIssue = headersProblem(overrides.headers);
249
+ if (headersIssue !== undefined) return headersIssue;
250
+ const responseIssue = responseOverrideProblem(overrides.response);
251
+ if (responseIssue !== undefined) return responseIssue;
252
+ const contentType = recipe.request.contentType ?? 'json';
253
+ const bodyIssue = bodyProblem(overrides.body, contentType);
254
+ if (bodyIssue !== undefined) return bodyIssue;
255
+ if (overrides.body !== undefined && recipe.request.method === 'GET') {
256
+ return 'GET 不带 body';
257
+ }
258
+ const auth = authOf(recipe);
259
+ const place = auth.kind === 'none' ? undefined : auth.place;
260
+ if (place?.in === 'body' && overrides.body !== undefined && recordOf(overrides.body) === undefined) {
261
+ return 'auth.place 是 body 时 overrides.body 要是对象';
262
+ }
263
+ return undefined;
264
+ }
265
+
266
+
267
+ export function recipeProblemOf(recipe: unknown, overrides?: CallOverrides): string | undefined {
268
+ const box = recordOf(recipe);
269
+ if (box === undefined) return '配方要是对象';
270
+ const originIssue = httpOriginProblem(box['origin']);
271
+ if (originIssue !== undefined) return originIssue;
272
+ const origin = textOf(box['origin']);
273
+ if (box['loginUrl'] !== undefined) {
274
+ const loginIssue = absoluteUrlProblem(box['loginUrl'], 'loginUrl');
275
+ if (loginIssue !== undefined) return loginIssue;
276
+ }
277
+ if (box['session'] !== undefined && textOf(box['session']) === '') return 'session 不能为空';
278
+ if (box['timeoutMs'] !== undefined) {
279
+ const timeout = box['timeoutMs'];
280
+ if (typeof timeout !== 'number' || !Number.isInteger(timeout) || timeout <= 0) {
281
+ return 'timeoutMs 要是正整数';
282
+ }
283
+ }
284
+ const request = recordOf(box['request']);
285
+ if (request === undefined) return 'request 要是对象';
286
+ const method = textOf(request['method']);
287
+ if (!METHODS.includes(method)) return `request.method 只认 ${METHODS.join(' / ')}`;
288
+ const pathIssue = requestPathProblem(request['path'], origin, 'request.path');
289
+ if (pathIssue !== undefined) return pathIssue;
290
+ const queryIssue = queryProblem(request['query']);
291
+ if (queryIssue !== undefined) return queryIssue;
292
+ const headersIssue = headersProblem(request['headers']);
293
+ if (headersIssue !== undefined) return headersIssue;
294
+ const contentType = textOf(request['contentType']);
295
+ if (contentType !== '' && !CONTENT_TYPES.includes(contentType)) {
296
+ return `request.contentType 只认 ${CONTENT_TYPES.join(' / ')}`;
297
+ }
298
+ const bodyIssue = bodyProblem(request['body'], contentType === 'form' ? 'form' : 'json');
299
+ if (bodyIssue !== undefined) return bodyIssue;
300
+ if (request['body'] !== undefined && method === 'GET') return 'GET 不带 body';
301
+ const authIssue = authProblem(box['auth'], origin);
302
+ if (authIssue !== undefined) return authIssue;
303
+ const auth = recordOf(box['auth']);
304
+ const place = auth === undefined ? undefined : recordOf(auth['place']);
305
+ if (place !== undefined && textOf(place['in']) === 'body' && request['body'] !== undefined) {
306
+ if (recordOf(request['body']) === undefined) return 'auth.place 是 body 时 request.body 要是对象';
307
+ }
308
+ const responseIssue = responseProblem(box['response']);
309
+ if (responseIssue !== undefined) return responseIssue;
310
+ if (overrides === undefined) return undefined;
311
+ return overridesProblem(recipe as RequestRecipe, overrides);
312
+ }
313
+
314
+ export function callsProblemOf(recipe: unknown, calls: unknown): string | undefined {
315
+ const recipeIssue = recipeProblemOf(recipe);
316
+ if (recipeIssue !== undefined) return recipeIssue;
317
+ const box = recordOf(calls);
318
+ if (box === undefined) return 'calls 要是对象,键就是结果名';
319
+ const names = Object.keys(box);
320
+ if (names.length === 0) return 'calls 不能为空';
321
+ if (names.length > MAX_RECIPE_CALLS) return `一次最多 ${MAX_RECIPE_CALLS} 条 calls`;
322
+ for (const name of names) {
323
+ if (name.trim() === '') return 'calls 的键不能为空';
324
+ if (recordOf(box[name]) === undefined) return `calls.${name} 要是对象`;
325
+ const issue = overridesProblem(recipe as RequestRecipe, box[name] as CallOverrides);
326
+ if (issue !== undefined) return `calls.${name}:${issue}`;
327
+ }
328
+ return undefined;
329
+ }
330
+
@@ -0,0 +1,334 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { CallOverrides, RequestRecipe } from '../contract/data.js';
3
+ import { describeRecipe, scriptOf, scriptOfMany } from './recipe.js';
4
+ import { callsProblemOf, MAX_RECIPE_CALLS, recipeProblemOf } from './recipe-rules.js';
5
+
6
+ const BASE: RequestRecipe = {
7
+ origin: 'https://x.example.com',
8
+ request: { method: 'GET', path: '/api/balance' },
9
+ };
10
+
11
+ describe('scriptOf · 六种配方', () => {
12
+ it('公开:只判 origin 再 fetch,不带 token', () => {
13
+ const script = scriptOf({ ...BASE, auth: { kind: 'none' } });
14
+ expect(script.startsWith('(async () => {')).toBe(true);
15
+ expect(script.endsWith('})()')).toBe(true);
16
+ expect(script).toContain('if (location.origin !== "https://x.example.com")');
17
+ expect(script).toContain('let url = "https://x.example.com/api/balance";');
18
+ expect(script).toContain('credentials: "include"');
19
+ expect(script).not.toContain('token');
20
+ });
21
+
22
+ it('cookie 是默认值:不换 token、不加头', () => {
23
+ const script = scriptOf(BASE);
24
+ expect(script).not.toContain('document.cookie');
25
+ expect(script).not.toContain('const token');
26
+ expect(script).toContain('const headers = {};');
27
+ });
28
+
29
+ it('bearer-refresh:先 POST 换 token,再按路径取', () => {
30
+ const script = scriptOf({
31
+ ...BASE,
32
+ auth: {
33
+ source: {
34
+ kind: 'endpoint',
35
+ url: '/api/user/auth/refresh',
36
+ method: 'POST',
37
+ body: {},
38
+ path: 'data.access_token',
39
+ },
40
+ place: { in: 'header', name: 'authorization', prefix: 'Bearer ' },
41
+ },
42
+ });
43
+ expect(script).toContain('fetch("https://x.example.com/api/user/auth/refresh"');
44
+ expect(script).toContain('const token = pick(preJson, "data.access_token");');
45
+ expect(script).toContain('headers["authorization"] = "Bearer " + token;');
46
+ expect(script).toContain('if (pre.status === 401 || pre.status === 403)');
47
+ });
48
+
49
+ it('CSRF 从 meta 取,放到头里', () => {
50
+ const script = scriptOf({
51
+ ...BASE,
52
+ auth: { source: { kind: 'meta', name: 'csrf-token' }, place: { in: 'header', name: 'X-CSRF-Token' } },
53
+ });
54
+ expect(script).toContain('document.querySelector("meta[name=\\"csrf-token\\"]")');
55
+ expect(script).toContain('headers["X-CSRF-Token"] = "" + token;');
56
+ });
57
+
58
+ it('CSRF 从 cookie 取,先 decodeURIComponent', () => {
59
+ const script = scriptOf({
60
+ ...BASE,
61
+ auth: { source: { kind: 'cookieValue', name: 'XSRF-TOKEN' }, place: { in: 'header', name: 'X-XSRF-TOKEN' } },
62
+ });
63
+ expect(script).toContain('document.cookie.split("; ")');
64
+ expect(script).toContain('decodeURIComponent(hit.slice(name.length + 1))');
65
+ });
66
+
67
+ it('页面全局变量与 storage 两种来源', () => {
68
+ const global = scriptOf({
69
+ ...BASE,
70
+ auth: { source: { kind: 'global', path: '__INITIAL_STATE__.token' }, place: { in: 'header', name: 'authorization', prefix: 'Bearer ' } },
71
+ });
72
+ expect(global).toContain('const token = pick(globalThis, "__INITIAL_STATE__.token");');
73
+
74
+ const storage = scriptOf({
75
+ ...BASE,
76
+ auth: {
77
+ source: { kind: 'storage', area: 'session', key: 'token', path: 'access_token' },
78
+ place: { in: 'header', name: 'authorization', prefix: 'Bearer ' },
79
+ },
80
+ });
81
+ expect(storage).toContain('sessionStorage.getItem("token")');
82
+ expect(storage).toContain('const token = pick(parsed, "access_token");');
83
+ });
84
+
85
+ it('token 放 query / body 的两种 place', () => {
86
+ const query = scriptOf({
87
+ ...BASE,
88
+ auth: { source: { kind: 'cookieValue', name: 'sid' }, place: { in: 'query', name: 'token' } },
89
+ });
90
+ expect(query).toContain('url += (url.indexOf("?") === -1 ? "?" : "&") + "token="');
91
+ expect(query).toContain('encodeURIComponent(token)');
92
+
93
+ const body = scriptOf({
94
+ ...BASE,
95
+ request: { method: 'POST', path: '/api/do', body: { a: 1 } },
96
+ auth: { source: { kind: 'cookieValue', name: 'sid' }, place: { in: 'body', field: 'meta.token' } },
97
+ });
98
+ expect(body).toContain('set(body, "meta.token", token);');
99
+ });
100
+
101
+ it('form 请求体编成 URLSearchParams,并补 content-type', () => {
102
+ const script = scriptOf({
103
+ ...BASE,
104
+ request: { method: 'POST', path: '/api/issues', contentType: 'form', body: { title: 'a' } },
105
+ auth: { source: { kind: 'meta', name: 'csrf' }, place: { in: 'body', field: 'csrf' } },
106
+ });
107
+ expect(script).toContain('new URLSearchParams({"title":"a"})');
108
+ expect(script).toContain('body.set("csrf", token);');
109
+ expect(script).toContain('"content-type":"application/x-www-form-urlencoded"');
110
+ });
111
+
112
+ it('response: text 直接回文本;json 带 path 时取那一格', () => {
113
+ const text = scriptOf({ ...BASE, response: { kind: 'text' } });
114
+ expect(text).toContain('return { ok: true, text };');
115
+
116
+ const json = scriptOf({ ...BASE, response: { kind: 'json', path: 'data.list' } });
117
+ expect(json).toContain('const value = pick(data, "data.list");');
118
+ expect(json).toContain('if (value === undefined)');
119
+ });
120
+
121
+ it('overrides 合并 query 与 body,不污染配方', () => {
122
+ const recipe: RequestRecipe = {
123
+ ...BASE,
124
+ request: { method: 'POST', path: '/api/do', query: { a: '1' }, body: { x: 1 } },
125
+ };
126
+ const script = scriptOf(recipe, { query: { b: '2' }, body: { x: 2 } });
127
+ expect(script).toContain('?a=1&b=2');
128
+ expect(script).toContain('let body = {"x":2};');
129
+ expect(recipe.request.body).toEqual({ x: 1 });
130
+ });
131
+
132
+ it('JSON 请求体在页面里 stringify,不把对象直接交给 fetch', () => {
133
+ const script = scriptOf({ ...BASE, request: { method: 'POST', path: '/api/do', body: { a: 1 } } });
134
+ expect(script).toContain('let body = {"a":1};');
135
+ expect(script).toContain('if (typeof body !== "string") body = JSON.stringify(body);');
136
+ });
137
+
138
+ it('换登录态那一次请求的 body 也 stringify', () => {
139
+ const script = scriptOf({
140
+ ...BASE,
141
+ auth: {
142
+ source: { kind: 'endpoint', url: '/r', method: 'POST', body: { a: 1 }, path: 't' },
143
+ place: { in: 'header', name: 'x-token' },
144
+ },
145
+ });
146
+ expect(script).toContain('body: JSON.stringify({"a":1})');
147
+ });
148
+
149
+ it('返回值里不带凭据', () => {
150
+ const script = scriptOf({
151
+ ...BASE,
152
+ auth: { source: { kind: 'global', path: 'token' }, place: { in: 'header', name: 'authorization' } },
153
+ });
154
+ expect(script).not.toContain('return { ok: true, token');
155
+ expect(script).toContain('return { ok: true, data };');
156
+ });
157
+ });
158
+
159
+ describe('describeRecipe', () => {
160
+ it('回 method / url / body / 凭据位置,不回 token', () => {
161
+ const described = describeRecipe(
162
+ {
163
+ ...BASE,
164
+ request: { method: 'GET', path: '/api/balance', query: { limit: 5 } },
165
+ auth: { source: { kind: 'cookieValue', name: 'sid' }, place: { in: 'header', name: 'authorization', prefix: 'Bearer ' } },
166
+ },
167
+ { query: { limit: 10 } },
168
+ );
169
+ expect(described).toEqual({
170
+ method: 'GET',
171
+ url: 'https://x.example.com/api/balance?limit=10',
172
+ auth: { source: 'cookieValue', place: 'header', name: 'authorization' },
173
+ });
174
+ });
175
+
176
+ it('没有 auth 时不出 auth 字段;body 原样回', () => {
177
+ const described = describeRecipe({ ...BASE, request: { method: 'POST', path: '/api/do', body: { a: 1 } } });
178
+ expect(described.body).toEqual({ a: 1 });
179
+ expect(described.auth).toEqual({ source: 'cookie', place: 'cookie' });
180
+ });
181
+ });
182
+
183
+ describe('recipeProblemOf · 校验', () => {
184
+ const bad = (recipe: unknown): string | undefined => recipeProblemOf(recipe);
185
+
186
+ it('origin 只收裸 origin', () => {
187
+ expect(bad({ ...BASE, origin: 'https://x.example.com/' })).toContain('origin');
188
+ expect(bad({ ...BASE, origin: 'ftp://x.example.com' })).toContain('http');
189
+ expect(bad({ ...BASE, origin: '' })).toContain('origin');
190
+ });
191
+
192
+ it('path 不许带查询 / 反斜杠 / 协议相对;绝对地址要同源', () => {
193
+ expect(bad({ ...BASE, request: { method: 'GET', path: '/a?b=1' } })).toContain('查询');
194
+ expect(bad({ ...BASE, request: { method: 'GET', path: '/a\\b' } })).toContain('反斜杠');
195
+ expect(bad({ ...BASE, request: { method: 'GET', path: '//evil.test/a' } })).toContain('协议相对');
196
+ expect(bad({ ...BASE, request: { method: 'GET', path: 'https://evil.test/a' } })).toContain('同源');
197
+ expect(bad({ ...BASE, request: { method: 'GET', path: 'https://x.example.com/a' } })).toBeUndefined();
198
+ });
199
+
200
+ it('method / contentType / response.kind 只认枚举', () => {
201
+ expect(bad({ ...BASE, request: { method: 'HEAD', path: '/a' } })).toContain('request.method');
202
+ expect(bad({ ...BASE, request: { method: 'GET', path: '/a', contentType: 'xml' } })).toContain('contentType');
203
+ expect(bad({ ...BASE, response: { kind: 'xml' } })).toContain('response.kind');
204
+ });
205
+
206
+ it('凭据头写死 → 红(request.headers 与 overrides 都拦)', () => {
207
+ expect(bad({ ...BASE, request: { method: 'GET', path: '/a', headers: { Authorization: 'Bearer x' } } })).toContain('auth.place');
208
+ expect(() => scriptOf(BASE, { headers: { cookie: 'a=b' } })).toThrow('auth.place');
209
+ });
210
+
211
+ it('GET 不带 body;form body 只要字符串', () => {
212
+ expect(bad({ ...BASE, request: { method: 'GET', path: '/a', body: { x: 1 } } })).toContain('GET');
213
+ expect(bad({ ...BASE, request: { method: 'POST', path: '/a', contentType: 'form', body: { x: 1 } } })).toContain('字符串');
214
+ });
215
+
216
+ it('auth 的 source / place 成对,且 cookie 来源不能放 header', () => {
217
+ expect(bad({ ...BASE, auth: { source: { kind: 'cookie' } } })).toContain('auth.place');
218
+ expect(
219
+ bad({ ...BASE, auth: { source: { kind: 'cookie' }, place: { in: 'header', name: 'authorization' } } }),
220
+ ).toContain('place.in 要写 cookie');
221
+ expect(
222
+ bad({ ...BASE, auth: { kind: 'none', source: { kind: 'cookie' }, place: { in: 'cookie' } } }),
223
+ ).toContain('source / place');
224
+ });
225
+
226
+ it('source 的必填格与枚举', () => {
227
+ expect(bad({ ...BASE, auth: { source: { kind: 'meta' }, place: { in: 'header', name: 'x' } } })).toContain('name');
228
+ expect(bad({ ...BASE, auth: { source: { kind: 'storage', area: 'memory', key: 'k' }, place: { in: 'header', name: 'x' } } })).toContain('area');
229
+ expect(bad({ ...BASE, auth: { source: { kind: 'endpoint', url: '/r', path: '' }, place: { in: 'header', name: 'x' } } })).toContain('path');
230
+ expect(bad({ ...BASE, auth: { source: { kind: 'nope' }, place: { in: 'header', name: 'x' } } })).toContain('kind');
231
+ });
232
+
233
+ it('endpoint.url 可以相对,也可以绝对;loginUrl 只收 http(s)', () => {
234
+ expect(
235
+ bad({ ...BASE, auth: { source: { kind: 'endpoint', url: '/r', path: 't' }, place: { in: 'header', name: 'x' } } }),
236
+ ).toBeUndefined();
237
+ expect(
238
+ bad({ ...BASE, auth: { source: { kind: 'endpoint', url: 'https://sso.example.com/r', path: 't' }, place: { in: 'header', name: 'x' } } }),
239
+ ).toBeUndefined();
240
+ expect(bad({ ...BASE, loginUrl: 'javascript:alert(1)' })).toContain('loginUrl');
241
+ });
242
+
243
+ it('maxBytes 是正整数且有上限', () => {
244
+ expect(bad({ ...BASE, response: { kind: 'json', maxBytes: 0 } })).toContain('maxBytes');
245
+ expect(bad({ ...BASE, response: { kind: 'json', maxBytes: 9 * 1024 * 1024 } })).toContain('maxBytes');
246
+ });
247
+
248
+ it('参数里的引号与反斜杠被 JSON.stringify 转义,不能改脚本结构', () => {
249
+ const script = scriptOf({
250
+ ...BASE,
251
+ request: { method: 'GET', path: '/api/do', headers: { 'x-note': 'a"b\\c' } },
252
+ });
253
+ expect(script).toContain('"x-note":"a\\"b\\\\c"');
254
+ });
255
+ });
256
+
257
+ describe('scriptOfMany · 批量读', () => {
258
+ it('一次换 token、N 条并发,各自取自己那一格', () => {
259
+ const script = scriptOfMany(
260
+ {
261
+ ...BASE,
262
+ auth: {
263
+ source: { kind: 'endpoint', url: '/api/user/auth/refresh', method: 'POST', body: {}, path: 'data.access_token' },
264
+ place: { in: 'header', name: 'authorization', prefix: 'Bearer ' },
265
+ },
266
+ request: { method: 'GET', path: '/api/subscription/self', query: { lang: 'zh' } },
267
+ response: { kind: 'json', path: 'data' },
268
+ },
269
+ {
270
+ wallet: { path: '/api/wallet/balance', response: { path: 'data.balance' } },
271
+ usage: { query: { days: 7 }, headers: { 'x-trace': '1' } },
272
+ },
273
+ );
274
+ expect(script).toContain('fetch("https://x.example.com/api/user/auth/refresh"');
275
+ expect(script).toContain('headers["authorization"] = "Bearer " + token;');
276
+ expect(script).toContain('one(withToken("https://x.example.com/api/wallet/balance?lang=zh"), headers, "json", "data.balance"');
277
+ expect(script).toContain('one(withToken("https://x.example.com/api/subscription/self?lang=zh&days=7"), Object.assign({}, headers, {"x-trace":"1"}), "json", "data"');
278
+ expect(script).toContain('await Promise.all([');
279
+ expect(script).toContain('return { ok: true, results };');
280
+ expect(script).not.toContain('auth.access_token');
281
+ });
282
+
283
+ it('没有 token 的批量读不带 withToken 前缀', () => {
284
+ const script = scriptOfMany(
285
+ { ...BASE, auth: { kind: 'none' } },
286
+ { a: { path: '/a' }, b: { path: '/b' } },
287
+ );
288
+ expect(script).toContain('const withToken = (url) => url;');
289
+ expect(script).not.toContain('const token');
290
+ });
291
+
292
+ it('token 放 query 时逐条拼上去', () => {
293
+ const script = scriptOfMany(
294
+ {
295
+ ...BASE,
296
+ auth: { source: { kind: 'global', path: 'state.token' }, place: { in: 'query', name: 'access_token' } },
297
+ },
298
+ { a: { path: '/a' } },
299
+ );
300
+ expect(script).toContain('const withToken = (url) => url + (url.indexOf("?") === -1 ? "?" : "&")');
301
+ expect(script).toContain('"access_token=" + encodeURIComponent(token)');
302
+ });
303
+
304
+ it('批量只认 GET,也不带 body 里的凭据', () => {
305
+ expect(() =>
306
+ scriptOfMany({ ...BASE, request: { method: 'POST', path: '/a' } }, { a: {} }),
307
+ ).toThrow('GET');
308
+ expect(() =>
309
+ scriptOfMany(
310
+ { ...BASE, auth: { source: { kind: 'global', path: 't' }, place: { in: 'body', field: 'token' } } },
311
+ { a: {} },
312
+ ),
313
+ ).toThrow('body');
314
+ });
315
+ });
316
+
317
+ describe('callsProblemOf · 批量校验', () => {
318
+ it('calls 要是对象、非空、不超过上限', () => {
319
+ expect(callsProblemOf(BASE, undefined)).toContain('对象');
320
+ expect(callsProblemOf(BASE, {})).toContain('不能为空');
321
+ const many: Record<string, CallOverrides> = {};
322
+ for (let index = 0; index < MAX_RECIPE_CALLS + 1; index += 1) many[`c${index}`] = {};
323
+ expect(callsProblemOf(BASE, many)).toContain('最多');
324
+ });
325
+
326
+ it('每格按 overrides 校验,并带上是哪一条', () => {
327
+ expect(callsProblemOf(BASE, { a: null })).toContain('calls.a');
328
+ expect(callsProblemOf(BASE, { a: { path: '/a?b=1' } })).toContain('查询');
329
+ expect(callsProblemOf(BASE, { a: { path: 'https://evil.test/a' } })).toContain('同源');
330
+ expect(callsProblemOf(BASE, { a: { headers: { 'x-api-key': 'k' } } })).toContain('calls.a');
331
+ expect(callsProblemOf(BASE, { a: { response: { kind: 'xml' } } })).toContain('calls.a');
332
+ expect(callsProblemOf(BASE, { a: { path: '/a' } })).toBeUndefined();
333
+ });
334
+ });