@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
@@ -1,7 +1,7 @@
1
1
  import type { Server } from 'node:http';
2
2
  import { closeDb, type Database } from '../modules/database/connection.js';
3
3
  import type { AdvisoryLease } from '../modules/database/advisory-lock.js';
4
- import { KbRemoteUnreachableError, type KbStartupRunner } from '../modules/workspace/startup/kb-startup-runner.js';
4
+ import { bootMaySurvive, type KbStartupRunner } from '../modules/workspace/startup/kb-startup-runner.js';
5
5
  import { noteBesideCheckout } from '../modules/workspace/startup/beside-checkout.js';
6
6
  import { unregisterBevelSecretsVariableLoader } from '../modules/secrets-vault/secrets-variable-loader.js';
7
7
  import type { WorkflowService } from '../modules/workflow/workflow.service.js';
@@ -164,7 +164,13 @@ export function holdCommitWorkerLease(
164
164
  running = false;
165
165
  wake?.();
166
166
  await loop;
167
+ // The worker is asked to stop whether or not this process is running
168
+ // it: a worker that can be held still (`holdable`) may be in the
169
+ // middle of a hold that uses the database, and its stop is what
170
+ // waits for that. Stopping a worker that is not running is nothing.
171
+ const idle = !workerRunning && !stopping;
167
172
  await stopWorker();
173
+ if (idle) await worker.stop().catch((err: unknown) => log(`commit worker stop failed: ${String(err)}`));
168
174
  await lease.release();
169
175
  })();
170
176
  return stopped;
@@ -202,6 +208,80 @@ export function withStartupTask(
202
208
  };
203
209
  }
204
210
 
211
+ /** A worker the lease loop drives, which something else may hold still for a while. */
212
+ export interface HoldableWorker extends LeasedWorker {
213
+ /**
214
+ * Run `work` with the worker stopped, and start it again afterwards if the
215
+ * lease loop still wants it running. A hold waits for the commit in flight
216
+ * to finish, as a stop does. Holds may overlap; the worker starts again
217
+ * when the last one ends, whether `work` resolved or threw.
218
+ */
219
+ whileHeld<T>(work: () => Promise<T>): Promise<T>;
220
+ }
221
+
222
+ /**
223
+ * A worker that can be HELD STILL without the lease loop knowing.
224
+ *
225
+ * The lease loop decides whether this process is the one draining the commit
226
+ * queue; it starts the worker when the lease is taken and stops it when the
227
+ * lease is lost. Moving the deployment to another repository needs a second,
228
+ * independent reason for the worker not to run: while working copies are
229
+ * being set aside and cloned again, a queued commit must not be written into
230
+ * a directory that is being renamed away, nor into the fresh clone of a
231
+ * repository it was never meant for. Stopping the loop itself would release
232
+ * the lease, and another process would take the queue over mid-move.
233
+ *
234
+ * So the two reasons are kept apart. `wanted` is the lease loop's word,
235
+ * `holds` counts the moves in flight, and the worker runs exactly when it is
236
+ * wanted and nothing holds it.
237
+ *
238
+ * A stop waits for the holds in flight as well as for the worker. Shutdown
239
+ * awaits this stop (bounded by its own budget) before it ends the database
240
+ * pool, and a move still setting copies aside and cloning needs that pool:
241
+ * it is either finished, or known to have been cut.
242
+ */
243
+ export function holdable(worker: LeasedWorker): HoldableWorker {
244
+ let wanted = false;
245
+ const inFlight = new Set<Promise<unknown>>();
246
+ /**
247
+ * ONE stop at a time, shared by everyone who asks. A worker's own stop is
248
+ * idempotent by answering a second caller at once, while the first is
249
+ * still waiting for the commit in flight; a second hold given that answer
250
+ * would set a working copy aside under that commit.
251
+ */
252
+ let stopping: Promise<void> | null = null;
253
+ const stopWorker = (): Promise<void> => {
254
+ stopping ??= worker.stop().finally(() => {
255
+ stopping = null;
256
+ });
257
+ return stopping;
258
+ };
259
+ return {
260
+ start() {
261
+ wanted = true;
262
+ if (inFlight.size === 0) worker.start();
263
+ },
264
+ async stop() {
265
+ wanted = false;
266
+ await stopWorker();
267
+ await Promise.allSettled([...inFlight]);
268
+ },
269
+ whileHeld<T>(work: () => Promise<T>): Promise<T> {
270
+ const held = (async () => {
271
+ await stopWorker();
272
+ return work();
273
+ })();
274
+ inFlight.add(held);
275
+ const ended = (): void => {
276
+ inFlight.delete(held);
277
+ if (inFlight.size === 0 && wanted) worker.start();
278
+ };
279
+ held.then(ended, ended);
280
+ return held;
281
+ },
282
+ };
283
+ }
284
+
205
285
  /**
206
286
  * What one knowledge base's graph needs of itself to be STARTED: the boot
207
287
  * side effects that used to run inside `createCoreServer`, named so a host
@@ -228,7 +308,8 @@ export interface BootableCore {
228
308
  * jobs. `createCoreServer` runs this for a single-tenant deployment; a host
229
309
  * serving several knowledge bases runs it when a tenant is activated.
230
310
  *
231
- * Throws to stop the boot on every failure but an unreachable remote — the
311
+ * Throws to stop the boot on every failure but the ones the host answered
312
+ * about the repository or the credentials (see `bootMaySurvive`) — the
232
313
  * container's restart policy is the retry — see `kb-startup-runner.ts`.
233
314
  */
