@bevel-software/platform-core-backend 0.21.0 → 0.23.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 (434) hide show
  1. package/dist/core/core-ports.d.ts +11 -1
  2. package/dist/core/core-ports.d.ts.map +1 -1
  3. package/dist/core/core-ports.js.map +1 -1
  4. package/dist/core/create-core-server.d.ts +3 -3
  5. package/dist/core/create-core-server.d.ts.map +1 -1
  6. package/dist/core/create-core-server.js +62 -13
  7. package/dist/core/create-core-server.js.map +1 -1
  8. package/dist/core/create-core-services.d.ts +22 -3
  9. package/dist/core/create-core-services.d.ts.map +1 -1
  10. package/dist/core/create-core-services.js +158 -23
  11. package/dist/core/create-core-services.js.map +1 -1
  12. package/dist/core/lifecycle.d.ts +34 -1
  13. package/dist/core/lifecycle.d.ts.map +1 -1
  14. package/dist/core/lifecycle.js +89 -13
  15. package/dist/core/lifecycle.js.map +1 -1
  16. package/dist/core-config.d.ts +0 -12
  17. package/dist/core-config.d.ts.map +1 -1
  18. package/dist/core-config.js +11 -13
  19. package/dist/core-config.js.map +1 -1
  20. package/dist/index.d.ts +3 -2
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +2 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/modules/access/access-control.interface.d.ts +42 -12
  25. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  26. package/dist/modules/access/access-control.service.d.ts +67 -10
  27. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  28. package/dist/modules/access/access-control.service.js +214 -35
  29. package/dist/modules/access/access-control.service.js.map +1 -1
  30. package/dist/modules/access/access-requests.contract.d.ts +75 -0
  31. package/dist/modules/access/access-requests.contract.d.ts.map +1 -0
  32. package/dist/modules/access/access-requests.contract.js +20 -0
  33. package/dist/modules/access/access-requests.contract.js.map +1 -0
  34. package/dist/modules/access/access-requests.routes.d.ts +35 -0
  35. package/dist/modules/access/access-requests.routes.d.ts.map +1 -0
  36. package/dist/modules/access/access-requests.routes.js +237 -0
  37. package/dist/modules/access/access-requests.routes.js.map +1 -0
  38. package/dist/modules/access/access-requests.service.d.ts +123 -0
  39. package/dist/modules/access/access-requests.service.d.ts.map +1 -0
  40. package/dist/modules/access/access-requests.service.js +337 -0
  41. package/dist/modules/access/access-requests.service.js.map +1 -0
  42. package/dist/modules/access/access.routes.d.ts.map +1 -1
  43. package/dist/modules/access/access.routes.js +13 -15
  44. package/dist/modules/access/access.routes.js.map +1 -1
  45. package/dist/modules/access-model/access-grammar.d.ts +12 -0
  46. package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
  47. package/dist/modules/access-model/access-grammar.js +24 -0
  48. package/dist/modules/access-model/access-grammar.js.map +1 -1
  49. package/dist/modules/auth/account-admission.d.ts +58 -7
  50. package/dist/modules/auth/account-admission.d.ts.map +1 -1
  51. package/dist/modules/auth/account-admission.js +44 -1
  52. package/dist/modules/auth/account-admission.js.map +1 -1
  53. package/dist/modules/auth/account.routes.d.ts +1 -1
  54. package/dist/modules/auth/account.routes.d.ts.map +1 -1
  55. package/dist/modules/auth/account.routes.js +61 -5
  56. package/dist/modules/auth/account.routes.js.map +1 -1
  57. package/dist/modules/auth/auth.middleware.d.ts +1 -1
  58. package/dist/modules/auth/auth.middleware.d.ts.map +1 -1
  59. package/dist/modules/auth/auth.middleware.js +18 -7
  60. package/dist/modules/auth/auth.middleware.js.map +1 -1
  61. package/dist/modules/auth/auth.routes.d.ts.map +1 -1
  62. package/dist/modules/auth/auth.routes.js +8 -0
  63. package/dist/modules/auth/auth.routes.js.map +1 -1
  64. package/dist/modules/auth/auth.service.d.ts +96 -3
  65. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  66. package/dist/modules/auth/auth.service.js +187 -9
  67. package/dist/modules/auth/auth.service.js.map +1 -1
  68. package/dist/modules/auth/oidc-auth-provider.d.ts.map +1 -1
  69. package/dist/modules/auth/oidc-auth-provider.js +7 -2
  70. package/dist/modules/auth/oidc-auth-provider.js.map +1 -1
  71. package/dist/modules/database/core-schema.d.ts +41 -24
  72. package/dist/modules/database/core-schema.d.ts.map +1 -1
  73. package/dist/modules/database/core-schema.js +35 -27
  74. package/dist/modules/database/core-schema.js.map +1 -1
  75. package/dist/modules/github-app/github-app.client.d.ts +101 -0
  76. package/dist/modules/github-app/github-app.client.d.ts.map +1 -0
  77. package/dist/modules/github-app/github-app.client.js +222 -0
  78. package/dist/modules/github-app/github-app.client.js.map +1 -0
  79. package/dist/modules/github-app/github-app.connection.d.ts +102 -0
  80. package/dist/modules/github-app/github-app.connection.d.ts.map +1 -0
  81. package/dist/modules/github-app/github-app.connection.js +185 -0
  82. package/dist/modules/github-app/github-app.connection.js.map +1 -0
  83. package/dist/modules/github-app/github-app.routes.d.ts +59 -0
  84. package/dist/modules/github-app/github-app.routes.d.ts.map +1 -0
  85. package/dist/modules/github-app/github-app.routes.js +323 -0
  86. package/dist/modules/github-app/github-app.routes.js.map +1 -0
  87. package/dist/modules/github-app/index.d.ts +4 -0
  88. package/dist/modules/github-app/index.d.ts.map +1 -0
  89. package/dist/modules/github-app/index.js +4 -0
  90. package/dist/modules/github-app/index.js.map +1 -0
  91. package/dist/modules/kb-fs/branch-name.d.ts.map +1 -1
  92. package/dist/modules/kb-fs/branch-name.js +12 -2
  93. package/dist/modules/kb-fs/branch-name.js.map +1 -1
  94. package/dist/modules/kb-fs/remote-url.d.ts +22 -0
  95. package/dist/modules/kb-fs/remote-url.d.ts.map +1 -0
  96. package/dist/modules/kb-fs/remote-url.js +35 -0
  97. package/dist/modules/kb-fs/remote-url.js.map +1 -0
  98. package/dist/modules/kb-fs/repo-path.d.ts +11 -16
  99. package/dist/modules/kb-fs/repo-path.d.ts.map +1 -1
  100. package/dist/modules/kb-fs/repo-path.js +79 -0
  101. package/dist/modules/kb-fs/repo-path.js.map +1 -1
  102. package/dist/modules/kb-sync/kb-sync.routes.d.ts +2 -2
  103. package/dist/modules/kb-sync/kb-sync.routes.d.ts.map +1 -1
  104. package/dist/modules/kb-sync/kb-sync.routes.js +14 -3
  105. package/dist/modules/kb-sync/kb-sync.routes.js.map +1 -1
  106. package/dist/modules/kb-sync/sync-auth.d.ts +3 -1
  107. package/dist/modules/kb-sync/sync-auth.d.ts.map +1 -1
  108. package/dist/modules/kb-sync/sync-auth.js +1 -1
  109. package/dist/modules/kb-sync/sync-auth.js.map +1 -1
  110. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  111. package/dist/modules/mcp/mcp-auth.middleware.js +21 -6
  112. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  113. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
  114. package/dist/modules/mcp/oauth/bevel-oauth-provider.js +3 -2
  115. package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
  116. package/dist/modules/plugins/join-proposals.d.ts +17 -5
  117. package/dist/modules/plugins/join-proposals.d.ts.map +1 -1
  118. package/dist/modules/plugins/join-proposals.js +76 -22
  119. package/dist/modules/plugins/join-proposals.js.map +1 -1
  120. package/dist/modules/plugins/join-requests.service.d.ts +111 -18
  121. package/dist/modules/plugins/join-requests.service.d.ts.map +1 -1
  122. package/dist/modules/plugins/join-requests.service.js +173 -29
  123. package/dist/modules/plugins/join-requests.service.js.map +1 -1
  124. package/dist/modules/plugins/plugins.routes.d.ts.map +1 -1
  125. package/dist/modules/plugins/plugins.routes.js +3 -2
  126. package/dist/modules/plugins/plugins.routes.js.map +1 -1
  127. package/dist/modules/settings/deployment-settings.service.d.ts +22 -1
  128. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
  129. package/dist/modules/settings/deployment-settings.service.js +101 -7
  130. package/dist/modules/settings/deployment-settings.service.js.map +1 -1
  131. package/dist/modules/settings/managed-repository.d.ts +43 -0
  132. package/dist/modules/settings/managed-repository.d.ts.map +1 -0
  133. package/dist/modules/settings/managed-repository.js +60 -0
  134. package/dist/modules/settings/managed-repository.js.map +1 -0
  135. package/dist/modules/settings/repository-source.d.ts +128 -0
  136. package/dist/modules/settings/repository-source.d.ts.map +1 -0
  137. package/dist/modules/settings/repository-source.js +150 -0
  138. package/dist/modules/settings/repository-source.js.map +1 -0
  139. package/dist/modules/settings/setup.routes.d.ts +78 -4
  140. package/dist/modules/settings/setup.routes.d.ts.map +1 -1
  141. package/dist/modules/settings/setup.routes.js +379 -17
  142. package/dist/modules/settings/setup.routes.js.map +1 -1
  143. package/dist/modules/skills/skill-access-requests.routes.d.ts +6 -8
  144. package/dist/modules/skills/skill-access-requests.routes.d.ts.map +1 -1
  145. package/dist/modules/skills/skill-access-requests.routes.js +24 -63
  146. package/dist/modules/skills/skill-access-requests.routes.js.map +1 -1
  147. package/dist/modules/skills/skills.contract.d.ts +90 -18
  148. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  149. package/dist/modules/skills/skills.contract.js +4 -1
  150. package/dist/modules/skills/skills.contract.js.map +1 -1
  151. package/dist/modules/skills/skills.service.d.ts +98 -9
  152. package/dist/modules/skills/skills.service.d.ts.map +1 -1
  153. package/dist/modules/skills/skills.service.js +239 -37
  154. package/dist/modules/skills/skills.service.js.map +1 -1
  155. package/dist/modules/skills/skills.tools.d.ts +7 -0
  156. package/dist/modules/skills/skills.tools.d.ts.map +1 -1
  157. package/dist/modules/skills/skills.tools.js +71 -7
  158. package/dist/modules/skills/skills.tools.js.map +1 -1
  159. package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
  160. package/dist/modules/tool-auth/external-api-key.service.js +4 -2
  161. package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
  162. package/dist/modules/tool-auth/internal-token.service.d.ts +3 -3
  163. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +16 -7
  164. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  165. package/dist/modules/tool-auth/tool-auth.middleware.js +34 -12
  166. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  167. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
  168. package/dist/modules/tool-helpers/tool-context.js +15 -0
  169. package/dist/modules/tool-helpers/tool-context.js.map +1 -1
  170. package/dist/modules/tool-helpers/tool-handler.d.ts +2 -1
  171. package/dist/modules/tool-helpers/tool-handler.d.ts.map +1 -1
  172. package/dist/modules/tool-helpers/tool-handler.js +25 -4
  173. package/dist/modules/tool-helpers/tool-handler.js.map +1 -1
  174. package/dist/modules/tool-helpers/tool.contract.d.ts +3 -2
  175. package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
  176. package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
  177. package/dist/modules/tool-helpers/validate-token.js +1 -1
  178. package/dist/modules/tool-helpers/validate-token.js.map +1 -1
  179. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +98 -0
  180. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts.map +1 -0
  181. package/dist/modules/workflow/agent-tools/change-request-summary.js +81 -0
  182. package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -0
  183. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  184. package/dist/modules/workflow/agent-tools/workflow.tools.js +113 -37
  185. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  186. package/dist/modules/workflow/file-lock.service.d.ts +24 -0
  187. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  188. package/dist/modules/workflow/file-lock.service.js +30 -0
  189. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  190. package/dist/modules/workflow/git/git.service.d.ts +72 -1
  191. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  192. package/dist/modules/workflow/git/git.service.js +170 -6
  193. package/dist/modules/workflow/git/git.service.js.map +1 -1
  194. package/dist/modules/workflow/git/node-git-runner.d.ts.map +1 -1
  195. package/dist/modules/workflow/git/node-git-runner.js +5 -0
  196. package/dist/modules/workflow/git/node-git-runner.js.map +1 -1
  197. package/dist/modules/workflow/git/pull-request.service.d.ts +45 -0
  198. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  199. package/dist/modules/workflow/git/pull-request.service.js +99 -0
  200. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  201. package/dist/modules/workflow/pending-commits.service.d.ts +38 -0
  202. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  203. package/dist/modules/workflow/pending-commits.service.js +55 -0
  204. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  205. package/dist/modules/workflow/workflow-hooks.d.ts +54 -32
  206. package/dist/modules/workflow/workflow-hooks.d.ts.map +1 -1
  207. package/dist/modules/workflow/workflow-hooks.js +16 -1
  208. package/dist/modules/workflow/workflow-hooks.js.map +1 -1
  209. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  210. package/dist/modules/workflow/workflow.routes.js +3 -1
  211. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  212. package/dist/modules/workflow/workflow.service.d.ts +94 -13
  213. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  214. package/dist/modules/workflow/workflow.service.js +258 -51
  215. package/dist/modules/workflow/workflow.service.js.map +1 -1
  216. package/dist/modules/workspace/agent-access.gate.d.ts +94 -0
  217. package/dist/modules/workspace/agent-access.gate.d.ts.map +1 -0
  218. package/dist/modules/workspace/agent-access.gate.js +123 -0
  219. package/dist/modules/workspace/agent-access.gate.js.map +1 -0
  220. package/dist/modules/workspace/routine-write-policy.d.ts +5 -6
  221. package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
  222. package/dist/modules/workspace/routine-write-policy.js +5 -6
  223. package/dist/modules/workspace/routine-write-policy.js.map +1 -1
  224. package/dist/modules/workspace/session-sink.d.ts +5 -5
  225. package/dist/modules/workspace/set-aside-clone.d.ts +46 -0
  226. package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -0
  227. package/dist/modules/workspace/set-aside-clone.js +92 -0
  228. package/dist/modules/workspace/set-aside-clone.js.map +1 -0
  229. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +117 -1
  230. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  231. package/dist/modules/workspace/startup/kb-startup-runner.js +190 -1
  232. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  233. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  234. package/dist/modules/workspace/workspace.routes.js +92 -3
  235. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  236. package/dist/modules/workspace/workspace.service.d.ts +115 -5
  237. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  238. package/dist/modules/workspace/workspace.service.js +272 -26
  239. package/dist/modules/workspace/workspace.service.js.map +1 -1
  240. package/dist/modules/workspace/workspace.tools.d.ts +2 -2
  241. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  242. package/dist/modules/workspace/workspace.tools.js +337 -117
  243. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  244. package/dist/modules/write-access/write-access.d.ts +60 -0
  245. package/dist/modules/write-access/write-access.d.ts.map +1 -0
  246. package/dist/modules/write-access/write-access.js +129 -0
  247. package/dist/modules/write-access/write-access.js.map +1 -0
  248. package/dist/shared/domain-errors.d.ts +64 -0
  249. package/dist/shared/domain-errors.d.ts.map +1 -1
  250. package/dist/shared/domain-errors.js +83 -0
  251. package/dist/shared/domain-errors.js.map +1 -1
  252. package/dist/shared/git.contract.d.ts +30 -0
  253. package/dist/shared/git.contract.d.ts.map +1 -1
  254. package/dist/shared/git.contract.js +26 -0
  255. package/dist/shared/git.contract.js.map +1 -1
  256. package/dist/tenancy/static-tenant-source.d.ts +0 -1
  257. package/dist/tenancy/static-tenant-source.d.ts.map +1 -1
  258. package/dist/tenancy/static-tenant-source.js +0 -2
  259. package/dist/tenancy/static-tenant-source.js.map +1 -1
  260. package/migrations/0014_change_request_closed_reason.sql +1 -0
  261. package/migrations/0015_account_deactivation.sql +3 -0
  262. package/migrations/meta/0014_snapshot.json +2265 -0
  263. package/migrations/meta/0015_snapshot.json +2271 -0
  264. package/migrations/meta/_journal.json +14 -0
  265. package/package.json +3 -3
  266. package/src/__tests__/kb-layout-config.test.ts +0 -2
  267. package/src/__tests__/retired-settings.test.ts +96 -0
  268. package/src/core/__tests__/gated-boot.test.ts +132 -0
  269. package/src/core/__tests__/lifecycle.test.ts +170 -1
  270. package/src/core/__tests__/set-aside-root-is-one-place.test.ts +63 -0
  271. package/src/core/core-ports.ts +11 -1
  272. package/src/core/create-core-server.ts +69 -15
  273. package/src/core/create-core-services.ts +190 -27
  274. package/src/core/lifecycle.ts +98 -13
  275. package/src/core-config.ts +13 -14
  276. package/src/index.ts +12 -1
  277. package/src/modules/access/__tests__/access-control.preview-relocation.test.ts +385 -0
  278. package/src/modules/access/__tests__/access-control.prospective.test.ts +94 -18
  279. package/src/modules/access/__tests__/access-requests.recut.test.ts +134 -0
  280. package/src/modules/access/__tests__/access-requests.routes.test.ts +610 -0
  281. package/src/modules/access/__tests__/access.routes.prospective.test.ts +6 -7
  282. package/src/modules/access/access-control.interface.ts +43 -12
  283. package/src/modules/access/access-control.service.ts +234 -36
  284. package/src/modules/access/access-requests.contract.ts +93 -0
  285. package/src/modules/access/access-requests.routes.ts +286 -0
  286. package/src/modules/access/access-requests.service.ts +420 -0
  287. package/src/modules/access/access.routes.ts +13 -15
  288. package/src/modules/access-model/access-grammar.ts +22 -0
  289. package/src/modules/agent-instructions/__tests__/agent-instructions.route.test.ts +3 -1
  290. package/src/modules/auth/__tests__/account-deactivation.test.ts +252 -0
  291. package/src/modules/auth/__tests__/account.routes.test.ts +81 -3
  292. package/src/modules/auth/__tests__/auth.middleware.test.ts +45 -22
  293. package/src/modules/auth/__tests__/auth.service.test.ts +39 -1
  294. package/src/modules/auth/account-admission.ts +78 -9
  295. package/src/modules/auth/account.routes.ts +63 -7
  296. package/src/modules/auth/auth.middleware.ts +19 -8
  297. package/src/modules/auth/auth.routes.ts +8 -0
  298. package/src/modules/auth/auth.service.ts +207 -7
  299. package/src/modules/auth/oidc-auth-provider.ts +7 -2
  300. package/src/modules/database/core-schema.ts +35 -27
  301. package/src/modules/github-app/__tests__/github-app.test.ts +848 -0
  302. package/src/modules/github-app/github-app.client.ts +268 -0
  303. package/src/modules/github-app/github-app.connection.ts +204 -0
  304. package/src/modules/github-app/github-app.routes.ts +359 -0
  305. package/src/modules/github-app/index.ts +19 -0
  306. package/src/modules/kb-fs/__tests__/branch-name.test.ts +10 -0
  307. package/src/modules/kb-fs/__tests__/remote-url.test.ts +38 -0
  308. package/src/modules/kb-fs/__tests__/repo-path.test.ts +123 -0
  309. package/src/modules/kb-fs/branch-name.ts +14 -1
  310. package/src/modules/kb-fs/remote-url.ts +35 -0
  311. package/src/modules/kb-fs/repo-path.ts +85 -0
  312. package/src/modules/kb-sync/__tests__/kb-sync.routes.test.ts +26 -1
  313. package/src/modules/kb-sync/kb-sync.routes.ts +14 -4
  314. package/src/modules/kb-sync/sync-auth.ts +2 -2
  315. package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +60 -3
  316. package/src/modules/mcp/__tests__/mcp.service.test.ts +68 -6
  317. package/src/modules/mcp/mcp-auth.middleware.ts +20 -6
  318. package/src/modules/mcp/oauth/bevel-oauth-provider.ts +3 -1
  319. package/src/modules/plugins/__tests__/join-proposals.test.ts +100 -19
  320. package/src/modules/plugins/__tests__/join-requests.service.test.ts +27 -11
  321. package/src/modules/plugins/__tests__/join-requests.settlement.test.ts +371 -0
  322. package/src/modules/plugins/__tests__/plugins.routes.test.ts +1 -1
  323. package/src/modules/plugins/__tests__/plugins.tools.test.ts +2 -1
  324. package/src/modules/plugins/join-proposals.ts +87 -19
  325. package/src/modules/plugins/join-requests.service.ts +199 -39
  326. package/src/modules/plugins/plugins.routes.ts +3 -2
  327. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +16 -0
  328. package/src/modules/settings/__tests__/repository-source.test.ts +191 -0
  329. package/src/modules/settings/__tests__/setup.routes.git-mode.test.ts +386 -0
  330. package/src/modules/settings/__tests__/setup.routes.github-app.test.ts +299 -0
  331. package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +236 -0
  332. package/src/modules/settings/__tests__/setup.routes.repository-change.test.ts +390 -0
  333. package/src/modules/settings/__tests__/setup.routes.test.ts +3 -0
  334. package/src/modules/settings/deployment-settings.service.ts +117 -6
  335. package/src/modules/settings/managed-repository.ts +68 -0
  336. package/src/modules/settings/repository-source.ts +197 -0
  337. package/src/modules/settings/setup.routes.ts +463 -15
  338. package/src/modules/skills/__tests__/allowed-tools-warn.tools.test.ts +2 -1
  339. package/src/modules/skills/__tests__/branch-skills.tools.test.ts +218 -0
  340. package/src/modules/skills/__tests__/skill-access-requests.routes.test.ts +5 -1
  341. package/src/modules/skills/__tests__/skills.service.test.ts +229 -5
  342. package/src/modules/skills/skill-access-requests.routes.ts +25 -75
  343. package/src/modules/skills/skills.contract.ts +91 -18
  344. package/src/modules/skills/skills.service.ts +278 -41
  345. package/src/modules/skills/skills.tools.ts +80 -8
  346. package/src/modules/tool-auth/__tests__/manual-auth.middleware.test.ts +14 -1
  347. package/src/modules/tool-auth/external-api-key.service.ts +4 -2
  348. package/src/modules/tool-auth/internal-token.service.ts +3 -3
  349. package/src/modules/tool-auth/tool-auth.middleware.ts +34 -11
  350. package/src/modules/tool-helpers/__tests__/agent-roles-write.test.ts +5 -3
  351. package/src/modules/tool-helpers/__tests__/phase4-tools.test.ts +3 -3
  352. package/src/modules/tool-helpers/__tests__/validate-token.test.ts +11 -1
  353. package/src/modules/tool-helpers/tool-context.ts +15 -0
  354. package/src/modules/tool-helpers/tool-handler.ts +24 -4
  355. package/src/modules/tool-helpers/tool.contract.ts +3 -2
  356. package/src/modules/tool-helpers/validate-token.ts +1 -1
  357. package/src/modules/workflow/__tests__/apply-failure.test.ts +9 -1
  358. package/src/modules/workflow/__tests__/pending-commits.repository-replaced.test.ts +111 -0
  359. package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +25 -0
  360. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +159 -8
  361. package/src/modules/workflow/__tests__/workflow.service.repository-replaced.test.ts +249 -0
  362. package/src/modules/workflow/__tests__/workflow.service.update-from-target.test.ts +146 -40
  363. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +405 -7
  364. package/src/modules/workflow/agent-tools/change-request-summary.ts +182 -0
  365. package/src/modules/workflow/agent-tools/workflow.tools.ts +135 -38
  366. package/src/modules/workflow/file-lock.service.ts +31 -0
  367. package/src/modules/workflow/git/__tests__/git.service.fileBytesAtCommit.test.ts +260 -0
  368. package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +121 -0
  369. package/src/modules/workflow/git/__tests__/pull-request.service.getPrDetail.test.ts +145 -2
  370. package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-delete.test.ts +166 -0
  371. package/src/modules/workflow/git/git.service.ts +193 -4
  372. package/src/modules/workflow/git/node-git-runner.ts +6 -0
  373. package/src/modules/workflow/git/pull-request.service.ts +119 -0
  374. package/src/modules/workflow/pending-commits.service.ts +59 -1
  375. package/src/modules/workflow/workflow-hooks.ts +64 -26
  376. package/src/modules/workflow/workflow.routes.ts +3 -1
  377. package/src/modules/workflow/workflow.service.ts +290 -55
  378. package/src/modules/workspace/__tests__/agent-access.gate.test.ts +208 -0
  379. package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +384 -0
  380. package/src/modules/workspace/__tests__/file-stat-access.test.ts +2 -1
  381. package/src/modules/workspace/__tests__/git-internals.security.test.ts +2 -1
  382. package/src/modules/workspace/__tests__/set-aside-clone.test.ts +52 -0
  383. package/src/modules/workspace/__tests__/workspace.routes.at-ref.test.ts +305 -0
  384. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +2 -0
  385. package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +147 -0
  386. package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +326 -0
  387. package/src/modules/workspace/__tests__/workspace.service.test.ts +20 -9
  388. package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +43 -0
  389. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +2 -3
  390. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +235 -10
  391. package/src/modules/workspace/__tests__/workspace.tools.test.ts +720 -17
  392. package/src/modules/workspace/agent-access.gate.ts +164 -0
  393. package/src/modules/workspace/routine-write-policy.ts +5 -6
  394. package/src/modules/workspace/session-sink.ts +5 -5
  395. package/src/modules/workspace/set-aside-clone.ts +96 -0
  396. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +297 -0
  397. package/src/modules/workspace/startup/kb-startup-runner.ts +242 -2
  398. package/src/modules/workspace/workspace.routes.ts +95 -3
  399. package/src/modules/workspace/workspace.service.ts +273 -25
  400. package/src/modules/workspace/workspace.tools.ts +373 -119
  401. package/src/modules/write-access/__tests__/write-access.test.ts +248 -0
  402. package/src/modules/write-access/write-access.ts +153 -0
  403. package/src/shared/domain-errors.ts +89 -0
  404. package/src/shared/git.contract.ts +37 -0
  405. package/src/tenancy/__tests__/static-tenant-source.test.ts +0 -1
  406. package/src/tenancy/static-tenant-source.ts +0 -3
  407. package/dist/modules/workflow/session-ontology.policy.d.ts +0 -52
  408. package/dist/modules/workflow/session-ontology.policy.d.ts.map +0 -1
  409. package/dist/modules/workflow/session-ontology.policy.js +0 -62
  410. package/dist/modules/workflow/session-ontology.policy.js.map +0 -1
  411. package/dist/modules/workflow/session-ontology.service.d.ts +0 -105
  412. package/dist/modules/workflow/session-ontology.service.d.ts.map +0 -1
  413. package/dist/modules/workflow/session-ontology.service.js +0 -147
  414. package/dist/modules/workflow/session-ontology.service.js.map +0 -1
  415. package/dist/modules/workspace/session-ontology.gate.d.ts +0 -114
  416. package/dist/modules/workspace/session-ontology.gate.d.ts.map +0 -1
  417. package/dist/modules/workspace/session-ontology.gate.js +0 -161
  418. package/dist/modules/workspace/session-ontology.gate.js.map +0 -1
  419. package/dist/shared/kb-layout.d.ts +0 -39
  420. package/dist/shared/kb-layout.d.ts.map +0 -1
  421. package/dist/shared/kb-layout.js +0 -103
  422. package/dist/shared/kb-layout.js.map +0 -1
  423. package/dist/shared/kb-layout.test.d.ts +0 -2
  424. package/dist/shared/kb-layout.test.d.ts.map +0 -1
  425. package/dist/shared/kb-layout.test.js +0 -75
  426. package/dist/shared/kb-layout.test.js.map +0 -1
  427. package/src/modules/workflow/__tests__/session-ontology.policy.test.ts +0 -62
  428. package/src/modules/workflow/__tests__/session-ontology.service.test.ts +0 -201
  429. package/src/modules/workflow/session-ontology.policy.ts +0 -70
  430. package/src/modules/workflow/session-ontology.service.ts +0 -183
  431. package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +0 -239
  432. package/src/modules/workspace/session-ontology.gate.ts +0 -191
  433. package/src/shared/kb-layout.test.ts +0 -98
  434. package/src/shared/kb-layout.ts +0 -102
