@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
@@ -2,8 +2,13 @@ import { describe, it, expect } from 'vitest';
2
2
  import { pendingProposals } from '../join-proposals.js';
3
3
 
4
4
  /**
5
- * What a join branch proposes, relative to the default branch. This diff IS
6
- * the request's lifecycle: non-empty ⇒ open, empty ⇒ settled and closed.
5
+ * What a request's branch proposes, relative to the default branch. This diff
6
+ * IS the request's lifecycle: non-empty ⇒ open, empty ⇒ settled and closed,
7
+ * null ⇒ the branch could not be read and nothing may be concluded.
8
+ *
9
+ * Which grammar reads it follows the ITEM, and the second describe below is
10
+ * why: a folder's rules are block lists in an access.md body, a file's are its
11
+ * own frontmatter, where a single grant is a scalar.
7
12
  */
8
13
  const PATH = 'Plugins/GTM/access.md';
9
14
 
@@ -17,7 +22,7 @@ describe('pendingProposals', () => {
17
22
  const branch = base(
18
23
  'read:\n - GTM Team\n - Olga Ivanova <olga@bevel.software>\n - Ali Baba <ali@bevel.software>\n',
19
24
  );
20
- expect(pendingProposals(branch, DEFAULT_MD, PATH)).toEqual([
25
+ expect(pendingProposals(branch, DEFAULT_MD, PATH, 'folder')).toEqual([
21
26
  {
22
27
  verb: 'read',
23
28
  id: 'user:ali@bevel.software',
@@ -31,7 +36,7 @@ describe('pendingProposals', () => {
31
36
  // A branch asking for `write` must be visible AS a write request rather
32
37
  // than hiding behind "asked to join".
33
38
  const branch = base('read:\n - GTM Team\n - Olga Ivanova <olga@bevel.software>\nwrite:\n - Finance Team\n');
34
- expect(pendingProposals(branch, DEFAULT_MD, PATH)).toEqual([
39
+ expect(pendingProposals(branch, DEFAULT_MD, PATH, 'folder')).toEqual([
35
40
  {
36
41
  verb: 'write',
37
42
  id: 'role:finance team',
@@ -42,12 +47,12 @@ describe('pendingProposals', () => {
42
47
  });
43
48
 
44
49
  it('is EMPTY when the branch is identical — the settled state', () => {
45
- expect(pendingProposals(DEFAULT_MD, DEFAULT_MD, PATH)).toEqual([]);
50
+ expect(pendingProposals(DEFAULT_MD, DEFAULT_MD, PATH, 'folder')).toEqual([]);
46
51
  });
47
52
 
48
53
  it('is EMPTY when the branch grants a SUBSET (its proposal already landed)', () => {
49
54
  const branch = base('read:\n - Olga Ivanova <olga@bevel.software>\n');
50
- expect(pendingProposals(branch, DEFAULT_MD, PATH)).toEqual([]);
55
+ expect(pendingProposals(branch, DEFAULT_MD, PATH, 'folder')).toEqual([]);
51
56
  });
52
57
 
53
58
  it('ignores a REMOVAL — a branch that drops one grant but keeps the rest proposes nothing', () => {
@@ -55,36 +60,38 @@ describe('pendingProposals', () => {
55
60
  // ordinary change request in the review UI. (Distinct from the subset
56
61
  // case above: here GTM Team survives and only Olga is dropped.)
57
62
  const branch = base('read:\n - GTM Team\n');
58
- expect(pendingProposals(branch, DEFAULT_MD, PATH)).toEqual([]);
63
+ expect(pendingProposals(branch, DEFAULT_MD, PATH, 'folder')).toEqual([]);
59
64
  });
60
65
 
61
66
  it('ignores a `deny` entry — a denial is not something to accept', () => {
62
67
  const branch = base(
63
68
  'read:\n - GTM Team\n - Olga Ivanova <olga@bevel.software>\n - deny Ali Baba <ali@bevel.software>\n',
64
69
  );
65
- expect(pendingProposals(branch, DEFAULT_MD, PATH)).toEqual([]);
70
+ expect(pendingProposals(branch, DEFAULT_MD, PATH, 'folder')).toEqual([]);
66
71
  });
67
72
 
68
73
  it('matches principals canonically (case-insensitive email, canonical role)', () => {
69
74
  const branch = base('read:\n - gtm team\n - Olga I <OLGA@Bevel.Software>\n');
70
- expect(pendingProposals(branch, DEFAULT_MD, PATH)).toEqual([]);
75
+ expect(pendingProposals(branch, DEFAULT_MD, PATH, 'folder')).toEqual([]);
71
76
  });
72
77
 
73
- it('treats a MALFORMED branch file as proposing nothing (fail-closed)', () => {
74
- // A body naming a verb but shaped wrong is a hard parse error; there is
75
- // nothing to offer a manager, and merging is not how this settles anyway.
76
- expect(pendingProposals(base('read: GTM Team\n'), DEFAULT_MD, PATH)).toEqual([]);
78
+ it('says NOTHING about a malformed branch file — not "nothing to propose"', () => {
79
+ // A body naming a verb but shaped wrong is a hard parse error. The
80
+ // difference matters because an empty answer CLOSES the request: it has
81
+ // to mean the branch asks for nothing more, never that it would not say.
82
+ expect(pendingProposals(base('read: GTM Team\n'), DEFAULT_MD, PATH, 'folder')).toBeNull();
77
83
  });
78
84
 
79
- it('treats a missing branch file as proposing nothing', () => {
80
- expect(pendingProposals(null, DEFAULT_MD, PATH)).toEqual([]);
85
+ it('says NOTHING about a missing branch file either', () => {
86
+ expect(pendingProposals(null, DEFAULT_MD, PATH, 'folder')).toBeNull();
81
87
  });
82
88
 
83
89
  it('surfaces every branch grant when the DEFAULT file is missing or unreadable', () => {
84
- // The safe direction: show the manager proposals to consider rather than
85
- // silently swallowing them against an unreadable baseline.
90
+ // The safe direction on THAT side: show the editor proposals to consider
91
+ // rather than swallowing them against a baseline nobody could read.
86
92
  const branch = base('read:\n - Ali Baba <ali@bevel.software>\n');
87
- expect(pendingProposals(branch, null, PATH)).toHaveLength(1);
93
+ expect(pendingProposals(branch, null, PATH, 'folder')).toHaveLength(1);
94
+ expect(pendingProposals(branch, base('read: broken\n'), PATH, 'folder')).toHaveLength(1);
88
95
  });
89
96
 
90
97
  it('does not confuse the SELF-frontmatter with folder rules', () => {
@@ -96,6 +103,80 @@ describe('pendingProposals', () => {
96
103
  const branch =
97
104
  '---\nread:\n - everyone\n - Ali Baba <ali@bevel.software>\n---\n' +
98
105
  'read:\n - GTM Team\n - Olga Ivanova <olga@bevel.software>\n';
99
- expect(pendingProposals(branch, DEFAULT_MD, PATH)).toEqual([]);
106
+ expect(pendingProposals(branch, DEFAULT_MD, PATH, 'folder')).toEqual([]);
107
+ });
108
+ });
109
+
110
+ /**
111
+ * A FILE's rules are not an access.md. They live in the node's own
112
+ * frontmatter, and `spliceGrant` writes one grant there as the scalar form
113
+ * the resolver accepts — `write: Rita <rita@x.io>`, not a list.
114
+ *
115
+ * Read with the FOLDER grammar that is a hard parse error ("'write:' must be
116
+ * a list"), the whole file yields no grants, and a branch with a perfectly
117
+ * good proposal on it looks like a branch with nothing left to propose. Every
118
+ * request whose target was a file was closed on the editors' first listing
119
+ * because of it — before anyone saw the line, let alone pressed Accept.
120
+ */
121
+ describe('pendingProposals on a FILE, whose rules are its own frontmatter', () => {
122
+ const FILE = 'Research/Notes.md';
123
+ const LIVE = '---\nnodeType: "[Note](../NodeTypes/Note.md)"\n---\n# Notes\n\nbody\n';
124
+ /** Exactly what `spliceGrant(..., { allowScalar: true, target: 'node' })` writes. */
125
+ const ASKS_WRITE =
126
+ '---\nnodeType: "[Note](../NodeTypes/Note.md)"\nwrite: Rita Reader <rita@x.io>\n---\n# Notes\n\nbody\n';
127
+
128
+ it('reads the scalar grant the splice actually writes', () => {
129
+ expect(pendingProposals(ASKS_WRITE, LIVE, FILE, 'file')).toEqual([
130
+ {
131
+ verb: 'write',
132
+ id: 'user:rita@x.io',
133
+ principal: { kind: 'user', email: 'rita@x.io', displayName: 'Rita Reader' },
134
+ label: 'Rita Reader',
135
+ },
136
+ ]);
137
+ });
138
+
139
+ it('is not fooled by the FOLDER grammar, which cannot read it at all', () => {
140
+ // The regression guard. Before, this answered `[]` — indistinguishable
141
+ // from a finished request, which is what closed them.
142
+ expect(pendingProposals(ASKS_WRITE, LIVE, FILE, 'folder')).toBeNull();
143
+ });
144
+
145
+ it('ignores the non-access keys a node legitimately carries', () => {
146
+ expect(pendingProposals(LIVE, LIVE, FILE, 'file')).toEqual([]);
147
+ // `nodeType` is not a grant, and its presence is not a parse failure.
148
+ expect(pendingProposals(LIVE, null, FILE, 'file')).toEqual([]);
149
+ });
150
+
151
+ it('reads a file with no frontmatter as granting nothing, not as unreadable', () => {
152
+ // Nothing to say is a real answer here; a node need not declare rules.
153
+ expect(pendingProposals('# Just a note\n', LIVE, FILE, 'file')).toEqual([]);
154
+ });
155
+
156
+ it('says NOTHING about a file whose frontmatter is broken — not "nothing to propose"', () => {
157
+ // The same rule the folder side keeps: an empty answer closes somebody's
158
+ // request, so a block that could not be read must not produce one.
159
+ const neverClosed = '---\nnodeType: "[Note](x)"\nwrite: Rita Reader <rita@x.io>\n# Notes\n\nbody\n';
160
+ expect(pendingProposals(neverClosed, LIVE, FILE, 'file')).toBeNull();
161
+ const notAMapping = '---\n- just\n- a list\n---\n# Notes\n';
162
+ expect(pendingProposals(notAMapping, LIVE, FILE, 'file')).toBeNull();
163
+ // An empty block is readable, and says nothing.
164
+ expect(pendingProposals('---\n---\n# Notes\n', LIVE, FILE, 'file')).toEqual([]);
165
+ // A broken copy on LIVE stays the safe direction: every grant looks incoming.
166
+ expect(pendingProposals(ASKS_WRITE, neverClosed, FILE, 'file')).toEqual([
167
+ expect.objectContaining({ verb: 'write', id: 'user:rita@x.io' }),
168
+ ]);
169
+ });
170
+
171
+ it('reads the list form too, and an Owner request', () => {
172
+ const asksOwner =
173
+ '---\nnodeType: "[Note](x)"\nowner:\n - Rita Reader <rita@x.io>\n---\n# Notes\n';
174
+ expect(pendingProposals(asksOwner, LIVE, FILE, 'file')).toEqual([
175
+ expect.objectContaining({ verb: 'owner', id: 'user:rita@x.io' }),
176
+ ]);
177
+ });
178
+
179
+ it('still settles a file request once the grant is on live', () => {
180
+ expect(pendingProposals(ASKS_WRITE, ASKS_WRITE, FILE, 'file')).toEqual([]);
100
181
  });
101
182
  });
@@ -7,8 +7,10 @@ import {
7
7
  type IWorkflowService,
8
8
  } from '@bevel-software/platform-shared';
9
9
  import { type WorkspaceService } from '../../workspace/workspace.service.js';
10
+ import type { IAccessControl } from '../../access/access-control.interface.js';
10
11
  import { workspaceIdForBranch } from '../../../shared/workspace-id.js';
11
12
  import { JoinRequestsService } from '../join-requests.service.js';
13
+ import { folderTarget } from '../../access/access-requests.contract.js';
12
14
  import { testKbContext } from '../../../__tests__/kb-context.js';
13
15
 
14
16
  /**
@@ -44,7 +46,7 @@ function cr(over: Partial<ChangeRequest> = {}): ChangeRequest {
44
46
  }
45
47
 
46
48
  /** `byRef[ref]` is the access.md content at that ref (undefined ⇒ absent). */
47
- function makeHarness(byRef: Record<string, string>) {
49
+ function makeHarness(byRef: Record<string, string>, holds: Record<string, unknown> = {}) {
48
50
  const workspaceService = {
49
51
  ensureRemotesFetched: vi.fn(async () => undefined),
50
52
  readFileAtRef: vi.fn(async (_ws: string, ref: string, path: string) =>
@@ -55,7 +57,21 @@ function makeHarness(byRef: Record<string, string>) {
55
57
  rejectChangeRequest: vi.fn(async () => ({ number: 7, state: 'closed' })),
56
58
  deleteBranch: vi.fn(async () => undefined),
57
59
  } as unknown as IWorkflowService;
58
- return { svc: new JoinRequestsService(workspaceService, workflow, testKbContext()), workflow, workspaceService };
60
+ // Nobody holds anything by default: every proposal stands on the file diff
61
+ // alone, which is what these cases are about.
62
+ const accessControl = {
63
+ canRead: vi.fn(async () => false),
64
+ canWrite: vi.fn(async () => false),
65
+ canOwner: vi.fn(async () => false),
66
+ canDownload: vi.fn(async () => false),
67
+ ...holds,
68
+ } as unknown as IAccessControl;
69
+ return {
70
+ svc: new JoinRequestsService(workspaceService, workflow, testKbContext(), accessControl),
71
+ workflow,
72
+ workspaceService,
73
+ accessControl,
74
+ };
59
75
  }
60
76
 
61
77
  const refs = (branchMd: string | undefined, defaultMd = DEFAULT_MD) => ({
@@ -66,7 +82,7 @@ const refs = (branchMd: string | undefined, defaultMd = DEFAULT_MD) => ({
66
82
  describe('JoinRequestsService.list', () => {
67
83
  it('reports a request with its pending proposals, and leaves it open', async () => {
68
84
  const h = makeHarness(refs(PROPOSING_MD));
69
- const out = await h.svc.list('GTM', FOLDER, [cr()], ACTOR);
85
+ const out = await h.svc.list('GTM', folderTarget(FOLDER), [cr()], ACTOR);
70
86
  expect(out).toHaveLength(1);
71
87
  expect(out[0]).toMatchObject({ number: 7, requesterName: 'Ali Baba' });
72
88
  expect(out[0].proposals.map((p) => p.label)).toEqual(['Ali Baba']);
@@ -76,7 +92,7 @@ describe('JoinRequestsService.list', () => {
76
92
  it('SETTLES a request whose proposals have all landed: closes it, deletes the branch, omits it', async () => {
77
93
  // The grant is on the default branch now, so the branch adds nothing.
78
94
  const h = makeHarness(refs(PROPOSING_MD, PROPOSING_MD));
79
- const out = await h.svc.list('GTM', FOLDER, [cr()], ACTOR);
95
+ const out = await h.svc.list('GTM', folderTarget(FOLDER), [cr()], ACTOR);
80
96
  expect(out).toEqual([]);
81
97
  expect(h.workflow.rejectChangeRequest).toHaveBeenCalledWith(
82
98
  7,
@@ -114,7 +130,7 @@ describe('JoinRequestsService.list', () => {
114
130
  new Error('gh down'),
115
131
  );
116
132
  const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
117
- await expect(h.svc.list('GTM', FOLDER, [cr()], ACTOR)).resolves.toEqual([]);
133
+ await expect(h.svc.list('GTM', folderTarget(FOLDER), [cr()], ACTOR)).resolves.toEqual([]);
118
134
  expect(warn).toHaveBeenCalled();
119
135
  warn.mockRestore();
120
136
  });
@@ -125,7 +141,7 @@ describe('JoinRequestsService.list', () => {
125
141
  const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
126
142
  // The request still settles (omitted from the list) — the leftover branch
127
143
  // is cosmetic, and the failure is logged rather than swallowed.
128
- await expect(h.svc.list('GTM', FOLDER, [cr()], ACTOR)).resolves.toEqual([]);
144
+ await expect(h.svc.list('GTM', folderTarget(FOLDER), [cr()], ACTOR)).resolves.toEqual([]);
129
145
  expect(h.workflow.rejectChangeRequest).toHaveBeenCalled();
130
146
  expect(warn).toHaveBeenCalledWith(expect.stringContaining('could not delete'));
131
147
  warn.mockRestore();
@@ -142,10 +158,10 @@ describe('JoinRequestsService.list', () => {
142
158
  [`origin/${DEFAULT_BRANCH}`]: DEFAULT_MD,
143
159
  [`origin/${joinBranchFor(ALI, 'Finance!')}`]: DEFAULT_MD,
144
160
  });
145
- await expect(h.svc.list('finance', 'Plugins/finance', [other], ACTOR)).resolves.toEqual([]);
161
+ await expect(h.svc.list('finance', folderTarget('Plugins/finance'), [other], ACTOR)).resolves.toEqual([]);
146
162
  expect(h.workflow.rejectChangeRequest).not.toHaveBeenCalled();
147
163
  expect(h.workflow.deleteBranch).not.toHaveBeenCalled();
148
- await expect(h.svc.reconcile('finance', 'Plugins/finance', other, ACTOR)).resolves.toBe(false);
164
+ await expect(h.svc.reconcile('finance', folderTarget('Plugins/finance'), other, ACTOR)).resolves.toBe(false);
149
165
  expect(h.workflow.rejectChangeRequest).not.toHaveBeenCalled();
150
166
  });
151
167
  });
@@ -153,21 +169,21 @@ describe('JoinRequestsService.list', () => {
153
169
  describe('JoinRequestsService.reconcile', () => {
154
170
  it('settles and reports true once nothing is pending', async () => {
155
171
  const h = makeHarness(refs(PROPOSING_MD, PROPOSING_MD));
156
- await expect(h.svc.reconcile('GTM', FOLDER, cr(), ACTOR)).resolves.toBe(true);
172
+ await expect(h.svc.reconcile('GTM', folderTarget(FOLDER), cr(), ACTOR)).resolves.toBe(true);
157
173
  expect(h.workflow.rejectChangeRequest).toHaveBeenCalled();
158
174
  expect(h.workflow.deleteBranch).toHaveBeenCalled();
159
175
  });
160
176
 
161
177
  it('leaves a request alone while proposals remain', async () => {
162
178
  const h = makeHarness(refs(PROPOSING_MD));
163
- await expect(h.svc.reconcile('GTM', FOLDER, cr(), ACTOR)).resolves.toBe(false);
179
+ await expect(h.svc.reconcile('GTM', folderTarget(FOLDER), cr(), ACTOR)).resolves.toBe(false);
164
180
  expect(h.workflow.rejectChangeRequest).not.toHaveBeenCalled();
165
181
  });
166
182
 
167
183
  it('refuses a change request that is not this plugin\'s join branch', async () => {
168
184
  const h = makeHarness(refs(PROPOSING_MD, PROPOSING_MD));
169
185
  await expect(
170
- h.svc.reconcile('GTM', FOLDER, cr({ branch: 'ali/some-draft' }), ACTOR),
186
+ h.svc.reconcile('GTM', folderTarget(FOLDER), cr({ branch: 'ali/some-draft' }), ACTOR),
171
187
  ).resolves.toBe(false);
172
188
  expect(h.workflow.rejectChangeRequest).not.toHaveBeenCalled();
173
189
  });
@@ -0,0 +1,371 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import {
3
+ DEFAULT_BRANCH,
4
+ joinBranchFor,
5
+ type AuthUser,
6
+ type ChangeRequest,
7
+ type IWorkflowService,
8
+ } from '@bevel-software/platform-shared';
9
+ import { type WorkspaceService } from '../../workspace/workspace.service.js';
10
+ import type { IAccessControl } from '../../access/access-control.interface.js';
11
+ import { folderTarget } from '../../access/access-requests.contract.js';
12
+ import { JoinRequestsService } from '../join-requests.service.js';
13
+ import { spliceGrant } from '../../access-model/access-splice.js';
14
+ import { testKbContext } from '../../../__tests__/kb-context.js';
15
+
16
+ /**
17
+ * A request ends when the person it names HOLDS what it asked for — however
18
+ * they came to hold it.
19
+ *
20
+ * The file diff alone cannot see that. Owner covers Can edit without putting
21
+ * a `write:` line anywhere; a role or a group carries access without naming
22
+ * anybody; a grant on a parent folder never touches this file at all. Each of
23
+ * those leaves the branch proposing something no editor can usefully accept,
24
+ * and the request sits open forever. These are the cases that asking the
25
+ * resolver instead settles — including for the two Library request kinds,
26
+ * which go through exactly this service.
27
+ */
28
+ const ALI = 'ali@bevel.software';
29
+ const FOLDER = 'Plugins/GTM';
30
+ const ACCESS_MD = `${FOLDER}/access.md`;
31
+ const ACTOR: AuthUser = { id: 'u-1', email: 'olga@bevel.software', name: 'Olga Ivanova' };
32
+ const BRANCH = joinBranchFor(ALI, 'GTM');
33
+
34
+ const base = (body: string) => `---\nread:\n - everyone\n---\n${body}`;
35
+ const DEFAULT_MD = base('read:\n - GTM Team\n');
36
+ const ASKS_WRITE = base(`read:\n - GTM Team\nwrite:\n - Ali Baba <${ALI}>\n`);
37
+ const ASKS_FOR_A_ROLE = base('read:\n - GTM Team\n - role/Analysts\n');
38
+
39
+ function cr(over: Partial<ChangeRequest> = {}): ChangeRequest {
40
+ return {
41
+ number: 7,
42
+ title: 'Access request: GTM',
43
+ author: { login: 'user-abc' },
44
+ authorId: 'hash-ali',
45
+ appAuthor: { name: 'Ali Baba' },
46
+ branch: BRANCH,
47
+ base: DEFAULT_BRANCH,
48
+ state: 'open',
49
+ createdAt: '2026-01-01T00:00:00.000Z',
50
+ touchedNodePaths: [ACCESS_MD],
51
+ review: { approvals: 0, changesRequested: 0, pendingLogins: [] },
52
+ url: '/change-requests/7',
53
+ ...over,
54
+ } as ChangeRequest;
55
+ }
56
+
57
+ function makeHarness(
58
+ branchMd: string | undefined,
59
+ holds: Partial<Record<'canRead' | 'canWrite' | 'canOwner' | 'canDownload', boolean>> = {},
60
+ /** Refs that only appear once a FORCED fetch has run — a branch just pushed. */
61
+ arrivesOnForcedFetch: string | undefined = undefined,
62
+ liveMd: string = DEFAULT_MD,
63
+ ) {
64
+ const byRef: Record<string, string> = {
65
+ [`origin/${DEFAULT_BRANCH}`]: liveMd,
66
+ ...(branchMd === undefined ? {} : { [`origin/${BRANCH}`]: branchMd }),
67
+ };
68
+ const fetches: { force: boolean }[] = [];
69
+ const workspaceService = {
70
+ ensureRemotesFetched: vi.fn(async (_ws: string, opts: { force?: boolean } = {}) => {
71
+ fetches.push({ force: !!opts.force });
72
+ if (opts.force && arrivesOnForcedFetch !== undefined) {
73
+ byRef[`origin/${BRANCH}`] = arrivesOnForcedFetch;
74
+ }
75
+ }),
76
+ readFileAtRef: vi.fn(async (_ws: string, ref: string, p: string) => {
77
+ if (p !== ACCESS_MD) return null;
78
+ const text = byRef[ref];
79
+ // A ref this clone has not heard of is not an empty file — it throws,
80
+ // exactly as `git show` does for an unknown ref.
81
+ if (text === undefined) throw new Error(`unknown revision ${ref}`);
82
+ return text;
83
+ }),
84
+ } as unknown as WorkspaceService;
85
+ const workflow = {
86
+ rejectChangeRequest: vi.fn(async () => ({ number: 7, state: 'closed' })),
87
+ deleteBranch: vi.fn(async () => undefined),
88
+ } as unknown as IWorkflowService;
89
+ const asked: { verb: string; path: string; email: string }[] = [];
90
+ const answer = (verb: 'canRead' | 'canWrite' | 'canOwner' | 'canDownload') =>
91
+ vi.fn(async (_ws: string, email: string, path: string) => {
92
+ asked.push({ verb, path, email });
93
+ return holds[verb] ?? false;
94
+ });
95
+ const accessControl = {
96
+ canRead: answer('canRead'),
97
+ canWrite: answer('canWrite'),
98
+ canOwner: answer('canOwner'),
99
+ canDownload: answer('canDownload'),
100
+ } as unknown as IAccessControl;
101
+ return {
102
+ svc: new JoinRequestsService(workspaceService, workflow, testKbContext(), accessControl),
103
+ workflow,
104
+ asked,
105
+ fetches,
106
+ };
107
+ }
108
+
109
+ describe('a request settles once the person holds what it asked for', () => {
110
+ it('settles a write request when Owner already covers it', async () => {
111
+ // `canWrite` is the resolver's own question, and owner folds into write —
112
+ // so an owner grant made in the dialog retires the Can edit request.
113
+ const h = makeHarness(ASKS_WRITE, { canWrite: true });
114
+ await expect(h.svc.list('GTM', folderTarget(FOLDER), [cr()], ACTOR)).resolves.toEqual([]);
115
+ expect(h.workflow.rejectChangeRequest).toHaveBeenCalled();
116
+ expect(h.workflow.deleteBranch).toHaveBeenCalledWith(expect.anything(), BRANCH, ACTOR);
117
+ });
118
+
119
+ it('asks about the ITEM, not its rules file — a folder request is about the folder', async () => {
120
+ const h = makeHarness(ASKS_WRITE, { canWrite: true });
121
+ await h.svc.list('GTM', folderTarget(FOLDER), [cr()], ACTOR);
122
+ expect(h.asked).toContainEqual({ verb: 'canWrite', path: FOLDER, email: ALI });
123
+ });
124
+
125
+ it('settles through a role or group the person has since joined', async () => {
126
+ // Nothing in the file names Ali, yet the resolver says he writes here:
127
+ // that is a role, a group, or a grant on a parent folder.
128
+ const h = makeHarness(ASKS_WRITE, { canWrite: true });
129
+ await expect(h.svc.reconcile('GTM', folderTarget(FOLDER), cr(), ACTOR)).resolves.toBe(true);
130
+ });
131
+
132
+ it('leaves the request open while the person still holds nothing', async () => {
133
+ const h = makeHarness(ASKS_WRITE);
134
+ const out = await h.svc.list('GTM', folderTarget(FOLDER), [cr()], ACTOR);
135
+ expect(out).toHaveLength(1);
136
+ expect(out[0].proposals.map((p) => p.verb)).toEqual(['write']);
137
+ expect(h.workflow.rejectChangeRequest).not.toHaveBeenCalled();
138
+ });
139
+
140
+ it('keeps today\'s literal rule for a proposal naming a ROLE', async () => {
141
+ // "Does this role hold read here" is not a question the per-person
142
+ // resolver answers, so a role proposal is settled by the live rules
143
+ // carrying it — never by what some person happens to hold.
144
+ const h = makeHarness(ASKS_FOR_A_ROLE, { canRead: true, canWrite: true, canOwner: true });
145
+ const out = await h.svc.list('GTM', folderTarget(FOLDER), [cr()], ACTOR);
146
+ expect(out).toHaveLength(1);
147
+ expect(out[0].proposals.map((p) => p.label)).toEqual(['role/Analysts']);
148
+ expect(h.workflow.rejectChangeRequest).not.toHaveBeenCalled();
149
+ });
150
+
151
+ it('leaves the request open when the resolver cannot answer', async () => {
152
+ const h = makeHarness(ASKS_WRITE);
153
+ const svc = h.svc as unknown as { accessControl: { canWrite: () => Promise<boolean> } };
154
+ svc.accessControl.canWrite = () => Promise.reject(new Error('resolver down'));
155
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
156
+ const out = await h.svc.list('GTM', folderTarget(FOLDER), [cr()], ACTOR);
157
+ expect(out).toHaveLength(1);
158
+ expect(h.workflow.rejectChangeRequest).not.toHaveBeenCalled();
159
+ warn.mockRestore();
160
+ });
161
+ });
162
+
163
+ describe('a branch that cannot be read settles nothing', () => {
164
+ /**
165
+ * The failure this guards against, in full: these reads go through
166
+ * `origin/<branch>` in the DEFAULT branch's clone, and that clone's fetch is
167
+ * throttled. A request opened seconds ago names a ref the clone has not
168
+ * heard of. Read as "the branch proposes nothing" — which is what an empty
169
+ * diff against a missing file looks like — the request is CLOSED, by
170
+ * nobody, before any editor has seen it, and the person is told their
171
+ * request was not accepted.
172
+ *
173
+ * So: a miss is retried behind a forced fetch, and a miss that survives that
174
+ * leaves the request exactly where it was.
175
+ */
176
+
177
+ it('retries behind a FORCED fetch before believing a branch says nothing', async () => {
178
+ // Present on origin, absent from this clone's refs until it fetches.
179
+ const h = makeHarness(undefined, {}, ASKS_WRITE);
180
+ const out = await h.svc.list('GTM', folderTarget(FOLDER), [cr()], ACTOR);
181
+
182
+ expect(h.fetches.some((f) => f.force)).toBe(true);
183
+ expect(out).toHaveLength(1);
184
+ expect(out[0].proposals.map((p) => p.verb)).toEqual(['write']);
185
+ expect(h.workflow.rejectChangeRequest).not.toHaveBeenCalled();
186
+ });
187
+
188
+ it('leaves the request open — and unclosed — when the branch still cannot be read', async () => {
189
+ const h = makeHarness(undefined);
190
+ await expect(h.svc.list('GTM', folderTarget(FOLDER), [cr()], ACTOR)).resolves.toEqual([]);
191
+ expect(h.workflow.rejectChangeRequest).not.toHaveBeenCalled();
192
+ expect(h.workflow.deleteBranch).not.toHaveBeenCalled();
193
+ });
194
+
195
+ it('reconcile refuses to settle one it could not read', async () => {
196
+ const h = makeHarness(undefined);
197
+ await expect(h.svc.reconcile('GTM', folderTarget(FOLDER), cr(), ACTOR)).resolves.toBe(false);
198
+ expect(h.workflow.rejectChangeRequest).not.toHaveBeenCalled();
199
+ });
200
+
201
+ it('still settles a branch it DID read that proposes nothing', async () => {
202
+ // The distinction has to cut both ways, or nothing would ever retire.
203
+ const h = makeHarness(DEFAULT_MD);
204
+ await expect(h.svc.reconcile('GTM', folderTarget(FOLDER), cr(), ACTOR)).resolves.toBe(true);
205
+ expect(h.workflow.rejectChangeRequest).toHaveBeenCalled();
206
+ });
207
+ });
208
+
209
+ describe('a person the rules explicitly deny', () => {
210
+ /**
211
+ * `ARTest5`-shaped rules: read granted, write DENIED, and the branch
212
+ * proposing the write anyway. The resolver says that person cannot write
213
+ * (verified against the real one), so their proposal is unmet and must
214
+ * survive to reach an editor — "Accept … includes a person the rules
215
+ * explicitly deny" is unreachable otherwise, and the requester is told a
216
+ * refusal nobody made.
217
+ */
218
+ const DENIED_MD = base(`read:\n - Ali Baba <${ALI}>\nwrite:\n - deny Ali Baba <${ALI}>\n`);
219
+ const ASKS_DESPITE_DENY = base(
220
+ `read:\n - Ali Baba <${ALI}>\nwrite:\n - deny Ali Baba <${ALI}>\n - Ali Baba <${ALI}>\n`,
221
+ );
222
+
223
+ it('keeps their proposal, so an editor can still accept it', async () => {
224
+ // Live DENIES the write; the branch adds the grant beside the deny, which
225
+ // is exactly the text the dialog's own grant writes for this person.
226
+ const h = makeHarness(ASKS_DESPITE_DENY, { canRead: true, canWrite: false }, undefined, DENIED_MD);
227
+ const out = await h.svc.list('GTM', folderTarget(FOLDER), [cr()], ACTOR);
228
+ expect(out).toHaveLength(1);
229
+ expect(out[0].proposals.map((p) => p.verb)).toEqual(['write']);
230
+ expect(h.workflow.rejectChangeRequest).not.toHaveBeenCalled();
231
+ });
232
+
233
+ it('retires it once the grant has landed and the resolver says they write', async () => {
234
+ // What Accept does: a same-scope grant beats a same-scope deny, so the
235
+ // resolver then reports write and the request has nothing left to ask.
236
+ const h = makeHarness(ASKS_DESPITE_DENY, { canWrite: true }, undefined, DENIED_MD);
237
+ await expect(h.svc.reconcile('GTM', folderTarget(FOLDER), cr(), ACTOR)).resolves.toBe(true);
238
+ });
239
+ });
240
+
241
+ describe('a stale ref must not be mistaken for a finished request', () => {
242
+ /**
243
+ * The nastier half of the same hazard. Here the branch's ref RESOLVES — it
244
+ * is simply a few seconds behind, pointing at the commit before the
245
+ * proposal was pushed. That reads as a file identical to live, which is an
246
+ * empty diff, which looks exactly like a request whose grants have all
247
+ * landed. Nothing about it looks like a failure, and the request is closed.
248
+ */
249
+
250
+ /** Stale: the branch answers with live's own text until a forced fetch. */
251
+ const staleHarness = () => makeHarness(DEFAULT_MD, {}, ASKS_WRITE);
252
+
253
+ it('does not settle a request whose proposal only shows after a fresh fetch', async () => {
254
+ const h = staleHarness();
255
+ const out = await h.svc.list('GTM', folderTarget(FOLDER), [cr()], ACTOR);
256
+
257
+ expect(h.workflow.rejectChangeRequest).not.toHaveBeenCalled();
258
+ expect(h.workflow.deleteBranch).not.toHaveBeenCalled();
259
+ // And having fetched, it reports the proposal rather than nothing.
260
+ expect(out).toHaveLength(1);
261
+ expect(out[0].proposals.map((p) => p.verb)).toEqual(['write']);
262
+ });
263
+
264
+ it('reconcile refuses to settle it either', async () => {
265
+ const h = staleHarness();
266
+ await expect(h.svc.reconcile('GTM', folderTarget(FOLDER), cr(), ACTOR)).resolves.toBe(false);
267
+ expect(h.workflow.rejectChangeRequest).not.toHaveBeenCalled();
268
+ });
269
+
270
+ it('confirms with a FORCED fetch before closing anything', async () => {
271
+ const h = makeHarness(DEFAULT_MD);
272
+ await h.svc.list('GTM', folderTarget(FOLDER), [cr()], ACTOR);
273
+ expect(h.fetches.some((f) => f.force)).toBe(true);
274
+ // Genuinely finished — the confirming read agrees, so it closes.
275
+ expect(h.workflow.rejectChangeRequest).toHaveBeenCalled();
276
+ });
277
+ });
278
+
279
+ describe('a request whose target is a FILE', () => {
280
+ /**
281
+ * The staging failure, at the level it happened. Every file-target request
282
+ * self-closed on the editors' FIRST listing: the listing answered
283
+ * `{"requests":[]}` and closed the change request in the same call, and the
284
+ * requester's status dropped to "none". Nobody had seen the line.
285
+ *
286
+ * A file's rules are its own frontmatter, and `spliceGrant` writes one grant
287
+ * there as a scalar — the same bytes the dialog's ordinary grant writes, and
288
+ * the resolver honours them. Read with the folder grammar it is a parse
289
+ * error, which yielded no grants, which reads as a finished request.
290
+ *
291
+ * So this exercises the real splice output against the real reader, with the
292
+ * target the request was actually made about.
293
+ */
294
+ const FILE = 'Plugins/GTM/page.md';
295
+ const LIVE_FILE = '---\nnodeType: "[Note](../NodeTypes/Note.md)"\n---\n# Page\n\nbody\n';
296
+ const fileTarget = { path: FILE, kind: 'file' as const };
297
+
298
+ /** Exactly what the request opener writes onto the branch for a file. */
299
+ const asksWrite = spliceGrant(
300
+ LIVE_FILE,
301
+ 'write',
302
+ { kind: 'user', email: ALI, displayName: 'Ali Baba' },
303
+ { allowScalar: true, target: 'node' },
304
+ ).text;
305
+
306
+ /** The harness above is keyed on an access.md; this one answers for a file. */
307
+ function fileHarness(branchText: string, holds: Record<string, boolean> = {}) {
308
+ const byRef: Record<string, string> = {
309
+ [`origin/${DEFAULT_BRANCH}`]: LIVE_FILE,
310
+ [`origin/${BRANCH}`]: branchText,
311
+ };
312
+ const workspaceService = {
313
+ ensureRemotesFetched: vi.fn(async () => undefined),
314
+ readFileAtRef: vi.fn(async (_ws: string, ref: string, p: string) => {
315
+ if (p !== FILE) throw new Error(`unexpected path ${p}`);
316
+ const text = byRef[ref];
317
+ if (text === undefined) throw new Error(`unknown revision ${ref}`);
318
+ return text;
319
+ }),
320
+ } as unknown as WorkspaceService;
321
+ const workflow = {
322
+ rejectChangeRequest: vi.fn(async () => ({ number: 7, state: 'closed' })),
323
+ deleteBranch: vi.fn(async () => undefined),
324
+ } as unknown as IWorkflowService;
325
+ const answer = (verb: string) => vi.fn(async () => holds[verb] ?? false);
326
+ const accessControl = {
327
+ canRead: answer('canRead'),
328
+ canWrite: answer('canWrite'),
329
+ canOwner: answer('canOwner'),
330
+ canDownload: answer('canDownload'),
331
+ } as unknown as IAccessControl;
332
+ return {
333
+ svc: new JoinRequestsService(workspaceService, workflow, testKbContext(), accessControl),
334
+ workflow,
335
+ workspaceService,
336
+ };
337
+ }
338
+
339
+ it('reads the file\'s OWN frontmatter, so the request survives the first listing', async () => {
340
+ const h = fileHarness(asksWrite);
341
+ const out = await h.svc.list('GTM', fileTarget, [cr()], ACTOR);
342
+
343
+ expect(out).toHaveLength(1);
344
+ expect(out[0].proposals.map((p) => p.verb)).toEqual(['write']);
345
+ expect(h.workflow.rejectChangeRequest).not.toHaveBeenCalled();
346
+ expect(h.workflow.deleteBranch).not.toHaveBeenCalled();
347
+ });
348
+
349
+ it('reads the FILE itself, never an access.md beside it', async () => {
350
+ // A request on a file changes only that file's rules, so the only path it
351
+ // may read is the file. The harness throws on anything else.
352
+ const h = fileHarness(asksWrite);
353
+ await h.svc.list('GTM', fileTarget, [cr()], ACTOR);
354
+ const paths = (h.workspaceService.readFileAtRef as ReturnType<typeof vi.fn>).mock.calls.map(
355
+ (c) => c[2],
356
+ );
357
+ expect(new Set(paths)).toEqual(new Set([FILE]));
358
+ });
359
+
360
+ it('retires it once the person holds the level on the file', async () => {
361
+ const h = fileHarness(asksWrite, { canWrite: true });
362
+ await expect(h.svc.reconcile('GTM', fileTarget, cr(), ACTOR)).resolves.toBe(true);
363
+ });
364
+
365
+ it('retires it once the grant has landed on live', async () => {
366
+ const h = fileHarness(asksWrite);
367
+ const byRef = h.workspaceService.readFileAtRef as ReturnType<typeof vi.fn>;
368
+ byRef.mockImplementation(async () => asksWrite); // both refs carry it now
369
+ await expect(h.svc.reconcile('GTM', fileTarget, cr(), ACTOR)).resolves.toBe(true);
370
+ });
371
+ });