234
315
  export async function startCore<C extends BootableCore>(
@@ -267,18 +348,22 @@ export async function startCore<C extends BootableCore>(
267
348
  try {
268
349
  await core.kbStartupRunner.runAll();
269
350
  } catch (err) {
270
- // The one failure a boot survives: the remote cannot be reached. That
271
- // says nothing about the knowledge base — what we would write is not
272
- // known to be wrong, we cannot get there — so refusing to boot only took
273
- // away the login and setup screens an operator needs to fix it (a
274
- // rotated token, say). The deployment comes up GATED: the setup routes
351
+ // The failures a boot survives: the host ANSWERED about the repository or
352
+ // the credentials, or could not be reached at all. None of them says
353
+ // anything about the knowledge base — what we would write is not known to
354
+ // be wrong — and every one of them is fixed somewhere else: the host comes
355
+ // back, the repository is created, the token is granted access, a rotated
356
+ // token is typed into the setup screen. Refusing to boot took away the
357
+ // login and setup screens the fix is entered on, and on 2026-09-28 a
358
+ // repository that had been replaced crash-looped a deployment for exactly
359
+ // that reason. The deployment comes up GATED instead: the setup routes
275
360
  // read the runner's standing failure and keep the app shut, the setup
276
- // screen shows why, saving it retries, and the runner keeps trying on
277
- // its own. Every other failure still stops the boot, because it means
278
- // the template or a step would write something wrong.
279
- if (!(err instanceof KbRemoteUnreachableError)) throw err;
280
- startupLog.error('booting UNMAINTAINED and gated — the knowledge-base remote could not be reached:', {
281
- detail: err.message,
361
+ // screen shows why, saving it retries, and the runner keeps trying on its
362
+ // own. Every other failure still stops the boot, because it means the
363
+ // template or a step would write something wrong. See `bootMaySurvive`.
364
+ if (!bootMaySurvive(err)) throw err;
365
+ startupLog.error('booting UNMAINTAINED and gated — the knowledge base could not be initialized:', {
366
+ detail: err instanceof Error ? err.message : String(err),
282
367
  });
283
368
  // Kept on the graph so that stopping the graph stops the asking: a
284
369
  // retry that outlived its graph would clone into a workspaces folder
@@ -6,6 +6,8 @@ import { assertKeyDecodesTo32Bytes } from './shared/token-crypto.js';
6
6
  import { DEFAULT_GIT_TIMEOUT_MS } from './modules/workflow/git/node-git-runner.js';
7
7
  import { DEFAULT_DB_SCHEMA, assertSchemaName } from './modules/database/connection.js';
8
8
  import { logger } from './shared/logging.js';
9
+ import { hasControlCharacter } from './modules/kb-fs/repo-path.js';
10
+ import { sanitizedPath } from './shared/printable.js';
9
11
 
10
12
  const log = logger('config');
11
13
 
@@ -100,7 +102,6 @@ export interface TenantConfig {
100
102
  readonly gitUsername: string;
101
103
  readonly gitToken: string;
102
104
  readonly kbTemplateDir: string;
103
- readonly ontologySessionBlock: boolean;
104
105
  readonly updateCheckEnabled: boolean;
105
106
  readonly loginPasswordEnabled: boolean;
106
107
  readonly allowedEmailDomains: string[];
@@ -259,17 +260,6 @@ export class CoreConfig implements TenantConfig, ProcessConfig {
259
260
  * existing one. Override with `KB_TEMPLATE_DIR`.
260
261
  */
261
262
  readonly kbTemplateDir: string;
262
- /**
263
- * Ontology-session boundary kill-switch. When true (default), an agent run
264
- * that has read across more than one ontology can no longer write. A no-op on
265
- * a single-ontology KB. Set `ONTOLOGY_SESSION_BLOCK=false` to disable.
266
- *
267
- * Stays on CORE config (not `AppConfig`) even though the write BLOCK itself
268
- * is an enterprise-registered hook: the flag also switches off the core-owned
269
- * touch TRACKING (the gate skips entirely when false — see
270
- * `session-ontology.gate.ts`), so it must exist wherever the tracking runs.
271
- */
272
- readonly ontologySessionBlock: boolean;
273
263
  /**
274
264
  * In-app update check. When true (default), `GET /api/update-check` lazily
275
265
  * asks api.github.com for the newest Hexis release — only when an admin's
@@ -469,8 +459,6 @@ export class CoreConfig implements TenantConfig, ProcessConfig {
469
459
  // Default: the `kb-template/` folder shipped inside this package (works
470
460
  // both from src/ and compiled dist/ — see assets.ts).
471
461
  this.kbTemplateDir = process.env.KB_TEMPLATE_DIR || defaultKbTemplateDir();
472
- this.ontologySessionBlock =
473
- (process.env.ONTOLOGY_SESSION_BLOCK ?? 'true').trim().toLowerCase() !== 'false';
474
462
  this.updateCheckEnabled =
475
463
  (process.env.UPDATE_CHECK ?? 'true').trim().toLowerCase() !== 'false';
476
464
  this.allowedEmailDomains = (process.env.ALLOWED_EMAIL_DOMAINS || '')
@@ -621,5 +609,16 @@ export class CoreConfig implements TenantConfig, ProcessConfig {
621
609
  ) {
622
610
  throw new Error(`KB_DIR_NAME must be a single path segment: ${this.kbDirName}`);
623
611
  }
612
+ // A control character, a line break above all: this name is PREFIXED onto
613
+ // every workspace-relative path, and a line break in a path is a separator
614
+ // in git's line-oriented stdin protocols (`cat-file --batch` reads one
615
+ // `<ref>:<path>` per line). A name carrying one would make the normaliser
616
+ // emit the very path it refuses from a caller — so the deployment fails to
617
+ // boot rather than resolving paths nothing downstream can safely read.
618
+ if (hasControlCharacter(this.kbDirName)) {
619
+ throw new Error(
620
+ `KB_DIR_NAME must not contain a control character: "${sanitizedPath(this.kbDirName)}"`,
621
+ );
622
+ }
624
623
  }
625
624
  }
package/src/index.ts CHANGED
@@ -101,8 +101,18 @@ export type {
101
101
  export type { ILlmUsageMeter } from './modules/tool-auth/llm-usage-meter.js';
102
102
  export type { AuthProviderPlugin } from './modules/auth/auth.routes.js';
103
103
  export type { IErasureParticipant } from './modules/auth/account-erasure.service.js';
104
+ export {
105
+ alwaysWritable,
106
+ READ_ONLY_CODE,
107
+ type IWriteAccess,
108
+ type WriteAccessVerdict,
109
+ } from './modules/write-access/write-access.js';
104
110
  export {
105
111
  AccountAdmissionRefusedError,
112
+ AccountDeactivatedError,
113
+ AccountChangeRefusedError,
114
+ AuthBackendError,
115
+ ACCOUNT_DEACTIVATED_MESSAGE,
106
116
  admitEveryone,
107
117
  type AccountAdmissionVerdict,
108
118
  type AccountProvisionReason,
@@ -112,6 +122,7 @@ export {
112
122
  WorkflowHooks,
113
123
  type CommitValidationHook,
114
124
  type CommitValidationContext,
125
+ type AgentOperationContext,
126
+ type AgentReadHook,
115
127
  type PreWriteHook,
116
- type PreWriteContext,
117
128
  } from './modules/workflow/workflow-hooks.js';
@@ -0,0 +1,385 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+
6
+ import { NodeFs } from '../../kb-fs/node-fs.js';
7
+ import type { WorkspaceService } from '../../workspace/workspace.service.js';
8
+ import { AccessControlService } from '../access-control.service.js';
9
+
10
+ /**
11
+ * `previewAccessAfterRelocation`: the caller's OWN verbs at a destination as
12
+ * they will be once a move or a copy has landed there.
13
+ *
14
+ * The question the four gates cannot answer, because the `access.md` files a
15
+ * folder carries are not at the destination yet — which is how a rename came
16
+ * to warn about losing owner access the folder's own rules were about to hand
17
+ * straight back. Driven over a real on-disk tree, and every answer checked
18
+ * against the ordinary gates once the tree really says what the preview said
19
+ * it would.
20
+ */
21
+
22
+ const KB_DIR = 'knowledge-base';
23
+ const MOVER = 'mover@x.io';
24
+
25
+ function stubWorkspaceService(workspaceId: string, workspaceDir: string): WorkspaceService {
26
+ return {
27
+ getWorkspacePath: async (id: string) => {
28
+ if (id !== workspaceId) throw new Error(`unexpected workspace ${id}`);
29
+ return workspaceDir;
30
+ },
31
+ ensureRemotesFetched: async () => undefined,
32
+ } as unknown as WorkspaceService;
33
+ }
34
+
35
+ const rules = (body: string) => `---\n${body}---\n`;
36
+
37
+ describe('AccessControlService.previewAccessAfterRelocation', () => {
38
+ let root: string;
39
+ let repo: string;
40
+ const workspaceId = 'ws-preview-relocation';
41
+
42
+ beforeEach(async () => {
43
+ root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-preview-reloc-'));
44
+ repo = path.join(root, workspaceId, KB_DIR);
45
+ });
46
+ afterEach(async () => {
47
+ if (root) await fs.rm(root, { recursive: true, force: true });
48
+ });
49
+
50
+ async function write(files: Record<string, string>): Promise<void> {
51
+ for (const [rel, contents] of Object.entries(files)) {
52
+ const abs = path.join(repo, rel);
53
+ await fs.mkdir(path.dirname(abs), { recursive: true });
54
+ await fs.writeFile(abs, contents);
55
+ }
56
+ }
57
+
58
+ /**
59
+ * A service reading the tree as it is NOW. Built fresh after every change
60
+ * on purpose: in production the file-change notifier invalidates the model,
61
+ * and a test that leaned on the five-second cache would be asserting the
62
+ * cache rather than the resolver.
63
+ */
64
+ const service = () =>
65
+ new AccessControlService(
66
+ stubWorkspaceService(workspaceId, path.join(root, workspaceId)),
67
+ KB_DIR,
68
+ new NodeFs(),
69
+ );
70
+
71
+ /** The caller's four verbs at a path, through the ordinary gates. */
72
+ async function verbsAt(svc: AccessControlService, rel: string) {
73
+ const [read, write, download, owner] = await Promise.all([
74
+ svc.canRead(workspaceId, MOVER, rel),
75
+ svc.canWrite(workspaceId, MOVER, rel),
76
+ svc.canDownload(workspaceId, MOVER, rel),
77
+ svc.canOwner(workspaceId, MOVER, rel),
78
+ ]);
79
+ return { read, write, download, owner };
80
+ }
81
+
82
+ /** Move `from` to `to` on disk, as the move itself does. */
83
+ async function reallyMove(from: string, to: string): Promise<void> {
84
+ const dest = path.join(repo, to);
85
+ await fs.mkdir(path.dirname(dest), { recursive: true });
86
+ await fs.rename(path.join(repo, from), dest);
87
+ }
88
+
89
+ /** Copy `from` to `to` on disk, leaving the source where it is. */
90
+ async function reallyCopy(from: string, to: string): Promise<void> {
91
+ const dest = path.join(repo, to);
92
+ await fs.mkdir(path.dirname(dest), { recursive: true });
93
+ await fs.cp(path.join(repo, from), dest, { recursive: true });
94
+ }
95
+
96
+ const BASE = {
97
+ 'roles.yaml': 'roles:\n Admin:\n - admin@x.io\n',
98
+ 'access.md': rules('read:\n - everyone\n'),
99
+ };
100
+
101
+ it('a folder keeps the access its own access.md gives it when it is renamed', async () => {
102
+ await write({
103
+ ...BASE,
104
+ 'Sales/access.md': rules(`write:\n - Mover <${MOVER}>\nowner:\n - Mover <${MOVER}>\n`),
105
+ 'Sales/deal.md': '# Deal\n',
106
+ });
107
+
108
+ const svc = service();
109
+ const before = await verbsAt(svc, 'Sales');
110
+ const after = await svc.previewAccessAfterRelocation(workspaceId, MOVER, 'Sales', 'Revenue');
111
+
112
+ expect(before.owner).toBe(true);
113
+ expect(after).toEqual(before);
114
+
115
+ // The root grants read and nothing else, so the destination AS IT STANDS
116
+ // says the opposite — which is the warning people were shown.
117
+ expect(await verbsAt(svc, 'Revenue')).toMatchObject({ write: false, owner: false });
118
+
119
+ await reallyMove('Sales', 'Revenue');
120
+ expect(await verbsAt(service(), 'Revenue')).toEqual(after);
121
+ });
122
+
123
+ it('a folder with no access.md of its own takes the new parent\'s rules, as it always did', async () => {
124
+ await write({
125
+ ...BASE,
126
+ 'Work/access.md': rules(`write:\n - Mover <${MOVER}>\n`),
127
+ 'Work/Notes/note.md': '# Note\n',
128
+ 'Legal/access.md': rules('read:\n - everyone\n'),
129
+ });
130
+
131
+ const svc = service();
132
+ const before = await verbsAt(svc, 'Work/Notes');
133
+ const after = await svc.previewAccessAfterRelocation(workspaceId, MOVER, 'Work/Notes', 'Legal/Notes');
134
+
135
+ expect(before.write).toBe(true);
136
+ expect(after.write).toBe(false);
137
+ // Nothing travels, so the preview is exactly what the gates say today.
138
+ expect(after).toEqual(await verbsAt(svc, 'Legal/Notes'));
139
+
140
+ await reallyMove('Work/Notes', 'Legal/Notes');
141
+ expect(await verbsAt(service(), 'Legal/Notes')).toEqual(after);
142
+ });
143
+
144
+ it('what the folder inherited is left behind; what it carries comes along', async () => {
145
+ await write({
146
+ ...BASE,
147
+ 'Work/access.md': rules(`write:\n - Mover <${MOVER}>\nowner:\n - Mover <${MOVER}>\n`),
148
+ 'Work/Team/access.md': rules(`write:\n - Mover <${MOVER}>\n`),
149
+ 'Work/Team/plan.md': '# Plan\n',
150
+ 'Legal/access.md': rules('read:\n - everyone\n'),
151
+ });
152
+
153
+ const svc = service();
154
+ expect(await verbsAt(svc, 'Work/Team')).toMatchObject({ write: true, owner: true });
155
+
156
+ const after = await svc.previewAccessAfterRelocation(workspaceId, MOVER, 'Work/Team', 'Legal/Team');
157
+
158
+ // Write is the folder's own and travels; owner came from `Work/` and
159
+ // does not.
160
+ expect(after).toMatchObject({ write: true, owner: false });
161
+
162
+ await reallyMove('Work/Team', 'Legal/Team');
163
+ expect(await verbsAt(service(), 'Legal/Team')).toEqual(after);
164
+ });
165
+
166
+ it('a nested access.md does not change the answer at the folder itself', async () => {
167
+ const tree = {
168
+ ...BASE,
169
+ 'Work/access.md': rules(`write:\n - Mover <${MOVER}>\nowner:\n - Mover <${MOVER}>\n`),
170
+ 'Work/Team/access.md': rules(`write:\n - Mover <${MOVER}>\n`),
171
+ 'Work/Team/plan.md': '# Plan\n',
172
+ 'Legal/access.md': rules('read:\n - everyone\n'),
173
+ };
174
+ await write(tree);
175
+ const withoutNested = await service().previewAccessAfterRelocation(
176
+ workspaceId, MOVER, 'Work/Team', 'Legal/Team',
177
+ );
178
+
179
+ // `Sub/` shuts the caller out entirely, and it moves too — but it governs
180
+ // `Legal/Team/Sub`, not `Legal/Team`.
181
+ await write({ 'Work/Team/Sub/access.md': rules(`read:\n - deny Mover <${MOVER}>\n`) });
182
+ const svc = service();
183
+ expect(await svc.previewAccessAfterRelocation(workspaceId, MOVER, 'Work/Team', 'Legal/Team'))
184
+ .toEqual(withoutNested);
185
+ // It is counted, though — at the path it lands on.
186
+ expect(
187
+ await svc.previewAccessAfterRelocation(workspaceId, MOVER, 'Work/Team', 'Legal/Team'),
188
+ ).not.toEqual(
189
+ await svc.previewAccessAfterRelocation(workspaceId, MOVER, 'Work/Team/Sub', 'Legal/Team/Sub'),
190
+ );
191
+
192
+ await reallyMove('Work/Team', 'Legal/Team');
193
+ const after = service();
194
+ expect(await verbsAt(after, 'Legal/Team')).toEqual(withoutNested);
195
+ expect(await verbsAt(after, 'Legal/Team/Sub')).toMatchObject({ read: false });
196
+ });
197
+
198
+ it('a copy leaves the source rules where they are and lands a second set', async () => {
199
+ await write({
200
+ ...BASE,
201
+ 'Sales/access.md': rules(`write:\n - Mover <${MOVER}>\nowner:\n - Mover <${MOVER}>\n`),
202
+ 'Sales/deal.md': '# Deal\n',
203
+ // A destination governed DIFFERENTLY from the source, so the answer can
204
+ // only come from the rules the copy carries: Legal grants read and
205
+ // nothing else, as the root does.
206
+ 'Legal/access.md': rules('read:\n - everyone\n'),
207
+ });
208
+
209
+ const svc = service();
210
+ const after = await svc.previewAccessAfterRelocation(
211
+ workspaceId, MOVER, 'Sales', 'Legal/Sales-Copy', { sourceRemains: true },
212
+ );
213
+
214
+ // The copied `access.md` governs the copy, which is the opposite of what
215
+ // the destination says today.
216
+ expect(after).toMatchObject({ write: true, owner: true });
217
+ expect(await verbsAt(svc, 'Legal')).toMatchObject({ write: false, owner: false });
218
+
219
+ await reallyCopy('Sales', 'Legal/Sales-Copy');
220
+ const done = service();
221
+ expect(await verbsAt(done, 'Legal/Sales-Copy')).toEqual(after);
222
+ // `sourceRemains`: both sets of rules are real afterwards, and the source
223
+ // keeps the access it always had.
224
+ expect(await verbsAt(done, 'Sales')).toMatchObject({ owner: true });
225
+ });
226
+
227
+ /**
228
+ * A lone `access.md` is a single FILE whose rules are a whole folder's.
229
+ * Keyed by the directory it sits in, it is not a path "under" the source,
230
+ * so the folder walk cannot see it — and `copy_file` will copy one, which
231
+ * is how a preview came to answer the destination's old rules about a
232
+ * directory that was about to be governed by the file landing in it.
233
+ */
234
+ it('a lone access.md governs the folder it is copied into', async () => {
235
+ await write({
236
+ ...BASE,
237
+ 'Sales/access.md': rules(`write:\n - Mover <${MOVER}>\nowner:\n - Mover <${MOVER}>\n`),
238
+ 'Legal/note.md': '# Note\n',
239
+ });
240
+
241
+ const svc = service();
242
+ // `Legal/` has no rules of its own; the root grants read and nothing else.
243
+ expect(await verbsAt(svc, 'Legal')).toMatchObject({ write: false, owner: false });
244
+
245
+ const after = await svc.previewAccessAfterRelocation(
246
+ workspaceId, MOVER, 'Sales/access.md', 'Legal/access.md', { sourceRemains: true },
247
+ );
248
+ expect(after).toMatchObject({ write: true, owner: true });
249
+
250
+ await fs.copyFile(path.join(repo, 'Sales/access.md'), path.join(repo, 'Legal/access.md'));
251
+ const done = service();
252
+ expect(await verbsAt(done, 'Legal/access.md')).toEqual(after);
253
+ // What landed governs the folder, not only itself.
254
+ expect(await verbsAt(done, 'Legal')).toMatchObject({ write: true, owner: true });
255
+ });
256
+
257
+ /**
258
+ * The name the bytes land under decides what they GOVERN, and it decides
259
+ * how they are PARSED: the same frontmatter is a folder's rules in an
260
+ * `access.md` and the file's own rules anywhere else. So these bytes
261
+ * govern no folder at `Legal/rules-copy.md` — and still grant the caller
262
+ * write and owner, on that one file. Reading them as the source's name said
263
+ * `write: false, owner: false`, and the copy then handed over both: a
264
+ * preview contradicting the operation it was previewing, which is the
265
+ * entire fault this ticket exists to kill.
266
+ */
267
+ it('a file copied under any other name governs no folder, but keeps its own grants', async () => {
268
+ await write({
269
+ ...BASE,
270
+ 'Sales/access.md': rules(`write:\n - Mover <${MOVER}>\nowner:\n - Mover <${MOVER}>\n`),
271
+ 'Legal/note.md': '# Note\n',
272
+ });
273
+
274
+ const svc = service();
275
+ const after = await svc.previewAccessAfterRelocation(
276
+ workspaceId, MOVER, 'Sales/access.md', 'Legal/rules-copy.md', { sourceRemains: true },
277
+ );
278
+ // Pinned, not merely equal to the outcome: both sides reading `false`
279
+ // would satisfy the comparison below and be wrong together.
280
+ expect(after).toMatchObject({ write: true, owner: true });
281
+
282
+ await reallyCopy('Sales/access.md', 'Legal/rules-copy.md');
283
+ const done = service();
284
+ expect(await verbsAt(done, 'Legal/rules-copy.md')).toEqual(after);
285
+ // The folder it landed in is untouched: an ordinary note governs nothing
286
+ // but itself, whatever its frontmatter says.
287
+ expect(await verbsAt(done, 'Legal')).toMatchObject({ write: false, owner: false });
288
+ expect(await verbsAt(done, 'Legal/note.md')).toMatchObject({ write: false, owner: false });
289
+ });
290
+
291
+ /**
292
+ * And the mirror, which `copy_file` also allows: ordinary bytes landing as
293
+ * a directory's `access.md` become that directory's rules the moment they
294
+ * land. The preview reads them as the destination will.
295
+ */
296
+ it('a file copied TO an access.md gives the destination folder its rules', async () => {
297
+ await write({
298
+ ...BASE,
299
+ 'Draft/proposed-rules.md': rules(`write:\n - Mover <${MOVER}>\nowner:\n - Mover <${MOVER}>\n`),
300
+ 'Legal/note.md': '# Note\n',
301
+ });
302
+
303
+ const svc = service();
304
+ expect(await verbsAt(svc, 'Legal')).toMatchObject({ write: false, owner: false });
305
+
306
+ const after = await svc.previewAccessAfterRelocation(
307
+ workspaceId, MOVER, 'Draft/proposed-rules.md', 'Legal/access.md', { sourceRemains: true },
308
+ );
309
+ expect(after).toMatchObject({ write: true, owner: true });
310
+
311
+ await reallyCopy('Draft/proposed-rules.md', 'Legal/access.md');
312
+ const done = service();
313
+ expect(await verbsAt(done, 'Legal/access.md')).toEqual(after);
314
+ // It governs the folder now, which is the whole difference the name makes.
315
+ expect(await verbsAt(done, 'Legal')).toMatchObject({ write: true, owner: true });
316
+ expect(await verbsAt(done, 'Legal/note.md')).toMatchObject({ write: true, owner: true });
317
+ });
318
+
319
+ /**
320
+ * The same rule for the other thing that travels with a file: its own
321
+ * frontmatter. Asking the DESTINATION for it always answered null — nothing
322
+ * is there yet — so a rename of a self-governing file previewed a loss the
323
+ * move hands straight back, and the preview contradicted the access the
324
+ * caller really has afterwards (Specification requirement 6).
325
+ */
326
+ it("a file's own frontmatter travels with its bytes, so a rename costs nothing", async () => {
327
+ await write({
328
+ ...BASE,
329
+ 'Work/access.md': rules('read:\n - everyone\n'),
330
+ 'Work/plan.md': rules(`owner:\n - Mover <${MOVER}>\n`) + '# Plan\n',
331
+ });
332
+
333
+ const svc = service();
334
+ const before = await verbsAt(svc, 'Work/plan.md');
335
+ expect(before.owner).toBe(true);
336
+
337
+ const after = await svc.previewAccessAfterRelocation(
338
+ workspaceId, MOVER, 'Work/plan.md', 'Work/roadmap.md',
339
+ );
340
+ expect(after).toEqual(before);
341
+
342
+ await reallyMove('Work/plan.md', 'Work/roadmap.md');
343
+ expect(await verbsAt(service(), 'Work/roadmap.md')).toEqual(after);
344
+ });
345
+
346
+ it('a single file answers exactly as the gates at the destination do', async () => {
347
+ await write({
348
+ ...BASE,
349
+ 'Work/access.md': rules(`write:\n - Mover <${MOVER}>\n`),
350
+ 'Work/note.md': '# Note\n',
351
+ 'Legal/access.md': rules('read:\n - everyone\n'),
352
+ });
353
+
354
+ const svc = service();
355
+ const after = await svc.previewAccessAfterRelocation(workspaceId, MOVER, 'Work/note.md', 'Legal/note.md');
356
+
357
+ expect(after).toEqual(await verbsAt(svc, 'Legal/note.md'));
358
+
359
+ await reallyMove('Work/note.md', 'Legal/note.md');
360
+ expect(await verbsAt(service(), 'Legal/note.md')).toEqual(after);
361
+ });
362
+
363
+ it('answers the caller\'s own verbs and nothing else, and writes nothing', async () => {
364
+ await write({
365
+ ...BASE,
366
+ 'Sales/access.md': rules(`read:\n - deny everyone\n - Mover <${MOVER}>\nowner:\n - Mover <${MOVER}>\n`),
367
+ 'Sales/deal.md': '# Deal\n',
368
+ });
369
+
370
+ const svc = service();
371
+ const after = await svc.previewAccessAfterRelocation(workspaceId, MOVER, 'Sales', 'Revenue');
372
+
373
+ // Four booleans. Nothing about who else holds what, and nothing out of
374
+ // an `access.md` the caller may not read.
375
+ expect(Object.keys(after).sort()).toEqual(['download', 'owner', 'read', 'write']);
376
+ for (const v of Object.values(after)) expect(typeof v).toBe('boolean');
377
+
378
+ // A stranger gets their own answer, not the caller's.
379
+ expect(await svc.previewAccessAfterRelocation(workspaceId, 'stranger@x.io', 'Sales', 'Revenue'))
380
+ .toMatchObject({ read: false, owner: false });
381
+
382
+ expect(await fs.readFile(path.join(repo, 'Sales/access.md'), 'utf-8')).toContain('owner:');
383
+ await expect(fs.access(path.join(repo, 'Revenue'))).rejects.toThrow();
384
+ });
385
+ });