@@ -0,0 +1,610 @@
1
+ import type { Server } from 'node:http';
2
+ import type { AddressInfo } from 'node:net';
3
+ import express from 'express';
4
+ import { describe, it, expect, afterEach, vi } from 'vitest';
5
+ import { joinBranchFor, type IWorkflowService } from '@bevel-software/platform-shared';
6
+
7
+ import { workspaceIdForBranch } from '../../../shared/workspace-id.js';
8
+ import { testKbContext, TEST_BRANCH_MODEL } from '../../../__tests__/kb-context.js';
9
+ import type { AuthService } from '../../auth/auth.service.js';
10
+ import type { WorkspaceService } from '../../workspace/workspace.service.js';
11
+ import type { IAccessControl } from '../access-control.interface.js';
12
+ import type { IAccessRequestLifecycle } from '../access-requests.contract.js';
13
+ import { createAccessRequestRoutes } from '../access-requests.routes.js';
14
+ import { ChangeRequestConflictsError } from '../../../shared/domain-errors.js';
15
+ import { pendingProposals } from '../../plugins/join-proposals.js';
16
+
17
+ /**
18
+ * Asking for Can edit or Owner on an item, and what the editors then see.
19
+ *
20
+ * The live workspace is the only place a request can be made, an item the
21
+ * caller cannot read answers as unknown, and nothing here ever reports a
22
+ * request that does not exist.
23
+ */
24
+
25
+ const KB = 'knowledge-base';
26
+ const LIVE = TEST_BRANCH_MODEL.defaultBranch;
27
+ const LIVE_WS = workspaceIdForBranch(LIVE);
28
+ const RITA = { id: 'u-rita', email: 'rita@x.io', name: 'Rita Oyelaran' };
29
+ const FOLDER = 'Research';
30
+ const FOLDER_RULES = `${KB}/Research/access.md`;
31
+ const NOTE_FILE = 'Research/Notes.md';
32
+
33
+ const proposalFor = (verb: 'write' | 'owner') => ({
34
+ verb,
35
+ id: `user:${RITA.email}`,
36
+ principal: { kind: 'user' as const, email: RITA.email, displayName: RITA.name },
37
+ label: RITA.name,
38
+ });
39
+
40
+ interface HarnessOpts {
41
+ /** Paths the caller may write (its rules file ⇒ they are an editor). */
42
+ writable?: string[];
43
+ readable?: boolean;
44
+ openCrs?: { number: number; branch: string; state: string }[];
45
+ /** What the caller's branch still proposes — null ⇒ the branch is unreadable. */
46
+ proposals?: ReturnType<typeof proposalFor>[] | null;
47
+ /** Branch names the probe reports as already on origin. */
48
+ existingBranches?: string[];
49
+ lastClosed?: { number: number; state: 'closed' | 'merged' } | null;
50
+ liveRules?: string | null;
51
+ branchRules?: string | null;
52
+ detailBody?: string;
53
+ }
54
+
55
+ async function makeHarness(opts: HarnessOpts = {}) {
56
+ const {
57
+ writable = [],
58
+ readable = true,
59
+ openCrs = [],
60
+ proposals = [],
61
+ existingBranches = [],
62
+ lastClosed = null,
63
+ liveRules = '---\n---\nwrite:\n - Ed <ed@x.io>\n',
64
+ branchRules = null,
65
+ detailBody = '',
66
+ } = opts;
67
+
68
+ const absent = Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
69
+ const onOrigin = new Set(existingBranches);
70
+ const opened: unknown[] = [];
71
+ const written: { path: string; text: string }[] = [];
72
+
73
+ const workflow = {
74
+ listChangeRequestsAuthoredBy: vi.fn(async () => openCrs),
75
+ listChangeRequests: vi.fn(async () => openCrs),
76
+ // The ref list FOLLOWS create and delete, as origin's does: a probe that
77
+ // ignored them would let the recut pass on a route that never cut anything.
78
+ listBranches: vi.fn(async () =>
79
+ [...onOrigin].map((name) => ({ name, isDefault: false, isProtected: false })),
80
+ ),
81
+ deleteBranch: vi.fn(async (_ws: string, name: string) => {
82
+ onOrigin.delete(name);
83
+ }),
84
+ createBranch: vi.fn(async (_ws: string, name: string) => {
85
+ onOrigin.add(name);
86
+ return { name, isDefault: false, isProtected: false };
87
+ }),
88
+ commitChanges: vi.fn(async () => null),
89
+ openChangeRequest: vi.fn(async (_ws: string, _u: unknown, input: unknown) => {
90
+ opened.push(input);
91
+ return { number: 77 };
92
+ }),
93
+ latestClosedChangeRequest: vi.fn(async () => lastClosed),
94
+ getChangeRequest: vi.fn(async (n: number) => ({ number: n, state: 'open' })),
95
+ getChangeRequestDetail: vi.fn(async () => ({ body: detailBody })),
96
+ } as unknown as IWorkflowService;
97
+
98
+ const workspaceService = {
99
+ getOrCreateForBranch: vi.fn(async (branch: string) => ({ id: workspaceIdForBranch(branch) })),
100
+ readFile: vi.fn(async (wsId: string, p: string) => {
101
+ const text = wsId === LIVE_WS ? liveRules : branchRules;
102
+ if (text === null) throw absent;
103
+ void p;
104
+ return text;
105
+ }),
106
+ writeFile: vi.fn(async (_ws: string, p: string, text: string) => {
107
+ written.push({ path: p, text });
108
+ }),
109
+ readFileBinary: vi.fn(async () => Buffer.from('---\n---\nhello\n')),
110
+ } as unknown as WorkspaceService;
111
+
112
+ const accessControl = {
113
+ canRead: vi.fn(async () => readable),
114
+ canWrite: vi.fn(async (_ws: string, _e: string, p: string) => writable.includes(p)),
115
+ kbPrincipals: vi.fn(async () => ({ roles: [], groups: [], plugins: [] })),
116
+ invalidate: vi.fn(),
117
+ } as unknown as IAccessControl;
118
+
119
+ const lifecycle = {
120
+ proposalsOn: vi.fn(async () => proposals),
121
+ list: vi.fn(async () => [
122
+ {
123
+ number: 77,
124
+ branch: joinBranchFor(RITA.email, FOLDER),
125
+ requesterName: RITA.name,
126
+ createdAt: '2026-01-01T00:00:00.000Z',
127
+ proposals: proposals ?? [],
128
+ },
129
+ ]),
130
+ reconcile: vi.fn(async () => true),
131
+ } as unknown as IAccessRequestLifecycle;
132
+
133
+ const app = express();
134
+ app.use(express.json());
135
+ app.use((req, _res, next) => {
136
+ req.userId = RITA.id;
137
+ next();
138
+ });
139
+ app.use(
140
+ '/api',
141
+ createAccessRequestRoutes({
142
+ accessControl,
143
+ workspaceService,
144
+ authService: { getUserById: async () => RITA } as unknown as AuthService,
145
+ workflow,
146
+ lifecycle,
147
+ kb: testKbContext({ kbDirName: KB }),
148
+ }),
149
+ );
150
+ const server = await new Promise<Server>((resolve) => {
151
+ const s = app.listen(0, () => resolve(s));
152
+ });
153
+ const { port } = server.address() as AddressInfo;
154
+ const base = `http://127.0.0.1:${port}/api/workspace`;
155
+ return { server, base, workflow, workspaceService, accessControl, lifecycle, opened, written };
156
+ }
157
+
158
+ const post = (url: string, body: unknown) =>
159
+ fetch(url, {
160
+ method: 'POST',
161
+ headers: { 'content-type': 'application/json' },
162
+ body: JSON.stringify(body),
163
+ });
164
+
165
+ const folderBody = { path: `${KB}/${FOLDER}`, kind: 'folder', level: 'write' };
166
+
167
+ describe('POST /workspace/:id/access/request', () => {
168
+ let server: Server | null = null;
169
+ afterEach(async () => {
170
+ vi.restoreAllMocks();
171
+ if (server) await new Promise<void>((r) => server!.close(() => r()));
172
+ server = null;
173
+ });
174
+
175
+ it('opens one change request titled after the item, proposing exactly the level asked for', async () => {
176
+ const h = await makeHarness();
177
+ server = h.server;
178
+ const res = await post(`${h.base}/${LIVE_WS}/access/request`, {
179
+ ...folderBody,
180
+ level: 'owner',
181
+ note: 'I maintain these pages now',
182
+ });
183
+ expect(res.status).toBe(200);
184
+ expect(await res.json()).toEqual({ ok: true, state: 'pending', number: 77, level: 'owner' });
185
+ expect(h.opened).toEqual([
186
+ expect.objectContaining({
187
+ sourceBranch: joinBranchFor(RITA.email, FOLDER),
188
+ targetBranch: LIVE,
189
+ title: 'Access request: Research',
190
+ }),
191
+ ]);
192
+ // The rules file it splices is the FOLDER's access.md, and nothing else.
193
+ expect(h.written.map((w) => w.path)).toEqual([FOLDER_RULES]);
194
+ expect(h.written[0].text).toContain('owner:');
195
+ expect(h.written[0].text).toContain(RITA.email);
196
+ });
197
+
198
+ it("names the person, the level, the path and the note in the description", async () => {
199
+ const h = await makeHarness();
200
+ server = h.server;
201
+ await post(`${h.base}/${LIVE_WS}/access/request`, { ...folderBody, note: 'I run this now' });
202
+ const { description } = h.opened[0] as { description: string };
203
+ // What a reader SEES: markdown consumes the backslashes the escaping put
204
+ // in, so the assertion reads the description the way the renderer does.
205
+ const rendered = description.replace(/\\(.)/g, '$1');
206
+ expect(rendered).toContain(RITA.name);
207
+ expect(rendered).toContain(RITA.email);
208
+ expect(rendered).toContain('Can edit');
209
+ expect(rendered).toContain('Research');
210
+ expect(rendered).toContain('I run this now');
211
+ });
212
+
213
+ it('escapes the note, so nothing in it renders as a heading, a link or an image', async () => {
214
+ const h = await makeHarness();
215
+ server = h.server;
216
+ await post(`${h.base}/${LIVE_WS}/access/request`, {
217
+ ...folderBody,
218
+ note: '# Big\n![x](http://tracker/pixel)',
219
+ });
220
+ const { description } = h.opened[0] as { description: string };
221
+ expect(description).not.toMatch(/^> # Big$/m);
222
+ expect(description).toContain('\\#');
223
+ expect(description).toContain('\\!\\[x\\]\\(http://tracker/pixel\\)');
224
+ });
225
+
226
+ it('a file request splices the FILE\'s own rules — the folder is untouched', async () => {
227
+ const h = await makeHarness({ liveRules: '---\n---\nnotes\n' });
228
+ server = h.server;
229
+ const res = await post(`${h.base}/${LIVE_WS}/access/request`, {
230
+ path: `${KB}/${NOTE_FILE}`,
231
+ kind: 'file',
232
+ level: 'write',
233
+ });
234
+ expect(res.status).toBe(200);
235
+ expect(h.written.map((w) => w.path)).toEqual([`${KB}/${NOTE_FILE}`]);
236
+ });
237
+
238
+ it('writes a file proposal the request reader can actually read back', async () => {
239
+ // The seam that broke on staging: the opener wrote one grant into a node's
240
+ // frontmatter as a scalar, and the reader parsed it with the folder
241
+ // grammar, which rejects a scalar and so found no grants at all. Every
242
+ // file request then looked finished and was closed on the editors' first
243
+ // listing. Asserting the WRITE alone could never catch that — the write
244
+ // was always right — so assert the round trip.
245
+ const live = '---\nnodeType: "[Note](../NodeTypes/Note.md)"\n---\n# Notes\n';
246
+ const h = await makeHarness({ liveRules: live });
247
+ server = h.server;
248
+ await post(`${h.base}/${LIVE_WS}/access/request`, {
249
+ path: `${KB}/${NOTE_FILE}`,
250
+ kind: 'file',
251
+ level: 'owner',
252
+ });
253
+
254
+ const onBranch = h.written[0].text;
255
+ expect(pendingProposals(onBranch, live, NOTE_FILE, 'file')).toEqual([
256
+ expect.objectContaining({ verb: 'owner', id: `user:${RITA.email}` }),
257
+ ]);
258
+ });
259
+
260
+ it('answers with the OPEN request instead of opening a second one', async () => {
261
+ const branch = joinBranchFor(RITA.email, FOLDER);
262
+ const h = await makeHarness({ openCrs: [{ number: 31, branch, state: 'open' }] });
263
+ server = h.server;
264
+ const res = await post(`${h.base}/${LIVE_WS}/access/request`, folderBody);
265
+ expect(await res.json()).toMatchObject({ number: 31 });
266
+ expect(h.workflow.openChangeRequest).not.toHaveBeenCalled();
267
+ expect(h.workspaceService.writeFile).not.toHaveBeenCalled();
268
+ });
269
+
270
+ it('splices onto the LIVE rules, so a request after a decline asks for one level only', async () => {
271
+ // The branch still carries the declined `write` — that is how the dialog
272
+ // knows to say "wasn't accepted". A fresh Owner request must not ask for
273
+ // both.
274
+ const h = await makeHarness({
275
+ liveRules: '---\n---\nwrite:\n - Ed <ed@x.io>\n',
276
+ branchRules: `---\n---\nwrite:\n - Ed <ed@x.io>\n - ${RITA.name} <${RITA.email}>\n`,
277
+ });
278
+ server = h.server;
279
+ await post(`${h.base}/${LIVE_WS}/access/request`, { ...folderBody, level: 'owner' });
280
+ const text = h.written[0].text;
281
+ expect(text).toContain('owner:');
282
+ // Rita appears once, under owner — the declined write proposal is gone.
283
+ expect(text.match(new RegExp(RITA.email, 'g'))).toHaveLength(1);
284
+ });
285
+
286
+ it('refuses every workspace but the live one', async () => {
287
+ const h = await makeHarness();
288
+ server = h.server;
289
+ const res = await post(`${h.base}/${workspaceIdForBranch('rita/draft')}/access/request`, folderBody);
290
+ expect(res.status).toBe(400);
291
+ expect(await res.json()).toMatchObject({ kind: 'not-live-workspace' });
292
+ expect(h.workflow.openChangeRequest).not.toHaveBeenCalled();
293
+ });
294
+
295
+ it('answers as unknown for an item the caller cannot read', async () => {
296
+ const h = await makeHarness({ readable: false });
297
+ server = h.server;
298
+ const res = await post(`${h.base}/${LIVE_WS}/access/request`, folderBody);
299
+ expect(res.status).toBe(404);
300
+ expect(h.workflow.openChangeRequest).not.toHaveBeenCalled();
301
+ });
302
+
303
+ it('refuses an editor — they have the dialog, not a request', async () => {
304
+ const h = await makeHarness({ writable: [`${FOLDER}/access.md`] });
305
+ server = h.server;
306
+ const res = await post(`${h.base}/${LIVE_WS}/access/request`, folderBody);
307
+ expect(res.status).toBe(409);
308
+ expect(await res.json()).toMatchObject({ kind: 'already-writable' });
309
+ });
310
+
311
+ it('refuses a file that cannot carry rules, and names the folder that governs it', async () => {
312
+ const h = await makeHarness();
313
+ server = h.server;
314
+ const res = await post(`${h.base}/${LIVE_WS}/access/request`, {
315
+ path: `${KB}/Research/Deck.pdf`,
316
+ kind: 'file',
317
+ level: 'write',
318
+ });
319
+ expect(res.status).toBe(409);
320
+ expect(await res.json()).toMatchObject({ kind: 'folder-governs', folder: FOLDER });
321
+ expect(h.workspaceService.writeFile).not.toHaveBeenCalled();
322
+ });
323
+
324
+ it('refuses a level that is not Can edit or Owner, and a note over 500 characters', async () => {
325
+ const h = await makeHarness();
326
+ server = h.server;
327
+ expect((await post(`${h.base}/${LIVE_WS}/access/request`, { ...folderBody, level: 'read' })).status).toBe(400);
328
+ expect(
329
+ (await post(`${h.base}/${LIVE_WS}/access/request`, { ...folderBody, note: 'x'.repeat(501) })).status,
330
+ ).toBe(400);
331
+ expect(h.workflow.openChangeRequest).not.toHaveBeenCalled();
332
+ });
333
+ });
334
+
335
+ describe('GET /workspace/:id/access/request — what the requester is told', () => {
336
+ let server: Server | null = null;
337
+ afterEach(async () => {
338
+ if (server) await new Promise<void>((r) => server!.close(() => r()));
339
+ server = null;
340
+ });
341
+
342
+ const status = (h: { base: string }, kind = 'folder') =>
343
+ fetch(`${h.base}/${LIVE_WS}/access/request?path=${encodeURIComponent(`${KB}/${FOLDER}`)}&kind=${kind}`);
344
+
345
+ it('reports the open request, taking the level off the REQUEST rather than the branch', async () => {
346
+ // The branch is where the proposal lives, but a clone whose refs are
347
+ // seconds stale cannot read a branch cut moments ago. "Requested: Owner"
348
+ // is a promise about a request, so it is answered from the request — here
349
+ // the branch is deliberately unreadable and the line is still right.
350
+ const branch = joinBranchFor(RITA.email, FOLDER);
351
+ const h = await makeHarness({
352
+ openCrs: [{ number: 31, branch, state: 'open' }],
353
+ proposals: null,
354
+ detailBody: '<!--hexis:access-request-level:owner-->\nRita asked for Owner.',
355
+ });
356
+ server = h.server;
357
+ expect(await (await status(h)).json()).toEqual({ state: 'pending', level: 'owner', number: 31 });
358
+ });
359
+
360
+ it("calls a request with no level recorded on it Can edit — the skill button's only level", async () => {
361
+ const branch = joinBranchFor(RITA.email, FOLDER);
362
+ const h = await makeHarness({
363
+ openCrs: [{ number: 31, branch, state: 'open' }],
364
+ detailBody: 'Opened before the level was recorded.',
365
+ });
366
+ server = h.server;
367
+ expect(await (await status(h)).json()).toMatchObject({ state: 'pending', level: 'write' });
368
+ });
369
+
370
+ it('says nothing is outstanding when the branch cannot be read — never that a request was refused', async () => {
371
+ // A read that failed proves nothing. Showing the control is recoverable;
372
+ // telling somebody their request was turned down when nobody turned it
373
+ // down is not.
374
+ const h = await makeHarness({ proposals: null, lastClosed: { number: 31, state: 'closed' } });
375
+ server = h.server;
376
+ expect(await (await status(h)).json()).toEqual({ state: 'none' });
377
+ });
378
+
379
+ it('reports a closed request whose proposal is still unmet as not accepted', async () => {
380
+ const h = await makeHarness({
381
+ proposals: [proposalFor('write')],
382
+ lastClosed: { number: 31, state: 'closed' },
383
+ });
384
+ server = h.server;
385
+ expect(await (await status(h)).json()).toEqual({ state: 'not-accepted', level: 'write', number: 31 });
386
+ });
387
+
388
+ it('reports nothing outstanding once the proposal is met — a settled request never reads as declined', async () => {
389
+ // Nothing unmet on the branch: the request was accepted, or the access
390
+ // arrived another way, and its branch is gone.
391
+ const h = await makeHarness({ proposals: [], lastClosed: { number: 31, state: 'closed' } });
392
+ server = h.server;
393
+ expect(await (await status(h)).json()).toEqual({ state: 'none' });
394
+ });
395
+
396
+ it('reports nothing when the person never asked', async () => {
397
+ const h = await makeHarness({ proposals: [], lastClosed: null });
398
+ server = h.server;
399
+ expect(await (await status(h)).json()).toEqual({ state: 'none' });
400
+ });
401
+ });
402
+
403
+ describe('the editors\' side', () => {
404
+ let server: Server | null = null;
405
+ afterEach(async () => {
406
+ if (server) await new Promise<void>((r) => server!.close(() => r()));
407
+ server = null;
408
+ });
409
+
410
+ const listUrl = (base: string) =>
411
+ `${base}/${LIVE_WS}/access/requests?path=${encodeURIComponent(`${KB}/${FOLDER}`)}&kind=folder`;
412
+
413
+ it('lists the item\'s open requests for an editor, with the note read back as plain text', async () => {
414
+ const h = await makeHarness({
415
+ writable: [`${FOLDER}/access.md`],
416
+ proposals: [proposalFor('owner')],
417
+ detailBody:
418
+ 'x\n\n<!--hexis:access-request-note-->\n> I maintain these pages now\n<!--/hexis:access-request-note-->\n',
419
+ });
420
+ server = h.server;
421
+ const body = await (await fetch(listUrl(h.base))).json();
422
+ expect(body.requests).toHaveLength(1);
423
+ expect(body.requests[0]).toMatchObject({ number: 77, note: 'I maintain these pages now' });
424
+ });
425
+
426
+ it('answers [] to everyone who is not an editor, rather than a 403', async () => {
427
+ const h = await makeHarness({ proposals: [proposalFor('write')] });
428
+ server = h.server;
429
+ const res = await fetch(listUrl(h.base));
430
+ expect(res.status).toBe(200);
431
+ expect(await res.json()).toEqual({ requests: [] });
432
+ expect(h.lifecycle.list).not.toHaveBeenCalled();
433
+ });
434
+
435
+ it('reconciles one request for an editor, and hides the route from everyone else', async () => {
436
+ const h = await makeHarness({ writable: [`${FOLDER}/access.md`] });
437
+ server = h.server;
438
+ const body = { path: `${KB}/${FOLDER}`, kind: 'folder' };
439
+ const ok = await post(`${h.base}/${LIVE_WS}/access/requests/77/reconcile`, body);
440
+ expect(await ok.json()).toEqual({ closed: true });
441
+
442
+ const h2 = await makeHarness();
443
+ const denied = await post(`${h2.base}/${LIVE_WS}/access/requests/77/reconcile`, body);
444
+ expect(denied.status).toBe(404);
445
+ await new Promise<void>((r) => h2.server.close(() => r()));
446
+ });
447
+ });
448
+
449
+ describe('which request a branch IS', () => {
450
+ let server: Server | null = null;
451
+ afterEach(async () => {
452
+ if (server) await new Promise<void>((r) => server!.close(() => r()));
453
+ server = null;
454
+ });
455
+
456
+ it('a skill folder request from the dialog is the SAME request as the skill page\'s button', async () => {
457
+ // Both name the branch from (person, folder path) — the skill route and
458
+ // this one give `joinBranchFor` the same key — so the second surface
459
+ // finds the first's request instead of opening a rival.
460
+ const skillFolder = 'Skills/Eng/deploy';
461
+ const h = await makeHarness();
462
+ server = h.server;
463
+ await post(`${h.base}/${LIVE_WS}/access/request`, {
464
+ path: `${KB}/${skillFolder}`,
465
+ kind: 'folder',
466
+ level: 'write',
467
+ });
468
+ expect(h.opened[0]).toMatchObject({
469
+ sourceBranch: joinBranchFor(RITA.email, skillFolder),
470
+ });
471
+ });
472
+
473
+ it('does not follow the item when it is renamed — the request stays where it was opened', async () => {
474
+ // The branch is named from the path the request was made about. After a
475
+ // rename the new path names a different branch, so the open request is
476
+ // not this item's; it stays in Change requests for you, to be declined.
477
+ const h = await makeHarness({ writable: ['Archive/Research/access.md'] });
478
+ server = h.server;
479
+ expect(joinBranchFor(RITA.email, FOLDER)).not.toBe(joinBranchFor(RITA.email, 'Archive/Research'));
480
+ await fetch(
481
+ `${h.base}/${LIVE_WS}/access/requests?path=${encodeURIComponent(`${KB}/Archive/Research`)}&kind=folder`,
482
+ );
483
+ // The listing is keyed on the path asked about, never on the old one.
484
+ expect(h.lifecycle.list).toHaveBeenCalledWith(
485
+ 'Archive/Research',
486
+ { path: 'Archive/Research', kind: 'folder' },
487
+ expect.anything(),
488
+ expect.anything(),
489
+ );
490
+ });
491
+ });
492
+
493
+ describe('a branch left over from an answered request', () => {
494
+ let server: Server | null = null;
495
+ afterEach(async () => {
496
+ vi.restoreAllMocks();
497
+ if (server) await new Promise<void>((r) => server!.close(() => r()));
498
+ server = null;
499
+ });
500
+
501
+ const stale = joinBranchFor(RITA.email, FOLDER);
502
+
503
+ it('is CUT AGAIN from live before the new proposal goes on it', async () => {
504
+ // Opening a change request merges live into its source branch. A branch
505
+ // still standing here was answered long ago, on a base that may predate
506
+ // every edit since — and the moment live has touched the same rules file
507
+ // (an editor granting anyone anything on this item does exactly that)
508
+ // that merge conflicts. The person asking would be told to resolve
509
+ // conflicts on an internal branch they cannot reach, for good. Recutting
510
+ // from live makes live the merge base, so there is nothing to conflict.
511
+ const h = await makeHarness({ existingBranches: [stale] });
512
+ server = h.server;
513
+ const res = await post(`${h.base}/${LIVE_WS}/access/request`, folderBody);
514
+
515
+ expect(res.status).toBe(200);
516
+ expect(h.workflow.deleteBranch).toHaveBeenCalledWith(LIVE_WS, stale, expect.anything());
517
+ expect(h.workflow.createBranch).toHaveBeenCalledWith(LIVE_WS, stale, LIVE);
518
+ // Order matters: recut first, THEN write the proposal onto it.
519
+ const deleted = (h.workflow.deleteBranch as ReturnType<typeof vi.fn>).mock.invocationCallOrder[0];
520
+ const written = (h.workspaceService.writeFile as ReturnType<typeof vi.fn>).mock.invocationCallOrder[0];
521
+ expect(deleted).toBeLessThan(written);
522
+ });
523
+
524
+ it('is left alone when there is no branch to recut', async () => {
525
+ const h = await makeHarness();
526
+ server = h.server;
527
+ await post(`${h.base}/${LIVE_WS}/access/request`, folderBody);
528
+ expect(h.workflow.deleteBranch).not.toHaveBeenCalled();
529
+ expect(h.workflow.createBranch).toHaveBeenCalledWith(LIVE_WS, stale, LIVE);
530
+ });
531
+
532
+ it('still opens the request when the recut fails, rather than refusing the person', async () => {
533
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
534
+ const h = await makeHarness({ existingBranches: [stale] });
535
+ (h.workflow.deleteBranch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
536
+ new Error('the branch could not be removed'),
537
+ );
538
+ server = h.server;
539
+ const res = await post(`${h.base}/${LIVE_WS}/access/request`, folderBody);
540
+ expect(res.status).toBe(200);
541
+ expect(h.workflow.openChangeRequest).toHaveBeenCalled();
542
+ });
543
+
544
+ it('answers with the rival request when one opened while the recut was under way', async () => {
545
+ // `deleteBranch` refuses a branch with an open change request. That
546
+ // refusal is the guard: somebody else's click got there first, and one
547
+ // request is what they both get.
548
+ const h = await makeHarness({ existingBranches: [stale] });
549
+ let listings = 0;
550
+ (h.workflow.listChangeRequests as ReturnType<typeof vi.fn>).mockImplementation(async () =>
551
+ listings++ === 0 ? [] : [{ number: 55, branch: stale, state: 'open' }],
552
+ );
553
+ (h.workflow.deleteBranch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
554
+ new Error('Branch has an open change request (#55).'),
555
+ );
556
+ server = h.server;
557
+ const res = await post(`${h.base}/${LIVE_WS}/access/request`, folderBody);
558
+ expect(await res.json()).toMatchObject({ number: 55 });
559
+ expect(h.workflow.openChangeRequest).not.toHaveBeenCalled();
560
+ });
561
+
562
+ it('answers a re-send with the level the OPEN request asks for, not the one just sent', async () => {
563
+ // Two tabs: the second sends Can edit while an Owner request is open.
564
+ // Answering with what was typed would put "Requested: Can edit" over a
565
+ // request asking for Owner.
566
+ const h = await makeHarness({
567
+ openCrs: [{ number: 31, branch: stale, state: 'open' }],
568
+ detailBody: '<!--hexis:access-request-level:owner-->',
569
+ });
570
+ server = h.server;
571
+ const res = await post(`${h.base}/${LIVE_WS}/access/request`, { ...folderBody, level: 'write' });
572
+ expect(await res.json()).toEqual({ ok: true, state: 'pending', number: 31, level: 'owner' });
573
+ });
574
+
575
+ it('records the level on the request itself, so it can be read back', async () => {
576
+ const h = await makeHarness();
577
+ server = h.server;
578
+ await post(`${h.base}/${LIVE_WS}/access/request`, { ...folderBody, level: 'owner' });
579
+ const { description } = h.opened[0] as { description: string };
580
+ expect(description).toContain('<!--hexis:access-request-level:owner-->');
581
+ });
582
+ });
583
+
584
+ describe('what a failed send tells the person', () => {
585
+ let server: Server | null = null;
586
+ afterEach(async () => {
587
+ vi.restoreAllMocks();
588
+ if (server) await new Promise<void>((r) => server!.close(() => r()));
589
+ server = null;
590
+ });
591
+
592
+ it('never asks a reader to resolve conflicts on a branch they have never heard of', async () => {
593
+ // The dialog shows the server's reason verbatim, so the reason has to be
594
+ // something the person can act on. "Resolve on
595
+ // reader2/join-artest3-04i7dr4-0nxlotq and try again" is not.
596
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
597
+ const h = await makeHarness();
598
+ (h.workflow.openChangeRequest as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
599
+ new ChangeRequestConflictsError(joinBranchFor(RITA.email, FOLDER), LIVE, [
600
+ `${FOLDER}/access.md`,
601
+ ]),
602
+ );
603
+ server = h.server;
604
+ const res = await post(`${h.base}/${LIVE_WS}/access/request`, folderBody);
605
+ expect(res.status).toBe(409);
606
+ const body = await res.json();
607
+ expect(body.error).toBe('The rules changed while your request was being sent. Try again.');
608
+ expect(JSON.stringify(body)).not.toContain('join-');
609
+ });
610
+ });
@@ -10,7 +10,6 @@ import type { WorkflowService } from '../../workflow/workflow.service.js';
10
10
  import type { WorkflowEventBus } from '../../workflow/event-bus.js';
11
11
  import type { Database } from '../../database/connection.js';
12
12
  import { createAccessRoutes } from '../access.routes.js';
13
- import { WorkflowDomainError } from '../../../shared/domain-errors.js';
14
13
  import { testKbContext } from '../../../__tests__/kb-context.js';
15
14
 
16
15
  /**
@@ -192,15 +191,15 @@ describe('GET /access/prospective', () => {
192
191
  expect(await res.json()).toEqual({ error: 'Internal error.' });
193
192
  });
194
193
 
195
- it('refuses a folder source the way the resolver does, without guessing', async () => {
194
+ it('asks about a folder source the same way, keeping its name at the destination', async () => {
196
195
  h = await makeHarness();
197
- h.prospectiveHolders.mockRejectedValueOnce(
198
- new WorkflowDomainError('prospective access answers for a file, not a folder', 400),
199
- );
200
196
 
201
- const res = await get(`from=${encodeURIComponent('Knowledge/Legal')}&toDir=Knowledge`);
197
+ const res = await get(`from=${encodeURIComponent('Knowledge/Legal')}&toDir=${encodeURIComponent('Knowledge/Sales')}`);
202
198
 
203
- expect(res.status).toBe(400);
199
+ expect(res.status).toBe(200);
200
+ // The route derives the destination from the name, folder or file alike;
201
+ // which of the two it is, and what travels with it, is the resolver's.
202
+ expect(h.prospectiveHolders).toHaveBeenCalledWith(WS, 'Knowledge/Legal', 'Knowledge/Sales/Legal');
204
203
  });
205
204
 
206
205
  it('falls back to the name-only roles list when the resolver omits kinds', async () => {